Re: rpki-client: backoff retry for persistently non-functional CAs

Job Snijders <[email protected]>
Newsgroups gmane.os.openbsd.tech
Message-ID <[email protected]>
Dear all,

I just wanted to share some work-in-progress - 

On Fri, May 15, 2026 at 11:56:17AM +0000, Job Snijders wrote:
> While some large CAs (e.g., RIPE & APNIC) have adopted a policy of
> eventually revoking persistently non-functional CAs, such a practice is
> not universally adopted among RIR and NIRs (nor is expected to ever be).
> With the below changeset, the existing detection mechanism for
> non-functional CAs is extended into a stateful backoff retry mechanism.
> While there, expose the newly gathered state in the JSON output and fix
> failure attribution in the openmetrics output (by counting detected
> non-func CAs towards the parent repo instead of the broken CA's own
> repo).

This new version of the changeset (partially) resolves a metastable
behaviour I didn't quite like in the previous diff. This version
groups together non-functional CAs based on the FQDN, so that retries
eventually end up being done in batches. My goal is that _if_ an
effort is made to synchronize against a given FQDN (via one or another
transport), we'd better check if that one synchronisation task unjiggled
any of the non-functioncal CAs dependent on that synchronisation task
to not waste syncing effort. With the previous diff, the RP could end
up syncing frequently to a publication point (more than once a day)
without actually trying to find manifests for all the non-functioncal
CAs pointing into that remote repo. This metastable behaviour was most
noticable under NICBR where CAs frequently switch from functional to
non-functioncal throughout the day.

My next step is to figure out a batching scheme that also takes
the transpot into account: when it's time to retry an rsync-only
non-functioncal CA, this in itself shouldn't trigger RRDP work related
to adjacent non-functioncal CAs. I think I need to slice the NCA batches
not just by FQDN but maybe also some other parameters.

Kind regards,

Job


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	30 May 2026 07:43:57 -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	30 May 2026 07:43:57 -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;
 
@@ -2063,11 +2064,15 @@ cert_insert_nca(struct nca_tree *tree, c
 	if ((nca->ski = strdup(cert->ski)) == 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,7 +2082,6 @@ 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);
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	30 May 2026 07:43:57 -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,17 @@ 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;
+	time_t			 since;
+	time_t			 last_attempt;
+	int			 attempts;
+	int			 defer;
+};
+
 /*
  * Non-functional CA tree element.
  * Initially all CA and TA certs are added to this tree.
@@ -159,7 +176,12 @@ struct nonfunc_ca {
 	char			*mfturi;
 	char			*ski;
 	int			 certid;
+	unsigned int		 repoid;
 	int			 talid;
+	time_t			 since;
+	time_t			 last_attempt;
+	int			 attempts;
+	int			 deferred;
 };
 
 /*
@@ -601,8 +623,6 @@ enum stype {
 	STYPE_PROVIDERS,
 	STYPE_OVERFLOW,
 	STYPE_SEQNUM_GAP,
-	STYPE_FUNC,
-	STYPE_NONFUNC,
 };
 
 struct repo;
@@ -617,6 +637,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 +719,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 +748,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 +889,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 *);
+
 /* Repository handling */
 int		 filepath_add(struct filepath_tree *, char *, int, time_t, int);
 int		 filepath_valid(struct filepath_tree *, char *, int);
