<?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>Networking on ErrorZap</title><link>https://errorzap.com/tags/networking/</link><description>Recent content in Networking on ErrorZap</description><image><title>ErrorZap</title><url>https://errorzap.com/og.png</url><link>https://errorzap.com/og.png</link></image><generator>Hugo</generator><language>en-US</language><copyright>ErrorZap</copyright><lastBuildDate>Tue, 02 Jun 2026 00:00:00 -0600</lastBuildDate><atom:link href="https://errorzap.com/tags/networking/index.xml" rel="self" type="application/rss+xml"/><item><title>Seven Network Gotchas That Look Like Ghosts</title><link>https://errorzap.com/posts/seven-network-gotchas-that-look-like-ghosts/</link><pubDate>Tue, 02 Jun 2026 00:00:00 -0600</pubDate><guid>https://errorzap.com/posts/seven-network-gotchas-that-look-like-ghosts/</guid><description>The network isn&amp;rsquo;t haunted — it&amp;rsquo;s orphaned ports, stale DNS, and a firewall eating your return path. Run the checklist before you light the candles.</description><content:encoded><![CDATA[<figure style="text-align:center;margin:0 0 30px"><img src="hero.png" alt="Seven Network Gotchas That Look Like Ghosts" style="max-width:520px;width:100%;border-radius:14px"/></figure>
<p>The ticket said: <em>&ldquo;The server is possessed.&rdquo;</em></p>
<p>That&rsquo;s a direct quote. An app had been rock-solid for two years, then one Tuesday it &ldquo;just stopped&rdquo; — no config change, no deploy, no nothing. The on-call engineer had already rebooted twice, blamed cosmic rays, and was halfway to blaming the building&rsquo;s wiring.</p>
<p>It wasn&rsquo;t possessed. It never is.</p>
<p>After enough years you learn that 95% of &ldquo;haunted network&rdquo; tickets are the same seven mundane bastards wearing bedsheets. None of them are mysterious. All of them are one check away from being solved. The trick is running the check <em>before</em> you start theorizing about ghosts.</p>
<p>Here&rsquo;s the scene I keep coming back to.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>  THE GHOST                        THE CHECK              VERDICT
</span></span><span style="display:flex;"><span>  ─────────────────────────        ──────────────────     ──────
</span></span><span style="display:flex;"><span>  &#34;service won&#39;t start&#34;     ──►    who owns the port?     ✓ orphan
</span></span><span style="display:flex;"><span>  &#34;site loads wrong server&#34; ──►    what does dig say?     ✓ stale DNS
</span></span><span style="display:flex;"><span>  &#34;one client 500s&#34;         ──►    force --http1.1        ✓ h2 quirk
</span></span><span style="display:flex;"><span>  &#34;big transfers hang&#34;      ──►    ping -M do -s 1472     ✓ MTU/MSS
</span></span><span style="display:flex;"><span>  &#34;intermittent dropouts&#34;   ──►    arp -a (two MACs?)     ✓ dup IP
</span></span><span style="display:flex;"><span>  &#34;works one direction&#34;     ──►    firewall state table   ✓ asymmetric
</span></span><span style="display:flex;"><span>  &#34;localhost can&#39;t connect&#34; ──►    loopback fw rule        ✓ blocked pipe
</span></span></code></pre></div><h2 id="the-investigation-compressed-into-a-checklist">The Investigation, Compressed Into a Checklist</h2>
<p><strong>1. The service that &ldquo;can&rsquo;t start.&rdquo;</strong> Symptom: it crashed, you restart it, and now it refuses to bind. The aha: the crashed process orphaned the port and the kernel still thinks it&rsquo;s owned. The check — find the squatter:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#6272a4"># Linux</span>
</span></span><span style="display:flex;"><span>ss -ltnp <span style="color:#f1fa8c">&#39;sport = :8080&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># Windows</span>
</span></span><span style="display:flex;"><span>netstat -ano | findstr :8080
</span></span></code></pre></div><p>Kill the orphan PID, the port frees, the service starts. No exorcism required.</p>
<p><strong>2. DNS serving a corpse.</strong> Symptom: you migrated a site, half the world sees the new box, this one client sees the old one. The check — ask what&rsquo;s <em>actually</em> resolving, not what should:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>dig +short app.example.com @1.1.1.1
</span></span><span style="display:flex;"><span>nslookup app.example.com
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># flush the local liar:</span>
</span></span><span style="display:flex;"><span>sudo resolvectl flush-caches      <span style="color:#6272a4"># systemd</span>
</span></span><span style="display:flex;"><span>ipconfig /flushdns                <span style="color:#6272a4"># Windows</span>
</span></span></code></pre></div><p>Stale cache, gone.</p>
<p><strong>3. The one client that 500s while everyone else is fine.</strong> That&rsquo;s an HTTP/2 downgrade or framing incompatibility. Pin the protocol and watch it heal:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl -v --http1.1 https://app.example.com/health
</span></span></code></pre></div><p>If <code>--http1.1</code> works and the default doesn&rsquo;t, you found your ghost.</p>
<p><strong>4. Small requests fine, big ones hang.</strong> Classic MTU/MSS mismatch — pings and tiny payloads sail through, the TLS handshake or a large transfer stalls forever. Probe for the real path MTU:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#6272a4"># 1472 + 28 overhead = 1500. Shrink until it stops fragmenting.</span>
</span></span><span style="display:flex;"><span>ping -M <span style="color:#ff79c6">do</span> -s <span style="color:#bd93f9">1472</span> 192.0.2.1
</span></span></code></pre></div><p>If 1472 fails but 1400 works, clamp your MSS and the hangs vanish.</p>
<figure style="text-align:center;margin:34px 0">
<svg viewBox="0 0 560 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="MTU mismatch: small packet passes, oversized packet gets dropped">
<rect x="0" y="0" width="560" height="220" rx="10" fill="#11111b"/>
<rect x="40" y="40" width="120" height="140" rx="8" fill="#1e1e2e" stroke="#45475a"/>
<text x="100" y="115" fill="#cdd6f4" font-family="monospace" font-size="14" text-anchor="middle">host A</text>
<rect x="400" y="40" width="120" height="140" rx="8" fill="#1e1e2e" stroke="#45475a"/>
<text x="460" y="115" fill="#cdd6f4" font-family="monospace" font-size="14" text-anchor="middle">host B</text>
<rect x="250" y="30" width="60" height="160" rx="6" fill="#313244" stroke="#45475a"/>
<text x="280" y="205" fill="#6c7086" font-family="monospace" font-size="12" text-anchor="middle">MTU 1400 link</text>
<rect x="170" y="70" width="70" height="14" rx="3" fill="#a6e3a1"/>
<text x="248" y="81" fill="#a6e3a1" font-family="monospace" font-size="13">small ✓</text>
<rect x="170" y="120" width="130" height="22" rx="3" fill="#f38ba8"/>
<text x="170" y="160" fill="#f38ba8" font-family="monospace" font-size="13">oversized ✗</text>
<line x1="300" y1="131" x2="252" y2="131" stroke="#f38ba8" stroke-width="2" stroke-dasharray="4 3"/>
<text x="252" y="118" fill="#f38ba8" font-family="monospace" font-size="16" text-anchor="middle">▼</text>
</svg>
<figcaption style="color:#6c7086;font-size:14px;margin-top:8px">The big frame doesn't fit through the narrow link — and nobody told the sender.</figcaption>
</figure>
<p><strong>5. The intermittent flapper.</strong> Symptom: connectivity that drops for seconds at random — a rogue DHCP server or a static IP collision. Check the ARP table for one IP claimed by two MACs:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>arp -a | sort        <span style="color:#6272a4"># two different MACs on the same IP = your culprit</span>
</span></span><span style="display:flex;"><span>ip neigh show
</span></span></code></pre></div><p><strong>6. Works one way, dies the other.</strong> Asymmetric routing where the return path takes a different door and a stateful firewall drops it for having no matching session. Look at the state table:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#6272a4"># Linux conntrack</span>
</span></span><span style="display:flex;"><span>conntrack -L | grep 192.0.2.50
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># pf</span>
</span></span><span style="display:flex;"><span>pfctl -ss | grep 192.0.2.50
</span></span></code></pre></div><p>No state entry for the return flow = there&rsquo;s your &ldquo;ghost.&rdquo;</p>
<p><strong>7. The app that &ldquo;should be local&rdquo; but can&rsquo;t reach itself.</strong> A host firewall rule or blocked named pipe is eating loopback. Confirm the listener and that localhost itself is allowed:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ss -ltnp | grep 127.0.0.1
</span></span><span style="display:flex;"><span>sudo iptables -L INPUT -n -v | grep -E <span style="color:#f1fa8c">&#39;lo|127.0.0.1&#39;</span>
</span></span></code></pre></div><p>That Tuesday &ldquo;possession&rdquo;? Number one. Crashed worker, orphaned port, <code>ss -ltnp</code> named the PID in four seconds. Ghost dispelled.</p>
<h2 id="why-it-happens">Why It Happens</h2>
<p>None of these are exotic. They&rsquo;re the natural failure modes of stateful systems: processes die untidily, caches outlive their truth, two devices want the same address, a firewall remembers a flow that no longer exists. The supernatural feeling comes entirely from <em>partial</em> symptoms — it works for some and not others, or for small things and not big ones. Partial failure reads as spooky. It&rsquo;s just state you haven&rsquo;t looked at yet.</p>
<h2 id="takeaways">Takeaways</h2>
<ul>
<li><strong>Run the seven-item checklist before you theorize.</strong> The boring answer is almost always right.</li>
<li><strong>&ldquo;Some clients work&rdquo; is a clue, not a contradiction</strong> — it points straight at DNS, HTTP/2, or MTU.</li>
<li><strong>&ldquo;Small works, big hangs&rdquo; is MTU/MSS until proven otherwise.</strong> Don&rsquo;t waste an hour on app logs.</li>
<li><strong>Check state, not config.</strong> <code>ss</code>, <code>dig</code>, <code>arp</code>, and the firewall state table tell you what <em>is</em>, not what <em>should be</em>.</li>
<li><strong>Reboots hide orphans; they don&rsquo;t explain them.</strong> Find the squatter PID before you cycle power and lose the evidence.</li>
</ul>
]]></content:encoded></item><item><title>The 99 Access Points That Didn't Exist</title><link>https://errorzap.com/posts/the-99-access-points-that-didnt-exist/</link><pubDate>Fri, 29 May 2026 00:00:00 -0600</pubDate><guid>https://errorzap.com/posts/the-99-access-points-that-didnt-exist/</guid><description>An inventory report swore a site had 99 access points; the ceiling said twelve, and the database had been lying the whole time.</description><content:encoded><![CDATA[<figure style="text-align:center;margin:0 0 30px"><img src="hero.png" alt="The 99 Access Points That Didn't Exist" style="max-width:520px;width:100%;border-radius:14px"/></figure>
<p>The ticket landed with a number attached, and the number was wrong.</p>
<p>A monitoring report — the one we generate so nobody has to think too hard — claimed a site was running <strong>99 wireless access points</strong>. Ninety-nine. For a building that, last I checked, you could walk end to end during a coffee that hadn&rsquo;t gone cold yet.</p>
<p>I&rsquo;d been in that building. I knew the ceilings. There were not 99 APs up there. There weren&rsquo;t even a quarter of that.</p>
<p>So either the site had quietly become an enterprise campus overnight, or the report was hallucinating. Spoiler: reports don&rsquo;t hallucinate. They count exactly what you tell them to count, which is a different and worse problem.</p>
<h2 id="the-investigation">The investigation</h2>
<p>First rule when a number looks insane: don&rsquo;t argue with the number. Go find ground truth and make the number argue with <em>that</em>.</p>
<p>I pulled the controller&rsquo;s live device list — the actual adopted, talking-to-me-right-now hardware:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#6272a4"># adopted APs the controller is actively managing</span>
</span></span><span style="display:flex;"><span>curl -sk -b /tmp/controller.cookie <span style="color:#f1fa8c">\
</span></span></span><span style="display:flex;"><span>  https://controller.example.internal/proxy/network/api/s/default/stat/device <span style="color:#f1fa8c">\
</span></span></span><span style="display:flex;"><span>  | jq <span style="color:#f1fa8c">&#39;[.data[] | select(.type==&#34;uap&#34;)] | length&#39;</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>12
</span></span></code></pre></div><p>Twelve. Twelve adopted access points, online, real, with MAC addresses I could go put my hand on.</p>
<p>So where did the report get 99?</p>
<p>I went and read the thing that built the report instead of the report itself. The collector wasn&rsquo;t querying live adopted devices. It was scraping a broader chunk of the controller database — and it was counting <em>rows</em>, not <em>radios you could hang on a wall</em>.</p>
<h2 id="the-aha">The aha</h2>
<p>Three lies were stacked on top of each other, and the report added them all up with a straight face.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>  WHAT THE REPORT COUNTED            WHAT WAS ACTUALLY THERE
</span></span><span style="display:flex;"><span>  ─────────────────────────          ───────────────────────
</span></span><span style="display:flex;"><span>  ┌───────────────────────┐
</span></span><span style="display:flex;"><span>  │ adopted APs        12 │ ◄──────── ✓ real, on the ceiling
</span></span><span style="display:flex;"><span>  ├───────────────────────┤
</span></span><span style="display:flex;"><span>  │ pending / forgotten   │
</span></span><span style="display:flex;"><span>  │ ghost DB rows      ~30│ ◄──────── ✗ stale, never cleaned
</span></span><span style="display:flex;"><span>  ├───────────────────────┤
</span></span><span style="display:flex;"><span>  │ per-radio entries     │
</span></span><span style="display:flex;"><span>  │ (2.4 / 5 / 6 GHz)     │ ◄──────── ✗ one AP counted 3×
</span></span><span style="display:flex;"><span>  ├───────────────────────┤
</span></span><span style="display:flex;"><span>  │ per-SSID rows         │ ◄──────── ✗ counted broadcasts
</span></span><span style="display:flex;"><span>  └───────────────────────┘            as devices
</span></span><span style="display:flex;"><span>        Σ = 99                          Σ (real) = 12
</span></span></code></pre></div><p>Every tri-band AP showed up as three radio entries. Every SSID broadcast left its own footprint. And the controller DB still held a graveyard of devices that had been swapped out, RMA&rsquo;d, or &ldquo;forgotten&rdquo; in the UI months ago — rows that never actually leave the database, they just stop mattering. To a human. Not to a <code>COUNT(*)</code>.</p>
<p>Stack the real twelve on top of the radio multiplication on top of the ghost rows, and you arrive — with perfect arithmetic and zero truth — at 99.</p>
<figure style="text-align:center;margin:34px 0">
<svg viewBox="0 0 560 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="One real access point exploding into many counted rows">
  <rect x="0" y="0" width="560" height="220" rx="10" fill="#11111b"/>
  <rect x="34" y="78" width="120" height="64" rx="8" fill="#1e1e2e" stroke="#45475a"/>
  <circle cx="94" cy="110" r="18" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="2"/>
  <path d="M82 110 a12 12 0 0 1 24 0" fill="none" stroke="#a6e3a1" stroke-width="2"/>
  <path d="M86 110 a8 8 0 0 1 16 0" fill="none" stroke="#a6e3a1" stroke-width="2"/>
  <circle cx="94" cy="110" r="2.5" fill="#a6e3a1"/>
  <text x="94" y="160" fill="#a6e3a1" font-family="monospace" font-size="13" text-anchor="middle">1 real AP</text>
  <path d="M160 110 H230" stroke="#6c7086" stroke-width="2" stroke-dasharray="5 4"/>
  <polygon points="230,110 222,105 222,115" fill="#6c7086"/>
  <g font-family="monospace" font-size="12">
    <rect x="240" y="34" width="150" height="26" rx="5" fill="#313244" stroke="#cba6f7"/>
    <text x="252" y="51" fill="#cba6f7">radio 2.4GHz row</text>
    <rect x="240" y="68" width="150" height="26" rx="5" fill="#313244" stroke="#89b4fa"/>
    <text x="252" y="85" fill="#89b4fa">radio 5GHz row</text>
    <rect x="240" y="102" width="150" height="26" rx="5" fill="#313244" stroke="#94e2d5"/>
    <text x="252" y="119" fill="#94e2d5">radio 6GHz row</text>
    <rect x="240" y="136" width="150" height="26" rx="5" fill="#313244" stroke="#fab387"/>
    <text x="252" y="153" fill="#fab387">per-SSID rows...</text>
    <rect x="240" y="170" width="150" height="26" rx="5" fill="#313244" stroke="#f38ba8"/>
    <text x="252" y="187" fill="#f38ba8">ghost / forgotten</text>
  </g>
  <path d="M398 130 H452" stroke="#6c7086" stroke-width="2"/>
  <polygon points="452,130 444,125 444,135" fill="#6c7086"/>
  <text x="506" y="124" fill="#f38ba8" font-family="monospace" font-size="34" text-anchor="middle" font-weight="bold">99</text>
  <text x="506" y="150" fill="#6c7086" font-family="monospace" font-size="12" text-anchor="middle">"APs"</text>
