implement the external code review, plus three defects it missed - #8
Merged
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
review-fixes: external review, test infrastructure, CI, and IPv6 echo origination
158 commits, 105 files, +11057/-389, off
mainat 8b8282e.This branch started as the implementation of a four-document external review
(code review, architecture, testing, symmetric deployment) and grew to cover
everything that review made reachable. Three quarters of it is test and CI
infrastructure, because most of the defects it fixes were on paths nothing
could execute.
Every behaviour change here has a before-arm on record: a failing test or a
capture taken against the unfixed build. Where a claim is reasoned rather
than measured, it says so.
1. Fast-path correctness (XDP)
087c9af). With anymultihop session configured,
prog_flagsbit 1 makes the parser defer theTTL verdict; a low-TTL packet for an unconfigured address pair then reached
the
cfg == NULLbranch and was passed to the stack. The comment thereclaimed an unconfigured pair must still drop, which was false outside
promiscuous mode. This was not in the review — it was found while verifying
one of its findings, and it is what turns a parity gap into a reachable
off-link spoof.
3fa4ffc), firstfragments only, with the port test short-circuited so
udp->destis neverread at a nonzero offset. Non-first fragments pass, deliberately: they have
no counter to witness, and counting fragments generally is not an error
signal.
eb4add9) withtheir own stat slot rather than passed. A malformed header stays counted and
passed — a broken header is not evidence of an attack — but a well-formed
header we cannot honour must not reach a userspace path that accepts it as
plain unauthenticated BFD.
bfd_hdr_validbecamebfd_hdr_verdict,matching the parser's sentinel idiom.
74e9e0d) instead of beingfiled as a BFD reject.
2e5edca).bpf_timer_initcanfail, its return was discarded, and
initedwas already set — so on akernel without timer support the sweep silently never armed and nothing said
so. Now captured in
init_errwith a stat slot. Deliberately not retried:on an unsupported kernel that runs a failing helper on every packet forever.
8696ea2).2. Validation parity between the two planes
The engine validated control packets in XDP and, separately, in userspace, and
the two disagreed. Closed in three parts:
a78a879).bfd_ctrl_check()inbfd_shared.h, called by both planes, taking host-order fields explicitlybecause the two byte-swap with different helpers. The divergence it exposed:
userspace never looked at
bfd->lenat all, so a packet claiming length 200inside a 24-byte datagram was accepted there and rejected in XDP. Fixing
that also required
MSG_TRUNCon all fourrecvmsgcalls, or the twoplanes would still have compared different lengths.
808ab96, then7815e87). Thefirst attempt used
IP_MINTTL/IPV6_MINHOPCOUNT. The namespace rig thenshowed both are inert on UDP — Linux enforces
IP_MINTTLonly for TCP — soa TTL-64 packet reached the session in both families. The v6 half had been
broken since long before this branch. Replaced with
IP_RECVTTL/IPV6_RECVHOPLIMITand a per-packet check, placed before the acceptancepredicate. A missing cmsg reads -1 and drops.
your_discmust name a session (4ff544d), or be zero with the peer inDown/AdminDown, across all four receive blocks. The old code fell back to
the address pair on any miss. This is XDP's own rule and has been running on
the live mesh for months, so it closes a divergence rather than inventing
policy.
3. Dataplane socket robustness
13819dd). Attach is now throughbpf_link_create, so the kernel detaches on process death includingSIGKILL. Before-arm on record: with the flags-based build,
pkill -KILLleft the program attached and answering control packets from a frozen
tx_config. Consequence for operators:ip link set dev X xdp offnolonger removes it — killing the owner does.
88a1eef). ThreeMSG_DONTWAITdrainsran to EAGAIN, so a sustained flood of frames XDP passes to the stack kept
the loop inside a drain and starved transmit, detect, dplane read and echo.
Reaching the stack needs only a well-formed header at TTL 255 naming an
unconfigured pair — trivially generatable, no session required. Not in the
review; found while deciding whether a dead-man switch was warranted, and it
is why one was not needed.
dp_sendtreated EAGAIN as fatal (ea67ef3).dp_connisO_NONBLOCK, so a full send buffer surfaced as an error whose only pathclosed the connection and orphaned every session. Replaced with a 64KB
outbox that every message goes through, flushed from the main loop.
Ordering is correct by construction because nothing bypasses the queue, and
partial writes fall out for free. All four disconnect paths now route
through one
dp_drop_conn(why)that clears the queue — a stale outboxotherwise carried into the next connection.
0441f6c), found by the fuzztarget on its first run with a corpus.
dp_processcan reply, a reply on afull queue calls
dp_drop_conn, which zeroesdp_havemid-loop; bothcounters are
size_t, so the subtraction underflowed and the loop walkedpast
dp_buf. Observed 42 bytes past the end. Fixed by making the loopcondition underflow-proof rather than by knowing which callee shrinks the
buffer. 45-byte reproducer committed.
4. Lifetime and configuration-change bugs
echo_peersandtx_configon shared peers and address moves(
343bb8f).echo_peerswas keyed on peer address alone, so tearing downone session removed echo for another sharing that peer; and
sm_addrsranunconditionally on the update path, so an address change leaked the old
kernel map entries. Now
echo_peer_refresh()re-derives membership byscanning the session table rather than refcounting — a refcount that drifts
by one silently enables or disables echo with no witness, while a 64-slot
rescan on a config event is free.
78547cd). An orderlylocal teardown previously made the peer burn a full detect timeout. Three
packets, because there is no retransmission once the slot is gone.
Deliberately not sent on the
--dp-holdorphan path, where the peer mustnot notice. Proven on the wire: the peer now reports neighbor signaled
session down rather than control detection time expired.
8845deb). Only the Up branch hadRFC 5880 §6.8.3 jitter, so a mesh coming up together synchronised into a
burst every second. Jittering 1s to 750ms is not an undershoot — §6.8.7
constrains
DesiredMinTxInterval, not the resulting gap.650ab2b).rx_pktsdid not exist for the userspace half, so non-ktx receive wasstructurally zero and
show bfd peers countersreported 0 input.b650227). The engine had the numbers allalong and the counters reply filled only the control fields.
5. Observability
9e4b5a6).BFD_STAT_LIST(X)inbfd_shared.h; the BPF side takes only the enum so no name strings land inthe object, and the harness parses the header rather than keeping a fourth
copy of the table. All twenty bare
count(N)calls became named constants.ee9e0de,b18ea0a), plus flap accountingand
last_reason. Written to a temp path and renamed so a reader never seesa half-written file; the handler only sets a flag, so nothing in it needs to
be async-signal-safe. This snapshot is what most of the test suites assert
against, and it is why the counter-fidelity fix above had to come first.
05013be,335c1db,4a6032a). State transitions stay atinfo deliberately: it is the most operationally valuable line the process
emits, and hiding it means an operator debugging a flap has to restart at a
higher verbosity to see what already happened.
cd7a7e9), which is what settled thetick-ladder question below.
by name (
184e5b8); the loader's age arithmetic no longer wraps when apacket races the now-snapshot (
aa693cd).6. Main loop restructure (architecture item 2.2)
2fac4ad),replacing the
SO_RCVTIMEOblocking receive that was the de facto clock.That timeout slept on the jiffy wheel, which put a hard floor under
detection resolution that no tuning could cross — measured, not assumed; see
the tick ladder below.
c1311a1), one batchmap fetch per pass instead of a lookup per session (
f6bd178), and thedplane fds are polled rather than called blind (
6c654b8).SO_RCVTIMEOand the sub-jiffy warning it justified were removed(
ad13120).f9ef2c2says soexplicitly rather than leaving it implied.
7. Multi-interface fast path (architecture item 2.3)
77e062e,e1fdd94). The ADDhas always carried
ifindexandifnameand the engine ignored both, so asingle-hop session bfdd placed on any interface other than
--kernel-txsilently ran userspace-only.
ktx_attach()is split intoktx_load()andan idempotent
ktx_attach_if(); the same loaded program is attached ondemand to whatever interface a session arrives on, with per-interface mode
fallback because a veth or a driver without native XDP refuses drv mode.
reconfiguration and a flapping link teardown is worse than the cliff it
fixes.
0591b2frecords what this was worth — the off-interface session could notstay up at all.
8. IPv6 echo origination
The echo originator was IPv4-only. The stated reason was an assumption nobody
had tested: that a neighbour's forwarding plane does not loop a self-addressed
IPv6 packet the way it loops a v4 one, inferred from FRR sourcing its v6
echoes at the peer.
It loops. Measured before writing any code, with a v4 control arm in the same
rig so a v6 zero could not be a broken injector: 10/10 returned at hop limit
254 with the neighbour forwarding, 0/10 with it off.
30470f4— seven v6 echo cases in the Layer 1 harness, landed first so thebefore-arm is in the history: the return with a known discriminator, the
same frame with one we never sent, 254-not-self, off-link, not-self at 255,
declined, and reflect.
6c2587d— the XDP half.parse.h's v6 branch dropped every return beforethe echo path was reached and read the UDP header only after the hop-limit
check, which is the v4 trap verbatim; the pointer moved and the GTSM check
gained the same narrow exception (echo port, hop limit 254, self-addressed).
echo_reflect_v6gained the return branch, ordered before GTSM with everymiss returning rather than falling through, so
BFD_STAT_ECHO_TTLstaysunreachable in both families.
9452f47— the engine half. Frame build split intoecho_build_v4/echo_build_v6over a shared payload writer, 86-byte v6 frame, UDP checksumfolded over the 40-byte pseudo-header, no IP checksum. A second stale family
gate was found in
stats.c, which reported echo as inactive on atransmitting v6 session;
activenow means the negotiated interval isnon-zero rather than the family is v4.
fd145c1— the wire test and its evidence. Two arms of one config with onlythe neighbour's sysctl changed: 295 frames sent in each, all with a valid
UDP checksum as judged by tcpdump rather than by our own fold; 0 returned
with forwarding off; 295 returned with it on, every returned payload
byte-identical to one that was sent.
9. Test infrastructure
Four layers, none of which existed at the branch point.
Layer 1 —
BPF_PROG_TEST_RUN(783563a…67a6ef2, and the v6 echocases). Loads the real object, builds synthetic frames, populates maps through
the syscall API, asserts verdict, returned frame and map deltas. Covers the
bounce field by field, the
adjust_tailtrim, the demux matrix including theno-fallback-on-miss rule, fragments, IP options, the malformed/unsupported
matrix, the sweep via a test-only BPF object, and the echo matrices. Runs in
seconds and needs no testbed.
Layer 2 — userspace unit suites (
65f9961,f16c9f4,9d25e25,6cdf973,ba79e48,c31a92f). The FSM transition table with no seamsrequired, detect timing and the RFC 5880 jitter bounds, bffdp framing over a
real socket torn at every boundary, session lifecycle in both families
including the address-move path, and shared detect-basis vectors driven
against both the engine and the XDP copy of the same rule.
Layer 3 — namespace rigs (
9b7f30e,b8da5c6,4db21d7,9852361,12e1714,cdd8943,f61e7b6). A veth/netns rig for the userspace receivepath — the path four review documents and two reviewers never reached, because
nothing could reach it. On its first working run it caught inert
IP_MINTTL,inert
IPV6_MINHOPCOUNT, and the unimplemented demux. Extended to end-to-endscenarios under pytest, multihop over a router namespace, and three scenarios
against stock FRR in containers.
Layer 4 — ABI, fuzz, and the harness itself (
a825576,96979d6,054120f,3f550b3)._Static_assertpins on every shared struct size,offset and enum value, generated by a probe rather than hand-written; a
libFuzzer target for the bffdp parser driven through a read seam rather than a
socket, because an earlier socket-based harness found only connection-lifecycle
bugs in itself.
Harness bugs fixed along the way, each of which had been producing vacuous
passes:
--onlywith a name matching nothing printed all cases passed withno cases (
88cd653); phantom sessions were picked by property and couldselect a live peer (
bb170fc);ip-optionsasserted a counter it no longerused and had been failing unnoticed for three weeks (
837e3b2); a map read byname matched every loaded engine (
41a8a01,1965bef);--jsonwas notmachine-readable (
dbcc745).10. CI
Five jobs on every pull request (
7de3346and the surrounding commits):and Layer 3 scenarios, FRR container scenarios.
comment. 18 and 19 must fail and the log must contain the expected
diagnostic, so a Makefile breakage cannot satisfy the expected-fail arms.
chosen kernel; adding one is a single matrix line.
bogus triple fails.
make checkon a stock machine with nothingpreinstalled, plus scan-build and the perf harness. Its value is the stock
machine — that is what caught the clang floor.
Plus a nightly fuzz workflow (
60f61e1) which cannot run until this merges,since scheduled workflows only fire from the default branch.
tools/pre-push(a669c5a) runs the injection matrix and refuses the push ona failing case, skipping cleanly when the testbed is absent — a hook that
blocks pushes on an unrelated machine gets disabled, and a disabled hook
catches nothing.
11. Benchmark reproduction
perf/bfdwire.pyandperf/check.py(0309498…7335d80) recompute thepublished benchmark numbers from the committed pcaps, reproducing results 1
and 3 to the last digit. Result 2 is checked with tolerances, because its
percentiles could not be reproduced exactly and gating on an unrecoverable
figure would gate on the reconstruction rather than on the result.
Two portability traps are handled and documented: parse the hex rather than
tcpdump's decode, because one tcpdump build renders UDP/3784 as a Broadcom
lawful-intercept dissector; and pass
-n, because otherwise addresses renderdifferently depending on the host's
/etc/hosts.12. Measurements, including three that corrected published claims
762de40,a81cfa6,ba1d338). The sweep interval isnow a load-time tunable, and the ladder that enabled shows a shorter sweep
giving a larger mean overshoot. A falsification arm at 100ms produced a
2.58ms maximum, which a 100ms quantizer cannot do. Confirmed in the code
afterwards: the only path to Down with diag 1 is userspace
fsm_detect; thekernel sweep clears a flag and emits a ringbuf event nobody consumes. The
README's attribution of detection overshoot to 5ms sweep quantization was
wrong. What this does not undermine: RX-clocked TX is about transmit, and
it stands.
ab46d1e,cd7a7e9,dbdea40,6418b18). The main-looptick does quantize detection, down to about one jiffy, and no further —
SO_RCVTIMEOslept on the jiffy wheel. Four explanations were falsifiedbefore this one. This is the measurement behind the timerfd restructure.
ca61f16,3a754fd). Under RT starvation the enginedetects a dead peer ~950ms late against a 30ms budget, bounded by the RT
throttle rather than by anything in the design. Engine self-report and an
independent hypervisor capture agree on all ten samples, differing by the
detect budget. The project is scheduler-immune in one direction only:
transmit is answered from softirq, but noticing a quiet peer runs entirely
in the loop being starved. The honest harm statement is a delayed local
notification, not a lie on the wire.
abf3e84,802aa99). Two RX-clocked ends produce690223 packets in 5 seconds against a configured 10ms interval, against 1043
with kernel-TX on one side. The README predicted the opposite — that two
such ends would fall silent — and flagged it untested. "Silence" reads as a
safe failure mode; a flood is not. Sessions stay Up while the link does not.
c9e8cfd,48ff9bb). With kernel-TX, 57 sessionsstayed Up for 60 seconds with the engine SIGSTOPped, ~2000x the detect
budget. A demonstrated consequence with no demonstrated cause: SIGSTOP is
synthetic and the one known wedge mechanism was removed by the drain bound.
Not fixed, and arguably should not be — a dead-man switch reintroduces the
failure mode RX-clocked TX exists to avoid.
13. Upstream FRR
Bugs found by running a real dataplane at scale, all reported and merged
separately: the unixc
sockaddrlength (#22621), the output buffer truncatingregistration bursts (#22645), the buffer never drained on shutdown (#22692),
show bfd peers counterstearing down the dplane connection (#22694), echointerval never negotiated for dataplane sessions (#22805), and the IPv6 echo
source-address regression (#22920).
14. Documentation
docs/gained evidence directories for each measurement — sweep ladder, tickladder, starved detection, symmetric ktx, wedged ktx, dp-fuzz, netns rig, v6
echo — each with the captures behind its numbers.
Seven documents were corrected for the two wrong claims above (
06aa950,2eed3b8,802aa99,a7e2583,2568503). Two counts in prose were foundstale in the same pass, which is why
tests/README.mdnow points at theX-macro rather than repeating a slot count.
Deliberately not done
measured and holds, but the proposed pacing gate is an architecture change.
Worth separating: §3.5's argument that the gate is a TX-rate governor
applies to the current asymmetric deployment too, since an on-link
attacker who reads
my_discoff our traffic can make the engine bounce atline rate. That is not addressed here.
nobody. Wiring it is a behaviour change — a new path to Down — and belongs
in its own commit with its own evidence.
_Static_assertas the answer to plane divergence. It shipped, but theearlier pushback stands and is recorded: both planes include the same header
from the same tree, so the failure that actually bites is a stale loaded
object against rebuilt userspace, which no compile-time assert can see. A
version field checked at attach time is still the fix.
Known gaps
validation used a veth carrying no live session.
echo_lostis a running total that never decrements, so it should not beread as a current loss figure.
xdp_ifindex, which names the--kernel-txinterface rather than everything the program now covers.Testing
All four unit suites, the injection matrix against the live mesh,
dp_holdwith a peer-side witness,
poll_final, the namespace rigs and the containerscenarios. CI green on all five jobs.