@@ -895,6 +923,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	30 May 2026 07:43:57 -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);
+
 	/* 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	30 May 2026 07:43:57 -0000
@@ -0,0 +1,410 @@
+/*	$OpenBSD$ */
+/*
+ * Copyright (c) 2026 Job Snijders <[email protected]>
+ *
+ * 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 <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "extern.h"
+
+static LIST_HEAD(, fqdnlistentry) retrylist = LIST_HEAD_INITIALIZER(retrylist);
+
+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);
+}
+
+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 retry in order to control the rate of
+ * synchronization attempts for non-functional CAs.
+ * First just retry a few times consecutively, then insert 90 minute
+ * pauses between retries, and after a full day settle on retrying only
+ * once per day.
+ * 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;
+}
+
+/*
+ * Batch retries per FQDN.
+ */
+static void
+ncas_batch_retries(void)
+{
+	struct nca_hist *nca_hist;
+	char *fqdn, *host;
+	size_t host_len;
+	struct fqdnlistentry *fle, *fle_tmp;
+
+	RB_FOREACH(nca_hist, nca_hist_tree, &ncas_hist) {
+		nca_hist->defer = 1;
+
+		if (!nca_decide_retry(nca_hist))
+			continue;
+
+		fqdn = strdup(nca_hist->mfturi + RSYNC_PROTO_LEN);
+		if (fqdn == NULL)
+			err(1, NULL);
+
+		fqdn[strcspn(fqdn, "/")] = '\0';
+
+		if ((fle = malloc(sizeof(*fle))) == NULL)
+			err(1, NULL);
+		if ((fle->fqdn = strdup(fqdn)) == NULL)
+			err(1, NULL);
+
+		LIST_INSERT_HEAD(&retrylist, fle, entry);
+
+		free(fqdn);
+		fqdn = NULL;
+		fle = NULL;
+	}
+
+	RB_FOREACH(nca_hist, nca_hist_tree, &ncas_hist) {
+		LIST_FOREACH(fle, &retrylist, entry) {
+			host = nca_hist->mfturi + RSYNC_PROTO_LEN;
+			host_len = strcspn(host, "/");
+
+			if (strlen(fle->fqdn) == host_len &&
+			    strncasecmp(host, fle->fqdn, host_len) == 0) {
+				nca_hist->defer = 0;
+				break;
+			}
+		}
+	}
+
+	LIST_FOREACH_SAFE(fle, &retrylist, 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, *since, *last_attempt, *attempts;
+		size_t loc_len, mfturi_len;
+
+		/*
+		 * Hex-encoded SHA1, time, time, attempts, cert location & mft.
+		 */
+		if (linelen < 40 + 1 + 10 + 1 + 10 + 1 + 1 + 1 + 13 + 1 + 21)
+			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 (l == NULL)
+			goto err;
+
+		/* minimal example mft location: rsync://ab.cd/a/b.mft */
+		mfturi = l;
+		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);
+
+		if (RB_INSERT(nca_hist_tree, &ncas_hist, nca_hist) != NULL)
+			err(1, "ncas_hist_tree corrupted");
+
+		nca_hist = NULL;
+	}
+
+	if (ferror(f))
+		goto err;
+
+	fclose(f);
+	free(line);
+
+	ncas_batch_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 for a given CA, if any.
+ * Return 1 to schedule a sync, 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->defer == 0)
+			attempts++;
+		else {
+			last_attempt = nca_hist->last_attempt;
+			do_sync = 0;
+		}
+	}
+
+	if (!do_sync && verbose > 1)
+		warnx("%s: deferring sync, non-functional since %s",
+		    cert->path, time2str(since));
+
+	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)
+{
+	char temp[] = ".nca_history.XXXXXXXX";
+	FILE *f = NULL;
+	int fd;
+	struct nonfunc_ca *nca, **ncas_sorted = NULL;
+	size_t ncas_num = 0, idx = 0;
+
+	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\n", nca->ski,
+		    (long long)nca->since, (long long)nca->last_attempt,
+		    nca->attempts, nca->location, nca->mfturi) < 0)
+			goto err;
+	}
+
+	if (fclose(f) != 0) {
+		f = NULL;
+		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	30 May 2026 07:43:57 -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);
@@ -193,6 +194,10 @@ output_json(FILE *out, struct validation
 		json_do_string("caRepository", nca->carepo);
 		json_do_string("rpkiManifest", nca->mfturi);
 		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	30 May 2026 07:43:57 -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	30 May 2026 07:43:57 -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	30 May 2026 07:43:58 -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.139 rpki-client.8
--- rpki-client.8	17 Feb 2026 13:54:42 -0000	1.139
+++ rpki-client.8	30 May 2026 07:43:58 -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 once per day.
 .It Fl n
 Offline mode.
 Validate the contents of
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.