</svg>
<figcaption style="color:#6c7086;font-size:13px;margin-top:8px">One access point on the ceiling, multiplied by radios, SSIDs, and database ghosts into a number nobody could find.</figcaption>
</figure>
<h2 id="the-fix">The fix</h2>
<p>The fix wasn&rsquo;t clever. It was <em>narrowing the question</em>. Count adopted UAP-type devices, deduplicated by MAC, and exclude anything pending or forgotten:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl -sk -b /tmp/controller.cookie <span style="color:#f1fa8c">\
</span></span></span><span style="display:flex;"><span>  https://controller.example.internal/proxy/network/api/s/default/stat/device <span style="color:#f1fa8c">\
</span></span></span><span style="display:flex;"><span>  | jq <span style="color:#f1fa8c">&#39;[.data[]
</span></span></span><span style="display:flex;"><span><span style="color:#f1fa8c">         | select(.type==&#34;uap&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#f1fa8c">         | select(.adopted==true)
</span></span></span><span style="display:flex;"><span><span style="color:#f1fa8c">         | select(.state==1)          # 1 = connected
</span></span></span><span style="display:flex;"><span><span style="color:#f1fa8c">         | .mac]
</span></span></span><span style="display:flex;"><span><span style="color:#f1fa8c">        | unique | length&#39;</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>12
</span></span></code></pre></div><p>Then I did the thing the database can&rsquo;t: I walked the building and counted ceilings. Twelve.</p>
<p>The report now pulls that single reconciled number, and it carries a footnote: <em>adopted, connected, deduped by MAC.</em> If it ever disagrees with the live device list again, it&rsquo;s the report that has to explain itself.</p>
<h2 id="why-it-happened">Why it happened</h2>
<p>Because counting feels trivial, so nobody guards it. <code>length</code> on a JSON array looks like truth. But the controller DB isn&rsquo;t a list of access points — it&rsquo;s an event log of everything that ever <em>claimed</em> to be one: every radio, every SSID broadcast, every device that got swapped and never purged. Counting rows in that pile and calling it &ldquo;APs&rdquo; is like counting fingerprints and reporting the number of burglars.</p>
<p>The artifact survived for months because 99 was <em>plausible-adjacent</em>. Nobody who&rsquo;d been on-site ever read the report; nobody who read the report had ever been on-site. The number floated in the gap between them, fat and unchallenged, until somebody finally looked up.</p>
<h2 id="takeaways">Takeaways</h2>
<ul>
<li><strong>Never report an inventory number you can&rsquo;t reconcile to ground truth.</strong> A count needs two witnesses: the live device list <em>and</em> a physical walk.</li>
<li><strong>Rows are not things.</strong> Per-radio and per-SSID entries multiply one AP into many. Filter by device type and dedupe by MAC before you trust a total.</li>
<li><strong>Controller databases keep ghosts.</strong> Forgotten, pending, and RMA&rsquo;d devices linger in the DB long after they leave the ceiling. Scope your query to <em>adopted and connected</em>.</li>
<li><strong>Plausible-adjacent is the dangerous zone.</strong> A wrong number that&rsquo;s obviously insane gets caught. One that&rsquo;s merely &ldquo;a bit high&rdquo; rides for months.</li>
<li><strong>Read the query, not the report.</strong> When a metric lies, the bug is upstream in what you told it to count — go fix the question.</li>
</ul>
]]></content:encoded></item><item><title>The Smart Blind That Took Down an Entire Network</title><link>https://errorzap.com/posts/the-smart-blind-that-took-down-the-network/</link><pubDate>Thu, 28 May 2026 00:00:00 -0600</pubDate><guid>https://errorzap.com/posts/the-smart-blind-that-took-down-the-network/</guid><description>A $30 Wi-Fi window blind decided it was the default gateway, and the whole site went dark whenever it felt like it.</description><content:encoded><![CDATA[<figure style="text-align:center;margin:0 0 30px"><img src="hero.png" alt="The Smart Blind That Took Down an Entire Network" style="max-width:520px;width:100%;border-radius:14px"/></figure>
<p>The ticket said &ldquo;internet is down.&rdquo; Then, four minutes later, &ldquo;never mind, it&rsquo;s back.&rdquo; Then, twenty minutes after that, &ldquo;it&rsquo;s down again.&rdquo;</p>
<p>That pattern is a special kind of hell. A clean outage you can chase. A flapping one that heals itself before you SSH in just laughs at you.</p>
<p>I got a client on the phone. Their whole site would blackhole — every desktop, every phone, the printer, all of it — for one to five minutes, then snap back like nothing happened. No schedule. No trigger anyone could name. The ISP swore the circuit was clean, and for once the ISP was right.</p>
<h2 id="the-investigation">The investigation</h2>
<p>I started where you start: at the edge. The gateway was up. WAN was up. The firewall logs were boring in the way you <em>want</em> them to be boring. No flapping interface, no CPU spike, no DHCP storm in the leases.</p>
<p>So I sat on a workstation and just hammered the gateway while I waited for the next outage.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ping -i 0.5 192.0.2.1
</span></span></code></pre></div><p>It ran fine for ten minutes. Then — dead air. Eight, nine, ten dropped replies. Total blackhole. Then it came back on its own.</p>
<p>The instant it died, I dumped the ARP table.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>arp -a 192.0.2.1
</span></span></code></pre></div><p>And there it was. The gateway&rsquo;s IP was mapped to a MAC address I did not recognize. Not the firewall&rsquo;s MAC. Some OUI I had to look up by hand.</p>
<p>I watched it flap in real time:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>┌─────────────────────────────────────────────────────────────┐
</span></span><span style="display:flex;"><span>│  arp -a 192.0.2.1   (watched over ~30s)                      │
</span></span><span style="display:flex;"><span>├─────────────────────────────────────────────────────────────┤
</span></span><span style="display:flex;"><span>│  t+0s   192.0.2.1  ►  aa:bb:cc:11:22:33   (firewall)   ✓     │
</span></span><span style="display:flex;"><span>│  t+6s   192.0.2.1  ►  de:ad:be:ef:00:99   (???)        ✗     │
</span></span><span style="display:flex;"><span>│  t+9s   192.0.2.1  ►  de:ad:be:ef:00:99   (???)        ✗     │
</span></span><span style="display:flex;"><span>│  t+14s  192.0.2.1  ►  aa:bb:cc:11:22:33   (firewall)   ✓     │
</span></span><span style="display:flex;"><span>│  t+19s  192.0.2.1  ►  de:ad:be:ef:00:99   (???)        ✗     │
</span></span><span style="display:flex;"><span>└─────────────────────────────────────────────────────────────┘
</span></span><span style="display:flex;"><span>         ▼ when the IP pointed at de:ad:..:99, traffic vanished
</span></span></code></pre></div><p>When the gateway IP pointed at the firewall, the network worked. When it pointed at that mystery MAC, every packet bound for the internet got handed to a device that did absolutely nothing with it. Classic ARP poisoning. The only question was <em>who</em>.</p>
<h2 id="the-aha">The &ldquo;aha&rdquo;</h2>
<p>I looked up the OUI. The mystery MAC belonged to a Tuya-based Wi-Fi module — the kind that ships inside cheap smart-home gadgets by the millions.</p>
<p>Then I cross-referenced the DHCP leases for that MAC and found a hostname that made me laugh out loud: a smart <strong>window blind</strong> controller. Somebody had put a $30 motorized blind on the corporate flat network.</p>
<p>That little NIC was broadcasting unsolicited ARP replies claiming to be <code>192.0.2.1</code>. Every client on the segment believed it, updated its ARP cache, and started shipping its default route straight into a window covering.</p>
<h2 id="the-fix">The fix</h2>
<p>First, stop the bleeding. I pinned the gateway&rsquo;s real MAC on the affected machines so they&rsquo;d stop trusting the lie while I worked.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#6272a4"># Linux</span>
</span></span><span style="display:flex;"><span>sudo ip neigh replace 192.0.2.1 lladdr aa:bb:cc:11:22:33 dev eth0 nud permanent
</span></span></code></pre></div><p>Then the real fix — get IoT off the flat network entirely and turn on the switch protections that should&rsquo;ve been on from day one.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>BEFORE                              AFTER
</span></span><span style="display:flex;"><span>──────                              ─────
</span></span><span style="display:flex;"><span>VLAN 1 (everything)                 VLAN 10  desktops/servers
</span></span><span style="display:flex;"><span>  ├─ desktops                       VLAN 20  voice
</span></span><span style="display:flex;"><span>  ├─ servers                        VLAN 90  IoT  ◄── blind lives here,
</span></span><span style="display:flex;"><span>  ├─ phones                                          firewalled, no L2
</span></span><span style="display:flex;"><span>  └─ smart blind  ◄── poisoner                       reach to clients
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#6272a4"># Cisco-style switch hardening (concept)</span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># Trust only the uplink for DHCP; drop rogue offers elsewhere</span>
</span></span><span style="display:flex;"><span>ip dhcp snooping
</span></span><span style="display:flex;"><span>ip dhcp snooping vlan <span style="color:#bd93f9">90</span>
</span></span><span style="display:flex;"><span>interface Gi1/0/24
</span></span><span style="display:flex;"><span> ip dhcp snooping trust
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># Dynamic ARP Inspection — validate ARP against snooping bindings</span>
</span></span><span style="display:flex;"><span>ip arp inspection vlan <span style="color:#bd93f9">90</span>
</span></span><span style="display:flex;"><span>interface Gi1/0/24
</span></span><span style="display:flex;"><span> ip arp inspection trust
</span></span></code></pre></div><figure style="text-align:center;margin:34px 0">
<svg viewBox="0 0 560 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="A smart blind impersonating the gateway in the ARP table">
<rect x="0" y="0" width="560" height="220" rx="10" fill="#11111b"/>
<rect x="30" y="40" width="130" height="64" rx="8" fill="#1e1e2e" stroke="#45475a"/>
<text x="95" y="68" text-anchor="middle" font-family="monospace" font-size="13" fill="#cdd6f4">Workstation</text>
<text x="95" y="88" text-anchor="middle" font-family="monospace" font-size="11" fill="#6c7086">who has .1?</text>
<rect x="400" y="20" width="130" height="50" rx="8" fill="#1e1e2e" stroke="#a6e3a1"/>
<text x="465" y="42" text-anchor="middle" font-family="monospace" font-size="13" fill="#a6e3a1">Gateway .1</text>
<text x="465" y="59" text-anchor="middle" font-family="monospace" font-size="10" fill="#6c7086">aa:bb:cc...</text>
<rect x="400" y="140" width="130" height="56" rx="8" fill="#1e1e2e" stroke="#f38ba8"/>
<text x="465" y="164" text-anchor="middle" font-family="monospace" font-size="12" fill="#f38ba8">Smart Blind</text>
<text x="465" y="181" text-anchor="middle" font-family="monospace" font-size="10" fill="#fab387">"I am .1!"</text>
<line x1="160" y1="64" x2="400" y2="45" stroke="#45475a" stroke-width="2" stroke-dasharray="5 4"/>
<line x1="160" y1="80" x2="400" y2="160" stroke="#f38ba8" stroke-width="2.5"/>
<text x="270" y="120" text-anchor="middle" font-family="monospace" font-size="11" fill="#f38ba8">poisoned ARP ►</text>
<text x="280" y="210" text-anchor="middle" font-family="monospace" font-size="11" fill="#cba6f7">traffic blackholes into a window covering</text>
</svg>
<figcaption style="color:#6c7086;font-size:14px;margin-top:8px">The blind announces itself as the gateway; clients believe it and route to nowhere.</figcaption>
</figure>
<p>Last step: I yanked the blind, pushed its vendor firmware update, and parked it on the isolated IoT VLAN where it can lie about being the gateway all it wants — and nobody who matters will hear it.</p>
<h2 id="why-it-happened">Why it happened</h2>
<p>Cheap Tuya firmware is a grab bag. Some builds have buggy network stacks that emit malformed or unsolicited ARP — gratuitous ARP gone feral, sometimes triggered by a Wi-Fi reconnect, which explains the random timing. Could also be straight-up malicious; with no-name IoT you genuinely can&rsquo;t tell, and it doesn&rsquo;t change the remediation.</p>
<p>Either way, a flat L2 network <em>trusts every device equally</em>. ARP has no authentication. Whatever shouts loudest and last, wins. Put one mouthy gadget on that wire and it can pretend to be anything — including the door to the internet.</p>
<h2 id="takeaways">Takeaways</h2>
<ul>
<li><strong>On weird, intermittent, self-healing outages, check the gateway&rsquo;s ARP entry FIRST.</strong> <code>arp -a &lt;gateway-ip&gt;</code> flapping to an unknown MAC is a five-second smoking gun.</li>
<li><strong>Match a rogue MAC to its OUI and your DHCP leases.</strong> That&rsquo;s how a &ldquo;mystery device&rdquo; became &ldquo;the smart blind in the break room.&rdquo;</li>
<li><strong>IoT does not belong on your flat network. Ever.</strong> Its own VLAN/SSID, firewalled off from clients, no L2 reach to anything that matters.</li>
<li><strong>Turn on DHCP Snooping and Dynamic ARP Inspection.</strong> They exist precisely to kill rogue gateways and ARP lies at the switch.</li>
<li><strong>Cheap IoT is an untrusted host by default</strong> — bug or malice, you treat it the same: isolate, update, and never let it speak for the gateway.</li>
</ul>
]]></content:encoded></item><item><title>90s Kid - The LAN Party</title><link>https://errorzap.com/posts/90s-kid-the-lan-party/</link><pubDate>Tue, 05 May 2026 00:00:00 -0600</pubDate><guid>https://errorzap.com/posts/90s-kid-the-lan-party/</guid><description>You remember hauling your whole computer across town just to sit in a room and frag your friends.</description><content:encoded><![CDATA[<figure style="margin:0 0 28px">
<svg viewBox="0 0 800 240" xmlns="http://www.w3.org/2000/svg" style="width:100%;border-radius:14px">
 <defs>
  <linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1e1e2e"/><stop offset="1" stop-color="#11111b"/></linearGradient>
  <linearGradient id="sun" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f9e2af"/><stop offset="0.5" stop-color="#fab387"/><stop offset="1" stop-color="#f38ba8"/></linearGradient>
 </defs>
 <rect width="800" height="240" fill="url(#sky)"/>
 <circle cx="640" cy="150" r="74" fill="url(#sun)"/><rect x="566" y="118" width="148" height="4" fill="#11111b"/><rect x="566" y="127" width="148" height="4" fill="#11111b"/><rect x="566" y="136" width="148" height="4" fill="#11111b"/><rect x="566" y="145" width="148" height="4" fill="#11111b"/><rect x="566" y="154" width="148" height="4" fill="#11111b"/><rect x="566" y="163" width="148" height="4" fill="#11111b"/><rect x="566" y="172" width="148" height="4" fill="#11111b"/>
 <line x1="-200" y1="240" x2="400" y2="176" stroke="#cba6f7" stroke-width="1" opacity="0.25"/><line x1="-120" y1="240" x2="400" y2="176" stroke="#cba6f7" stroke-width="1" opacity="0.25"/><line x1="-40" y1="240" x2="400" y2="176" stroke="#cba6f7" stroke-width="1" opacity="0.25"/><line x1="40" y1="240" x2="400" y2="176" stroke="#cba6f7" stroke-width="1" opacity="0.25"/><line x1="120" y1="240" x2="400" y2="176" stroke="#cba6f7" stroke-width="1" opacity="0.25"/><line x1="200" y1="240" x2="400" y2="176" stroke="#cba6f7" stroke-width="1" opacity="0.25"/><line x1="280" y1="240" x2="400" y2="176" stroke="#cba6f7" stroke-width="1" opacity="0.25"/><line x1="360" y1="240" x2="400" y2="176" stroke="#cba6f7" stroke-width="1" opacity="0.25"/><line x1="440" y1="240" x2="400" y2="176" stroke="#cba6f7" stroke-width="1" opacity="0.25"/><line x1="520" y1="240" x2="400" y2="176" stroke="#cba6f7" stroke-width="1" opacity="0.25"/><line x1="600" y1="240" x2="400" y2="176" stroke="#cba6f7" stroke-width="1" opacity="0.25"/><line x1="680" y1="240" x2="400" y2="176" stroke="#cba6f7" stroke-width="1" opacity="0.25"/><line x1="760" y1="240" x2="400" y2="176" stroke="#cba6f7" stroke-width="1" opacity="0.25"/><line x1="840" y1="240" x2="400" y2="176" stroke="#cba6f7" stroke-width="1" opacity="0.25"/><line x1="920" y1="240" x2="400" y2="176" stroke="#cba6f7" stroke-width="1" opacity="0.25"/><line x1="1000" y1="240" x2="400" y2="176" stroke="#cba6f7" stroke-width="1" opacity="0.25"/><line x1="0" y1="178" x2="800" y2="178" stroke="#94e2d5" stroke-width="1" opacity="0.22"/><line x1="0" y1="184" x2="800" y2="184" stroke="#94e2d5" stroke-width="1" opacity="0.22"/><line x1="0" y1="193" x2="800" y2="193" stroke="#94e2d5" stroke-width="1" opacity="0.22"/><line x1="0" y1="206" x2="800" y2="206" stroke="#94e2d5" stroke-width="1" opacity="0.22"/><line x1="0" y1="227" x2="800" y2="227" stroke="#94e2d5" stroke-width="1" opacity="0.22"/>
 <text x="48" y="150" font-size="78">🕹</text>
 <text x="150" y="96" font-family="ui-monospace,monospace" font-size="14" fill="#cba6f7" letter-spacing="5">90s KID //</text>
 <text x="150" y="140" font-family="-apple-system,Segoe UI,sans-serif" font-size="30" font-weight="800" fill="#cdd6f4">The LAN Party</text>
 <rect x="0" y="236" width="800" height="4" fill="#f38ba8"/>
