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,

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 <[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 <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
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.