Skip to content

M5: fail-safe bpffs pinning — enforcement outlives the daemon - #5

Merged
nikicat merged 6 commits into
mainfrom
m5/fail-safe-pinning
Jul 28, 2026
Merged

M5: fail-safe bpffs pinning — enforcement outlives the daemon#5
nikicat merged 6 commits into
mainfrom
m5/fail-safe-pinning

Conversation

@nikicat

@nikicat nikicat commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Closes M5. Until now, attach()'s LsmLinkId was discarded, so every LSM hook detached when cordond exited — a crash silently switched the boundary off. That fail-open made every other guarantee in DESIGN conditional on a process staying alive.

The shape: pin the links, and only the links

A pinned bpf_link holds its program, and a program holds its maps — so seven pinned links keep the programs and the seeded policy enforcing with no holding process.

The roadmap specified "pin program + maps + link; adopt existing pins on startup". Adoption was dropped: it buys nothing the link pins don't already give, and would have cost a mandatory cross-process writer lock, an ABI check so an upgraded daemon can't drive the old kernel program, and a clash with the self-test (which writes bank 0 + SETTINGS before seed_policy). A restarting daemon still loads its own object with its own private maps, so seed.rs's single-writer invariant is untouched — seed.rs, events.rs and bless.rs needed no changes at all.

Ordered attach → self-test → seed → pin → sweep older generations, so the previous generation keeps enforcing until the replacement is proven.

Two roadmap notes turned out to be wrong and are corrected in STATUS.md rather than implemented:

  • "Pin the policy maps only — a pinned BLESSED means grants that never expire" is backwards. Under a pinned program an unpinned BLESSED is still alive and its stale grants persist anyway, just unreachably.
  • The singleton guard M5 required also fixes a live bug: a second daemon used to unlink the first one's live socket and bind its own.

Behaviour change to know about

systemctl stop cordond no longer stops enforcement.

That is the point — stop/restart/crash are all gapless. Turning it off is cordond --teardown, which needs no socket and refuses while a daemon holds the lock. cordon status reports the pin state when it can't reach the daemon, so "connection refused" is never read as "not enforcing".

Two things found while building it

  • A new hole, closed in the same series. live_bank fast-allows settings.daemon_pid with a total unlogged bypass. Pinning makes that record outlive its writer, so a recycled pid would inherit it. Settings gained daemon_exe; no new BTF offset, and && short-circuits so the hot path is unaffected.
  • An upgrade bug in that fix. Resolving /proc/self/exe per seed breaks once a package replaces the binary — canonicalize fails ENOENT on the (deleted) path, refusing the next policy apply. Now memoized, since mm->exe_file is fixed for the life of a process.

Verification

All 30 VM scenarios green on the CI-pinned v6.12, plus 135 unit tests, fmt, clippy -D warnings. Three new scenarios; both regression tests were verified to fail on the pre-fix code before being kept:

OK: scraper STILL denied with NO daemon (denied)
OK: owner STILL allowed with NO daemon (allowed)
OK: --teardown removed the pins with no daemon running
OK: a second teardown says the box was already off, not that it disarmed one
OK: policy apply succeeded after the binary was replaced

Known gaps

  • The suite never exercises the daemon reading a protected path, so a broken fast-allow would be invisible to it.
  • cordon status's enforcement claims are untested (the --teardown equivalents now are).

🤖 Generated with Claude Code

nikicat and others added 6 commits July 28, 2026 18:39
…ocket

`serve` removes a stale socket by testing only "is this inode a socket" — it
never probes whether anyone is listening. So a second daemon unlinked the
first one's *live* socket and bound its own: the first kept running with a
listener nobody could reach, and every client silently talked to the second.

Take an exclusive `flock` on `<socket>.lock` before the kernel or the socket
is touched. Holding it is what makes the stale-socket unlink safe. The handle
*is* the lock — released when the file closes — so a crash frees it with no
stale-lock sweep and no pidfile to reconcile; the lock file is never unlinked,
since removing it would let the next caller lock a fresh inode while the old
one still holds the original.

Keyed on the socket rather than the bpffs pin directory so it also covers a
user-mode dev daemon, which has no pins but must not have its socket stolen.
`File::try_lock` is stable as of 1.89 and the MSRV is 1.95, so this needs no
new dependency.

`run()` gains a pre-flight step, so it splits into resolve_config + the lock +
serve_daemon rather than growing a fourth concern in one body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`live_bank` returns `None` — a total, unlogged bypass of every protected
object — when the caller's pid matches `settings.daemon_pid`, so the daemon
never deadlocks on its own I/O. A pid is a recyclable name, and pinning (next
commit) makes `SETTINGS` outlive the process that wrote it, at which point
whatever lands on that pid inherits the bypass. Pair it with the exe's
`FileId` so the window narrows to "and it is also running cordond".

Costs nothing on the hot path: `&&` short-circuits on the pid, so the two
extra derefs only happen for the daemon itself. No new BTF offset either —
`caller_exe()` takes no arguments, so `live_bank`'s signature is unchanged and
the five-register ceiling `consult` sits at is untouched.

The identity is resolved **once**. `mm->exe_file` is fixed for the life of a
process, so this is a constant; re-deriving it per seed can only introduce
disagreement with the kernel's side of the comparison. Concretely, a package
upgrade replaces the binary under a running daemon and `/proc/self/exe` then
reads `… (deleted)`: `fs::canonicalize` fails ENOENT (measured), which would
refuse the next `policy apply` for no reason a user could act on — and if it
*had* resolved, it would name the new inode, which this process never exec'd,
silently breaking the fast-allow. A test pins both halves of that premise.