</svg></figure>
<h2 id="the-night-we-carried-our-computers-into-the-basement">The Night We Carried Our Computers Into the Basement</h2>
<p>You remember <strong>the LAN party.</strong></p>
<p>Not a download.<br>
Not a lobby.<br>
A <em>pilgrimage.</em></p>
<p>You unplugged your entire desktop — the beige tower, the CRT the size of a microwave, the rat&rsquo;s nest of cables — and you carried it, in your arms, like a wounded soldier, to somebody&rsquo;s basement.</p>
<p>The monitor weighed forty pounds.<br>
You did this <strong>on purpose.</strong><br>
For fun.</p>
<p>And when you got there, there was already a folding table, an extension cord doing something it was definitely not rated for, and a bowl of Doritos that would last exactly nine minutes.</p>
<p>Here&rsquo;s what it <em>felt</em> like as a kid:</p>
<ul>
<li><strong>Forbidden</strong> — six computers in one room felt like a hacker movie</li>
<li><strong>Sacred</strong> — nobody touched the host&rsquo;s machine. He was the king.</li>
<li><strong>Slightly dangerous</strong> — that many power bricks in one outlet? bold</li>
<li><strong>A little magic</strong> — your screen and <em>his</em> screen showing the <em>same world</em></li>
<li><strong>Eternal</strong> — it was 2am and nobody&rsquo;s mom had come downstairs yet</li>
</ul>
<p>You&rsquo;d hear someone yell &ldquo;<strong>WHO HAS THE GRAY CABLE</strong>&rdquo; and the whole night hinged on it.</p>
<h3 id="the-reveal">The reveal</h3>
<p>That gray cable was the whole trick.</p>
<p>It was a <strong>Cat-5 Ethernet cable</strong>, and it ran to a little blinking box in the middle of the table — a <strong>hub</strong> (later, the fancy kids had a <em>switch</em>). Every computer plugged into it, and that box let your machines gossip with each other at a blistering <strong>10 megabits a second</strong>.</p>
<p>No internet involved. None. The whole party was a tiny private island of computers — a <strong>Local Area Network</strong> — talking only to each other.</p>
<p>Before someone bought the hub, you did it the <em>cursed</em> way: a <strong>coaxial cable</strong> daisy-chained machine to machine, with a little metal <strong>terminator</strong> screwed onto each end. If one person tripped over the line, the <em>entire network died</em> — and everyone knew whose fault it was.</p>
<p>And the game finding everyone? That was your machine literally <strong>shouting into the wire</strong> — &ldquo;anybody out there hosting?&rdquo; — and a host shouting back. That&rsquo;s why someone always had to &ldquo;<strong>make the server</strong>.&rdquo; He wasn&rsquo;t being dramatic. He was being the server.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>   .------.   .------.   .------.
</span></span><span style="display:flex;"><span>   |  PC  |   |  PC  |   |  PC  |
</span></span><span style="display:flex;"><span>   &#39;--||--&#39;   &#39;--||--&#39;   &#39;--||--&#39;
</span></span><span style="display:flex;"><span>      ||         ||         ||
</span></span><span style="display:flex;"><span>   ===++=========++=========++===
</span></span><span style="display:flex;"><span>      |     [ H U B ]   .|.|.     |
</span></span><span style="display:flex;"><span>      &#39;------ blink blink --------&#39;
</span></span><span style="display:flex;"><span>        every box hears every box
</span></span></code></pre></div><h3 id="where-it-went">Where it went</h3>
<p>It never really died — it just <strong>stopped weighing forty pounds.</strong></p>
<p>That hub in the middle of the table? It moved into the little plastic router blinking in your hallway right now. That &ldquo;shout into the wire to find a host&rdquo;? That&rsquo;s <strong>matchmaking</strong>, except now the wire is the whole planet and the host is a warehouse in Virginia.</p>
<p>You still play with your friends.<br>
You just don&rsquo;t carry your computer to do it.</p>
<p>But some part of you misses it — the cables, the chaos, the bowl of Doritos, the <strong>gray cable</strong> that held the whole night together.</p>
<p>We didn&rsquo;t have multiplayer.</p>
<p>We <em>built</em> it, in a basement, every single time.</p>
]]></content:encoded></item><item><title>Two Apps, One Port</title><link>https://errorzap.com/posts/two-apps-one-port/</link><pubDate>Sat, 18 Apr 2026 00:00:00 -0600</pubDate><guid>https://errorzap.com/posts/two-apps-one-port/</guid><description>Edits vanished, reads lied, and nothing in the logs was wrong — because two copies of the same app were quietly knife-fighting over the same TCP port.</description><content:encoded><![CDATA[<figure style="text-align:center;margin:0 0 30px"><img src="hero.png" alt="Two Apps, One Port" style="max-width:520px;width:100%;border-radius:14px"/></figure>
<p>Someone pinged me with the kind of bug that makes your skin crawl: &ldquo;I save a note, it saves fine, no error. But when I come back later it&rsquo;s gone. And sometimes I open a file and it&rsquo;s the <em>wrong</em> file entirely.&rdquo;</p>
<p>No errors. No crash. Data just&hellip; wrong. The worst kind of ticket — because &ldquo;wrong data, no error&rdquo; means the system is lying to you with a straight face.</p>
<p>I assumed corruption. Maybe a bad sync, a stale cache, a half-written file on disk. I was wrong about all of it.</p>
<h2 id="the-investigation">The investigation</h2>
<p>First thing I did was stop trusting the app and start trusting the wire. The app talked to itself over a local REST API on a fixed port. So I asked the only question that matters when data is &ldquo;wrong&rdquo;:</p>
<p><strong>Am I even talking to the thing I think I&rsquo;m talking to?</strong></p>
<p>The user ran two separate profiles of the same application — two distinct workspaces, two configs, two processes. Different data, different windows. But same binary, same defaults.</p>
<p>I checked what was actually listening.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>        Profile A (started first)        Profile B (started second)
</span></span><span style="display:flex;"><span>        ┌──────────────────────┐         ┌──────────────────────┐
</span></span><span style="display:flex;"><span>        │  app instance A      │         │  app instance B      │
</span></span><span style="display:flex;"><span>        │  workspace: WORK     │         │  workspace: PERSONAL │
</span></span><span style="display:flex;"><span>        └──────────┬───────────┘         └──────────┬───────────┘
</span></span><span style="display:flex;"><span>                   │ bind :8200 OK                  │ bind :8200 FAIL
</span></span><span style="display:flex;"><span>                   ▼                                ▼
</span></span><span style="display:flex;"><span>            ┌─────────────┐                  (silent: port taken)
</span></span><span style="display:flex;"><span>            │  TCP :8200  │ ◄──── requests land HERE
</span></span><span style="display:flex;"><span>            └─────────────┘        regardless of which UI you used
</span></span><span style="display:flex;"><span>                   ▲
</span></span><span style="display:flex;"><span>                   │
</span></span><span style="display:flex;"><span>            edits in B&#39;s UI → API call → answered by A
</span></span></code></pre></div><p>Both instances were configured to expose their REST API on the <strong>same hard-coded port</strong>. Whichever process won the startup race grabbed the socket. The loser tried to bind, failed, and — because the failure was non-fatal — just shrugged and kept running its UI as if nothing happened.</p>
<p>So you&rsquo;d type into Profile B&rsquo;s window. The UI would fire an API write. That write went to <strong>port 8200</strong>, which Profile A owned. Profile A happily saved it into the <em>wrong workspace</em>. Reads came back from A too. Profile B&rsquo;s UI was a puppet with cut strings.</p>
<h2 id="the-aha">The &ldquo;aha&rdquo;</h2>
<p>The tell was <code>netstat</code>. One PID owned the port. Not two. Not a conflict logged anywhere — just one quiet winner.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#6272a4"># Windows: who actually holds the port?</span>
</span></span><span style="display:flex;"><span>netstat -ano | findstr :8200
</span></span><span style="display:flex;"><span><span style="color:#6272a4">#   TCP    127.0.0.1:8200    0.0.0.0:0    LISTENING    18044</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>tasklist /fi <span style="color:#f1fa8c">&#34;PID eq 18044&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">#   app.exe    18044   ...   &lt;- this is Profile A, not B</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#6272a4"># Linux/macOS equivalent</span>
</span></span><span style="display:flex;"><span>lsof -nP -iTCP:8200 -sTCP:LISTEN
</span></span><span style="display:flex;"><span>ss -ltnp <span style="color:#f1fa8c">&#39;sport = :8200&#39;</span>
</span></span></code></pre></div><p>One listener. Two apps. The data wasn&rsquo;t corrupt — it was just going to the wrong house, and the mailman never complained.</p>
<figure style="text-align:center;margin:34px 0">
<svg viewBox="0 0 560 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Two app instances both pointing at one shared port">
  <rect x="0" y="0" width="560" height="220" rx="10" fill="#11111b"/>
  <rect x="32" y="44" width="170" height="64" rx="10" fill="#1e1e2e" stroke="#45475a"/>
  <text x="117" y="72" text-anchor="middle" fill="#cdd6f4" font-family="monospace" font-size="14">Profile A</text>
  <text x="117" y="92" text-anchor="middle" fill="#6c7086" font-family="monospace" font-size="11">workspace: WORK</text>
  <rect x="358" y="44" width="170" height="64" rx="10" fill="#1e1e2e" stroke="#45475a"/>
  <text x="443" y="72" text-anchor="middle" fill="#cdd6f4" font-family="monospace" font-size="14">Profile B</text>
  <text x="443" y="92" text-anchor="middle" fill="#6c7086" font-family="monospace" font-size="11">workspace: PERSONAL</text>
  <rect x="210" y="150" width="140" height="46" rx="10" fill="#313244" stroke="#cba6f7"/>
  <text x="280" y="171" text-anchor="middle" fill="#cba6f7" font-family="monospace" font-size="13">:8200</text>
  <text x="280" y="187" text-anchor="middle" fill="#6c7086" font-family="monospace" font-size="10">one listener</text>
  <line x1="117" y1="108" x2="250" y2="150" stroke="#a6e3a1" stroke-width="2"/>
  <text x="150" y="135" fill="#a6e3a1" font-family="monospace" font-size="12">bind OK</text>
  <line x1="443" y1="108" x2="310" y2="150" stroke="#f38ba8" stroke-width="2" stroke-dasharray="5 4"/>
  <text x="372" y="135" fill="#f38ba8" font-family="monospace" font-size="12">bind FAIL → routes to A</text>
