A greedy wildcard, and why Unbound sits behind AdGuard
/ 10 min read
Table of Contents
In part 1 I set up Cloudflare DDNS and UniFi’s Alternate Address so my WireGuard clients connect to vpn.huislab.app instead of a hardcoded IP. Phone on cellular: handshake in seconds. Job done, I thought.
Then I tried the travel router. Same fresh config, same hostname — stuck on “connecting” forever. No handshake, no error, nothing.
The only difference: the travel router was sitting at home, on my own Wi-Fi.
This post is the rabbit hole that followed. Spoiler: nothing was broken. Two things were each doing exactly what I’d told them to do — they just disagreed about what vpn.huislab.app means.
The one-line diagnosis
When a hostname-based connection hangs, the first question is always: what does that hostname actually resolve to from where the client is standing?
$ dig vpn.huislab.app +short192.168.1.155Wait, what? That’s not my WAN IP. That’s my internal reverse proxy — the box running Nginx Proxy Manager. And for comparison, what does the rest of the world see?
$ dig @1.1.1.1 vpn.huislab.app +short203.0.113.10 # my actual WAN IP — correctSame name, two completely different answers depending on who you ask. Cloudflare (public) says my WAN IP, my own resolver (internal) says the proxy box. The travel router, sitting at home, asked the internal resolver — got the proxy — and dutifully fired WireGuard handshakes at a box that has no idea what WireGuard is. Hence: “connecting” until the heat death of the universe.
This pattern has a name: split-horizon DNS. One domain, two views — an internal one and a public one. I’d been running it for ages without ever calling it that.
flowchart TB Q["dig vpn.huislab.app<br/><span style='font-size:0.8em'>same name, asked from two places</span>"] subgraph home["🏠 asked from inside the house"] direction TB AG["AdGuard · rewrite *.huislab.app → .155"] PROXY["💀 192.168.1.155 · reverse proxy<br/><span style='font-size:0.8em'>no idea what WireGuard is → 'connecting…' forever</span>"] AG -->|"wildcard swallows vpn"| PROXY end subgraph world["🌐 asked from anywhere else"] direction TB CF["Cloudflare · public DNS"] WAN["✅ 203.0.113.10 · real WAN IP<br/><span style='font-size:0.8em'>handshake lands</span>"] CF --> WAN end Q -.->|"internal resolver"| home Q ==>|"public resolver"| world classDef bad stroke:#e06c75,stroke-width:2px,stroke-dasharray:6 4,fill:transparent; classDef good stroke:#98c379,stroke-width:2px,fill:transparent; class home bad; class world good;
How I’d built this trap for myself
Like a lot of homelabs, mine runs everything behind a wildcard. In AdGuard Home (my network-wide DNS), there was a single DNS rewrite:
*.huislab.app → 192.168.1.155Every internal service name — hass.huislab.app, grafana.huislab.app, z2m.huislab.app — resolves to the reverse proxy, NPM routes by hostname, TLS via Cloudflare DNS challenges, lovely. None of these names exist in public DNS, which is deliberate: internal service names don’t need to be advertised to the internet.
The problem: a wildcard doesn’t know which names you meant. The moment I created vpn.huislab.app as a genuinely public name (part 1), the wildcard swallowed it internally. Outside the house, Cloudflare answered with the WAN IP. Inside, AdGuard answered with the proxy. Both technically correct. One of them useless.
The phone had been masking this the whole time: its config predated the hostname change and still carried the literal WAN IP as its endpoint. Connecting by IP doesn’t ask DNS anything — so it worked everywhere, and the broken internal view stayed invisible until the first client actually used the hostname from inside.
The quick fix (and its expiry date)
AdGuard resolves the most specific rewrite first, so adding an exact entry above the wildcard works:
vpn.huislab.app → 203.0.113.10Internal clients now get the WAN IP, hairpin NAT loops the traffic back through the gateway, handshake lands. (Worth knowing: hairpin NAT — reaching your own public IP from inside — works out of the box on UniFi gateways. Not every router does this.)
Tested, worked, travel router connected. Done?
Not really. Look at that entry again: it’s a static IP — the exact thing part 1 was about eliminating. Cloudflare’s record self-updates via DDNS when my ISP rotates the address. This AdGuard entry doesn’t. The day the IP changes, remote clients heal automatically and everyone at home silently points at a dead address. I’d have rebuilt the original problem, but only for half my network, and in the half where I’d take longest to notice.
The detour: automating the wrong thing
My first instinct was the classic homelab move: script it. A cron job on the AdGuard VM that asks 1.1.1.1 what vpn.huislab.app currently resolves to (Cloudflare is the source of truth — DDNS already maintains it) and syncs AdGuard’s rewrite through its API whenever they drift. I actually wrote it. It would have worked fine.
But it nagged at me. The script existed only to paper over a missing feature: what I actually wanted to tell my resolver was
“wildcard everything under
huislab.app… exceptvpn, which should resolve normally.”
And AdGuard Home simply cannot express that:
- Its wildcards are multi-level —
*.huislab.appmatchesvpn.huislab.appbut alsovpn.ext.huislab.app,anything.deeper.huislab.app, all the way down. You can’t escape a wildcard by nesting the name deeper. (I checked. Standard DNS wildcard semantics work the same way.) - Rewrites can only add answers. There’s no rewrite that means “don’t rewrite this one, go upstream.” Wildcard exceptions have been an open AdGuard feature request since 2020.
So the choice was: keep a cron job forever compensating for my resolver, or use a resolver that can express the rule. Easy call.
Unbound behind AdGuard
Unbound is a small, battle-hardened recursive DNS resolver. No GUI, no ad-blocking — just a resolver with a precise configuration language. And its local-zone model has exactly the precedence behaviour AdGuard lacks.
The architecture: AdGuard stays the front door — clients keep pointing at it, it keeps doing filtering, per-client stats, and the dashboard. But its only upstream becomes a local Unbound instance, which owns all the huislab.app zone logic and does full DNSSEC-validating recursion for everything else.
clients → AdGuard (filtering) → Unbound (zone logic + recursion) → the internetThe entire split-horizon configuration is this:
# Wildcard: every name under huislab.app -> the reverse proxylocal-zone: "huislab.app." redirectlocal-data: "huislab.app. IN A 192.168.1.155"
# Exception: vpn resolves normally -> Cloudflare -> current WAN IPlocal-zone: "vpn.huislab.app." transparentThat’s it. redirect answers every name in the zone with the proxy address — the wildcard. transparent with no local data means “I know nothing about this name, resolve it upstream like any other.” Unbound always matches the most specific zone, so the vpn exception beats the wildcard. The exact rule AdGuard couldn’t express, in four lines.
flowchart TB
IN["query for *.huislab.app"]
IN --> MATCH{"most-specific<br/>local-zone match"}
MATCH -->|"vpn / blog<br/>transparent"| REC["recurse upstream<br/><span style='font-size:0.8em'>→ Cloudflare → current WAN IP / blog</span>"]
MATCH -->|"everything else<br/>redirect"| WILD["192.168.1.155<br/><span style='font-size:0.8em'>reverse proxy — the wildcard</span>"]
classDef good stroke:#98c379,stroke-width:2px,fill:transparent;
classDef neutral stroke:#5c6370,stroke-width:1px,fill:transparent;
class REC good;
class WILD neutral;
And the part that kills the sync script for good: the exception doesn’t contain an IP at all. Unbound recurses out and gets whatever Cloudflare’s DDNS-maintained record currently says, honouring its ~5-minute TTL. When my ISP rotates the IP, both horizons now self-heal — remote clients via public DNS, home clients via Unbound’s recursion. Nothing to maintain.
Install and cutover
On the AdGuard VM (Debian-based; AdGuard runs in Docker there):
sudo apt update && sudo apt install -y unboundDrop the config into /etc/unbound/unbound.conf.d/huislab.conf. Beyond the four zone lines above, mine binds to the VM’s LAN IP on port 5335 (AdGuard owns :53
), allows queries from localhost + RFC1918 (which covers Docker’s bridge networks — relevant, since a containerised AdGuard reaches Unbound via the host IP, not loopback), disables IPv6 recursion (my ISP gives me none), and adds the usual hardening: qname-minimisation, harden-dnssec-stripped, prefetch. The Debian package wires up the DNSSEC trust anchor on its own.
sudo unbound-checkconf && sudo systemctl restart unbound && sudo systemctl enable unboundTest Unbound directly before touching AdGuard — this is the no-downtime trick. Cut over only after the new layer demonstrably works:
dig @127.0.0.1 -p 5335 hass.huislab.app +short # → 192.168.1.155 (wildcard)dig @127.0.0.1 -p 5335 vpn.huislab.app +short # → my WAN IP (exception!)dig @127.0.0.1 -p 5335 google.com +short # → normal answer (recursion)Three correct answers. Then the cutover, in order:
- AdGuard → Settings → DNS settings → Upstream DNS servers: replace everything with the single line
192.168.1.155:5335. (My fallback servers —9.9.9.9and1.1.1.1— stay. If Unbound ever dies, the internet keeps resolving and only the internal names go dark until I fix it. Best available failure mode.) - Filters → DNS rewrites: delete both the wildcard and the static
vpnentry. This is the step that actually hands control to Unbound — AdGuard answers rewrites locally and never forwards them, so any rewrite left behind keeps intercepting queries before Unbound ever sees them. - Clear AdGuard’s cache, then verify from a normal client.
I learned step 2’s importance the slightly embarrassing way: after switching the upstream, my test digs all passed — and meant nothing, because the rewrites were still answering from AdGuard. Right results, wrong layer. Delete the rewrites, then trust the digs.
The bonus find: my own blog
With the pattern in place, one more thing fell out of the audit. My blog (blog.huislab.app) runs on Cloudflare Workers — a genuinely public name, just like vpn. Which means the wildcard had been hijacking it internally this whole time:
$ dig blog.huislab.app +short192.168.1.155 # my proxy — which knows nothing about the blog$ dig @1.1.1.1 blog.huislab.app +short188.114.96.0 # Cloudflare — the actual blogFrom inside my own house, my own blog didn’t resolve to my blog. The fix is the now familiar one-liner:
local-zone: "blog.huislab.app." transparentRestart Unbound, and inside and outside finally agree. That’s the real win of this design — it’s a pattern, not a patch. Any future public name under the domain is one transparent line away.
Where this leaves the DNS stack
flowchart TB CLIENTS["📱 clients<br/><span style='font-size:0.8em'>DHCP-advertised resolver</span>"] subgraph vm["🏠 AdGuard VM"] direction TB AG["AdGuard Home<br/><span style='font-size:0.8em'>filtering · per-client stats · :53</span>"] subgraph ub["Unbound · :5335 · owns huislab.app"] direction TB Z1["redirect — *.huislab.app<br/><span style='font-size:0.8em'>→ 192.168.1.155 reverse proxy</span>"] Z2["transparent — vpn / blog<br/><span style='font-size:0.8em'>fall through to recursion</span>"] end AG ==>|"single upstream · 192.168.1.155:5335"| ub end CLIENTS --> AG Z2 ==>|"DNSSEC recursion"| NET["🌐 the internet · Cloudflare"] AG -.->|"fallback if Unbound dies · 9.9.9.9 · 1.1.1.1"| NET classDef box fill:transparent,stroke:#5c6370,stroke-width:1px; classDef good stroke:#98c379,stroke-width:2px,fill:transparent; class vm,ub box; class Z2 good;
- AdGuard Home — filtering, per-client stats, DHCP-advertised resolver. Knows nothing about
huislab.appanymore. Single upstream: Unbound. Fallback: public resolvers. - Unbound — owns the zone: wildcard internal names → reverse proxy;
vpnandblogfall through to public resolution; DNSSEC-validating recursion for the rest. - Cloudflare DDNS — keeps
vpn.huislab.appcurrent; both horizons follow it automatically.
No static entries, no sync script, nothing that goes stale when the ISP rotates my IP. The travel router connects from the couch and from a hotspot with the same config — which was the whole point.
TL;DR
- If a hostname works remotely but not at home, compare
dig name +shortagainstdig @1.1.1.1 name +short. Different answers = split-horizon DNS, and your internal view is the suspect. - Wildcard DNS rewrites are multi-level and swallow every name under the domain — including the public ones you add later. You can’t nest your way out of one.
- AdGuard Home can’t do wildcard exceptions (rewrites only add answers; the feature request is years old). Don’t script around a missing primitive — put Unbound behind AdGuard and express the rule properly.
local-zone redirect= wildcard; a more-specificlocal-zone transparent= “resolve this one normally.” Most-specific zone wins.- An exception that recurses instead of pinning an IP makes the internal view self-healing — it always returns whatever DDNS currently publishes.
- Cut over safely: stand up Unbound on an alt port, test it directly, switch AdGuard’s upstream, then delete the old rewrites — and re-test, because leftover rewrites make your verification lie to you.