From d6ce9ee096bef32a424930a4acd7db410f75c9dc Mon Sep 17 00:00:00 2001 From: mervin008 Date: Sat, 26 Sep 2026 14:04:38 +0200 Subject: [PATCH 1/2] Map the renewal anchor, and pin only the repository that dispatched Two failures of the sync workflow, each of which left the API guides and the route table unpublished while the whole-page sync before them went through. Since certpilot#114, three runs failed in sync-guides: "certificates links to #asking-for-one, which is not a heading in that section and has no entry in ANCHOR_TO_PAGE". The link was correct in certpilot, where the API reference is one page; here the reference is split, and the anchor lives on the renewal-queue page. ANCHOR_TO_PAGE is the designed place for it. On 24 September a dispatch from certpilot-agent failed in sync-routes: certpilot's routes.json at 1d78e23 returned 404, because 1d78e23 is an agent commit. The pages step already pins only the repository that dispatched; the routes and guides steps took the dispatched SHA whoever sent it. They now use the same expression, so an agent merge reads certpilot at main. Verified: with CERTPILOT_REF at certpilot main (8103195), sync-routes and sync-guides both complete (14 guides written, where sync-guides exited before), the site builds, and check:links finds 9091 links across 72 pages with none dead; the certificates page links to /api/reference/renewal-queue#asking-for-one, which exists. The local sync output is not committed: the workflow's own pull request carries it. actionlint is clean on sync.yml. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/sync.yml | 9 +++++++-- scripts/sync-guides.mjs | 3 +++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index a96d645..e99c270 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -124,12 +124,17 @@ jobs: # Routes and guides are different, and get a pull request. A new endpoint # usually needs a sentence of prose that a generator cannot write, and # opening a pull request is what creates the moment to write it. + # Pinned the same way as the pages above, and for the same reason: only + # the repository that dispatched is pinned. These two read certpilot + # alone, and used to take the dispatched SHA whoever sent it, so an agent + # merge asked for certpilot's routes.json at an agent commit and got a + # 404 (run 36064618794). - run: npm run sync env: - CERTPILOT_REF: ${{ github.event.client_payload.sha }} + CERTPILOT_REF: ${{ github.event.client_payload.repo == 'certpilot-agent' && 'main' || github.event.client_payload.sha }} - run: npm run sync:guides env: - CERTPILOT_REF: ${{ github.event.client_payload.sha }} + CERTPILOT_REF: ${{ github.event.client_payload.repo == 'certpilot-agent' && 'main' || github.event.client_payload.sha }} - uses: peter-evans/create-pull-request@v7 with: diff --git a/scripts/sync-guides.mjs b/scripts/sync-guides.mjs index 9287135..a74371c 100644 --- a/scripts/sync-guides.mjs +++ b/scripts/sync-guides.mjs @@ -122,6 +122,9 @@ const CODE_TREE = 'https://github.com/certpilot/certpilot/blob/main' */ const ANCHOR_TO_PAGE = { 'authenticating-an-unattended-screen': '/api/display-tokens', + // Certificates → renewing: a key CertPilot does not hold is refused, and + // the rule is written up where the renewal queue is (certpilot#114). + 'asking-for-one': '/api/reference/renewal-queue#asking-for-one', } /** From 07a0569daae018670e1e503616d65ca6af028b1a Mon Sep 17 00:00:00 2001 From: mervin008 Date: Sat, 26 Sep 2026 14:08:19 +0200 Subject: [PATCH 2/2] Sync the API guides from certpilot 8103195 The guides have not been published since certpilot#114: every sync since stopped at the anchor the commit before this maps. This is what that sync would have committed, pinned to certpilot main at 8103195, which carries #114, #115 and #116: renewal of a key CertPilot does not hold is refused, renew_after is bounded by the certificate's lifetime, and the name filter matches SANs. Without it this branch's own check fails ("guides are stale"), because the check compares against certpilot main. Checked: sync-guides --check, sync-pages --check and sync-routes all up to date at that commit; the site builds; check:links finds none dead. Co-Authored-By: Claude Opus 5.5 --- guides/agents.md | 9 ++++++++- guides/ca-accounts-gateways.md | 2 +- guides/certificate-transparency.md | 2 +- guides/certificates.md | 11 ++++++++++- guides/cloud-inventory.md | 2 +- guides/dashboard.md | 2 +- guides/deployment.md | 2 +- guides/discovery.md | 2 +- guides/display-tokens.md | 2 +- guides/live-event-stream.md | 2 +- guides/notification-channels.md | 2 +- guides/pki-ca-management.md | 2 +- guides/policies.md | 2 +- guides/renewal-queue.md | 15 +++++++++++++-- 14 files changed, 42 insertions(+), 15 deletions(-) diff --git a/guides/agents.md b/guides/agents.md index 0f2cbed..844e3bb 100644 --- a/guides/agents.md +++ b/guides/agents.md @@ -1,4 +1,4 @@ - Two APIs, kept completely apart. @@ -300,6 +300,13 @@ grant's `renew_before_days`. A host that picked its own moment could decide to renew hourly, and four hundred of them would be a denial of service against the CA. +`renew_after` always falls inside the certificate's own lifetime, and never in +its first third. A lead time longer than two thirds of the lifetime is replaced +by renewing when a third remains, so a six-day certificate under the default 30 +days renews at day four. Without that bound, a certificate shorter than its lead +time was handed a `renew_after` that had already passed, and the host renewed it +on every cycle ([#109](https://github.com/certpilot/certpilot/issues/109)). + ## Installing, and reloading diff --git a/guides/ca-accounts-gateways.md b/guides/ca-accounts-gateways.md index cfbb7df..79866f8 100644 --- a/guides/ca-accounts-gateways.md +++ b/guides/ca-accounts-gateways.md @@ -1,4 +1,4 @@ - ## Registering diff --git a/guides/certificate-transparency.md b/guides/certificate-transparency.md index b1b82a9..e30a01a 100644 --- a/guides/certificate-transparency.md +++ b/guides/certificate-transparency.md @@ -1,4 +1,4 @@ - This is the half of discovery a network scan cannot reach. A scan answers "what diff --git a/guides/certificates.md b/guides/certificates.md index 45436e4..108c3d0 100644 --- a/guides/certificates.md +++ b/guides/certificates.md @@ -1,9 +1,15 @@ - `GET /certificates` filters on `status`, `environment`, `common_name`, `ca_account_id`. +Despite its name, `common_name` matches **any** of a certificate's names, the +subject common name or any SAN, by substring and ignoring case. Conforming CAs +are dropping the common name (Pebble already issues without one), and a +certificate whose only name is in its SANs must still be findable by it. A +certificate matches once however many of its names do. + ## Issuing ```http @@ -48,6 +54,9 @@ a policy that finds something must say so even when it does not block. `POST /certificates/:id/renew` runs the same path. The key is rotated and the new one persisted; a renewal that produced a certificate without storing its matching key would leave a record that looks healthy and cannot terminate TLS. +That is only possible for a key CertPilot already holds: a certificate whose +`key_custody` is `AGENT` or `EXTERNAL` is refused with `400`, and renewed by +whoever holds its key (see [Asking for one](/api/reference/renewal-queue#asking-for-one)). On failure the record is marked `RENEWAL_FAILED` with `renewal_error` set, and the previous certificate is left intact. diff --git a/guides/cloud-inventory.md b/guides/cloud-inventory.md index 800e1d0..580d736 100644 --- a/guides/cloud-inventory.md +++ b/guides/cloud-inventory.md @@ -1,4 +1,4 @@ - The third place certificates hide. A scan finds what is **served**; Certificate diff --git a/guides/dashboard.md b/guides/dashboard.md index 28d4e31..d11b631 100644 --- a/guides/dashboard.md +++ b/guides/dashboard.md @@ -1,4 +1,4 @@ - ## Statistics diff --git a/guides/deployment.md b/guides/deployment.md index b9d7734..4ab7b6a 100644 --- a/guides/deployment.md +++ b/guides/deployment.md @@ -1,4 +1,4 @@ - Everything above deployment in this document observes. This changes something diff --git a/guides/discovery.md b/guides/discovery.md index 7ad3d86..af67aee 100644 --- a/guides/discovery.md +++ b/guides/discovery.md @@ -1,4 +1,4 @@ - Scanning is operator-gated because it opens connections to third-party diff --git a/guides/display-tokens.md b/guides/display-tokens.md index bd9c48b..735db42 100644 --- a/guides/display-tokens.md +++ b/guides/display-tokens.md @@ -1,4 +1,4 @@ - Create: diff --git a/guides/live-event-stream.md b/guides/live-event-stream.md index f8ee693..034325d 100644 --- a/guides/live-event-stream.md +++ b/guides/live-event-stream.md @@ -1,4 +1,4 @@ - Opens with a `retry:` directive and a `snapshot` event carrying current diff --git a/guides/notification-channels.md b/guides/notification-channels.md index 134ea9c..8e75896 100644 --- a/guides/notification-channels.md +++ b/guides/notification-channels.md @@ -1,4 +1,4 @@ - Three channel types have a notifier: `slack`, `webhook`, `email`. Migration 001 diff --git a/guides/pki-ca-management.md b/guides/pki-ca-management.md index 266bb3b..a741132 100644 --- a/guides/pki-ca-management.md +++ b/guides/pki-ca-management.md @@ -1,4 +1,4 @@ - ## Importing the CAs behind a CA account diff --git a/guides/policies.md b/guides/policies.md index 10b41ef..9d805f5 100644 --- a/guides/policies.md +++ b/guides/policies.md @@ -1,4 +1,4 @@ - Rule types, all of which are evaluated: diff --git a/guides/renewal-queue.md b/guides/renewal-queue.md index 3dc3c1d..16ce6ee 100644 --- a/guides/renewal-queue.md +++ b/guides/renewal-queue.md @@ -1,4 +1,4 @@ - Renewal is the only part of this system that changes the world. Everything else @@ -34,6 +34,15 @@ weekly rate limit. A certificate with no CA account — anything discovered rath than issued — is refused with **400** at this point rather than becoming a job that fails forever. +So is a certificate whose private key CertPilot does not hold, again with +**400**. When `key_custody` is `AGENT`, the refusal names the agent and says how +to renew it on that host (`certpilot-agent request --name …`). When it is +`EXTERNAL`, it asks for a new signing request from whoever holds the key. A +renewal here always ends with the gateway generating a fresh keypair. For these +certificates, CertPilot would then hold a key it promised never to hold, and +the record would describe a certificate the key holder is not serving. A job +for one that was queued some other way is cancelled, not retried. + ## Where a job stands ```json @@ -137,7 +146,9 @@ true because the next call failed. The advice overrides the configured lead time in both directions — it can bring a renewal forward and hold one back — but **never past a seven-day safety -floor**. Inside that window a certificate renews regardless of what the CA +floor**, or past a third of the certificate's lifetime when that is shorter. A +six-day certificate is inside seven days from the moment it is issued, so an +unbounded floor would renew it on every sweep whatever the CA advised. Inside that window a certificate renews regardless of what the CA suggested, so a bad window, or a stale one left by a poller that stopped running, cannot defer something about to expire.