</svg>
<figcaption style="color:#6c7086;font-size:14px;margin-top:8px">Both UIs wired to one fixed port. The first process to start owns it; the second silently misroutes.</figcaption>
</figure>
<h2 id="the-fix">The fix</h2>
<p>Stop them from racing for the same socket. Give each instance its own port.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#6272a4"># 1. Edit each profile&#39;s config — distinct ports per instance</span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">#    Profile A</span>
</span></span><span style="display:flex;"><span><span style="color:#f1fa8c">&#34;api&#34;</span>: <span style="color:#ff79c6">{</span> <span style="color:#f1fa8c">&#34;enabled&#34;</span>: true, <span style="color:#f1fa8c">&#34;port&#34;</span>: <span style="color:#bd93f9">8200</span> <span style="color:#ff79c6">}</span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">#    Profile B</span>
</span></span><span style="display:flex;"><span><span style="color:#f1fa8c">&#34;api&#34;</span>: <span style="color:#ff79c6">{</span> <span style="color:#f1fa8c">&#34;enabled&#34;</span>: true, <span style="color:#f1fa8c">&#34;port&#34;</span>: <span style="color:#bd93f9">8201</span> <span style="color:#ff79c6">}</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># 2. Fully stop both instances (no orphan holding the old socket)</span>
</span></span><span style="display:flex;"><span>taskkill /f /im app.exe        <span style="color:#6272a4"># Windows</span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># pkill -f app                 # Linux/macOS</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># 3. Confirm the ports are actually free before restart</span>
</span></span><span style="display:flex;"><span>netstat -ano | findstr <span style="color:#f1fa8c">&#34;8200 8201&#34;</span>   <span style="color:#6272a4"># expect: nothing</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># 4. Start both, then verify ownership — two PIDs, two ports</span>
</span></span><span style="display:flex;"><span>netstat -ano | findstr <span style="color:#f1fa8c">&#34;8200 8201&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">#   TCP  127.0.0.1:8200  LISTENING  20110   &lt;- Profile A</span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">#   TCP  127.0.0.1:8201  LISTENING  20338   &lt;- Profile B</span>
</span></span></code></pre></div><p>Two listeners, two PIDs, two ports. Writes to B stayed in B. Reads told the truth. The &ldquo;disappearing edits&rdquo; were gone because they&rsquo;d never disappeared — they&rsquo;d just been filed in the wrong cabinet.</p>
<h2 id="why-it-happened">Why it happened</h2>
<p>Defaults. The app shipped with a single hard-coded API port and assumed exactly one instance would ever run. Reasonable for the common case, fatal for power users running multiple profiles. There was no port-conflict warning because a failed bind on a <em>convenience</em> API isn&rsquo;t fatal — the app degrades silently instead of refusing to start. Silent degradation is how a config bug masquerades as data loss.</p>
<h2 id="takeaways">Takeaways</h2>
<ul>
<li><strong>When data looks &ldquo;wrong&rdquo; but nothing errors, question the endpoint first.</strong> Confirm you&rsquo;re talking to the instance you <em>think</em> you are before you go disk-spelunking for corruption.</li>
<li><strong><code>netstat -ano</code> / <code>lsof -i</code> is ground truth for &ldquo;who owns this port.&rdquo;</strong> One listener where you expected two is the whole bug, right there.</li>
<li><strong>Multiple instances of the same app will knife-fight over hard-coded ports.</strong> Give every instance a unique port in its config — don&rsquo;t trust defaults to coexist.</li>
<li><strong>A non-fatal bind failure is a trap.</strong> Silent degradation turns a 5-minute config fix into a week of phantom data-loss tickets.</li>
<li><strong>Pre-flight before restart:</strong> kill all instances, verify the port is actually free, <em>then</em> start — and read back the listeners to prove it stuck.</li>
</ul>
]]></content:encoded></item><item><title>The Bot That Hung Because of IPv6</title><link>https://errorzap.com/posts/the-bot-that-hung-because-of-ipv6/</link><pubDate>Wed, 15 Apr 2026 00:00:00 -0600</pubDate><guid>https://errorzap.com/posts/the-bot-that-hung-because-of-ipv6/</guid><description>A Telegram bot kept freezing on outbound calls with no error — turns out a NIC with IPv6 addresses but no working IPv6 routing was quietly killing every request.</description><content:encoded><![CDATA[<figure style="text-align:center;margin:0 0 30px"><img src="hero.png" alt="The Bot That Hung Because of IPv6" style="max-width:520px;width:100%;border-radius:14px"/></figure>
<p>The bot worked. Then it didn&rsquo;t. Then it worked again.</p>
<p>It was a small Node.js Telegram bot running on a client&rsquo;s server. Its whole job was to fire off outbound API calls — <code>sendMessage</code>, <code>getUpdates</code>, the usual. Most of the time it was instant. But every so often a call would just&hellip; stop. No exception. No 500. No DNS error. The promise sat there until the request timeout finally put it out of its misery seconds later.</p>
<p>Intermittent. Silent. Multi-second. The worst kind of bug there is.</p>
<h2 id="the-investigation">The investigation</h2>
<p>First instinct was the obvious one: the upstream API is flaky. So I logged timing around every call. The hangs weren&rsquo;t correlated with anything — not load, not time of day, not a specific endpoint. Same call, same payload, same host. Sometimes 80ms, sometimes 5,000ms-then-timeout.</p>
<p>I pulled <code>strace</code> on the process during a hang and watched the syscalls. The bot wasn&rsquo;t busy. It was sitting in a <code>connect()</code> that never came back. It wasn&rsquo;t waiting on the API — it was waiting on a socket that was never going to open.</p>
<p>That reframed everything. This wasn&rsquo;t a slow server. This was a connection attempt going into the void.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>   bot ──► undici (happy-eyeballs)
</span></span><span style="display:flex;"><span>              │
</span></span><span style="display:flex;"><span>              ├─► resolve host
</span></span><span style="display:flex;"><span>              │     ├─ AAAA (IPv6)  ─► ::  ►  connect() ──► ✗ black hole (no route)
</span></span><span style="display:flex;"><span>              │     └─ A    (IPv4)  ─► .   ►  connect() ──► ✓ would work fine
</span></span><span style="display:flex;"><span>              │
</span></span><span style="display:flex;"><span>              ▼
</span></span><span style="display:flex;"><span>        races both... but stalls on the v6 attempt
</span></span></code></pre></div><p>The host had <strong>IPv6 addresses configured</strong> on its primary NIC. It just had <strong>no working IPv6 route</strong> to the outside world. Addresses present, connectivity dead. The packets went out and nothing ever came back.</p>
<h2 id="the-aha">The &ldquo;aha&rdquo;</h2>
<p>The bot used <code>undici</code> as its HTTP client. Modern undici does Happy Eyeballs (RFC 8305) — it resolves both A and AAAA records and races IPv4 and IPv6 connection attempts, preferring the v6 path.</p>
<p>On a healthy host that&rsquo;s great. On this host it was poison. undici would get an AAAA record, try the IPv6 path first, and that <code>connect()</code> would sail into a black hole. The race timer is supposed to fall back to IPv4 — but with a route that silently drops packets instead of cleanly rejecting them, the fallback timing slipped and the whole call stalled until the app-level timeout.</p>
<p>A quick <code>curl</code> proved it cold:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#6272a4"># hangs forever — there&#39;s an AAAA record and broken v6 routing</span>
</span></span><span style="display:flex;"><span>curl -v https://api.example.com/
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># instant — forced down IPv4</span>
</span></span><span style="display:flex;"><span>curl -4 -v https://api.example.com/
</span></span></code></pre></div><p><code>-6</code> hung. <code>-4</code> flew. That&rsquo;s your smoking gun.</p>
<figure style="text-align:center;margin:34px 0">
<svg viewBox="0 0 560 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="IPv6 path to a black hole, IPv4 path succeeds">
  <rect x="0" y="0" width="560" height="220" rx="10" fill="#11111b"/>
  <rect x="28" y="86" width="120" height="48" rx="8" fill="#1e1e2e" stroke="#45475a"/>
  <text x="88" y="115" text-anchor="middle" font-family="monospace" font-size="14" fill="#cdd6f4">the bot</text>
  <path d="M148 100 H290" stroke="#f38ba8" stroke-width="2.5" fill="none"/>
  <polygon points="300,100 288,94 288,106" fill="#f38ba8"/>
  <text x="222" y="90" text-anchor="middle" font-family="monospace" font-size="12" fill="#f38ba8">AAAA / IPv6</text>
  <circle cx="360" cy="100" r="34" fill="#1e1e2e" stroke="#f38ba8" stroke-width="2.5"/>
  <text x="360" y="96" text-anchor="middle" font-family="monospace" font-size="11" fill="#f38ba8">black</text>
  <text x="360" y="110" text-anchor="middle" font-family="monospace" font-size="11" fill="#f38ba8">hole ✗</text>
  <path d="M148 120 H300 Q330 120 340 150 H460" stroke="#a6e3a1" stroke-width="2.5" fill="none"/>
  <polygon points="470,150 458,144 458,156" fill="#a6e3a1"/>
  <text x="250" y="172" text-anchor="middle" font-family="monospace" font-size="12" fill="#a6e3a1">A / IPv4</text>
  <rect x="470" y="128" width="68" height="44" rx="8" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="2"/>
  <text x="504" y="155" text-anchor="middle" font-family="monospace" font-size="13" fill="#a6e3a1">API ✓</text>
