Index | Thread | Search

From:
Job Snijders <job@bsd.nl>
Subject:
Re: rpki-client: backoff retry for persistently non-functional CAs
To:
tech@openbsd.org
Date:
Mon, 1 Jun 2026 21:39:10 +0000

Download raw body.

Thread
Dear all,

Here is an updated version of the diff I'd like to put forward for review.

The idea is to persist state about non-functional RPKI CAs to disk, and
then use that state in the next invocation of the utility to decide
whether to sync or defer syncing (to a future invocation) for a given
malfunctioning CA. The program's decisions are exposed through json &
metrics output and in verbose / ultra verbose mode some or more log
lines are emitted. Of course the /var/cache/rpki-client/.nca_history
state file itself is interesting to inspect.

What this backoff retry mechanism gives us is a differentiated treatment
for persistently non-functional CAs (the utility will spend less
resources on such CAs), and better (more detailed) reporting on the
start and duration of the non-functioning of these CAs. The latter
helps the operator community (me, but also RIRs/NIRs) quite a bit in
developing & executing revocation procedures for non-functional CAs. The
mechanism also batches CAs together by RRDP URL (if there is one!), in
order to further conserve syncing effort.

The guiding design principle here is that non-functional CAs should
not impact the utility's execution flow _every time_ the utility is
run. This results in second-order benefits like not bumping into
synchronisation timeouts or wasting bandwidth & inodes, _every time_.

I think this is the diff that would've helped reduce RPKI data
propagation delays when that quirky (now revoked) non-functional
rsync-only CA existed under NICBR [1]. Of course, chances are weird
CAs will appear again in the future, I think its good to have some
automation in place to mitigate the worst of it in such situations.

Kind regards,

Job

[1]: https://mailarchive.ietf.org/arch/msg/sidrops/TxyuAvPqX8OOpnWMvVAgRlbIY6Y/

Index: Makefile
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/Makefile,v
diff -u -p -r1.40 Makefile
--- Makefile	1 May 2026 11:22:24 -0000	1.40
+++ Makefile	1 Jun 2026 21:02:41 -0000
@@ -19,6 +19,7 @@ SRCS+=	json.c
 SRCS+=	main.c
 SRCS+=	mft.c
 SRCS+=	mkdir.c
+SRCS+=	nca.c
 SRCS+=	ometric.c
 SRCS+=	output-bgpd.c
 SRCS+=	output-bird.c
