Skip to content

Serve the Pangolin-fronted zones from local DNS - #428

Merged
ChrisonSimtian merged 1 commit into
mainfrom
feat/static-dns
Aug 16, 2026
Merged

Serve the Pangolin-fronted zones from local DNS#428
ChrisonSimtian merged 1 commit into
mainfrom
feat/static-dns

Conversation

@ChrisonSimtian

Copy link
Copy Markdown
Collaborator

Closes #419. Refs #314.

The problem

*.lab / *.arr / *.iot are grey-cloud A records at Cloudflare pointing at the home WAN IP, so inside the house they resolved to the WAN too:

pulse.lab.chrison.dev      -> 118.67.199.127   (the home WAN IP)
shelfmark.arr.chrison.dev  -> 118.67.199.127

Every admin UI on the LAN was therefore reached by NAT hairpin back through the pangolin-https port-forward. That made the forward load-bearing for LAN access, not just remote access — so ADR-0007's "reversible exit" of closing :443 would have taken the whole admin surface down at home as well.

Three records, not sixteen

#419 estimated ~16 records and asked whether the controller supports wildcards. It does, and they match arbitrary labels — verified against the live gateway, where the Azure Lab's *.topaz.local.dev already answers any label under it. So a new Pangolin resource now needs no DNS work at all:

staticDns:
  - { name: "*.lab.chrison.dev", value: 10.10.0.13 }
  - { name: "*.arr.chrison.dev", value: 10.10.0.13 }
  - { name: "*.iot.chrison.dev", value: 10.10.0.13 }

The mechanism

A staticDns list on the UnifiNetwork shape, reconciled by converge-unifi against the v2 site API (a different surface from the legacy one — UnifiSharp#18 added the client). Add-only and drift-correcting, like the rest: the 17 records belonging to other things are listed and left alone.

Three decisions worth reviewing:

  • Matched by name and type. An A and a CNAME for one name are distinct rows on the controller; matching on name alone would make converge try to turn one into the other.
  • TTL is only claimed when the shape states one — otherwise every run rewrites the controller's default forever.
  • An update carries the live record's untouched fields forward. v2 rejects partial updates, so a full replacement rebuilt from defaults would silently reset fields the shape says nothing about. That's the specific hazard of a replace-only endpoint, and it's tested.

Deliberately not overridden

  • pangolin.chrison.dev — orange-clouded, rides the core CF tunnel. Every resource bounces through it for the SSO gate; pointing it at Traefik locally would take that redirect off the tunnel serving it.
  • vpn.chrison.dev — also a WAN A record, but a VPN endpoint must resolve to the WAN address from inside the house too, or the client dials its own LAN.
  • Everything else in the zone is an orange-cloud CNAME to a tunnel; a local override would bypass Cloudflare Access on the break-glass hostnames.

Verified live

Applied, then checked:

pulse.lab.chrison.dev       -> 10.10.0.13     shelfmark.arr.chrison.dev -> 10.10.0.13
traefik.lab.chrison.dev     -> 10.10.0.13     brandnew.iot.chrison.dev  -> 10.10.0.13   (wildcard, new label)

pangolin.chrison.dev  -> 104.21.65.197 (Cloudflare, unchanged)
vpn.chrison.dev       -> 118.67.199.127 (WAN, unchanged)

Against #419's acceptance criteria:

  • the three zones resolve to 10.10.0.13 from inside the house
  • each UI loads on the LAN — all three answer 302 to the SSO gate, over the direct path
  • Pangolin's SSO redirect target still resolves internally (pangolin.chrison.dev is untouched and still Cloudflare)
  • Let's Encrypt unaffected — DNS-01 needs no inbound, and the LE cert for pulse.lab.chrison.dev is served directly by Traefik on the LAN path

:443 is not being closed here — that stays open per the 2026-08-16 decision. This removes the reason it was load-bearing for LAN access, so closing it becomes a decision about remote access alone.

A second run reports Everything declared is present and matching — nothing to do. 308 tests pass (14 new).

🤖 Generated with Claude Code

*.lab / *.arr / *.iot are grey-cloud A records at Cloudflare pointing to the home WAN
IP, so inside the house they resolved to the WAN too and every admin UI was reached by
NAT hairpin back through the pangolin-https port-forward. That made the forward
load-bearing for LAN access rather than only for remote access, so ADR-0007's
"reversible exit" of closing :443 would have taken the whole admin surface down at home
as well. Three controller-local records decouple the two.

Wildcards, not the ~16 individual records #419 estimated: the controller supports them
and matches arbitrary labels, which was verified against the live gateway (the Azure
Lab's *.topaz.local.dev already answers any label under it). A new Pangolin resource now
needs no DNS work at all.

The mechanism is a new `staticDns` list on the UnifiNetwork shape, reconciled by
converge-unifi against the v2 site API. Add-only and drift-correcting, like the rest: a
record that exists but answers differently is corrected, and the 17 records belonging to
other things are listed and left alone.

Matching is by name AND type, because an A and a CNAME for one name are distinct rows —
matching on name alone would make converge try to turn one into the other. TTL is only
claimed when the shape states one, or every run would rewrite the controller's default
forever. An update carries the live record's untouched fields forward: v2 rejects partial
updates, so a full replacement rebuilt from defaults would silently reset fields the
shape says nothing about.

Deliberately NOT overridden: pangolin.chrison.dev, which is orange-clouded and rides the
core CF tunnel that serves the SSO redirect every resource bounces through; and
vpn.chrison.dev, which has to resolve to the WAN address from inside the house too or the
client dials its own LAN.

Verified live. All three zones resolve to 10.10.0.13 including a brand-new label; the UIs
answer 302 to the SSO gate; that gate still resolves via Cloudflare; the Let's Encrypt
cert is served directly by Traefik on the LAN path; and a second run reports no changes.

Closes #419. Refs #314 — this codifies the UniFi half; pairing a record with a Pangolin
resource declaration is the remaining part.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ChrisonSimtian
ChrisonSimtian merged commit 4542278 into main Aug 16, 2026
6 checks passed
@ChrisonSimtian
ChrisonSimtian deleted the feat/static-dns branch August 16, 2026 09:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Serve the Pangolin-fronted zones from local DNS so LAN access stops depending on NAT hairpin

1 participant