</svg>
<figcaption style="color:#6c7086;font-size:14px;margin-top:8px">Happy Eyeballs races both paths — but a route that drops packets stalls the v6 attempt instead of failing fast.</figcaption>
</figure>
<h2 id="the-fix">The fix</h2>
<p>Two layers. First, kill IPv6 on the one NIC that had it broken — while leaving it alone on the VPN and VM interfaces that genuinely use it.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#6272a4"># disable IPv6 on the offending interface only (e.g. eth0), keep it elsewhere</span>
</span></span><span style="display:flex;"><span>sudo sysctl -w net.ipv6.conf.eth0.disable_ipv6<span style="color:#ff79c6">=</span><span style="color:#bd93f9">1</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># make it stick across reboots</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">echo</span> <span style="color:#f1fa8c">&#39;net.ipv6.conf.eth0.disable_ipv6 = 1&#39;</span> | sudo tee /etc/sysctl.d/99-disable-ipv6-eth0.conf
</span></span><span style="display:flex;"><span>sudo sysctl --system
</span></span></code></pre></div><p>Second, belt and suspenders at the app layer — force the resolver IPv4-first so even a half-broken host can&rsquo;t bite us again:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#6272a4">// top of the entrypoint, before any outbound calls
</span></span></span><span style="display:flex;"><span><span style="color:#ff79c6">const</span> dns <span style="color:#ff79c6">=</span> require(<span style="color:#f1fa8c">&#39;node:dns&#39;</span>)
</span></span><span style="display:flex;"><span>dns.setDefaultResultOrder(<span style="color:#f1fa8c">&#39;ipv4first&#39;</span>)
</span></span></code></pre></div><p><code>curl -4</code> already proved the IPv4 path was healthy, so this was a safe, deterministic pin. Restarted the bot, hammered it with calls for ten minutes. Zero hangs. Every request back under 200ms.</p>
<h2 id="why-it-happened">Why it happened</h2>
<p>&ldquo;Broken but present&rdquo; IPv6 is worse than no IPv6 at all. If the host had <strong>no</strong> AAAA-resolvable path, the client never tries v6 and you never notice. But give it IPv6 addresses with no real route, and every modern HTTP client cheerfully tries the dead path first — then waits on a <code>connect()</code> that silently drops instead of refusing.</p>
<p>No RST means no fast failure. No fast failure means a stall. And because Happy Eyeballs only <em>sometimes</em> loses the race badly, the stall is intermittent. That&rsquo;s why it looked like a flaky upstream when it was a flaky local NIC the whole time.</p>
<h2 id="takeaways">Takeaways</h2>
<ul>
<li><strong>If something hangs only <em>sometimes</em> on outbound HTTP, suspect IPv6.</strong> Test it in one command: <code>curl -4</code> vs <code>curl -6</code>. If <code>-6</code> hangs, you found it.</li>
<li><strong>Addresses ≠ connectivity.</strong> A NIC can hold a perfectly valid IPv6 address and still have zero working route to the internet.</li>
<li><strong>Disable IPv6 per-interface, not globally.</strong> Kill it on the broken NIC; leave it on VPN/VM links that actually need it.</li>
<li><strong>Pin IPv4-first at the app layer too</strong> (<code>dns.setDefaultResultOrder('ipv4first')</code>) — defense in depth for hosts you don&rsquo;t fully control.</li>
<li><strong>A <code>connect()</code> that never returns is a routing problem, not a server problem.</strong> <code>strace</code> the hang before you blame the API.</li>
</ul>
]]></content:encoded></item><item><title>How a Mesh VPN Killed My SSH Jump Host</title><link>https://errorzap.com/posts/how-a-mesh-vpn-killed-my-ssh-jump-host/</link><pubDate>Sat, 11 Apr 2026 00:00:00 -0600</pubDate><guid>https://errorzap.com/posts/how-a-mesh-vpn-killed-my-ssh-jump-host/</guid><description>The bastion broke every time an IP changed, so I deleted the bastion.</description><content:encoded><![CDATA[<figure style="text-align:center;margin:0 0 30px"><img src="hero.png" alt="How a Mesh VPN Killed My SSH Jump Host" style="max-width:520px;width:100%;border-radius:14px"/></figure>
<p>There&rsquo;s a box at a remote site. It lives behind NAT, behind a router I don&rsquo;t control, on a connection that hands out a new public IP whenever the ISP feels like it.</p>
<p>To reach it, I had a ritual. SSH into a bastion. From the bastion, SSH into the target. Type two passphrases, pray nothing changed, and hope the WAN IP I memorized last month was still the WAN IP.</p>
<pre tabindex="0"><code>ssh -J bastion target
</code></pre><p>That one flag hid a small empire of fragility.</p>
<h2 id="the-investigation">The investigation</h2>
<p>It broke on a Tuesday, the way these things do. <code>ssh: connect to host ... port 22: Connection timed out</code>.</p>
<p>First instinct: the target is down. It wasn&rsquo;t — a colleague on-site confirmed it was humming along, recording, serving, fine.</p>
<p>Second instinct: the bastion is down. It wasn&rsquo;t either. I could land on the bastion clean.</p>
<pre tabindex="0"><code>ssh bastion &#39;echo alive&#39;
alive
</code></pre><p>So the bastion was up, the target was up, and the hop between them was dead. I jumped onto the bastion and tried the second leg by hand.</p>
<pre tabindex="0"><code>ssh me@198.51.100.x
ssh: connect to host 198.51.100.x port 22: No route to host
</code></pre><p>There it was. The remote site&rsquo;s WAN IP had rotated overnight. The <code>198.51.100.x</code> baked into my SSH config — and into the bastion&rsquo;s <code>known_hosts</code>, and into a port-forward rule, and into my muscle memory — now pointed at some stranger&rsquo;s DHCP lease.</p>
<h2 id="the-aha">The &ldquo;aha&rdquo;</h2>
<p>I&rsquo;d built a chain of three brittle links to solve one problem: <em>the target has no stable address I can reach from outside.</em></p>
<p>Every fix I&rsquo;d ever applied — dynamic DNS, a forwarded port, a second hop — was just another way of chasing an address that refused to hold still. The bastion wasn&rsquo;t a solution. It was a workaround for a missing fact.</p>
<p>What if the box just <em>had</em> a stable address? One that didn&rsquo;t care about NAT, ISP whims, or which coffee shop my laptop was sitting in?</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>   BEFORE                                AFTER
</span></span><span style="display:flex;"><span> ┌─────────┐                          ┌─────────┐
</span></span><span style="display:flex;"><span> │ laptop  │                          │ laptop  │
</span></span><span style="display:flex;"><span> └────┬────┘                          └────┬────┘
</span></span><span style="display:flex;"><span>      │ ssh                                │ ssh
</span></span><span style="display:flex;"><span>      ▼                                    │ (encrypted mesh)
</span></span><span style="display:flex;"><span> ┌─────────┐                               │
</span></span><span style="display:flex;"><span> │ bastion │  ◄── public IP that          │
</span></span><span style="display:flex;"><span> └────┬────┘      keeps rotating          │
</span></span><span style="display:flex;"><span>      │ ssh                                ▼
</span></span><span style="display:flex;"><span>      ▼                                ┌─────────┐
</span></span><span style="display:flex;"><span> ┌─────────┐   NAT / CGNAT             │ target  │
</span></span><span style="display:flex;"><span> │ target  │   no inbound port         │ 100.x.. │ stable tailnet IP
</span></span><span style="display:flex;"><span> └─────────┘                           └─────────┘
</span></span></code></pre></div><p>That address is exactly what a WireGuard-based mesh VPN hands you. Put the laptop and the target on the same tailnet, and each device gets a private IP that follows it everywhere — through NAT, through CGNAT, through IP rotations — because the mesh handles NAT traversal for you.</p>
<h2 id="the-fix">The fix</h2>
<p>Install the mesh on both ends. (Generic Tailscale-style flow below.)</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#6272a4"># On the target — behind NAT, no inbound ports needed</span>
</span></span><span style="display:flex;"><span>curl -fsSL https://tailscale.com/install.sh | sh
</span></span><span style="display:flex;"><span>sudo tailscale up --ssh --hostname remote-target
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># On my laptop</span>
</span></span><span style="display:flex;"><span>tailscale up
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># Confirm the target shows a stable tailnet IP</span>
</span></span><span style="display:flex;"><span>tailscale status
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># 100.x.y.z   remote-target   linux   active; direct</span>
</span></span></code></pre></div><p>Now SSH goes straight there. No bastion, no <code>-J</code>, no rotating address.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ssh me@100.x.y.z
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># or, by MagicDNS name</span>
</span></span><span style="display:flex;"><span>ssh me@remote-target
</span></span></code></pre></div><p>Lock it down with an ACL so only my devices can reach port 22 on that box:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;acls&#34;</span>: [
</span></span><span style="display:flex;"><span>    { <span style="color:#ff79c6">&#34;action&#34;</span>: <span style="color:#f1fa8c">&#34;accept&#34;</span>, <span style="color:#ff79c6">&#34;src&#34;</span>: [<span style="color:#f1fa8c">&#34;tag:admin&#34;</span>], <span style="color:#ff79c6">&#34;dst&#34;</span>: [<span style="color:#f1fa8c">&#34;tag:remote:22&#34;</span>] }
</span></span><span style="display:flex;"><span>  ]
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Then I deleted the bastion&rsquo;s forward rule, pulled the stale host key, and retired the jump chain entirely.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ssh-keygen -R 198.51.100.x
</span></span></code></pre></div><figure style="text-align:center;margin:34px 0">
<svg viewBox="0 0 560 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Mesh VPN topology replacing a bastion hop">
  <rect x="0" y="0" width="560" height="220" rx="10" fill="#11111b"/>
  <text x="280" y="28" fill="#cdd6f4" font-family="monospace" font-size="15" text-anchor="middle">one tailnet, every device gets a stable address</text>
  <!-- laptop -->
  <rect x="40" y="80" width="120" height="60" rx="8" fill="#1e1e2e" stroke="#45475a"/>
  <text x="100" y="106" fill="#89b4fa" font-family="monospace" font-size="13" text-anchor="middle">laptop</text>
  <text x="100" y="126" fill="#6c7086" font-family="monospace" font-size="11" text-anchor="middle">100.64.0.2</text>
  <!-- target -->
  <rect x="400" y="80" width="120" height="60" rx="8" fill="#1e1e2e" stroke="#45475a"/>
  <text x="460" y="106" fill="#a6e3a1" font-family="monospace" font-size="13" text-anchor="middle">target</text>
  <text x="460" y="126" fill="#6c7086" font-family="monospace" font-size="11" text-anchor="middle">100.64.0.7</text>
  <!-- NAT cloud in middle, bypassed -->
  <rect x="230" y="150" width="100" height="40" rx="8" fill="#313244" stroke="#45475a"/>
  <text x="280" y="175" fill="#f38ba8" font-family="monospace" font-size="12" text-anchor="middle">NAT / CGNAT</text>
  <!-- direct encrypted line -->
  <line x1="160" y1="110" x2="400" y2="110" stroke="#cba6f7" stroke-width="3"/>
  <circle cx="280" cy="110" r="6" fill="#94e2d5"/>
  <text x="280" y="66" fill="#fab387" font-family="monospace" font-size="12" text-anchor="middle">WireGuard tunnel · ACL-gated</text>
  <!-- dashed line showing NAT is bypassed -->
  <line x1="280" y1="116" x2="280" y2="150" stroke="#6c7086" stroke-width="1.5" stroke-dasharray="4 4"/>
