diff options
| author | Richard Castro <richard@tailscale.com> | 2023-10-12 15:20:43 -0700 |
|---|---|---|
| committer | Richard Castro <richard@tailscale.com> | 2023-10-27 17:41:31 -0700 |
| commit | 754cda3b9aaab27f91adcf950a9a94056dbb0dd7 (patch) | |
| tree | a1a85fea7a39527bc2ae47979bd2c3c3560cdd30 | |
| parent | b4247fabecfc5f1536943f027e660887364fc6e4 (diff) | |
| download | tailscale-richard/15037-2.tar.xz tailscale-richard/15037-2.zip | |
net/dns/resolver: add subdomain resolver support in MagicDNSrichard/15037-2
This PR adds processing of subdomains that if there is an entry for a
wildcard entry in hosts for a domain, MagicDNS will resolve with the
host IP.
Fixes #15037
Signed-off-by: Richard Castro <richard@tailscale.com>
| -rw-r--r-- | ipn/ipnlocal/local.go | 15 | ||||
| -rw-r--r-- | net/dns/config.go | 8 | ||||
| -rw-r--r-- | net/dns/resolver/tsdns.go | 31 | ||||
| -rw-r--r-- | net/dns/resolver/tsdns_test.go | 29 | ||||
| -rw-r--r-- | tailcfg/tailcfg.go | 9 | ||||
| -rw-r--r-- | util/dnsname/dnsname.go | 35 | ||||
| -rw-r--r-- | util/dnsname/dnsname_test.go | 86 |
7 files changed, 196 insertions, 17 deletions
diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 682add38e..6ea0e4dd1 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -3383,11 +3383,22 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg. // Ignore. continue } - fqdn, err := dnsname.ToFQDN(rec.Name) + // If the name has a leading dot, but is not exactly '.'. + var isSuffix bool + // Assume upstream provides either a suffix or an FQDN that are + // respectively well formed. + if len(rec.Name) > 1 && rec.Name[0] == '.' { + isSuffix = true + } + fqdn, err := dnsname.NewFQDN(rec.Name) if err != nil { continue } - dcfg.Hosts[fqdn] = append(dcfg.Hosts[fqdn], ip) + if isSuffix { + mak.Set(&dcfg.Suffixes, fqdn, append(dcfg.Suffixes[fqdn], ip)) + } else { + dcfg.Hosts[fqdn] = append(dcfg.Hosts[fqdn], ip) + } } if !prefs.CorpDNS() { diff --git a/net/dns/config.go b/net/dns/config.go index 9c55f6d73..7361a74b3 100644 --- a/net/dns/config.go +++ b/net/dns/config.go @@ -41,6 +41,14 @@ type Config struct { // it to resolve, you also need to add appropriate routes to // Routes. Hosts map[dnsname.FQDN][]netip.Addr + // Suffixes maps DNS FQDNs and all subdomains to their IPs, which can be a + // mix of IPv4 and IPv6. + // Queries matching entries in Suffixes are resolved locally by + // 100.100.100.100 without leaving the machine. + // Adding an entry to Suffixes merely creates the record. If you want + // it to resolve, you also need to add appropriate routes to + // Routes. + Suffixes map[dnsname.FQDN][]netip.Addr // OnlyIPv6, if true, uses the IPv6 service IP (for MagicDNS) // instead of the IPv4 version (100.100.100.100). OnlyIPv6 bool diff --git a/net/dns/resolver/tsdns.go b/net/dns/resolver/tsdns.go index ddb7b6cdb..92cf5bc3c 100644 --- a/net/dns/resolver/tsdns.go +++ b/net/dns/resolver/tsdns.go @@ -71,8 +71,11 @@ type Config struct { // Queries only match the most specific suffix. // To register a "default route", add an entry for ".". Routes map[dnsname.FQDN][]*dnstype.Resolver - // LocalHosts is a map of FQDNs to corresponding IPs. + // Hosts is a map of FQDNs to corresponding IPs. Hosts map[dnsname.FQDN][]netip.Addr + // Suffixes is a map of FQDNs to corresponding IPs that should match all + // subdomains and the suffix itself. + Suffixes map[dnsname.FQDN][]netip.Addr // LocalDomains is a list of DNS name suffixes that should not be // routed to upstream resolvers. LocalDomains []dnsname.FQDN @@ -195,8 +198,11 @@ type Resolver struct { // mu guards the following fields from being updated while used. mu sync.Mutex localDomains []dnsname.FQDN - hostToIP map[dnsname.FQDN][]netip.Addr - ipToHost map[netip.Addr]dnsname.FQDN + // hostToIP maps a single FQDN to one or more IPs. + hostToIP map[dnsname.FQDN][]netip.Addr + // suffixes maps and FQDN and all subdomains to one or more IPs. + suffixes map[dnsname.FQDN][]netip.Addr + ipToHost map[netip.Addr]dnsname.FQDN } type ForwardLinkSelector interface { @@ -246,6 +252,7 @@ func (r *Resolver) SetConfig(cfg Config) error { r.localDomains = cfg.LocalDomains r.hostToIP = cfg.Hosts r.ipToHost = reverse + r.suffixes = cfg.Suffixes return nil } @@ -596,11 +603,25 @@ func (r *Resolver) resolveLocal(domain dnsname.FQDN, typ dns.Type) (netip.Addr, r.mu.Lock() hosts := r.hostToIP + suffixes := r.suffixes localDomains := r.localDomains r.mu.Unlock() - addrs, found := hosts[domain] - if !found { + addrs, ok := hosts[domain] + if !ok { + // Look for a matching suffix in suffixes. This implementation prefers + // the case where the number of domain labels is typically few, and the + // list of suffix candidates to match is arbitrarily sized. + d := domain.WithTrailingDot() + for ix := strings.IndexRune(d, '.'); ix >= 0; ix = strings.IndexRune(d, '.') { + d = d[ix+1:] + if addrs, ok = suffixes[dnsname.FQDN(d)]; ok { + break + } + } + } + + if !ok { for _, suffix := range localDomains { if suffix.Contains(domain) { // We are authoritative for the queried domain. diff --git a/net/dns/resolver/tsdns_test.go b/net/dns/resolver/tsdns_test.go index 882462012..af3861deb 100644 --- a/net/dns/resolver/tsdns_test.go +++ b/net/dns/resolver/tsdns_test.go @@ -32,8 +32,11 @@ import ( ) var ( - testipv4 = netip.MustParseAddr("1.2.3.4") - testipv6 = netip.MustParseAddr("0001:0203:0405:0607:0809:0a0b:0c0d:0e0f") + testipv4 = netip.MustParseAddr("1.2.3.4") + testipv6 = netip.MustParseAddr("0001:0203:0405:0607:0809:0a0b:0c0d:0e0f") + testipv4alt1 = netip.MustParseAddr("2.2.3.4") + testipv4alt2 = netip.MustParseAddr("12.2.3.4") + testipv4alt3 = netip.MustParseAddr("21.2.3.4") testipv4Arpa = dnsname.FQDN("4.3.2.1.in-addr.arpa.") testipv6Arpa = dnsname.FQDN("f.0.e.0.d.0.c.0.b.0.a.0.9.0.8.0.7.0.6.0.5.0.4.0.3.0.2.0.1.0.0.0.ip6.arpa.") @@ -43,8 +46,13 @@ var ( var dnsCfg = Config{ Hosts: map[dnsname.FQDN][]netip.Addr{ - "test1.ipn.dev.": {testipv4}, - "test2.ipn.dev.": {testipv6}, + "test1.ipn.dev.": {testipv4}, + "test2.ipn.dev.": {testipv6}, + "nonwild.subdomain.test.": {testipv4alt1}, + }, + Suffixes: map[dnsname.FQDN][]netip.Addr{ + "domain.test.": {testipv4alt2}, + "sub.domain.test.": {testipv4alt3}, }, LocalDomains: []dnsname.FQDN{"ipn.dev.", "3.2.1.in-addr.arpa.", "1.0.0.0.ip6.arpa."}, } @@ -361,6 +369,11 @@ func TestResolveLocal(t *testing.T) { // suffixes are currently hard-coded and not plumbed via the netmap) {"via_form3_dec_example.com", dnsname.FQDN("1-2-3-4-via-1.example.com."), dns.TypeAAAA, netip.Addr{}, dns.RCodeRefused}, {"via_form3_dec_examplets.net", dnsname.FQDN("1-2-3-4-via-1.examplets.net."), dns.TypeAAAA, netip.Addr{}, dns.RCodeRefused}, + + // subdomain entry for app connectors + {"subdomain", dnsname.FQDN(".domain.test."), dns.TypeA, testipv4alt2, dns.RCodeSuccess}, + {"exact subdomain", dnsname.FQDN("deep.sub.domain.test."), dns.TypeA, testipv4alt3, dns.RCodeSuccess}, + {"priority subdomain", dnsname.FQDN("priority.sub.domain.test."), dns.TypeA, testipv4alt3, dns.RCodeSuccess}, } for _, tt := range tests { @@ -375,6 +388,14 @@ func TestResolveLocal(t *testing.T) { } }) } + + // Wilcard paths should have 0 allocs + allocs := testing.AllocsPerRun(1000, func() { + r.resolveLocal(dnsname.FQDN(".domain.test."), dns.TypeA) + }) + if allocs > 0 { + t.Errorf("allocs per run = %v; want 0", allocs) + } } func TestResolveLocalReverse(t *testing.T) { diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index fd45108bc..ec11bd3d6 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -120,7 +120,9 @@ type CapabilityVersion int // - 77: 2023-10-03: Client understands Peers[].SelfNodeV6MasqAddrForThisPeer // - 78: 2023-10-05: can handle c2n Wake-on-LAN sending // - 79: 2023-10-05: Client understands UrgentSecurityUpdate in ClientVersion -const CurrentCapabilityVersion CapabilityVersion = 79 +// - 80: 2023-10-16: wildcards are supported as entries in Config.Hosts + +const CurrentCapabilityVersion CapabilityVersion = 80 type StableID string @@ -1553,7 +1555,10 @@ type DNSConfig struct { // DNSRecord is an extra DNS record to add to MagicDNS. type DNSRecord struct { // Name is the fully qualified domain name of - // the record to add. The trailing dot is optional. + // the record to add. If a leading dot is + // present, the record will serve all + // subomdains as well as the fully qualified + // domain name. The trailing dot is optional. Name string // Type is the DNS record type. diff --git a/util/dnsname/dnsname.go b/util/dnsname/dnsname.go index 6481a5867..f3c4f0ea8 100644 --- a/util/dnsname/dnsname.go +++ b/util/dnsname/dnsname.go @@ -20,14 +20,12 @@ const ( // A FQDN is a fully-qualified DNS name or name suffix. type FQDN string -func ToFQDN(s string) (FQDN, error) { +// NewFQDN converts a string to a FQDN that retains any leading '.' in the case of wildcards. +func NewFQDN(s string) (FQDN, error) { if len(s) == 0 || s == "." { return FQDN("."), nil } - if s[0] == '.' { - s = s[1:] - } raw := s totalLen := len(s) if s[len(s)-1] == '.' { @@ -41,6 +39,11 @@ func ToFQDN(s string) (FQDN, error) { st := 0 for i := 0; i < len(s); i++ { + // Ignore leading '.' from wildcards for label processing + if i == 0 && s[i] == '.' { + st = i + 1 + continue + } if s[i] != '.' { continue } @@ -65,6 +68,30 @@ func ToFQDN(s string) (FQDN, error) { return FQDN(raw), nil } +// ToFQDN strips a leading '.' on a string before converting to a FQDN. +func ToFQDN(s string) (FQDN, error) { + if len(s) == 0 || s == "." { + return FQDN("."), nil + } + + if s[0] == '.' { + s = s[1:] + } + return NewFQDN(s) +} + +// ToFQDNSuffix returns an FQDN with a leading '.'. +func ToFQDNSuffix(s string) (FQDN, error) { + if len(s) == 0 || s == "." { + return FQDN("."), nil + } + + if s[0] != '.' { + s = "." + s + } + return NewFQDN(s) +} + // WithTrailingDot returns f as a string, with a trailing dot. func (f FQDN) WithTrailingDot() string { return string(f) diff --git a/util/dnsname/dnsname_test.go b/util/dnsname/dnsname_test.go index 563959d33..0666d966a 100644 --- a/util/dnsname/dnsname_test.go +++ b/util/dnsname/dnsname_test.go @@ -210,6 +210,92 @@ func TestValidHostname(t *testing.T) { } } +func TestNewFQDN(t *testing.T) { + tests := []struct { + in string + want FQDN + wantErr bool + wantLabels int + }{ + {"", ".", false, 0}, + {".", ".", false, 0}, + {".foo.com", ".foo.com.", false, 3}, + {"foo.com.", "foo.com.", false, 2}, + } + + for _, test := range tests { + t.Run(test.in, func(t *testing.T) { + got, err := NewFQDN(test.in) + if got != test.want { + t.Errorf("NewFQDN(%q) got %q, want %q", test.in, got, test.want) + } + if (err != nil) != test.wantErr { + t.Errorf("NewFQDN(%q) err %v, wantErr=%v", test.in, err, test.wantErr) + } + if err != nil { + return + } + + gotDot := got.WithTrailingDot() + if gotDot != string(test.want) { + t.Errorf("NewFQDN(%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("NewFQDN(%q).WithoutTrailingDot() got %q, want %q", test.in, gotNoDot, wantNoDot) + } + + if gotLabels := got.NumLabels(); gotLabels != test.wantLabels { + t.Errorf("NewFQDN(%q).NumLabels() got %v, want %v", test.in, gotLabels, test.wantLabels) + } + }) + } +} + +func TestToFQDNSuffix(t *testing.T) { + tests := []struct { + in string + want FQDN + wantErr bool + wantLabels int + }{ + {"", ".", false, 0}, + {".", ".", false, 0}, + {".foo.com", ".foo.com.", false, 3}, + {"foo.com.", ".foo.com.", false, 3}, + } + + for _, test := range tests { + t.Run(test.in, func(t *testing.T) { + got, err := ToFQDNSuffix(test.in) + if got != test.want { + t.Errorf("ToFQDNSuffix(%q) got %q, want %q", test.in, got, test.want) + } + if (err != nil) != test.wantErr { + t.Errorf("ToFQDNSuffix(%q) err %v, wantErr=%v", test.in, err, test.wantErr) + } + if err != nil { + return + } + + gotDot := got.WithTrailingDot() + if gotDot != string(test.want) { + t.Errorf("ToFQDNSuffix(%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("ToFQDNSuffix(%q).WithoutTrailingDot() got %q, want %q", test.in, gotNoDot, wantNoDot) + } + + if gotLabels := got.NumLabels(); gotLabels != test.wantLabels { + t.Errorf("ToFQDNSuffix(%q).NumLabels() got %v, want %v", test.in, gotLabels, test.wantLabels) + } + }) + } +} + var sinkFQDN FQDN func BenchmarkToFQDN(b *testing.B) { |
