Skip to content

feat(nebula): CA rotation, the certificate audit, and pb-nebula v0.3's host flags - #5

Merged
skeeeon merged 1 commit into
mainfrom
feat/nebula-v0.3
Sep 13, 2026
Merged

skeeeon merged 1 commit into
mainfrom
feat/nebula-v0.3

Conversation

@skeeeon

@skeeeon skeeeon commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Catches the CLI up to pb-nebula v0.3 and the two routes the platform put in front of it (stone-age-io/platform#10).

stone nebula — two operations that are not record writes

stone nebula cert-audit                        # hosts whose certificate no longer matches their network
stone nebula ca-rotate prepare|commit|finish   # roll the org's CA

ca-rotate is a route because nebula_ca.updateRule is operator-only. Permitting the trigger through an update rule would mean a deny-list over the certificate, the private key, the incoming and outgoing CA material and the expiry — a deny-list that silently opens the moment anyone adds a field, on the record holding the trust anchor for the tenant's whole mesh. Same reason stone nats account-keys exists.

Three steps, and the wait between them is the feature. Nebula verification is mutual — each peer checks the other against its own local CA pool, with no chain and no fallback — and hosts pull their config whenever they like. So one write carrying both the new trust bundle and the new certificate splits the mesh: a host that has fetched presents a new-CA certificate to one that has not, and the handshake fails in both directions until propagation finishes. The long help repeats this per step rather than only on the parent, because the step someone is about to run is the one they are reading.

cert-audit is a route for a different reason. Deciding whether a host certificate still matches its network means parsing a Nebula certificate, which no client can do. It answers in ids; cmd/nebula.go resolves those to hostnames, falling back to the bare id rather than dropping the row — a host we cannot name is still a host that needs re-signing.

There is deliberately no bulk re-issue verb. Re-signing moves a certificate's fingerprint, and a fingerprint is what the revocation blocklist matches, so a sweep rewrites every peer config in the mesh. The help says to go one at a time.

Seven new nebula-host flags

--is-relay, --unsafe-networks, --unsafe-routes, --preferred-ranges, --mtu, --tun-device, --renew. is_relay joins the ls columns.

--unsafe-networks and --unsafe-routes are two halves of one feature on different hosts: the first is signed into the gateway's certificate and authorizes it to route that subnet, the second goes on each host that wants to reach it. Neither derives the other, and setting one alone moves no traffic — the help says so.

routeOnlyFields, the half of the drift guard that was missing

The existing tests catch a flag for a field that does not exist. That is how nebula-ca --rotate-keys was found — a flag against a field with no home anywhere, reporting success on every run because PocketBase discards writes to fields a collection lacks.

They cannot catch the adjacent case: a flag for a field that does exist and that a tenant may not write. That one 404s at the rule layer, on a command that looks exactly like every other update. nebula_ca.rotate is precisely that shape now that it is real, and the nats_accounts signing-key triggers have been in that position all along with only a prose note guarding them.

routeOnlyFields names those fields with the reason and the command to use instead. I confirmed it fails when handed a --rotate flag before removing the probe.

libraryOwnedFields gains pb-nebula's additions. Worth noting it behaves better than the pb-nats case documented beside it: pb-nebula migrates missing fields (addMissingFields), so a database created before v0.3.0 acquires them on the next start rather than never.

Version floor

pb-nebula v0.3.0; the platform pins v0.3.2. Against v0.2.0 the two routes 404 and all seven flags name fields the collection does not have — so the write is discarded and the command reports success. That is exactly the failure the drift guard exists to make loud, and it is the one case it cannot catch, because the vendored schema is a copy of a file that never declared these fields either. Written down in CLAUDE.md, README and SKILLS rather than left to be rediscovered.

Also fixed

Every error the CLI has ever printed ended in (0). PocketBase sends the status as status; PBError only read code, which nothing populates. So the number in every error message was zero, and the distinction that matters most — a 400 from a validator versus a 404 from an update rule — was invisible:

before:  error: Invalid CA rotation: nothing to finish; ... (0)
after:   error: Invalid CA rotation: nothing to finish; ... (400)
         error: The requested resource wasn't found. (404)

Both names are read now, with the HTTP status as a fallback.

Verification

Against a live platform on pb-nebula v0.3.2:

  • A full prepare → commit → finish through the CLI, with the server's refusals arriving intact — out-of-order steps, a second prepare, and the named-host interlock on finish.
  • cert-audit flagging both hosts after a network was widened, clearing per host on --renew, and reporting nothing once every host was re-signed. Also confirmed it never lists an inactive host: re-signing one would publish a new fingerprint while the old certificate stayed valid and unblocklisted.
  • The new flags round-tripping into the generated Nebula config, and pb-nebula's validation messages surviving to the terminal (Invalid preferred range: 172.16.0.5/24 has host bits set, did you mean 172.16.0.0/24?).
  • gofmt, go vet, go test ./... clean.

🤖 Generated with Claude Code

…s host flags

pb-nebula v0.3.0 grew a rotation trigger and twelve fields; the platform put two
routes in front of them. This catches the CLI up to both.

STONE NEBULA CA-ROTATE. Three steps, because Nebula verification is mutual and
hosts pull their config on their own schedule -- one write carrying both the new
trust bundle and the new certificate splits the mesh until propagation finishes.
The long help says so at each step rather than only at the parent, because the
step someone is about to run is the one they are reading.

It is a route because nebula_ca.updateRule is operator-only: permitting the
trigger through an update rule means a deny-list over the certificate, the
private key and the rest of the CA material, on the record holding the trust
anchor for the whole mesh. Same reason as `stone nats account-keys`.

STONE NEBULA CERT-AUDIT exists for a different reason -- deciding whether a host
certificate still matches its network means parsing a Nebula certificate, which
no client can do. The route answers in ids; this resolves them to hostnames, and
falls back to the bare id rather than dropping a row, because a host we cannot
name is still a host that needs re-signing.

No bulk re-issue verb, deliberately. Re-signing moves a fingerprint and the
revocation blocklist matches on fingerprints, so a sweep rewrites every peer
config in the mesh. The help says to go one at a time.

SEVEN NEW HOST FLAGS: --is-relay, --unsafe-networks, --unsafe-routes,
--preferred-ranges, --mtu, --tun-device, --renew. is_relay joins the ls columns.

ROUTEONLYFIELDS is the new half of the drift guard. The existing tests catch a
flag for a field that does not exist -- which is how `nebula-ca --rotate-keys`
was found. They cannot catch a flag for a field that DOES exist and that a
tenant may not write: that one 404s at the rule layer, on a command that looks
like every other update. `nebula_ca.rotate` is exactly that shape now, and the
nats_accounts signing-key triggers have been in that position all along with
only a prose note guarding them. Verified the guard fails when given a --rotate
flag before removing the probe.

libraryOwnedFields gains pb-nebula's additions. Worth noting it behaves better
than the pb-nats case documented beside it: pb-nebula MIGRATES missing fields, so
a database created before v0.3.0 acquires them on the next start rather than
never. The floor is v0.3.0, and below it every flag here is a silent no-op --
which the vendored schema cannot catch, because it is a copy of a file that
never declared these fields either.

ALSO FIXED: every error the CLI printed ended in "(0)". PocketBase sends the
status as `status`; PBError only read `code`, which nothing populates. The
distinction that matters most -- 400 from a validator versus 404 from an update
rule -- was invisible. Both are read now, with the HTTP status as a fallback.

Verified against a live platform on pb-nebula v0.3.2: a full prepare -> commit ->
finish through the CLI with the out-of-order refusals coming back intact, the
audit flagging both hosts after a network widened and clearing per host on
--renew, and the new flags round-tripping into the generated config.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@skeeeon
skeeeon merged commit 0150480 into main Sep 13, 2026
1 check passed
@skeeeon
skeeeon deleted the feat/nebula-v0.3 branch September 13, 2026 01:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant