Today started as a chore and turned into a small security finding hiding in plain sight.
The chore: add two VLANs to the network. VLAN 70 for Plex media traffic, VLAN 200 for a site-two disaster-recovery segment. Both needed to route their DNS through the right ControlD profile — ctrld runs on the UDM Pro and steers each VLAN’s queries to a different filtering policy, so adding a network means adding two stanzas to ctrld.toml, one for IPv4 and one for IPv6. Mechanical. Fifteen minutes, I figured.
Then I opened the existing config to copy a stanza as a template, and the IPv6 section didn’t look the way I remembered leaving it.
The map drifted while I wasn’t looking
Here’s the thing about ctrld.toml: the IPv4 side maps each VLAN to its profile by CIDR, and those CIDRs are stable. 192.168.40.0/24 is the guest network today, it was the guest network last month, it’ll be the guest network forever. Static subnets don’t move.
IPv6 on this network does not work that way. The UDM hands out its delegated /64 prefixes via DHCPv6-PD, and — this is the part I learned the hard way — it assigns them to bridges in interface order, not by any stable identity. So when you add or reorder VLANs in the UniFi controller, the controller renumbers the bridge interfaces, and the prefix delegation gets re-handed down the line. Every /64 shifts by however many interfaces moved in front of it.
Which means the hand-built IPv6-to-profile map in ctrld.toml — the one I’d carefully assembled weeks ago by reading the live prefixes off the router — was now describing a network topology that no longer existed. The labels were right. The addresses underneath them had rotated.
I stopped trusting the file and went to the source of truth:
ssh udm 'ip -6 addr'
Then I sat down and matched every bridge’s actual current /64 against what the config claimed it should be. Of eleven VLANs, three were still pointing IPv6 at the correct ControlD profile. The other eight were routing their v6 DNS through whatever profile now happened to own their old prefix.
The one that mattered
Most of the mismatches were harmless-but-wrong: a VLAN getting a slightly stricter or slightly looser filter than intended, nobody noticing because IPv4 was still correct and most lookups resolve over v4 first anyway.
One of them was not harmless. Guest — VLAN 40 — had its IPv6 traffic landing on the Trusted profile.
Read that again the way I did. The guest network is supposed to get the most restrictive ControlD policy: ad and tracker blocking, no access to internal-looking domains, the works. Instead, any guest device that happened to prefer IPv6 (which, on a modern phone, is most of them) was being handed the trusted-devices filtering policy. A guest-filter bypass. Not because anyone misconfigured the guest VLAN — its IPv4 rule was perfect — but because adding a Plex VLAN three weeks from now would shuffle a /64 that used to be guest’s onto a bridge that ctrld thought was trusted.
Nobody attacked anything. The hole opened itself, as a side effect of normal network housekeeping, and would have stayed open silently because the symptom — “IPv6 DNS goes to the wrong filter” — produces no error, no log line, no alert. It just quietly filters less.
The cleanup
So the fifteen-minute chore became a full remap. I rewrote every IPv6 /64 to match the real bridge prefix from ip -6 addr, not the stale one in the file. Guest went back to its restrictive profile. IOT and the CCTV segment, which had drifted into “unmapped” and were falling through to the Default profile, got pointed back where they belong.
I also deleted a rule I’d written months ago that never made sense in retrospect: a fe80::/10 → Shared_Devices mapping. Link-local addressing is global to every VLAN — every interface on every network has an fe80:: address — so a single link-local-to-one-profile rule was meaningless at best and another silent mis-route at worst. Gone.
Then the two VLANs I actually came here to add: 70 (Plex media) to the Default profile, 200 (site-two DR) to the production profile, both v4 and v6 this time.
Deployment was its own small correction. The runbook I’d left myself said to apply with ctrld reset. That’s wrong — reset is the heavier, more disruptive verb. The right one is ctrld reload, which re-reads the config and re-applies without tearing the service down. I backed up the live config to ctrld.toml.bak-20260618 on the router first, reloaded, confirmed the service stayed up, and verified resolution end to end: A and AAAA records both resolving correctly through dnsmasq on :53 and through ctrld on ::1:5354. Then I fixed the runbook so the next time I do this — and the IPv6 map will drift again the next time VLANs change — the instructions actually match reality, including a one-liner to re-verify the bridge-to-prefix map before trusting the file.
IPv4 CIDRs, for the record, I didn’t touch a single one. They were right the whole time. This was a purely IPv6 failure mode, and that asymmetry is exactly what made it invisible.
Meanwhile, the budget app grew a front porch
The other half of today was the opposite kind of work — additive, planned, no surprises — on OurBudgetTracker, the family trip-budget app. For its whole life, signing in dropped you straight into one trip’s dense dashboard: hero number, category bars, the works. Fine when there’s one trip. Less fine as a home.
So v0.11 builds a proper landing hub. The dense dashboard relocated from / to /trip (a clean file move plus repointing every back-link, redirect, and Drawer action that meant “the trip view” — fourteen files, all swept and test-covered), and / became a calm post-login hub: a friendly greeting by first name, an at-a-glance TripGlanceCard for the active trip, cards for any other trips you’re a member of, and a real empty state for the first-timer with no trips yet. A small pure mapper — tripGlance — turns each trip’s existing summary numbers into the card’s status, and because it’s pure, it’s trivially unit-tested.
One bug worth catching: the glance card showed a per-day allowance, and on an over-budget trip the math went negative — a cheerful little “−$14/day” that makes no sense. The fix was to simply suppress the per-day line when the hero is already over budget. You don’t tell someone their daily allowance is negative; you tell them they’re over, and stop.
The thread connecting both halves
Tonight’s research digest, which I read after all this, had a quiet theme running through it: two CVEs that looked actionable on paper — one for n8n, one for Authentik — turned out to be non-issues the moment I checked the live versions instead of the advisory text. n8n is running far past the fixed release; Authentik likewise. Both were the same false-positive shape as issues I’d already closed.
Which is the same lesson the IPv6 map taught me an hour earlier, wearing different clothes: the document is not the system. The config file said the guest VLAN was filtered. The advisory said n8n was vulnerable. Both were describing a world that the actual running machine had already moved past. The only way to know is to ask the machine — ip -6 addr, ctrld --version, the live thing — and believe it over the paperwork.
I added two VLANs today. I also closed a guest-filter bypass I didn’t know was open. Only one of those was on the to-do list.
