diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 11c93d06e..55d4f0baa 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -43,6 +43,7 @@ import ( "tailscale.com/types/netmap" "tailscale.com/types/persist" "tailscale.com/types/wgkey" + "tailscale.com/util/dnsname" "tailscale.com/util/systemd" "tailscale.com/version" "tailscale.com/wgengine" @@ -1529,12 +1530,12 @@ func (b *LocalBackend) authReconfig() { dcfg.DefaultResolvers = append(dcfg.DefaultResolvers, res) } if len(nm.DNS.Routes) > 0 { - dcfg.Routes = map[string][]netaddr.IPPort{} + dcfg.Routes = map[dnsname.FQDN][]netaddr.IPPort{} } for suffix, resolvers := range nm.DNS.Routes { - if !strings.HasSuffix(suffix, ".") || strings.HasPrefix(suffix, ".") { - b.logf("[unexpected] malformed DNS route suffix %q", suffix) - continue + fqdn, err := dnsname.ToFQDN(suffix) + if err != nil { + b.logf("[unexpected] non-FQDN route suffix %q", suffix) } for _, resolver := range resolvers { res, err := parseResolver(resolver) @@ -1542,23 +1543,33 @@ func (b *LocalBackend) authReconfig() { b.logf(err.Error()) continue } - dcfg.Routes[suffix] = append(dcfg.Routes[suffix], res) + dcfg.Routes[fqdn] = append(dcfg.Routes[fqdn], res) + } + } + for _, dom := range nm.DNS.Domains { + fqdn, err := dnsname.ToFQDN(dom) + if err != nil { + b.logf("[unexpected] non-FQDN search domain %q", dom) } + dcfg.SearchDomains = append(dcfg.SearchDomains, fqdn) } - dcfg.SearchDomains = nm.DNS.Domains dcfg.AuthoritativeSuffixes = magicDNSRootDomains(nm) set := func(name string, addrs []netaddr.IPPrefix) { if len(addrs) == 0 || name == "" { return } + fqdn, err := dnsname.ToFQDN(name) + if err != nil { + return // TODO: propagate error? + } var ips []netaddr.IP for _, addr := range addrs { ips = append(ips, addr.IP) } - dcfg.Hosts[name] = ips + dcfg.Hosts[fqdn] = ips } if nm.DNS.Proxied { // actually means "enable MagicDNS" - dcfg.Hosts = map[string][]netaddr.IP{} + dcfg.Hosts = map[dnsname.FQDN][]netaddr.IP{} set(nm.Name, nm.Addresses) for _, peer := range nm.Peers { set(peer.Name, peer.Addresses) @@ -1691,9 +1702,14 @@ func (b *LocalBackend) initPeerAPIListener() { } // magicDNSRootDomains returns the subset of nm.DNS.Domains that are the search domains for MagicDNS. -func magicDNSRootDomains(nm *netmap.NetworkMap) []string { +func magicDNSRootDomains(nm *netmap.NetworkMap) []dnsname.FQDN { if v := nm.MagicDNSSuffix(); v != "" { - return []string{strings.Trim(v, ".")} + fqdn, err := dnsname.ToFQDN(v) + if err != nil { + // TODO: propagate error + return nil + } + return []dnsname.FQDN{fqdn} } return nil } diff --git a/net/dns/config.go b/net/dns/config.go index 48abebe0a..0a6fb3481 100644 --- a/net/dns/config.go +++ b/net/dns/config.go @@ -6,9 +6,9 @@ package dns import ( "sort" - "strings" "inet.af/netaddr" + "tailscale.com/util/dnsname" ) // Config is a DNS configuration. @@ -22,21 +22,21 @@ type Config struct { // for queries that fall within that suffix. // If a query doesn't match any entry in Routes, the // DefaultResolvers are used. - Routes map[string][]netaddr.IPPort + Routes map[dnsname.FQDN][]netaddr.IPPort // SearchDomains are DNS suffixes to try when expanding // single-label queries. - SearchDomains []string + SearchDomains []dnsname.FQDN // Hosts maps DNS FQDNs to their IPs, which can be a mix of IPv4 // and IPv6. // Queries matching entries in Hosts are resolved locally without // recursing off-machine. - Hosts map[string][]netaddr.IP + Hosts map[dnsname.FQDN][]netaddr.IP // AuthoritativeSuffixes is a list of fully-qualified DNS suffixes // for which the in-process Tailscale resolver is authoritative. // Queries for names within AuthoritativeSuffixes can only be // fulfilled by entries in Hosts. Queries with no match in Hosts // return NXDOMAIN. - AuthoritativeSuffixes []string + AuthoritativeSuffixes []dnsname.FQDN } // needsAnyResolvers reports whether c requires a resolver to be set @@ -85,24 +85,26 @@ func (c Config) hasHosts() bool { // matchDomains returns the list of match suffixes needed by Routes, // AuthoritativeSuffixes. Hosts is not considered as we assume that // they're covered by AuthoritativeSuffixes for now. -func (c Config) matchDomains() []string { - ret := make([]string, 0, len(c.Routes)+len(c.AuthoritativeSuffixes)) - seen := map[string]bool{} +func (c Config) matchDomains() []dnsname.FQDN { + ret := make([]dnsname.FQDN, 0, len(c.Routes)+len(c.AuthoritativeSuffixes)) + seen := map[dnsname.FQDN]bool{} for _, suffix := range c.AuthoritativeSuffixes { if seen[suffix] { continue } - ret = append(ret, strings.TrimSuffix(suffix, ".")) + ret = append(ret, suffix) seen[suffix] = true } for suffix := range c.Routes { if seen[suffix] { continue } - ret = append(ret, strings.TrimSuffix(suffix, ".")) + ret = append(ret, suffix) seen[suffix] = true } - sort.Strings(ret) + sort.Slice(ret, func(i, j int) bool { + return ret[i].WithTrailingDot() < ret[j].WithTrailingDot() + }) return ret } diff --git a/net/dns/direct.go b/net/dns/direct.go index c9081b16e..9bb6ef34b 100644 --- a/net/dns/direct.go +++ b/net/dns/direct.go @@ -9,6 +9,7 @@ package dns import ( "bufio" "bytes" + "fmt" "io" "io/ioutil" "os" @@ -18,6 +19,7 @@ import ( "inet.af/netaddr" "tailscale.com/atomicfile" + "tailscale.com/util/dnsname" ) const ( @@ -26,7 +28,7 @@ const ( ) // writeResolvConf writes DNS configuration in resolv.conf format to the given writer. -func writeResolvConf(w io.Writer, servers []netaddr.IP, domains []string) { +func writeResolvConf(w io.Writer, servers []netaddr.IP, domains []dnsname.FQDN) { io.WriteString(w, "# resolv.conf(5) file generated by tailscale\n") io.WriteString(w, "# DO NOT EDIT THIS FILE BY HAND -- CHANGES WILL BE OVERWRITTEN\n\n") for _, ns := range servers { @@ -38,7 +40,7 @@ func writeResolvConf(w io.Writer, servers []netaddr.IP, domains []string) { io.WriteString(w, "search") for _, domain := range domains { io.WriteString(w, " ") - io.WriteString(w, domain) + io.WriteString(w, domain.WithoutTrailingDot()) } io.WriteString(w, "\n") } @@ -70,7 +72,11 @@ func readResolvFile(path string) (OSConfig, error) { if strings.HasPrefix(line, "search") { domain := strings.TrimPrefix(line, "search") domain = strings.TrimSpace(domain) - config.SearchDomains = append(config.SearchDomains, domain) + fqdn, err := dnsname.ToFQDN(domain) + if err != nil { + return OSConfig{}, fmt.Errorf("parsing search domains %q: %w", line, err) + } + config.SearchDomains = append(config.SearchDomains, fqdn) continue } } diff --git a/net/dns/manager.go b/net/dns/manager.go index 2207e1582..a026c4b40 100644 --- a/net/dns/manager.go +++ b/net/dns/manager.go @@ -12,6 +12,7 @@ import ( "tailscale.com/net/dns/resolver" "tailscale.com/net/tsaddr" "tailscale.com/types/logger" + "tailscale.com/util/dnsname" "tailscale.com/wgengine/monitor" ) @@ -60,7 +61,7 @@ func forceSplitDNSForTesting(cfg *Config) { } if cfg.Routes == nil { - cfg.Routes = map[string][]netaddr.IPPort{} + cfg.Routes = map[dnsname.FQDN][]netaddr.IPPort{} } for _, search := range cfg.SearchDomains { cfg.Routes[search] = cfg.DefaultResolvers @@ -112,14 +113,14 @@ func (m *Manager) compileConfig(cfg Config) (resolver.Config, OSConfig, error) { // Default resolvers plus other stuff always ends up proxying // through quad-100. rcfg := resolver.Config{ - Routes: map[string][]netaddr.IPPort{ + Routes: map[dnsname.FQDN][]netaddr.IPPort{ ".": cfg.DefaultResolvers, }, Hosts: cfg.Hosts, - LocalDomains: addFQDNDots(cfg.AuthoritativeSuffixes), + LocalDomains: cfg.AuthoritativeSuffixes, } for suffix, resolvers := range cfg.Routes { - rcfg.Routes[suffix+"."] = resolvers + rcfg.Routes[suffix] = resolvers } ocfg := OSConfig{ Nameservers: []netaddr.IP{tsaddr.TailscaleServiceIP()}, @@ -149,12 +150,12 @@ func (m *Manager) compileConfig(cfg Config) (resolver.Config, OSConfig, error) { // or routes + MagicDNS, or just MagicDNS, or on an OS that cannot // split-DNS. Install a split config pointing at quad-100. rcfg = resolver.Config{ - Routes: map[string][]netaddr.IPPort{}, Hosts: cfg.Hosts, - LocalDomains: addFQDNDots(cfg.AuthoritativeSuffixes), + LocalDomains: cfg.AuthoritativeSuffixes, + Routes: map[dnsname.FQDN][]netaddr.IPPort{}, } for suffix, resolvers := range cfg.Routes { - rcfg.Routes[suffix+"."] = resolvers + rcfg.Routes[suffix] = resolvers } ocfg = OSConfig{ Nameservers: []netaddr.IP{tsaddr.TailscaleServiceIP()}, @@ -179,7 +180,7 @@ func (m *Manager) compileConfig(cfg Config) (resolver.Config, OSConfig, error) { // quad-9 if we accidentally go down this codepath. canUseHack := false for _, dom := range cfg.SearchDomains { - if strings.HasSuffix(dom, ".tailscale.com") { + if strings.HasSuffix(dom.WithoutTrailingDot(), ".tailscale.com") { canUseHack = true break } @@ -198,17 +199,6 @@ func (m *Manager) compileConfig(cfg Config) (resolver.Config, OSConfig, error) { return rcfg, ocfg, nil } -func addFQDNDots(domains []string) []string { - ret := make([]string, 0, len(domains)) - for _, dom := range domains { - if !strings.HasSuffix(dom, ".") { - dom = dom + "." - } - ret = append(ret, dom) - } - return ret -} - // toIPsOnly returns only the IP portion of ipps. // TODO: this discards port information on the assumption that we're // always pointing at port 53. diff --git a/net/dns/manager_test.go b/net/dns/manager_test.go index 7062b17ec..8610dd0d0 100644 --- a/net/dns/manager_test.go +++ b/net/dns/manager_test.go @@ -11,6 +11,7 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "inet.af/netaddr" "tailscale.com/net/dns/resolver" + "tailscale.com/util/dnsname" ) type fakeOSConfigurator struct { @@ -64,78 +65,78 @@ func TestManager(t *testing.T) { { name: "search-only", in: Config{ - SearchDomains: strs("tailscale.com", "universe.tf"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, os: OSConfig{ - SearchDomains: strs("tailscale.com", "universe.tf"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, }, { name: "corp", in: Config{ DefaultResolvers: mustIPPs("1.1.1.1:53", "9.9.9.9:53"), - SearchDomains: strs("tailscale.com", "universe.tf"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, os: OSConfig{ Nameservers: mustIPs("1.1.1.1", "9.9.9.9"), - SearchDomains: strs("tailscale.com", "universe.tf"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, }, { name: "corp-split", in: Config{ DefaultResolvers: mustIPPs("1.1.1.1:53", "9.9.9.9:53"), - SearchDomains: strs("tailscale.com", "universe.tf"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, split: true, os: OSConfig{ Nameservers: mustIPs("1.1.1.1", "9.9.9.9"), - SearchDomains: strs("tailscale.com", "universe.tf"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, }, { name: "corp-magic", in: Config{ DefaultResolvers: mustIPPs("1.1.1.1:53", "9.9.9.9:53"), - SearchDomains: strs("tailscale.com", "universe.tf"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), Hosts: hosts( "dave.ts.com.", "1.2.3.4", "bradfitz.ts.com.", "2.3.4.5"), - AuthoritativeSuffixes: strs("ts.com"), + AuthoritativeSuffixes: fqdns("ts.com"), }, os: OSConfig{ Nameservers: mustIPs("100.100.100.100"), - SearchDomains: strs("tailscale.com", "universe.tf"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, rs: resolver.Config{ Routes: upstreams(".", "1.1.1.1:53", "9.9.9.9:53"), Hosts: hosts( "dave.ts.com.", "1.2.3.4", "bradfitz.ts.com.", "2.3.4.5"), - LocalDomains: strs("ts.com."), + LocalDomains: fqdns("ts.com."), }, }, { name: "corp-magic-split", in: Config{ DefaultResolvers: mustIPPs("1.1.1.1:53", "9.9.9.9:53"), - SearchDomains: strs("tailscale.com", "universe.tf"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), Hosts: hosts( "dave.ts.com.", "1.2.3.4", "bradfitz.ts.com.", "2.3.4.5"), - AuthoritativeSuffixes: strs("ts.com"), + AuthoritativeSuffixes: fqdns("ts.com"), }, split: true, os: OSConfig{ Nameservers: mustIPs("100.100.100.100"), - SearchDomains: strs("tailscale.com", "universe.tf"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, rs: resolver.Config{ Routes: upstreams(".", "1.1.1.1:53", "9.9.9.9:53"), Hosts: hosts( "dave.ts.com.", "1.2.3.4", "bradfitz.ts.com.", "2.3.4.5"), - LocalDomains: strs("ts.com."), + LocalDomains: fqdns("ts.com."), }, }, { @@ -143,11 +144,11 @@ func TestManager(t *testing.T) { in: Config{ DefaultResolvers: mustIPPs("1.1.1.1:53", "9.9.9.9:53"), Routes: upstreams("corp.com", "2.2.2.2:53"), - SearchDomains: strs("tailscale.com", "universe.tf"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, os: OSConfig{ Nameservers: mustIPs("100.100.100.100"), - SearchDomains: strs("tailscale.com", "universe.tf"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, rs: resolver.Config{ Routes: upstreams( @@ -160,12 +161,12 @@ func TestManager(t *testing.T) { in: Config{ DefaultResolvers: mustIPPs("1.1.1.1:53", "9.9.9.9:53"), Routes: upstreams("corp.com", "2.2.2.2:53"), - SearchDomains: strs("tailscale.com", "universe.tf"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, split: true, os: OSConfig{ Nameservers: mustIPs("100.100.100.100"), - SearchDomains: strs("tailscale.com", "universe.tf"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, rs: resolver.Config{ Routes: upstreams( @@ -177,15 +178,15 @@ func TestManager(t *testing.T) { name: "routes", in: Config{ Routes: upstreams("corp.com", "2.2.2.2:53"), - SearchDomains: strs("tailscale.com", "universe.tf"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, bs: OSConfig{ Nameservers: mustIPs("8.8.8.8"), - SearchDomains: strs("coffee.shop"), + SearchDomains: fqdns("coffee.shop"), }, os: OSConfig{ Nameservers: mustIPs("100.100.100.100"), - SearchDomains: strs("tailscale.com", "universe.tf", "coffee.shop"), + SearchDomains: fqdns("tailscale.com", "universe.tf", "coffee.shop"), }, rs: resolver.Config{ Routes: upstreams( @@ -197,13 +198,13 @@ func TestManager(t *testing.T) { name: "routes-split", in: Config{ Routes: upstreams("corp.com", "2.2.2.2:53"), - SearchDomains: strs("tailscale.com", "universe.tf"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, split: true, os: OSConfig{ Nameservers: mustIPs("2.2.2.2"), - SearchDomains: strs("tailscale.com", "universe.tf"), - MatchDomains: strs("corp.com"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), + MatchDomains: fqdns("corp.com"), }, }, { @@ -212,15 +213,15 @@ func TestManager(t *testing.T) { Routes: upstreams( "corp.com", "2.2.2.2:53", "bigco.net", "3.3.3.3:53"), - SearchDomains: strs("tailscale.com", "universe.tf"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, bs: OSConfig{ Nameservers: mustIPs("8.8.8.8"), - SearchDomains: strs("coffee.shop"), + SearchDomains: fqdns("coffee.shop"), }, os: OSConfig{ Nameservers: mustIPs("100.100.100.100"), - SearchDomains: strs("tailscale.com", "universe.tf", "coffee.shop"), + SearchDomains: fqdns("tailscale.com", "universe.tf", "coffee.shop"), }, rs: resolver.Config{ Routes: upstreams( @@ -235,13 +236,13 @@ func TestManager(t *testing.T) { Routes: upstreams( "corp.com", "2.2.2.2:53", "bigco.net", "3.3.3.3:53"), - SearchDomains: strs("tailscale.com", "universe.tf"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, split: true, os: OSConfig{ Nameservers: mustIPs("100.100.100.100"), - SearchDomains: strs("tailscale.com", "universe.tf"), - MatchDomains: strs("bigco.net", "corp.com"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), + MatchDomains: fqdns("bigco.net", "corp.com"), }, rs: resolver.Config{ Routes: upstreams( @@ -255,23 +256,23 @@ func TestManager(t *testing.T) { Hosts: hosts( "dave.ts.com.", "1.2.3.4", "bradfitz.ts.com.", "2.3.4.5"), - AuthoritativeSuffixes: strs("ts.com"), - SearchDomains: strs("tailscale.com", "universe.tf"), + AuthoritativeSuffixes: fqdns("ts.com"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, bs: OSConfig{ Nameservers: mustIPs("8.8.8.8"), - SearchDomains: strs("coffee.shop"), + SearchDomains: fqdns("coffee.shop"), }, os: OSConfig{ Nameservers: mustIPs("100.100.100.100"), - SearchDomains: strs("tailscale.com", "universe.tf", "coffee.shop"), + SearchDomains: fqdns("tailscale.com", "universe.tf", "coffee.shop"), }, rs: resolver.Config{ Routes: upstreams(".", "8.8.8.8:53"), Hosts: hosts( "dave.ts.com.", "1.2.3.4", "bradfitz.ts.com.", "2.3.4.5"), - LocalDomains: strs("ts.com."), + LocalDomains: fqdns("ts.com."), }, }, { @@ -280,20 +281,20 @@ func TestManager(t *testing.T) { Hosts: hosts( "dave.ts.com.", "1.2.3.4", "bradfitz.ts.com.", "2.3.4.5"), - AuthoritativeSuffixes: strs("ts.com"), - SearchDomains: strs("tailscale.com", "universe.tf"), + AuthoritativeSuffixes: fqdns("ts.com"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, split: true, os: OSConfig{ Nameservers: mustIPs("100.100.100.100"), - SearchDomains: strs("tailscale.com", "universe.tf"), - MatchDomains: strs("ts.com"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), + MatchDomains: fqdns("ts.com"), }, rs: resolver.Config{ Hosts: hosts( "dave.ts.com.", "1.2.3.4", "bradfitz.ts.com.", "2.3.4.5"), - LocalDomains: strs("ts.com."), + LocalDomains: fqdns("ts.com."), }, }, { @@ -303,16 +304,16 @@ func TestManager(t *testing.T) { Hosts: hosts( "dave.ts.com.", "1.2.3.4", "bradfitz.ts.com.", "2.3.4.5"), - AuthoritativeSuffixes: strs("ts.com"), - SearchDomains: strs("tailscale.com", "universe.tf"), + AuthoritativeSuffixes: fqdns("ts.com"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, bs: OSConfig{ Nameservers: mustIPs("8.8.8.8"), - SearchDomains: strs("coffee.shop"), + SearchDomains: fqdns("coffee.shop"), }, os: OSConfig{ Nameservers: mustIPs("100.100.100.100"), - SearchDomains: strs("tailscale.com", "universe.tf", "coffee.shop"), + SearchDomains: fqdns("tailscale.com", "universe.tf", "coffee.shop"), }, rs: resolver.Config{ Routes: upstreams( @@ -321,7 +322,7 @@ func TestManager(t *testing.T) { Hosts: hosts( "dave.ts.com.", "1.2.3.4", "bradfitz.ts.com.", "2.3.4.5"), - LocalDomains: strs("ts.com."), + LocalDomains: fqdns("ts.com."), }, }, { @@ -331,21 +332,21 @@ func TestManager(t *testing.T) { Hosts: hosts( "dave.ts.com.", "1.2.3.4", "bradfitz.ts.com.", "2.3.4.5"), - AuthoritativeSuffixes: strs("ts.com"), - SearchDomains: strs("tailscale.com", "universe.tf"), + AuthoritativeSuffixes: fqdns("ts.com"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), }, split: true, os: OSConfig{ Nameservers: mustIPs("100.100.100.100"), - SearchDomains: strs("tailscale.com", "universe.tf"), - MatchDomains: strs("corp.com", "ts.com"), + SearchDomains: fqdns("tailscale.com", "universe.tf"), + MatchDomains: fqdns("corp.com", "ts.com"), }, rs: resolver.Config{ Routes: upstreams("corp.com.", "2.2.2.2:53"), Hosts: hosts( "dave.ts.com.", "1.2.3.4", "bradfitz.ts.com.", "2.3.4.5"), - LocalDomains: strs("ts.com."), + LocalDomains: fqdns("ts.com."), }, }, } @@ -387,11 +388,20 @@ func mustIPPs(strs ...string) (ret []netaddr.IPPort) { return ret } -func strs(strs ...string) []string { return strs } +func fqdns(strs ...string) (ret []dnsname.FQDN) { + for _, s := range strs { + fqdn, err := dnsname.ToFQDN(s) + if err != nil { + panic(err) + } + ret = append(ret, fqdn) + } + return ret +} -func hosts(strs ...string) (ret map[string][]netaddr.IP) { - var key string - ret = map[string][]netaddr.IP{} +func hosts(strs ...string) (ret map[dnsname.FQDN][]netaddr.IP) { + var key dnsname.FQDN + ret = map[dnsname.FQDN][]netaddr.IP{} for _, s := range strs { if ip, err := netaddr.ParseIP(s); err == nil { if key == "" { @@ -399,15 +409,19 @@ func hosts(strs ...string) (ret map[string][]netaddr.IP) { } ret[key] = append(ret[key], ip) } else { - key = s + fqdn, err := dnsname.ToFQDN(s) + if err != nil { + panic(err) + } + key = fqdn } } return ret } -func upstreams(strs ...string) (ret map[string][]netaddr.IPPort) { - var key string - ret = map[string][]netaddr.IPPort{} +func upstreams(strs ...string) (ret map[dnsname.FQDN][]netaddr.IPPort) { + var key dnsname.FQDN + ret = map[dnsname.FQDN][]netaddr.IPPort{} for _, s := range strs { if ipp, err := netaddr.ParseIPPort(s); err == nil { if key == "" { @@ -415,7 +429,11 @@ func upstreams(strs ...string) (ret map[string][]netaddr.IPPort) { } ret[key] = append(ret[key], ipp) } else { - key = s + fqdn, err := dnsname.ToFQDN(s) + if err != nil { + panic(err) + } + key = fqdn } } return ret diff --git a/net/dns/manager_windows.go b/net/dns/manager_windows.go index a2bead3d5..4b1cb3659 100644 --- a/net/dns/manager_windows.go +++ b/net/dns/manager_windows.go @@ -17,6 +17,7 @@ import ( "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" "inet.af/netaddr" "tailscale.com/types/logger" + "tailscale.com/util/dnsname" ) const ( @@ -95,7 +96,7 @@ func delValue(key registry.Key, name string) error { // 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 { +func (m windowsManager) setSplitDNS(resolvers []netaddr.IP, domains []dnsname.FQDN) error { if len(resolvers) == 0 { return m.delKey(nrptBase) } @@ -108,7 +109,7 @@ func (m windowsManager) setSplitDNS(resolvers []netaddr.IP, domains []string) er for _, domain := range domains { // NRPT rules must have a leading dot, which is not usual for // DNS search paths. - doms = append(doms, "."+domain) + doms = append(doms, "."+domain.WithoutTrailingDot()) } // CreateKey is actually open-or-create, which suits us fine. @@ -139,7 +140,7 @@ func (m windowsManager) setSplitDNS(resolvers []netaddr.IP, domains []string) er // "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 { +func (m windowsManager) setPrimaryDNS(resolvers []netaddr.IP, domains []dnsname.FQDN) error { var ipsv4 []string var ipsv6 []string @@ -151,6 +152,11 @@ func (m windowsManager) setPrimaryDNS(resolvers []netaddr.IP, domains []string) } } + domStrs := make([]string, 0, len(domains)) + for _, dom := range domains { + domStrs = append(domStrs, dom.WithoutTrailingDot()) + } + key4, err := m.openKey(m.ifPath(ipv4RegBase)) if err != nil { return err @@ -169,7 +175,7 @@ func (m windowsManager) setPrimaryDNS(resolvers []netaddr.IP, domains []string) if err := delValue(key4, "SearchList"); err != nil { return err } - } else if err := key4.SetStringValue("SearchList", strings.Join(domains, ",")); err != nil { + } else if err := key4.SetStringValue("SearchList", strings.Join(domStrs, ",")); err != nil { return err } @@ -191,7 +197,7 @@ func (m windowsManager) setPrimaryDNS(resolvers []netaddr.IP, domains []string) if err := delValue(key6, "SearchList"); err != nil { return err } - } else if err := key6.SetStringValue("SearchList", strings.Join(domains, ",")); err != nil { + } else if err := key6.SetStringValue("SearchList", strings.Join(domStrs, ",")); err != nil { return err } diff --git a/net/dns/osconfig.go b/net/dns/osconfig.go index 889cec967..77e4b30f0 100644 --- a/net/dns/osconfig.go +++ b/net/dns/osconfig.go @@ -8,6 +8,7 @@ import ( "errors" "inet.af/netaddr" + "tailscale.com/util/dnsname" ) // An OSConfigurator applies DNS settings to the operating system. @@ -42,13 +43,13 @@ type OSConfig struct { // SearchDomains are the domain suffixes to use when expanding // single-label name queries. SearchDomains is additive to // whatever non-Tailscale search domains the OS has. - SearchDomains []string + SearchDomains []dnsname.FQDN // MatchDomains are the DNS suffixes for which Nameservers should // be used. If empty, Nameservers is installed as the "primary" resolver. // A non-empty MatchDomains requests a "split DNS" configuration // from the OS, which will only work with OSConfigurators that // report SupportsSplitDNS()=true. - MatchDomains []string + MatchDomains []dnsname.FQDN } // ErrGetBaseConfigNotSupported is the error diff --git a/net/dns/resolved.go b/net/dns/resolved.go index e1e5464c7..5e6e2bd11 100644 --- a/net/dns/resolved.go +++ b/net/dns/resolved.go @@ -137,7 +137,7 @@ func (m resolvedManager) SetDNS(config OSConfig) error { var linkDomains = make([]resolvedLinkDomain, len(config.SearchDomains)) for i, domain := range config.SearchDomains { linkDomains[i] = resolvedLinkDomain{ - Domain: domain, + Domain: domain.WithoutTrailingDot(), RoutingOnly: false, } } diff --git a/net/dns/resolver/forwarder.go b/net/dns/resolver/forwarder.go index e9e3b0df1..74c5a10d7 100644 --- a/net/dns/resolver/forwarder.go +++ b/net/dns/resolver/forwarder.go @@ -102,7 +102,7 @@ func getTxID(packet []byte) txid { } type route struct { - suffix string + suffix dnsname.FQDN resolvers []netaddr.IPPort } @@ -272,7 +272,7 @@ func (f *forwarder) forward(query packet) error { var resolvers []netaddr.IPPort for _, route := range routes { - if route.suffix != "." && !dnsname.HasSuffix(domain, route.suffix) { + if route.suffix != "." && !route.suffix.Contains(domain) { continue } resolvers = route.resolvers @@ -489,7 +489,7 @@ func (c *fwdConn) close() { } // nameFromQuery extracts the normalized query name from bs. -func nameFromQuery(bs []byte) (string, error) { +func nameFromQuery(bs []byte) (dnsname.FQDN, error) { var parser dns.Parser hdr, err := parser.Start(bs) @@ -506,5 +506,5 @@ func nameFromQuery(bs []byte) (string, error) { } n := q.Name.Data[:q.Name.Length] - return rawNameToLower(n), nil + return dnsname.ToFQDN(rawNameToLower(n)) } diff --git a/net/dns/resolver/tsdns.go b/net/dns/resolver/tsdns.go index 708d8cd4c..ff51b29cb 100644 --- a/net/dns/resolver/tsdns.go +++ b/net/dns/resolver/tsdns.go @@ -9,7 +9,6 @@ package resolver import ( "encoding/hex" "errors" - "fmt" "sort" "strings" "sync" @@ -59,12 +58,12 @@ type Config struct { // queries within that suffix. // Queries only match the most specific suffix. // To register a "default route", add an entry for ".". - Routes map[string][]netaddr.IPPort + Routes map[dnsname.FQDN][]netaddr.IPPort // LocalHosts is a map of FQDNs to corresponding IPs. - Hosts map[string][]netaddr.IP + Hosts map[dnsname.FQDN][]netaddr.IP // LocalDomains is a list of DNS name suffixes that should not be // routed to upstream resolvers. - LocalDomains []string + LocalDomains []dnsname.FQDN } // Resolver is a DNS resolver for nodes on the Tailscale network, @@ -92,9 +91,9 @@ type Resolver struct { // mu guards the following fields from being updated while used. mu sync.Mutex - localDomains []string - hostToIP map[string][]netaddr.IP - ipToHost map[netaddr.IP]string + localDomains []dnsname.FQDN + hostToIP map[dnsname.FQDN][]netaddr.IP + ipToHost map[netaddr.IP]dnsname.FQDN } // New returns a new resolver. @@ -107,8 +106,8 @@ func New(logf logger.Logf, linkMon *monitor.Mon) *Resolver { responses: make(chan packet), errors: make(chan error), closed: make(chan struct{}), - hostToIP: map[string][]netaddr.IP{}, - ipToHost: map[netaddr.IP]string{}, + hostToIP: map[dnsname.FQDN][]netaddr.IP{}, + ipToHost: map[netaddr.IP]dnsname.FQDN{}, } r.forwarder = newForwarder(r.logf, r.responses) if r.linkMon != nil { @@ -121,10 +120,6 @@ func New(logf logger.Logf, linkMon *monitor.Mon) *Resolver { return r } -func isFQDN(s string) bool { - return strings.HasSuffix(s, ".") -} - func (r *Resolver) TestOnlySetHook(hook func(Config)) { r.saveConfigForTests = hook } func (r *Resolver) SetConfig(cfg Config) error { @@ -133,26 +128,15 @@ func (r *Resolver) SetConfig(cfg Config) error { } routes := make([]route, 0, len(cfg.Routes)) - reverse := make(map[netaddr.IP]string, len(cfg.Hosts)) + reverse := make(map[netaddr.IP]dnsname.FQDN, len(cfg.Hosts)) for host, ips := range cfg.Hosts { - if !isFQDN(host) { - return fmt.Errorf("host entry %q is not a FQDN", host) - } for _, ip := range ips { reverse[ip] = host } } - for _, domain := range cfg.LocalDomains { - if !isFQDN(domain) { - return fmt.Errorf("local domain %q is not a FQDN", domain) - } - } for suffix, ips := range cfg.Routes { - if !strings.HasSuffix(suffix, ".") { - return fmt.Errorf("route suffix %q is not a FQDN", suffix) - } routes = append(routes, route{ suffix: suffix, resolvers: ips, @@ -160,7 +144,7 @@ func (r *Resolver) SetConfig(cfg Config) error { } // Sort from longest prefix to shortest. sort.Slice(routes, func(i, j int) bool { - return dnsname.NumLabels(routes[i].suffix) > dnsname.NumLabels(routes[j].suffix) + return routes[i].suffix.NumLabels() > routes[j].suffix.NumLabels() }) r.forwarder.setRoutes(routes) @@ -229,12 +213,11 @@ func (r *Resolver) NextResponse() (packet []byte, to netaddr.IPPort, err error) // resolveLocal returns an IP for the given domain, if domain is in // the local hosts map and has an IP corresponding to the requested // typ (A, AAAA, ALL). -// The domain name must be in canonical form (with a trailing period). // Returns dns.RCodeRefused to indicate that the local map is not // authoritative for domain. -func (r *Resolver) resolveLocal(domain string, typ dns.Type) (netaddr.IP, dns.RCode) { +func (r *Resolver) resolveLocal(domain dnsname.FQDN, typ dns.Type) (netaddr.IP, dns.RCode) { // Reject .onion domains per RFC 7686. - if dnsname.HasSuffix(domain, ".onion") { + if dnsname.HasSuffix(domain.WithoutTrailingDot(), ".onion") { return netaddr.IP{}, dns.RCodeNameError } @@ -246,7 +229,7 @@ func (r *Resolver) resolveLocal(domain string, typ dns.Type) (netaddr.IP, dns.RC addrs, found := hosts[domain] if !found { for _, suffix := range localDomains { - if dnsname.HasSuffix(domain, suffix) { + if suffix.Contains(domain) { // We are authoritative for the queried domain. return netaddr.IP{}, dns.RCodeNameError } @@ -304,8 +287,7 @@ func (r *Resolver) resolveLocal(domain string, typ dns.Type) (netaddr.IP, dns.RC } // resolveReverse returns the unique domain name that maps to the given address. -// The returned domain name is in canonical form (with a trailing period). -func (r *Resolver) resolveLocalReverse(ip netaddr.IP) (string, dns.RCode) { +func (r *Resolver) resolveLocalReverse(ip netaddr.IP) (dnsname.FQDN, dns.RCode) { r.mu.Lock() ips := r.ipToHost r.mu.Unlock() @@ -362,7 +344,7 @@ type response struct { Header dns.Header Question dns.Question // Name is the response to a PTR query. - Name string + Name dnsname.FQDN // IP is the response to an A, AAAA, or ALL query. IP netaddr.IP } @@ -425,7 +407,7 @@ func marshalAAAARecord(name dns.Name, ip netaddr.IP, builder *dns.Builder) error // marshalPTRRecord serializes a PTR record into an active builder. // The caller may continue using the builder following the call. -func marshalPTRRecord(queryName dns.Name, name string, builder *dns.Builder) error { +func marshalPTRRecord(queryName dns.Name, name dnsname.FQDN, builder *dns.Builder) error { var answer dns.PTRResource var err error @@ -435,7 +417,7 @@ func marshalPTRRecord(queryName dns.Name, name string, builder *dns.Builder) err Class: dns.ClassINET, TTL: uint32(defaultTTL / time.Second), } - answer.PTR, err = dns.NewName(name) + answer.PTR, err = dns.NewName(name.WithTrailingDot()) if err != nil { return err } @@ -508,12 +490,13 @@ const ( // r._dns-sd._udp.. // dr._dns-sd._udp.. // lb._dns-sd._udp.. -func hasRDNSBonjourPrefix(s string) bool { +func hasRDNSBonjourPrefix(name dnsname.FQDN) bool { // Even the shortest name containing a Bonjour prefix is long, // so check length (cheap) and bail early if possible. - if len(s) < len("*._dns-sd._udp.0.0.0.0.in-addr.arpa.") { + if len(name) < len("*._dns-sd._udp.0.0.0.0.in-addr.arpa.") { return false } + s := name.WithTrailingDot() dot := strings.IndexByte(s, '.') if dot == -1 { return false // shouldn't happen @@ -548,9 +531,9 @@ func rawNameToLower(name []byte) string { // 4.3.2.1.in-addr.arpa // is transformed to // 1.2.3.4 -func rdnsNameToIPv4(name string) (ip netaddr.IP, ok bool) { - name = strings.TrimSuffix(name, rdnsv4Suffix) - ip, err := netaddr.ParseIP(string(name)) +func rdnsNameToIPv4(name dnsname.FQDN) (ip netaddr.IP, ok bool) { + s := strings.TrimSuffix(name.WithTrailingDot(), rdnsv4Suffix) + ip, err := netaddr.ParseIP(s) if err != nil { return netaddr.IP{}, false } @@ -567,21 +550,21 @@ func rdnsNameToIPv4(name string) (ip netaddr.IP, ok bool) { // b.a.9.8.7.6.5.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa. // is transformed to // 2001:db8::567:89ab -func rdnsNameToIPv6(name string) (ip netaddr.IP, ok bool) { +func rdnsNameToIPv6(name dnsname.FQDN) (ip netaddr.IP, ok bool) { var b [32]byte var ipb [16]byte - name = strings.TrimSuffix(name, rdnsv6Suffix) + s := strings.TrimSuffix(name.WithTrailingDot(), rdnsv6Suffix) // 32 nibbles and 31 dots between them. - if len(name) != 63 { + if len(s) != 63 { return netaddr.IP{}, false } // Dots and hex digits alternate. prevDot := true // i ranges over name backward; j ranges over b forward. - for i, j := len(name)-1, 0; i >= 0; i-- { - thisDot := (name[i] == '.') + for i, j := len(s)-1, 0; i >= 0; i-- { + thisDot := (s[i] == '.') if prevDot == thisDot { return netaddr.IP{}, false } @@ -590,7 +573,7 @@ func rdnsNameToIPv6(name string) (ip netaddr.IP, ok bool) { if !thisDot { // This is safe assuming alternation. // We do not check that non-dots are hex digits: hex.Decode below will do that. - b[j] = name[i] + b[j] = s[i] j++ } } @@ -605,7 +588,7 @@ func rdnsNameToIPv6(name string) (ip netaddr.IP, ok bool) { // respondReverse returns a DNS response to a PTR query. // It is assumed that resp.Question is populated by respond before this is called. -func (r *Resolver) respondReverse(query []byte, name string, resp *response) ([]byte, error) { +func (r *Resolver) respondReverse(query []byte, name dnsname.FQDN, resp *response) ([]byte, error) { if hasRDNSBonjourPrefix(name) { return nil, errNotOurName } @@ -613,9 +596,9 @@ func (r *Resolver) respondReverse(query []byte, name string, resp *response) ([] var ip netaddr.IP var ok bool switch { - case strings.HasSuffix(name, rdnsv4Suffix): + case strings.HasSuffix(name.WithTrailingDot(), rdnsv4Suffix): ip, ok = rdnsNameToIPv4(name) - case strings.HasSuffix(name, rdnsv6Suffix): + case strings.HasSuffix(name.WithTrailingDot(), rdnsv6Suffix): ip, ok = rdnsNameToIPv6(name) default: return nil, errNotOurName @@ -656,7 +639,12 @@ func (r *Resolver) respond(query []byte) ([]byte, error) { return marshalResponse(resp) } rawName := resp.Question.Name.Data[:resp.Question.Name.Length] - name := rawNameToLower(rawName) + name, err := dnsname.ToFQDN(rawNameToLower(rawName)) + if err != nil { + // DNS packet unexpectedly contains an invalid FQDN. + resp.Header.RCode = dns.RCodeFormatError + return marshalResponse(resp) + } // Always try to handle reverse lookups; delegate inside when not found. // This way, queries for existent nodes do not leak, diff --git a/net/dns/resolver/tsdns_test.go b/net/dns/resolver/tsdns_test.go index 6a7fa7034..993d7411f 100644 --- a/net/dns/resolver/tsdns_test.go +++ b/net/dns/resolver/tsdns_test.go @@ -13,23 +13,24 @@ import ( dns "golang.org/x/net/dns/dnsmessage" "inet.af/netaddr" "tailscale.com/tstest" + "tailscale.com/util/dnsname" ) var testipv4 = netaddr.MustParseIP("1.2.3.4") var testipv6 = netaddr.MustParseIP("0001:0203:0405:0607:0809:0a0b:0c0d:0e0f") var dnsCfg = Config{ - Hosts: map[string][]netaddr.IP{ + Hosts: map[dnsname.FQDN][]netaddr.IP{ "test1.ipn.dev.": []netaddr.IP{testipv4}, "test2.ipn.dev.": []netaddr.IP{testipv6}, }, - LocalDomains: []string{"ipn.dev."}, + LocalDomains: []dnsname.FQDN{"ipn.dev."}, } -func dnspacket(domain string, tp dns.Type) []byte { +func dnspacket(domain dnsname.FQDN, tp dns.Type) []byte { var dnsHeader dns.Header question := dns.Question{ - Name: dns.MustNewName(domain), + Name: dns.MustNewName(domain.WithTrailingDot()), Type: tp, Class: dns.ClassINET, } @@ -44,7 +45,7 @@ func dnspacket(domain string, tp dns.Type) []byte { type dnsResponse struct { ip netaddr.IP - name string + name dnsname.FQDN rcode dns.RCode } @@ -94,7 +95,10 @@ func unpackResponse(payload []byte) (dnsResponse, error) { if err != nil { return response, err } - response.name = res.NS.String() + response.name, err = dnsname.ToFQDN(res.NS.String()) + if err != nil { + return response, err + } default: return response, errors.New("type not in {A, AAAA, NS}") } @@ -119,7 +123,7 @@ func mustIP(str string) netaddr.IP { func TestRDNSNameToIPv4(t *testing.T) { tests := []struct { name string - input string + input dnsname.FQDN wantIP netaddr.IP wantOK bool }{ @@ -144,7 +148,7 @@ func TestRDNSNameToIPv4(t *testing.T) { func TestRDNSNameToIPv6(t *testing.T) { tests := []struct { name string - input string + input dnsname.FQDN wantIP netaddr.IP wantOK bool }{ @@ -194,7 +198,7 @@ func TestResolveLocal(t *testing.T) { tests := []struct { name string - qname string + qname dnsname.FQDN qtype dns.Type ip netaddr.IP code dns.RCode @@ -235,7 +239,7 @@ func TestResolveLocalReverse(t *testing.T) { tests := []struct { name string ip netaddr.IP - want string + want dnsname.FQDN code dns.RCode }{ {"ipv4", testipv4, "test1.ipn.dev.", dns.RCodeSuccess}, @@ -285,7 +289,7 @@ func TestDelegate(t *testing.T) { defer r.Close() cfg := dnsCfg - cfg.Routes = map[string][]netaddr.IPPort{ + cfg.Routes = map[dnsname.FQDN][]netaddr.IPPort{ ".": { netaddr.MustParseIPPort(v4server.PacketConn.LocalAddr().String()), netaddr.MustParseIPPort(v6server.PacketConn.LocalAddr().String()), @@ -360,7 +364,7 @@ func TestDelegateSplitRoute(t *testing.T) { defer r.Close() cfg := dnsCfg - cfg.Routes = map[string][]netaddr.IPPort{ + cfg.Routes = map[dnsname.FQDN][]netaddr.IPPort{ ".": {netaddr.MustParseIPPort(server1.PacketConn.LocalAddr().String())}, "other.": {netaddr.MustParseIPPort(server2.PacketConn.LocalAddr().String())}, } @@ -417,7 +421,7 @@ func TestDelegateCollision(t *testing.T) { defer r.Close() cfg := dnsCfg - cfg.Routes = map[string][]netaddr.IPPort{ + cfg.Routes = map[dnsname.FQDN][]netaddr.IPPort{ ".": { netaddr.MustParseIPPort(server.PacketConn.LocalAddr().String()), }, @@ -425,7 +429,7 @@ func TestDelegateCollision(t *testing.T) { r.SetConfig(cfg) packets := []struct { - qname string + qname dnsname.FQDN qtype dns.Type addr netaddr.IPPort }{ @@ -692,7 +696,7 @@ func TestAllocs(t *testing.T) { func TestTrimRDNSBonjourPrefix(t *testing.T) { tests := []struct { - in string + in dnsname.FQDN want bool }{ {"b._dns-sd._udp.0.10.20.172.in-addr.arpa.", true}, @@ -702,7 +706,6 @@ func TestTrimRDNSBonjourPrefix(t *testing.T) { {"lb._dns-sd._udp.0.10.20.172.in-addr.arpa.", true}, {"qq._dns-sd._udp.0.10.20.172.in-addr.arpa.", false}, {"0.10.20.172.in-addr.arpa.", false}, - {"i-have-no-dot", false}, } for _, test := range tests { @@ -722,7 +725,7 @@ func BenchmarkFull(b *testing.B) { defer r.Close() cfg := dnsCfg - cfg.Routes = map[string][]netaddr.IPPort{ + cfg.Routes = map[dnsname.FQDN][]netaddr.IPPort{ ".": { netaddr.MustParseIPPort(server.PacketConn.LocalAddr().String()), }, diff --git a/util/dnsname/dnsname.go b/util/dnsname/dnsname.go index e4d17940f..4a0cf8eaa 100644 --- a/util/dnsname/dnsname.go +++ b/util/dnsname/dnsname.go @@ -5,45 +5,134 @@ // Package dnsname contains string functions for working with DNS names. package dnsname -import "strings" +import ( + "fmt" + "strings" +) -var separators = map[byte]bool{ - ' ': true, - '.': true, - '@': true, - '_': true, -} +const ( + // maxLabelLength is the maximum length of a label permitted by RFC 1035. + maxLabelLength = 63 + // maxNameLength is the maximum length of a DNS name. + maxNameLength = 253 +) -func islower(c byte) bool { - return 'a' <= c && c <= 'z' +// A FQDN is a fully-qualified DNS name or name suffix. +type FQDN string + +func ToFQDN(s string) (FQDN, error) { + if isValidFQDN(s) { + return FQDN(s), nil + } + if len(s) == 0 { + return FQDN("."), nil + } + + if s[len(s)-1] == '.' { + s = s[:len(s)-1] + } + if len(s) > maxNameLength { + return "", fmt.Errorf("%q is too long to be a DNS name", s) + } + + fs := strings.Split(s, ".") + for _, f := range fs { + if !validLabel(f) { + return "", fmt.Errorf("%q is not a valid DNS label", f) + } + } + + return FQDN(s + "."), nil } -func isupper(c byte) bool { - return 'A' <= c && c <= 'Z' +func validLabel(s string) bool { + if len(s) == 0 || len(s) > maxLabelLength { + return false + } + if !isalphanum(s[0]) || !isalphanum(s[len(s)-1]) { + return false + } + for i := 1; i < len(s)-1; i++ { + if !isalphanum(s[i]) && s[i] != '-' { + return false + } + } + return true } -func isalpha(c byte) bool { - return islower(c) || isupper(c) +// WithTrailingDot returns f as a string, with a trailing dot. +func (f FQDN) WithTrailingDot() string { + return string(f) } -func isalphanum(c byte) bool { - return isalpha(c) || ('0' <= c && c <= '9') +// WithoutTrailingDot returns f as a string, with the trailing dot +// removed. +func (f FQDN) WithoutTrailingDot() string { + return string(f[:len(f)-1]) } -func isdnschar(c byte) bool { - return isalphanum(c) || c == '-' +func (f FQDN) NumLabels() int { + if f == "." { + return 0 + } + return strings.Count(f.WithTrailingDot(), ".") } -func tolower(c byte) byte { - if isupper(c) { - return c + 'a' - 'A' - } else { - return c +func (f FQDN) Contains(other FQDN) bool { + if f == other { + return true + } + cmp := f.WithTrailingDot() + if cmp != "." { + cmp = "." + cmp } + return strings.HasSuffix(other.WithTrailingDot(), cmp) } -// maxLabelLength is the maximal length of a label permitted by RFC 1035. -const maxLabelLength = 63 +// isValidFQDN reports whether s is already a valid FQDN, without +// allocating. +func isValidFQDN(s string) bool { + if len(s) == 0 { + return false + } + if len(s) > maxNameLength { + return false + } + // DNS root name. + if s == "." { + return true + } + // Missing trailing dot. + if s[len(s)-1] != '.' { + return false + } + // Leading dots not allowed. + if s[0] == '.' { + return false + } + + st := 0 + for i := 0; i < len(s); i++ { + if s[i] != '.' { + continue + } + label := s[st:i] + if len(label) == 0 || len(label) > maxLabelLength { + return false + } + if !isalphanum(label[0]) || !isalphanum(label[len(label)-1]) { + return false + } + for j := 1; j < len(label)-1; j++ { + if !isalphanum(label[j]) && label[j] != '-' { + return false + } + } + st = i + 1 + } + + return true +} // SanitizeLabel takes a string intended to be a DNS name label // and turns it into a valid name label according to RFC 1035. @@ -133,3 +222,38 @@ func NumLabels(hostname string) int { } return strings.Count(hostname, ".") } + +var separators = map[byte]bool{ + ' ': true, + '.': true, + '@': true, + '_': true, +} + +func islower(c byte) bool { + return 'a' <= c && c <= 'z' +} + +func isupper(c byte) bool { + return 'A' <= c && c <= 'Z' +} + +func isalpha(c byte) bool { + return islower(c) || isupper(c) +} + +func isalphanum(c byte) bool { + return isalpha(c) || ('0' <= c && c <= '9') +} + +func isdnschar(c byte) bool { + return isalphanum(c) || c == '-' +} + +func tolower(c byte) byte { + if isupper(c) { + return c + 'a' - 'A' + } else { + return c + } +} diff --git a/util/dnsname/dnsname_test.go b/util/dnsname/dnsname_test.go index 166040de0..3df40dded 100644 --- a/util/dnsname/dnsname_test.go +++ b/util/dnsname/dnsname_test.go @@ -9,6 +9,87 @@ import ( "testing" ) +func TestFQDN(t *testing.T) { + tests := []struct { + in string + want FQDN + wantErr bool + wantLabels int + }{ + {"", ".", false, 0}, + {".", ".", false, 0}, + {"foo.com", "foo.com.", false, 2}, + {"foo.com.", "foo.com.", false, 2}, + {"com", "com.", false, 1}, + {"www.tailscale.com", "www.tailscale.com.", false, 3}, + {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", "", true, 0}, + {strings.Repeat("aaaaa.", 60) + "com", "", true, 0}, + {".com", "", true, 0}, + {"foo..com", "", true, 0}, + } + + for _, test := range tests { + t.Run(test.in, func(t *testing.T) { + got, err := ToFQDN(test.in) + if got != test.want { + t.Errorf("ToFQDN(%q) got %q, want %q", test.in, got, test.want) + } + if (err != nil) != test.wantErr { + t.Errorf("ToFQDN(%q) err %v, wantErr=%v", test.in, err, test.wantErr) + } + if err != nil { + return + } + + gotDot := got.WithTrailingDot() + if gotDot != string(test.want) { + t.Errorf("ToFQDN(%q).WithTrailingDot() got %q, want %q", test.in, gotDot, test.want) + } + gotNoDot := got.WithoutTrailingDot() + wantNoDot := string(test.want)[:len(test.want)-1] + if gotNoDot != wantNoDot { + t.Errorf("ToFQDN(%q).WithoutTrailingDot() got %q, want %q", test.in, gotNoDot, wantNoDot) + } + + if gotLabels := got.NumLabels(); gotLabels != test.wantLabels { + t.Errorf("ToFQDN(%q).NumLabels() got %v, want %v", test.in, gotLabels, test.wantLabels) + } + }) + } +} + +func TestFQDNContains(t *testing.T) { + tests := []struct { + a, b string + want bool + }{ + {"", "", true}, + {"", "foo.com", true}, + {"foo.com", "", false}, + {"tailscale.com", "www.tailscale.com", true}, + {"www.tailscale.com", "tailscale.com", false}, + {"scale.com", "tailscale.com", false}, + {"foo.com", "foo.com", true}, + } + + for _, test := range tests { + t.Run(test.a+"_"+test.b, func(t *testing.T) { + a, err := ToFQDN(test.a) + if err != nil { + t.Fatalf("ToFQDN(%q): %v", test.a, err) + } + b, err := ToFQDN(test.b) + if err != nil { + t.Fatalf("ToFQDN(%q): %v", test.b, err) + } + + if got := a.Contains(b); got != test.want { + t.Errorf("ToFQDN(%q).Contains(%q) got %v, want %v", a, b, got, test.want) + } + }) + } +} + func TestSanitizeLabel(t *testing.T) { tests := []struct { name string