<?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>Dns on Andy Little</title><link>https://andylittle.net/tags/dns/</link><description>Recent content in Dns on Andy Little</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Thu, 17 Sep 2026 05:00:00 -0700</lastBuildDate><atom:link href="https://andylittle.net/tags/dns/index.xml" rel="self" type="application/rss+xml"/><item><title>Tricking a Weather Station Into Uploading to a Raspberry Pi</title><link>https://andylittle.net/blog/2026/weewx-interceptor-isolated-weather-station/</link><pubDate>Thu, 17 Sep 2026 05:00:00 -0700</pubDate><guid>https://andylittle.net/blog/2026/weewx-interceptor-isolated-weather-station/</guid><description>A Vevor weather station that can only upload to Weather Underground, a Raspberry Pi 3 pretending to be wunderground.com on its own isolated wifi network, and weewx-interceptor catching the data. Plus the WPA2 handshake that failed nine times out of ten, and the console clock that was an hour fast.</description><content:encoded><![CDATA[<p>I bought a Vevor weather station. It&rsquo;s a rebadged Fine Offset unit, and its
wifi console (firmware 1.0.1) can send readings to exactly two places: Weather
Underground and WeatherCloud. The setup page has no custom server field and no
port field. You get those two cloud services or nothing.</p>
<p>I wanted the data on my own hardware, recorded by <a href="https://weewx.com/">weewx</a>,
and I didn&rsquo;t want an inexpensive IoT console on my LAN or talking to the
internet at all.</p>
<p>The end state: a Raspberry Pi 3 (<code>pi3-1</code>) runs its own WPA2 access point just
for the station. On that network the Pi&rsquo;s DNS server says it <em>is</em>
<code>wunderground.com</code>. The station uploads to the Pi, thinking it&rsquo;s Weather
Underground, and
<a href="https://github.com/matthewwall/weewx-interceptor">weewx-interceptor</a> parses the
upload and hands it to weewx. The station can&rsquo;t reach the LAN or the internet,
and the weewx reports are served to the LAN by nginx.</p>
<h2 id="how-it-fits-together">How it fits together</h2>
<pre tabindex="0"><code>Vevor console --wifi (WPA2, SSID weewx-ap)--&gt; Pi wlan0 192.168.5.1
  DHCP + DNS: dnsmasq
    wunderground.com -&gt; 192.168.5.1
    every other name -&gt; NXDOMAIN, nothing forwarded upstream
  HTTP to port 80 --nftables redirect--&gt; :8090 weewx-interceptor (wu-client)
    --&gt; weewxd --&gt; SQLite archive
  weewx reports --&gt; /var/www/weewx --&gt; nginx on port 80 (eth0, LAN)
  IP forwarding off + nftables drops all forwarding to/from wlan0
