<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Routing on bismigel</title><link>https://bismigel.dev/tags/routing/</link><description>Recent content in Routing on bismigel</description><generator>Hugo</generator><language>en-us</language><copyright>bismigel</copyright><lastBuildDate>Wed, 28 Oct 2026 20:40:00 -0400</lastBuildDate><atom:link href="https://bismigel.dev/tags/routing/index.xml" rel="self" type="application/rss+xml"/><item><title>Seven outages, two words</title><link>https://bismigel.dev/posts/2026-10-28-seven-outages-two-words/</link><pubDate>Wed, 28 Oct 2026 20:40:00 -0400</pubDate><guid>https://bismigel.dev/posts/2026-10-28-seven-outages-two-words/</guid><description/><content:encoded><![CDATA[<p>On 2026-10-25 the media node went off the network seven times. Each time it came back only after a power-cycle, and each time the trigger was a deploy that changed something innocent-looking about its WireGuard tunnel.</p>
<p>The fix turned out to be two words in a config file: <code>pref 32765</code> and <code>pref 32764</code>. But it took three days, a disposable virtual machine, and a handful of wrong theories to get there — and the honest story is more useful than the fix.</p>
<h1 id="what-it-looked-like">What it looked like</h1>
<p>The node answered ARP and nothing else. No ICMP, no TCP, no SSH from any other host. The link light was on, the interface was up, <code>wg show</code> reported a recent handshake, and the kill-switch rules were in place. Everything an operator would check said &ldquo;fine&rdquo;.</p>
<p>A power-cycle fixed it, every time, which is exactly the kind of repair that teaches you nothing.</p>
<h1 id="the-theories-in-the-order-they-were-wrong">The theories, in the order they were wrong</h1>
<p><code>The endpoint is dead.</code> Plausible: Proton rotates endpoints, and the tunnel held a stale server. But the &ldquo;old&rdquo; endpoint answered fine from another host when it was finally tested — days later than it should have been.</p>
<p><code>It is IPv6.</code> The tunnel carried <code>::/0</code> while the peer had no IPv6 allowed-ips, so v6 packets entered the tunnel and died. Real, but a different bug: it made dual-stack clients slow, not dead.</p>
<p><code>It is the stale private key.</code> This one was real — a key that pairs with the wrong server still completes a handshake and then blackholes everything. It caused four of the seven outages, and it is why there is now a fingerprint check in the recovery script.</p>
<p>Each of those was a genuine bug. None of them explained the <strong>pattern</strong>: the node stranding itself during an activation. So the pattern got its own investigation, and that investigation needed somewhere to fail safely.</p>
<h1 id="a-staging-guest-that-fails-the-way-production-fails">A staging guest that fails the way production fails</h1>
<p>You cannot experiment with &ldquo;does this take the media node off the network&rdquo; on the media node. So there is now a throwaway KVM guest that mirrors the parts that matter:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">k3s, --flannel-backend=none, kube-proxy ON   (same as production)
</span></span><span class="line"><span class="cl">Cilium v1.20.3, same image digest, tunnel/vxlan, ipam kubernetes
</span></span><span class="line"><span class="cl">two NICs: eth0 = &#34;WAN&#34; (NAT), eth1 = LAN on a bridge
</span></span><span class="line"><span class="cl">pod CIDR 10.52.0.0/16, service CIDR 10.53.0.0/16  (never 10.42/10.43)
</span></span><span class="line"><span class="cl">a wg-quick full tunnel (AllowedIPs 0.0.0.0/0) to a dead TEST-NET-3 endpoint</span></span></code></pre></div>
</div>
<p>The dead endpoint is deliberate: it makes &ldquo;the tunnel carries no traffic&rdquo; deterministic, so any failure is a <strong>routing</strong> failure and not a VPN question.</p>
<p>It reproduced: <strong>3 strands in about 21 cold boots</strong>. Roughly one in seven, at random. That rate is a trap — a run that survives proves nothing, so a clean result cannot be read as a fix.</p>
<h1 id="catching-it-in-the-act">Catching it in the act</h1>
<p>The turning point was instrumenting the failure instead of explaining it. A timer armed before the tunnel came up dumped <code>ip rule show</code> to the serial console every three seconds.</p>
<p>In the healthy case:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">0:     from all lookup local
</span></span><span class="line"><span class="cl">32764: from all lookup main suppress_prefixlength 0
</span></span><span class="line"><span class="cl">32765: not from all fwmark 0xca6c lookup 51820
</span></span><span class="line"><span class="cl">32766: from all lookup main</span></span></code></pre></div>
</div>
<p>In the stranded case:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">9:     from all fwmark 0x200/0xf00 lookup 2004     &lt;- Cilium
</span></span><span class="line"><span class="cl">98:    from all lookup main suppress_prefixlength 0
</span></span><span class="line"><span class="cl">99:    not from all fwmark 0xca6c lookup 51820
</span></span><span class="line"><span class="cl">100:   from all lookup local</span></span></code></pre></div>
</div>
<p>The interface was identical. The routes were identical. What differed was the <strong>priority numbering</strong>: in the failing case the tunnel&rsquo;s two rules sit at 98 and 99, which is <strong>above</strong> the <code>local</code> rule at 100 — and in the working case they sit at 32764/32765, comfortably below <code>local</code> at 0.</p>
<p>That is the whole failure. When the tunnel&rsquo;s rules outrank <code>local</code>, a packet addressed to the node&rsquo;s own LAN address is looked up in <code>main</code>, finds the connected /24, and is sent <strong>out of the interface</strong> instead of being delivered locally. The node stops answering its own IP. ARP still works, because ARP is layer 2. Nothing in the logs says anything, and <code>wg-quick down</code> cannot repair it, because the rules were added at the wrong priorities in the first place.</p>
<h1 id="why-98-and-99">Why 98 and 99?</h1>
<p><code>wg-quick</code> adds its rules with <code>ip rule add</code> and no explicit priority. The kernel then picks one, in <code>fib_default_rule_pref()</code> — <a href="https://github.com/torvalds/linux/blob/master/net/core/fib_rules.c" target="_blank" rel="noopener noreferrer">net/core/fib<sub>rules</sub>.c</a>
:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">c</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="cl"><span class="k">static</span> <span class="n">u32</span> <span class="nf">fib_default_rule_pref</span><span class="p">(</span><span class="k">struct</span> <span class="n">fib_rules_ops</span> <span class="o">*</span><span class="n">ops</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nf">list_empty</span><span class="p">(</span><span class="o">&amp;</span><span class="n">ops</span><span class="o">-&gt;</span><span class="n">rules_list</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">pos</span> <span class="o">=</span> <span class="n">ops</span><span class="o">-&gt;</span><span class="n">rules_list</span><span class="p">.</span><span class="n">next</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">pos</span><span class="o">-&gt;</span><span class="n">next</span> <span class="o">!=</span> <span class="o">&amp;</span><span class="n">ops</span><span class="o">-&gt;</span><span class="n">rules_list</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">rule</span> <span class="o">=</span> <span class="nf">list_entry</span><span class="p">(</span><span class="n">pos</span><span class="o">-&gt;</span><span class="n">next</span><span class="p">,</span> <span class="k">struct</span> <span class="n">fib_rule</span><span class="p">,</span> <span class="n">list</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="n">rule</span><span class="o">-&gt;</span><span class="n">pref</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                <span class="k">return</span> <span class="n">rule</span><span class="o">-&gt;</span><span class="n">pref</span> <span class="o">-</span> <span class="mi">1</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>That is: <strong>the priority of the second rule in the list, minus one</strong>. It is not a constant, and it is not &ldquo;the highest unused priority&rdquo; as the folklore says. What ends up second in the list depends on what else has numbered itself.</p>
<p>On these nodes, Cilium installs a rule at priority 9 and the <code>local</code> rule sits at</p>
<ol>
<li>So the list the kernel sees is <code>[9, 100, 32766, 32767]</code>, the computed default</li>
</ol>
<p>is 99, and the tunnel&rsquo;s two rules land at 99 and 98 — both above <code>local</code>.</p>
<p>When the tunnel comes up <strong>before</strong> Cilium has created its rule, the list is <code>[0, 32766, 32767]</code>, the default is 32765, and the rules land at 32765/32764 — below <code>local</code>, and everything works.</p>
<p>That also explains the one-in-seven rate: it is a <strong>boot race</strong> between Cilium numbering its rules and <code>wg-quick</code> adding its own. On a node that is already running, it is not a race at all — it is deterministic, and a restart will strand it every single time:</p>
<table>
	<thead>
			<tr>
					<th>rule variant</th>
					<th>reachable</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>pref 32765</code> / <code>pref 32764</code></td>
					<td>10/10</td>
			</tr>
			<tr>
					<td>no <code>pref</code> (<code>wg-quick</code>, and the staged module)</td>
					<td>0/10</td>
			</tr>
	</tbody>
</table>
<h1 id="the-proof-in-one-guest-seconds-apart">The proof, in one guest, seconds apart</h1>
<p>Same node, same Cilium state, same table contents — only the priority differs:</p>
<table>
	<thead>
			<tr>
					<th>step</th>
					<th>rules</th>
					<th>gateway</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>A</td>
					<td>explicit <code>pref</code></td>
					<td><code>GW-OK</code></td>
			</tr>
			<tr>
					<td>B</td>
					<td>identical rules, no <code>pref</code> → 98/99</td>
					<td><code>GW-FAIL</code></td>
			</tr>
			<tr>
					<td>C</td>
					<td>explicit <code>pref</code> restored</td>
					<td><code>GW-OK</code></td>
			</tr>
	</tbody>
</table>
<p>That is causal, not correlational. And it disposes of the tidy explanation that the tunnel&rsquo;s rules were &ldquo;added in the wrong order&rdquo;: they were added in exactly the right order, at the wrong priority, because the kernel derived it from somebody else&rsquo;s numbering.</p>
<h1 id="the-fix-and-what-the-fix-did-not-cover">The fix, and what the fix did not cover</h1>
<p>The tunnel now runs on <code>networking.wireguard</code> with both rules pinned:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ip -4 rule add pref <span class="m">32765</span> not fwmark <span class="m">51820</span> table <span class="m">51820</span>
</span></span><span class="line"><span class="cl">ip -4 rule add pref <span class="m">32764</span> table main suppress_prefixlength <span class="m">0</span></span></span></code></pre></div>
</div>
<p>and <code>nix flake check</code> refuses to build if those two <code>pref</code> arguments disappear, or if a <code>wg-quick</code> interface comes back.</p>
<p>That is the mechanism fixed. Then the reboots found two more bugs, and neither could have been found by deploying to a running node:</p>
<p><strong>Deploying is not booting.</strong> <code>postSetup</code> pinned the endpoint route before NetworkManager had given <code>eno1</code> an address, and <code>ip route ... via 192.168.2.1</code> answered <code>Nexthop has invalid gateway</code>. The unit is <code>Type=oneshot</code>, so that failure is final: the node came back with no tunnel and no egress at all. It looked fine during the deploy, because the device was already configured then. <code>postSetup</code> now retries for 60 s and refuses to continue rather than blackholing the machine.</p>
<p><strong>The tunnel renamed the node.</strong> k3s derives its node address from the default route, and on this host the default route is the tunnel. With the tunnel now starting earlier, k3s called itself <code>10.2.0.2</code> and etcd advertised <code>https://10.2.0.2:2379</code> — an address no peer can reach, because it lives inside the tunnel that the peers do not share. The member never rejoined and the node sat <code>NotReady</code> for ten minutes. It is a genuinely funny shape: the node&rsquo;s identity was derived from the tunnel, and the tunnel&rsquo;s job is to carry traffic for that identity. <code>--node-ip</code> and <code>--advertise-address</code> are now pinned.</p>
<h1 id="what-i-would-take-from-this">What I would take from this</h1>
<p><strong>The lock was the bug.</strong> &ldquo;Never restart the tunnel&rdquo; was written down as a rule, and it was correct as a description of the symptom — but a rule like that hides the defect behind a habit. The replacement says what actually keeps the node up: those two <code>pref</code> arguments. The same applies to the rollback: <code>wg-restore.sh</code> repairs the link in place, and rolling back with <code>wg-quick up</code> would have re-created the outage while looking like a return to safety.</p>
<p><strong>An absent metric looks exactly like a healthy one.</strong> Earlier in this saga every <code>wireguard_*</code> metric had never reached Prometheus, because the textfile collector skipped a file that was <code>0600</code> while node-exporter ran as <code>nobody</code>. The only alert that ever fired was the one for the metric being missing.</p>
<p><strong>An empty PromQL result is indistinguishable from an error.</strong> The first version of the fix for a noisy alert used <code>and ... group_left</code>, which is not valid PromQL; it failed as an empty result, which looks precisely like &ldquo;nothing to report&rdquo;. The control query — the same expression with the guard disabled — is what exposed it.</p>
<p><strong>Boot is part of the acceptance test.</strong> Twice now the difference between &ldquo;works&rdquo; and &ldquo;works after a power cut&rdquo; was a boot-ordering assumption nobody had written down.</p>
<h1 id="where-it-stands">Where it stands</h1>
<p>The tunnel is up within about forty seconds of boot, egress through it works, and the operation that caused seven outages is now routine:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ssh root@192.168.2.81 systemctl restart wireguard-proton0
</span></span><span class="line"><span class="cl"><span class="c1">#   3/3 restarts on 2026-10-28: rules at 32764/32765, gateway reachable,</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   egress 301, fresh handshake each time</span></span></span></code></pre></div>
</div>
<p>The recovery procedures are in runbook 14, the invariants — including the two <code>pref</code> values — are in <code>NETWORK.md</code>, and the staging guest that made this answerable is checked in so the next mystery starts with an instrument instead of a theory.</p>
]]></content:encoded></item></channel></rss>