Skip to content

feat: the fast-allow path - an in-kernel allow that buys the round trip (opt-in) - #36

Open
MotherSphere wants to merge 24 commits into
mainfrom
feat/fast-allow
Open

feat: the fast-allow path - an in-kernel allow that buys the round trip (opt-in)#36
MotherSphere wants to merge 24 commits into
mainfrom
feat/fast-allow

Conversation

@MotherSphere

Copy link
Copy Markdown
Member

TODO 1a, reshaped by an adversarial design review that stood 59 objections before a line was written. Off by default ([ebpf] fast_allow = true); this is the change the TODO said wanted its own tests and its own blast-radius discussion.

What it does

A process a lasting rule allows outright no longer pays the NFQUEUE round trip per connection. The cookie connect hooks and two new sendmsg hooks mark its sockets with a value the daemon draws at random each start; meta mark @fast_allow accept ahead of the queue takes them through. Fast-allowed flows are reported on ALLOW_EVENTS, so the live feed, rule hit counts, the allowed counter and the enforcing heuristic keep telling the truth.

What the review forced, and how each is answered

  • The mark lives on the socket, not the map. Re-decided at every flow start (connect() and sendmsg()): set if the grant is current, stripped if not, and sockets carrying a foreign mark are left alone in both directions (tailscale is on the author's own machine).
  • CAP_NET_RAW can set SO_MARK since 5.17 (docker default). The value is random per start, in a pinned map and an nftables set - never in a package.
  • A static accept rule is a token on every install. The snippet ships the set empty; the daemon adds one element when armed - the one thing it does to nftables, with the SELinux grant to match.
  • No kernel-side exec clear. FAST_ALLOW.remove in try_exec and in the exit tracepoint, next to the VERDICTS removes.
  • Dead daemon. The programs are pinned and keep their maps alive, so a CLOCK_BOOTTIME deadline refreshed every 10 s (not MONOTONIC: laptops suspend) is what makes a dead daemon fail-closed within 60 s.
  • Deny-shaped resync reused for allows. Grants have their own rule: allow from a lasting rule, else remove; abstention removes.
  • Eligibility: pinned/inherited enforcement, exit detected exactly (group_dead), cookie variants verified, config set - each an Off(<reason>) in cfc status.
  • Boot order (found while implementing): the nft unit starts after the daemon, so arming the set is a heartbeat-driven retry, not a startup step.

ABI v4

New maps and programs the connect hooks read; v3 pins would enforce fine and never mark. verdict::ALLOW (matched, never written, two releases) is gone.

What CI is the oracle for

Whether bpf_setsockopt verifies on sock_addr hooks on 5.10 (the design's fallback to the _basic twins covers a refusal), and the verifier counts of the grown/new programs (budget ceilings raised; observed left at the pre-feature numbers so the diff that records them shows the delta).

Not yet done, said in TODO: the latency win is unmeasured on the veth bench.

An in-kernel allow now buys the NFQUEUE round trip. The connect hooks
(cookie variants) and two new sendmsg hooks re-decide, at every flow
start, whether the socket carries the daemon's mark: set when the tgid
is in FAST_ALLOW and the CLOCK_BOOTTIME deadline is fresh, stripped when
it is not, and left alone entirely when the socket carries a mark that
is not ours - a VPN or proxy marking for policy routing would be broken
by an overwrite and broken again by a strip. sendmsg exists because
unconnected UDP starts flows without ever passing connect().

Grants are cleared in the kernel on exec (try_exec, the one path every
execve takes, before anything can return) and on exit, next to the
VERDICTS removes: a grant surviving exec would hand the successor binary
a mark past the queue with no daemon needed for the hand-over, and the
exec-to-consumer window would be a bypass rather than a missed deny.

The mark value lives in a map, never in the object: since 5.17 SO_MARK
needs only CAP_NET_RAW, which docker grants by default, so a published
value is a bypass token. The deadline is what makes a dead daemon fail
closed - the programs are pinned and keep their maps alive, so nothing
dies with the daemon by itself. ALLOW_EVENTS reports every honoured
grant, ConnectReport carrying the protocol now instead of padding.

ABI v3 -> v4: v3 pins would enforce fine and never mark, the exact case
the version-in-the-pin-path exists for. verdict::ALLOW - matched by the
kernel and written by nobody for two releases - is gone.
The daemon only ever grants; every grant is re-earned per execve. One
rule for every writer (on_exec, both halves of resync, the startup
sweep): the entry exists iff the process-wide verdict is an allow from a
rule that lasts. Anything else removes it - there is no keep arm as
there is for denies, because a deny kept in doubt fails closed and an
allow kept in doubt is a bypass. A timed allow keeps the packet path so
its expiry is exact.

Eligibility is decided once and its reason travels to cfc status:
enforcement pinned or inherited (a Process-mode link takes the exit
eviction with it), exit detected exactly (group_dead), the cookie
connect variants verified, [ebpf] fast_allow set. Off by default.

Arming is two-sided and the order was forced by systemd: the nft unit
starts after the daemon, so at boot the table is not there yet. The
kernel side is armed at start (a random mark, deadline zero - nothing
honoured); the heartbeat task retries the nftables set every ten
seconds until it can be written, then refreshes the deadline every tick.
A stopped daemon disarms: zero deadline, unarmed mark, set flushed.

ALLOW_EVENTS feeds the observed stream, the rule hit counts and the
allowed counter, so the live feed, the busiest allow rule and the
enforcing heuristic keep telling the truth about flows the packet path
never sees. Startup now runs a full resync rather than compile_rules
alone, which is also what reconciles inherited pins with this daemon's
rules.
…t changed

The snippet ships set fast_allow empty with meta mark @fast_allow accept
ahead of the queue: empty, it matches nothing, so a default install
carries no bypass value; the daemon adds one element when armed. The
SELinux policy grants exactly that exec (iptables_exec_t), the unit and
spec say the daemon now runs nft, TESTING.md exercises it, and the
sample config documents fast_allow = false with what turning it on buys
and costs.

TODO 1a records what shipped and the three design objections that
reshaped it; the CHANGELOG carries the feature and the ABI bump; the
architecture and hardening docs stop saying nothing here filters and
five capabilities where there are seven; the spec description counts
seven programs.
Two things, one of them mine. The kernel matrix test asserted the fast
path was off 'because the config flag is unset' - but the test hands the
layer no decision engine, so the honest reason is 'no decision engine',
and every kernel said so at once. An assertion about a state the test's
own inputs never produce is the class of mistake this suite exists to
catch in the code, not to commit in the tests.

The other is the oracle doing its job: 5.10 verifies bpf_getsockopt on
the connect hooks and refuses it on the sendmsg hooks ('unknown func
bpf_getsockopt#57'), while 6.12, 7.1 and mainline accept both. The
'cookie verified implies sendmsg verified' assertion was therefore false
on the matrix floor and is gone; what remains asserted is that the two
sendmsg programs verify together or not at all. The attach result now
carries WHICH of two reasons left the fast path off - basic connect
twins, or sendmsg refused on a kernel that does verify the connect side
- because the status line reported the first for a kernel in the second
case, and that sends a reader to the wrong kernel version.

The budget file records the measured costs on 7.1.8: connect4 106 -> 318,
connect6 109 -> 323, sendmsg4 257, sendmsg6 259, exec 2355 -> 2388 and
exit 25 -> 52 for the unconditional FAST_ALLOW deletes. TODO and the
CHANGELOG name the kernel boundary.
…e processes it was for

The adversarial review of the diff found four defects that are all the same
mistake seen from different sides: the grant path had writers that decided
from something other than /proc, and writers that could not run at all.

resync's live loop decided from the ExecEvent - the execve *string* and the
uid the process had at exec - while the orphan sweep beside it, on_exec, and
the packet path all decided from /proc. Two deciders that disagree about one
process, failing open three ways:

  * execve("./foo") records no absolute path, so the loop skipped the pid
    whole. Deleting the rule that granted it, or replacing it with a Block,
    left the grant standing: a marked socket past the queue for a program no
    rule allowed any more.
  * the execve string is what the caller typed. A rule naming a symlink, or
    /bin/curl on a merged-usr system, granted here what on_exec and the packet
    path refuse.
  * the uid was the uid at exec, so a process that dropped privileges kept a
    uid-scoped grant it no longer qualified for.

Both loops now read one `proc_view`, which is also where the "(deleted)"
normalisation lives. matches_process looks at the path, the hash and the uid,
so that view is the whole decision surface.

on_exec granted for a pid whose /proc was already unreadable. The comment
justifying it - "a wrong decision for a dead pid is cleaned by the exit
program or the next sweep" - is true of a refusal and false of a grant, which
lands on whoever owns that pid next. The deny still falls back to the event's
path; the grant does not happen.

Grants had no liveness guard at all, where the deny side has carried the
judged-at start time since the orphan sweep was written. `grant_if_still`
gives it to them; withdrawal stays unguarded, because withdrawing from a
recycled pid is harmless.

And the feature was inert for the population it exists for. Every writer needs
an event, so nothing reached a process that was already running: after a
restart the pinned map is flushed and the proc table starts empty, and the
table's entries expire on a one-hour TTL anyway. A browser started three hours
ago never got its "allow always", and after every restart no long-lived
program did - while `cfc status` said `fast-allow live`. sweep_fast_allow
walks /proc at the end of resync and only ever adds; withdrawal stays with the
two loops that can see pids /proc no longer has.
Three defects in how the nftables half of the fast path is armed and kept.

The mark was 32 uniformly random bits, and the mark space belongs to the
whole machine. The consumers that matter select on a *mask*: they do not have
to guess the value, only to share a bit with it. kube-proxy's masks are single
bits - 0x8000/0x8000 is what it marks packets it then DROPs - so on a
Kubernetes node this broke every fast-allowed flow on roughly every other
daemon start, freshly, with the fast path reporting live. Tailscale's ip rules
select 0x80000/0xff0000, which is one start in 256 routed around the tunnel.

Candidates that collide with a documented selector are now rejected before
being armed. Rejection sampling rather than a claimed range, because a range
is the part that must stay unpredictable: SO_MARK needs only CAP_NET_RAW since
5.17. About a quarter of the word survives, which is thirty bits of entropy
and four draws. The list cannot be complete - nothing enumerates a host's
fwmark users - so `[ebpf] fast_allow_mark` pins one for a machine with a
selector CFC does not know, and says in the log when the pinned value collides
with one it does.

The set was flushed only on the *arming* path. A daemon that crashed while
armed and came back with fast_allow off, on a kernel it now judged ineligible,
or with no engine, left its predecessor's mark accepted by the ruleset,
refreshed by nobody, and removed by nothing short of the table going away.
That is a standing bypass token rather than a stale entry: any process that
was ever fast-allowed reads the value back off its own socket with
getsockopt(SO_MARK), and setting it again needs only CAP_NET_RAW. The flush is
now unconditional, before any branch, and `arm` flushes before it adds - the
startup flush finds no table on a boot, so the heartbeat's retry was the one
that could leave two marks accepted.

And armed was treated as a fact that stays true. Once the element went in, the
loop only ever refreshed the deadline; `systemctl restart nftables` or any
reload that recreates the table left the set empty while the daemon went on
marking sockets, crediting rule hits, and telling cfc status the path was
live - with every one of those flows in fact taking the queue and being
counted twice. Every sixth tick now asks whether the mark is still accepted
and re-arms if it is not. A failed probe changes nothing: it is not evidence.
… the level

Truth defects, from the same review. Each one is a sentence the code does not
honour, and two of them hid working failures.

The sendmsg comment said "the same mark_decision, on every datagram send".
cgroup/sendmsg{4,6} runs for a send that carries a destination; send() on a
connected UDP socket does not pass it. So such a socket is marked once, at its
connect(), and never re-decided: revoking the grant does not unmark it, and
the deadline never reaches it either, because both are consulted at hook time
and there is no next hook. What actually bounds it is conntrack - the ruleset
queues `ct state new`, so later datagrams were never going to the daemon
anyway - and the residual window is a socket idle long enough for its
conntrack entry to expire and then reused. That is not closable from inside
the hook, so it is stated as a boundary instead of being left to be found.

The fast-allow level was published by `start()`, after `load_and_attach`
returned - which is after the heartbeat task exists. On a restart the table is
already loaded, so the heartbeat's first act is to arm and publish `Live`, and
`start` then put the pre-heartbeat state back on top of it. Nothing corrected
it, because the heartbeat only publishes while it is not armed: `cfc status`
said "waiting for the nftables table" for the life of a daemon whose fast path
was live. The loader now publishes before it spawns that task, and `start`
publishes only on the path where the loader never got to.

Report::log() never mentioned the fast path at all, so its five off-reasons
reached `cfc status` and nothing else. An operator who set fast_allow = true
and restarted had no way to learn from the journal that the kernel refused a
hook - which is the opposite of degrading loudly.

FOREIGN_MARK's doc said "fast-allow granted but the socket already carried a
foreign mark". The kernel bumps it before it looks the pid up, deliberately -
the foreign mark settles the question and a lookup that cannot change the
outcome is not worth paying for there - so it counts every process with a
foreign mark, granted or not.

xtask's REQUIRED_SYMBOLS says it holds everything the loader looks up by name,
and the loader looks up thirteen maps while the list named seven. The fast
path's four and EXE_RULES' two were missing, so the build check that exists to
turn a runtime failure into a build failure would have passed an object with
no FAST_ALLOW in it.

And three counts that had drifted: the kernel header said five programs for an
object that defines nine (seven attached at once), a root test printed "all
five programs attached and pinned" after asserting seven pins, HARDENING.md
said seven capabilities and listed five - omitting CAP_CHOWN and
CAP_DAC_READ_SEARCH, which are the two whose absence once cost the machine its
outbound traffic. The RPM description claimed the package attaches seven eBPF
programs when the same spec deliberately ships no object at all.
…nows

Four ways the ladder's inputs were weaker than the sentences built on them.

Pinned is not attached. `attach_tracepoint` re-attaches unpinned when a link
cannot be pinned - no BPF_LINK_TYPE_PERF_EVENT before 5.15, or a read-only
bpffs - and then returned the same Ok as a pinned attach, so the outcome was
thrown away. The ladder asks for Pinned enforcement and exit tracking on the
reasoning that a grant is always cleared even if the daemon dies, and that
reasoning is the *pin's*: the connect links are pinned separately and go on
marking sockets after this daemon is gone, so on such a kernel the clears died
with it and the sixty-second deadline was all that stood between a grant and a
recycled pid. The outcome now reaches the report and the ladder requires it.

The ladder also ran before the ring consumers existed, and two of them retract
what it assumed - a failed exec consumer turns exec_tracking off, a failed exit
one turns both off. The path could be armed, reported live, and marking
sockets while on_exec, its only per-execve writer, could never run. Moving the
decision would mean moving the sink it needs, so the retraction is handled
where it happens: flush the grants, empty the set, never spawn the heartbeat.

Two reason strings asserted causes nothing had established. Any attach_one
failure for a sendmsg hook was reported as this kernel's verifier refusing it,
with kernel versions - the exact mistake the connect loop twenty lines above
documents having already made once ("claiming a verifier rejection for an
EEXIST sent a reader hunting a program bug where there was a state bug"). And
on the inherited path, where the pins do not say which connect variant is
running, absent sendmsg pins were reported as the basic-connect fallback,
naming a cause that path cannot know. Both now say what is known and point at
the log line that has the kernel's actual answer; the inherited case gets its
own state rather than borrowing one.

One reason string served two facts - "exit tracking is off" and "this kernel
has no readable group_dead" - so a tracepoint that simply failed to attach
sent the operator to check their kernel version.

And the report ring dropped records in silence. A fast path that let five
connections through and one that let five thousand through while reporting
five looked identical in cfc status, and the counters are what the live tests
read to prove the hooks fire at all. REPORT_DROPPED counts them. The startup
carry-over note now names every counter it reads, rather than reading
foreign_mark and leaving it out of both the guard and the message.
…iting it

The last of the review's truth findings, in the files an operator reads.

`VerdictSink::arm` now zeroes FAST_ALLOW_UNTIL. Two comments claimed "the
deadline stays zero until the first beat, so nothing is honoured before the
heartbeat runs" and it was not a property the code had: the map is pinned, so
after an unclean death it holds the previous daemon's deadline, up to a minute
ahead. Nothing was honoured on the strength of it - the grant map is flushed
at start and the set holds no mark - but a load-bearing sentence should be
made true rather than qualified, and it costs one `set`.

precommit_verdict's doc still said an in-kernel allow "would buy nothing".
It buys the whole fast path now. The reason it must not be minted *there* is
different and worth stating: that function runs with no daemon alive, so
nothing would refresh the deadline that bounds a grant, nothing would
re-decide it on a rule change, and it reads a table keyed on the execve string
that on_exec exists to correct.

The nftables snippet said the value's only weakness is guessing. A process the
fast path has granted reads it off its own socket with getsockopt(SO_MARK) -
not a new privilege, but it scopes the secret to processes that have never
been granted, and it is why the set is flushed unconditionally rather than
left holding a value every past grantee knows. nft_set's module doc named two
read paths and omitted that third one. The snippet also claimed marks stop
matching within 60 s of the daemon going away "because the kernel re-decides
at every connect" - true, and a connected socket does not connect again.

FastAllow's own doc opened its list of off-reasons with "an inherited attach
from a build that predates it", which the ABI-versioned pin directory makes
impossible, and the matching reason string said "the loaded object predates
the fast path" where the v4 symbol check would have refused such an object
first. The config doc listed three eligibility conditions and left out the two
that actually fail in the field: the sendmsg hooks, and the nft table that is
not loaded yet because its unit starts after the daemon.

The SELinux policy said the daemon runs "two commands" - it runs five
subcommands, on a ten-second retry until the table appears and a sixty-second
check after that, so a denied host gets an AVC every ten seconds rather than
twice in a daemon's life. It also required "all five BPF programs" where seven
attach. And TESTING.md told the tester to stop the daemon and watch the set go
empty, which cannot be seen: colony-firewall-nft.service is PartOf= the daemon
and takes the whole table down first.
The ALLOW_EVENTS consumer exists because fast-allowed flows never reach the
packet path - and it did not do the two things that path does with a
destination: attach whatever the reverse-DNS cache already holds, and enqueue a
lookup for next time. So the one kind of connection whose destination never got
a name was the one belonging to a program the user had trusted enough to allow
outright, and the cache was never warmed for those hosts either, so the next
flow to the same address had nothing to attach. It now uses the same seam.

And the docs say what the feature does not do. ARCHITECTURE.md gains the
revocation boundary - a grant is re-decided at every hook that opens a flow,
and a connected UDP socket passes none, which conntrack makes mostly moot and
which is bounded by the conntrack timeout when it does not. The CHANGELOG
entry gains that, the mark-collision sieve and the config knob for it, and the
fact that grants now reach processes that were already running rather than
only ones that exec after their rule - which is what the entry's first
sentence had been promising all along.
Moving resync's live loop to /proc put per-pid I/O inside the mutex it holds -
the exact hazard the orphan sweep twenty lines below documents and avoids.
on_exec and on_exit block on that mutex from inside the ring consumers, and a
stalled exec ring is a dropped record, which is a process with no in-kernel
verdict at all. The loop could hold the lock for free while it decided from an
in-memory record; it stopped being free when the record became a file.

Every view is read first, then the lock is taken for the deny pass alone, then
released before the grant pass - which also stops the grant map's mutex from
ever nesting under the verdict one.
Six lenses over the eight fix commits, every finding verified against the code.
Most of what it found was mine, including one thing a commit message of mine
claimed to have done.

`grant_if_still` guarded nothing when the start time was unknown. `proc_view`
reads the exe, then the uid, then the start time; a process that exits in
between yields a complete view with `judged_at = None`, and `None != None` is
false - so the guard fell through and wrote a grant for a pid with no process.
Nothing would have cleared it: the kernel's exec and exit clears both belong to
a process that has already gone, so a pid recycled by a fork that never execs
inherited the mark. The guard now refuses an unknown start time outright.

It could not see an execve either, because execve keeps the start time. A
process judged as an allowed binary, exec'ing into a denied one while resync
was deciding, had its grant correctly cleared by the kernel and then reinstated
here. There is now a second check, on the program rather than the pid, taken
*after* the write - which makes it race-free rather than merely narrower: an
execve that beat the write is visible in /proc by then, and one that follows it
is cleared by the kernel itself.

The deny pass collected the start times and dropped them on the floor, so it
could overwrite a DENY that `on_exec` had just installed for a recycled pid -
usually with a clear, erasing the refusal the new process had earned.

And the commit that moved the live loop's /proc reads out from under the
verdict lock missed the `doomed` pass, which runs over the largest pid set of
all - every inherited entry at once after a restart - and then asserted the
invariant held everywhere.

The startup nft flush was "unconditional" behind five early returns, including
the missing-object case, which is the most common outcome on a default
install. It now runs before anything can return.

Shutdown raced the heartbeat. `JoinHandle::abort` cannot cancel a
`spawn_blocking`, so an `nft add element` already in flight ran to completion
after `Drop` flushed the set - leaving a mark accepted with no daemon alive to
refresh a deadline or ever remove it. A gate serialises the two so both orders
end flushed.

`pick_mark`'s fallback armed with the constant 0x00000001 when the draw kept
failing, which is exactly the published bypass token the random draw exists to
avoid. It refuses now, and the path stays off.

ENFORCE_STATS grew from five slots to six inside one ABI version, so a daemon
upgraded in place reuses a five-slot pin: reading past the end used to fail the
whole counter read and take the carry-over note and `cfc status` with it.

And the conntrack claim was wrong in the direction that matters. This project's
own packet path records that unreplied UDP is conntrack-NEW on every datagram -
so for a connected UDP socket talking to something that never answers, a stale
mark takes *every* datagram past the queue, and the deadline does not reach it
either. "A dead daemon fails closed within 60 s" does not hold for that one
case. Said plainly now, in the kernel comment and in ARCHITECTURE.md, rather
than softened into a residual window.
The one mark that could not be taken back.

`connect()` fixes a UDP socket's peer, and its later `send()` calls name no
destination - so they pass neither the connect hook nor the sendmsg one. The
mark such a socket is given at `connect()` is the mark it keeps until it is
closed: deleting the rule cannot reach it, replacing it with a Block cannot,
and neither can the deadline, which is only read at a hook that runs. It is
the single case where "a dead daemon leaves the machine fail-closed within
sixty seconds" was not true.

Refusing costs almost nothing, and that is what settles it rather than any
appeal to how narrow the case is. The ruleset queues `ct state new`. A UDP
peer that answers makes the flow conntrack-established after one exchange, and
established traffic is not queued with or without a mark - so a marked
connected-UDP socket only kept *gaining* anything while its peer stayed
silent. And unreplied UDP is conntrack-NEW on every datagram, which this
project's own packet path records and sizes a queue around. The benefit and
the hazard were the same case, which is the shape that makes a feature not
worth defending.

TCP is untouched: it passes the connect hook for every connection it opens.
Unconnected UDP is untouched: `sendto` carries a destination, so it passes the
sendmsg hooks and is re-decided per datagram, which is what made those hooks
worth having. What is given up is the fast path for QUIC and anything else
that connects a UDP socket - which, its peer answering, was getting its first
packet through it and nothing more.

The docs that stated this as a boundary of the feature now state it as a
property of it, in ARCHITECTURE.md, the snippet, the CHANGELOG and TODO. The
verifier budget's `observed` for connect4|6 predates the guard and says so
rather than guessing: `max` is the check, and 318 against 450 has the room.
The UDP guard returned early, before the code that removes a mark. So it
reopened the hole it was written to close, through the door it had just built:

    socket() -> sendto(peer)   the sendmsg hook marks it
    connect(peer)              this hook - the last one it will ever see
    send()                     no hook, forever

A socket marked by a sendmsg hook and connected afterwards kept that mark with
nothing left in its life able to remove it. That is strictly worse than the
behaviour being fixed, because the code before the guard at least re-decided
such a socket and stripped the mark when the grant was gone.

The flag now only forces the wanted mark to zero and lets the existing strip
run exactly as it does for any other refusal - which also keeps the "leave a
foreign mark alone" rule and the "only call setsockopt when the value would
change" rule, both of which the early return had skipped as well. Never set,
always strip.

A UDP connect() now counts as UNKNOWN - a fall-through to the packet path,
which is what it is - rather than as STALE, which reads as "the daemon stopped
refreshing" and would send a reader to look at a healthy heartbeat.

cgroup/connect4 goes 2504 to 2656 bytes on disk against a max of 450 verifier
instructions with 318 observed; the matrix prints the real number.
`disarm_for_shutdown` set a process-global flag and nothing ever cleared it.
The flag means "do not arm the set again", so the first `Attached` dropped
refused every arm for the rest of that process: every later test in one test
binary, and any reload of the eBPF layer that did not also restart the daemon.
It was written as "shutdown has started" and behaved as "shutdown has happened,
once, forever".

`disarm_for_start` - the flush at the top of `load_and_attach`, which is the
one place that means a new layer lifetime begins - now clears it.

The flag half is split into `begin_lifetime` so the regression can be tested
without running nft, and the test stops one step short of asserting that a
later arm succeeds: checking that would mean letting `arm` reach nft, and on a
machine with the table loaded a unit test would put a live element in a live
ruleset.
…he fixes

The review of the two unreviewed commits confirmed the UDP early-return (fixed
already) and then found the door it had left open: **the sendmsg hooks could
still mark a socket that was already connected.** `sendto` with an explicit
address is legal on a connected UDP socket and the hook runs whenever a
destination is supplied, so the sequence connect() -> sendto(addr) -> send()
put an unrevocable mark on exactly the socket the guard existed to protect.

So the rule is an allowlist now: only TCP is ever marked, from either hook.
The property that decides it is not the protocol but whether a socket passes
one of our hooks *again* after setup - TCP does, once per connection; nothing
else here reliably does. Naming UDP only covered what someone thought to name,
and UDP-Lite, DCCP and SCTP connect the same way with no sendmsg hook either.
Two narrower rules leaked in a row; a third guess was not worth making.

Also from the review:

`proc_starttime` and `proc_uid` used `read_to_string` on files the kernel
writes comm into raw, so a program named with non-UTF-8 bytes answered "no such
process" - permanently, not transiently. The new guards then made that
permanent in both directions: never fast-allowed, and after the deny pass
started filtering on the start time, never given an in-kernel deny either,
which is resync's whole job. Read as bytes, decoded lossily; U+FFFD cannot
introduce a ')' or a digit, so both parses are unchanged.

`stats()`'s out-of-range guard was inert on the one path it was written for.
`map.len()` reports the max_entries this build's *object* declares, not the
pinned map's, so on the five-slot pin it was meant to survive it returned 6 and
the read failed exactly as before. The kernel's ENOENT, surfaced by aya as
KeyNotFound, is the only place the real slot count is observable.

The post-write exe check was called race-free and is not. It now also checks
before the write, which turns the precondition from "exec'd anywhere in the
judging window" into "exec'd between the pre-check and the insert", and the
comment says "narrower" because that is the true word. One reader, `proc_exe`,
for all three sites that wanted the same normalisation.

The startup flush's failure was pushed into a Report that every early return
in `load_and_attach` discards - and a failed flush followed by a failed load is
precisely the shape that strands a predecessor's mark. Logged as well as noted.

And `arm()`'s doc comment had been donated to the SHUTDOWN static when the gate
was added, leaving the function undocumented.
The matrix said the guard cost 118 verifier instructions on `cfc_connect4`
(356 -> 474 on 6.12, over the 450 ceiling). Computing it one line before its
only use - the obvious fix - made it *worse*, 494, and cost the sendmsg hooks
88 they had not been paying at all.

The cost was never the context load. It was a second boolean alive across the
tail: the setsockopt, its error path and all three counter arms get walked in
four states instead of two. Moving the computation later did not help because
the live range that mattered was the one after it.

Folded into `granted` there is no second boolean, and the `not_tcp` counter arm
disappears with it - a socket that is not TCP is simply not granted, so it
falls to the same UNKNOWN arm as a pid with no entry, which is exactly what it
is. On-disk sections come back to within 8 bytes of the pre-guard build:
connect4 2632 -> 2512 against a 2504 baseline, sendmsg4 1528 -> 1408 against
1400.

The budget file records all three shapes, because "how the guard is written"
mattering ten times more than "what it tests" is the part worth not
rediscovering.
The whole matrix is green with the folded guard - 5.10 through mainline, plus
runner-kernel and capabilities - so `observed` can stop being a number from
before the change. On 7.1.8, the kernel this file is measured on: connect4
318 -> 384, connect6 323 -> 389, sendmsg 257/259 -> 297/299. exec, exit and
dns_ingress are unmoved, which is the useful part of measuring all of them.

`max` for the connect pair goes 450 -> 600. The ceiling did its job twice in
this change and is not being widened to hide anything: 384 against 450 leaves
17%, where this file's own rule is roughly 2x - "enough headroom that ordinary
edits do not trip it". Moving it is what you do *after* a deliberate addition
has been measured and then optimised, which is what happened.

And a correction worth leaving in the file rather than in a commit nobody will
re-read: the folded build's on-disk section came back to within 8 bytes of the
pre-guard one, and I inferred from that it would cost nothing. It costs +66.
Section size is not a proxy for verifier cost.
Two changes that follow from one principle: never widen what is allowed, only
shorten the path to it - and when a kernel cannot hold a guarantee, degrade the
parameter rather than withdraw the feature.

`lifecycle_pinned` stops being a rung on the eligibility ladder. It was one for
a day and it was the wrong instrument: pinning the exec/exit tracepoint links
needs BPF_LINK_TYPE_PERF_EVENT, which arrived in 5.15, so the rung withheld the
fast path from every kernel between 5.10 and 5.14 - the RHEL floor this project
targets - for a risk the deadline already bounds. Unpinned, those clears die
with the daemon and the exposure is a granted pid exiting, being recycled, and
its new owner connecting, all inside one deadline. So the deadline goes from
sixty seconds to six, refreshed every two instead of ten: the window shrinks by
the same factor it costs an eight-byte map write, and the feature exists on
five more kernel versions.

`FastAllow::Live` carries that number and `cfc status` prints it - "live,
grants lapse within 6s" rather than "live" - because one word for two different
guarantees misleads on exactly the kernels where the weaker one applies. Both
values are the daemon's alone; the kernel only ever compares now < until, so
none of this is ABI.

And the protocol test moves ahead of the map lookup in `mark_decision`. `&&`
short-circuits, so the cheaper test belongs first: the sendmsg hooks see only
UDP and can no longer grant, so with the lookup first every sendto on the
machine - every DNS query - paid a hash lookup to reach a conclusion the
protocol alone settles.

The sendmsg programs stay. They are provably dead now - they cannot set a mark
without TCP, and they cannot strip one either, because a strip fires only for
our current mark and any other value takes the FOREIGN_MARK early return - but
removing them costs an ABI change and takes `fast_path_attached()`'s only
evidence of which connect variant an inherited pin set is running. TODO.md
records that, and records that removing them would not unlock 5.10 either,
which is a claim I made here and got wrong.
Read personally, end to end, against the final state of the code rather than
the diff - the same lines have been rewritten three times and the diff lies
about where they landed.

Two things the shortened deadline made false the moment it existed. The
heartbeat's failure warning said "grants lapse within a minute" where the
unpinned pair makes it six seconds, which would send a reader looking for a
window that closed long ago; it names the number now. The nftables snippet,
CHANGELOG, TODO, `disarm`'s doc and the shared constants all stated 60 s and
10 s as facts rather than as one of two pairs.

`checks_every` was `60 / heartbeat_secs`, clamped only on the divisor. A
heartbeat longer than the check period would have made it zero, and
`since_check >= 0` is always true - a fork and exec on every beat, forever.
Clamped at both ends.

The kernel comment still described the intermediate design: "the flag below
only forces the wanted mark to zero", when bfae3f4 had already deleted that
flag and folded the test into `granted`. It had also run into the following
comment with no separation, so it read as one paragraph about the wrong thing.

`enforce_stat::UNKNOWN` says "no entry for this pid", and since only TCP is
marked it now also counts every non-TCP hook - every UDP datagram carrying a
destination. Anyone comparing it against ALLOWED to judge what the fast path
buys would understate it by the machine's whole DNS volume. The doc says so.

And one real improvement the reading turned up: the deadline read and
`bpf_ktime_get_boot_ns` sat outside the `&&`, so every hook paid a map read
and a helper call for a value only a granted socket uses - and most are not
granted. Inside the `&&`, `cgroup/connect4` comes out at 2472 bytes against a
2504 pre-guard baseline and sendmsg4 at 1368 against 1400: the protocol guard
is now net-free on disk. The matrix has the verifier numbers.

Two invariants checked by hand rather than asserted. FAST_ALLOW has exactly one
insert site (enforce.rs:416), behind four guards, and `Grant::Yes` requires
Action::Allow with Duration::Always or UntilRestart - the kernel only ever
removes, never inserts. And every path that arms has a disarm; the two that
skip the kernel-side unarm are inert anyway, because the grant map is flushed
and the nftables set holds nothing.
They were true because I traced them by hand yesterday, which makes them true
yesterday. Three assertions now, each mutation-tested - a test that has never
been seen to fail is a test nobody knows can.

**Only a lasting allow may mark a socket**, over the whole input space rather
than a sample: three actions by five durations, with the expected answers as a
table rather than the implementation's own expression rewritten, because a test
that recomputes what it checks passes for a wrong implementation too. Adding a
variant to either enum breaks it, which is the point - whether a new action or
duration may mark a socket is a decision, not something to inherit by default.
Mutation: making Seconds eligible fails it on "Allow + Seconds(0) must not be
fast-allow eligible".

**A flow-scoped allow never grants**, one case per predicate that makes a rule
flow-scoped. `allow --dst-port 443` means "may reach 443"; a mark means "every
packet skips the queue". Conflating them would be the widest failure this
feature has available, and a predicate missing from `constrains_destination`
would show up only as a rule type that quietly grants everything. The test
first asserts the unconstrained rule *does* grant, so the seven negatives
cannot pass for the wrong reason. Mutation: dropping dst_port from
`constrains_destination` fails it by name.

**The kernel never writes the grant map.** Every guard deciding whether a grant
may exist is in the daemon - allow, lasting duration, no flow scope, the pid
still the one judged, the program unchanged - and a write from a BPF program
answers to none of them. That was true because nobody had written the other
thing yet. `cargo xtask ebpf-check`, which both ebpf.yml and release.yml run,
now reads the kernel source and refuses `FAST_ALLOW.insert`. Verified to exit 1
with the mutation and 0 without, because a check that prints an error and exits
zero leaves CI green.

And one thing this turned up in my own work: the first insertion anchored on the
`fn` line rather than the attribute above it, so `#[test]` ended up on the new
test and `process_wide_verdict_names_the_rule_that_answered` silently stopped
being a test. Clippy caught it. Restored, and the suite is one test larger than
it looked.
…d it

The whole matrix is green on the final shape, so `observed` can be what the
verifier reported rather than what it reported two commits ago. On 7.1, the
kernel this file is measured on: connect4 384 -> 335, connect6 389 -> 340,
sendmsg 297/299 -> 267/269. Against the pre-guard baseline of 318, the protocol
guard costs 17 instructions - which is what "a load and a compare" was supposed
to mean when I first claimed it and did not yet deserve to.

Two shape changes got it there: folding the protocol test into `granted`, and
moving the deadline read and `bpf_ktime_get_boot_ns` inside the `&&` so an
ungranted hook stops paying for a value only a granted one reads.

So `max` goes back to 450 and 350. I raised it to 600 and 400 when `observed`
stood at 384 and the headroom was 17%, and argued at the time that moving a
ceiling is what you do after a deliberate addition has been measured and
optimised. The optimising then continued and made that premise false. A tripwire
widened on a number that later improved, and left wide, is precisely what this
file exists to prevent - and this one caught two real regressions on the way, so
it has earned being kept tight. Worst case across the matrix is 366, on 6.12,
which leaves the same 1.3x margin this program has always had.
Read personally, every file in its final state rather than its diff - the
same lines were rewritten three or four times and a diff cannot show where
they landed. Fourteen files touched; one finding matters more than the rest.

The shortened deadline was written up, in seven places, as "5.10 to 5.14 get
the fast path". They do not. The kernel matrix's own guest logs say that
`sched_process_exit` has no readable `group_dead` on 5.10 and on 6.12, and
does on 6.18 - so `exit_precise` is false on the floor kernels and the ladder
refuses them one rung *before* `lifecycle_pinned` is ever consulted. That
refusal is right and stays: without exact exit detection the daemon's own
eviction is wrong while it is alive, and no deadline bounds a grant the
heartbeat keeps refreshing. What the shortened deadline actually serves is a
kernel that has `group_dead` but cannot pin a perf-event link - a modern
kernel with a read-only bpffs. Narrower than claimed, still worth having, and
the honest consequence is now written down: the fast path today needs a kernel
newer than anything RHEL ships, and the real question for reaching RHEL is
whether exit precision can be degraded rather than refused, which is harder
than the deadline was. TODO.md, CHANGELOG.md, ARCHITECTURE.md, the sample
config, the budget file, the shared constants and two doc comments all said
the wrong thing; the CHANGELOG also named the sendmsg hooks as what blocks
5.10 when the ladder never reaches them there.

A user-visible note had ~30 spaces embedded in it: the degradation warning was
written through a Python heredoc that treated `\`-newline as a continuation,
the same mistake that mangled the late-withdrawal reason two days ago. A grep
for string literals with long runs of spaces finds nothing else.

Three doc comments described designs already replaced. `mark_decision`'s own
doc still presented the connected-UDP hole as a live boundary, ten lines above
the allowlist that closes it; the sendmsg programs' doc said they were "the
only place a UDP socket is ever marked" - no UDP socket is; and
`SendmsgUnavailable` justified itself by a stale-UDP-mark scenario that can no
longer occur. Each now says what the code does and, where it changed, why.

`IPPROTO_TCP` had been inserted between `SOL_SOCKET`'s doc comment and its
target, so the socket-option constants lost their architecture note and the
protocol constant inherited it. `disarm()`'s doc named the start and shutdown
flushes as its callers; they have their own functions. `set_fast_allow_level`
said "called once"; it has six call sites and last-writer-wins is the whole
reason the loader publishes before spawning the heartbeat. `lifecycle_pinned`
and `FastAllow` still described the pin as a refusal. The xtask maintenance
note told the next person to add `LINK_*` constants to REQUIRED_SYMBOLS - those
are bpffs pin names, not ELF symbols, and the list can never contain them.
`resync` and `sweep_fast_allow` understated their /proc reads by half. The
shared constants said 2^32 guesses where the snippet, correctly, says 2^30
after the sieve. ARCHITECTURE said "more than a minute" for a deadline that is
sometimes six seconds; the CHANGELOG said the sendmsg hooks mark sockets; the
SELinux policy said the arming retry is every ten seconds. And TODO.md and the
budget file contradicted each other on whether removing the sendmsg programs
would unlock 5.10 - both premature, and both now say what actually decides it.

One test had the wrong doc comment: `only_lasting_allows_are_fast_allow_eligible`
carried a paragraph about rule expiry that belonged to
`an_expired_rule_is_not_compilable`, which had none. Moved back.

Two design observations recorded rather than acted on. The `Enforcement::Pinned`
rung's rationale reads oddly for Process mode, where every link and map dies
with the daemon and a stale grant cannot exist; and `SendmsgUnavailable` still
refuses the path for hooks whose security role has shrunk to defence in depth.
Both are decisions, not corrections.
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