Index: cert.c
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/cert.c,v
diff -u -p -r1.237 cert.c
--- cert.c	16 May 2026 07:27:03 -0000	1.237
+++ cert.c	1 Jun 2026 21:02:42 -0000
@@ -2048,7 +2048,8 @@ RB_GENERATE(brk_tree, brk, entry, brkcmp
  * Add each CA cert into the non-functional CA tree.
  */
 void
-cert_insert_nca(struct nca_tree *tree, const struct cert *cert, struct repo *rp)
+cert_insert_nca(struct nca_tree *tree, const struct cert *cert, time_t since,
+    time_t last_attempt, int attempts, int do_sync)
 {
 	struct nonfunc_ca *nca;
 
@@ -2062,12 +2063,22 @@ cert_insert_nca(struct nca_tree *tree, c
 		err(1, NULL);
 	if ((nca->ski = strdup(cert->ski)) == NULL)
 		err(1, NULL);
+
+	if (cert->notify == NULL)
+		nca->notify = NULL;
+	else if ((nca->notify = strdup(cert->notify)) == NULL)
+		err(1, NULL);
+
 	nca->certid = cert->certid;
+	nca->repoid = cert->repoid;
 	nca->talid = cert->talid;
+	nca->since = since;
+	nca->last_attempt = last_attempt;
+	nca->attempts = attempts;
+	nca->deferred = do_sync ? 0 : 1;
 
 	if (RB_INSERT(nca_tree, tree, nca) != NULL)
 		errx(1, "non-functional CA tree corrupted");
-	repo_stat_inc(rp, nca->talid, RTYPE_CER, STYPE_NONFUNC);
 }
 
 void
@@ -2077,11 +2088,11 @@ cert_remove_nca(struct nca_tree *tree, i
 
 	if ((found = RB_FIND(nca_tree, tree, &needle)) != NULL) {
 		RB_REMOVE(nca_tree, tree, found);
-		repo_stat_inc(rp, found->talid, RTYPE_CER, STYPE_FUNC);
 		free(found->location);
 		free(found->carepo);
 		free(found->mfturi);
 		free(found->ski);
+		free(found->notify);
 		free(found);
 	}
 }
Index: extern.h
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/extern.h,v
diff -u -p -r1.281 extern.h
--- extern.h	18 May 2026 16:26:41 -0000	1.281
+++ extern.h	1 Jun 2026 21:02:42 -0000
@@ -29,6 +29,12 @@
 
 #define MAX_MSG_SIZE	(50 * 1024 * 1024)
 
+struct fqdnlistentry {
+	LIST_ENTRY(fqdnlistentry)	entry;
+	char				*fqdn;
+};
+LIST_HEAD(fqdns, fqdnlistentry);
+
 enum cert_as_type {
 	CERT_AS_ID, /* single identifier */
 	CERT_AS_INHERIT, /* inherit from issuer */
@@ -147,6 +153,18 @@ struct cert {
 	unsigned char	 mfthash[SHA256_DIGEST_LENGTH]; /* of the parent mft */
 };
 
+struct nca_hist {
+	RB_ENTRY(nca_hist)	 entry;
+	char			*ski;
+	char			*location;
+	char			*mfturi;
+	char			*notify;
+	time_t			 since;
+	time_t			 last_attempt;
+	int			 attempts;
+	int			 do_sync;
+};
+
 /*
  * Non-functional CA tree element.
  * Initially all CA and TA certs are added to this tree.
@@ -158,8 +176,14 @@ struct nonfunc_ca {
 	char			*carepo;
 	char			*mfturi;
 	char			*ski;
+	char			*notify;
 	int			 certid;
+	unsigned int		 repoid;
 	int			 talid;
+	time_t			 since;
+	time_t			 last_attempt;
+	int			 attempts;
+	int			 deferred;
 };
 
 /*
@@ -601,8 +625,6 @@ enum stype {
 	STYPE_PROVIDERS,
 	STYPE_OVERFLOW,
 	STYPE_SEQNUM_GAP,
-	STYPE_FUNC,
-	STYPE_NONFUNC,
 };
 
 struct repo;
@@ -617,6 +639,7 @@ struct repotalstats {
 	uint32_t	 certs; /* certificates */
 	uint32_t	 certs_fail; /* invalid certificate */
 	uint32_t	 certs_nonfunc; /* non-functional CA certificates */
+	uint32_t	 certs_nonfunc_deferred; /* sync deferred nonfunc CAs */
 	uint32_t	 mfts; /* total number of manifests */
 	uint32_t	 mfts_gap; /* manifests with sequence gaps */
 	uint32_t	 mfts_fail; /* failing syntactic parse */
@@ -698,6 +721,7 @@ extern int filemode;
 extern int excludeaspa;
 extern int experimental;
 extern int excludeas0;
+extern int retry_all_ncas;
 extern const char *tals[];
 extern const char *taldescs[];
 extern unsigned int talrepocnt[];
@@ -726,7 +750,7 @@ struct cert	*ta_validate(const char *, s
 struct cert	*cert_read(struct ibuf *);
 void		 cert_insert_brks(struct brk_tree *, struct cert *);
 void		 cert_insert_nca(struct nca_tree *, const struct cert *,
-		    struct repo *);
+		    time_t, time_t, int, int);
 void		 cert_remove_nca(struct nca_tree *, int, struct repo *);
 
 enum rtype	 rtype_from_file_extension(const char *);
@@ -867,6 +891,12 @@ void		 proc_rsync(char *, char *, int) _
 void		 proc_http(char *, int) __attribute__((noreturn));
 void		 proc_rrdp(int) __attribute__((noreturn));
 
+
+/* Non-functional CAs. */
+int		 nca_history_check(const struct cert *, struct nca_tree *);
+void		 nca_history_load(void);
+void		 nca_history_save(struct nca_tree *, time_t);
+
 /* Repository handling */
 int		 filepath_add(struct filepath_tree *, char *, int, time_t, int);
 int		 filepath_valid(struct filepath_tree *, char *, int);
@@ -895,6 +925,7 @@ void		 repo_cleanup(struct filepath_tree
 int		 repo_check_timeout(int);
 void		 repostats_new_files_inc(struct repo *, const char *);
 void		 repo_stat_inc(struct repo *, int, enum rtype, enum stype);
+void		 repo_stat_inc_nca(unsigned int, int, int);
 void		 repo_tal_stats_collect(void (*)(const struct repo *,
 		    const struct repotalstats *, void *), int, void *);
 void		 repo_stats_collect(void (*)(const struct repo *,
Index: main.c
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/main.c,v
diff -u -p -r1.306 main.c
--- main.c	9 May 2026 01:22:32 -0000	1.306
+++ main.c	1 Jun 2026 21:02:42 -0000
@@ -73,6 +73,7 @@ int	shortlistmode;
 int	rrdpon = 1;
 int	repo_timeout;
 int	experimental;
+int	retry_all_ncas = 0;
 time_t	deadline;
 
 /* 9999-12-31 23:59:59 UTC */
@@ -84,12 +85,6 @@ int64_t  evaluation_time = X509_TIME_MIN
 
 struct stats	 stats;
 
-struct fqdnlistentry {
-	LIST_ENTRY(fqdnlistentry)	 entry;
-	char				*fqdn;
-};
-LIST_HEAD(fqdns, fqdnlistentry);
-
 struct fqdns shortlist = LIST_HEAD_INITIALIZER(fqdns);
 struct fqdns skiplist = LIST_HEAD_INITIALIZER(fqdns);
 
@@ -546,6 +541,9 @@ queue_add_from_cert(const struct cert *c
 		return;
 	}
 
+	if (!nca_history_check(cert, ncas))
+		return;
+
 	repo = repo_lookup(cert->talid, cert->repo,
 	    rrdpon ? cert->notify : NULL);
 	if (repo == NULL)
@@ -575,7 +573,6 @@ queue_add_from_cert(const struct cert *c
 			err(1, NULL);
 	}
 
-	cert_insert_nca(ncas, cert, repo);
 	entityq_add(npath, nfile, RTYPE_MFT, DIR_UNKNOWN, repo, NULL, 0,
 	    cert->talid, cert->certid, NULL);
 }
@@ -809,6 +806,7 @@ sum_stats(const struct repo *rp, const s
 	out->certs += in->certs;
 	out->certs_fail += in->certs_fail;
 	out->certs_nonfunc += in->certs_nonfunc;
+	out->certs_nonfunc_deferred += in->certs_nonfunc_deferred;
 	out->roas += in->roas;
 	out->roas_fail += in->roas_fail;
 	out->roas_invalid += in->roas_invalid;
@@ -1064,7 +1062,7 @@ main(int argc, char *argv[])
 		err(1, "pledge");
 
 	while ((c =
-	    getopt(argc, argv, "0Ab:Bcd:e:fH:jmnop:P:Rs:S:t:vVx")) != -1)
+	    getopt(argc, argv, "0Ab:Bcd:e:fH:jmNnop:P:Rs:S:t:vVx")) != -1)
 		switch (c) {
 		case '0':
 			excludeas0 = 0;
@@ -1101,6 +1099,9 @@ main(int argc, char *argv[])
 		case 'm':
 			outformats |= FORMAT_OMETRIC;
 			break;
+		case 'N':
+			retry_all_ncas = 1;
+			break;
 		case 'n':
 			noop = 1;
 			break;
@@ -1348,6 +1349,8 @@ main(int argc, char *argv[])
 	if (fchdir(cachefd) == -1)
 		err(1, "fchdir");
 
+	nca_history_load();
+
 	while (entity_queue > 0 && !killme) {
 		int polltim;
 
@@ -1561,6 +1564,8 @@ main(int argc, char *argv[])
 
 	vd.buildtime = get_current_time();
 
+	nca_history_save(&vd.ncas, vd.buildtime);
+
 	/* change working directory to the output directory */
 	if (fchdir(outdirfd) == -1)
 		err(1, "fchdir output dir");
@@ -1600,9 +1605,11 @@ main(int argc, char *argv[])
 		    stats.repo_tal_stats.spls_invalid);
 	}
 	printf("BGPsec Router Certificates: %u\n", stats.repo_tal_stats.brks);
-	printf("Certificates: %u (%u invalid, %u non-functional)\n",
-	    stats.repo_tal_stats.certs, stats.repo_tal_stats.certs_fail,
-	    stats.repo_tal_stats.certs_nonfunc);
+	printf("Certificates: %u (%u invalid, %u non-functional, %u sync "
+	    "deferred)\n", stats.repo_tal_stats.certs,
+	    stats.repo_tal_stats.certs_fail,
+	    stats.repo_tal_stats.certs_nonfunc,
+	    stats.repo_tal_stats.certs_nonfunc_deferred);
 	printf("Trust Anchor Locators: %u (%u invalid)\n",
 	    stats.tals, talsz - stats.tals);
 	printf("Manifests: %u (%u failed parse, %u seqnum gaps)\n",
@@ -1632,7 +1639,7 @@ main(int argc, char *argv[])
 
 usage:
 	fprintf(stderr,
-	    "usage: rpki-client [-0ABcjmnoRVvx] [-b sourceaddr] [-d cachedir]"
+	    "usage: rpki-client [-0ABcjmNnoRVvx] [-b sourceaddr] [-d cachedir]"
 	    " [-e rsync_prog]\n"
 	    "                   [-H fqdn] [-P posix-seconds] [-p threads]"
 	    " [-S skiplist]\n"
Index: nca.c
===================================================================
RCS file: nca.c
diff -N nca.c
--- /dev/null	1 Jan 1970 00:00:00 -0000
+++ nca.c	1 Jun 2026 21:02:42 -0000
@@ -0,0 +1,446 @@
+/*	$OpenBSD$ */
+/*
+ * Copyright (c) 2026 Job Snijders <job@bsd.nl>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/limits.h>
+#include <sys/stat.h>
+#include <sys/time.h>
+#include <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "extern.h"
+
+static LIST_HEAD(, fqdnlistentry) notifys = LIST_HEAD_INITIALIZER(notifys);
+
+static RB_HEAD(nca_hist_tree, nca_hist)	ncas_hist = RB_INITIALIZER(&ncas_hist);
+
+static inline int
+nca_hist_cmp(struct nca_hist *a, struct nca_hist *b)
+{
+	int cmp;
+
+	cmp = strcmp(a->ski, b->ski);
+	if (cmp > 0)
+		return 1;
+	if (cmp < 0)
+		return -1;
+
+	cmp = strcmp(a->location, b->location);
+	if (cmp > 0)
+		return 1;
+	if (cmp < 0)
+		return -1;
+
+	cmp = strcmp(a->mfturi, b->mfturi);
+	if (cmp > 0)
+		return 1;
+	if (cmp < 0)
+		return -1;
+
+	return 0;
+}
+
+RB_PROTOTYPE_STATIC(nca_hist_tree, nca_hist, entry, nca_hist_cmp);
+RB_GENERATE_STATIC(nca_hist_tree, nca_hist, entry, nca_hist_cmp);
+
+static void
+nca_hist_free(struct nca_hist *nca_hist)
+{
+	if (nca_hist == NULL)
+		return;
+
+	free(nca_hist->ski);
+	free(nca_hist->location);
+	free(nca_hist->mfturi);
+	free(nca_hist->notify);
+	free(nca_hist);
+}
+
+static void
+ncas_hist_free(void)
+{
+	struct nca_hist *nca_hist, *nca_hist_tmp;
+
+	RB_FOREACH_SAFE(nca_hist, nca_hist_tree, &ncas_hist, nca_hist_tmp) {
+		RB_REMOVE(nca_hist_tree, &ncas_hist, nca_hist);
+		nca_hist_free(nca_hist);
+	}
+}
+
+/*
+ * Decide whether to schedule a synchronization retry in order to control the
+ * rate of attempts for non-functional CAs.
+ * First just retry a few times consecutively, then insert 90 minute pauses
+ * between the retries, and after 24 hours settle on retrying only once per day
+ * (modulo any RRDP rpkiNotify batching by the caller).
+ * Return 1 if it is time to queue a retry, 0 otherwise.
+ */
+static int
+nca_decide_retry(const struct nca_hist *nca_hist)
+{
+	time_t now, since, last_attempt;
+
+	now = get_current_time();
+
+	since = nca_hist->since;
+	last_attempt = nca_hist->last_attempt;
+
+	if (nca_hist->attempts < 3)
+		return 1;
+
+	if ((now - since < 24 * 60 * 60) && (now > last_attempt + 90 * 60))
+		return 1;
+
+	if ((now - since > 24 * 60 * 60) && (now - last_attempt > 24 * 60 * 60))
+		return 1;
+
+	return 0;
+}
+
+/*
+ * Determine which non-functioncal CAs are eligible for retry.
+ * If multiple NCAs point to the same RRDP repo and at least one is eligible
+ * for retry, batch all of those together.
+ */
+static void
+ncas_decide_retries(void)
+{
+	struct nca_hist *nca_hist;
+	struct fqdnlistentry *fle, *fle_tmp;
+	size_t notify_len;
+
+	RB_FOREACH(nca_hist, nca_hist_tree, &ncas_hist) {
+		if (!nca_decide_retry(nca_hist))
+			continue;
+
+		nca_hist->do_sync = 1;
+
+		if (nca_hist->notify == NULL)
+			continue;
+
+		if ((fle = malloc(sizeof(*fle))) == NULL)
+			err(1, NULL);
+
+		if ((fle->fqdn = strdup(nca_hist->notify)) == NULL)
+			err(1, NULL);
+
+		LIST_INSERT_HEAD(&notifys, fle, entry);
+	}
+
+	RB_FOREACH(nca_hist, nca_hist_tree, &ncas_hist) {
+		if (nca_hist->notify == NULL)
+			continue;
+
+		notify_len = strlen(nca_hist->notify);
+
+		LIST_FOREACH(fle, &notifys, entry) {
+			if (strlen(fle->fqdn) == notify_len &&
+			    strncasecmp(nca_hist->notify, fle->fqdn,
+			    notify_len) == 0) {
+				nca_hist->do_sync = 1;
+				break;
+			}
+		}
+	}
+
+	LIST_FOREACH_SAFE(fle, &notifys, entry, fle_tmp) {
+		LIST_REMOVE(fle, entry);
+		free(fle->fqdn);
+		free(fle);
+	}
+}
+
+void
+nca_history_load(void)
+{
+	FILE *f;
+	char *line = NULL;
+	size_t linesize = 0;
+	ssize_t linelen;
+	const char *errstr;
+	struct nca_hist *nca_hist;
+
+	if ((f = fopen(".nca_history", "r")) == NULL) {
+		if (errno == ENOENT)
+			return;
+		err(1, "failed to open .nca_history");
+	}
+
+	while ((linelen = getline(&line, &linesize, f)) != -1) {
+		char *l, *ski, *loc, *mfturi, *notify,
+		    *since, *last_attempt, *attempts;
+		size_t loc_len, mfturi_len, notify_len;
+
+		/*
+		 * Hex-encoded SHA1, time, time, attempts, cert location, mft,
+		 * and rpkiNotify (which is optional and therefor could be '-').
+		 */
+		if (linelen < 40 + 1 + 10 + 1 + 10 + 1 + 1 + 1 + 13 + 1 + 21 +
+		    1 + 1)
+			goto err;
+
+		if (line[linelen - 1] == '\n')
+			line[linelen - 1] = '\0';
+
+		if ((nca_hist = calloc(1, sizeof(*nca_hist))) == NULL)
+			err(1, NULL);
+
+		l = line;
+
+		if ((ski = strsep(&l, " ")) == NULL)
+			goto err;
+		if ((nca_hist->ski = strdup(ski)) == NULL)
+			err(1, NULL);
+
+		if ((since = strsep(&l, " ")) == NULL)
+			goto err;
+		nca_hist->since = strtonum(since, 1, LLONG_MAX, &errstr);
+		if (errstr != NULL)
+			goto err;
+
+		if ((last_attempt = strsep(&l, " ")) == NULL)
+			goto err;
+		nca_hist->last_attempt = strtonum(last_attempt, 1, LLONG_MAX,
+		    &errstr);
+		if (errstr != NULL)
+			goto err;
+
+		if ((attempts = strsep(&l, " ")) == NULL)
+			goto err;
+		nca_hist->attempts = strtonum(attempts, 1, LLONG_MAX, &errstr);
+		if (errstr != NULL)
+			goto err;
+
+		if ((loc = strsep(&l, " ")) == NULL)
+			goto err;
+
+		/* minimal example cert location: ab.cd/a/b.cer */
+		if ((loc_len = strlen(loc)) < 13)
+			goto err;
+		if (strcmp(loc + loc_len - 4, ".cer") != 0)
+			goto err;
+		if (!valid_uri(loc, strlen(loc), NULL))
+			goto err;
+		if ((nca_hist->location = strdup(loc)) == NULL)
+			err(1, NULL);
+
+		if ((mfturi = strsep(&l, " ")) == NULL)
+			goto err;
+
+		/* minimal example mft location: rsync://ab.cd/a/b.mft */
+		if ((mfturi_len = strlen(mfturi)) < 21)
+			goto err;
+		if (strcmp(mfturi + mfturi_len - 4, ".mft") != 0)
+			goto err;
+		if (!valid_uri(mfturi, strlen(mfturi), RSYNC_PROTO))
+			goto err;
+		if ((nca_hist->mfturi = strdup(mfturi)) == NULL)
+			err(1, NULL);
+
+		notify = l;
+		if (notify == NULL)
+			goto err;
+
+		if (strcmp("-", notify) == 0) {
+			nca_hist->notify = NULL;
+		} else {
+			/* minimal example rpkiNotify: https://ab.cd/a.xml */
+			if ((notify_len = strlen(notify)) < 19)
+				goto err;
+			if (strcmp(notify + notify_len - 4, ".xml") != 0)
+				goto err;
+			if (!valid_uri(notify, strlen(notify), HTTPS_PROTO))
+				goto err;
+			if ((nca_hist->notify = strdup(notify)) == NULL)
+				err(1, NULL);
+		}
+
+		if (RB_INSERT(nca_hist_tree, &ncas_hist, nca_hist) != NULL)
+			err(1, "ncas_hist_tree corrupted");
+	}
+
+	if (ferror(f))
+		goto err;
+
+	fclose(f);
+	free(line);
+
+	ncas_decide_retries();
+
+	return;
+
+ err:
+	warnx("error reading .nca_history");
+	fclose(f);
+	unlink(".nca_history");
+
+	free(line);
+
+	nca_hist_free(nca_hist);
+
+	ncas_hist_free();
+}
+
+/*
+ * Look up history and synchronization eligibility for a given CA, if any.
+ * Return 1 to trigger a synchronization attempt, otherwise 0.
+ */
+int
+nca_history_check(const struct cert *cert, struct nca_tree *ncas)
+{
+	struct nca_hist *nca_hist, needle;
+	time_t since, last_attempt;
+	int attempts = 1, do_sync = 1;
+
+	since = last_attempt = get_current_time();
+
+	needle.ski = cert->ski;
+	needle.location = cert->path;
+	needle.mfturi = cert->mft;
+	if ((nca_hist = RB_FIND(nca_hist_tree, &ncas_hist, &needle)) != NULL) {
+		since = nca_hist->since;
+		attempts = nca_hist->attempts;
+
+		if (retry_all_ncas || nca_hist->do_sync == 1) {
+			if (verbose)
+				warnx("%s: retrying, non-functional since %s",
+				    cert->path, time2str(since));
+			attempts++;
+		} else {
+			if (verbose > 1)
+				warnx("%s: deferring sync, non-functional since"
+				    " %s", cert->path, time2str(since));
+			last_attempt = nca_hist->last_attempt;
+			do_sync = 0;
+		}
+	}
+
+	cert_insert_nca(ncas, cert, since, last_attempt, attempts, do_sync);
+
+	return do_sync;
+}
+
+static int
+ncas_sorted_cmp(const void *a, const void *b)
+{
+	int cmp;
+	struct nonfunc_ca *na = *(struct nonfunc_ca **)a;
+	struct nonfunc_ca *nb = *(struct nonfunc_ca **)b;
+
+	cmp = strcmp(na->mfturi, nb->mfturi);
+	if (cmp > 0)
+		return 1;
+	if (cmp < 0)
+		return -1;
+
+	cmp = strcmp(na->location, nb->location);
+	if (cmp > 0)
+		return 1;
+	if (cmp < 0)
+		return -1;
+
+	cmp = strcmp(na->ski, nb->ski);
+	if (cmp > 0)
+		return 1;
+	if (cmp < 0)
+		return -1;
+
+	return 0;
+}
+
+void
+nca_history_save(struct nca_tree *ncas, time_t buildtime)
+{
+	char temp[] = ".nca_history.XXXXXXXX";
+	FILE *f = NULL;
+	int fd;
+	struct nonfunc_ca *nca, **ncas_sorted = NULL;
+	size_t ncas_num = 0, idx = 0;
+	struct timespec ts[2];
+
+	if (RB_EMPTY(ncas)) {
+		unlink(".nca_history");
+		return;
+	}
+
+	if ((fd = mkostemp(temp, O_CLOEXEC)) == -1)
+		goto err;
+	(void)fchmod(fd, 0644);
+
+	if ((f = fdopen(fd, "w")) == NULL)
+		err(1, "fopen");
+
+	RB_FOREACH(nca, nca_tree, ncas)
+		ncas_num++;
+
+	if ((ncas_sorted = calloc(ncas_num, sizeof(ncas_sorted[0]))) == NULL)
+		err(1, NULL);
+
+	RB_FOREACH(nca, nca_tree, ncas)
+		ncas_sorted[idx++] = nca;
+
+	qsort(ncas_sorted, ncas_num, sizeof(ncas_sorted[0]), ncas_sorted_cmp);
+
+	for (idx = 0; idx < ncas_num; idx++) {
+		nca = ncas_sorted[idx];
+
+		repo_stat_inc_nca(nca->repoid, nca->talid, nca->deferred);
+
+		if (fprintf(f, "%s %lld %lld %d %s %s", nca->ski,
+		    (long long)nca->since, (long long)nca->last_attempt,
+		    nca->attempts, nca->location, nca->mfturi) < 0)
+			goto err;
+
+		if (nca->notify == NULL) {
+			if (fprintf(f, " -\n") < 0)
+				goto err;
+		} else {
+			if (fprintf(f, " %s\n", nca->notify) < 0)
+				goto err;
+		}
+	}
+
+	if (fclose(f) != 0) {
+		f = NULL;
+		goto err;
+	}
+
+	ts[0].tv_nsec = UTIME_OMIT;
+	ts[1].tv_sec = buildtime;
+	ts[1].tv_nsec = 0;
+
+	if (utimensat(AT_FDCWD, temp, ts, 0) == -1)
+		goto err;
+
+	if (rename(temp, ".nca_history") == -1)
+		goto err;
+
+	free(ncas_sorted);
+	ncas_hist_free();
+
+	return;
+
+ err:
+	warn("error saving non-functional CA history to %s", temp);
+	if (f != NULL)
+		fclose(f);
+	unlink(temp);
+	free(ncas_sorted);
+	ncas_hist_free();
+}
Index: output-json.c
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/output-json.c,v
diff -u -p -r1.59 output-json.c
--- output-json.c	13 Nov 2025 15:18:53 -0000	1.59
+++ output-json.c	1 Jun 2026 21:02:42 -0000
@@ -64,6 +64,7 @@ outputheader_json(struct validation_data
 	json_do_int("certificates", st->repo_tal_stats.certs);
 	json_do_int("invalidcertificates", st->repo_tal_stats.certs_fail);
 	json_do_int("nonfunctionalcas", st->repo_tal_stats.certs_nonfunc);
+	json_do_int("deferredcas", st->repo_tal_stats.certs_nonfunc_deferred);
 	json_do_int("taks", st->repo_tal_stats.taks);
 	json_do_int("tals", st->tals);
 	json_do_int("invalidtals", talsz - st->tals);
@@ -192,7 +193,13 @@ output_json(FILE *out, struct validation
 		json_do_string("ta", taldescs[nca->talid]);
 		json_do_string("caRepository", nca->carepo);
 		json_do_string("rpkiManifest", nca->mfturi);
+		if (nca->notify != NULL)
+			json_do_string("rpkiNotify", nca->notify);
 		json_do_string("ski", nca->ski);
+		json_do_int("since", (long long)nca->since);
+		json_do_int("last_attempt", (long long)nca->last_attempt);
+		json_do_int("total_attempts", nca->attempts);
+		json_do_bool("deferred", nca->deferred);
 		json_do_end();
 	}
 	json_do_end();
Index: output-ometric.c
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/output-ometric.c,v
diff -u -p -r1.16 output-ometric.c
--- output-ometric.c	13 Nov 2025 15:18:53 -0000	1.16
+++ output-ometric.c	1 Jun 2026 21:02:42 -0000
@@ -44,6 +44,9 @@ set_common_stats(const struct repotalsta
 	    OKV("type", "state"), OKV("cert", "failed parse"), ol);
 	ometric_set_int_with_labels(metric, in->certs_nonfunc,
 	    OKV("type", "state"), OKV("cert", "non-functional"), ol);
+	ometric_set_int_with_labels(metric, in->certs_nonfunc_deferred,
+	    OKV("type", "state"), OKV("cert", "sync-deferred"),
+	    ol);
 
 	ometric_set_int_with_labels(metric, in->mfts,
 	    OKV("type", "state"), OKV("manifest", "valid"), ol);
Index: output.c
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/output.c,v
diff -u -p -r1.45 output.c
--- output.c	13 Nov 2025 15:18:53 -0000	1.45
+++ output.c	1 Jun 2026 21:02:42 -0000
@@ -269,14 +269,15 @@ outputheader(FILE *out, struct validatio
 	    "# CCR validated ASPA payloads hash: %s\n"
 	    "# Route Origin Authorizations: %u (%u failed parse, %u invalid)\n"
 	    "# BGPsec Router Certificates: %u\n"
-	    "# Certificates: %u (%u invalid, %u non-functional)\n",
-	    hn, tbuf, (long long)st->elapsed_time.tv_sec,
+	    "# Certificates: %u (%u invalid, %u non-functional, %u sync "
+	    "deferred)\n", hn, tbuf, (long long)st->elapsed_time.tv_sec,
 	    (long long)st->user_time.tv_sec, (long long)st->system_time.tv_sec,
 	    vd->ccr.mfts_hash, vd->ccr.vrps_hash, vd->ccr.vaps_hash,
 	    st->repo_tal_stats.roas, st->repo_tal_stats.roas_fail,
 	    st->repo_tal_stats.roas_invalid, st->repo_tal_stats.brks,
 	    st->repo_tal_stats.certs, st->repo_tal_stats.certs_fail,
-	    st->repo_tal_stats.certs_nonfunc) < 0)
+	    st->repo_tal_stats.certs_nonfunc,
+	    st->repo_tal_stats.certs_nonfunc_deferred) < 0)
 		return -1;
 
 	if (fprintf(out,
Index: repo.c
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/repo.c,v
diff -u -p -r1.81 repo.c
--- repo.c	13 May 2026 04:38:42 -0000	1.81
+++ repo.c	1 Jun 2026 21:02:42 -0000
@@ -1537,6 +1537,23 @@ repostats_new_files_inc(struct repo *rp,
 		rp->repostats.new_files++;
 }
 
+void
+repo_stat_inc_nca(unsigned int id, int talid, int deferred)
+{
+	struct repo *rp;
+
+	SLIST_FOREACH(rp, &repos, entry) {
+		if (rp->id == id) {
+			rp->stats[talid].certs_nonfunc++;
+
+			if (deferred)
+				rp->stats[talid].certs_nonfunc_deferred++;
+
+			break;
+		}
+	}
+}
+
 /*
  * Update stats object of repository depending on rtype and subtype.
  */
@@ -1552,10 +1569,6 @@ repo_stat_inc(struct repo *rp, int talid
 			rp->stats[talid].certs++;
 		if (subtype == STYPE_FAIL)
 			rp->stats[talid].certs_fail++;
-		if (subtype == STYPE_NONFUNC)
-			rp->stats[talid].certs_nonfunc++;
-		if (subtype == STYPE_FUNC)
-			rp->stats[talid].certs_nonfunc--;
 		if (subtype == STYPE_BGPSEC) {
 			rp->stats[talid].certs--;
 			rp->stats[talid].brks++;
@@ -1854,6 +1867,9 @@ repo_cleanup_entry(FTSENT *e, struct fil
 	path = skip_dotslash(e->fts_path);
 	switch (e->fts_info) {
 	case FTS_NSOK:
+		if (e->fts_level == 1 && fts_state.type == BASE_DIR &&
+		    strcmp(e->fts_name, ".nca_history") == 0)
+			break;
 		if (filepath_exists(tree, path)) {
 			e->fts_parent->fts_number++;
 			break;
Index: rpki-client.8
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/rpki-client.8,v
diff -u -p -r1.140 rpki-client.8
--- rpki-client.8	30 May 2026 02:09:04 -0000	1.140
+++ rpki-client.8	1 Jun 2026 21:02:42 -0000
@@ -22,7 +22,7 @@
 .Nd RPKI validator to support BGP routing security
 .Sh SYNOPSIS
 .Nm
-.Op Fl 0ABcjmnoRVvx
+.Op Fl 0ABcjmNnoRVvx
 .Op Fl b Ar sourceaddr
 .Op Fl d Ar cachedir
 .Op Fl e Ar rsync_prog
@@ -153,6 +153,12 @@ for a description of the fields.
 Create output in the file
 .Pa metrics
 in the output directory in OpenMetrics format.
+.It Fl N
+Disable the backoff retry mechanism applied to non-functional CAs.
+By default
+.Nm
+progressively decreases the synchronization frequency for persistently
+non-functional CAs, eventually settling on retrying about once per day.
 .It Fl n
 Offline mode.
 Validate the contents of