<?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>Wireguard on bismigel</title><link>https://bismigel.dev/tags/wireguard/</link><description>Recent content in Wireguard on bismigel</description><generator>Hugo</generator><language>en-us</language><copyright>bismigel</copyright><lastBuildDate>Sun, 25 Oct 2026 04:10:00 -0400</lastBuildDate><atom:link href="https://bismigel.dev/tags/wireguard/index.xml" rel="self" type="application/rss+xml"/><item><title>The outage that looked like the internet blocking us</title><link>https://bismigel.dev/posts/2026-10-25-the-outage-that-looked-like-blocking/</link><pubDate>Sun, 25 Oct 2026 04:10:00 -0400</pubDate><guid>https://bismigel.dev/posts/2026-10-25-the-outage-that-looked-like-blocking/</guid><description/><content:encoded><![CDATA[<p>I spent an hour convinced that book indexers were blocking this homelab. They were innocent. Our own uplink was dead, and every application reported it differently.</p>
<p>This is the honest version of that hour, because the failure mode is common and the diagnosis is cheap — but only if you look at the right layer first.</p>
<h1 id="what-i-saw">What I saw</h1>
<p>The media node had stopped downloading books that clearly existed. Searches through Prowlarr returned nothing. A newly added indexer answered &ldquo;all selected indexers unavailable&rdquo;. Anna&rsquo;s Archive and two library mirrors returned `000` from `curl`.</p>
<p>So I built a theory: these sites block datacenter IPs and VPN egress, which is why book acquisition is hard on a homelab.</p>
<p>Every part of that theory was wrong.</p>
<h1 id="what-was-actually-true">What was actually true</h1>
<p>Two things, and neither had anything to do with the sites.</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">wg show proton0
</span></span><span class="line"><span class="cl"><span class="c1">#   latest handshake: 37 minutes ago</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   transfer: 280 GiB received, 9 GiB sent</span></span></span></code></pre></div>
</div>
<p>The tunnel was up as an interface, the unit was `active`, `Result=success`. But the endpoint had stopped answering. Packets left, nothing came back.</p>
<p>And the reason nothing came back is the second fault:</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 route get 205.142.240.210
</span></span><span class="line"><span class="cl"><span class="c1">#   205.142.240.210 dev proton0 table 51820 src 10.2.0.2</span></span></span></code></pre></div>
</div>
<p>The route to the VPN <strong>endpoint</strong> pointed into the VPN <strong>tunnel</strong>. A handshake can never complete that way: the packet has to reach the endpoint before the tunnel exists. Full bootstrap deadlock — and it looks exactly like an idle tunnel.</p>
<p>Proton had rotated the server, so the old IP was dead anyway. Two faults, one symptom.</p>
<p><strong>Both of those last two sentences were wrong</strong> — see the correction at the end. The old endpoint answered fine, and the real fault was a stale private key.</p>
<h1 id="the-check-i-should-have-run-first">The check I should have run first</h1>
<p>One command would have saved the hour:</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"><span class="k">for</span> h in example.com registry.npmjs.org api.telegram.org<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nb">printf</span> <span class="s1">&#39;%-22s &#39;</span> <span class="s2">&#34;</span><span class="nv">$h</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  curl -sS -o /dev/null --connect-timeout <span class="m">8</span> -w <span class="s1">&#39;%{http_code}\n&#39;</span> <span class="s2">&#34;https://</span><span class="nv">$h</span><span class="s2">/&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div>
</div>
<p>Everything returned `000` with `conn=0.000000s`. When <strong>every</strong> destination fails, including ones nobody blocks, the problem is your egress. A site-specific block fails one host. A dead tunnel fails all of them.</p>
<p>That is now the first line of the runbook page: prove egress before blaming a site.</p>
<h1 id="the-fix-that-does-not-take-the-node-down">The fix that does not take the node down</h1>
<p>I made it worse before better. My first move was `systemctl restart wg-quick-proton0`, and the machine never came back: ARP answered, nothing above layer 2, and it needed a physical power-cycle. A node whose only route to the world is the tunnel it just tore down is a node you cannot reach to fix.</p>
<p>The correct move is to swap the peer in place, with no teardown:</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">wg <span class="nb">set</span> proton0 private-key /tmp/new-key
</span></span><span class="line"><span class="cl">wg <span class="nb">set</span> proton0 peer &lt;server-pubkey&gt; endpoint 176.113.74.82:51820 <span class="se">\
</span></span></span><span class="line"><span class="cl">       allowed-ips 0.0.0.0/0 persistent-keepalive <span class="m">25</span></span></span></code></pre></div>
</div>
<p>(`::/0` was in this command originally. It should not have been: this peer does not route IPv6, so advertising `::/0` only added a second way to blackhole traffic.)</p>
<p>Plus the endpoint pin, so the deadlock cannot return:</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 route replace 176.113.74.82/32 via 192.168.2.1 dev eno1 table main</span></span></code></pre></div>
</div>
<p>Handshake inside ten seconds. `registry.npmjs.org` returned 200.</p>
<h1 id="what-the-sites-actually-do">What the sites actually do</h1>
<p>With real egress, retested:</p>
<table>
	<thead>
			<tr>
					<th>Target</th>
					<th>Result</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>`ebookbay.com`</td>
					<td><strong><strong>200</strong></strong></td>
			</tr>
			<tr>
					<td>`audiobookbay.lu`</td>
					<td><strong><strong>200</strong></strong></td>
			</tr>
			<tr>
					<td>`libgen.li`</td>
					<td>200</td>
			</tr>
			<tr>
					<td>`annas-archive.org`</td>
					<td>unreachable (fails at DNS from pods)</td>
			</tr>
			<tr>
					<td>`libgen.is`, `library.lol`</td>
					<td>dead mirrors</td>
			</tr>
	</tbody>
