<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>Abdul Wasey: Writing</title><description>Notes on networking, the Linux data path, firmware, and self-hosted infrastructure.</description><link>https://w453y.me/</link><language>en-us</language><managingEditor>w453y.me@gmail.com (Abdul Wasey)</managingEditor><webMaster>w453y.me@gmail.com (Abdul Wasey)</webMaster><copyright>© 2026 Abdul Wasey</copyright><atom:link href="https://w453y.me/rss.xml" rel="self" type="application/rss+xml"/><item><title>Building xdp-bfd, part 10: under attack</title><link>https://w453y.me/writing/xdp-bfd-10-under-attack/</link><guid isPermaLink="true">https://w453y.me/writing/xdp-bfd-10-under-attack/</guid><description>What someone on the wire can do to xdp-bfd, from floods on the VMs to an hour of everything at once on real hardware, plus a neighbour that holds the key. Then where it&apos;s at, and looking back.</description><pubDate>Sat, 03 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Up to &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-9-real-hardware/&quot;&gt;part 9&lt;/a&gt;, the thing trying to break
xdp-bfd was always its own box being too busy. The other way to break BFD is
from the wire.&lt;/p&gt;
&lt;h2 id=&quot;who-i-was-defending-against&quot;&gt;Who I was defending against&lt;/h2&gt;
&lt;p&gt;The person I planned for is anyone on the same link who can see every packet
and send whatever they like, as fast as the link goes, but doesn’t have the
auth key. Plain BFD can’t stop them, since everything is sent in the clear and
they can just copy a real packet. What I could do was make sure authenticated
sessions can’t be faked, and that a flood aimed at one session doesn’t take the
others down with it.&lt;/p&gt;
&lt;h2 id=&quot;floods-on-the-vms&quot;&gt;Floods on the VMs&lt;/h2&gt;
&lt;p&gt;I started on the 64-session VM mesh in September. The first flood I tried was
junk sent to the BFD port. XDP was passing it up to userspace, where it filled
the socket queue and pushed real packets out, and three sessions went down. So
now XDP drops anything that isn’t a valid packet for a session it knows, and
with the same flood all 64 stayed up.&lt;/p&gt;
&lt;p&gt;Signed packets were the other worry, because checking a signature means
running the HMAC, and that’s expensive. So each authenticated session got a
budget of failures: after eight bad signatures within one detection time, its
packets get dropped without being checked until that time is up. Half a million
bad packets at one session only got hashed 248 times. That session went down,
and the other 63 didn’t notice.&lt;/p&gt;
&lt;h2 id=&quot;floods-at-1024-sessions&quot;&gt;Floods at 1024 sessions&lt;/h2&gt;
&lt;p&gt;Later in September, at 1024 sessions, three more floods got through, all using
packets that looked real enough that XDP couldn’t just drop them. One named a
real session from an unknown address. That has to go to userspace, because a
peer’s address can legitimately change, and it kept the engine busy enough that
221 sessions went down. Another spoofed echo packets, and bouncing them all
overflowed the transmit queue our real replies go out on. The last one used a
real session but changed its timers in every packet, so XDP sent a reply for
each one.&lt;/p&gt;
&lt;p&gt;Each of those got its own rate limit, and now only the session being flooded
can go down.&lt;/p&gt;
&lt;h2 id=&quot;floods-on-real-hardware&quot;&gt;Floods on real hardware&lt;/h2&gt;
&lt;p&gt;On the real machines from part 9, I flooded the xdp-bfd box at about 1.2
million packets a second, one kind of attack at a time. Only one of them took
down sessions it wasn’t aimed at, between 37 and 48 of them. That attack filled
the switch port to the xdp-bfd box. The switch puts packets marked CS6 in a
priority queue, and xdp-bfd and bfdd mark their BFD packets that way, except
bfdd’s IPv6 echo, which wasn’t marked. So those echoes waited behind the flood
and got dropped. I fixed the marking in FRR
(&lt;a href=&quot;https://github.com/FRRouting/frr/pull/23495&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#23495&lt;/a&gt;), and after that no
other sessions went down.&lt;/p&gt;
&lt;h2 id=&quot;the-worst-hour-i-could-set-up&quot;&gt;The worst hour I could set up&lt;/h2&gt;
&lt;p&gt;Then I tried everything at once, for an hour: a flood at full line rate
switching between five kinds of attack, while the stress on the box switched
between a priority-99 hog, CPU and timer load, and filling memory until it
swapped. That ended with 165,028 down events.&lt;/p&gt;
&lt;p&gt;The engine was fine the whole time. It was the box’s receive path that gave
out. Junk that wasn’t BFD was going up to the kernel on the same CPU that
handled BFD, and on this NIC one stuck receive queue held up the other three.
So I added an option to send the non-BFD traffic to other CPUs, turned off
Ethernet flow control so the driver drops per queue instead, and spread
incoming UDP across the queues by port. With those, the same hour came down to
4,148.&lt;/p&gt;
&lt;h2 id=&quot;a-neighbour-that-holds-the-key&quot;&gt;A neighbour that holds the key&lt;/h2&gt;
&lt;p&gt;The last case was a neighbour that does have the key and sends valid packets
on purpose. The failure budget never kicks in for those, since every signature
checks out, so each one cost a full HMAC, almost 40 times more than a packet
that gets dropped early. So I added a second limit in front of the HMAC that
counts every check, and allows twice as many as a real peer is ever allowed to
send. A check that fails gives its slot back, so someone without the key can’t
use up the real peer’s share. On normal traffic it never kicked in once.&lt;/p&gt;
&lt;h2 id=&quot;where-its-at&quot;&gt;Where it’s at&lt;/h2&gt;
&lt;p&gt;On real hardware, xdp-bfd now holds all 1022 sessions through the same load
that flapped stock bfdd 18,910 times. It does everything bfdd can hand a data
plane: RFC 5880, 5881 and 5883, with echo, demand mode and authentication. It
does keyed SHA1 the way bfdd does it, as an HMAC, and my
&lt;a href=&quot;https://github.com/FRRouting/frr/issues/23274&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;issue&lt;/a&gt; about that in FRR is
still open.&lt;/p&gt;
&lt;p&gt;Two xdp-bfd engines still can’t face each other, for the reason in part 2.
S-BFD and micro-BFD are what I want to do next, once the auth work is finished
on the FRR side too. On FRR, 11 of my fixes are merged but none are in a
release yet, and six more are still open.&lt;/p&gt;
&lt;h2 id=&quot;looking-back&quot;&gt;Looking back&lt;/h2&gt;
&lt;p&gt;When I went back through the bugs while writing this series, almost every one
was found in a packet capture and almost none in a log. The daemon in part 1
logged zero timeouts while its session was dying. The bug in part 2 showed up
as one missing packet that the logs never mentioned. The multihop test in part
5 passed while sending to the wrong port. Each time, the logs looked fine and
the capture showed what was really going on.&lt;/p&gt;
&lt;p&gt;Thanks for reading all ten parts. The code is at
&lt;a href=&quot;https://github.com/w453y/xdp-bfd&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;github.com/w453y/xdp-bfd&lt;/a&gt;, with the captures
on its &lt;code&gt;docs&lt;/code&gt; branch, and if you liked it, a star would mean a lot.&lt;/p&gt;</content:encoded><media:content url="https://w453y.me/uploads/cards/xdp-bfd-10-under-attack.png" medium="image" type="image/png"/><media:thumbnail url="https://w453y.me/uploads/cards/xdp-bfd-10-under-attack.png"/><category>bfd</category><category>xdp</category><category>security</category><category>measurement</category><category>Building xdp-bfd</category><author>w453y.me@gmail.com (Abdul Wasey)</author><enclosure url="https://w453y.me/uploads/cards/xdp-bfd-10-under-attack.png" length="41006" type="image/png"/></item><item><title>Building xdp-bfd, part 9: running it on real hardware</title><link>https://w453y.me/writing/xdp-bfd-9-real-hardware/</link><guid isPermaLink="true">https://w453y.me/writing/xdp-bfd-9-real-hardware/</guid><description>Every number so far came from VMs. On four real machines, stock bfdd flapped 18,910 times under load, and xdp-bfd found three bugs the VMs had hidden before it got to zero.</description><pubDate>Wed, 30 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every number up to &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-8-shipping-it/&quot;&gt;part 8&lt;/a&gt; came
from Proxmox VMs, with the hypervisor bridge as the thing watching the wire. I
wanted to know if any of it held up on real machines, with real NICs and no
hypervisor in the way.&lt;/p&gt;
&lt;h2 id=&quot;the-setup&quot;&gt;The setup&lt;/h2&gt;
&lt;p&gt;I used four machines on their own switch: one running xdp-bfd with FRR on top,
one FRR peer doing all its sessions in software, one to generate load, and one
that only captures. The first three are Intel i5-12500 boxes with Intel I210
NICs, with XDP running in native mode inside the NIC driver, on Ubuntu 26.04
with kernel 7.0.&lt;/p&gt;
&lt;p&gt;Without a hypervisor, I needed something else to watch the wire from outside.
The switch copies everything the xdp-bfd box and the peer send over to the
capture box, which timestamps each packet on its NIC. I checked that against a
capture on the peer itself, and the two agreed to within 0.2%.&lt;/p&gt;
&lt;p&gt;I moved the whole 1024-session test mesh over as it was, with every kind of
session from the earlier parts, plus 40 at &lt;code&gt;3x10ms&lt;/code&gt;. The stress ladder was the one
from &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-1-why-software-bfd-flaps/&quot;&gt;part 1&lt;/a&gt;, scaled up to 12
threads.&lt;/p&gt;
&lt;h2 id=&quot;stock-bfdd-first&quot;&gt;Stock bfdd first&lt;/h2&gt;
&lt;p&gt;I started with plain FRR bfdd doing all 1022 sessions itself. The timer load
(L3) didn’t hurt it at all. The real-time load (L4) did: &lt;strong&gt;18,910 flaps across
957 of the 1022 sessions&lt;/strong&gt;, including the ones with 1 second timers. bfdd
stalled for up to 2.5 seconds at a time, and it kept flapping for a while after
the stress had stopped.&lt;/p&gt;
&lt;p&gt;Then I ran xdp-bfd exactly as it had run on the VMs. It did a lot better, but
it wasn’t zero: 698 flaps across 213 sessions. On the VMs the ladder had
come out clean, but those runs had 64 sessions, not 1022.&lt;/p&gt;
&lt;h2 id=&quot;three-things-the-vms-hadnt-shown&quot;&gt;Three things the VMs hadn’t shown&lt;/h2&gt;
&lt;p&gt;The first one was the dead-man gate from &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-7-checking-my-own-work/&quot;&gt;part
7&lt;/a&gt;, which stops XDP answering once
userspace has been silent for longer than a bound, which was 1 second. But
under real-time load, Linux lets real-time tasks use 95% of each one-second
period, and normal tasks only get the last sliver of it. A normal task stuck
behind real-time hogs ends up running about once a second, so the gaps in the
userspace loop came out between 1.05 and 2.1 seconds. The check was taking down
sessions that were fine. The default bound is 3 seconds now.&lt;/p&gt;
&lt;p&gt;The second was in demand mode. Those sessions were timing out their own Poll
even after the peer had answered it. That turned out to have three separate
causes, and I fixed all of them.&lt;/p&gt;
&lt;p&gt;The third was in the package from part 8. Its systemd unit ran the engine at
real-time priority 10, below the priority-50 load, so under L4 it got no CPU at
all and 957 sessions went down, once each. It runs at 60 now.&lt;/p&gt;
&lt;p&gt;With those three fixed, the ladder came out at &lt;strong&gt;0 flaps on all 1022
sessions&lt;/strong&gt;. The &lt;code&gt;3x10ms&lt;/code&gt; sessions had a largest gap of 20.2ms against their
30ms budget.&lt;/p&gt;
&lt;h2 id=&quot;upgrading-under-load&quot;&gt;Upgrading under load&lt;/h2&gt;
&lt;p&gt;Part 8 said &lt;code&gt;systemctl reload&lt;/code&gt; and package upgrades hand over without dropping
anything. On the real machines I found that neither did. Reload never worked at
all, because of a check on the process name, and a package upgrade stopped the
engine before installing the new one.&lt;/p&gt;
&lt;p&gt;After fixing both, I installed a new package with &lt;code&gt;dpkg -i&lt;/code&gt; over a running
engine with 1024 sessions. The handover took 10 to 31ms, and the peer saw 0
sessions go down. The packaged engine also went through the whole ladder with 0 flaps, using
about 2% of one core for 1024 sessions.&lt;/p&gt;
&lt;h2 id=&quot;where-the-replies-were-waiting&quot;&gt;Where the replies were waiting&lt;/h2&gt;
&lt;p&gt;Next I looked at how long replies took. Inside XDP, building a reply
takes about 1.5 microseconds. On the wire the replies were leaving 118
microseconds after the peer’s packet arrived, and 250 at p99.&lt;/p&gt;
&lt;p&gt;My first guesses were interrupt moderation and CPU sleep states, but turning
either of them off on its own didn’t change much. It turned out to be PCIe power saving (ASPM) on the
link to the NIC, which puts the link to sleep between packets. With ASPM off it dropped to 33 microseconds. On these boxes the firmware owns ASPM, so it has to be turned
off in the BIOS or with &lt;code&gt;setpci&lt;/code&gt;. BFD timers are in milliseconds, so none of
this changes detection.&lt;/p&gt;
&lt;h2 id=&quot;priority-60-isnt-enough-either&quot;&gt;Priority 60 isn’t enough either&lt;/h2&gt;
&lt;p&gt;The engine was at priority 60 now, but that only works as long as nothing runs
above it. That’s the same problem I had with priority 90 back in &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-2-getting-tx-out-of-userspace/&quot;&gt;part
2&lt;/a&gt;. So I tried a priority-99
hog against the engine at 60, and got 959 down events.&lt;/p&gt;
&lt;p&gt;Linux has another scheduling class, SCHED_DEADLINE, where a task reserves a
slice of CPU time in every period, and the kernel runs it ahead of every
real-time priority. So the engine now reserves 1ms in every 10ms. Against the
same priority-99 hog, that gave 0 flaps. Even 0.2ms was enough. If the kernel refuses the
reservation, for example because the service is pinned to only some of the
CPUs, the engine logs why and keeps running at priority 60.&lt;/p&gt;
&lt;p&gt;Next is what happens when the problem isn’t a busy box but someone on the wire
trying to break it: &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-10-under-attack/&quot;&gt;part 10&lt;/a&gt;.&lt;/p&gt;</content:encoded><media:content url="https://w453y.me/uploads/cards/xdp-bfd-9-real-hardware.png" medium="image" type="image/png"/><media:thumbnail url="https://w453y.me/uploads/cards/xdp-bfd-9-real-hardware.png"/><category>bfd</category><category>xdp</category><category>linux</category><category>measurement</category><category>Building xdp-bfd</category><author>w453y.me@gmail.com (Abdul Wasey)</author><enclosure url="https://w453y.me/uploads/cards/xdp-bfd-9-real-hardware.png" length="45666" type="image/png"/></item><item><title>My fixes to FRR’s BFD daemon</title><link>https://w453y.me/writing/frr-bfdd-fixes/</link><guid isPermaLink="true">https://w453y.me/writing/frr-bfdd-fixes/</guid><description>Eleven fixes to bfdd that have been merged into FRR so far. Most came from using bfdd with xdp-bfd, the rest from adding demand mode and authentication.</description><pubDate>Mon, 28 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;While building &lt;a href=&quot;https://w453y.me/writing/series/line-card-bfd/&quot;&gt;xdp-bfd&lt;/a&gt;, I ran FRR’s bfdd with
xdp-bfd as its data plane. Not many people use that part of bfdd, and I kept
running into bugs in it. Later on, adding demand mode and authentication turned
up a few more. So far eleven of my fixes have been merged, and this post goes
through them in roughly the order I hit them.&lt;/p&gt;
&lt;p&gt;All of them are on FRR’s master branch, and none are in a release yet. Where
there’s a workaround for released versions, I’ve put it in. Also, if you want
to use a data plane at all, you need FRR 10.1.2 or newer, because older versions
keep dropping the connection to it.&lt;/p&gt;
&lt;h2 id=&quot;data-plane-plumbing&quot;&gt;Data plane plumbing&lt;/h2&gt;
&lt;h3 id=&quot;the-unix-socket-transport-never-worked&quot;&gt;The UNIX socket transport never worked&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/FRRouting/frr/pull/22621&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#22621&lt;/a&gt;. This was the very first
one. bfdd and xdp-bfd were running on the same box, so I wanted them to talk
over a UNIX socket, but every connect failed with &lt;code&gt;EINVAL&lt;/code&gt;. The same setup over
TCP worked fine.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;strace&lt;/code&gt; showed the socket path was right, but the address length bfdd passed
in was 112 bytes, and a UNIX socket address is only 110. It turns out bfdd
keeps addresses in a union big enough for every address type, and it was
passing the size of the whole union instead of the size of the address inside
it. TCP sockets don’t mind a length that’s too long, but UNIX sockets check it
and refuse it. Going by the git history, as far as I can tell &lt;code&gt;unixc:&lt;/code&gt; never
worked on Linux. The fix passes the real length. On released versions, use TCP.&lt;/p&gt;
&lt;h3 id=&quot;sessions-lost-to-an-8kb-buffer-twice&quot;&gt;Sessions lost to an 8KB buffer, twice&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/FRRouting/frr/pull/22645&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#22645&lt;/a&gt; and
&lt;a href=&quot;https://github.com/FRRouting/frr/pull/22692&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#22692&lt;/a&gt;. When I went from 16
sessions to 64, bfdd silently lost every session after the 40th when it
connected to xdp-bfd. On shutdown it had the same problem the other way round
and lost the last few delete messages. This one has &lt;a href=&quot;https://w453y.me/writing/bfdd-dataplane-buffer-overflow/&quot;&gt;its own
post&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;a-zero-byte-read-that-looked-like-a-closed-socket&quot;&gt;A zero-byte read that looked like a closed socket&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/FRRouting/frr/pull/22694&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#22694&lt;/a&gt;. Running &lt;code&gt;show bfd peers counters&lt;/code&gt; dropped the connection to xdp-bfd every second time I ran it. This
one has &lt;a href=&quot;https://w453y.me/writing/bfdd-counters-teardown-zero-length-read/&quot;&gt;its own post&lt;/a&gt; too.&lt;/p&gt;
&lt;h2 id=&quot;echo&quot;&gt;Echo&lt;/h2&gt;
&lt;h3 id=&quot;echo-interval&quot;&gt;Echo interval&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/FRRouting/frr/pull/22805&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#22805&lt;/a&gt;. BFD peers tell each other
how often they’re willing to receive echo packets, and you’re not supposed to
send faster than that. While I was adding echo to xdp-bfd, I had a peer asking
for echoes every 200ms, and bfdd was telling xdp-bfd to send them every 50ms,
which was just my local setting.&lt;/p&gt;
&lt;p&gt;bfdd does work out the right interval, but only in the code where it handles
packets itself. For a session handed to a data plane that code never runs, and
the one other place that could have done it skips data plane sessions on
purpose. The confusing part was that bfdd had the peer’s 200ms stored, and
&lt;code&gt;show bfd peer&lt;/code&gt; displayed it, so everything looked right in the CLI. I only
noticed because I was measuring how often echoes actually went out. The fix
uses whichever of the two intervals is slower, and tells the data plane
whenever that changes.&lt;/p&gt;
&lt;h3 id=&quot;ipv6-echo-from-the-wrong-address&quot;&gt;IPv6 echo from the wrong address&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/FRRouting/frr/pull/22920&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#22920&lt;/a&gt;. With IPv6, turning on
echo mode could take a perfectly good session down.&lt;/p&gt;
&lt;p&gt;An earlier change in bfdd made it only bounce back echo packets whose source
address matches a session it knows, which is a sensible safety check. But
bfdd’s own IPv6 echo socket isn’t tied to an address, so the kernel picks the
source for it. If the interface has more than one address in the same network,
the kernel can pick one the peer doesn’t know about. The peer drops the echo,
and the session goes down. The fix sets the source address on each echo packet.
It has to be per packet, because the same socket sends echoes for every
session.&lt;/p&gt;
&lt;p&gt;Writing the test took longer than the fix. I needed Linux to pick the wrong
address on purpose, and adding a second address didn’t do it, because Linux
kept using the older one. What worked in the end was marking the right address
as deprecated, so the kernel avoids it. The test also checks that the kernel
really did pick the wrong address, so it can’t quietly pass if that behaviour
ever changes.&lt;/p&gt;
&lt;h2 id=&quot;demand-mode&quot;&gt;Demand mode&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/FRRouting/frr/pull/23023&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#23023&lt;/a&gt;. Demand mode lets two BFD
peers stop sending regular packets once both sides are up, and only check the
path when they need to. bfdd had the option defined, but it didn’t do anything.
The flag was set to 0 in three places and never to 1, and there were &lt;code&gt;TODO: demand mode&lt;/code&gt; comments sitting in the code. I talked the design through with
Rafael Zalamena first, then implemented it, including passing it through to
data planes.&lt;/p&gt;
&lt;h3 id=&quot;zero-discriminator-checked-against-the-wrong-state&quot;&gt;Zero discriminator checked against the wrong state&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/FRRouting/frr/pull/23284&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#23284&lt;/a&gt;. While I was testing
demand mode, I restarted one side and it never came back. The side that stayed
up just kept refusing the restarted peer’s packets.&lt;/p&gt;
&lt;p&gt;A peer that has restarted sends a zero where the other side’s discriminator
should be, and the RFC says to accept that as long as the packet says the peer
is Down. bfdd was checking whether its own session was Down instead. Normally
nobody notices, because the detection timer runs out, the session goes Down,
and the next packet gets through. In demand mode that timer doesn’t run, so
the session just stayed stuck. The fix checks the state in the packet, like
the RFC says.&lt;/p&gt;
&lt;h2 id=&quot;auth&quot;&gt;Auth&lt;/h2&gt;
&lt;h3 id=&quot;the-replay-check-never-ran&quot;&gt;The replay check never ran&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/FRRouting/frr/pull/23281&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#23281&lt;/a&gt;. With keyed SHA1, every
packet carries a sequence number, and bfdd is supposed to reject old or
repeated ones so nobody can replay captured packets. It turns out that check
never ran. bfdd decided whether it had seen a sequence number yet by checking
if the last one was zero, and the only code that ever updated it was inside
that same check. So it stayed at zero forever, and &lt;strong&gt;every packet was
accepted, replays included&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;FRR’s existing auth test never caught this, because two well-behaved BFD
implementations never send a bad sequence number. So the test I added uses a
small BFD speaker that sends its sequence numbers out of order on purpose.&lt;/p&gt;
&lt;h3 id=&quot;no-usable-key-meant-no-auth-at-all&quot;&gt;No usable key meant no auth at all&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/FRRouting/frr/pull/23282&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#23282&lt;/a&gt;. With a 20-byte SHA1 key,
bfdd sent plain unauthenticated packets, while &lt;code&gt;show bfd peer&lt;/code&gt; still showed the
keychain.&lt;/p&gt;
&lt;p&gt;bfdd limited every key to 16 bytes, which is the limit for simple passwords,
but SHA1 keys can be up to 20. With a longer key, bfdd found no usable key and
quietly fell back to no authentication. A key with no algorithm set did the
same thing. Now the length limit depends on the algorithm, and if a keychain
has no usable key, the session doesn’t come up at all instead of running
without auth. Anyone who was running without auth by accident will see their
session go down after upgrading, so I pointed that out in the PR.&lt;/p&gt;
&lt;h3 id=&quot;auth-keys-never-reached-the-data-plane&quot;&gt;Auth keys never reached the data plane&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/FRRouting/frr/pull/23331&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#23331&lt;/a&gt;. Once xdp-bfd could sign
packets itself, I found that bfdd had no way to give it the keys. A session
with authentication got handed to the data plane and went out with no
authentication at all, while &lt;code&gt;show bfd peer&lt;/code&gt; still said auth was on. Now bfdd
sends the keys that are usable now and the ones that will be later, with the
times each one is valid, so the data plane can switch keys by itself. &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-6-auth-and-hitless-restart/&quot;&gt;Part 6
of the xdp-bfd series&lt;/a&gt; has more
on this.&lt;/p&gt;
&lt;h2 id=&quot;still-in-review&quot;&gt;Still in review&lt;/h2&gt;
&lt;p&gt;These six were still open in early October. Five of them are approved and waiting to be merged; #23465 hasn’t been reviewed yet.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/FRRouting/frr/pull/23453&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#23453&lt;/a&gt;: don’t send an echo
interval to the data plane when the peer says it can’t receive echo&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/FRRouting/frr/pull/23463&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#23463&lt;/a&gt;: let a data plane tell
bfdd what it supports, so authenticated sessions only go to one that can
handle them&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/FRRouting/frr/pull/23464&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#23464&lt;/a&gt;: forget the peer’s
discriminator after a detection time, which fixes a case where a restarted
peer stays down&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/FRRouting/frr/pull/23465&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#23465&lt;/a&gt;: don’t open echo sockets
when a data plane handles the packets&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/FRRouting/frr/pull/23495&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#23495&lt;/a&gt;: send IPv6 echo with the
same traffic class as the rest of BFD, so a congested switch doesn’t drop it
first. Found in the flood tests in &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-10-under-attack/&quot;&gt;part 10&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/FRRouting/frr/pull/23496&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#23496&lt;/a&gt;: open the echo sockets for
a session that’s enabled after bfdd starts. Since a change in FRR 10.7.0, echo
sessions set up that way got no sockets and kept flapping.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Thanks to Rafael Zalamena, Russ White, Donald Sharp and the other FRR reviewers for
reviewing the ones that are merged.&lt;/p&gt;</content:encoded><media:content url="https://w453y.me/uploads/cards/frr-bfdd-fixes.png" medium="image" type="image/png"/><media:thumbnail url="https://w453y.me/uploads/cards/frr-bfdd-fixes.png"/><category>bfd</category><category>frr</category><category>rfc-5880</category><category>Fixing FRR’s BFD daemon</category><author>w453y.me@gmail.com (Abdul Wasey)</author><enclosure url="https://w453y.me/uploads/cards/frr-bfdd-fixes.png" length="51889" type="image/png"/></item><item><title>Building xdp-bfd, part 8: shipping it</title><link>https://w453y.me/writing/xdp-bfd-8-shipping-it/</link><guid isPermaLink="true">https://w453y.me/writing/xdp-bfd-8-shipping-it/</guid><description>Packaging xdp-bfd so it runs without root, finding out which kernels and FRR versions it actually works with, and a bug that only showed up on VyOS.</description><pubDate>Tue, 22 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Until September, the only place xdp-bfd had ever run was my own test setup,
built from source. I wanted it to be something someone else could install next
to their FRR and just run.&lt;/p&gt;
&lt;h2 id=&quot;packages&quot;&gt;Packages&lt;/h2&gt;
&lt;p&gt;I made packages for Debian, Ubuntu and Fedora, and I wanted them to run without
root. So I went through what the engine actually needs, and it came down to
four capabilities. Two are for loading the XDP program, one is for attaching it
to an interface, and the last is for the raw socket that echo packets go out
on. The service runs as its own user with just those four.&lt;/p&gt;
&lt;p&gt;Older distros couldn’t even build it, because it needed clang 20. The problem
was a 32-bit atomic operation that older clang versions can’t compile for BPF.
Making those atomics 64-bit fixed it, and it builds with clang 17 now.&lt;/p&gt;
&lt;h2 id=&quot;which-kernels&quot;&gt;Which kernels&lt;/h2&gt;
&lt;p&gt;For kernels, I stopped going by version numbers and just checked whether the
XDP program loads. Most kernels from 6.1 up are fine. AlmaLinux 9 says it’s
5.14 and still loads it, because Red Hat backports the BPF parts of newer
kernels. Debian 11 and the stock kernel on Ubuntu 22.04 don’t.&lt;/p&gt;
&lt;h2 id=&quot;which-frr&quot;&gt;Which FRR&lt;/h2&gt;
&lt;p&gt;The FRR version mattered more, so I ran the FRR tests against 13 releases and
master. Everything older than 10.1.2 connected to xdp-bfd and then dropped the
connection about once a second, and no session ever came up. bfdd was going
into its reconnect code right after connecting and closing the socket. That
was fixed upstream in 10.1.2, so that’s the oldest version that works.&lt;/p&gt;
&lt;p&gt;Even on newer releases, two of the bugs from &lt;a href=&quot;https://w453y.me/writing/frr-bfdd-fixes/&quot;&gt;my FRR
post&lt;/a&gt; still apply, because the fixes are only on
master. The UNIX socket doesn’t work, so it has to be TCP, and bfdd only gets
about 58 sessions across to xdp-bfd each time it connects.&lt;/p&gt;
&lt;h2 id=&quot;vyos&quot;&gt;VyOS&lt;/h2&gt;
&lt;p&gt;Then I tried it on VyOS, which comes with its own FRR. Sessions came up on the
fast path, except the ones configured without a local address. bfdd was handing
those to xdp-bfd with the local address left blank. XDP looks sessions up by
both addresses, so nothing ever matched, and the peer’s packets got dropped as
unknown. Now xdp-bfd fills in the address the kernel would use to reach that
peer.&lt;/p&gt;
&lt;h2 id=&quot;restarts-and-the-readme&quot;&gt;Restarts and the README&lt;/h2&gt;
&lt;p&gt;The restart handover from part 6 went into the package too, so &lt;code&gt;systemctl reload&lt;/code&gt; and package upgrades would hand over without dropping anything. Both
passed CI. Neither actually worked on a real install, which I only found out in
part 9.&lt;/p&gt;
&lt;p&gt;The README had grown to 786 words by then. I cut it down to 266, just what
xdp-bfd is and how to build and run it, and moved everything else to the wiki.&lt;/p&gt;
&lt;p&gt;Next, &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-9-real-hardware/&quot;&gt;running it on real hardware&lt;/a&gt;.&lt;/p&gt;</content:encoded><media:content url="https://w453y.me/uploads/cards/xdp-bfd-8-shipping-it.png" medium="image" type="image/png"/><media:thumbnail url="https://w453y.me/uploads/cards/xdp-bfd-8-shipping-it.png"/><category>bfd</category><category>xdp</category><category>packaging</category><category>frr</category><category>Building xdp-bfd</category><author>w453y.me@gmail.com (Abdul Wasey)</author><enclosure url="https://w453y.me/uploads/cards/xdp-bfd-8-shipping-it.png" length="39765" type="image/png"/></item><item><title>Interviewing with Jane Street for a Linux Engineer role</title><link>https://w453y.me/writing/jane-street-linux-engineer-interview/</link><guid isPermaLink="true">https://w453y.me/writing/jane-street-linux-engineer-interview/</guid><description>Interviewing with Jane Street for a Linux Engineer role: the basics I made sure of, the deeper Linux work I do, and how it went.</description><pubDate>Wed, 16 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In August I interviewed with Jane Street for a Linux Engineer role, and I
didn’t get it. They asked me not to share anything about the interview itself,
so this post doesn’t: no questions, no tasks, nothing from inside it. What I can
share is the ground I made sure I had covered going in, because none of that is
theirs. It’s the same ground any Linux, systems or infrastructure role expects
you to know.&lt;/p&gt;
&lt;figure&gt;
  &lt;img width=&quot;1275&quot; height=&quot;694&quot; src=&quot;https://w453y.me/uploads/writing/jane-street/invite.png&quot; alt=&quot;Email from Jane Street recruiting dated 12 August 2026 with the subject Jane Street - Linux Engineering Opportunity / Abdul - Interview, inviting me to interview for a 2027 new grad role with their Linux Engineering team in Hong Kong and asking for my availability. The recruiter&amp;#x27;s name is blacked out&quot; loading=&quot;lazy&quot;&gt;
  &lt;figcaption&gt;The invite.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure&gt;
  &lt;img width=&quot;1275&quot; height=&quot;438&quot; src=&quot;https://w453y.me/uploads/writing/jane-street/thanks.png&quot; alt=&quot;Email from careers@janestreet.com dated 26 August 2026 with the subject Thank you for interviewing with Jane Street, thanking me for interviewing and saying they decided not to move forward with my candidacy&quot; loading=&quot;lazy&quot;&gt;
  &lt;figcaption&gt;And the email after it.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Before writing this, I asked if I could write about the interview, and whether
they’d rather I didn’t name them. This was the reply, and it’s why there’s
nothing from the interview itself in here.&lt;/p&gt;
&lt;figure&gt;
  &lt;img width=&quot;1275&quot; height=&quot;318&quot; src=&quot;https://w453y.me/uploads/writing/jane-street/ask.png&quot; alt=&quot;My email of 31 August 2026 to Jane Street recruiting, asking whether I could write publicly about what I learned from preparing for and doing the interview, keeping to general Linux concepts rather than the specific tasks, and whether they&amp;#x27;d prefer I not mention Jane Street by name. The recruiter&amp;#x27;s name is blacked out&quot; loading=&quot;lazy&quot;&gt;
  &lt;figcaption&gt;What I asked.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure&gt;
  &lt;img width=&quot;1275&quot; height=&quot;331&quot; src=&quot;https://w453y.me/uploads/writing/jane-street/reply.png&quot; alt=&quot;Reply from Jane Street recruiting on 2 September 2026, asking that I not publicly share anything about my Jane Street interview experience, since they consider the interview process and content intellectual property and want the questions to stay useful for future candidates. The recruiter&amp;#x27;s name is blacked out&quot; loading=&quot;lazy&quot;&gt;
  &lt;figcaption&gt;Their reply.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Most of my day-to-day work is kernel and data plane code. Before the interview
I went over the fundamentals around it as well, from the kernel boundary
outwards.&lt;/p&gt;
&lt;h2 id=&quot;the-basics&quot;&gt;The basics&lt;/h2&gt;
&lt;p&gt;These are what any Linux, systems or infrastructure role expects you to know:
syscalls and what &lt;code&gt;strace&lt;/code&gt; shows, &lt;code&gt;fork&lt;/code&gt; and &lt;code&gt;exec&lt;/code&gt;, threads and processes,
signals and process states (D state, orphans, zombies, PID 1), virtual memory,
page faults and overcommit, inodes and permissions, file descriptors, pipes and
redirection order, what the shell expands before a program runs, the boot path
from firmware to systemd, load and iowait, and sockets and TCP states. I wanted
to be able to explain each of them without looking anything up.&lt;/p&gt;
&lt;h2 id=&quot;the-deeper-parts&quot;&gt;The deeper parts&lt;/h2&gt;
&lt;p&gt;Most of my own time goes into the areas below, and I’ve written about all of
them here.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;XDP and eBPF.&lt;/strong&gt; In &lt;a href=&quot;https://w453y.me/writing/series/line-card-bfd/&quot;&gt;xdp-bfd&lt;/a&gt;, replies are
built inside an XDP program by rewriting the incoming packet. That meant BPF
timers, keeping BPF maps consistent with userspace, pinning maps and links so a
new process can take over without dropping anything, and fitting SHA1 into the
512-byte BPF stack.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Scheduling.&lt;/strong&gt; &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-1-why-software-bfd-flaps/&quot;&gt;Part 1&lt;/a&gt; of that
series is about how the normal scheduler picks the next task and what
SCHED_FIFO does to everything below it. &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-9-real-hardware/&quot;&gt;Part
9&lt;/a&gt; gets into real-time throttling, and why a
SCHED_DEADLINE reservation is the only thing that outranks a priority-99 task.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The receive path.&lt;/strong&gt; On real hardware I had to deal with NIC queues, flow
control and moving work to other CPUs with a cpumap, and I found PCIe power
saving showing up as reply latency. Those are in part 9 and &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-10-under-attack/&quot;&gt;part
10&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TCP and tunnels.&lt;/strong&gt; My &lt;a href=&quot;https://w453y.me/writing/offsite-backups-tcp-congestion-control/&quot;&gt;offsite backups&lt;/a&gt;
were slow because cubic collapses on a small amount of loss over a long path,
and switching to BBR with &lt;code&gt;fq&lt;/code&gt; pacing fixed it. The same post covers why a
WireGuard peer behind NAT needs a keepalive.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Routing daemons.&lt;/strong&gt; I have eleven fixes merged in FRR’s BFD daemon, from its
data plane protocol to authentication, and they’re all in &lt;a href=&quot;https://w453y.me/writing/frr-bfdd-fixes/&quot;&gt;the FRR
post&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;how-it-went&quot;&gt;How it went&lt;/h2&gt;
&lt;p&gt;From my side, the interview went well. I got through everything within the
hour, and it wrapped up around the 56th minute. I don’t know what Jane Street’s
criteria are, so I can’t say what decided it. Better luck next time.&lt;/p&gt;</content:encoded><media:content url="https://w453y.me/uploads/cards/jane-street.png" medium="image" type="image/png"/><media:thumbnail url="https://w453y.me/uploads/cards/jane-street.png"/><category>linux</category><category>interviews</category><category>learning</category><author>w453y.me@gmail.com (Abdul Wasey)</author><enclosure url="https://w453y.me/uploads/cards/jane-street.png" length="42762" type="image/png"/></item><item><title>Building xdp-bfd, part 7: checking my own work</title><link>https://w453y.me/writing/xdp-bfd-7-checking-my-own-work/</link><guid isPermaLink="true">https://w453y.me/writing/xdp-bfd-7-checking-my-own-work/</guid><description>Getting the code reviewed, an engine that kept answering after it died, fuzzing, and a test that had been passing for weeks without ever running.</description><pubDate>Tue, 15 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;While I was building the things in &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-6-auth-and-hitless-restart/&quot;&gt;part
6&lt;/a&gt;, from the middle of August to
the end of September, I spent just as much time checking what I already had.
Most of xdp-bfd runs inside the kernel, where I can’t step through it or print
anything, and nearly all the tests were ones I’d written myself while building
each feature.&lt;/p&gt;
&lt;h2 id=&quot;the-review&quot;&gt;The review&lt;/h2&gt;
&lt;p&gt;I started by getting someone else to go through all of it. The first thing
they found was that the two halves of xdp-bfd didn’t agree with each other. XDP
and userspace both look at incoming BFD packets, and each had its own checks. A
packet that said it was 200 bytes long but was really 24 got dropped by XDP and
accepted by userspace, because userspace never looked at the length. I moved
the checks into one function that both of them call.&lt;/p&gt;
&lt;p&gt;The TTL check had a worse problem. Userspace asked the kernel to drop anything
below TTL 255 using &lt;code&gt;IP_MINTTL&lt;/code&gt;, but when I tested it in network namespaces, a
TTL-64 packet went straight through. It turns out Linux only enforces that
option for TCP. For UDP it does nothing, so the check had never worked. Now
userspace reads the TTL off each packet itself.&lt;/p&gt;
&lt;h2 id=&quot;dead-but-still-answering&quot;&gt;Dead but still answering&lt;/h2&gt;
&lt;p&gt;The review also asked what happens when the engine dies. I killed it with
&lt;code&gt;kill -9&lt;/code&gt;, and XDP kept answering the peer anyway, using whatever config it had
last. The peer had no idea anything was wrong. An XDP program normally stays
attached after the process that loaded it is gone, so I switched to attaching
it through a BPF link, which the kernel removes when its owner dies.&lt;/p&gt;
&lt;p&gt;A stuck engine was harder. At the end of August I froze it for 15 seconds, and
55 of 64 sessions stayed Up the whole time. It’s the same thing that kept
sessions alive under load in part 2, except this time XDP was telling the peer
everything was fine for an engine that wasn’t running at all. So in September I
added a dead-man gate. The engine bumps a heartbeat every time its loop goes
round, and if the heartbeat gets older than a second, XDP stops answering and
lets the peers time out on their own.&lt;/p&gt;
&lt;p&gt;My first test for the gate was wrong too. It said the problem was fixed as
soon as any session went down, and it said that while 55 were still up. A
session going down while the engine is frozen doesn’t prove much anyway, since
the frozen engine explains it just as well. So now the test runs twice: once
with the gate on, where the session has to go down, and once with it off, where
it has to stay up. After that I did the same for every new test, running it
once with the thing it tests turned off to make sure it fails.&lt;/p&gt;
&lt;h2 id=&quot;fuzzing&quot;&gt;Fuzzing&lt;/h2&gt;
&lt;p&gt;Around the same time I pointed a fuzzer at the code that reads bfdd’s
messages. On its first run, at the end of August, it found a read 42 bytes past the end of the buffer.
If a reply couldn’t go out because the queue was full, the connection got
dropped in the middle of the parse loop, the byte count went to zero, and the
subtraction after it wrapped around. I fixed the loop and kept the input that
caused it as a test.&lt;/p&gt;
&lt;p&gt;Later I fuzzed the XDP program too. For every input, a dropped packet had to
leave every session untouched, and anything sent back had to be a valid BFD
packet. It got through about 10 million inputs without breaking either rule.&lt;/p&gt;
&lt;p&gt;By the end of September the tests had grown into four layers, from a few
seconds of checks that don’t need root, up to stock FRR bfdd running in
containers against xdp-bfd. Every pull request also goes through about 28 CI
checks.&lt;/p&gt;
&lt;h2 id=&quot;the-test-that-never-ran&quot;&gt;The test that never ran&lt;/h2&gt;
&lt;p&gt;One of the checks I was counting on wasn’t running at all. Before every push, a
git hook ran a script that fires bad packets at the test mesh and checks that
each one gets dropped. At some point I’d moved the script to another folder,
and the hook was still calling the old path. The error landed in the hook’s own
“couldn’t parse, skipping” branch, which exits with success, so every push had
passed for weeks without the test running once. When I fixed the path, the
script turned out to have two more bugs of its own.&lt;/p&gt;
&lt;h2 id=&quot;answering-too-fast&quot;&gt;Answering too fast&lt;/h2&gt;
&lt;p&gt;Near the end of September I went through the RFC itself, and the biggest
problem came straight out of part 2. XDP answers every packet the peer sends,
so xdp-bfd was sending at the peer’s pace, not its own. If I set 300ms and the
peer sent every 100ms, I was sending three times faster than the RFC allows.
Now XDP only answers on its own while the peer’s pace is one I’m allowed to
match. Otherwise userspace sends at my own rate, and XDP just watches for the
peer going quiet.&lt;/p&gt;
&lt;p&gt;Next, &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-8-shipping-it/&quot;&gt;packaging it&lt;/a&gt; so someone else could run
it.&lt;/p&gt;</content:encoded><media:content url="https://w453y.me/uploads/cards/xdp-bfd-7-checking-my-own-work.png" medium="image" type="image/png"/><media:thumbnail url="https://w453y.me/uploads/cards/xdp-bfd-7-checking-my-own-work.png"/><category>bfd</category><category>xdp</category><category>testing</category><category>fuzzing</category><category>Building xdp-bfd</category><author>w453y.me@gmail.com (Abdul Wasey)</author><enclosure url="https://w453y.me/uploads/cards/xdp-bfd-7-checking-my-own-work.png" length="46561" type="image/png"/></item><item><title>Fixing my slow offsite backups over WireGuard</title><link>https://w453y.me/writing/offsite-backups-tcp-congestion-control/</link><guid isPermaLink="true">https://w453y.me/writing/offsite-backups-tcp-congestion-control/</guid><description>My offsite backups had been failing silently for 23 days. Fixing that showed they were also crawling at 16 Mbps over a 1 Gbps tunnel, and the cause turned out to be 0.033% packet loss and TCP cubic.</description><pubDate>Sat, 12 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I run two Proxmox nodes at home with 28 guests between them. Every night at
02:00 each node backs everything up to its own disk with &lt;code&gt;vzdump&lt;/code&gt;, and a cron
job rsyncs those backups to an offsite box over WireGuard. The offsite box sits
behind a NAT I don’t control, so the tunnel is the only way to reach it. That’s
about 150 GB a night, 91 GB from one node and 59 GB from the other, and it had
been working for months.&lt;/p&gt;
&lt;h2 id=&quot;23-days-of-failed-syncs&quot;&gt;23 days of failed syncs&lt;/h2&gt;
&lt;p&gt;On 10 September I was setting up Prometheus, Loki and Grafana across the homelab
with Claude Code, and one of the things on the list was monitoring the backup
script. Before writing anything, Claude read the script’s log to see what it
looked like. The first node’s log was fine. The second node’s had the same error
every night:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark-default&quot; style=&quot;background-color:#0d1117;color:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;rsync: [Receiver] mkdir &quot;/root/backups/node-b&quot; failed: Permission denied (13)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;rsync error: error in file IO (code 11) at main.c(791) [Receiver=3.2.7]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The last time it had finished was 18 August, so &lt;strong&gt;it had been failing every
night for 23 days&lt;/strong&gt;. Each run failed in under a second and only wrote the error
to a log file I never looked at, so I had no idea. 59 GB of backups had never
left the box.&lt;/p&gt;
&lt;p&gt;I never found out what changed on the offsite box that day. Making the directory
exist fixed the error. Before running it again, we made the script report on
itself. It now writes its exit code, duration and bytes sent to a file
Prometheus picks up, even when it fails, and every step has a timeout. One check
took more than I expected. A quick TCP connect to port 22 wasn’t enough, because
when the offsite box was half dead, it answered the TCP handshake and then hung
in the middle of the SSH handshake. Only a real SSH login with a timeout catches
that.&lt;/p&gt;
&lt;h2 id=&quot;then-it-was-slow&quot;&gt;Then it was slow&lt;/h2&gt;
&lt;p&gt;With the permission fixed, both nodes started a real sync, and both failed after
about 100 minutes with rsync’s exit code 30, a timeout while sending. The
progress output showed the transfer starting at hundreds of MB/s and then
sinking to a few MB/s.&lt;/p&gt;
&lt;p&gt;Claude measured the link by piping 400 MB through SSH:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark-default&quot; style=&quot;background-color:#0d1117;color:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FFA657&quot;&gt;dd&lt;/span&gt;&lt;span style=&quot;color:#A5D6FF&quot;&gt; if=/dev/zero&lt;/span&gt;&lt;span style=&quot;color:#A5D6FF&quot;&gt; bs=1M&lt;/span&gt;&lt;span style=&quot;color:#A5D6FF&quot;&gt; count=&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;400&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt; |&lt;/span&gt;&lt;span style=&quot;color:#FFA657&quot;&gt; ssh&lt;/span&gt;&lt;span style=&quot;color:#A5D6FF&quot;&gt; peer&lt;/span&gt;&lt;span style=&quot;color:#A5D6FF&quot;&gt; &quot;cat &gt; /dev/null&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That gave 15 Mbps, and then 16 and 17 on the next two runs. From there it
decided the link was 16 Mbps, worked out that shipping 150 GB at that speed
would take 21.3 hours out of every 24, and told me offsite replication was
impossible as I’d set it up. Its suggestions were to move to Proxmox Backup
Server or drop the bigger guests from the job.&lt;/p&gt;
&lt;p&gt;That didn’t match what I was seeing. 87 GB had already arrived on the offsite
box, and the tunnel runs over a 1 Gbps link. So I ran iperf3 over UDP through
the tunnel, in both directions:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark-default&quot; style=&quot;background-color:#0d1117;color:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;$ iperf3 -c iperf.example.net -u -b 1000M -t 10&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;[  5]   0.00-10.00  sec  1.04 GBytes   896 Mbits/sec  0.000 ms  0/943220 (0%)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;$ iperf3 -c iperf.example.net -u -b 1000M -t 10 -R&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;[  5]   0.00-10.00  sec  1.05 GBytes   903 Mbits/sec  0.011 ms  101653/1051648 (9.7%)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;About 900 Mbps both ways. If something on the path were limiting it to 16 Mbps,
sending 1000 Mbps of UDP would have lost almost all of it, not 9.7%. The &lt;code&gt;dd&lt;/code&gt;
test had only measured one TCP stream going through SSH, and that was slow for
some other reason.&lt;/p&gt;
&lt;h2 id=&quot;the-tunnel-was-broken-too&quot;&gt;The tunnel was broken too&lt;/h2&gt;
&lt;p&gt;I also had a ping running from the offsite box back to the homelab, and that
showed a different problem. One run lost 33% of its packets, and a few minutes
later nothing was getting through at all. The internet from the same box was
fine, so I wondered if it was my OPNsense firewall.&lt;/p&gt;
&lt;p&gt;The WireGuard status on OPNsense showed the offsite peer’s last handshake was
over an hour old. WireGuard renews its handshake about every two minutes while
traffic is flowing, so anything past three minutes means the tunnel is dead. The
other peers on the same server had handshakes from seconds ago, so the firewall
was fine. The difference was that the offsite peer had no persistent keepalive
set.&lt;/p&gt;
&lt;p&gt;WireGuard doesn’t send anything when there’s nothing to send. The NAT in front
of the offsite box forgets the mapping after a short time with no traffic, and
after that, my side has no way to reach it. Only the offsite box sending
something opens it up again. That’s also why, for months, I’d kept a ping
running from the offsite box all the time. Traffic going out from behind the NAT
was the only thing keeping the mapping alive, so that ping was doing the
keepalive’s job by hand.&lt;/p&gt;
&lt;p&gt;I set a 25-second keepalive on both ends, though it’s the one on the offsite box
that matters, since it’s the side behind the NAT. The loss went from 33% to
zero:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark-default&quot; style=&quot;background-color:#0d1117;color:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;50 packets transmitted, 50 received, 0% packet loss&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;rtt min/avg/max/mdev = 52.068/52.459/52.879/0.196 ms&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;still-slow&quot;&gt;Still slow&lt;/h2&gt;
&lt;p&gt;The backups were still slow with the tunnel fixed, so I measured TCP with iperf3
this time. Towards the homelab it did 502 Mbps with no retransmits. Towards the
offsite box, the direction the backups go, it did 41 Mbps with 164 retransmits,
and four parallel streams more than doubled that. If the link itself were the
limit, four streams would have shared the same 41 Mbps. So something was holding
back each single TCP flow.&lt;/p&gt;
&lt;p&gt;Next we checked everything in between. OPNsense was 99% idle during the
transfer, the MSS was clamped below the MTU on both ends, the offsite box’s CPU
was idle, and its tunnel interface had no drops. None of it explained anything,
so I swept UDP at a few fixed rates instead, to see what the path itself was
doing:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark-default&quot; style=&quot;background-color:#0d1117;color:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;offered  50M  -&gt;  50.0 Mbps   0.029% loss&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;offered 100M  -&gt; 100   Mbps   0.038% loss&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;offered 200M  -&gt; 200   Mbps   0.032% loss&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;offered 400M  -&gt; 400   Mbps   0.033% loss&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;400 Mbps went through with 0.033% loss, about 3 packets in 10,000. The path was
fine.&lt;/p&gt;
&lt;h2 id=&quot;why-one-tcp-flow-was-slow&quot;&gt;Why one TCP flow was slow&lt;/h2&gt;
&lt;p&gt;That much loss is nothing to UDP, but it hurts a single TCP flow over a 52ms
round trip. Both of my nodes were using cubic, the default congestion control on
Linux, and cubic treats every lost packet as a sign of congestion and cuts its
sending rate. With a steady trickle of loss over a long path, it never gets to
speed up.&lt;/p&gt;
&lt;p&gt;There’s a rough formula for this, from Mathis: throughput is about MSS divided
by RTT times the square root of the loss rate. With a 1200-byte MSS, 52ms and
0.033% loss, that comes out at about 10 Mbps. With almost no loss, the same path
allows about 584 Mbps. Cubic actually got around 38, more than the formula says,
probably because the formula assumes an older and more cautious algorithm, but
it’s nowhere near 584.&lt;/p&gt;
&lt;p&gt;BBR works differently. Instead of slowing down on loss, it measures how fast the
path is and how long the round trip takes, and sends at that rate. So I tried
both, one stream each, in the same minute:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark-default&quot; style=&quot;background-color:#0d1117;color:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;$ echo cubic &gt; /proc/sys/net/ipv4/tcp_congestion_control&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;$ iperf3 -c 10.0.0.2 -t 10&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;[  5]   0.00-10.00  sec  36.5 MBytes  30.6 Mbits/sec  312   sender&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;[  5]   0.00-10.04  sec  33.4 MBytes  27.9 Mbits/sec        receiver&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;$ modprobe tcp_bbr&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;$ echo bbr &gt; /proc/sys/net/ipv4/tcp_congestion_control&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;$ iperf3 -c 10.0.0.2 -t 10&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;[  5]   0.00-10.00  sec   473 MBytes   396 Mbits/sec  1999  sender&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;[  5]   0.00-10.04  sec   472 MBytes   394 Mbits/sec        receiver&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;27.9 Mbps with cubic, 394 with BBR.&lt;/strong&gt; The retransmits went up, from 312 to
1999. BBR was losing more packets, but it didn’t back off each time. Through
SSH, the same 400 MB test from before now ran at 200 Mbps instead of about 20.&lt;/p&gt;
&lt;h2 id=&quot;the-fix&quot;&gt;The fix&lt;/h2&gt;
&lt;p&gt;The fix was three lines of sysctl on both nodes:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark-default&quot; style=&quot;background-color:#0d1117;color:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;ini&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#8B949E&quot;&gt;# /etc/sysctl.d/99-homelab-tcp.conf&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;net.core.default_qdisc&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; = fq&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;net.ipv4.tcp_congestion_control&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; = bbr&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;net.ipv4.tcp_wmem&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; = 4096 16384 33554432&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;fq&lt;/code&gt; is the queueing discipline BBR is designed to work with, since it does the
packet pacing BBR relies on. The bigger send buffer is a separate, smaller
thing. The old 4 MB limit capped a single flow at about 645 Mbps over this path,
which wasn’t the problem here, but there was no reason to keep it. I also added
&lt;code&gt;tcp_bbr&lt;/code&gt; to &lt;code&gt;/etc/modules-load.d/&lt;/code&gt; so it loads after a reboot.&lt;/p&gt;
&lt;p&gt;After that, both nodes finished. The second node was in sync after 9 minutes,
its first clean sync since 18 August, and the first node after 18, 69 GB image
included. The earlier runs had already got 87 GB across before timing out, so
these two only had to finish the job. They moved 57.7 GB between them, which is
an average of 285 Mbps through rsync and SSH. At that rate, the full 150 GB that
was supposed to need 21.3 hours a day takes a bit over an hour.&lt;/p&gt;
&lt;h2 id=&quot;the-other-changes&quot;&gt;The other changes&lt;/h2&gt;
&lt;p&gt;A few other things I changed along the way were really workarounds for cubic,
from before I knew it was cubic. I switched rsync from &lt;code&gt;--inplace&lt;/code&gt; to
&lt;code&gt;--append-verify&lt;/code&gt;, so a resumed 69 GB file appends from where it stopped instead
of comparing both copies from the start. I split the sync across four parallel
rsync workers, which went from 19 Mbps to 58, because each stream gets its own
congestion window. And I switched SSH to chacha20, which was about 20% faster on
these CPUs. I kept all three anyway.&lt;/p&gt;
&lt;h2 id=&quot;the-monitoring&quot;&gt;The monitoring&lt;/h2&gt;
&lt;p&gt;The monitoring I was setting up in the first place is running now too. The
backup script reports how long each run took, how much it sent and how it
exited, and Grafana shows that for both nodes:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href=&quot;https://w453y.me/uploads/writing/offsite-backups/backup-sync.png&quot;&gt;&lt;img width=&quot;1600&quot; height=&quot;1268&quot; src=&quot;https://w453y.me/uploads/writing/offsite-backups/backup-sync.png&quot; alt=&quot;Three Grafana panels for node-a and node-b since 10 September: run duration, with a spike of 1.67 hours on the 10th and then steady nightly runs between about 13 and 40 minutes; bytes sent per run; and exit code, with spikes to 30 and 64 on the 10th and zero after that&quot; loading=&quot;lazy&quot;&gt;&lt;/a&gt;
  &lt;figcaption&gt;Run time, data sent and exit code for each node since 10 September. The spikes on the 10th are the runs from before the fix.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;There are seven alert rules around it. The main one fires when the last
successful sync is more than 48 hours old, since the offsite box going offline
for one night is normal and three nights isn’t. Two of them watch the backup
peer itself, one for its WireGuard handshake going stale and one for its
keepalive being turned off.&lt;/p&gt;
&lt;figure&gt;
  &lt;a href=&quot;https://w453y.me/uploads/writing/offsite-backups/backup-alerts-all.png&quot;&gt;&lt;img width=&quot;1600&quot; height=&quot;1130&quot; src=&quot;https://w453y.me/uploads/writing/offsite-backups/backup-alerts.png&quot; alt=&quot;Three Prometheus alert rules, all inactive: BackupSyncStale, firing when the last successful sync is over 48 hours old, and BackupPeerTunnelDown and BackupPeerKeepaliveDisabled for the backup peer&amp;#x27;s WireGuard handshake and keepalive&quot; loading=&quot;lazy&quot;&gt;&lt;/a&gt;
  &lt;figcaption&gt;The main alert and the two for the backup peer, none of them firing. Click for all seven.&lt;/figcaption&gt;
&lt;/figure&gt;</content:encoded><media:content url="https://w453y.me/uploads/cards/offsite-backups.png" medium="image" type="image/png"/><media:thumbnail url="https://w453y.me/uploads/cards/offsite-backups.png"/><category>homelab</category><category>tcp</category><category>wireguard</category><category>backups</category><author>w453y.me@gmail.com (Abdul Wasey)</author><enclosure url="https://w453y.me/uploads/cards/offsite-backups.png" length="41400" type="image/png"/></item><item><title>Building xdp-bfd, part 6: demand mode, auth and hitless restarts</title><link>https://w453y.me/writing/xdp-bfd-6-auth-and-hitless-restart/</link><guid isPermaLink="true">https://w453y.me/writing/xdp-bfd-6-auth-and-hitless-restart/</guid><description>Demand mode, SHA1 inside an XDP program, getting FRR to hand keys to a data plane, and restarting the engine under 1024 live sessions without a single flap.</description><pubDate>Sun, 06 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;After &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-5-ipv6-echo-multihop/&quot;&gt;part 5&lt;/a&gt;, three big things were
still missing, and they took up September. There was no demand mode, no
authentication, and restarting xdp-bfd itself, for an upgrade or anything else,
took every session down with it.&lt;/p&gt;
&lt;h2 id=&quot;demand-mode&quot;&gt;Demand mode&lt;/h2&gt;
&lt;p&gt;In demand mode, two peers stop sending regular packets once both are Up, and
only check the path every now and then. bfdd had a setting for it, but the
setting didn’t do anything. The flag was set to 0 in three places and never set
to 1 anywhere. So I
implemented it in FRR first, which has &lt;a href=&quot;https://w453y.me/writing/frr-bfdd-fixes/#demand-mode&quot;&gt;its own
section&lt;/a&gt; in the FRR post, and then in
xdp-bfd.&lt;/p&gt;
&lt;p&gt;The trick from part 2 doesn’t work here, though. When the peer stops sending,
there’s nothing for XDP to bounce. So while the peer is in demand
mode, xdp-bfd stops replying from XDP and leaves the occasional Poll to
userspace. While xdp-bfd is the side asking for quiet, it also holds off its
own detection, or it would time out a peer that was only doing what it was
asked.&lt;/p&gt;
&lt;p&gt;At first it only worked in one direction. When xdp-bfd was the side asking for
quiet, the peer just kept sending. It turned out xdp-bfd was going quiet within
a microsecond of both sides being Up, before it had sent a single packet saying
it wanted demand mode, so the peer had never actually been asked.
Now it says so three times first.&lt;/p&gt;
&lt;p&gt;Once both sides are quiet, something still has to check the path is there. The
RFC leaves that optional and bfdd never does it, so xdp-bfd sends a Poll once a
second.&lt;/p&gt;
&lt;h2 id=&quot;signing-packets-inside-xdp&quot;&gt;Signing packets inside XDP&lt;/h2&gt;
&lt;p&gt;BFD authentication adds a signature to every packet, so the other side can
tell it really came from you. In xdp-bfd the replies are built in XDP, so XDP
had to be the one signing them, and it also had to check the signature on
every packet coming in.&lt;/p&gt;
&lt;p&gt;The kernel doesn’t give XDP programs a hash function to call, so I wrote SHA1
into the XDP program myself. The tricky part was that BPF programs only get 512
bytes of stack. The usual way of computing SHA1 keeps 80 values around at once,
and that didn’t fit next to everything else. So my version only keeps the last 16 and
works out the rest as it goes.&lt;/p&gt;
&lt;p&gt;FRR’s keyed SHA1 also isn’t quite what the BFD RFC describes. It’s an HMAC, which is a different way of mixing the key
in. xdp-bfd does it FRR’s way, since FRR is what it has to talk to, and I
opened &lt;a href=&quot;https://github.com/FRRouting/frr/issues/23274&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;an issue&lt;/a&gt; in FRR about
the difference.&lt;/p&gt;
&lt;h2 id=&quot;getting-the-keys-from-frr&quot;&gt;Getting the keys from FRR&lt;/h2&gt;
&lt;p&gt;xdp-bfd could sign packets now, but bfdd had no way to hand it the keys. The
message bfdd uses to pass a session to xdp-bfd had a &lt;code&gt;TODO&lt;/code&gt; comment where the
key was supposed to go. On top of that, when you set up an authenticated
session in FRR and it got handed to a data plane, it went out with no
authentication at all, while &lt;code&gt;show bfd peer&lt;/code&gt; still said auth was on.&lt;/p&gt;
&lt;p&gt;I fixed that in FRR, and it was merged in late September. bfdd now sends the
keys that are usable now and the ones that will be later, along with when each
is valid, not just the current key. That way xdp-bfd can switch to the next
key on its own when one expires. Around the same time I also fixed two more
auth bugs in bfdd itself, and those are in &lt;a href=&quot;https://w453y.me/writing/frr-bfdd-fixes/#auth&quot;&gt;the FRR
post&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;On the test mesh, all 68 authenticated sessions came up.&lt;/p&gt;
&lt;h2 id=&quot;restarting-without-dropping-anything&quot;&gt;Restarting without dropping anything&lt;/h2&gt;
&lt;p&gt;The other problem was restarts. Every time I restarted xdp-bfd, every session
went down with it. On the test mesh with 1024 sessions, one restart caused 1022
flaps.&lt;/p&gt;
&lt;p&gt;What I wanted was for a new copy of xdp-bfd to take over from the old one
without the peers ever going unanswered. So I added a &lt;code&gt;--pin&lt;/code&gt; option. With it,
xdp-bfd pins its BPF maps and the attachment of its XDP program in the kernel,
so they stay there after the process exits. When a new xdp-bfd starts, it
picks up the pinned sessions, swaps its own XDP program into the pinned
attachment in one step, and tells the old one to exit. The attachment and the
session state never go away, so the peers keep getting answers the whole time.
The old process was gone within 13ms and the new one had taken over in about
12ms, and this time the restart caused zero flaps.&lt;/p&gt;
&lt;p&gt;With that, the test mesh was 1024 sessions on Proxmox VMs, covering everything
from the earlier parts, and all of them came up apart from two I left pointing
at peers that don’t exist on purpose.&lt;/p&gt;
&lt;p&gt;Next: &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-7-checking-my-own-work/&quot;&gt;checking my own work&lt;/a&gt;.&lt;/p&gt;</content:encoded><media:content url="https://w453y.me/uploads/cards/xdp-bfd-6-auth-and-hitless-restart.png" medium="image" type="image/png"/><media:thumbnail url="https://w453y.me/uploads/cards/xdp-bfd-6-auth-and-hitless-restart.png"/><category>bfd</category><category>xdp</category><category>authentication</category><category>frr</category><category>Building xdp-bfd</category><author>w453y.me@gmail.com (Abdul Wasey)</author><enclosure url="https://w453y.me/uploads/cards/xdp-bfd-6-auth-and-hitless-restart.png" length="49198" type="image/png"/></item><item><title>FRR bfdd dropping the data plane on &quot;show bfd peers counters&quot;</title><link>https://w453y.me/writing/bfdd-counters-teardown-zero-length-read/</link><guid isPermaLink="true">https://w453y.me/writing/bfdd-counters-teardown-zero-length-read/</guid><description>Every second time I ran &quot;show bfd peers counters&quot;, bfdd dropped its connection to the data plane. Two small bugs in how it read replies added up to it.</description><pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;With 64 sessions running through &lt;a href=&quot;https://w453y.me/writing/series/line-card-bfd/&quot;&gt;xdp-bfd&lt;/a&gt;, I
ran &lt;code&gt;show bfd peers counters&lt;/code&gt; in FRR and it worked. I ran it again, and bfdd
dropped its connection to xdp-bfd partway through. The third time it worked
again, and from then on it kept alternating, failing every second run.&lt;/p&gt;
&lt;p&gt;Something that fails every other time usually means something is left over
from the previous run. In this case it turned out to be bfdd’s input buffer.&lt;/p&gt;
&lt;h2 id=&quot;what-was-going-wrong&quot;&gt;What was going wrong&lt;/h2&gt;
&lt;p&gt;When you run the counters command, bfdd asks the data plane for each session’s
counters and reads the replies into an 8KB buffer. Normally bfdd clears out the
replies it has already read to make room, but the function used for this
command only did that after three reads in one go. The counters command reads
one reply at a time, so the clean-up never happened. After a full run over 64
sessions, 5,120 bytes of the buffer were still taken up by replies bfdd had
already dealt with.&lt;/p&gt;
&lt;p&gt;On the next run there were only 3,072 bytes free, which is enough for 38
replies. Reply 39 filled the buffer exactly, and bfdd then asked the socket for
zero bytes, because there was no room for more. A zero-byte read always returns
zero, and in bfdd a read that returns zero means the other side closed the
connection. So &lt;strong&gt;bfdd decided xdp-bfd had hung up and closed a perfectly good
connection&lt;/strong&gt;. Reconnecting cleared the buffer, which is why the run after that
worked again.&lt;/p&gt;
&lt;p&gt;That works out to 64 plus 39, so 103 replies per connection, and that’s exactly
what I saw every time.&lt;/p&gt;
&lt;p&gt;From the command line, the failing run still printed the full list of peers,
just with old or zero counters for every session after the point where it
broke, and with no error unless debug logging was on. Each failure also made
bfdd reconnect and register every session again, so anything polling this
command for monitoring would keep setting it off.&lt;/p&gt;
&lt;h2 id=&quot;the-fix&quot;&gt;The fix&lt;/h2&gt;
&lt;p&gt;The fix clears out already-read replies every time that function runs, so the
buffer can’t fill up with them. It also stops bfdd from ever asking for zero
bytes. If the buffer is somehow still full after clearing it, a single message
must be bigger than the whole buffer, so bfdd now logs that as an error and
closes the connection properly.&lt;/p&gt;
&lt;p&gt;My first version returned an error there without closing the connection, which
would have left it stuck. Greptile, the automated reviewer on the PR, pointed
that out, so I changed it to close the connection the same way bfdd does for
its other errors.&lt;/p&gt;
&lt;h2 id=&quot;writing-a-test-for-it&quot;&gt;Writing a test for it&lt;/h2&gt;
&lt;p&gt;Donald Sharp asked for a topotest that shows the fix works. The catch was that
this bug only happens when bfdd is connected to an external data plane, and
FRR’s tests had nothing that could act as one.&lt;/p&gt;
&lt;p&gt;He pointed me at &lt;code&gt;fpm_listener&lt;/code&gt;, a small fake receiver FRR already uses to test
a similar connection in zebra, so I wrote &lt;code&gt;bfd_dplane_listener&lt;/code&gt; the same way. It
accepts bfdd’s connection, keeps track of the sessions bfdd sends it, answers
counter requests and reports every session as up, without running any real BFD.
The test runs the counters command 20 times over 8 peers and checks that every
request got an answer without a reconnect. On the old code it fails with &lt;code&gt;103 of 160 counter requests were answered&lt;/code&gt;, which is right where the buffer runs out.&lt;/p&gt;
&lt;p&gt;Getting that merged needed a few extra changes I hadn’t expected: a build macro
the new program needed, packaging entries for both RPM and Debian, and a
&lt;code&gt;noreturn&lt;/code&gt; attribute because FRR’s OpenBSD build treats warnings as errors. The
fix itself was only a few lines, and the test setup ended up being most of the
pull request.&lt;/p&gt;
&lt;p&gt;It’s merged as &lt;a href=&quot;https://github.com/FRRouting/frr/pull/22694&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#22694&lt;/a&gt;, and the
fake data plane is now part of FRR’s tree, so this part of bfdd can be tested
in CI.&lt;/p&gt;</content:encoded><media:content url="https://w453y.me/uploads/cards/bfdd-counters-teardown-zero-length-read.png" medium="image" type="image/png"/><media:thumbnail url="https://w453y.me/uploads/cards/bfdd-counters-teardown-zero-length-read.png"/><category>bfd</category><category>frr</category><category>debugging</category><category>Fixing FRR’s BFD daemon</category><author>w453y.me@gmail.com (Abdul Wasey)</author><enclosure url="https://w453y.me/uploads/cards/bfdd-counters-teardown-zero-length-read.png" length="58020" type="image/png"/></item><item><title>FRR bfdd losing data plane sessions to a full buffer</title><link>https://w453y.me/writing/bfdd-dataplane-buffer-overflow/</link><guid isPermaLink="true">https://w453y.me/writing/bfdd-dataplane-buffer-overflow/</guid><description>With more than about 40 to 58 sessions, depending on config, bfdd silently lost sessions when it connected to a data plane, and lost delete messages when it shut down. Both came from the same 8KB buffer.</description><pubDate>Sat, 15 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I was testing &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-4-from-1-to-8192-sessions/&quot;&gt;xdp-bfd&lt;/a&gt; with more
and more sessions, with FRR’s bfdd handing them over. At 16 sessions everything
worked. At 64, some sessions just never came up, and there was nothing about
them in any log.&lt;/p&gt;
&lt;h2 id=&quot;losing-sessions-on-connect&quot;&gt;Losing sessions on connect&lt;/h2&gt;
&lt;p&gt;The problem was in what bfdd sends when it connects to a data plane. It goes
through every configured session and puts one registration message for each into
an 8KB output buffer, all in one go, before any of it gets sent. A session with
default settings takes a 140-byte message, so 58 of those fit. My sessions had
timers configured, which makes each message 203 bytes, so only 40 fit. With my
64 sessions, the last 24 didn’t make it.&lt;/p&gt;
&lt;p&gt;A full buffer by itself would have been a normal error. What made it bad was
what bfdd did with the sessions that didn’t fit. Before registering them, it
had already switched off its own software BFD for them, since the data plane
was supposed to take over. Then the registration failed, and bfdd cleared the
session’s link to the data plane. So &lt;strong&gt;those sessions weren’t being run by the
data plane or by bfdd&lt;/strong&gt;. They still showed up in &lt;code&gt;show bfd peers&lt;/code&gt;, but nothing
was actually doing anything with them.&lt;/p&gt;
&lt;p&gt;I wanted the FRR maintainers to be able to reproduce this without needing
xdp-bfd, and it turned out a real data plane wasn’t needed at all. A small
script that accepts bfdd’s connection and just counts the bytes it receives
was enough. With enough sessions configured, the count always stopped at
exactly one buffer’s worth.&lt;/p&gt;
&lt;p&gt;The fix, &lt;a href=&quot;https://github.com/FRRouting/frr/pull/22645&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#22645&lt;/a&gt;, has two parts.
When the buffer fills up, bfdd now sends what’s in it before giving up. During
the registration burst the socket can almost always take more, so normally
this just works. And if a registration still fails, bfdd logs it and goes back
to running that session in software, the same way it already did when a data
plane disconnects. With 128 sessions and the byte-counting script, the old code
registered 58 and silently lost the other 70. With the fix, all 128 got
through.&lt;/p&gt;
&lt;h2 id=&quot;losing-deletes-on-shutdown&quot;&gt;Losing deletes on shutdown&lt;/h2&gt;
&lt;p&gt;With that fixed, I looked at what happens when bfdd shuts down. It puts a
delete message for every data plane session into the same buffer, but by then
its event loop has stopped, so nothing ever sends them. Then it closes the
connection and throws the buffer away with the messages still in it.&lt;/p&gt;
&lt;p&gt;What the data plane got depended on the version. Before my first fix, it got
no deletes at all. After it, the new “send when full” code kicked in once
halfway through, so exactly 58 deletes arrived and the rest were lost. The
second case is actually more confusing for a data plane. Getting nothing looks
like a crash, but getting 58 looks like a normal shutdown that happened to
leave some sessions behind.&lt;/p&gt;
&lt;p&gt;The fix for this one, &lt;a href=&quot;https://github.com/FRRouting/frr/pull/22692&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#22692&lt;/a&gt;,
makes bfdd write out whatever is left in the buffer right before it closes the
connection on shutdown. It writes as much as the socket will take, and if
anything is still left over, it logs how many bytes were lost instead of
dropping them silently. With a real data plane holding 64 sessions, a clean
bfdd restart used to leave 6 of them behind. With the fix, all 64 deletes
arrived and nothing was left behind.&lt;/p&gt;
&lt;p&gt;Both fixes are merged into FRR’s master branch. On released versions, if you
have more than about 40 data plane sessions (up to 58, depending on your
config), add them through &lt;code&gt;vtysh&lt;/code&gt; after the data plane has connected, instead
of putting them in &lt;code&gt;frr.conf&lt;/code&gt;.&lt;/p&gt;</content:encoded><media:content url="https://w453y.me/uploads/cards/bfdd-dataplane-buffer-overflow.png" medium="image" type="image/png"/><media:thumbnail url="https://w453y.me/uploads/cards/bfdd-dataplane-buffer-overflow.png"/><category>bfd</category><category>frr</category><category>networking</category><category>Fixing FRR’s BFD daemon</category><author>w453y.me@gmail.com (Abdul Wasey)</author><enclosure url="https://w453y.me/uploads/cards/bfdd-dataplane-buffer-overflow.png" length="50400" type="image/png"/></item><item><title>Building xdp-bfd, part 5: IPv6, echo, and multihop</title><link>https://w453y.me/writing/xdp-bfd-5-ipv6-echo-multihop/</link><guid isPermaLink="true">https://w453y.me/writing/xdp-bfd-5-ipv6-echo-multihop/</guid><description>IPv6 gave me a clean experiment proving the kernel reply is what matters. Echo is the one feature XDP can&apos;t fully do. Multihop passed every test while testing nothing.</description><pubDate>Mon, 03 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Back in part 3 I had a list of things xdp-bfd couldn’t do yet. After the
64-session work in July, the biggest ones left were IPv6, echo mode, and
multihop sessions. This part goes through all three, which took the rest of
July.&lt;/p&gt;
&lt;h2 id=&quot;ipv6&quot;&gt;IPv6&lt;/h2&gt;
&lt;p&gt;Adding IPv6 was mostly careful plumbing. I changed the session key so both
IPv4 and IPv6 addresses fit in it, then taught the XDP program to read IPv6
headers and build IPv6 replies. The more interesting part was something I
hadn’t planned.&lt;/p&gt;
&lt;p&gt;This whole project rests on one idea: sending the reply from the kernel is what
keeps sessions up under load. Up to here, though, I’d only shown that by
comparing different programs on different runs, so the load was never exactly
the same between them.&lt;/p&gt;
&lt;p&gt;While I was adding IPv6, there was a point where the XDP program could already
read IPv6 packets, but IPv6 replies were still being sent from userspace. IPv4
replies were already coming from the kernel. So I ran both at the same time,
on the same box, through the same stress, with 300ms timers. IPv4 had zero
flaps. IPv6 had 19, with gaps of up to 1.9 seconds.&lt;/p&gt;
&lt;p&gt;Then I turned on the kernel reply for IPv6 and ran it again. IPv6 had zero
flaps too. The only thing that changed between those two runs was where the
IPv6 reply came from, so that settled it for me.&lt;/p&gt;
&lt;p&gt;With both in place, I ran 32 IPv4 and 32 IPv6 sessions together under stress,
and neither had a single flap.&lt;/p&gt;
&lt;h2 id=&quot;echo&quot;&gt;Echo&lt;/h2&gt;
&lt;p&gt;Echo is a BFD mode where you send a packet addressed to yourself. The
neighbour’s router just forwards it back to you, without its BFD daemon ever
seeing it, and you time how long the round trip took. It checks that the
neighbour can still forward packets, not just that its BFD software is
running. I expected it to be one of the easier features.&lt;/p&gt;
&lt;p&gt;XDP can only do half of it, though. Bouncing a neighbour’s echo back is
easy, because there’s a packet in hand, the same trick as part 2. But sending
my own echoes means creating packets from nothing, and XDP can’t do that. So
the bouncing happens in XDP and the sending happens from userspace. Since
userspace sending can be starved, I also made sure a failing echo only gets
reported and never takes a session down on its own.&lt;/p&gt;
&lt;p&gt;Up to then, I had only tested the bouncing with echo packets I built by hand.
While working on the FRR side of echo, I found out why no real peer had ever
been part of those tests: xdp-bfd had been telling every peer it couldn’t
receive echo. BFD has a field for how often you’re willing to receive echo
packets, and xdp-bfd was sending 0 there, which means “don’t send me any”.
Once I fixed that, FRR started sending echoes to it, and xdp-bfd bounced back
all 433 of them in that test, the fastest in 12 microseconds.&lt;/p&gt;
&lt;p&gt;The most confusing bug came under load. Echo sending stalled for 2.6 seconds
at a time, but the loss counter stayed at zero and echo still showed as
healthy. The counter only counts a lost echo when it’s time to send the next
one and the last one hasn’t come back. During a stall, it’s never time to send
the next one, so nothing ever got counted. I only caught it by separately
measuring the time between sends.&lt;/p&gt;
&lt;p&gt;In September I found one more echo problem, this time with IPv6. FRR’s IPv6
echo works differently from IPv4. Instead of addressing it to itself, bfdd
sends it to the peer and expects the peer to send it back. On the xdp-bfd side
that only worked while the local bfdd happened to have an echo socket open,
which in data plane mode depends on the order its config gets applied. After
an FRR restart it didn’t have one, so the host answered those echoes with an
ICMP error instead, and all 28 IPv6 echo sessions on the test mesh started
flapping. XDP now bounces those echoes itself, and the socket problem is an
&lt;a href=&quot;https://w453y.me/writing/frr-bfdd-fixes/#still-in-review&quot;&gt;open FRR fix&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;multihop&quot;&gt;Multihop&lt;/h2&gt;
&lt;p&gt;Normal BFD sessions are between directly connected boxes, which is why part 3
required a TTL of 255. Multihop sessions go through other routers on the way,
so the TTL arrives lower, and instead each session gets a minimum TTL it will
accept. I kept the old strict check for single-hop sessions and only let lower
TTLs through for sessions that are actually set up as multihop.&lt;/p&gt;
&lt;p&gt;I wrote a test for it, and everything passed on the first try. When I looked
at the capture afterwards, though, the test hadn’t been testing multihop at
all. Multihop BFD uses port 4784, and my test was sending to 3784, the port for
normal single-hop sessions. The capture showed FRR sending to 4784 and my test
packets going to 3784, and none of them had touched the multihop code. After I
fixed the port, the test finally ran against the multihop path.&lt;/p&gt;
&lt;p&gt;Next: &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-6-auth-and-hitless-restart/&quot;&gt;demand mode, auth, and restarts that don’t drop
anything&lt;/a&gt;.&lt;/p&gt;</content:encoded><media:content url="https://w453y.me/uploads/cards/xdp-bfd-5-ipv6-echo-multihop.png" medium="image" type="image/png"/><media:thumbnail url="https://w453y.me/uploads/cards/xdp-bfd-5-ipv6-echo-multihop.png"/><category>bfd</category><category>xdp</category><category>ipv6</category><category>echo</category><category>rfc-5883</category><category>Building xdp-bfd</category><author>w453y.me@gmail.com (Abdul Wasey)</author><enclosure url="https://w453y.me/uploads/cards/xdp-bfd-5-ipv6-echo-multihop.png" length="46145" type="image/png"/></item><item><title>Building xdp-bfd, part 4: from 1 session to 8192</title><link>https://w453y.me/writing/xdp-bfd-4-from-1-to-8192-sessions/</link><guid isPermaLink="true">https://w453y.me/writing/xdp-bfd-4-from-1-to-8192-sessions/</guid><description>Everything so far had been one session. Going to 64 found five bugs, going to 1024 needed a rewrite of how the engine waits, and 8192 ended up limited by the software peer, not the engine.</description><pubDate>Sun, 26 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every number up to &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-3-plugging-into-frr/&quot;&gt;part 3&lt;/a&gt; came from a
single session. The maps had room for 64 and the docs said “multi-session”, but
I had never actually run more than one. A real router talks to a lot of
neighbours, so I wanted to know what happens when xdp-bfd has to handle more
than one at a time.&lt;/p&gt;
&lt;h2 id=&quot;going-past-one-session&quot;&gt;Going past one session&lt;/h2&gt;
&lt;p&gt;I started small. Two sessions came up fine, and when I killed FRR on one of the
neighbours, that session went down in 31ms while the other one carried on
without noticing. Sixteen sessions were fine too, even with the L3 stress from
part 1 running.&lt;/p&gt;
&lt;p&gt;Going to 64 sessions found five bugs. Four of them were mine, like my
per-session source ports starting at 49152, which is exactly where bfdd hands
out its own, so the two collided.&lt;/p&gt;
&lt;p&gt;The fifth was in FRR. When bfdd connected, it sent all 64 sessions to xdp-bfd
in one go, overflowed an 8KB buffer, and silently lost everything after the
40th. That bug ended up with &lt;a href=&quot;https://w453y.me/writing/bfdd-dataplane-buffer-overflow/&quot;&gt;its own
post&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The 64-session runs under L3 showed one more thing. At one point 19
sessions flapped at the exact same moment. It was the same cause as the single
flap in part 3: a softirq that ran late. With one session, a late softirq only
delays one reply. With 64, every session’s reply goes through that same
softirq, so they were all late together, and they all went down together. They
came back on their own in about 50ms.&lt;/p&gt;
&lt;h2 id=&quot;why-1024-sessions-was-eating-a-cpu-core&quot;&gt;Why 1024 sessions was eating a CPU core&lt;/h2&gt;
&lt;p&gt;All of that was back in July. In September I came back to scale and raised the
default to 1024 sessions. XDP handled that without any trouble, but the
userspace side was now using 16.8% of a CPU core just to keep those sessions going.&lt;/p&gt;
&lt;p&gt;The reason was that userspace checked the maps on a timer, going through every
session each time whether anything had changed or not. With one session that
was nothing. With 1024 it added up. I changed it so userspace only wakes up
when there’s actually something to handle. After that it sat at 2.1% of a core with the same 1024 sessions, and
sessions were still being declared down on time.&lt;/p&gt;
&lt;h2 id=&quot;how-far-it-goes&quot;&gt;How far it goes&lt;/h2&gt;
&lt;p&gt;After that I wanted to see how far it could be pushed, so I added a
&lt;code&gt;--max-sessions&lt;/code&gt; option that goes up to 8192 and tested it against a normal FRR
bfdd on the other end, with 300ms timers:&lt;/p&gt;
&lt;div class=&quot;table-scroll&quot; tabindex=&quot;0&quot; role=&quot;region&quot; aria-label=&quot;Table, scrollable&quot;&gt;
























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th align=&quot;right&quot;&gt;Sessions&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;xdp-bfd CPU&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Flaps&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;2048&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;3.8%&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;4096&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;6.2%&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;8192&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;12.1%&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;38&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;At 8192 I got 38 flaps, so I looked into them. xdp-bfd was fine. The bfdd on
the other side was using 53% of a core just to keep up and was falling behind.&lt;/p&gt;
&lt;p&gt;An audit I did just before this scale work also found a bug. XDP could read a
session’s config while userspace was halfway through updating it, and send a reply carrying another session’s discriminators, or half of each version. A test
built to hit that race produced these broken replies until I fixed it by
having XDP lock the config and work from its own copy, which only costs about
37 nanoseconds per reply.&lt;/p&gt;
&lt;p&gt;The strangest problem wasn’t in xdp-bfd at all. During the 4096-session tests,
the host running them lost ARP for its own management address. My test setup
used a lot of network namespaces, and the kernel’s neighbour table is shared
between all of them. The test had filled it past its limit.&lt;/p&gt;
&lt;p&gt;Next up is &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-5-ipv6-echo-multihop/&quot;&gt;IPv6, echo and multihop&lt;/a&gt;.&lt;/p&gt;</content:encoded><media:content url="https://w453y.me/uploads/cards/xdp-bfd-4-from-1-to-8192-sessions.png" medium="image" type="image/png"/><media:thumbnail url="https://w453y.me/uploads/cards/xdp-bfd-4-from-1-to-8192-sessions.png"/><category>bfd</category><category>xdp</category><category>scale</category><category>concurrency</category><category>Building xdp-bfd</category><author>w453y.me@gmail.com (Abdul Wasey)</author><enclosure url="https://w453y.me/uploads/cards/xdp-bfd-4-from-1-to-8192-sessions.png" length="45228" type="image/png"/></item><item><title>Building xdp-bfd, part 3: results, FRR, and hardening</title><link>https://w453y.me/writing/xdp-bfd-3-plugging-into-frr/</link><guid isPermaLink="true">https://w453y.me/writing/xdp-bfd-3-plugging-into-frr/</guid><description>Zero flaps through the whole stress ladder, one flap that fixed itself in 3.8ms, FRR driving the engine over its own protocol, and a code review whose two most annoying comments were real bugs.</description><pubDate>Sun, 19 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;With the XDP version from &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-2-getting-tx-out-of-userspace/&quot;&gt;part
2&lt;/a&gt; passing the FIFO test, I
wanted to see how it did on the whole stress ladder from part 1. So I ran it
through every level plus a five minute soak, at normal process priority, with
stock FRR on the other end:&lt;/p&gt;
&lt;div class=&quot;table-scroll&quot; tabindex=&quot;0&quot; role=&quot;region&quot; aria-label=&quot;Table, scrollable&quot;&gt;














































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Level&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;p50&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;p99&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;max&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;flaps&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;L1 CPU hogs&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;8.97&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10.03&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;13.3&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;L2 context switching&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;8.83&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10.01&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;12.0&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;L3 timer storm&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;8.72&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10.11&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;28.0&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;L4 FIFO hogs&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;8.72&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10.01&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;11.5&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;5 min soak&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;8.77&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10.01&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10.7&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The only flap was under L3, the same load that made bfdd flap 44 times. I
wanted to know what caused it, so I went through the capture around it. One
reply had been stuck in softirq for 28ms, just long enough for the peer’s 30ms
timer to run out, and the peer took the session down.&lt;/p&gt;
&lt;p&gt;It came back by itself, though. My userspace saw the peer go Down and brought
the session back up, and the whole thing took 3.8ms. Across all eleven minutes
of testing, the session was down for less than 4ms. The scheduler wasn’t a
problem anymore, but a slow softirq still could be, and that was the one time
it happened.&lt;/p&gt;
&lt;h2 id=&quot;hooking-it-up-to-frr&quot;&gt;Hooking it up to FRR&lt;/h2&gt;
&lt;p&gt;All of those runs had xdp-bfd managing the session by itself, with its own
config. On a real router, though, BFD gets set up in FRR along with BGP and the
rest, so I wanted FRR to be the one in charge and xdp-bfd to only handle the
packets.&lt;/p&gt;
&lt;p&gt;bfdd already has a mode for that. It can hand the packet side of its sessions
to a separate program over a protocol called bfddp, which exists so hardware
vendors can plug their own forwarding hardware into FRR. I added bfddp support
to xdp-bfd so bfdd could use it the same way. You set up BFD in FRR like
normal, and &lt;code&gt;show bfd peers&lt;/code&gt; looks like normal, but the packets go through XDP,
and even the counters FRR shows are read from the BPF map.&lt;/p&gt;
&lt;p&gt;Since both were running on the same box, I first tried connecting them over a
UNIX socket, and bfdd failed to connect every time with &lt;code&gt;EINVAL&lt;/code&gt;. Over TCP it
connected fine. The UNIX socket failure turned out to be a bug in FRR, and it
was the first of quite a few FRR bugs I ran into, so those have &lt;a href=&quot;https://w453y.me/writing/frr-bfdd-fixes/&quot;&gt;their own
post&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Once the two were talking, I ran the FIFO test again with FRR in charge. It
still had 0 flaps and a 13ms worst gap, about the same as before.&lt;/p&gt;
&lt;p&gt;That was still only one IPv4 session, though, and restarting bfdd took it down,
so that’s what I worked on next.&lt;/p&gt;
&lt;h2 id=&quot;surviving-a-bfdd-restart&quot;&gt;Surviving a bfdd restart&lt;/h2&gt;
&lt;p&gt;Restarting bfdd is a normal thing to do, for an upgrade or a config change, so
I didn’t want it taking routes down. The reason it did was pretty simple. As
soon as bfdd disconnected, xdp-bfd threw the session away, because as far as
it knew nobody was managing it anymore. bfdd added the session back when it
reconnected, but by then the peer had already seen it go down, and any routes
depending on it went down too.&lt;/p&gt;
&lt;p&gt;So I added an option called &lt;code&gt;--dp-hold&lt;/code&gt;. It tells xdp-bfd to hold on to its
sessions while bfdd is away and keep answering the peer as if nothing happened.
When bfdd reconnects and adds its sessions again, xdp-bfd lines each one up
with the session it’s been holding, so the peer never notices, and anything
bfdd doesn’t add back gets removed. Rafael Zalamena on the FRR list helped me
work out how this should behave.&lt;/p&gt;
&lt;p&gt;With that, restarting bfdd stopped taking the session down in my tests. Much
later, on the big test mesh, I found where it falls short: one clean FRR
restart took all of the roughly 1,000 sessions down at once. &lt;code&gt;--dp-hold&lt;/code&gt; only
helps when bfdd goes away without warning, like a crash. When FRR is stopped
properly, bfdd first sends a delete for every session, and xdp-bfd removes
them, since that’s what it was asked to do.&lt;/p&gt;
&lt;h2 id=&quot;refusing-bad-packets&quot;&gt;Refusing bad packets&lt;/h2&gt;
&lt;p&gt;The other big gap was that xdp-bfd barely checked the packets it received. In
this design every packet that gets through tells xdp-bfd the peer is still
alive, and it also gets bounced back to the peer. So a fake packet that got
past the checks could keep a session up even after the real peer had died.&lt;/p&gt;
&lt;p&gt;So I added checks. A packet has to arrive with a TTL of 255. Every router a
packet passes through lowers its TTL by one, so 255 means it came from a
directly connected box (this is called GTSM). The discriminator in the packet
has to match the session. And a session can only be created by bfdd, not by
someone sending packets at the box. If a packet fails any of these, XDP drops
it and userspace never sees it.&lt;/p&gt;
&lt;p&gt;The discriminator check needed one exception. When a peer restarts, it doesn’t
know my discriminator yet, so it sends zero. I have to accept that, or the
session could never come back up. But I only accept zero when the packet also
says the peer is Down, so a random packet with zero in it still gets dropped.
Here’s the check:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark-default&quot; style=&quot;background-color:#0d1117;color:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;c&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; (cfg &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; cfg&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;my_disc) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;        __u32 ydisc &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D2A8FF&quot;&gt; bpf_ntohl&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;(bfd-&gt;your_disc);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;        if&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; (ydisc &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;!=&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; cfg-&gt;my_disc &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;            !&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;(ydisc &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt; &amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; rstate &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;&amp;#x3C;=&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;)) {&lt;/span&gt;&lt;span style=&quot;color:#8B949E&quot;&gt;   /* Down or AdminDown */&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D2A8FF&quot;&gt;                count&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;3&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;                return&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; XDP_DROP;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first time I tested this, from another host, the checks counted every bad
packet and the session still flapped. The reject paths were returning
&lt;code&gt;XDP_PASS&lt;/code&gt; instead of &lt;code&gt;XDP_DROP&lt;/code&gt;, so the bad packets were counted and then
handed up to userspace anyway, and a forged packet with the right discriminator
overwrote the session’s state. After switching both to &lt;code&gt;XDP_DROP&lt;/code&gt;, I sent 600
forged packets at it, all 600 were dropped in XDP, and the session never
noticed.&lt;/p&gt;
&lt;h2 id=&quot;the-review&quot;&gt;The review&lt;/h2&gt;
&lt;p&gt;After adding those checks, I had someone else review the code. I nearly ignored
two of their comments, but both were real bugs.&lt;/p&gt;
&lt;p&gt;One of them was in the checks I had just written. They read the TTL and the
discriminator from fixed spots in the packet, which only works if the IP
header is the usual 20 bytes. A packet with IP options has a longer header, so
everything after it starts later, and my code was reading the wrong bytes. That
meant a packet with options could get past both checks and reach userspace.
Single-hop BFD never uses IP options, so I made XDP drop any packet that has
them.&lt;/p&gt;
&lt;p&gt;The other one was in how xdp-bfd reads bfddp messages from bfdd. When a message
came in broken, the reader cleared its buffer and kept reading from the same
connection. It could then start reading halfway through the next message and
treat whatever bytes were there as a new one. I changed it to close the
connection instead. bfdd reconnects on its own, and with &lt;code&gt;--dp-hold&lt;/code&gt; the
sessions stay up while it does.&lt;/p&gt;
&lt;p&gt;They also noticed that the structs shared between the XDP program and
userspace were copied into three different files and kept in sync by hand, so I
moved them into one header. I also kept a note of the suggestions I didn’t take
and why, and that note ended up being really useful later.&lt;/p&gt;
&lt;p&gt;After this, the next thing I needed was more than one session. That’s &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-4-from-1-to-8192-sessions/&quot;&gt;part
4&lt;/a&gt;.&lt;/p&gt;</content:encoded><media:content url="https://w453y.me/uploads/cards/xdp-bfd-3-plugging-into-frr.png" medium="image" type="image/png"/><media:thumbnail url="https://w453y.me/uploads/cards/xdp-bfd-3-plugging-into-frr.png"/><category>bfd</category><category>xdp</category><category>frr</category><category>rfc-5880</category><category>Building xdp-bfd</category><author>w453y.me@gmail.com (Abdul Wasey)</author><enclosure url="https://w453y.me/uploads/cards/xdp-bfd-3-plugging-into-frr.png" length="45700" type="image/png"/></item><item><title>Building xdp-bfd, part 2: getting transmit out of userspace</title><link>https://w453y.me/writing/xdp-bfd-2-getting-tx-out-of-userspace/</link><guid isPermaLink="true">https://w453y.me/writing/xdp-bfd-2-getting-tx-out-of-userspace/</guid><description>I tried five ways to send BFD packets under real-time starvation. The fancy one had the best p99 and flapped the most. The one that worked was to stop sending packets and bounce the peer&apos;s back instead.</description><pubDate>Fri, 10 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;a href=&quot;https://w453y.me/writing/xdp-bfd-1-why-software-bfd-flaps/&quot;&gt;Part 1&lt;/a&gt; ended with my simple
daemon breaking under SCHED_FIFO load: 25 flaps in a minute. So I started
trying other ways to get the packets out on time, running each one under the
same test as before (four FIFO hogs, 60 seconds, &lt;code&gt;3x10ms&lt;/code&gt;, captured from the
hypervisor).&lt;/p&gt;
&lt;h2 id=&quot;raising-the-priority&quot;&gt;Raising the priority&lt;/h2&gt;
&lt;p&gt;The first thing I tried was running my daemon at real-time priority 90, above
the priority-50 hogs. That fixed it completely: zero flaps and a 15ms worst gap.&lt;/p&gt;
&lt;p&gt;It only worked because nothing else on my test box wanted a higher priority,
though. On a real router the forwarding path runs at high priority too, and I
didn’t want BFD fighting it for the CPU. So I kept looking.&lt;/p&gt;
&lt;h2 id=&quot;trying-so_txtime&quot;&gt;Trying SO_TXTIME&lt;/h2&gt;
&lt;p&gt;Next I tried &lt;code&gt;SO_TXTIME&lt;/code&gt;, which I had high hopes for. It lets you queue a
packet with a launch time, and the &lt;code&gt;etf&lt;/code&gt; qdisc sends it at exactly that time. My
thinking was that even if the daemon got to run late, the packets it had
already queued would still go out on time.&lt;/p&gt;
&lt;p&gt;The first problem showed up while setting it up. I put the etf qdisc on all of
the interface’s queues, and the interface quietly stopped resolving neighbours.
It turns out etf drops every packet on its band that has no launch time, and
that includes ARP, so I had to limit the tc filter to just the BFD flow.&lt;/p&gt;
&lt;p&gt;Once it worked, I ran the FIFO test with one packet queued at a time, and it
flapped 48 times. So I changed it to queue five packets ahead. My first try at
that kept renegotiating the session once a second: packets like the Final that
answers a Poll need to go out right away, and I was scheduling them a full
second in the future. The logs looked normal, and I only spotted it in the
capture. With that fixed, the five-packet version got &lt;strong&gt;the best p99 of
anything I’d tried, 10.1ms&lt;/strong&gt;, and it still flapped 48 times.&lt;/p&gt;
&lt;p&gt;Next I checked the qdisc stats to see if it was dropping packets. It wasn’t.
Every packet I’d queued had gone out exactly on time. The gaps
were happening after the queue ran dry, while the daemon was waiting for CPU
to queue the next ones. Five packets only lasts 50ms, and the daemon was going
almost a whole second between turns.&lt;/p&gt;
&lt;p&gt;I did think about queuing a lot more, but every packet carries the session
state from when it was built. With a second of packets queued up, I’d be
telling the peer what the session looked like a second ago.&lt;/p&gt;
&lt;h2 id=&quot;where-that-left-me&quot;&gt;Where that left me&lt;/h2&gt;
&lt;p&gt;At this point I had tried three different ways of sending from userspace. Next
to bfdd from part 1, they looked like this:&lt;/p&gt;
&lt;div class=&quot;table-scroll&quot; tabindex=&quot;0&quot; role=&quot;region&quot; aria-label=&quot;Table, scrollable&quot;&gt;














































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;How it sends&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;flaps&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;p50&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;p99&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;max gap&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;FRR bfdd&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;constantly&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;8.8&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;287&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;960ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;my simple daemon&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;25&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10.0&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;12.0&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;324ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;simple daemon at &lt;code&gt;chrt -f 90&lt;/code&gt;, pinned core&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10.0&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;13.0&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;15.0ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;SO_TXTIME + etf, one packet queued&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;48&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10.0&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;13.2&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1517ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;SO_TXTIME + etf, 5 packets queued&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;48&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10.0&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10.1&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;994ms&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The only one that survived was the one I’d bumped to a higher priority. So I
started looking at sending from inside the kernel instead, since that keeps
running when userspace doesn’t. XDP was the obvious place to try, except that
XDP only runs when a packet arrives. It can’t send one on its own.&lt;/p&gt;
&lt;h2 id=&quot;bouncing-the-peers-packet-back&quot;&gt;Bouncing the peer’s packet back&lt;/h2&gt;
&lt;p&gt;My first thought was to find some way for XDP to send on a timer. BPF has
timers that run in the kernel, but a timer callback doesn’t have a packet, so
there’s nothing for it to send. I checked whether kernel 7.0 had changed that,
and it hadn’t.&lt;/p&gt;
&lt;p&gt;Then I noticed that the peer is already sending me a packet every 10ms. XDP can’t create a packet, but it can change one that just arrived
and send it back out. So that’s what I did. When the peer’s packet comes in, my
XDP program swaps the addresses around so it points back at the peer, writes my
side of the BFD session into it, and sends it straight back. Userspace keeps a
small config map up to date with what my side should say, and XDP just copies
from it. Here’s the part that fills in the reply:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark-default&quot; style=&quot;background-color:#0d1117;color:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;c&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;bfd&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;vers_diag   &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt; &amp;#x3C;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt; 5&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; (cfg&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;diag &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;&amp;#x26;&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt; 0x&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;1f&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;bfd&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;flags       &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; ((cfg&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;state &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;&amp;#x26;&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt; 0x&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;3&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;&amp;#x3C;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt; 6&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; send_final;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;send_final &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; cfg&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;poll &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; st&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;final_seq &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;!=&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; cfg&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;poll_seq)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;        bfd&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;flags &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;|=&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; BFD_F_POLL;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;bfd&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;detect_mult &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; cfg&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;mult;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;bfd&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;my_disc     &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D2A8FF&quot;&gt; bpf_htonl&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;(cfg&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;color:#FFA657&quot;&gt;my_disc&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;bfd&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;your_disc   &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D2A8FF&quot;&gt; bpf_htonl&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;(cfg&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;color:#FFA657&quot;&gt;your_disc&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#8B949E&quot;&gt;/* ...timers the same way... */&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; XDP_TX;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The reply leaves about 30 microseconds after the peer’s packet arrives, and
none of it waits on userspace. I don’t have a send timer anymore, because the
peer’s packets are the timer. If the peer’s packet has the Poll bit set, the
reply gets Final, so even Poll sequences get answered without userspace being
involved.&lt;/p&gt;
&lt;p&gt;Then I ran it through the same FIFO test, at normal priority. It had zero
flaps and a 12.5ms worst gap. The p50 was different too: all the userspace
versions sat at exactly 10.00ms, because that’s their own timer, but the XDP
version was at 8.75ms. That’s the peer’s timing, jitter included, coming
straight back to it.&lt;/p&gt;
&lt;h2 id=&quot;noticing-a-dead-peer&quot;&gt;Noticing a dead peer&lt;/h2&gt;
&lt;p&gt;That covered sending, but I also needed to notice when the peer stops. A dead
peer doesn’t send anything, so my XDP program never runs and has no way to
notice. So I added a BPF timer that looks at every session every 5ms and marks
any that have been quiet for longer than their detect time. Userspace then
picks that up, takes the session down and tells bfdd. Under the timer stress from part 1 it caught a dead peer 33 to 34ms after it stopped, with a 30ms detect time. For a
long time I thought the extra few ms came from the 5ms timer, but when I tested
that later, the extra actually came from how often the userspace loop checked
for the mark.&lt;/p&gt;
&lt;p&gt;Under load, it sometimes marked a perfectly healthy session as dead. The log
said the session had been silent for about 18 quintillion ms.&lt;/p&gt;
&lt;p&gt;Here’s what was happening. The timer grabs the current time and then goes
through the sessions. If a packet comes in on another CPU during that, the
session’s last-seen time ends up a little newer than the time the timer
grabbed. Subtracting the two should give a tiny negative number, but I was
using unsigned math, so it wrapped around to 18 quintillion instead.&lt;/p&gt;
&lt;p&gt;The fix was to subtract as signed and skip anything negative.&lt;/p&gt;
&lt;h2 id=&quot;what-stayed-in-userspace&quot;&gt;What stayed in userspace&lt;/h2&gt;
&lt;p&gt;With sending and detection in the kernel, I left everything else in userspace:
the state machine, bringing sessions up, and the slow once-a-second packets
the RFC wants while a session is down. My plan was for userspace to go quiet
once a session was Up and let XDP handle it from there.&lt;/p&gt;
&lt;p&gt;That plan didn’t survive testing. A session got stuck going round in a loop every
32.6ms, and the logs said the state machine was perfectly happy. In the capture
there was one packet missing each time, and it was a Final. Sessions often go
Up because the peer sent a Poll, so the Final answering it was exactly the
packet userspace had just decided not to send, and XDP had already used up the
peer’s packet by then. Now userspace sends that one packet as the session goes
Up, and XDP takes over after it.&lt;/p&gt;
&lt;p&gt;Since every reply rides on one of the peer’s packets, the peer does need its
own timer for any of this to work. I used to assume two xdp-bfd engines facing
each other would just sit there waiting for each other. When I actually tried
it, they did the opposite: each reply set off the next one, and they bounced
about 690,000 packets back and forth in five seconds, with a 10ms interval
configured. It needs something like FRR, with its own timer, on the other end,
and that’s still the case today.&lt;/p&gt;
&lt;p&gt;Next, &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-3-plugging-into-frr/&quot;&gt;putting FRR in charge of it and hardening
it&lt;/a&gt;.&lt;/p&gt;</content:encoded><media:content url="https://w453y.me/uploads/cards/xdp-bfd-2-getting-tx-out-of-userspace.png" medium="image" type="image/png"/><media:thumbnail url="https://w453y.me/uploads/cards/xdp-bfd-2-getting-tx-out-of-userspace.png"/><category>bfd</category><category>xdp</category><category>ebpf</category><category>so_txtime</category><category>Building xdp-bfd</category><author>w453y.me@gmail.com (Abdul Wasey)</author><enclosure url="https://w453y.me/uploads/cards/xdp-bfd-2-getting-tx-out-of-userspace.png" length="49449" type="image/png"/></item><item><title>Building xdp-bfd, part 1: why does software BFD flap?</title><link>https://w453y.me/writing/xdp-bfd-1-why-software-bfd-flaps/</link><guid isPermaLink="true">https://w453y.me/writing/xdp-bfd-1-why-software-bfd-flaps/</guid><description>Everyone says software BFD can&apos;t hold fast timers under load. Before building anything I measured it, and then wrote a tiny daemon that was supposed to fail and didn&apos;t.</description><pubDate>Mon, 06 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;BFD is the thing that tells BGP or OSPF a neighbour is gone. Two boxes send
each other tiny UDP packets every few milliseconds, and if a few in a row go
missing, the link is declared dead and routes get pulled. At &lt;code&gt;3x10ms&lt;/code&gt; (send
every 10ms, give up after 3 misses) that happens in 30ms.&lt;/p&gt;
&lt;p&gt;The catch is that BFD is only useful if it’s on time. If the sender is late,
the other side declares a perfectly fine link dead and tears down routes for
nothing. So a slow BFD daemon is worse than no BFD at all.&lt;/p&gt;
&lt;p&gt;The common advice is that software BFD can’t be trusted with fast timers when
the box is busy. That’s why people run &lt;code&gt;3x300ms&lt;/code&gt;, why SONiC caps software BFD
at 300ms, and why real routers push BFD onto line cards. I wanted to know if
that’s actually true, why, and whether a normal Linux box could get line-card
behaviour out of XDP.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;New to BFD or XDP? Quick terms&lt;/summary&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;BFD&lt;/strong&gt;: failure detection between two routers (RFC 5880). &lt;strong&gt;Session&lt;/strong&gt; is one
BFD conversation, &lt;strong&gt;flap&lt;/strong&gt; is a session going down and back up.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FRR&lt;/strong&gt;: the routing suite most Linux routers run. &lt;strong&gt;bfdd&lt;/strong&gt; is its BFD daemon.
It can hand packet work to an external &lt;strong&gt;data plane&lt;/strong&gt; over a protocol called
bfddp, which is how xdp-bfd plugs in.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;XDP&lt;/strong&gt;: an eBPF hook at the very start of packet receive. A program looks at
the packet and returns &lt;code&gt;XDP_PASS&lt;/code&gt; (carry on), &lt;code&gt;XDP_DROP&lt;/code&gt; or &lt;code&gt;XDP_TX&lt;/code&gt; (send it
straight back out). It can only react to packets. It can’t create one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Softirq&lt;/strong&gt;: kernel work that runs ahead of every normal process. XDP runs
here, so it keeps running when userspace is starved.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SCHED_FIFO&lt;/strong&gt;: real-time scheduling. FIFO tasks beat every normal task, and
the kernel only leaves normal tasks about 50ms per second when FIFO work eats
every core.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;p99 / max&lt;/strong&gt;: p99 is the value 99% of samples fall under. Max is the single
worst. For BFD the max is the one that kills you.&lt;/li&gt;
&lt;/ul&gt;
&lt;/details&gt;
&lt;h2 id=&quot;the-setup&quot;&gt;The setup&lt;/h2&gt;
&lt;p&gt;Three Ubuntu 26.04 VMs (kernel 7.0, virtio-net) on an isolated Proxmox bridge:
the box under test, an FRR peer, and a spare. I measured everything with
tcpdump &lt;strong&gt;on the hypervisor bridge&lt;/strong&gt;, not inside the guest. That ended up
mattering a lot, more on that below.&lt;/p&gt;
&lt;p&gt;Baseline: FRR 10.5.1 bfdd, one session at &lt;code&gt;3x10ms&lt;/code&gt;, and a stress-ng ladder on
the box under test. Gaps are time between packets sent to the peer, in ms.
Normal spacing is 7.5 to 10ms because BFD adds jitter on purpose.&lt;/p&gt;
&lt;div class=&quot;table-scroll&quot; tabindex=&quot;0&quot; role=&quot;region&quot; aria-label=&quot;Table, scrollable&quot;&gt;












































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Level&lt;/th&gt;&lt;th&gt;Load&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;p50&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;p99&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;max gap&lt;/th&gt;&lt;th&gt;Result&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;L1&lt;/td&gt;&lt;td&gt;4 CPU hogs&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;8.81&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10.03&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;~10&lt;/td&gt;&lt;td&gt;fine&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;L2&lt;/td&gt;&lt;td&gt;8 CPU hogs + context switching&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;8.73&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10.03&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;750&lt;/td&gt;&lt;td&gt;flapped&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;L3&lt;/td&gt;&lt;td&gt;CPU + timer pressure&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;8.82&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10.16&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;970&lt;/td&gt;&lt;td&gt;44 flaps in 120s&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;L4&lt;/td&gt;&lt;td&gt;4 SCHED_FIFO hogs&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;8.82&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;287&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;960&lt;/td&gt;&lt;td&gt;~40% of packets never sent&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Look at L3. &lt;strong&gt;44 flaps in two minutes, and the p99 is 10.16ms.&lt;/strong&gt; That’s a
perfect number. The stalls that kill the session are rare, a handful per
hundred seconds, so they never show up in a percentile. If you graph p99
packet spacing on a dashboard, this box looks healthy while it’s pulling routes
every few seconds. Only the max shows it.&lt;/p&gt;
&lt;h2 id=&quot;so-i-wrote-a-dumb-daemon-to-prove-the-point&quot;&gt;So I wrote a dumb daemon to prove the point&lt;/h2&gt;
&lt;p&gt;Before touching the kernel I wanted a second data point. I wrote the simplest
BFD daemon I could: 160 lines, one thread, a &lt;code&gt;recv()&lt;/code&gt; with a 2ms timeout as the
clock, and the RFC state machine. The plan was to watch it die like bfdd did
and then go build the XDP version.&lt;/p&gt;
&lt;p&gt;It didn’t die. Under the exact L3 load that flapped bfdd 44 times, it had
&lt;strong&gt;zero flaps and a 13ms p99&lt;/strong&gt;. I ran it again because I didn’t believe it.&lt;/p&gt;
&lt;p&gt;So why did my 160 lines survive when bfdd didn’t? After digging for a bit, it
came down to how Linux picks what runs next. The normal scheduler keeps track
of how much CPU each task has used, and when a task wakes up, the one that’s
used the least gets to go first. My daemon woke up every 2ms, did a few
microseconds of work and went back to sleep, so it had barely used any CPU.
Every time it woke up, it got to cut in front of the stress processes almost
immediately.&lt;/p&gt;
&lt;p&gt;bfdd is a much bigger program. It’s one event loop handling zebra, config and a
lot of timers, and the timer stress I was running hit exactly the part where it
wakes up. So it wasn’t that userspace can’t do BFD. bfdd just wakes up in a way
that this kind of load hurts.&lt;/p&gt;
&lt;h2 id=&quot;then-i-added-real-time-load&quot;&gt;Then I added real-time load&lt;/h2&gt;
&lt;p&gt;Next I tried the L4 load on it: four SCHED_FIFO hogs, one per vCPU. This time
my daemon broke too, with &lt;strong&gt;25 flaps in 60 seconds and a 324ms max gap.&lt;/strong&gt;
Real-time tasks always run before normal ones, so with all four cores busy my
daemon only got a tiny slice of CPU each second, and the quick wakeups stopped
mattering.&lt;/p&gt;
&lt;p&gt;The weird part was the daemon’s log. It showed &lt;strong&gt;zero timeouts&lt;/strong&gt; for the whole
run, like the peer’s packets had arrived right on time. The capture on the
hypervisor said the peer had seen 300ms of silence and dropped the session.&lt;/p&gt;
&lt;p&gt;Here’s what happened. While my daemon wasn’t running, the peer’s
packets were piling up in the socket buffer. When it got CPU again it read them
all at once, and from its point of view everything had arrived fine. If I’d
only had the daemon’s logs I would have missed this completely, which is why every timing and flap number in this series comes from a capture
taken outside the box being tested.&lt;/p&gt;
&lt;p&gt;So the next question was whether I could get packets out on time even when
userspace can’t run at all. That’s &lt;a href=&quot;https://w453y.me/writing/xdp-bfd-2-getting-tx-out-of-userspace/&quot;&gt;part
2&lt;/a&gt;.&lt;/p&gt;</content:encoded><media:content url="https://w453y.me/uploads/cards/xdp-bfd-1-why-software-bfd-flaps.png" medium="image" type="image/png"/><media:thumbnail url="https://w453y.me/uploads/cards/xdp-bfd-1-why-software-bfd-flaps.png"/><category>bfd</category><category>xdp</category><category>linux</category><category>measurement</category><category>Building xdp-bfd</category><author>w453y.me@gmail.com (Abdul Wasey)</author><enclosure url="https://w453y.me/uploads/cards/xdp-bfd-1-why-software-bfd-flaps.png" length="48243" type="image/png"/></item><item><title>How my homelab is set up</title><link>https://w453y.me/writing/homelab-setup/</link><guid isPermaLink="true">https://w453y.me/writing/homelab-setup/</guid><description>Two Proxmox nodes on shared iSCSI storage, a firewall that runs as one of its own guests, one reverse proxy in front of everything, DNS split for inside and outside, and the monitoring that watches it all.</description><pubDate>Sat, 18 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Everything I self-host runs on two Proxmox nodes at home. There are 28 guests on
them right now, half containers and half VMs: my mail, my Matrix server, my
password manager, DNS, media, this site, a local LLM, and the lab I used for
&lt;a href=&quot;https://w453y.me/writing/series/line-card-bfd/&quot;&gt;xdp-bfd&lt;/a&gt;. This is how it all fits together.&lt;/p&gt;
&lt;p&gt;Here’s all of it in one picture, with each guest on the node it’s running on
today:&lt;/p&gt;
&lt;figure&gt;
  &lt;a href=&quot;https://w453y.me/uploads/writing/homelab/overview.png&quot;&gt;&lt;img width=&quot;2400&quot; height=&quot;1147&quot; src=&quot;https://w453y.me/uploads/writing/homelab/overview.png&quot; alt=&quot;Isometric overview of the homelab: node-a and node-b as two planes, with the OPNsense firewall, Matrix, mail, media, code-server and a test-cluster node on node-a, and the nginx reverse proxy, auth service, Vaultwarden, Guacamole, Stirling-PDF, dnsdist, two Pi-holes, two stunnels, monitoring, this site, iperf3, a local LLM, a test-cluster node and the eBPF lab runner on node-b. The three eBPF lab VMs sit on isolated guest-only networks. Arrows show web requests from the internet through the firewall to the proxy and out to the services, DNS through dnsdist and the Pi-holes, monitoring alerts into Matrix, media traffic leaving through Cloudflare WARP, backups to an offsite peer over WireGuard, and both nodes on shared iSCSI storage&quot; loading=&quot;lazy&quot;&gt;&lt;/a&gt;
  &lt;figcaption&gt;Everything, and where it runs today. Click for the full size.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id=&quot;the-two-nodes&quot;&gt;The two nodes&lt;/h2&gt;
&lt;p&gt;Both nodes are in one Proxmox cluster. Each one has its own ZFS pool for the
host, but most guests live on storage that both nodes can see: one LUN over
iSCSI, with a shared LVM volume group on it. That’s what lets me move a running
guest from one node to the other. I do that by hand, though. The HA manager is
running, but I haven’t set any guest up for HA.&lt;/p&gt;
&lt;p&gt;Each node talks to the storage over four iSCSI paths, each on its own network.
Multipath uses two of them at a time and keeps the other two as standby, and if
every path drops for a moment, the guests’ disk I/O just waits instead of
erroring out. I don’t bond any links at all. The storage paths are where the
redundancy is.&lt;/p&gt;
&lt;figure&gt;
  &lt;img width=&quot;1800&quot; height=&quot;831&quot; src=&quot;https://w453y.me/uploads/writing/homelab/cluster.png&quot; alt=&quot;Diagram of the cluster: node-a and node-b, each with a local ZFS root pool and its containers and VMs, joined by the cluster network with two votes and no tiebreaker, and by manual live migration over the shared storage. Each node has two active and two standby iSCSI paths to the shared block storage, which carries a shared LVM with no snapshots&quot; loading=&quot;lazy&quot;&gt;
  &lt;figcaption&gt;Two nodes, one shared LUN, four paths from each node.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;It’s not perfect. With only two nodes there’s no tiebreaker, so if one node
goes down, the cluster loses quorum. And plain LVM on shared storage can’t do
snapshots, so the guests on it don’t get one before I change something.&lt;/p&gt;
&lt;h2 id=&quot;containers-or-vms&quot;&gt;Containers or VMs&lt;/h2&gt;
&lt;p&gt;If something only needs a userspace, it’s a container: the reverse proxy, the
whole DNS layer, Vaultwarden, Guacamole, monitoring and this site. If it needs
its own kernel or a different OS, it’s a VM: the firewall, mail, Matrix, the
media box, code-server, the LLM host and the eBPF lab. Two more VMs are a small
test cluster of their own, on the same bridges as the real one.&lt;/p&gt;
&lt;p&gt;Eleven of the guests run their software in Docker. Synapse and the DNS layer
don’t. They run directly on their guests.&lt;/p&gt;
&lt;h2 id=&quot;the-firewall-is-one-of-the-guests&quot;&gt;The firewall is one of the guests&lt;/h2&gt;
&lt;p&gt;My edge firewall is OPNsense, and it runs as a VM on the same cluster it routes
for. The internet comes in as a tagged VLAN on the hosts’ uplink bridge, and
OPNsense routes five internal networks from there: the main LAN, the cluster
interconnect, a second private network, a VLAN for testing, and WireGuard,
which is how my own devices and the offsite backup box get in. It also hands out
DHCP, serves NTP, runs its own resolver, and keeps an OpenVPN tunnel open going
out.&lt;/p&gt;
&lt;figure&gt;
  &lt;img width=&quot;1800&quot; height=&quot;1092&quot; src=&quot;https://w453y.me/uploads/writing/homelab/network.png&quot; alt=&quot;Diagram of the network: the internet reaches the OPNsense edge firewall, which runs as a VM on the cluster, over the WAN. The firewall routes the main LAN, the cluster interconnect, a secondary private segment, a VLAN test segment and the WireGuard tunnel network, and runs an outbound OpenVPN client tunnel&quot; loading=&quot;lazy&quot;&gt;
  &lt;figcaption&gt;The firewall and the segments behind it.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Two kinds of traffic never go through it. The iSCSI paths go straight from each
node to the storage, and the eBPF lab has two bridges with no physical ports at
all, so its traffic only moves between VMs. The lab VMs are on the main LAN as
well, though, so it’s the lab networks that are cut off, not the machines.&lt;/p&gt;
&lt;h2 id=&quot;one-proxy-in-front-of-everything&quot;&gt;One proxy in front of everything&lt;/h2&gt;
&lt;p&gt;Every web service sits behind one nginx reverse proxy, which has about 70
hostnames on it now. It runs my own nginx image with a JWT module built in.&lt;/p&gt;
&lt;p&gt;Most of my services have their own login, so the proxy just passes those
through. A few admin pages don’t have one, and those need a valid token. One
&lt;code&gt;map&lt;/code&gt; over the client’s address, the hostname and the user agent decides which
requests need a token, so the same page can be open from home and locked from
outside. Getting that decision to happen at runtime meant &lt;a href=&quot;https://w453y.me/writing/nginx-auth-jwt-runtime-variable/&quot;&gt;changing the JWT
module itself&lt;/a&gt;.&lt;/p&gt;
&lt;figure&gt;
  &lt;img width=&quot;1800&quot; height=&quot;950&quot; src=&quot;https://w453y.me/uploads/writing/homelab/proxy.png&quot; alt=&quot;Diagram of the request path: a client goes through the edge firewall to the reverse proxy, nginx in my own image with a JWT auth module, with certificates from certbot validated over DNS. Services with their own login, such as Matrix, the password vault, remote desktop, media and dashboards, are proxied directly. Admin pages with no login go through a JWT check first. The hypervisor web UI is one upstream with ip_hash and failover across both nodes&quot; loading=&quot;lazy&quot;&gt;
  &lt;figcaption&gt;What happens to a request once it reaches the proxy.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The certificates are wildcards from Let’s Encrypt, and certbot gets them over
DNS, so nothing has to be open to the internet for a renewal. The Proxmox web UI
is behind the proxy too, as one upstream across both nodes, so if one node
stops answering, I end up on the other.&lt;/p&gt;
&lt;h2 id=&quot;dns&quot;&gt;DNS&lt;/h2&gt;
&lt;p&gt;I wanted filtering and full recursion for my own devices, and encrypted DNS
with a different filter for anyone outside, so there are two Pi-holes. The
internal one has unbound running on the same guest for recursion, and my
devices just query it directly.&lt;/p&gt;
&lt;p&gt;Encrypted DNS comes in through the proxy instead. A DNS over HTTPS request hits
nginx, which picks one of two dnsdist listeners depending on where the request
came from, and each listener hands it to one of the Pi-holes. DNS over TLS goes
through two stunnel instances, one in front of each Pi-hole.&lt;/p&gt;
&lt;figure&gt;
  &lt;img width=&quot;1800&quot; height=&quot;857&quot; src=&quot;https://w453y.me/uploads/writing/homelab/dns.png&quot; alt=&quot;Diagram of the DNS chain: internal clients query the internal Pi-hole directly, which uses unbound for recursion. DNS over HTTPS clients reach the reverse proxy, whose geo and map split picks one of two dnsdist listeners, each forwarding to the internal or the public Pi-hole. DoT clients go through two stunnel instances, one in front of each Pi-hole&quot; loading=&quot;lazy&quot;&gt;
  &lt;figcaption&gt;Plain queries go straight to the filtering resolver. Encrypted ones come in at the proxy, which decides which policy they get.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id=&quot;mail-chat-and-the-rest&quot;&gt;Mail, chat and the rest&lt;/h2&gt;
&lt;p&gt;Mail is Mailcow in its own VM, which is 18 containers by itself: Postfix and
Dovecot, rspamd and ClamAV, a scanner for Office macros, and SOGo for webmail.
Matrix is Synapse with PostgreSQL, plus coturn for calls, all installed straight
on the VM, and registration is closed.&lt;/p&gt;
&lt;p&gt;The rest is Vaultwarden, Guacamole, Stirling-PDF, code-server, an iperf3
endpoint, a local LLM on llama.cpp, and the media stack, which is Jellyfin, the
*arr apps and qBittorrent. The media VM’s traffic goes out through a Cloudflare
WARP tunnel with a kill switch, so if the tunnel drops, that traffic stops
instead of going out directly.&lt;/p&gt;
&lt;h2 id=&quot;monitoring-and-backups&quot;&gt;Monitoring and backups&lt;/h2&gt;
&lt;p&gt;I only added monitoring on 10 September, long after most of what it watches.
It’s Prometheus, Loki, Grafana and Alertmanager in one container, with agents on
19 hosts. Prometheus pulls from the agents instead of having them push, so if a
host goes quiet, it shows up as down instead of just going silent. Blackbox
probes check things from the outside too, and an alert fires three weeks before
any certificate expires.&lt;/p&gt;
&lt;p&gt;The alerts go to a private room on my own Matrix server. The obvious problem is
that if the cluster goes down, the alerts go down with it.&lt;/p&gt;
&lt;p&gt;The backups are a nightly &lt;code&gt;vzdump&lt;/code&gt; on each node, then an rsync to an offsite box
over WireGuard. That box isn’t always online, so the alert only fires when the
last good sync is more than 48 hours old. The first thing the new monitoring
found was that one node’s sync had been failing every night for 23 days, and
that turned into &lt;a href=&quot;https://w453y.me/writing/offsite-backups-tcp-congestion-control/&quot;&gt;a much longer story about TCP congestion
control&lt;/a&gt;.&lt;/p&gt;
&lt;figure&gt;
  &lt;img width=&quot;1097&quot; height=&quot;893&quot; src=&quot;https://w453y.me/uploads/writing/homelab/backups.png&quot; alt=&quot;Diagram of the backup flow: the guests in the backup job are backed up nightly by vzdump in snapshot mode with zstd to a local store on each node, then rsynced with four workers and BBR over WireGuard to an offsite peer behind a NAT. Each run writes metrics through the textfile collector to Prometheus, which has seven alert rules that send alerts to a Matrix room on my own server&quot; loading=&quot;lazy&quot;&gt;
  &lt;figcaption&gt;The nightly backups, and how I&apos;d find out if they stopped.&lt;/figcaption&gt;
&lt;/figure&gt;</content:encoded><media:content url="https://w453y.me/uploads/cards/homelab.png" medium="image" type="image/png"/><media:thumbnail url="https://w453y.me/uploads/cards/homelab.png"/><category>homelab</category><category>proxmox</category><category>networking</category><category>self-hosting</category><author>w453y.me@gmail.com (Abdul Wasey)</author><enclosure url="https://w453y.me/uploads/cards/homelab.png" length="45775" type="image/png"/></item><item><title>Turning off JWT auth for my VPN in nginx</title><link>https://w453y.me/writing/nginx-auth-jwt-runtime-variable/</link><guid isPermaLink="true">https://w453y.me/writing/nginx-auth-jwt-runtime-variable/</guid><description>I wanted my self-hosted services to skip the login when I&apos;m on my VPN. The JWT module&apos;s on/off setting couldn&apos;t take a variable, so I added that, and my first version turned auth on for every site.</description><pubDate>Tue, 16 Sep 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I run about thirty self-hosted services behind one nginx reverse proxy, and all
of them sit behind &lt;a href=&quot;https://github.com/w453y/ngx-http-auth-jwt-gateway&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;a JWT auth
gateway&lt;/a&gt; that logs you in
with Google. For anything reachable from the internet that’s what I want. But
most of the time I’m already on my own WireGuard VPN, and having to log in
again just to open a dashboard got annoying.&lt;/p&gt;
&lt;p&gt;So I wanted auth to stay on for everyone, except traffic coming from the VPN
subnet. nginx has a &lt;code&gt;geo&lt;/code&gt; module for exactly this kind of thing. It sets a
variable based on the client’s address:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark-default&quot; style=&quot;background-color:#0d1117;color:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;nginx&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;geo &lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;$jwt_enabled {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;    default&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;        on&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;    10.10.10.0/&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;20&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;  off&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;;   &lt;/span&gt;&lt;span style=&quot;color:#8B949E&quot;&gt;# VPN&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The plan was to pass that to the JWT module with
&lt;code&gt;auth_jwt_enabled $jwt_enabled;&lt;/code&gt;. When I tried it, nginx refused to start,
saying the value was invalid.&lt;/p&gt;
&lt;h2 id=&quot;why-the-variable-didnt-work&quot;&gt;Why the variable didn’t work&lt;/h2&gt;
&lt;p&gt;In an nginx config it looks like variables work everywhere, but they only work
in directives that were written to accept them. Each directive decides in its C
code how its value gets read, and
&lt;code&gt;auth_jwt_enabled&lt;/code&gt; was set up as an on/off flag:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark-default&quot; style=&quot;background-color:#0d1117;color:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;c&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;NGX_HTTP_MAIN_CONF &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; NGX_HTTP_SRV_CONF &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; NGX_HTTP_LOC_CONF &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; NGX_CONF_FLAG,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;ngx_conf_set_flag_slot,&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A flag like this gets read once, when nginx loads its config. It has to be
exactly &lt;code&gt;on&lt;/code&gt; or &lt;code&gt;off&lt;/code&gt;, and it gets stored as a 1 or a 0. &lt;code&gt;$jwt_enabled&lt;/code&gt; isn’t
either of those, so nginx rejected it before any request came in.&lt;/p&gt;
&lt;h2 id=&quot;adding-variable-support&quot;&gt;Adding variable support&lt;/h2&gt;
&lt;p&gt;nginx already has a way for a directive to take a value that gets worked out
per request, called a complex value. So I changed &lt;code&gt;auth_jwt_enabled&lt;/code&gt; to use
that instead of a flag. The config now stores the expression, and the module
evaluates it for each request:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark-default&quot; style=&quot;background-color:#0d1117;color:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;c&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;ngx_int_t&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; enabled &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; (jwtcf&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;enabled &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;!=&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt; NULL&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;  ngx_str_t&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; cv;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#D2A8FF&quot;&gt;ngx_http_complex_value&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;(r, jwtcf-&gt;enabled, &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;&amp;#x26;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;cv) &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; NGX_OK &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; cv.len &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;&gt;&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#D2A8FF&quot;&gt;ngx_strncmp&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;(cv.data, &lt;/span&gt;&lt;span style=&quot;color:#A5D6FF&quot;&gt;&quot;off&quot;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;3&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;      enabled &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Auth only turns off if the value is exactly &lt;code&gt;off&lt;/code&gt;. If it’s empty, misspelled,
or the &lt;code&gt;geo&lt;/code&gt; block doesn’t match anything, auth stays on. Plain
&lt;code&gt;auth_jwt_enabled on;&lt;/code&gt; still works the same as before, since &lt;code&gt;on&lt;/code&gt; is just a
value that never changes.&lt;/p&gt;
&lt;h2 id=&quot;every-site-started-asking-for-a-token&quot;&gt;Every site started asking for a token&lt;/h2&gt;
&lt;p&gt;After building my first version, every site on the box started asking for a
JWT, including ones that had no JWT config at all.&lt;/p&gt;
&lt;p&gt;With the old flag, “not set” had its own special value, and the module turned
that into off. That’s why it did nothing on sites that never mentioned it. With
my change the setting was a pointer, and “not set” was now just an empty
pointer. My code started from &lt;code&gt;enabled = 1&lt;/code&gt; and only turned it off for an
explicit &lt;code&gt;off&lt;/code&gt;, so an empty pointer meant on.&lt;/p&gt;
&lt;p&gt;I fixed it by adding a check at the start: if no JWT key is configured, the
module steps aside. Without a key it can’t check tokens anyway.&lt;/p&gt;
&lt;h2 id=&quot;getting-it-merged&quot;&gt;Getting it merged&lt;/h2&gt;
&lt;p&gt;The maintainer liked the change and added some formatting fixes and tests. He
also removed my key check, saying other checks further down already handled
that case. I rebuilt with his changes and tried a config with no JWT settings,
and nothing asked for a token, so I agreed to drop it.&lt;/p&gt;
&lt;p&gt;For the tests, he couldn’t easily test a subnet in CI, so he set the variable
from a request header instead:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark-default&quot; style=&quot;background-color:#0d1117;color:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;nginx&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;map&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; $&lt;/span&gt;&lt;span style=&quot;color:#FFA657&quot;&gt;http_test_auth_enabled&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; $jwt_enabled {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;    default&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;  on&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;    on&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;       on&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;    off&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;      off&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A request with &lt;code&gt;Test-Auth-Enabled: on&lt;/code&gt; should get a 401, and one with &lt;code&gt;off&lt;/code&gt;
should get a 200.&lt;/p&gt;
&lt;p&gt;It was &lt;a href=&quot;https://github.com/TeslaGov/ngx-http-auth-jwt-module/releases/tag/2.4.0&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;released in
v2.4.0&lt;/a&gt;
about a month after I opened the PR, and my reverse proxy runs it now. On the
VPN, dashboards open straight away, and from anywhere else I get the Google
login.&lt;/p&gt;</content:encoded><media:content url="https://w453y.me/uploads/cards/nginx-jwt.png" medium="image" type="image/png"/><media:thumbnail url="https://w453y.me/uploads/cards/nginx-jwt.png"/><category>nginx</category><category>authentication</category><category>jwt</category><category>c</category><author>w453y.me@gmail.com (Abdul Wasey)</author><enclosure url="https://w453y.me/uploads/cards/nginx-jwt.png" length="48101" type="image/png"/></item><item><title>Proxmox Disks page failing on a USB SSD</title><link>https://w453y.me/writing/proxmox-smart-usb-disk-hang/</link><guid isPermaLink="true">https://w453y.me/writing/proxmox-smart-usb-disk-hang/</guid><description>The Disks page in Proxmox kept failing with a communication error when I plugged in an SSD over USB. It came down to a smartctl call that took two minutes and had no timeout.</description><pubDate>Sat, 08 Mar 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I plugged a 2.5” SSD into one of my Proxmox nodes through a USB-to-SATA
adapter and opened &lt;strong&gt;Node → Disks&lt;/strong&gt; in the web UI. It showed a spinner for a
while and then &lt;code&gt;Communication Failure&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The disk itself was working. &lt;code&gt;lsblk&lt;/code&gt; listed it, &lt;code&gt;fdisk&lt;/code&gt; could read it, and I
mounted it and copied files onto it without any problem. Only the Disks page
failed, and there was nothing about it in &lt;code&gt;journalctl&lt;/code&gt;, syslog or the
pvedaemon logs.&lt;/p&gt;
&lt;h2 id=&quot;finding-the-call-that-hung&quot;&gt;Finding the call that hung&lt;/h2&gt;
&lt;p&gt;Proxmox’s API is written in Perl, so I could read the code behind the Disks
page directly on the node. The request goes from &lt;code&gt;PVE/API2/Disks.pm&lt;/code&gt; into
&lt;code&gt;PVE/Diskmanage.pm&lt;/code&gt;, and for each disk it calls &lt;code&gt;get_smart_data()&lt;/code&gt;, which runs
&lt;code&gt;smartctl&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark-default&quot; style=&quot;background-color:#0d1117;color:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;perl&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; (!$nosmart) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;    eval&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;        my&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; $smartdata = get_smart_data($devpath, !is_ssdlike($type));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;        $health = $smartdata&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;{&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;health&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;} &lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; $smartdata&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;{&lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;health&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;};&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#8B949E&quot;&gt;        # ...wearout for SSDs...&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;    };&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;eval&lt;/code&gt; there catches errors, but it doesn’t help if the command is just
slow, and &lt;code&gt;smartctl&lt;/code&gt; was being run without any timeout. To check whether this
was the problem, I emptied out that block, restarted &lt;code&gt;pvedaemon&lt;/code&gt; and reloaded
the page. The Disks page loaded instantly, so the SMART call was what was
holding it up.&lt;/p&gt;
&lt;h2 id=&quot;it-wasnt-actually-hanging&quot;&gt;It wasn’t actually hanging&lt;/h2&gt;
&lt;p&gt;I filed &lt;a href=&quot;https://bugzilla.proxmox.com/show_bug.cgi?id=6224&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;bug #6224&lt;/a&gt; with
what I’d found. Fabian Grünbichler replied with two questions: does &lt;code&gt;smartctl&lt;/code&gt;
ever finish if you run it by hand, and how long does it take?&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark-default&quot; style=&quot;background-color:#0d1117;color:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;# time smartctl -a /dev/sdb&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Read NVMe Identify Controller failed: scsi error unsupported field in scsi command&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;real    2m0.758s&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It did finish, after two minutes. I’d been describing it as a hang, but
&lt;code&gt;smartctl&lt;/code&gt; was returning, just long after the web UI had given up waiting. That
meant the fix was a timeout, not tracking down something stuck.&lt;/p&gt;
&lt;p&gt;Daniel Kral worked out where the two minutes came from. smartmontools waits up
to 60 seconds for a SCSI command by default, so that a disk in a big storage
shelf has time to spin up. A USB-to-SATA adapter can take that whole 60
seconds to fail a command instead of failing it right away, and my two minutes
was that timeout being hit twice.&lt;/p&gt;
&lt;h2 id=&quot;the-fix&quot;&gt;The fix&lt;/h2&gt;
&lt;p&gt;The main change was adding a timeout to the &lt;code&gt;smartctl&lt;/code&gt; call:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark-default&quot; style=&quot;background-color:#0d1117;color:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;perl&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;-	run_command($cmd, &lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;noerr&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt; =&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; 1, &lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;outfunc&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt; =&gt;&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt; sub&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt;+	run_command($cmd, &lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;noerr&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt; =&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; 1, &lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;timeout&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt; =&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; 10, &lt;/span&gt;&lt;span style=&quot;color:#79C0FF&quot;&gt;outfunc&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt; =&gt;&lt;/span&gt;&lt;span style=&quot;color:#FF7B72&quot;&gt; sub&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3&quot;&gt; {&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ten seconds, since a healthy drive answers almost immediately. Two smaller
patches went in with it. One passes the real error up when the timeout hits,
and the other shows the disk’s health as unknown in that case, because the
web UI expected a health value and would otherwise get an error message
instead.&lt;/p&gt;
&lt;p&gt;The patches are in &lt;code&gt;pve-storage&lt;/code&gt; now, though the bug itself is still marked
PATCH AVAILABLE instead of resolved. I didn’t write them myself. My part was
tracing the call, isolating it, and answering the maintainers’ questions with
the timings they asked for.&lt;/p&gt;</content:encoded><media:content url="https://w453y.me/uploads/cards/proxmox-usb.png" medium="image" type="image/png"/><media:thumbnail url="https://w453y.me/uploads/cards/proxmox-usb.png"/><category>proxmox</category><category>linux</category><category>debugging</category><category>storage</category><author>w453y.me@gmail.com (Abdul Wasey)</author><enclosure url="https://w453y.me/uploads/cards/proxmox-usb.png" length="45738" type="image/png"/></item></channel></rss>