ADR-0029: Windows split DNS is NRPT, and the resolver moves to port 53 there

  • Status: accepted
  • Date: 2026-08-30

Context

ADR-0021 says only meshp's own names come to meshp: a resolver that captured every query would break split-horizon corporate DNS on the first laptop that joined, and would make meshp answerable for a user's entire name resolution — a much larger promise than the one being made. internal/resolved implements that on Linux through systemd-resolved and on macOS through the SystemConfiguration store, and reports honestly everywhere else.

Windows now has a tunnel (#175) and no names. This is the record of what it takes to give it some, and it is longer than it looked, because the design meshp uses everywhere else does not transfer.

meshp's resolver listens on a high port. cmd/meshpd binds 127.0.0.1:0 and lets the kernel choose, because 53 is usually taken and binding it needs privilege the agent would rather not depend on. Linux and macOS can both be told about a port: macOS carries one in the same dictionary as the server address, which is why #153 was straightforward. The comment that shipped with it said this would not be true everywhere and was worth checking before somebody assumed it. Somebody assumed it.

What Windows actually does

Established on windows-latest — Windows Server 2025, build 10.0.26100 — with a throwaway workflow that is not kept. Four runs, because the first three each answered a different question badly.

NRPT does per-namespace routing, and it works. A rule for .control.meshp.test naming 127.0.0.1, and a query for a name in that namespace arrived at a listener on 127.0.0.1:53. This is the mechanism, and it is the only one on this platform: interface DNS — which is what winipcfg.SetDNS writes — sets nameservers and a search list on an adapter, and Windows then chooses servers by interface metric rather than by domain. That is the capture-everything behaviour ADR-0021 refuses.

A rule is meshp's own and comes off cleanly. Each lives under a GUID of its own at HKLM\SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig\{…}, and removing it leaves nothing behind. Nothing of anybody else's is read, merged or rewritten — the same answer macOS's per-service key gives, and a stronger one than resolvectl revert, which restores previous state rather than never disturbing any.

A port in the nameserver is accepted and silently discarded. Add-DnsClientNrptRule -NameServers "127.0.0.1:15353" succeeded. Reading the rule back shows its nameserver list empty. No query arrived. So the failure is not a refusal — it is a rule that matches the namespace, names no server, and black-holes it, reported as success.

A loopback address that is not 127.0.0.1 is bindable and not reachable. A rule naming 127.0.0.2 stored correctly and a listener bound 127.0.0.2:53, and no query arrived. This one was going to be the design: give meshp's resolver an address of its own so it need not compete for 127.0.0.1:53. A successful bind() proves a socket opened, not that anything will be delivered to it.

Windows reserves blocks of high ports. 127.0.0.1:5353 was refused outright with ERROR_ACCESS_DENIED — WinNAT and Hyper-V take ranges. Even had a port been honoured, asking the kernel for any free one would fail unpredictably on machines that run either.

Decision

Split DNS on Windows is NRPT, one rule per mesh domain, each removed by deleting the rule that created it.

meshp's resolver binds 127.0.0.1:53 on Windows, rather than a port of the kernel's choosing. Nothing else is expressible: the only nameserver Windows honoured is a plain 127.0.0.1, on the port it assumes.

A resolver that cannot get port 53 reports that names will not resolve, and the agent carries on without them. That is what ADR-0021 already requires of a host whose resolver meshp cannot configure safely, and it is the honest answer when something else on the machine holds 53 — Docker Desktop and WSL both can.

Consequences

dns.Server gains a platform-dependent bind address, which it did not have. The change is confined to where cmd/meshpd chooses dnsListenAddr; the server itself does not care what it is given.

Windows is the first platform where meshp competes for a well-known port. On Linux and macOS the agent takes a high port precisely to avoid that fight. This is a real regression in robustness on one platform, accepted because the alternative is no names at all there, and because it fails loudly — a bind error at start-up, reported in meshp status — rather than quietly.

A rule with no nameserver is worse than no rule. Windows will accept one and black-hole the namespace, so what is written must be read back and checked rather than trusted to the call's return value. This is the second time on this platform that a call reported success having done nothing; the first was route(8) on macOS, and the lesson is the same one.

Nothing here needs winipcfg. NRPT is registry work and the dependency added in #175 does not expose it, so this reaches for golang.org/x/sys/windows/registry instead.

Alternatives considered

Interface DNS through winipcfg.SetDNS. One call, already available, and what WireGuard for Windows uses. Rejected because it is not split DNS: Windows picks servers by interface metric, so a tunnel adapter with a nameserver on it takes queries for names that have nothing to do with meshp. WireGuard for Windows can accept that because it also blocks untunnelled DNS outright; meshp's promise is the opposite one.

Give the resolver its own loopback address. Rejected on evidence: 127.0.0.2 was bindable and queries did not arrive. Worth recording as tried, because it is the obvious idea and the obvious idea does not work.

Run the resolver on 53 on the tunnel adapter's own address rather than loopback. Not rejected — untested. It may avoid the competition for 127.0.0.1:53 entirely, and it is the first thing to try if holding 53 on loopback turns out to be a problem in practice. It was not tested because the tunnel's address is assigned by the control plane and the probe had no network to get one from.

Do not implement DNS on Windows. What ships today, and honest: names answer to a direct query and meshp status says so. Rejected because the mechanism has a real undo, which is the bar ADR-0021 sets, and clearing it while declining to build is choosing less capability for no property gained.

Rendered from docs/adr/0029-windows-split-dns-is-nrpt-on-port-53.md, which is where it is edited.