Skip to content

Commit abbecde

Browse files
hhfrancoisclaude
andcommitted
fix(tun): Windows single-label DNS via a search suffix + NRPT (not catch-all)
Windows never issues a DNS query for a BARE single-label name (LLMNR/NetBIOS only), so the NRPT catch-all could never catch it: "Could not resolve host" in real apps, even though the in-stack resolver answered a direct query fine. Advertise a search suffix on plug0 so getaddrinfo turns my-service into my-service.plug (a real DNS query), route ".plug" to the resolver via NRPT, and strip the suffix back in answerDNS. Same mechanism Tailscale/WireGuard use for short names. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ad8051f commit abbecde

2 files changed

Lines changed: 31 additions & 9 deletions

File tree

cli/internal/tun/dns.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ const (
1919
dnsHost = 53 // reserved host byte: this instance's DNS is base|53
2020
)
2121

22+
// searchSuffix is the DNS search suffix plug0 advertises on Windows so the OS
23+
// actually issues a DNS query for a single-label cluster name. Windows resolves a
24+
// BARE name (my-service) via LLMNR/NetBIOS, never DNS — so getaddrinfo never reaches
25+
// our resolver. With this suffix in the search list, getaddrinfo tries
26+
// "my-service.plug"; an NRPT rule routes ".plug" to our resolver, and answerDNS
27+
// strips it back to the bare name. No effect on macOS/Linux (nothing appends it).
28+
const searchSuffix = "plug"
29+
2230
// faketab maps minted fake IPs to cluster names, all within ONE instance's
2331
// 198.18.<N>.0/24. The DNS forwarder mints; the netstack TCP forwarder looks up.
2432
// In-process, shared, mutex-guarded.
@@ -106,6 +114,19 @@ func answerDNS(q []byte, tab *faketab, upstream *net.Resolver) []byte {
106114
case qtype != 1: // non-A → NODATA
107115
case strings.EqualFold(name, "localhost") || strings.HasSuffix(strings.ToLower(name), ".localhost"):
108116
answerIP = net.IPv4(127, 0, 0, 1)
117+
case strings.HasSuffix(strings.ToLower(name), "."+searchSuffix):
118+
// Windows appended plug0's search suffix (my-service → my-service.plug) to
119+
// force a DNS query. Strip it and mint the SAME fake IP as the bare name, so
120+
// the connect maps back to "my-service" for the agent to resolve.
121+
if base := name[:len(name)-len(searchSuffix)-1]; base != "" && !strings.Contains(base, ".") {
122+
if ip := tab.mint(base); ip != 0 {
123+
answerIP = net.IPv4(byte(ip>>24), byte(ip>>16), byte(ip>>8), byte(ip))
124+
} else {
125+
rcode = 3
126+
}
127+
} else {
128+
rcode = 3
129+
}
109130
case !strings.Contains(name, "."): // single-label cluster name → fake
110131
if ip := tab.mint(name); ip != 0 {
111132
answerIP = net.IPv4(byte(ip>>24), byte(ip>>16), byte(ip>>8), byte(ip))

cli/internal/tun/route_windows.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,21 +43,22 @@ func configure(dev any, _, cidr, dnsIP string, log logfn) ([]string, string, fun
4343
if err := luid.AddRoute(netip.MustParsePrefix(cidr), netip.IPv4Unspecified(), 0); err != nil {
4444
return nil, "", func() {}, fmt.Errorf("add %s route: %w", cidr, err)
4545
}
46+
// Advertise the search suffix on plug0 too: Windows won't DNS-query a BARE
47+
// single-label name (LLMNR/NetBIOS only), so getaddrinfo must be nudged to try
48+
// "my-service.<suffix>" — that's what turns it into a real DNS query.
4649
if dns, err := netip.ParseAddr(dnsIP); err == nil {
47-
if e := luid.SetDNS(v4, []netip.Addr{dns}, nil); e != nil {
50+
if e := luid.SetDNS(v4, []netip.Addr{dns}, []string{searchSuffix}); e != nil {
4851
log.f("tun[win]: set DNS: %v", e)
4952
}
5053
}
51-
// Adapter DNS alone doesn't cover SINGLE-LABEL names (my-service): Windows
52-
// devolution + interface ordering skip it, so getaddrinfo("my-service") never
53-
// reaches our resolver ("Could not resolve host"). An NRPT catch-all rule points
54-
// ALL name resolution at dnsIP — the Windows counterpart of the macOS scutil
55-
// repoint. Our in-stack DNS answers cluster names with a fake IP and forwards the
56-
// rest to the saved upstreams, so a machine-wide rule is safe.
54+
// Route that suffix to our resolver via NRPT (the Windows counterpart of the
55+
// macOS scutil repoint; the same mechanism Tailscale/WireGuard use). ".<suffix>"
56+
// is surgical — only "*.<suffix>" goes in-stack, real internet DNS is untouched —
57+
// and answerDNS strips the suffix back to the bare cluster name.
5758
if err := setSystemNRPT(dnsIP); err != nil {
5859
log.f("tun[win]: NRPT repoint failed — single-label names may not resolve: %v", err)
5960
} else {
60-
log.f("tun[win]: system DNS repointed to %s (NRPT catch-all)", dnsIP)
61+
log.f("tun[win]: system DNS repointed — *.%s → %s (NRPT)", searchSuffix, dnsIP)
6162
}
6263

6364
cleanup := func() {
@@ -76,7 +77,7 @@ func configure(dev any, _, cidr, dnsIP string, log logfn) ([]string, string, fun
7677
// the resolver cache so a prior negative answer ("Could not resolve") doesn't stick.
7778
func setSystemNRPT(dnsIP string) error {
7879
clearSystemNRPT(dnsIP) // drop a stale rule a crashed run may have left
79-
return psRun("Add-DnsClientNrptRule -Namespace '.' -NameServers '" + dnsIP + "'; Clear-DnsClientCache")
80+
return psRun("Add-DnsClientNrptRule -Namespace '." + searchSuffix + "' -NameServers '" + dnsIP + "'; Clear-DnsClientCache")
8081
}
8182

8283
// clearSystemNRPT removes the rule(s) pointing at dnsIP and flushes the cache. Keyed

0 commit comments

Comments
 (0)