August 13, 2026 · devlog
The control plane answers one DNS zone, and the bench that made it forward the rest
Rasputin’s control plane runs an authoritative nameserver — the server that
holds the real answers — for exactly one family of names, <cluster-id>.internal
(for a cluster named home, that’s home.internal). It’s built into the api on
miekg/dns, the same embed-what-we-own pattern the control plane already uses
for its message bus. Every host has two names: the bare <name>.<cluster-id>.internal
resolves to the host’s tailnet IP — its address on the private mesh network
Rasputin sets up with Headscale — and <name>.lan.<cluster-id>.internal resolves
to its LAN IP, its address on your local network. The subdomain carries the
network, so no name ever resolves to two addresses depending on who is asking.
Answering one family of names and nothing else was the whole design — until a bench run showed where that is not enough.
Where the control plane sits in the DNS path, per mode
The control plane is the authority for .internal in every deployment mode. What
differs is who resolves everything else — the internet — for a client.
- With a Rasputin firewall (Mode A / Mode C): the firewall’s built-in DNS
server (dnsmasq) is the resolver every device is handed over DHCP. It sends
.internalnames to the control plane and everything else out to the internet. The control plane only ever answers.internal, and that is correct here — a control-plane reboot pauses those names, never the rest of the LAN’s internet. The operator does nothing. - With no firewall (Mode B): nothing Rasputin ships sits in the DNS path.
The natural operator move is to point the router’s DHCP-handed DNS at the
control plane — and that black-holed lookups for public websites, because the
control plane only answers its own
.internalnames and nothing else. That is what broke on the bench on 2026-08-09.
Timeline
- 2026-08-08 — the DNS decisions land in ADR-0004: authoritative control-plane
nameserver (embedded
miekg/dns), and one host = two names (bare = tailnet via MagicDNS,.lan.= LAN via the control plane). Each name has exactly one answer everywhere, so there is no one-name-two-addresses case to manage. - 2026-08-09 — Tier-1 validated on the bench (CP
2026.08.2-dev.150): the nameserver binds<LAN-IP>:53UDP+TCP alongside systemd-resolved’s127.0.0.53stub with no port conflict, and answers the zone. - 2026-08-09 — same bench, the Mode B black-hole surfaces, and the fix ships: an optional, guarded forwarding stub on the control plane.
The things worth knowing
The bind is a named LAN interface, not 0.0.0.0. The nameserver cannot take
0.0.0.0:53 and must not displace systemd-resolved’s 127.0.0.53:53 stub — that
stub also publishes the cluster’s <cluster-id>.local mDNS discovery name
(ADR-0003), so “free port 53 by disabling resolved” silently breaks cluster
discovery. It binds the LAN interface by name because the LAN IP moves per DHCP
lease.
Adding the control plane as a “backup” DNS server does not work. A device only
asks its backup DNS server when the first one
fails to answer. Ask a normal resolver for a name like jellyfin.home.internal
and it does not fail — it answers “there is no such name,” because that name only
exists inside Rasputin. That is a real answer, so the device is satisfied and
never asks the backup. So in Mode B the ways to reach an app are: tell your router
to send .internal names to the control plane, reach the app directly at its
node’s address and port (nodeIP:port), or turn on the forwarding stub below.
The forwarding stub is the piece that makes “point the router at the control
plane” work. It answers .internal names itself and passes every other lookup
along to a real internet DNS server — the control plane’s own upstream, the one
it learned from DHCP (1.1.1.1:53 only as a loop-safe fallback, used when it
can’t learn an upstream or the learned one would point back at itself). And it is
fenced. The guards, in api/internal/nameserver/forwarder.go: it listens only on
the LAN interface, only answers devices on a private (RFC1918) network
(isPrivateV4 — and 100.64/10 tailnet space is deliberately not in it),
refuses to forward to itself (upstreamIsSelf), is rate-limited, is IPv4-only
(Rasputin is IPv4-only), and never faces the WAN. It is off by default and the
firewall modes never turn it on. Its hardening rides a later security review.
Rasputin nodes make no DHCP reservations, so the control plane’s IP moves. Nodes
are identified by name, not address — a deliberate onboarding-cost choice — so a
node’s IP changes on most reboots. The nameserver reads the live node list, so
.lan. records fix themselves on the next query. The one address that self-heal
can’t cover is the control plane’s own IP: a client pinned at it as a resolver
goes stale when it moves. So the settings page now shows the control plane’s MAC
address and recommends reserving its IP on the router, next to the forwarder
toggle.
What shipped
The forwarding stub (forwarder.go), the enable toggle and upstream resolution
(dns.forwarding in the settings store, ResolveUpstream picking the
DHCP-learned upstream with the loop-safe fallback), and a NETWORK DNS section
in settings that carries the toggle, the upstream field, and the “point your
router at <CP IP>, reserve it by MAC <CP MAC>” guidance. All of it is off
unless an operator turns it on, and the firewall modes never need it.
This is in CP v2026.08.2 + OS 2026.08.2, cut stable 2026-08-10; it
went out on the dev channel first, which is what the bench runs above ran on.
I want to be clear that this forwarder is deliberately the small version — one zone answered, one upstream forwarded to, fenced to the LAN because a resolver reachable from the WAN is not something I want to ship at pre-alpha. The full opt-in whole-LAN resolver, with ad-blocking and per-household upstream choice, is a separate later feature. The Mode A firewall auto-forward that makes the firewall modes truly zero-touch is still owed before the first stable release, and the forwarder’s hardening is part of a later security-review pass. Expect this to tighten as that review runs.
Rasputin is an open-source (AGPL) homelab cluster system — a control plane, node OS, and firewall image that make a few Raspberry Pis or N100 boxes behave like one appliance. It’s pre-alpha, on GitHub, and I’m looking for a handful of design partners to run it and tell me what’s broken.
Want the next one in your inbox? Subscribe, or grab the RSS feed. Found something broken? We're recruiting design partners.