Probe the audit store's capacity and report it on the endpoint (#774) - #965
Conversation
A reservation with no measurement turns a slow fill into a sudden outage on the one host that must not be restarted, and a look-back window that has quietly shrunk is invisible. Both audit artifacts are fail-closed inputs to a live security argument, so a ceiling nobody is watching is a ceiling that announces itself only by refusing every enrollment. The daemon now measures the store on the rotation loop's existing maintenance tick and reports what it found in every response's registrar_health.audit_capacity member, on refusals as well as successes -- the store is a fail-closed control, so a success-only signal would stop carrying the alarm in exactly the state the alarm exists to announce. Both halves of the headroom come from one O_NOFOLLOW open of the store root, because two numbers only describe one object if they were derived from one resolution of the configured path. Usage is per mode: filesystem mode takes it from the same fstatvfs, directory mode walks the store by descriptor. The walk descends with openat/fdopendir rather than by path because openbao/ is written by the container's uid, so a directory replaced under an active walk is an attacker-reachable event rather than a hypothetical one; a followed link to / would sum the root filesystem into used_bytes and refuse every verb. It is deliberately a second implementation of "sum a subtree's allocated blocks". measure_underlying is path-based, runs once under an operator's init on a quiet store and fails on overflow; this one runs unattended every minute against a live, partly attacker-writable subtree and saturates instead. The maintenance callback becomes async so the walk and the record scan run under spawn_blocking with their handles awaited inside the tick. No second scheduler, interval or long-lived task. audit_store_low_water_bytes = 0 is refused at load: at zero the alarm band is empty, so the state machine would step straight from exhausted to ok and the alarm would never fire. Part of #774
The acceptance criterion pins the symbolic-link refusal at the store root in filesystem mode as well as directory mode, because there the root open is the only step that can refuse it -- there is no walk behind it to catch the link later. The tick test drove one mode, so the mode with the thinner defence was the untested one. The state it asserts before the failure is no longer `ok`: in filesystem mode a tempdir's usage is the whole host filesystem's, which the synthetic reserve is smaller than, so what matters is that some state was reached and stamped for the failure to preserve. Also drops a self-cancelling term from the walk's tracking assertion, which subtracted the previous reading back out of the store root's own allocation and would have underflowed had that allocation ever shrunk. Part of #774
The walk took a subdirectory's device, inode and block count from the fstatat that decided to open it, and never stat-ed the descriptor it then descended through. That leaves the object counted and the object traversed as two resolutions of one name, which is the thing descending by descriptor exists to rule out: openbao/ is written by the container's uid, so an entry replaced between those two calls is an attacker reachable event, and the same-device rule and the seen-inode set were both being decided on the entry that was no longer there. Directories are now accounted for from the fstat on the descriptor the walk holds open, and a failure of that stat is a failed probe -- an fstat on a live descriptor cannot report a vanished entry, so there is no race to carve out. Non-directories keep being classified and counted from their own fstatat, since nothing opens them. The seam's stat-an-open-directory operation had only the store root reaching it, so a test drives it on an enumerated subdirectory too: nothing else in the walk makes that call, and the injection can only fire if the rule holds. Part of #774
b933c90 to
c68de8a
Compare
|
[Reviewer Round 1] Changes requested.
|
|
[Review Verdict Round 1: NOT_APPROVED] |
The reviewer is right that the no-per-request-scan guarantee was unprotected for mint. `Operation::Mint` is refused at `requested_spec` with no response bytes, so no payload reaches the mint encoder through `handle` and no round trip can assert it; the golden fixture proves only that the codec carries the member. Cover it over the two halves the arm is made of instead. Both response arms now take the snapshot from one private accessor, so the request path has a single source for it, and the new test drives that accessor into the production mint encoder. The proof that no scan ran is not that the numbers look untouched: the reader is run against the handler's own store in the same test and returns a different count from the one the response carried. A companion source-level test reads this build's `mint` and `deregister` bodies and pins that each takes its health from the accessor, reaches the holder through nothing else, and that the module names no store reader at all. That is what catches a scan added to the mint arm, which no round trip on this build could. Part of #774
|
[Author Round 1] Pushed as Review item dispositions1.
|
|
[Reviewer Round 2] Changes requested.
The prior mint-path test concern is resolved: the shared |
|
[Review Verdict Round 2: NOT_APPROVED] |
|
[Author Round 2] Verified with a re-read: the body now carries one direct declaration for #774, with #775 as prose. Review item dispositions — Round 21. PR #965 body — remove the direct
|
|
[Reviewer Round 3] Changes requested.
The mint-path test concern remains resolved, and I found no additional code or issue-coverage defects in the updated diff. |
|
[Review Verdict Round 3: NOT_APPROVED] |
|
[Author Round 3] Body edited on PR #965; nothing else touched. DispositionConflicting-declaration item — Fixed (per operator resolution). The PR body's direct declarations are now exactly the two required ones, each on its own line at the top:
I kept an explanatory sentence, reworded so it no longer contradicts the new lines (the old one asserted the #775 linkage was not declared directly, which is now false). It reads: "#774 is the issue this pull request delivers; #775 is the umbrella it hangs under, and the reference to it is inherited through #774 rather than being work this pull request implements directly." Everything below that paragraph is byte-identical to the previous body (verified by diff — the only delta is GitHub's trailing newline). No code change, no new CI run, as instructed. The working tree is unchanged, head stays I understand this override applies to this run only and only to the direct-declaration point; the rest of the create-PR instruction remains in force. COMPLETED |
|
[Reviewer Round 4] [Reviewer Round 4] Approved — no unresolved items or new findings. The prior PR-linkage finding is resolved: the body now directly declares both |
|
[Review Verdict Round 4: APPROVED] |
Suggested squash commitTitle Body |
Part of #774
Part of #775
#774 is the issue this pull request delivers; #775 is the umbrella it hangs under, and the reference to it is inherited through #774 rather than being work this pull request implements directly.
What this adds
An endpoint-enabled daemon now measures the reserved audit store on the rotation loop's existing maintenance tick and reports what it found in every response's
registrar_health.audit_capacitymember — on refusals as well as successes, because the store is a fail-closed control and a success-only signal would stop carrying the alarm in exactly the state the alarm exists to announce.The probe —
src/registrar/audit_store/capacity.rs(new)Both halves of the headroom come from one
O_NOFOLLOW | O_DIRECTORY | O_RDONLY | O_CLOEXECopen ofaudit_store_dir, in both enforcement modes. A pathnamestatvfsbeside a separately opened walk root resolves the configured path twice and nothing holds the two resolutions to the same directory — and it would quietly report a planted symlink's target. A link at the store root is anELOOPfrom that open and a failed probe in both modes.Usage is measured per mode, because
statvfsdescribes a filesystem and not a subtree:filesystem—(f_blocks - f_bfree) * f_frsizefrom the samefstatvfson the same descriptor. Nothing is enumerated.directory— the store is walked, summingst_blocks * 512.Available bytes are
f_bavail * f_frsizein both modes, neverf_bfree, which counts blocks reserved for root that neither writer can reach.The walk descends by descriptor —
openat/fdopendir/readdir/fstat/fstatat/closedir— rather than by path, becauseopenbao/is written by the OpenBao container's uid by design, so a directory replaced under an active walk is an attacker-reachable event and not a hypothetical one. It skips.and..by name before anyfstatat,openator accounting; never opens a non-directory; stays on one device; counts each(st_dev, st_ino)once; counts each directory's own blocks including the store root; distinguishes end-of-directory from areaddirerror by zeroingerrnofirst; and fails the probe on every error except an entry that vanished betweenreaddirand the call that followed it, which is the ordinary rotation race.The descriptor has exactly one owner at every point.
fdopendirreceives it byinto_raw_fd(), a null return reconstructs theOwnedFdand closes it before propagating, and after a successfulfdopendirclosediris the only close. Everyunsafeblock wraps one call and carries a// SAFETY:comment.This is deliberately a second implementation of "sum a subtree's allocated blocks".
measure_underlyinginsrc/commands/audit_store/reserve.rsis unchanged and thebootroot initreserve preflight is not rewired: that one is path-based, runs once under an operator'siniton a quiet store and fails on arithmetic overflow, while this one runs unattended every minute against a live, partly attacker-writable subtree and saturates instead.The alarm
headroom_bytes = min(reserve - used, available), computed with checked conversions and an explicit clamp — noascast between theu64inputs and thei64result, and every block-count product saturates atu64::MAXrather than wrapping or failing the probe.next_stateapplies the three ordered rules exactly: non-positive headroom isexhausted; a return from an alarm clears only atthreshold + margin; otherwiseokstrictly above the threshold.margin = max(low_water / 10, 1 MiB), and the clear sum is a saturating add.The wiring
run_rotation_loop_with_maintenance's callback becomes async, awaited in the sametokio::select!branch body that already awaitsrotation.run_pass(...). The capacity probe andscan_audit_store_off_runtimeboth run underspawn_blockingwith their handles awaited inside the tick. No second scheduler, interval or long-lived task; no new health holder;ROTATION_INTERVALunchanged, someasured_atandrecords_measured_atare never more than about a minute old in a healthy daemon.The three record signals are read from
scan_audit_storewithAUDIT_SCAN_WINDOWand never re-derived, so the relayed values and whatbootroot statusprints host-locally cannot drift.On the response side, all three arms — mint success, deregistration success and refusal — take the snapshot from one private accessor,
ProductionHandler::health_snapshot, so the request path has a single source for it and nothing else reaches the holder.The wire
RegistrarHealthgainsaudit_capacity, appended afterlimiter, whose shape, contents and ordering are untouched. Optional members are omitted rather than emitted asnulland an explicitnullis refused on decode; both timestamps are RFC 3339 UTCZstrings.AuditStoreEnforcementgainedSerializerather than a second enum being declared for the wire. All five golden fixtures that carry the container were extended additively; the pre-registraraudit_unwritablerefusal still serializes"registrar_health":{}.Configuration
audit_store_low_water_bytes = 0is now refused at load with a diagnostic naming the key: at zero the low-water band is empty, so the state machine would step straight fromexhaustedtookand the alarm this key exists to raise could never fire. No key was added and no other key's validation changed.Documentation
A new "Watching the reserve fill" section in
docs/en/operations.mdanddocs/ko/operations.mdcovers the thresholds, the fourstatevalues, the hysteresis, the two timestamps and what bounds their staleness; theaudit_store_low_water_bytesentry in bothconfiguration.mdpages is updated;docs/reference/registrar-wire-contract.mddocuments theaudit_capacityschema beside thelimiterparagraph. Nomkdocs.ymlnav change.Test plan
Derived from the issue's acceptance criteria and test plan. Every box below was run and passed unless the line says otherwise.
Capacity, alarm and arithmetic (
src/registrar/audit_store/capacity/tests.rs)headroom_bytes == audit_store_low_water_bytes, off atlow_water + 1from a cold start, does not clear between the threshold andlow_water + margin, and clears atlow_water + margin— driven through the probe abstraction over atempfile::tempdir().fstatvfs-backed probe against a tempdir and asserts plausible non-zero values.statetable:unknownbefore any probe,ok,low_waterat and just below the inclusive threshold,exhaustedat zero and at negative headroom, the hysteresis-gated return took, the immediateexhausted→low_waterstep on the first positive headroom below the clear threshold, the directexhausted→okstep when one probe reachesthreshold + margin, and a failed probe leaving the previous state andmeasured_atintact.max(low_water / 10, 1 MiB): the floor applies where the 10% term truncates to zero, the 10% term applies at the default, and a threshold neari64::MAXsaturates the clear sum rather than wrapping it.used_bytesandfilesystem_available_bytesabovei64::MAXyield neither a spuriousoknor a spuriousexhausted;f_bavail × f_frsize,(f_blocks − f_bfree) × f_frsizeandst_blocks × 512each saturate tou64::MAXwith the probe still succeeding.Usage measurement and the walk (real
tempfile::tempdir()fixtures)filesystemmode derives usage from the samefstatvfson the store root descriptor and performs no walk;directorymode walks the store and tracks a file written into it; available bytes aref_bavail-derived in both.fstatatand neither opened nor traversed; a sparse file counts by allocated blocks; a nested real subdirectory on the same device is descended and its own blocks counted. The expected total is computed from each fixture's own reportedst_blocks.audit_store_diritself fails the probe in both enforcement modes and leaves the previous state andmeasured_atintact.The walk's test seam (failure injection only; every other walk test runs against a real tempdir)
ELOOP/ENOTDIRat a descentopenatfails the probe and contributes no usage at all.ENOENTat theopenat/fstatatfollowing areaddiris skipped and the probe still succeeds.EACCESon a subdirectory fails the probe and leaves the previous state andmeasured_atintact.readdirreturn with a nonzeroerrno(EIOmid-directory) fails the probe rather than reading as end-of-directory, and the short total is not reported.fdopendirreturn after a successfulopenatfails the probe and closes the descriptor itsopenatproduced.fstaton an enumerated subdirectory's own descriptor fails the probe — the call that proves a directory is classified from the descriptor the walk traverses, not from thefstatatthat decided to open it.fdopendirfailure after it — with no/proc/self/fdread, no process-global count and nofcntlcheck.The tick (
src/daemon/audit_capacity_tests.rs)ROTATION_INTERVAL; no second scheduler, interval or long-lived task is added, both filesystem operations run underspawn_blocking, and every spawned handle is awaited inside the tick.scan_audit_store's own output for the same store and window, and the tick declares no window value of its own — asserted over its source, so neither a second constant nor an inline 30-day duration can creep into the call site.malformed_recordscount reaches the health response andbootroot statusalike, and a malformed line in a surplus rotated generation the reader does not select is counted by neither. No assertion is made about a malformed line's age.truefor a forced store,falsefor a healthy one.measured_atintact while the scan still succeeds, and a failed scan leaves the previous three record values andrecords_measured_atunchanged rather than zeroing them.enforcementis always present and mirrors the configured mode in both deployments.The wire (
src/registrar/endpoint/protocol.rs,src/registrar/endpoint/tests.rs)registrar_health.audit_capacitycarries every specified member with the specified types and presence rules —stateandenforcementenums,headroom_bytessigned, both timestamps RFC 3339 UTC, the three capacity measurement members absent exactly whenstateisunknown, the four record members absent exactly before the first successful scan.limiterround-trips, and nolimiterbyte changed.null, and an explicitnullin a decoded payload is rejected — both directions, for one optional member of each type.audit_unwritablerefusal path still serializes"registrar_health":{}.mint-success.json,deregister-success.json,refusal-permanent.json,refusal-busy.json,refusal-unclassified.json.production::tests, where the handler's ownhealth_snapshot()feeds the production mint encoder and the reader is then run against the handler's own store in the same test and returns a different count from the one the response carried.no_request_path_arm_reads_the_audit_storereads this build'smintandderegisterbodies and asserts each takes its health from the single accessor, reaches the holder through nothing else, and that the module names no store reader.Configuration and provenance
audit_store_low_water_bytes = 0is rejected at load with a diagnostic naming the key, and the existing reserve and upper-bound rules still reject what they rejected before.measure_underlyinginsrc/commands/audit_store/reserve.rsis unchanged and thebootroot initreserve preflight is not rewired.tempfile::tempdir()and never a fixed path, and mutate no process environment.Suites and gates
cargo test --bin bootroot— 1286 passed on the macOS host; 1305 on Linux. Thebootroot statusagreement tests live in the binary crate, socargo test --libalone would skip them.cargo test --lib— 1068 passed on macOS, 1349 in a Linux container as a non-root user.src/registrar/endpointis gated ontarget_os = "linux", so the endpoint tests run only in the container; the capacity module carries#[cfg(any(target_os = "linux", test))]and its own tests run on both.cargo test --no-fail-fast— every target green on macOS. In the Linux container two unrelated targets fail for reasons that are the container's:tests/bootroot_rotate.rs's eightrotate ca-keycases need adockerbinary it has none of (all 46 pass on macOS), andtests/bootroot_verify.rs'stest_verify_successfails only under that container's full-suite parallelism and passes alone. Neither touches anything this change adds.cargo fmt -- --check --config group_imports=StdExternalCrate,cargo clippy --all-targets -- -D warnings, andcargo doc --no-deps --document-private-itemswithRUSTDOCFLAGS=-D warnings— the new module, its trait, its enums and the new health member all carry rustdoc. Clippy and rustdoc were additionally run on Linux, where the endpoint and the daemon's registrar wiring actually compile../scripts/check-docs.shfor thedocs/changes;scripts/preflight/ci/check.shas a whole, including ruff, biome andcargo audit.scripts/validate-deploy-compose.sh,validate-compose-instance-names.sh,validate-e2e-openssl-compat.sh,validate-e2e-leftover-check.sh,validate-e2e-run-scope.sh.statevalues, the hysteresis, the two timestamps and what bounds their staleness are covered in bothdocs/en/operations.mdanddocs/ko/operations.md,audit_store_low_water_bytesis updated in bothconfiguration.mdpages, anddocs/reference/registrar-wire-contract.mddocuments theaudit_capacityschema. Nomkdocs.ymlnav change.scripts/preflight/ci/test-core.shandscripts/preflight/ci/e2e-matrix.sh— did not run on this machine. See Preflight.Preflight
scripts/preflight/ci/check.shand everyvalidate-*.shabove ran and passed on this machine.scripts/preflight/ci/test-core.shandscripts/preflight/ci/e2e-matrix.shdid not run, for two reasons that are properties of this machine and not of the change:bootroot initas root throughsudo -n, andsudo -non this host fails with "a password is required".--skip-hostsdoes not stand in for it.bootroot-*Compose stack belonging to another session on this host. Both scripts begin by tearing down that Compose project, which would destroy work that is not mine.Nothing was weakened, skipped, marked
continue-on-erroror deleted to make a local run pass. This change touches daemon scheduling, configuration and the endpoint, so it is not eligible for the E2E exemption: CI'sDocker E2Ejobs gate the arm that did not run here, including the endpoint-enabled loopbackinitin step 13 that exercises the veryfilesystem-mode measurement this adds.Not addressed
The mint arm's relay is asserted over the two halves it is made of, not through one
handleround trip.Operation::Mintis refused atrequested_specwith no response bytes — minting waits on the wire spelling of a request'sspec, which this repository is explicitly not the author of — so no payload reaches the mint encoder throughhandle. The regression test therefore drivesProductionHandler::health_snapshot(), which is where the mint arm takes the value, intoprotocol::encode_mint_response, which is what that arm hands the value to, and a companion source-level test pins that the arm calls exactly those two and nothing store-reading. Together they cover the guarantee the round trip would have; only the frame around it is missing.bootroot statusand the health response are asserted to agree by each being held against the reader's own output for the same store and window, rather than by one test driving both surfaces over one store. The two live in different crates —statusis in the binary crate, the health tick in the library — and a single test cannot reach both. Since both callscan_audit_storewith the same five arguments, equality with the reader is the strongest available form of the agreement.scripts/preflight/ci/e2e-matrix.shdid not run at all, rather than running as far as it goes. Its first step tears down thebootroot-*Compose project, and on this host that project is a live stack belonging to another session, so there was no prefix of the matrix that could be run without destroying someone else's work.scripts/preflight/ci/test-core.shis blocked the same way. CI'sDocker E2Ejobs gate that arm.