The self-test passes a zero `FileId` alongside its `u32::MAX` pid: no real
inode is `dev=0 ino=0`, so the probe identity is unsatisfiable on both fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… daemon

`attach()` returned an `LsmLinkId` that was discarded, leaving the link inside
its `Program` inside `ebpf` — a local in `Backend::run`. Every hook therefore
detached when the daemon exited, and a crash silently switched the boundary
off. That fail-open made every other guarantee in DESIGN conditional on a
process staying alive.

Pin the links, and only the links. A pinned link holds its program and a
program holds its maps, so seven pinned links keep the programs *and* the
seeded policy enforcing with no holding process. Nothing is adopted on
startup: a restarting daemon still loads its own object with its own private
maps, which is what keeps seed.rs's single-writer invariant true — sharing
maps across processes is what would reintroduce a torn bank flip. It also
means an upgraded package can never end up driving the old kernel program.

Ordered attach → self-test → seed → pin → sweep older generations, so the
previous generation keeps enforcing until the replacement is *proven*; a
daemon that aborts on a bad policy leaves the boundary up rather than tearing
it down for one that never arrived. Pins live at `<pin_dir>/<pid>/<hook>` —
a generation subdirectory, because BPF_OBJ_PIN fails EEXIST on a taken path
and one shared directory would force a detach-then-attach gap.

Consequences worth knowing before meeting them: `systemctl stop cordond` no
longer stops enforcement (that is `cordond --teardown`, which takes the same
singleton lock and so refuses while a daemon runs), and teardown is
asynchronous — unlinking a pin drops the link's last reference and the kernel
detaches from a workqueue, so anything needing to observe the boundary down
has to poll.

`sweep`/`teardown` return `Swept` rather than a count: zero is a distinct
event, not a small number, and a blind `usize` had already produced a false
statement — a teardown on an unpinned box logged "enforcement torn down — the
boundary is OFF", which misreports the posture of a box that was already
unenforced. `Generations` carries a `NonZeroUsize`, so the variant and the
count cannot disagree.

No statfs pre-check for bpffs: BPF_OBJ_PIN already refuses any path outside
one with EINVAL, so the kernel makes "pinned into a regular directory and
silently working" impossible. What a check would add is a better message,
which the error text does instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Since the links are pinned, a dead daemon is not a dead boundary — but the
only thing the CLI printed on a failed connect was "is cordond running?",
which reads as "nothing is being enforced". That is the opposite of the truth
and the more dangerous direction to be wrong in.

On a connect failure, report what the pins say: still enforcing (and how to
turn it off), or genuinely off. bpffs is root-only, so an unprivileged caller
gets "unknown" rather than a guess — claiming either state without being able
to read the directory would be inventing an answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three scenarios for things no existing one could show, since every other
scenario proves the boundary works *while the daemon runs*:

- pinned_survives_daemon_death: SIGKILL (the crash path — nothing runs on the
  way out) and assert both the deny and the allow still stand with no process
  holding them, then that `--teardown` lifts them. That last step is what
  shows the verdicts came from the pins rather than some unrelated failure.
  It also runs teardown twice and requires the two to report *different*
  postures; asserting only the second would pass if both claimed "already
  off", which is the inverse falsehood.
- apply_survives_binary_upgrade: rename a new inode over the running daemon's
  binary, assert the `(deleted)` precondition actually reproduced — otherwise
  a replacement that silently did nothing would let the scenario pass proving
  nothing — then that `policy apply` still works and the new rules really took
  effect, rather than trusting the CLI's exit code.
- singleton_refuses_a_second_daemon.

Harness: mount bpffs as a hard precondition (the daemon pins unconditionally,
so a SKIP would skip everything), and run `--teardown` in `daemon_stop`.
Without that, each scenario would leave a live boundary holding PROTECTED
entries for inodes its `rm -rf` just freed, and inode reuse would contaminate
the next scenario. Teardown is asynchronous, so the workspace removal retries
— the refusal in the meantime is itself a small proof the pins were working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
STATUS/DESIGN/plan are the resume point, so they carry what changed and, more
importantly, the reasoning that would otherwise be re-derived wrongly:

- adopt-existing-pins was specified and dropped. Pinning links alone delivers
  the property; adoption would have cost a cross-process writer lock, an
  ABI check so an upgraded daemon cannot drive the old program, and a clash
  with the self-test writing bank 0 before seed_policy.
- "pin the policy maps only — a pinned BLESSED means grants that never expire"
  was backwards. Under a pinned program an *unpinned* BLESSED is still alive
  and its stale grants persist anyway, just unreachably. Recorded so nobody
  "fixes" this by pinning maps.
- three measured kernel facts: a pinned link keeps its whole apparatus alive;
  unlinking a pin detaches asynchronously (via a workqueue — the first version
  of the smoke suite failed on exactly this); and /proc/self/exe stops
  canonicalizing once the binary is replaced, which is why the daemon identity
  is memoized.

README states plainly that stopping the daemon does not stop enforcement, and
names `cordond --teardown`; the plan notes the precondition M5 imposes on the
future "cordon cordons its own policy file" item, since compile_policy runs
mediated by the previous generation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nikicat
nikicat marked this pull request as ready for review July 28, 2026 15:48
@nikicat
nikicat merged commit d886862 into main Jul 28, 2026
4 checks passed
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