From 3e915ac78389a946439adbe7abae36979d7bc2e1 Mon Sep 17 00:00:00 2001 From: David Anderson Date: Mon, 5 Apr 2021 21:51:57 -0700 Subject: [PATCH] net/dns: implement OS-level split DNS for Windows. Part of #953. Signed-off-by: David Anderson --- net/dns/manager_windows.go | 214 +++++++++++++++++++++++++++++++++---- 1 file changed, 191 insertions(+), 23 deletions(-) diff --git a/net/dns/manager_windows.go b/net/dns/manager_windows.go index d14fa42ef..c3ab7be11 100644 --- a/net/dns/manager_windows.go +++ b/net/dns/manager_windows.go @@ -12,12 +12,19 @@ import ( "time" "golang.org/x/sys/windows/registry" + "inet.af/netaddr" "tailscale.com/types/logger" ) const ( ipv4RegBase = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters` ipv6RegBase = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters` + + // the GUID is randomly generated. At present, Tailscale installs + // zero or one NRPT rules, so hardcoding a single GUID everywhere + // is fine. + nrptBase = `SYSTEM\CurrentControlSet\services\Dnscache\Parameters\DnsPolicyConfig\{5abe529b-675b-4486-8459-25a634dacc23}` + nrptOverrideDNS = 0x8 // bitmask value for "use the provided override DNS resolvers" ) type windowsManager struct { @@ -26,10 +33,20 @@ type windowsManager struct { } func NewOSConfigurator(logf logger.Logf, interfaceName string) OSConfigurator { - return windowsManager{ + ret := windowsManager{ logf: logf, guid: interfaceName, } + + // Best-effort: if our NRPT rule exists, try to delete it. Unlike + // per-interface configuration, NRPT rules survive the unclean + // termination of the Tailscale process, and depending on the + // rule, it may prevent us from reaching login.tailscale.com to + // boot up. The bootstrap resolver logic will save us, but it + // slows down start-up a bunch. + ret.delKey(nrptBase) + + return ret } // keyOpenTimeout is how long we wait for a registry key to @@ -38,37 +55,86 @@ func NewOSConfigurator(logf logger.Logf, interfaceName string) OSConfigurator { // can end up racing with that. const keyOpenTimeout = 20 * time.Second -func setRegistryString(path, name, value string) error { +func (m windowsManager) openKey(path string) (registry.Key, error) { key, err := openKeyWait(registry.LOCAL_MACHINE, path, registry.SET_VALUE, keyOpenTimeout) if err != nil { - return fmt.Errorf("opening %s: %w", path, err) + return 0, fmt.Errorf("opening %s: %w", path, err) } - defer key.Close() + return key, nil +} - err = key.SetStringValue(name, value) - if err != nil { - return fmt.Errorf("setting %s[%s]: %w", path, name, err) +func (m windowsManager) ifPath(basePath string) string { + return fmt.Sprintf(`%s\Interfaces\%s`, basePath, m.guid) +} + +func (m windowsManager) delKey(path string) error { + if err := registry.DeleteKey(registry.LOCAL_MACHINE, path); err != nil && err != registry.ErrNotExist { + return err } return nil } -func (m windowsManager) setNameservers(basePath string, nameservers []string) error { - path := fmt.Sprintf(`%s\Interfaces\%s`, basePath, m.guid) - value := strings.Join(nameservers, ",") - return setRegistryString(path, "NameServer", value) +func delValue(key registry.Key, name string) error { + if err := key.DeleteValue(name); err != nil && err != registry.ErrNotExist { + return err + } + return nil } -func (m windowsManager) setDomains(basePath string, domains []string) error { - path := fmt.Sprintf(`%s\Interfaces\%s`, basePath, m.guid) - value := strings.Join(domains, ",") - return setRegistryString(path, "SearchList", value) +// setSplitDNS configures an NRPT (Name Resolution Policy Table) rule +// to resolve queries for domains using resolvers, rather than the +// system's "primary" resolver. +// +// If no resolvers are provided, the Tailscale NRPT rule is deleted. +func (m windowsManager) setSplitDNS(resolvers []netaddr.IP, domains []string) error { + if len(resolvers) == 0 { + return m.delKey(nrptBase) + } + + servers := make([]string, 0, len(resolvers)) + for _, resolver := range resolvers { + servers = append(servers, resolver.String()) + } + doms := make([]string, 0, len(domains)) + for _, domain := range domains { + // NRPT rules must have a leading dot, which is not usual for + // DNS search paths. + doms = append(doms, "."+domain) + } + + // CreateKey is actually open-or-create, which suits us fine. + key, _, err := registry.CreateKey(registry.LOCAL_MACHINE, nrptBase, registry.SET_VALUE) + if err != nil { + return fmt.Errorf("opening %s: %w", nrptBase, err) + } + defer key.Close() + if err := key.SetDWordValue("Version", 1); err != nil { + return err + } + if err := key.SetStringsValue("Name", doms); err != nil { + return err + } + if err := key.SetStringValue("GenericDNSServers", strings.Join(servers, "; ")); err != nil { + return err + } + if err := key.SetDWordValue("ConfigOptions", nrptOverrideDNS); err != nil { + return err + } + + return nil } -func (m windowsManager) SetDNS(config OSConfig) error { +// setPrimaryDNS sets the given resolvers and domains as the Tailscale +// interface's DNS configuration. +// If resolvers is non-empty, those resolvers become the system's +// "primary" resolvers. +// domains can be set without resolvers, which just contributes new +// paths to the global DNS search list. +func (m windowsManager) setPrimaryDNS(resolvers []netaddr.IP, domains []string) error { var ipsv4 []string var ipsv6 []string - for _, ip := range config.Nameservers { + for _, ip := range resolvers { if ip.Is4() { ipsv4 = append(ipsv4, ip.String()) } else { @@ -76,19 +142,104 @@ func (m windowsManager) SetDNS(config OSConfig) error { } } - if err := m.setNameservers(ipv4RegBase, ipsv4); err != nil { + key4, err := m.openKey(m.ifPath(ipv4RegBase)) + if err != nil { return err } - if err := m.setDomains(ipv4RegBase, config.Domains); err != nil { + defer key4.Close() + + if len(ipsv4) == 0 { + if err := delValue(key4, "NameServer"); err != nil { + return err + } + } else if err := key4.SetStringValue("NameServer", strings.Join(ipsv4, ",")); err != nil { return err } - if err := m.setNameservers(ipv6RegBase, ipsv6); err != nil { + if len(domains) == 0 { + if err := delValue(key4, "SearchList"); err != nil { + return err + } + } else if err := key4.SetStringValue("SearchList", strings.Join(domains, ",")); err != nil { return err } - if err := m.setDomains(ipv6RegBase, config.Domains); err != nil { + + key6, err := m.openKey(m.ifPath(ipv6RegBase)) + if err != nil { return err } + defer key6.Close() + + if len(ipsv6) == 0 { + if err := delValue(key6, "NameServer"); err != nil { + return err + } + } else if err := key6.SetStringValue("NameServer", strings.Join(ipsv6, ",")); err != nil { + return err + } + + if len(domains) == 0 { + if err := delValue(key6, "SearchList"); err != nil { + return err + } + } else if err := key6.SetStringValue("SearchList", strings.Join(domains, ",")); err != nil { + return err + } + + // Disable LLMNR on the Tailscale interface. We don't do + // multicast, and we certainly don't do LLMNR, so it's pointless + // to make Windows try it. + if err := key4.SetDWordValue("EnableMulticast", 0); err != nil { + return err + } + if err := key6.SetDWordValue("EnableMulticast", 0); err != nil { + return err + } + + return nil +} + +func (m windowsManager) SetDNS(cfg OSConfig) error { + // We can configure Windows DNS in one of two ways: + // + // - In primary DNS mode, we set the NameServer and SearchList + // registry keys on our interface. Because our interface metric + // is very low, this turns us into the one and only "primary" + // resolver for the OS, i.e. all queries flow to the + // resolver(s) we specify. + // - In split DNS mode, we set the Domain registry key on our + // interface (which adds that domain to the global search list, + // but does not contribute other DNS configuration from the + // interface), and configure an NRPT (Name Resolution Policy + // Table) rule to route queries for our suffixes to the + // provided resolver. + // + // When switching modes, we delete all the configuration related + // to the other mode, so these two are an XOR. + // + // Windows actually supports much more advanced configurations as + // well, with arbitrary routing of hosts and suffixes to arbitrary + // resolvers. However, we use it in a "simple" split domain + // configuration only, routing one set of things to the "split" + // resolver and the rest to the primary. + + if cfg.Primary { + if err := m.setSplitDNS(nil, nil); err != nil { + return err + } + if err := m.setPrimaryDNS(cfg.Nameservers, cfg.Domains); err != nil { + return err + } + } else { + if err := m.setSplitDNS(cfg.Nameservers, cfg.Domains); err != nil { + return err + } + // Still set search domains on the interface, since NRPT only + // handles query routing and not search domain expansion. + if err := m.setPrimaryDNS(nil, cfg.Domains); err != nil { + return err + } + } // Force DNS re-registration in Active Directory. What we actually // care about is that this command invokes the undocumented hidden @@ -97,24 +248,41 @@ func (m windowsManager) SetDNS(config OSConfig) error { // effect. // // This command can take a few seconds to run, so run it async, best effort. + // + // After re-registering DNS, also flush the DNS cache to clear out + // any cached split-horizon queries that are no longer the correct + // answer. go func() { t0 := time.Now() m.logf("running ipconfig /registerdns ...") cmd := exec.Command("ipconfig", "/registerdns") cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + err := cmd.Run() d := time.Since(t0).Round(time.Millisecond) - if err := cmd.Run(); err != nil { + if err != nil { m.logf("error running ipconfig /registerdns after %v: %v", d, err) } else { m.logf("ran ipconfig /registerdns in %v", d) } + + t0 = time.Now() + m.logf("running ipconfig /registerdns ...") + cmd = exec.Command("ipconfig", "/flushdns") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + err = cmd.Run() + d = time.Since(t0).Round(time.Millisecond) + if err != nil { + m.logf("error running ipconfig /flushdns after %v: %v", d, err) + } else { + m.logf("ran ipconfig /flushdns in %v", d) + } }() return nil } func (m windowsManager) SupportsSplitDNS() bool { - return false + return true } func (m windowsManager) Close() error {