</code></pre><p>The station sends a Weather Underground protocol request, a plain HTTP GET to
<code>/weatherstation/updateweatherstation.php?ID=...&amp;tempf=...&amp;humidity=...</code>, and
the interceptor&rsquo;s <code>wu-client</code> mode already understands that format. Getting
the station to send that request to the Pi only takes a DNS lie and a port
redirect.</p>
<h2 id="hardware-and-host">Hardware and host</h2>
<ul>
<li>Raspberry Pi 3B, Raspberry Pi OS based on Debian 13 (trixie), wired to the
LAN on <code>eth0</code></li>
<li>The Pi 3&rsquo;s built-in BCM43430 wifi (<code>brcmfmac</code> driver, 2.4 GHz only) as the
access point on <code>wlan0</code></li>
<li>Vevor weather station console with a BL602 wifi chip</li>
</ul>
<p>The Pi already runs another service that uses port 8080, which is why the
interceptor listens on 8090 here.</p>
<h2 id="1-base-system">1. Base system</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">apt-get update <span class="o">&amp;&amp;</span> apt-get install -y git hostapd dnsmasq nginx
</span></span><span class="line"><span class="cl">timedatectl set-timezone America/Los_Angeles
</span></span><span class="line"><span class="cl">raspi-config nonint do_wifi_country US
</span></span><span class="line"><span class="cl">rfkill unblock wifi
</span></span></code></pre></div><h2 id="2-weewx-and-the-interceptor">2. weewx and the interceptor</h2>
<p>I installed weewx 5.5 with the git method, in a Python venv. <strong>Do this as your
normal user, not root.</strong> <code>weectl station create</code> writes the current user and
the venv&rsquo;s Python path into the systemd unit it generates.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">python3 -m venv ~/weewx-venv
</span></span><span class="line"><span class="cl"><span class="nb">source</span> ~/weewx-venv/bin/activate
</span></span><span class="line"><span class="cl">python3 -m pip install --upgrade pip
</span></span><span class="line"><span class="cl">python3 -m pip install CT3 configobj Pillow ephem
</span></span><span class="line"><span class="cl">git clone https://github.com/weewx/weewx ~/weewx
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">python3 ~/weewx/src/weectl.py station create --no-prompt <span class="se">\
</span></span></span><span class="line"><span class="cl">  --driver<span class="o">=</span>weewx.drivers.simulator --location<span class="o">=</span><span class="s2">&#34;Rocklin, CA&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --latitude<span class="o">=</span>&lt;lat&gt; --longitude<span class="o">=</span>&lt;lon&gt; --altitude<span class="o">=</span>&lt;alt&gt;,foot --units<span class="o">=</span>us
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">python3 ~/weewx/src/weectl.py extension install --yes <span class="se">\
</span></span></span><span class="line"><span class="cl">  https://github.com/matthewwall/weewx-interceptor/archive/master.zip
</span></span></code></pre></div><p>The simulator driver is only there so <code>station create</code> has something to
configure. It gets replaced in the next step. Then, as root, install the
service:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sh ~&lt;user&gt;/weewx-data/scripts/setup-daemon.sh
</span></span></code></pre></div><h2 id="3-weewxconf">3. weewx.conf</h2>
<p>The relevant changes in <code>~/weewx-data/weewx.conf</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Station]</span>
</span></span><span class="line"><span class="cl">    <span class="na">station_type</span> <span class="o">=</span> <span class="s">Interceptor</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Interceptor]</span>
</span></span><span class="line"><span class="cl">    <span class="na">driver</span> <span class="o">=</span> <span class="s">user.interceptor
</span></span></span><span class="line"><span class="cl"><span class="s">    device_type = wu-client
</span></span></span><span class="line"><span class="cl"><span class="s">    port = 8090</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[StdReport]</span>
</span></span><span class="line"><span class="cl">    <span class="na">HTML_ROOT</span> <span class="o">=</span> <span class="s">/var/www/weewx</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[StdArchive]</span>
</span></span><span class="line"><span class="cl">    <span class="na">archive_interval</span> <span class="o">=</span> <span class="s">300
</span></span></span><span class="line"><span class="cl"><span class="s">    record_generation = software</span>
</span></span></code></pre></div><p>Two notes:</p>
<ul>
<li><strong><code>record_generation</code> must be <code>software</code>.</strong> The interceptor has no hardware
archive memory to read records from. With <code>hardware</code>, weewx starts up and
logs loop packets, but it never saves an archive record.</li>
<li><code>HTML_ROOT</code> points outside the home directory, at a directory the weewx user
owns. Home directories on this Pi are mode 700, so nginx can&rsquo;t read the
default <code>~/weewx-data/public_html</code>. The smartphone and mobile sub-reports
need their <code>HTML_ROOT</code> moved under <code>/var/www/weewx</code> too.</li>
</ul>
<h2 id="4-patching-the-interceptor">4. Patching the interceptor</h2>
<p>Version 0.60 of the interceptor needed four small fixes for this station, all
in <code>~/weewx-data/bin/user/interceptor.py</code>. Keep a copy of the original, because
reinstalling the extension overwrites your patches.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-diff" data-lang="diff"><span class="line"><span class="cl"><span class="gu">@@ do_POST
</span></span></span><span class="line"><span class="cl"><span class="gd">-            data = str(self.rfile.read(length))
</span></span></span><span class="line"><span class="cl"><span class="gi">+            data = _bytes_to_str(self.rfile.read(length))
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">@@ WUClient LABEL_MAP
</span></span></span><span class="line"><span class="cl">             &#39;solarradiation&#39;: &#39;solar_radiation&#39;,
</span></span><span class="line"><span class="cl"><span class="gi">+            &#39;solarRadiation&#39;: &#39;solar_radiation&#39;,
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">@@ WUClient IGNORED_LABELS
</span></span></span><span class="line"><span class="cl"><span class="gi">+            &#39;rainin&#39;,
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">@@ WUClient.Parser.parse
</span></span></span><span class="line"><span class="cl"><span class="gd">-                pkt[&#39;dateTime&#39;] = self.decode_datetime(
</span></span></span><span class="line"><span class="cl"><span class="gd">-                    data.pop(&#39;dateutc&#39;, int(time.time() + 0.5)))
</span></span></span><span class="line"><span class="cl"><span class="gi">+                # Console clock is 1 h fast (no DST) and has no NTP: use the Pi clock
</span></span></span><span class="line"><span class="cl"><span class="gi">+                data.pop(&#39;dateutc&#39;, None)
</span></span></span><span class="line"><span class="cl"><span class="gi">+                pkt[&#39;dateTime&#39;] = int(time.time() + 0.5)
</span></span></span></code></pre></div><p>What each one fixes:</p>
<ol>
<li><strong>Python 3 bytes bug.</strong> <code>str()</code> on a <code>bytes</code> object gives you the string
<code>&quot;b'...'&quot;</code>, not the decoded body. The parsed values then fail with
<code>could not convert string to float</code>. The module already has a
<code>_bytes_to_str</code> helper. It just wasn&rsquo;t used here.</li>
<li><strong>camelCase label.</strong> The Vevor sends <code>solarRadiation</code> where the interceptor
expects <code>solarradiation</code>. Without the extra mapping, the log fills with
&ldquo;unrecognized parameter&rdquo; warnings and solar radiation is never recorded.</li>
<li><strong>Rain.</strong> The station sends both <code>rainin</code> (rain in the last hour) and
<code>dailyrainin</code>. weewx works out rain per interval from the running daily
total, so <code>rainin</code> is ignored to avoid counting the same rain twice.</li>
<li><strong>Timestamps.</strong> The console&rsquo;s clock was an hour ahead. It doesn&rsquo;t handle
daylight saving time, and on an isolated network it can&rsquo;t reach an NTP
server to correct itself. Records were being stamped an hour in the future.
The Pi&rsquo;s clock is correct, so the patch ignores <code>dateutc</code> and stamps each
packet when it arrives.</li>
</ol>
<h2 id="5-the-access-point-hostapd-not-networkmanager">5. The access point: hostapd, not NetworkManager</h2>
<p>This part took the most time.</p>
<p>Raspberry Pi OS uses NetworkManager, and NetworkManager can run a wifi hotspot.
An <em>open</em> hotspot worked fine. A WPA2 hotspot never completed a handshake with
any client, not just the weather station. A kernel and firmware upgrade didn&rsquo;t
change that. Switching to plain <code>hostapd</code> got WPA2 working.</p>
<p>First, tell NetworkManager to leave <code>wlan0</code> alone
(<code>/etc/NetworkManager/conf.d/99-weewx-ap.conf</code>):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[keyfile]</span>
</span></span><span class="line"><span class="cl"><span class="na">unmanaged-devices</span><span class="o">=</span><span class="s">interface-name:wlan0</span>
</span></span></code></pre></div><p>Then <code>/etc/hostapd/hostapd.conf</code>:</p>
<pre tabindex="0"><code>interface=wlan0
driver=nl80211
ssid=weewx-ap
country_code=US
ieee80211d=1
hw_mode=g
channel=6
ieee80211n=1
wmm_enabled=1
macaddr_acl=0
auth_algs=1
ignore_broadcast_ssid=0
wpa=2
wpa_key_mgmt=WPA-PSK
wpa_pairwise=CCMP
rsn_pairwise=CCMP
ieee80211w=0
wpa_passphrase=&lt;your passphrase&gt;
eapol_version=1
wpa_pairwise_update_count=10
</code></pre><h3 id="the-slow-handshake">The slow handshake</h3>
<p>Even with hostapd and the default settings, the weather station failed to
join about nine times out of ten. With debug logging on, hostapd showed the reason:</p>
<pre tabindex="0"><code>WPA: PTKSTART: Retry limit 4 reached
</code></pre><p>The station&rsquo;s BL602 wifi chip is slow to answer the WPA2 4-way handshake.
hostapd sends message 1, waits, resends, and gives up before the station
replies. The last two lines of the config fix it:</p>
<ul>
<li><code>wpa_pairwise_update_count=10</code> lets hostapd resend handshake messages up to
10 times instead of 4, so a slow client has time to answer.</li>
<li><code>eapol_version=1</code> makes hostapd use the older EAPOL version, which some
embedded wifi stacks handle better.</li>
</ul>
<p>After that change, the station joined on the first try every time. If you
turned up hostapd&rsquo;s logging to debug this (<code>logger_syslog_level=0</code>), turn it
back down afterwards.</p>
<h2 id="6-dhcp-and-the-dns-lie">6. DHCP and the DNS lie</h2>
<p><code>/etc/dnsmasq.d/weewx-ap.conf</code>:</p>
<pre tabindex="0"><code>interface=wlan0
bind-interfaces
except-interface=lo
dhcp-range=192.168.5.10,192.168.5.254,1h
dhcp-option=option:router,192.168.5.1
dhcp-option=option:dns-server,192.168.5.1

# Never forward upstream. Only wunderground.com resolves, to this Pi.
no-resolv
no-poll
address=/wunderground.com/192.168.5.1
address=/#/
</code></pre><p><code>address=/wunderground.com/...</code> covers the domain and all its subdomains, so
<code>rtupdate.wunderground.com</code> and <code>weatherstation.wunderground.com</code> both resolve
to the Pi. <code>address=/#/</code> with no address answers every other name with
NXDOMAIN. <code>no-resolv</code> means dnsmasq never asks an upstream server, so the
station can&rsquo;t learn a real address for anything.</p>
<p>I used 192.168.5.0/24 for the AP subnet because the console&rsquo;s own setup hotspot
uses 192.168.4.1, and I didn&rsquo;t want the two to overlap.</p>
<h2 id="7-redirect-and-isolation-firewall">7. Redirect and isolation firewall</h2>
<p>A small script sets the address on <code>wlan0</code> and loads an nftables table, at
<code>/usr/local/sbin/weewx-ap-net</code> (mode 755):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="cp">#!/bin/sh
</span></span></span><span class="line"><span class="cl"><span class="nb">set</span> -e
</span></span><span class="line"><span class="cl">ip link <span class="nb">set</span> wlan0 up
</span></span><span class="line"><span class="cl">ip addr replace 192.168.5.1/24 dev wlan0
</span></span><span class="line"><span class="cl">nft delete table inet weewx 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl">nft -f - <span class="s">&lt;&lt;&#39;NFT&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">table inet weewx {
</span></span></span><span class="line"><span class="cl"><span class="s">	chain prerouting {
</span></span></span><span class="line"><span class="cl"><span class="s">		type nat hook prerouting priority dstnat; policy accept;
</span></span></span><span class="line"><span class="cl"><span class="s">		iifname &#34;wlan0&#34; tcp dport 80 counter redirect to :8090
</span></span></span><span class="line"><span class="cl"><span class="s">		iifname &#34;wlan0&#34; tcp dport 443 counter
</span></span></span><span class="line"><span class="cl"><span class="s">	}
</span></span></span><span class="line"><span class="cl"><span class="s">	chain input {
</span></span></span><span class="line"><span class="cl"><span class="s">		type filter hook input priority filter; policy accept;
</span></span></span><span class="line"><span class="cl"><span class="s">		iifname != &#34;wlan0&#34; accept
</span></span></span><span class="line"><span class="cl"><span class="s">		ct state established,related accept
</span></span></span><span class="line"><span class="cl"><span class="s">		udp dport 67 accept
</span></span></span><span class="line"><span class="cl"><span class="s">		meta l4proto { tcp, udp } th dport 53 accept
</span></span></span><span class="line"><span class="cl"><span class="s">		tcp dport 8090 accept
</span></span></span><span class="line"><span class="cl"><span class="s">		icmp type echo-request accept
</span></span></span><span class="line"><span class="cl"><span class="s">		counter drop
</span></span></span><span class="line"><span class="cl"><span class="s">	}
</span></span></span><span class="line"><span class="cl"><span class="s">	chain forward {
</span></span></span><span class="line"><span class="cl"><span class="s">		type filter hook forward priority filter; policy accept;
</span></span></span><span class="line"><span class="cl"><span class="s">		iifname &#34;wlan0&#34; counter drop
</span></span></span><span class="line"><span class="cl"><span class="s">		oifname &#34;wlan0&#34; counter drop
</span></span></span><span class="line"><span class="cl"><span class="s">	}
</span></span></span><span class="line"><span class="cl"><span class="s">}
</span></span></span><span class="line"><span class="cl"><span class="s">NFT</span>
</span></span></code></pre></div><ul>
<li><strong>prerouting</strong> sends the station&rsquo;s HTTP requests to the interceptor. The
port 443 rule only counts packets, so you can see whether the station ever
tries HTTPS.</li>
<li><strong>input</strong> lets wifi clients reach DHCP, DNS, the interceptor and ping, and
nothing else on the Pi. SSH and nginx can&rsquo;t be reached from <code>wlan0</code>.</li>
<li><strong>forward</strong> drops all traffic to or from <code>wlan0</code>. IP forwarding is also
left <strong>off</strong> (no <code>net.ipv4.ip_forward=1</code>, no masquerade), so the
isolation doesn&rsquo;t depend on a single setting.</li>
</ul>
<p>A oneshot unit runs the script at boot
(<code>/etc/systemd/system/weewx-ap-net.service</code>):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">weewx AP: wlan0 address, port redirect and isolation firewall</span>
</span></span><span class="line"><span class="cl"><span class="na">Before</span><span class="o">=</span><span class="s">hostapd.service dnsmasq.service</span>
</span></span><span class="line"><span class="cl"><span class="na">After</span><span class="o">=</span><span class="s">NetworkManager.service</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">oneshot</span>
</span></span><span class="line"><span class="cl"><span class="na">RemainAfterExit</span><span class="o">=</span><span class="s">yes</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/local/sbin/weewx-ap-net</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Install]</span>
</span></span><span class="line"><span class="cl"><span class="na">WantedBy</span><span class="o">=</span><span class="s">multi-user.target</span>
</span></span></code></pre></div><p>hostapd and dnsmasq each get a drop-in
(<code>/etc/systemd/system/hostapd.service.d/weewx-ap.conf</code> and the same path for
<code>dnsmasq.service.d</code>):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">After</span><span class="o">=</span><span class="s">weewx-ap-net.service</span>
</span></span><span class="line"><span class="cl"><span class="na">Wants</span><span class="o">=</span><span class="s">weewx-ap-net.service</span>
</span></span></code></pre></div><p>Use <code>Wants=</code>, not <code>Requires=</code>. With <code>Requires=</code>, restarting <code>weewx-ap-net</code> to
reload the firewall also restarts hostapd, and the station is kicked off the
network.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">systemctl daemon-reload
</span></span><span class="line"><span class="cl">systemctl unmask hostapd
</span></span><span class="line"><span class="cl">systemctl <span class="nb">enable</span> --now weewx-ap-net hostapd dnsmasq
</span></span></code></pre></div><h2 id="8-serving-the-reports">8. Serving the reports</h2>
<p><code>/etc/nginx/sites-available/weewx</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="k">server</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kn">listen</span> <span class="mi">80</span> <span class="s">default_server</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">listen</span> <span class="s">[::]:80</span> <span class="s">default_server</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">server_name</span> <span class="s">_</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">root</span> <span class="s">/var/www/weewx</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">index</span> <span class="s">index.html</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">location</span> <span class="s">/</span> <span class="p">{</span> <span class="kn">try_files</span> <span class="nv">$uri</span> <span class="nv">$uri/</span> <span class="p">=</span><span class="mi">404</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">rm /etc/nginx/sites-enabled/default
</span></span><span class="line"><span class="cl">ln -s /etc/nginx/sites-available/weewx /etc/nginx/sites-enabled/weewx
</span></span><span class="line"><span class="cl">systemctl reload nginx
</span></span></code></pre></div><p>LAN clients get the weewx Seasons report at <code>http://&lt;pi-address&gt;/</code>. Devices on
the station&rsquo;s wifi can&rsquo;t load it, because port 80 on <code>wlan0</code> goes to the
interceptor. That&rsquo;s fine, since the station is the only thing on that network.</p>
<h2 id="9-pointing-the-console-at-it">9. Pointing the console at it</h2>
<p>On the Vevor console&rsquo;s setup page, join the <code>weewx-ap</code> network and enable
Weather Underground with any station ID and key (<code>1234</code> works, since the
interceptor doesn&rsquo;t check them). WeatherCloud can stay enabled. Its DNS
lookups get NXDOMAIN and it quietly fails.</p>
<h2 id="checking-that-it-works">Checking that it works</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">iw dev wlan0 station dump                 <span class="c1"># station is associated</span>
</span></span><span class="line"><span class="cl">cat /var/lib/misc/dnsmasq.leases          <span class="c1"># and got a 192.168.5.x lease</span>
</span></span><span class="line"><span class="cl">journalctl -u hostapd <span class="p">|</span> grep <span class="s2">&#34;handshake completed&#34;</span>
</span></span><span class="line"><span class="cl">nft list table inet weewx                 <span class="c1"># port-80 counter rising, drop counters at 0</span>
</span></span><span class="line"><span class="cl">journalctl -u weewx -f                    <span class="c1"># &#34;Added record ...&#34; every 5 minutes</span>
</span></span><span class="line"><span class="cl">curl http://&lt;pi-address&gt;/                 <span class="c1"># weewx Seasons page</span>
</span></span></code></pre></div><p>In <code>nft list table inet weewx</code>, the port 80 redirect counter should go up with
every upload. The forward drop counters should stay at zero. If they start
climbing, the station is trying to reach something other than &ldquo;Weather
Underground&rdquo;.</p>
<h2 id="gotchas-collected">Gotchas, collected</h2>
<ul>
<li><strong>NetworkManager&rsquo;s WPA2 hotspot on the Pi 3 (brcmfmac) never completed a
handshake.</strong> An open hotspot worked. hostapd fixed it.</li>
<li><strong>The BL602 chip answers the handshake slowly.</strong> Set
<code>wpa_pairwise_update_count=10</code> and <code>eapol_version=1</code>, or expect &ldquo;Retry limit
4 reached&rdquo;.</li>
<li><strong>The interceptor&rsquo;s POST handling has a Python 3 bug</strong>, which shows up as
<code>could not convert string to float</code>.</li>
<li><strong><code>record_generation = hardware</code> saves no records</strong> with the interceptor.
Use <code>software</code>.</li>
<li><strong>The console clock is an hour fast</strong> and can&rsquo;t sync on an isolated
network, so use the Pi&rsquo;s clock for timestamps.</li>
<li><strong>Don&rsquo;t overlap the console&rsquo;s setup subnet</strong> (192.168.4.x).</li>
<li><strong><code>sqlite3</code> isn&rsquo;t installed by default.</strong> Query the archive with the venv&rsquo;s
Python <code>sqlite3</code> module instead of installing the CLI.</li>
<li><strong>Run git in <code>~/weewx</code> as the weewx user.</strong> As root, git refuses with a
&ldquo;dubious ownership&rdquo; error.</li>
</ul>
<p>The station now posts its &ldquo;Weather Underground&rdquo; updates to a Pi in the same
house. The station has no idea, and Weather Underground never hears from it.</p>
]]></content:encoded></item><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>