Add the registrar provisioning subcommand (#989) - #990
Conversation
A deployment tool has to know that bootroot's registrar surface exists, and has to obtain the credential that reaches it, before the host-local endpoint is of any use. Neither was possible: the endpoint shipped, but nothing on the CLI reported it and nothing minted the registrar's own client leaf, so an install could only defer the registrar and roxyd's certificate could not be issued on a real host. Two of the caller's pinned expectations contradict contracts this repository owns, and both are resolved in this repository's favour, because the identity rule and the socket are bootroot's to define. The identity is composed here from the host label and the domain, at the v1 instance label and under REGISTRAR_CLIENT_LABEL. No flag on the surface accepts a composed name: a caller-supplied name is a name that can disagree with what recognize_registrar_client accepts, and there is then no single place that decides what the identity is. The response reports what was composed, so the caller learns the identity rather than asserting it. The socket pathname is read from the installed socket unit, searched in systemd's own unit-directory precedence order, and falls back to the unit this repository ships — embedded at compile time, so the reported value cannot drift from the file an operator installs. No configuration key names the endpoint's path and none is added; the daemon still learns its own from the descriptor systemd hands it, and the answer has to stand while nothing is running. Issuance runs into a staging directory below the secrets tree and publishes only once every byte is in hand, so a run that fails part-way leaves no half-written pair at the caller's paths. That directory is named per process rather than at one fixed path: two invocations racing below the same secrets tree would otherwise read back each other's material, and one run's leaf published beside another's key is a pair that is neither half-written nor usable, so nothing downstream would say so. Its ACME inputs come from the rendered bootroot-internal registrar configuration, which keeps the verb credential-free: a provisioning tool drives it as root on the bootroot host with no OpenBao token in hand. Closes #989
`registrar issue` reads its ACME inputs through `load_internal_config`, which cannot tell an absent configuration from a malformed one: a config source that is not there deserializes as an empty one, so a host that `bootroot init` has never provisioned was reported as a file that "expected exactly one profile, found 0" — a diagnostic about the contents of a file that does not exist. That is the state a provisioning tool finds when it probes before the endpoint has been enabled, which makes it the one refusal on this surface a caller most needs to act on. Absence is checked here, ahead of the load, so the two stay distinguishable without changing a loader that the daemon, rotate and status also read through. Cover the EAB the issuance registers its account with, too. The verb drops `[eab]` from the settings it builds and passes the credentials as an argument, and nothing asserted the argument arrived: an account registration against an EAB-requiring step-ca would have been rejected on a real host while every existing assertion still passed. Part of #989
CodeQL's `rust/cleartext-logging` heuristic follows any identifier containing `secret` into a formatting sink, so the sweep's warning tripped it on the staging path derived from `--secrets-dir`. The path is the point of that warning — it names the directory whose unpublished key an operator has to remove by hand — so record why the sink is safe rather than dropping it, in the same form the other suppressed sinks in this crate use. Part of #989
|
[Reviewer Round 1] Changes requested — I found three correctness blockers.
PR hygiene is otherwise sound: PR #990 closes #989, includes a test plan, and its visible discussion does not show an author-created issue. |
|
[Review Verdict Round 1: NOT_APPROVED] |
`capabilities` read `ListenStream=` off one unit file and passed over a higher-precedence unit that bound nothing. Both diverge from what the host actually binds: a `systemctl edit` override resets the value in a `.d` drop-in the search never opened, and systemd loads exactly one unit file rather than falling through to a packaged one. A provisioning tool that trusted the answer would connect to a socket that is not there. The unit directories are now searched for the one unit file systemd would load, the `*.conf` drop-ins from every unit directory are merged on top of it in systemd's own dedup-and-sort order, and an installed unit that binds nothing once merged is a refusal. Only a host carrying no unit file at all still falls back to the shipped one; a unit named through `--socket-unit` is still the whole answer, since it is not a unit systemd has loaded. `issue` also held the three output destinations to nothing. Passing one path for `--cert-path` and `--key-path` published the certificate and then overwrote it with the private key, and exited zero; a `--key-path` on the derived `ca-bundle.pem` published a private key `0644` into the deployment's trust store. They are now held to being distinct files, compared on the absolute path with the containing directory resolved, before an order is placed. Publication was three independent writes with staging in front of them, which kept a failed *issuance* from reaching the caller's paths but not a failed *publication*: a key write failing after the certificate write succeeded left the new leaf beside the previous key, which the issue's "never leaves a half-written pair" forbids. Every destination is now read back before the first is replaced and put back at the mode it had when a step fails, with a first provisioning's files removed instead. Part of #989
|
[Author Round 1] CI is fully green on Round 1 dispositionAll three findings were valid; all three are Fixed. [P1] [P1] Output paths are not validated as distinct — Fixed. [P1] Staging does not make final publication failure-safe — Fixed. Docs updated: |
|
[Reviewer Round 2] Review verdict: not approved. Round 1 status:
PR linkage and test-plan hygiene are otherwise sound. |
|
[Review Verdict Round 2: NOT_APPROVED] |
A failed publication put the previous bytes back through this surface's own writers, so a certificate an operator had tightened to 0640 came back world-readable and a key another account owned came back owned by the process whose run failed. That is a change, not a restoration, and the surface documents a restoration. The snapshot now captures the permission bits and the uid and gid beside the contents, and the rollback republishes at the captured mode — which lands with the rename, so the destination is never observable wider than the mode it is going back to — and then re-establishes the ownership, skipping a chown that would change nothing. A chown that is refused strands the path in the diagnostic that already names the files to check by hand. Part of #989
|
[Author Round 2] CI is fully green on The Round 2 disposition stands as reported: the single P1 was valid and is Fixed — the rollback snapshot now captures each destination's mode and ownership beside its bytes, restores the captured mode with the rename (never observable wider than the mode it returns to) and re-establishes the uid/gid afterwards, with a refused |
|
[Reviewer Round 3] Review verdict: not approved. Round 2’s prior rollback finding is resolved: the updated snapshot restores normal modes and uid/gid.
|
|
[Review Verdict Round 3: NOT_APPROVED] |
The rollback a failed publication runs answers only for the run that owns it. Two `registrar issue` runs against the same paths can each succeed and still leave one run's certificate beside the other's key: A publishes its certificate, B publishes its certificate and key, A publishes its key. Neither failed, so neither has anything to put back, and nothing on disk records it. They are separate processes, so the process-wide bundle lock does not reach them. A publication now holds an exclusive flock on a lock file in each directory it writes into, from before the first snapshot until after the last write or restore, taken on the resolved directory and in sorted order so two runs cannot each hold what the other waits for. The issuance ahead of it stays unlocked: it writes only into its own per-process staging directory, and holding a lock across an ACME round trip would make one run wait out another's network exchange. The rollback also lost setuid, setgid and the sticky bit. It carried the whole mode through the rename and re-owned the file afterwards, and chown(2) clears those three, so a file that carried one came back without it while the restore reported success. They are re-applied after the ownership instead, which also keeps a setuid bit off the file while it is still owned by the run that failed. Part of #989
|
[Author Round 3] CI is fully green on Round 3 dispositionBoth findings were valid; both are Fixed. [P1] Concurrent runs can interleave into a mismatched pair — Fixed. The requested contention test is [P2] The rollback loses setuid/setgid/sticky — Fixed. Docs updated: reference §3.4 and a new §3.5, the mirrored |
|
[Reviewer Round 4] Changes requested — one remaining blocker.
The prior Round 3 items themselves are resolved: CLI-to-CLI publication is now serialized, and rollback correctly reapplies special permission bits after ownership restoration. PR linkage and test-plan hygiene are sound. |
|
[Review Verdict Round 4: NOT_APPROVED] |
The lock a publication of the registrar client leaf takes reached two runs of `registrar issue` and nothing else, but the CLI is not the only writer of that leaf. The daemon issues the same pair at start-up and renews it afterwards, at the `[registrar_endpoint]` paths a provisioning tool points this verb at -- pointing them at one file is the whole point of provisioning a credential the daemon then maintains. So a CLI run could still publish its certificate between the daemon's certificate and its key: both report success, the pair on disk matches neither, and neither rollback has anything to put back, because neither run failed. The lock moves into the library and all three writers take it, at the destinations each of them replaces. The daemon's start-time issuance reaches the ACME exchange and the publication as two steps rather than one call so the lock can cover the second and not the first, which is the rule the CLI already followed and the reason a stalled exchange cannot hold the lock. Its renewal takes the lock outside the bundle lock, and that is the one order every writer takes the two in, so no pair of them can each hold what the other waits for. Part of #989
|
[Author Round 4] Waiting on both. Here's where things stand while they run: Round 4 disposition — the single P1 was valid and is Fixed. The reviewer was right, and the finding was specific: the Round 3 lock served two What changed:
Four regression tests, all asserted from inside the publication with a non-blocking
Docs updated: reference §3.5 rewritten around three writers rather than two runs, the mirrored One verification note I'll confirm when the runs land: the Linux-only renewal module cannot build on this Mac, so I compiled, linted ( |
|
[Reviewer Round 5] [Reviewer Round 5] Changes requested — one blocker.
The prior Round 4 publication-lock finding is resolved: startup issuance and renewal now acquire the shared cross-process lock, and the tests verify it remains held throughout publication and rollback. PR linkage and test-plan hygiene are sound. |
|
[Review Verdict Round 5: NOT_APPROVED] |
capabilities read only the unit's own bootroot-registrar.socket.d, which is not the whole set systemd merges. A top-level socket.d/*.conf that resets and rebinds ListenStream= moves this host's socket while the verb kept answering the unit-local path, and a provisioning tool that trusts the answer connects to a socket nothing is listening on. The dash-truncated bootroot-.socket.d, which systemd derives from the unit's name, was missed for the same reason. Both are now resolved in systemd's own order: each unit directory contributes the unit's name and then its dash-truncated prefixes, and the type-wide directories come last across all of them, because that one is the most generic and is meant to lose to anything named after the unit. The prefix hierarchy is derived from the unit name rather than spelled out, so it stays right if the unit is ever renamed. A drop-in is no longer filtered by file type either. systemd's own way of cancelling one for a single unit is a symlink to /dev/null of the same name higher up, which masks by occupying the name; skipping it let the copy below apply instead. It now claims its name in the merge and contributes nothing, which is what makes a type-wide drop-in cancellable for this unit alone. Part of #989
|
[Author Round 5] CI is running; I'll report back when it lands. Meanwhile, the round 5 disposition: [P1] The reviewer was right, and the previous rationale ("a I checked the behaviour against systemd's own sources rather than from memory (
Seven tests added; six fail against the previous implementation (the type-wide rebind, one below the unit, filename ordering across the two, and both Docs updated: reference §2 (the paragraph that declared Local verification: |
|
[Reviewer Round 6] [Reviewer Round 6] Changes requested — one blocker.
The Round 5 systemd finding is resolved: the updated resolver includes both |
|
[Review Verdict Round 6: NOT_APPROVED] |
The distinctness check canonicalised only the destination's immediate parent, so two paths that alias solely through a directory neither carries yet were compared as typed. With a symlinked ancestor, that lets `--cert-path <link>/new/registrar.pem` and `--key-path <target>/new/registrar.pem` past the check; the publication then creates `new/` through the link and the key overwrites the certificate at the one file they always shared, with the run reporting success. Resolve the nearest ancestor that does exist and apply the remaining components to it. Applying them lexically is sound because they are not there: a directory that does not exist is not a symlink, so a `..` past it can only mean the level above. Part of #989
|
[Author Round 6] CI is fully green on Round 6 disposition[P1] Distinct-output validation misses aliases through a symlinked ancestor that does not yet exist — Fixed. The reviewer was right and the reproduction was exact.
I checked the sibling resolution in Four tests added, in
The first two fail against the previous implementation ( Docs updated: reference §3.3, and the mirrored Local verification: |
|
[Reviewer Round 7] [Reviewer Round 7] Approved — no unresolved findings. The Round 6 P1 is resolved: The updated implementation meets the issue contract, including the composed identity, socket-unit discovery, atomic/reversible publication, shared daemon/CLI publication locking, and meaningful integration coverage. PR linkage and test plan are sound. |
|
[Review Verdict Round 7: APPROVED] |
Suggested squash commitTitle Body |
Adds
bootroot registrar, the provisioning surface a deployment tool drives before the host-local registrar endpoint is of any use. The endpoint itself already ships; what did not was any way for an installer to learn that it exists, or to obtain the client credential that reaches it — so an install could only defer the registrar, and roxyd's certificate could not be minted on a real host.Both of the caller's pinned expectations that contradict contracts this repository owns are resolved in this repository's favour, because the identity rule and the socket are bootroot's to define.
Changes
registrar capabilities [--json] [--socket-unit <path>]— read-only, reportingapi_version(bootroot.registrar.v1),socket_pathandverbs(registrar.issue,registrar.mint,registrar.deregister, in that order). It describes the surface rather than the runtime, so it answers with the endpoint disabled and with nothing running. The pathname is the installed socket unit's effectiveListenStream=, resolved the way systemd resolves it: the unit directories are searched in systemd's own precedence order so an operator-installed unit outranks a packaged one, the first unit file found masks the ones below it, and every drop-in systemd would apply to that unit is merged on top, from every unit directory — the unit's ownbootroot-registrar.socket.d/*.conf, wheresystemctl editwrites its override; the dash-truncatedbootroot-.socket.d/*.confsystemd derives from the unit's name; and the type-widesocket.d/*.confthat alters every socket unit on the host. Drop-ins of the same filename are one drop-in, the more specific directory winning and a name-specific one outranking the type-wide one, and what is left applies sorted by filename wherever it came from. A drop-in symlinked to/dev/nullis systemd's own mask: it takes the name out of the merge without contributing a setting, which is how a type-wide drop-in is cancelled for this unit alone. Reading less than this would report the unit-local path while systemd bound another, and the caller would connect to a socket that is not there. With no unit installed anywhere it falls back to the unit this repository ships, embedded at compile time so the reported value cannot drift from the file an operator installs. A unit named through--socket-unitis authoritative and is not extended by drop-ins: one that cannot be read, or that binds nothing, is a refusal rather than a fall-through, as is an installed unit that binds nothing once its drop-ins are merged.registrar issue --host <label> --domain <domain> --cert-path <p> --key-path <p> [--secrets-dir <p>] [--json]— issues the registrar's client leaf into the caller's paths with the CA bundle as the certificate path's sibling, at the modesservice addestablishes, and answersapi_version,identityandnot_after.REGISTRAR_CLIENT_LABELat the v1 instance label. No flag on the surface accepts a composed name, so the certificate andrecognize_registrar_clientcannot disagree; the response reports what was composed, so the caller learns the identity rather than asserting it.--secrets-dir, which keeps the verb credential-free — a provisioning tool drives it as root with no OpenBao token in hand. A hostbootroot inithas never provisioned is refused by naming the file that is not there, distinctly from a configuration that is present and malformed.--cert-path,--key-pathand theca-bundle.pemderived beside the certificate — are held to being distinct files before anything is issued. Two of them naming one file would publish one and then overwrite it with another while reporting success; two spellings of one file are one destination. A directory that does not exist yet is resolved as well, because the publication creates it — every missing level, through whatever symlink the path traverses on the way: the nearest ancestor that does exist is resolved and the components below it are applied to that, so with/srv/linka symlink to/srv/realand neither carrying anew/,--cert-path /srv/link/new/registrar.pemand--key-path /srv/real/new/registrar.pemare the one file they would become and are refused rather than compared as typed. Applying the unborn components without following them is sound precisely because they are not there: a directory that does not exist is not a symlink.0640does not come back world-readable because that is the mode this verb writes at, and a key another account owned does not come back owned by the run that failed. setuid, setgid and the sticky bit ride the restore too, applied after the ownership rather than with the rename, becausechown(2)clears them — restoring the bits first and re-owning the file afterwards loses them silently. Without any of it, a key write failing after the certificate write succeeded would leave the new leaf beside the previous key: a pair that is complete, readable and useless, with nothing on disk saying so. A rollback that cannot itself complete — including one whosechownan unprivileged run is refused — names the files to check by hand and does not replace the reason the run failed.flock(2)on.bootroot-registrar-publish.lockin each directory it writes into, from before it reads the destinations back until after its last write or restore. Without it, one writer publishing the certificate while another publishes the certificate and key, before the first publishes its key, leaves one leaf beside the other's key with both reporting success. The lock is taken on the resolved directory, so two spellings of one directory are one lock, and the directories are taken in sorted order, so two writers naming the same ones in different roles cannot each hold what the other waits for. The lock file is empty, created0600, and left in place — unlinking it would hand the next two runs an inode each — and the kernel releases it when the descriptor closes, so a run killed mid-publication strands nothing. A destination named after it is refused. The issuance ahead of the publication stays unlocked: it writes only into the run's own staging directory, and holding a lock across an ACME round trip would make one writer wait out another's network exchange.[registrar_endpoint] client_cert_path/client_key_path, and the daemon's renewal of that same leaf. A deployment points this verb at the paths the daemon is configured with — that is the point of provisioning the credential the daemon then maintains — so a CLI-only lock leaves the daemon free to publish between this run's certificate and its key, and both report success over a pair that matches neither. The lock therefore lives in the library (src/publication_lock.rs) and all three take it, at the destinations each writes. The daemon's start-time issuance reaches the ACME exchange and the publication as separate steps so the lock can cover the second and not the first; its renewal takes the lock outsideca_bundle_lock, which is the one order every writer takes the two in, so no pair of them can each hold what the other waits for.Neither verb changes what the daemon does. It still learns its own socket from the descriptor systemd hands it, no configuration key names one, and no part of mint, deregister or renewal is reimplemented here — the daemon's only change is that its two publications of this same material now take the shared publication lock, which is what stops a run of this verb interleaving with one of theirs.
Documentation lands as the cross-repository contract in
docs/reference/registrar-provisioning-surface.md, plus the operator-facing entries in the mirroreddocs/en/cli.mdanddocs/ko/cli.md.Closes #989
Test plan
cargo fmt -- --check --config group_imports=StdExternalCrateandcargo clippy --all-targets -- -D warningsare clean, on macOS and in a Linux container (where the Linux-only endpoint code compiles).capabilities --jsonanswers the three fields, withsocket_pathequal to a fixture socket unit'sListenStream=rather than a literal in the test.systemctl edit-style drop-in override is merged; drop-ins below the unit still apply; they apply in filename order across directories; a same-named drop-in higher up masks a lower one; a non-.confentry is ignored; a named unit is not extended by drop-ins; an installed unit binding nothing masks the ones below it; a drop-in that clears the list is a refusal.socket.d/and the dash-truncatedbootroot-.socket.d/are resolved too: a type-wide drop-in that resets and rebindsListenStream=is reported, one below the unit still applies, it is ordered among the rest by filename, and the more specific directory wins an equally named file. The derived directory names follow systemd's prefix hierarchy — including the trailing-dash and leading-dash cases — and the full search order is asserted against systemd's own. A drop-in symlinked to/dev/nullmasks the copy below it, both against a type-wide file and against a lower-precedence unit-specific one. Six of these fail against the previous implementation.--socket-unitthat cannot be read, and one binding nothing, are refusals.capabilitiesanswers with the endpoint disabled and no daemon running, and independently of configuration.verbslists the three wire names in the fixed order, and a surface missing one fails the test rather than answering a shorter list.issuewrites the certificate (0644), the key (0600) and the sibling CA bundle (0644) at the caller's paths.recognize_registrar_clientfor that domain and refused for another.identityis the composed name;not_afterparses as RFC 3339 and lies in the future.--hostis refused.ca-bundle.pem, and two spellings of one file.<link>/new/registrar.pemagainst<target>/new/registrar.pem— are refused, as is a--key-pathlanding on the derived bundle the same way; two genuinely distinct destinations below unborn directories are still accepted, and the unborn tail is normalised (..included) onto the resolved ancestor. The two aliasing tests fail against the previous implementation.0640certificate, a0400key and a0600bundle; the snapshot captures the ownership beside the mode and the restore re-establishes it, skipping thechownthat would change nothing and reporting the one it is refused.04600file restored under a gid the process can grant comes back setuid, where restoring the bits with the rename and re-owning afterwards loses it. The test fails against the previous implementation.rust:latestcontainer:cargo clippy --all-targets -- -D warningsclean, and everyregistrar_renewaltest plus the newpublication_lockandregistrar_certsones pass../scripts/check-docs.shbuilds the manual with--strict, andmarkdownlint-cli2is clean.