Line-card BFD for plain Linux · part 6 of 12

Hardening, and a review that found real edges

Part 5 ended with a working benchmark and an embarrassing list: no session continuity, no real validation, one session tested out of sixty-four, and three RFC 5880 features missing outright.

This part takes the first two off that list. It is the unglamorous middle of the project, where nothing gets faster and the interesting question is no longer “does it work” but “does it work when someone is trying to break it, or when the control plane restarts underneath it”.

Nothing here changes the steady-state path, so the numbers from part 5 stand unchanged throughout.

Surviving a control-plane restart

The most visible gap was that restarting bfdd dropped every session. The dataplane tore them down when the control plane disconnected and bfdd re-added them on reconnect, which is correct in the sense that nothing lies about state, and useless in the sense that a routine daemon restart takes your routes with it.

The fix is a hold mode, --dp-hold, that keeps wire sessions alive across the gap. On disconnect the sessions are orphaned rather than deleted. When bfdd comes back and re-adds them, they are adopted by address pair with discriminator continuity, and a mark-and-sweep pass reconciles anything that did not come back. I took the shape of this from Rafael Zalamena’s guidance on the FRR dev list, since the protocol’s intent matters more than my reading of it. Two back-to-back FRR restarts now produce zero peer-visible events.

It is not the default, and should not be. Drop-and-recreate is what the protocol expects unless an operator has decided otherwise, so continuity is something you ask for.

The same milestone added self-initiated Poll sequences on parameter change, per RFC 5880 s6.8.3, along with transitional transmission during the change. Before that, altering a timer mid-session could flap it against either side, which is a poor reward for reconfiguring something.

Refusing traffic properly

The validation story until this point was thin. It now enforces GTSM at TTL 255, demux validation per s6.8.6, and session creation gated on control-plane config so nothing can conjure a session by sending packets at the box. Everything that fails lands on XDP_DROP, so a rejected packet never reaches userspace at all rather than being filtered somewhere later.

The demux check is the one that does the real work against spoofing:

__u8 rstate = BFD_STATE(bfd);
if (cfg && cfg->my_disc) {
        __u32 ydisc = bpf_ntohl(bfd->your_disc);
        if (ydisc != cfg->my_disc &&
            !(ydisc == 0 && rstate <= 1)) {
                count(3);
                return XDP_DROP;
        }
}

A packet has to name our discriminator back at us. The exception is the reason it is not simply an equality test: a peer that has lost its state and is restarting legitimately sends your_disc of 0, so that is allowed, but only while it also reports Down or AdminDown. Without the state condition, zero becomes a wildcard anyone can send.

What makes this a validation fix rather than a parsing detail is what happens on success. A packet that gets past here refreshes the session’s liveness and gets echoed back by the transmit path. Anything that can forge its way through is therefore able to keep a dead session up indefinitely, which is precisely the failure BFD exists to prevent. All of it was tested by injecting from a third host.

One measurement note cost me an hour and is worth passing on. XDP_DROP consumes the frame before any capture hook sees it, so tcpdump shows you nothing at all. The only trustworthy signal that a drop happened is the map counters read through bpftool. A test that “sees no packets” is otherwise indistinguishable from a test that sent none, and I briefly believed the wrong one.

The review pass

An external code review of the three source files, taken seriously enough to answer each point on the wire.

The structural item was the shared ABI header. The BPF map value structs had been living as three hand-synchronised copies, one per file that touched them, which works right up until one side gains a field the others do not and the two halves start reading different offsets out of the same bytes. They became a single included definition.

Two of the flagged edges turned out to be real, and both are the same kind of mistake.

The first is IP options. Options push the UDP header to a variable offset, and the parser was reading the GTSM and discriminator checks from fixed positions. A packet carrying options therefore walked straight past both guards from the previous section, the two that exist specifically to stop that packet, and arrived in userspace unvalidated.

/* IP options (ihl != 5) on a UDP packet: a single-hop BFD
 * control packet never carries them. Passing would skip the
 * GTSM/your_disc checks below (UDP header sits at a variable
 * offset with options) and leak the packet to the userspace
 * socket unvalidated - the same bypass class as an XDP_PASS
 * reject. Drop it. */
if (iph->ihl != 5) {
        count(3);
        return XDP_DROP;
}
udp = (void *)(iph + 1);

The fix is three lines, and the last line is the reason they are needed: udp = (void *)(iph + 1) assumes a 20-byte header. That assumption is fine, and it is load-bearing, and nothing had been enforcing it. A single-hop BFD control packet never legitimately carries options, so refusing them outright is cheaper and safer than learning to parse them.

The second is bfddp framing. On a framing error the reader reset its buffer and carried on reading the same stream, which means it can resync onto arbitrary mid-stream bytes and start interpreting them as messages. It now drops the connection cleanly, and because --dp-hold already exists that drop turns into a hitless reconnect rather than an outage.

Two RFC-correctness fixes rode along: the s6.8.7 jitter cap at 90% when detect_mult is 1, and trimming an over-length echoed frame back to 24 BFD bytes with a recomputed checksum.

Every change was checked the same way as everything else here, with an injection harness and a capture. None touched the steady-state path, so the resilience numbers from part 5 stand unchanged.

The rejected suggestions, and the reasoning for rejecting them, are in the repo under docs/refactor-abi. Writing down why you did not take a suggestion is worth as much as the changes you did take, and it is the part people skip.

What I took from it

The two review comments I was most inclined to wave away were the two that were real. Both were of the form “what if the header is not where you think it is”, which is exactly the class of assumption that a fast path is built on and a parser has to defend.

Next

Sixty-four sessions, which is the next item on that list and the one that found the most bugs.