</svg>
<figcaption style="color:#6c7086;font-size:14px;margin-top:8px">The hop is gone. The tunnel goes straight through NAT, ACL-gated, end to end.</figcaption>
</figure>
<h2 id="why-it-happened">Why it happened</h2>
<p>The bastion was never the point. It existed because the target had no reachable, durable address — so I borrowed one from a machine that did, then chained a second SSH session on top.</p>
<p>Every link in that chain depended on a public IP staying put. One of them rotated, and the whole thing collapsed. That&rsquo;s not a bug in SSH. That&rsquo;s the architecture telling you it was held together with assumptions.</p>
<p>A mesh VPN moves the addressing problem off the public internet entirely. The tailnet IP is yours, it&rsquo;s stable, and it doesn&rsquo;t care what the ISP does at 3 a.m.</p>
<h2 id="takeaways">Takeaways</h2>
<ul>
<li><strong>If you&rsquo;re chaining <code>ssh -J</code> to reach a NAT&rsquo;d box, that&rsquo;s a missing stable address, not a routing puzzle.</strong> Fix the address, delete the chain.</li>
<li><strong>Mesh VPNs (WireGuard-based) give every device a durable private IP</strong> that survives NAT, CGNAT, and ISP rotations — no inbound ports to forward.</li>
<li><strong>Stable addressing kills a whole class of failures</strong>: stale <code>known_hosts</code>, dead port-forwards, dynamic-DNS lag, memorized WAN IPs.</li>
<li><strong>Gate it with ACLs.</strong> A flat tailnet where everything reaches everything is just a bigger blast radius. Tag, scope, restrict to port 22.</li>
<li><strong>The most reliable hop is the one you deleted.</strong> Fewer moving parts, fewer 3 a.m. timeouts.</li>
</ul>
]]></content:encoded></item><item><title>The Speaker That Caused a Network Storm</title><link>https://errorzap.com/posts/the-speaker-that-caused-a-network-storm/</link><pubDate>Wed, 08 Apr 2026 00:00:00 -0600</pubDate><guid>https://errorzap.com/posts/the-speaker-that-caused-a-network-storm/</guid><description>A wired network melting down at a customer site, traced to a pair of consumer speakers quietly braiding a layer-2 loop out of copper and mesh.</description><content:encoded><![CDATA[<figure style="text-align:center;margin:0 0 30px"><img src="hero.png" alt="The Speaker That Caused a Network Storm" style="max-width:520px;width:100%;border-radius:14px"/></figure>
<p>The call came in the way these always do: &ldquo;the whole network is slow.&rdquo;</p>
<p>Not down. Slow. Which is worse, because slow means it&rsquo;s still trying.</p>
<p>I pulled up the switch stats from the site and the picture was ugly. Broadcast counters spinning like a gas pump. Port LEDs across the access switches strobing in unison — that synchronized, hypnotic blink that should make the hair on your neck stand up. CPU on the core switch pinned. Every wired device crawling, half the VLANs timing out.</p>
<p>That&rsquo;s not congestion. That&rsquo;s a storm.</p>
<h2 id="the-investigation">The investigation</h2>
<p>A broadcast storm has a signature. One frame gets flooded out every port, comes back in another port, gets flooded again, forever, at line rate. The switches aren&rsquo;t broken — they&rsquo;re doing exactly what they&rsquo;re told, infinitely.</p>
<p>So the question is never &ldquo;what&rsquo;s slow.&rdquo; It&rsquo;s &ldquo;where&rsquo;s the loop.&rdquo;</p>
<p>I started where loops live: the edge. I pulled the MAC address table and watched a handful of addresses flapping between ports — the same MAC showing up on port 14, then port 9, then port 14, dozens of times a second. A stable MAC doesn&rsquo;t teleport. A looped one does.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>show mac address-table | the same MAC, two ports, flapping
</span></span><span style="display:flex;"><span>─────────────────────────────────────────────────────────
</span></span><span style="display:flex;"><span>  02:00:00:aa:bb:cc   Gi0/14   ◄─┐
</span></span><span style="display:flex;"><span>  02:00:00:aa:bb:cc   Gi0/9    ◄─┤  same address,
</span></span><span style="display:flex;"><span>  02:00:00:aa:bb:cc   Gi0/14   ◄─┤  two ports,
</span></span><span style="display:flex;"><span>  02:00:00:aa:bb:cc   Gi0/9    ◄─┘  ~40x/sec  ✗
</span></span></code></pre></div><p>I traced both ports. Port 14 and port 9 went to two units of a consumer multi-room speaker system — the kind that builds its own little wireless mesh between speakers so they stay in sync.</p>
<p>And somebody had wired <em>both</em> of them into the LAN.</p>
<h2 id="the-aha">The &ldquo;aha&rdquo;</h2>
<p>There it was. Each speaker had an Ethernet drop. Each speaker was <em>also</em> bridging to its sibling over the proprietary wireless mesh. So a frame could leave the switch on port 14, ride into speaker A, hop the wireless mesh to speaker B, and come right back into the switch on port 9.</p>
<p>Copper out, radio across, copper back in. A loop with one leg made of Wi-Fi.</p>
<figure style="text-align:center;margin:34px 0">
<svg viewBox="0 0 560 220" xmlns="http://www.w3.org/2000/svg" font-family="ui-monospace,monospace" role="img" aria-label="Two wired speakers bridged by a wireless mesh forming a layer-2 loop">
<rect x="0" y="0" width="560" height="220" fill="#11111b" rx="10"/>
<rect x="210" y="20" width="140" height="44" rx="8" fill="#1e1e2e" stroke="#45475a"/>
<text x="280" y="47" fill="#cdd6f4" font-size="14" text-anchor="middle">Switch</text>
<rect x="70" y="140" width="130" height="44" rx="8" fill="#313244" stroke="#45475a"/>
<text x="135" y="167" fill="#cba6f7" font-size="13" text-anchor="middle">Speaker A</text>
<rect x="360" y="140" width="130" height="44" rx="8" fill="#313244" stroke="#45475a"/>
<text x="425" y="167" fill="#cba6f7" font-size="13" text-anchor="middle">Speaker B</text>
<line x1="240" y1="64" x2="135" y2="140" stroke="#89b4fa" stroke-width="2.5"/>
<line x1="320" y1="64" x2="425" y2="140" stroke="#89b4fa" stroke-width="2.5"/>
<text x="150" y="108" fill="#89b4fa" font-size="11" text-anchor="middle">copper</text>
<text x="410" y="108" fill="#89b4fa" font-size="11" text-anchor="middle">copper</text>
<line x1="200" y1="162" x2="360" y2="162" stroke="#fab387" stroke-width="2.5" stroke-dasharray="6 5"/>
<text x="280" y="153" fill="#fab387" font-size="11" text-anchor="middle">wireless mesh</text>
<text x="280" y="205" fill="#f38ba8" font-size="13" text-anchor="middle">► layer-2 loop ◄ — frames circulate forever</text>
</svg>
<figcaption style="color:#6c7086;font-size:14px;margin-top:8px">Two wired speakers, one wireless bridge between them: a loop the switch can't see coming.</figcaption>
</figure>
<p>The reason it had run fine for months and then exploded? Doesn&rsquo;t matter much. Someone re-cabled a closet, both speakers ended up plugged in, the mesh did its job, and nothing on those access ports was watching for a loop.</p>
<h2 id="the-fix">The fix</h2>
<p>The speaker was the trigger. The real bug was a switch fabric with no loop protection. So we fixed both.</p>
<p>First, the immediate bleeding — pull one of the two speaker uplinks. One leg of copper gone, loop broken, network breathes.</p>
<p>Then, the actual fix. Turn on spanning tree, and <strong>pick the root on purpose</strong> instead of letting MAC addresses elect a random one:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#6272a4"># Core switch — make it the root, deliberately. Lowest priority wins.</span>
</span></span><span style="display:flex;"><span>spanning-tree mode rstp
</span></span><span style="display:flex;"><span>spanning-tree vlan 1-4094 priority <span style="color:#bd93f9">4096</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># Distribution — second in line</span>
</span></span><span style="display:flex;"><span>spanning-tree vlan 1-4094 priority <span style="color:#bd93f9">8192</span>
</span></span></code></pre></div><p>Then lock down the edge so an end-device port can never become a transit path:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#6272a4"># Access port to the speaker / any end device</span>
</span></span><span style="display:flex;"><span>interface GigabitEthernet0/14
</span></span><span style="display:flex;"><span> spanning-tree portfast
</span></span><span style="display:flex;"><span> spanning-tree bpduguard <span style="color:#8be9fd;font-style:italic">enable</span>     <span style="color:#6272a4"># any BPDU here = err-disable the port</span>
</span></span><span style="display:flex;"><span> spanning-tree guard root            <span style="color:#6272a4"># this port may never be a path to root</span>
</span></span><span style="display:flex;"><span> storm-control broadcast level 1.00  <span style="color:#6272a4"># cap broadcast at 1% of line rate</span>
</span></span><span style="display:flex;"><span> storm-control action shutdown
</span></span></code></pre></div><p>Mesh-style managed gear is the same idea in different clothes — flip on <strong>STP</strong>, set tiered priorities so the core is root, and enable <strong>Loop Protection</strong> plus <strong>BPDU Guard</strong> on access ports.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>edge port discipline
</span></span><span style="display:flex;"><span>─────────────────────────────────
</span></span><span style="display:flex;"><span>  PortFast      ✓  fast end-device link
</span></span><span style="display:flex;"><span>  BPDU Guard    ✓  sees a switch → kills port
</span></span><span style="display:flex;"><span>  Loop Guard    ✓  detects the loop
</span></span><span style="display:flex;"><span>  Storm-control ✓  broadcast ceiling
</span></span><span style="display:flex;"><span>─────────────────────────────────
</span></span><span style="display:flex;"><span>  result: ▓▓▓▓▓ blast radius = one port
</span></span></code></pre></div><p>We re-cabled the speakers to a single drop each, verified one stayed on copper and the rest synced over mesh, and watched the broadcast counter flatline.</p>
<h2 id="why-it-happened">Why it happened</h2>
<p>Consumer mesh gear is a loop machine wearing a friendly face. It will happily bridge two of its own units, and it has no idea it&rsquo;s also sitting on your switched LAN. Plug two of them into copper and you&rsquo;ve handed your network a second path it never agreed to.</p>
<p>The switch didn&rsquo;t fail. It had no spanning tree configured, so it had no mechanism to notice the loop and break it. The default config trusted every port to behave. Edge ports never behave.</p>
<h2 id="takeaways">Takeaways</h2>
<ul>
<li><strong>Always run STP/RSTP.</strong> A modern loop-free topology still needs spanning tree on standby for the day someone hands you a loop.</li>
<li><strong>Set the root bridge on purpose.</strong> Lowest priority value wins; don&rsquo;t let a random MAC address decide where your traffic converges.</li>
<li><strong>Guard the edge.</strong> BPDU Guard, Loop Protection, and storm-control on access ports turn a network-wide meltdown into one dead port.</li>
<li><strong>Treat consumer mesh gear as hostile to your switch fabric.</strong> Never wire two mesh-bridging devices into the same LAN.</li>
<li><strong>A flapping MAC is a loop until proven otherwise.</strong> The mac-address-table is your fastest path from &ldquo;it&rsquo;s slow&rdquo; to &ldquo;it&rsquo;s port 9 and 14.&rdquo;</li>
</ul>
]]></content:encoded></item><item><title>The Firewall API Call That Factory-Resets Your Router</title><link>https://errorzap.com/posts/the-firewall-api-call-that-wipes-your-router/</link><pubDate>Thu, 12 Mar 2026 00:00:00 -0600</pubDate><guid>https://errorzap.com/posts/the-firewall-api-call-that-wipes-your-router/</guid><description>I ran a &amp;lsquo;harmless&amp;rsquo; config script against a pfSense box and watched the entire firewall configuration evaporate in a single function call.</description><content:encoded><![CDATA[<figure style="text-align:center;margin:0 0 30px"><img src="hero.png" alt="The Firewall API Call That Factory-Resets Your Router" style="max-width:520px;width:100%;border-radius:14px"/></figure>
<p>It was supposed to be a five-line automation. Read a value, tweak it, write it back. The kind of thing you knock out between coffees.</p>
<p>I shelled into a client&rsquo;s pfSense box — a clean, current 24.11 install — and ran my little script through <code>pfSsh.php</code>. It exited without a peep. No errors. No warnings. Just a fresh prompt blinking back at me.</p>
<p>Then the SSH session died.</p>
<p>Then the VPN dropped.</p>
<p>Then my phone lit up.</p>
<h2 id="the-investigation">The investigation</h2>
<p>I got back in through the console. The dashboard loaded, and my stomach went cold. No WAN config. No LAN rules. No NAT, no VLANs, no DHCP scopes. The box was sitting there with the bright-eyed innocence of a router that had never met a network.</p>
<p>The config wasn&rsquo;t corrupted. It was <em>gone</em>. Replaced with defaults, like someone had hit the reset button — except nobody had touched the hardware.</p>
<p>I pulled up my script. Five lines. The offending logic looked like ancient, trustworthy pfSense scripting lore:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#ff79c6">global</span> <span style="color:#8be9fd;font-style:italic">$config</span>;
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">$config</span>[<span style="color:#f1fa8c">&#39;system&#39;</span>][<span style="color:#f1fa8c">&#39;something&#39;</span>] <span style="color:#ff79c6">=</span> <span style="color:#f1fa8c">&#39;newvalue&#39;</span>;
</span></span><span style="display:flex;"><span>write_config(<span style="color:#f1fa8c">&#34;scripted tweak&#34;</span>);
</span></span></code></pre></div><p>Every old forum thread, every crusty wiki page from a decade ago, does exactly this. Grab the <code>$config</code> global, poke a key, call <code>write_config()</code>. It&rsquo;s the canonical move.</p>
<p>So why did it nuke the box?</p>
<p>I added one debug line before the write:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#ff79c6">global</span> <span style="color:#8be9fd;font-style:italic">$config</span>;
</span></span><span style="display:flex;"><span>var_dump(<span style="color:#8be9fd;font-style:italic">$config</span>);
</span></span></code></pre></div><p>The output told the whole story in one ugly word: <strong>empty.</strong></p>
<h2 id="the-aha">The &ldquo;aha&rdquo;</h2>
<p>On modern pfSense, that legacy <code>$config</code> global is no longer the living, fully-populated config tree the old guides assume. In the <code>pfSsh.php</code> scripting context it came back essentially empty — a hollow shell.</p>
<p>And <code>write_config()</code> doesn&rsquo;t merge. It doesn&rsquo;t patch. It takes whatever is in memory <em>right now</em> and serializes that as the new, authoritative config. The whole thing.</p>
<p>So my script read an empty global, set one key on top of nothing, and then confidently wrote that &ldquo;nothing plus one key&rdquo; back as the complete configuration. <code>write_config()</code> did exactly what it was told. It overwrote a fully working firewall with a near-empty husk.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>┌──────────────────────────────────────────────────────────┐
</span></span><span style="display:flex;"><span>│  WHAT I THOUGHT HAPPENED          WHAT ACTUALLY HAPPENED  │
</span></span><span style="display:flex;"><span>├──────────────────────────────────────────────────────────┤
</span></span><span style="display:flex;"><span>│  $config = { full tree }     ►    $config = { } (EMPTY)   │
</span></span><span style="display:flex;"><span>│        │                                  │               │
</span></span><span style="display:flex;"><span>│        ▼ set one key                      ▼ set one key   │
</span></span><span style="display:flex;"><span>│  { full tree + key }              { almost nothing }      │
</span></span><span style="display:flex;"><span>│        │                                  │               │
</span></span><span style="display:flex;"><span>│        ▼ write_config()                   ▼ write_config()│
</span></span><span style="display:flex;"><span>│   ✓ saved correctly               ✗ overwrote everything  │
</span></span><span style="display:flex;"><span>└──────────────────────────────────────────────────────────┘
</span></span></code></pre></div><p>The modern config backend wants you to read and write <em>specific paths</em> — <code>config_get_path()</code> and <code>config_set_path()</code> — never the whole global at once.</p>
<figure style="text-align:center;margin:34px 0">
<svg viewBox="0 0 560 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Path API versus legacy global write">
<rect x="0" y="0" width="560" height="220" fill="#11111b" rx="10"/>
<text x="280" y="30" fill="#cdd6f4" font-family="monospace" font-size="15" text-anchor="middle">read & write ONE key — not the whole tree</text>
<rect x="30" y="55" width="230" height="130" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="2" rx="8"/>
<text x="145" y="80" fill="#a6e3a1" font-family="monospace" font-size="13" text-anchor="middle">SAFE — path API</text>
<text x="145" y="110" fill="#94e2d5" font-family="monospace" font-size="12" text-anchor="middle">config_get_path(k)</text>
<text x="145" y="132" fill="#94e2d5" font-family="monospace" font-size="12" text-anchor="middle">config_set_path(k,v)</text>
<text x="145" y="162" fill="#cdd6f4" font-family="monospace" font-size="12" text-anchor="middle">► touches only k ✓</text>
<rect x="300" y="55" width="230" height="130" fill="#1e1e2e" stroke="#f38ba8" stroke-width="2" rx="8"/>
<text x="415" y="80" fill="#f38ba8" font-family="monospace" font-size="13" text-anchor="middle">FOOTGUN — global</text>
<text x="415" y="110" fill="#fab387" font-family="monospace" font-size="12" text-anchor="middle">global $config; // empty</text>
<text x="415" y="132" fill="#fab387" font-family="monospace" font-size="12" text-anchor="middle">write_config()</text>
<text x="415" y="162" fill="#f38ba8" font-family="monospace" font-size="12" text-anchor="middle">✗ overwrites ALL of it</text>
<text x="280" y="208" fill="#6c7086" font-family="monospace" font-size="12" text-anchor="middle">one call, two very different blast radii</text>
</svg>
<figcaption style="color:#6c7086;font-size:14px;margin-top:8px">Scoped path writes touch one key. A global write_config() rewrites the entire config from whatever's in memory.</figcaption>
</figure>
<h2 id="the-fix">The fix</h2>
<p>First, recover. Restore the last known-good config from a backup and reload:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span><span style="color:#6272a4"># Restore from a saved backup and reload</span>
</span></span><span style="display:flex;"><span>config_restore /cf/conf/backup/config-&lt;latest-good&gt;.xml
</span></span><span style="display:flex;"><span>/etc/rc.reload_all
</span></span></code></pre></div><p>pfSense keeps automatic backups under <code>/cf/conf/backup/</code> — list them with <code>ls -lt /cf/conf/backup/</code> and pick the newest one from <em>before</em> your script ran. That brought the firewall back to life.</p>
<p>Then, fix the script. Use the path API and stop touching the global entirely:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#6272a4">// Read a specific key (with a default), modify, write that key back.
</span></span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">$value</span> <span style="color:#ff79c6">=</span> config_get_path(<span style="color:#f1fa8c">&#39;system/something&#39;</span>, <span style="color:#f1fa8c">&#39;default&#39;</span>);
</span></span><span style="display:flex;"><span>config_set_path(<span style="color:#f1fa8c">&#39;system/something&#39;</span>, <span style="color:#f1fa8c">&#39;newvalue&#39;</span>);
</span></span><span style="display:flex;"><span>write_config(<span style="color:#f1fa8c">&#34;scripted tweak via path API&#34;</span>);
</span></span></code></pre></div><p>And — non-negotiable — snapshot the config <em>before</em> any scripted write, so recovery is one command instead of a panic:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>cp /cf/config.xml <span style="color:#f1fa8c">&#34;/cf/conf/backup/config-</span><span style="color:#ff79c6">$(</span>date +%Y%m%d-%H%M%S<span style="color:#ff79c6">)</span><span style="color:#f1fa8c">-prewrite.xml&#34;</span>
</span></span></code></pre></div><h2 id="why-it-happened">Why it happened</h2>
<p>Platforms migrate their internals quietly. pfSense moved to a path-based config backend, and the old global <code>$config</code> got demoted from &ldquo;the config&rdquo; to &ldquo;a vestigial variable that may or may not be populated, depending on context.&rdquo;</p>
<p>The docs got updated. The thousand forum posts from 2014 did not.</p>
<p>So the trap is perfect: the legacy pattern still <em>compiles</em>, still <em>runs</em>, still <em>exits clean</em>. It just operates on a ghost of the real config — and <code>write_config()</code> is happy to make that ghost permanent.</p>
<p>A function that overwrites everything from in-memory state is a loaded gun. When the thing loading that memory changes underneath you, the gun is now pointed at your whole config and you don&rsquo;t even know it.</p>
<h2 id="takeaways">Takeaways</h2>
<ul>
<li><strong>A global <code>write_config()</code> rewrites the entire config from in-memory state.</strong> If that state is stale, partial, or empty, you&rsquo;ve just factory-reset your box. Treat it as a full-config replace, never a patch.</li>
<li><strong>When a platform migrates its config backend, the old global is a footgun.</strong> It still runs clean — that&rsquo;s what makes it dangerous. Verify what it actually contains before trusting it.</li>
<li><strong>Use the scoped API.</strong> <code>config_get_path()</code> / <code>config_set_path()</code> touch exactly one key. That&rsquo;s the blast radius you want.</li>
<li><strong>Snapshot before any scripted write.</strong> <code>cp /cf/config.xml /cf/conf/backup/...-prewrite.xml</code> turns a disaster into a 30-second restore.</li>
<li><strong><code>var_dump()</code> your assumptions.</strong> One debug line showing an empty global would&rsquo;ve saved the whole incident. When in doubt, print what you&rsquo;ve actually got — not what the old guides promise you have.</li>
</ul>
]]></content:encoded></item></channel></rss>