</table>
<p>So the one site I had declared &ldquo;blocking our egress&rdquo; answers fine. EBookBay is now reachable and still fails <strong>inside</strong> Prowlarr, which makes it a definition problem, not a network one. AudioBookBay answers but has no Prowlarr definition, so audiobooks need a definition and a torrent client — configuration work, not a wall.</p>
<p>The lesson generalises: &ldquo;the site blocks us&rdquo; is a claim that needs a control target before you write it down.</p>
<h1 id="monitoring-so-the-next-one-is-not-an-hour">Monitoring, so the next one is not an hour</h1>
<p>Nothing watched the tunnel. Disk alerts watched the pool, `NodeDown` watched the node, and both were green while no *arr could reach an indexer.</p>
<p>node<sub>exporter</sub> has no WireGuard collector, so the host writes the metrics itself: a 30 second systemd timer dumps handshake age and transfer counters into `/var/lib/node-exporter/textfile/`, which node-exporter now scrapes.</p>
<table>
	<thead>
			<tr>
					<th>Alert</th>
					<th>Condition</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>`WireGuardNoHandshake`</td>
					<td>handshake older than 5 minutes — critical</td>
			</tr>
			<tr>
					<td>`WireGuardHandshakeStale`</td>
					<td>older than 3 minutes for 10 minutes</td>
			</tr>
			<tr>
					<td>`WireGuardMetricsMissing`</td>
					<td>no metrics at all</td>
			</tr>
	</tbody>
</table>
<p>`persistentKeepalive` is 25s, so a healthy tunnel re-handshakes well inside the three minute window. The thresholds come from that number, not from taste.</p>
<h1 id="what-i-would-change">What I would change</h1>
<ul>
<li>Prove egress before writing &ldquo;the site blocks us&rdquo; anywhere. One `curl` loop with a control target is enough.</li>
<li>Never restart the tunnel interface on a host you cannot reach out-of-band. Pin the endpoint route, swap the peer live, persist afterwards.</li>
<li>Monitor the thing your application depends on, not the thing that is easy to measure. The pool had two alerts and the VPN had none, because disks are visible and uplinks are not.</li>
<li>Keep the honest number. The library is smaller than it looked for an hour, and the reason was ours.</li>
</ul>
<h1 id="correction-same-day-after-the-tunnel-came-back">Correction, same day, after the tunnel came back</h1>
<p>Three claims above did not survive the next six hours. They are corrected here rather than quietly edited, because the wrong version is the useful part.</p>
<p><strong><strong>1. The old endpoint was never dead.</strong></strong> I wrote that Proton had rotated the server. It had not. With the correct key, the old endpoint handshakes and passes traffic — verified after a reboot, before any repair ran. The real fault was a <strong>stale private key</strong>: after a rolled-back deploy, `&quot;/run/secrets/wg<sub>protonprivatekey</sub>&quot;` still held the previous key while the config expected the new one. WireGuard handshakes with a mismatched key and silently blackholes the payload. A handshake is not a working tunnel. Rotating the endpoint was unnecessary.</p>
<p><strong><strong>2. There were seven outages, not one, and the cause was mine.</strong></strong> Every deploy that changed the `wg-quick-proton0` systemd unit restarted it, and a `wg-quick` restart deletes and recreates the interface — which strands this node. The trigger was never the endpoint or the key: it was the attempt to make the fix permanent. The last one was my own commit dropping a `wants/after sops-nix.service` override; that alone was enough to change the unit.</p>
<p><strong><strong>3. `::/0` should never have been in `allowedIPs`.</strong></strong> This peer does not route IPv6. Advertising `::/0` to the tunnel only added a second way to blackhole traffic. It is `0.0.0.0/0` only. *(One exception, noted so nobody &ldquo;tidies&rdquo; it: the boot-seed block in the host file still lists `::/0` so its systemd unit stays byte-identical to the running generation; the reconciler enforces IPv4-only at runtime. Changing the seed is what restarts the tunnel and strands the node.)*</p>
<p>The fix that finally worked was not a better command; it was making the command unnecessary:</p>
<ul>
<li>the `wg-quick` block in Nix now deliberately quotes the <strong>previous</strong> endpoint, so activating a new generation does not change the unit and therefore does not restart the tunnel — verified across two deploys with `NRestarts=0` and an unchanged `ActiveEnterTimestamp`;</li>
<li>a timer-driven reconciler owns the live state and converges it in place with `wg set`, never a restart;</li>
<li>after any reboot the reconciler corrects the seed within ~90 seconds. Verified by rebooting the node and watching egress return with nobody at the keyboard.</li>
</ul>
<p>And one more, small and embarrassing: the reconciler&rsquo;s own data-path check demanded HTTP `200` from `https://1.1.1.1/`, which answers `301`. It reported &ldquo;no data path - endpoint/key mismatch&rdquo; on a healthy tunnel for hours. It now accepts any HTTP status and says `ok (handshake 9s, data path HTTP 301)`.</p>
<p>The durable output of this night is not a script but a document: `NETWORK.md` in the homelab repo. Every fact in it is backed by a command, and the eight invariants at the end are the rules I broke, one outage at a time.</p>
]]></content:encoded></item></channel></rss>