<?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>Dhcp on Andy Little</title><link>https://andylittle.net/tags/dhcp/</link><description>Recent content in Dhcp on Andy Little</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Fri, 04 Sep 2026 20:00:00 -0500</lastBuildDate><atom:link href="https://andylittle.net/tags/dhcp/index.xml" rel="self" type="application/rss+xml"/><item><title>24 Days of Nobody Noticing: A DNS Record Pinned to a DHCP Lease</title><link>https://andylittle.net/blog/2026/static-dns-dynamic-lease-outage/</link><pubDate>Fri, 04 Sep 2026 20:00:00 -0500</pubDate><guid>https://andylittle.net/blog/2026/static-dns-dynamic-lease-outage/</guid><description>My GPS-disciplined time server dropped off the network and I didn&amp;rsquo;t find out for 24 days. The cause wasn&amp;rsquo;t the hardware, and it wasn&amp;rsquo;t what I assumed twice along the way — it was a permanent DNS record aimed at an address the router was free to hand to somebody else.</description><content:encoded><![CDATA[<p>A while back I <a href="/blog/2026/gps-disciplined-ntp-raspberry-pi/">wired a GPS module to a Raspberry Pi</a>
and turned it into a stratum-1 NTP server for the house. It worked beautifully:
PPS locked, error bars in the hundreds of nanoseconds, satellites doing the
timekeeping.</p>
<p>Then I opened my home network dashboard and the Time tab was red:</p>
<pre tabindex="0"><code>ssh: connect to host &lt;time-server&gt; port 22: No route to host
</code></pre><p>This is the story of what actually broke, which was not the thing I assumed —
twice — and of the twenty-four days it sat broken without anyone finding out.</p>
<p><em>(Addresses below are illustrative. Assume a home LAN of <code>192.0.2.0/24</code> with a
DHCP pool spanning <code>192.0.2.5</code> – <code>192.0.2.100</code>.)</em></p>
<h2 id="wrong-theory-1-the-pi-is-dead">Wrong theory #1: the Pi is dead</h2>
<p>&ldquo;No route to host&rdquo; plus no ARP reply looks like a box that&rsquo;s powered off. It
wasn&rsquo;t. A quick sweep found the Pi alive and well at a <em>different</em> address —
one inside the DHCP pool, holding a lease that had been renewed hours earlier.</p>
<p>So the Pi hadn&rsquo;t died. It had <strong>moved</strong>, and DNS hadn&rsquo;t.</p>
<h2 id="wrong-theory-2-its-dual-homed">Wrong theory #2: it&rsquo;s dual-homed</h2>
<p>Checking the router&rsquo;s leases and reservations turned up something that looked
like a smoking gun: there was a reservation for this host at an address <em>above</em>
the pool, on its wired MAC, and a separate dynamic lease on a MAC one digit
higher. On a Raspberry Pi the Wi-Fi MAC is typically the Ethernet MAC plus one:</p>
<pre tabindex="0"><code>aa:bb:cc:dd:ee:1a   eth0    reserved, above the pool
aa:bb:cc:dd:ee:1b   wlan0   dynamic lease, inside the pool
</code></pre><p>Two interfaces on one subnet, two valid answers to &ldquo;where is this host&rdquo; —
classic ARP flux, and a tidy explanation for how the address drifted without
anyone noticing.</p>
<p>It was also wrong. The Ethernet cable had been plugged in <em>minutes</em> earlier,
while debugging. Before that, this box had been Wi-Fi only for its entire life,
and the wired reservation had been sitting there dormant with nothing behind
it. There was no dual-homing during the outage, because there was no second
interface.</p>
<p>Two theories, two facts that killed them. Worth writing down, because the
debugging value here was entirely in the timeline, not the topology.</p>
<h2 id="the-actual-cause">The actual cause</h2>
<p>The pool covers the low half of the subnet. Reservations, by convention on this
network, live <em>above</em> it — and 92 of them do.</p>
<p>The time server was not one of them. It had an ordinary <strong>dynamic lease</strong>,
inside the pool, on its Wi-Fi interface. And a while back I had added a static
DNS host mapping pointing its hostname at that address.</p>
<p>That is the bug, and it&rsquo;s worth stating plainly:</p>
<blockquote>
<p>A permanent DNS record aimed at an address inside the DHCP pool, with no
matching reservation, is a time bomb. The router is free to hand that address
to something else. The name keeps resolving — it just resolves to the wrong
host, or to nothing.</p>
</blockquote>
<p>For weeks it looked fine, because DHCP leases renew and a host that stays up
tends to keep its address. Nothing forced the issue.</p>
<p>Then something did.</p>
<h2 id="the-trigger">The trigger</h2>
<p><code>dmesg</code> on the Pi, scrolled back far enough:</p>
<pre tabindex="0"><code>[1013882.469046] brcmf_fw_crashed: Firmware has halted or crashed
[1013882.511640] brcmf_cfg80211_get_tx_power: error (-5)
[1013883.092057] mmc1: card 0001 removed
[1013883.314334] mmc1: new ultra high speed DDR50 SDIO card at address 0001
[1013883.315903] brcmfmac: F1 signature read @0x18000000=0x15264345
</code></pre><p>The Broadcom Wi-Fi firmware halted. The SDIO card was removed and
re-enumerated — note the PHY renumbering from <code>phy0</code> to <code>phy1</code> in the
surrounding lines. The interface came back up, requested an address fresh, and
got a <em>different</em> one from the pool. The old address went back in the pool,
where it sits unallocated to this day.</p>
<p>Kernel ring buffer timestamps are seconds-since-boot, so converting them to a
wall-clock date is worth doing:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">python3 -c <span class="s2">&#34;
</span></span></span><span class="line"><span class="cl"><span class="s2">import datetime
</span></span></span><span class="line"><span class="cl"><span class="s2">uptime, event = 3107742, 1013882      # from /proc/uptime and the dmesg stamp
</span></span></span><span class="line"><span class="cl"><span class="s2">boot = datetime.datetime.now() - datetime.timedelta(seconds=uptime)
</span></span></span><span class="line"><span class="cl"><span class="s2">print(&#39;event:&#39;, boot + datetime.timedelta(seconds=event))&#34;</span>
</span></span></code></pre></div><p>That put the crash 24 days before I noticed. The DNS record had been pointing
at an empty address that whole time.</p>
<p>There were plenty of warnings that this radio was unwell, too — a tight loop of
<code>brcmf_cfg80211_scan: Connecting: status (7)</code> failures, and repeated
<code>brcmf_set_channel: set chanspec ... fail, reason -52</code> before the crash itself.</p>
<h2 id="why-nothing-told-me">Why nothing told me</h2>
<p>Here&rsquo;s the part I find most instructive, and I got it wrong on the first pass.</p>
<p>My initial explanation was that SSH connection multiplexing had masked it — the
dashboard keeps a <code>ControlMaster</code> connection to each host, so a live socket can
outlive the correctness of what it points at. I&rsquo;ve been bitten by that before,
on this same network, with a stale <code>known_hosts</code> entry that hid behind a mux
socket for six days.</p>
<p>But it doesn&rsquo;t hold here. The mux is configured with <code>ControlPersist=300</code> — the
master exits five minutes after last use — and this endpoint is only polled
when someone actually opens the dashboard. No socket survived 24 days. The
failure today was an immediate <code>No route to host</code>, which is exactly what you&rsquo;d
expect from a <em>fresh</em> connection attempt, not a stale one.</p>
<p>The real answer is duller and more useful: <strong>the health endpoint had been
reporting this failure correctly since the day of the crash, and nothing was
watching it.</strong></p>
<p>I had built the monitoring. <code>/api/health</code> returns a per-subsystem status, the
Time entry had been <code>ok: false</code> for 24 days, and it was completely accurate the
entire time. It&rsquo;s just that the only way that information ever reached a human
was if a human opened the page and looked at it.</p>
<blockquote>
<p>A health endpoint nobody watches isn&rsquo;t monitoring. It&rsquo;s a status page that
happens to be correct.</p>
</blockquote>
<p>That&rsquo;s the actual gap, and it&rsquo;s a much better thing to have learned than &ldquo;SSH
multiplexing is tricky.&rdquo;</p>
<h2 id="the-fix">The fix</h2>
<p>The cable I&rsquo;d plugged in during debugging turned out to be the right answer
anyway: move the box to Ethernet, where a reservation already existed at an
address safely above the pool. Update DNS to match. That&rsquo;s it.</p>
<p>Then make the configuration explicit. The dashboard had been finding the time
server through a <em>default value compiled into the code</em> — no environment
variable set anywhere, so it silently fell back to a hardcoded address that had
been correct when it was written. Making it an explicit setting is the whole
lesson of this outage applied to configuration: an implicit default that used
to be right is indistinguishable from one that still is.</p>
<h2 id="one-more-trap-set-doesnt-always-replace">One more trap: <code>set</code> doesn&rsquo;t always replace</h2>
<p>Applying the DNS change should have been a one-liner. On EdgeOS/Vyatta:</p>
<pre tabindex="0"><code>set system static-host-mapping host-name myhost.example.net inet 192.0.2.151
</code></pre><p>I ran it against a hostname that already had a record, expecting a replacement.
Instead the host started resolving to <strong>both</strong> addresses:</p>
<pre tabindex="0"><code>$ nslookup myhost.example.net &lt;router&gt;
Name:   myhost.example.net
Address: 192.0.2.71
Name:   myhost.example.net
Address: 192.0.2.151
</code></pre><p>The <code>inet</code> node is <strong>multi-valued</strong>. <code>set</code> appends to it. And my own tooling
hid the result, because the config parser read that node with a
&ldquo;give me the single value here&rdquo; helper that returned the first entry and
discarded the rest — so the API cheerfully reported one address while the
router was serving two.</p>
<p>The fix is <code>delete</code> then <code>set</code>. The broader lesson is that on tree-structured
config systems you have to know a node&rsquo;s arity before you write to it, and
&ldquo;read it back to confirm&rdquo; only works if your reader can represent what&rsquo;s
actually there. A parser that can&rsquo;t express the bug can&rsquo;t show you the bug.</p>
<h2 id="auditing-for-the-rest-of-them">Auditing for the rest of them</h2>
<p>If this happened once, it has probably happened elsewhere. The check is
mechanical: for every static DNS mapping, is the address inside the DHCP pool,
and if so does a reservation exist for it?</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">inpool</span> <span class="o">=</span> <span class="k">lambda</span> <span class="n">ip</span><span class="p">:</span> <span class="n">pool_start</span> <span class="o">&lt;=</span> <span class="n">ip_address</span><span class="p">(</span><span class="n">ip</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="n">pool_end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">record</span> <span class="ow">in</span> <span class="n">dns_records</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">inpool</span><span class="p">(</span><span class="n">record</span><span class="o">.</span><span class="n">ip</span><span class="p">)</span> <span class="ow">and</span> <span class="n">record</span><span class="o">.</span><span class="n">ip</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">reservations</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;time bomb:&#34;</span><span class="p">,</span> <span class="n">record</span><span class="o">.</span><span class="n">hostname</span><span class="p">,</span> <span class="n">record</span><span class="o">.</span><span class="n">ip</span><span class="p">)</span>
</span></span></code></pre></div><p>Three more turned up:</p>
<ul>
<li><strong>A NAS.</strong> Unreserved dynamic lease, with a service hostname pointed at it
<em>and</em> its address hardcoded into a stack of media containers. Same failure
mode as the time server, aimed at something far more disruptive. Moving it is
now a small project rather than a config edit, purely because the address got
copied into a dozen places instead of a name.</li>
<li><strong>A syslog VM.</strong> Statically configured on the VM itself, inside the pool, no
reservation. Nothing stops the router leasing that address to a new device
and colliding with the log collector. The pool is over half allocated, so
that&rsquo;s not hypothetical.</li>
<li><strong>A dead record</strong> for a host that no longer exists.</li>
</ul>
<h2 id="the-punchline">The punchline</h2>
<p>With the time server fixed and the dashboard green again, I ran one last check
on the Pi:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">chronyc clients
</span></span></code></pre></div><p>One client. And it was my own workstation, from a diagnostic query I&rsquo;d run ten
minutes earlier while testing.</p>
<p>Nothing else on the network has ever been syncing to it. I built a
GPS-disciplined, PPS-locked, sub-microsecond stratum-1 clock, wrote a blog post
about it, and never actually pointed anything at it. The 24-day outage didn&rsquo;t
degrade anyone&rsquo;s time, because there was no one to degrade.</p>
<p>That&rsquo;s its own kind of monitoring lesson. The correct next step isn&rsquo;t just
pointing hosts at it — it&rsquo;s having the router hand out the NTP server via DHCP
so clients get it without anyone remembering to configure them, and pointing
them at the <em>hostname</em> rather than an address, so the next time something moves,
DNS does its job.</p>
<h2 id="what-id-tell-past-me">What I&rsquo;d tell past me</h2>
<ol>
<li><strong>Never point a static DNS record at an address inside the DHCP pool.</strong> If a
name is permanent, the address behind it needs a reservation.</li>
<li><strong>Reference names, not addresses.</strong> Every hardcoded IP is a future outage
with a longer fix. The NAS is a project instead of an edit for exactly this
reason.</li>
<li><strong>A health endpoint nobody watches isn&rsquo;t monitoring.</strong> Mine was right for 24
days and told no one.</li>
<li><strong>An implicit default that used to be correct looks exactly like one that
still is.</strong> Make deployment config explicit.</li>
<li><strong>Convert dmesg timestamps to wall-clock time early.</strong> The entire diagnosis
turned on one arithmetic step that dated the crash.</li>
<li><strong>Check a config node&rsquo;s arity before writing to it</strong> — and make sure your
tooling can represent the wrong answer, or it can&rsquo;t show it to you.</li>
</ol>
<p>The uncomfortable one is #3. Every other item is a mistake I made once and can
fix. That one is a category of mistake I&rsquo;ll keep making as long as the only
consumer of my monitoring is me remembering to look at it.</p>
]]></content:encoded></item></channel></rss>