diff --git a/ipn/ipnlocal/dnsconfig_test.go b/ipn/ipnlocal/dnsconfig_test.go index e23d8a057..2e2e37e9f 100644 --- a/ipn/ipnlocal/dnsconfig_test.go +++ b/ipn/ipnlocal/dnsconfig_test.go @@ -18,6 +18,7 @@ import ( "tailscale.com/types/netmap" "tailscale.com/util/cloudenv" "tailscale.com/util/dnsname" + "tailscale.com/util/set" ) func ipps(ippStrs ...string) (ipps []netip.Prefix) { @@ -63,8 +64,9 @@ func TestDNSConfigForNetmap(t *testing.T) { nm: &netmap.NetworkMap{}, prefs: &ipn.Prefs{}, want: &dns.Config{ - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: map[dnsname.FQDN][]netip.Addr{}, + Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, + Hosts: map[dnsname.FQDN][]netip.Addr{}, + SubdomainHosts: map[dnsname.FQDN]bool{}, }, }, { @@ -101,6 +103,43 @@ func TestDNSConfigForNetmap(t *testing.T) { "peera.net.": ips("100.102.0.1", "100.102.0.2"), "v6-only.net.": ips("fe75::3"), }, + SubdomainHosts: map[dnsname.FQDN]bool{}, + }, + }, + { + name: "subdomain_resolve_capability", + nm: &netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + Name: "myname.net.", + Addresses: ipps("100.101.101.101"), + }).View(), + AllCaps: set.SetOf([]tailcfg.NodeCapability{tailcfg.NodeAttrDNSSubdomainResolve}), + }, + peers: nodeViews([]*tailcfg.Node{ + { + ID: 1, + Name: "peer-with-cap.net.", + Addresses: ipps("100.102.0.1"), + CapMap: tailcfg.NodeCapMap{tailcfg.NodeAttrDNSSubdomainResolve: nil}, + }, + { + ID: 2, + Name: "peer-without-cap.net.", + Addresses: ipps("100.102.0.2"), + }, + }), + prefs: &ipn.Prefs{}, + want: &dns.Config{ + Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, + Hosts: map[dnsname.FQDN][]netip.Addr{ + "myname.net.": ips("100.101.101.101"), + "peer-with-cap.net.": ips("100.102.0.1"), + "peer-without-cap.net.": ips("100.102.0.2"), + }, + SubdomainHosts: map[dnsname.FQDN]bool{ + "myname.net.": true, + "peer-with-cap.net.": true, + }, }, }, { @@ -141,6 +180,7 @@ func TestDNSConfigForNetmap(t *testing.T) { "peera.net.": ips("fe75::1001"), "v6-only.net.": ips("fe75::3"), }, + SubdomainHosts: map[dnsname.FQDN]bool{}, }, }, { @@ -166,6 +206,7 @@ func TestDNSConfigForNetmap(t *testing.T) { "foo.com.": ips("1.2.3.4"), "bar.com.": ips("1::6"), }, + SubdomainHosts: map[dnsname.FQDN]bool{}, }, }, { @@ -256,6 +297,7 @@ func TestDNSConfigForNetmap(t *testing.T) { "foo.com.", "bar.com.", }, + SubdomainHosts: map[dnsname.FQDN]bool{}, }, }, { @@ -290,6 +332,7 @@ func TestDNSConfigForNetmap(t *testing.T) { Routes: map[dnsname.FQDN][]*dnstype.Resolver{ "foo.com.": {{Addr: "1.2.3.4"}}, }, + SubdomainHosts: map[dnsname.FQDN]bool{}, }, }, { @@ -311,6 +354,7 @@ func TestDNSConfigForNetmap(t *testing.T) { DefaultResolvers: []*dnstype.Resolver{ {Addr: "8.8.4.4"}, }, + SubdomainHosts: map[dnsname.FQDN]bool{}, }, }, { @@ -326,8 +370,9 @@ func TestDNSConfigForNetmap(t *testing.T) { CorpDNS: true, }, want: &dns.Config{ - Hosts: map[dnsname.FQDN][]netip.Addr{}, - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, + Hosts: map[dnsname.FQDN][]netip.Addr{}, + Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, + SubdomainHosts: map[dnsname.FQDN]bool{}, }, }, { diff --git a/ipn/ipnlocal/node_backend.go b/ipn/ipnlocal/node_backend.go index efef57ea4..c6a6e2ba6 100644 --- a/ipn/ipnlocal/node_backend.go +++ b/ipn/ipnlocal/node_backend.go @@ -694,8 +694,9 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg. } dcfg := &dns.Config{ - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: map[dnsname.FQDN][]netip.Addr{}, + Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, + Hosts: map[dnsname.FQDN][]netip.Addr{}, + SubdomainHosts: map[dnsname.FQDN]bool{}, } // selfV6Only is whether we only have IPv6 addresses ourselves. @@ -749,8 +750,18 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg. dcfg.Hosts[fqdn] = ips } set(nm.SelfName(), nm.GetAddresses()) + if nm.AllCaps.Contains(tailcfg.NodeAttrDNSSubdomainResolve) { + if fqdn, err := dnsname.ToFQDN(nm.SelfName()); err == nil { + dcfg.SubdomainHosts[fqdn] = true + } + } for _, peer := range peers { set(peer.Name(), peer.Addresses()) + if peer.CapMap().Contains(tailcfg.NodeAttrDNSSubdomainResolve) { + if fqdn, err := dnsname.ToFQDN(peer.Name()); err == nil { + dcfg.SubdomainHosts[fqdn] = true + } + } } for _, rec := range nm.DNS.ExtraRecords { switch rec.Type { diff --git a/ipn/ipnlocal/state_test.go b/ipn/ipnlocal/state_test.go index 27d53fe01..1dac83fce 100644 --- a/ipn/ipnlocal/state_test.go +++ b/ipn/ipnlocal/state_test.go @@ -1300,8 +1300,9 @@ func TestEngineReconfigOnStateChange(t *testing.T) { Routes: routesWithQuad100(), }, wantDNSCfg: &dns.Config{ - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: hostsFor(node1), + Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, + Hosts: hostsFor(node1), + SubdomainHosts: map[dnsname.FQDN]bool{}, }, }, { @@ -1356,8 +1357,9 @@ func TestEngineReconfigOnStateChange(t *testing.T) { Routes: routesWithQuad100(), }, wantDNSCfg: &dns.Config{ - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: hostsFor(node2), + Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, + Hosts: hostsFor(node2), + SubdomainHosts: map[dnsname.FQDN]bool{}, }, }, { @@ -1404,8 +1406,9 @@ func TestEngineReconfigOnStateChange(t *testing.T) { Routes: routesWithQuad100(), }, wantDNSCfg: &dns.Config{ - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: hostsFor(node1), + Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, + Hosts: hostsFor(node1), + SubdomainHosts: map[dnsname.FQDN]bool{}, }, }, { @@ -1436,8 +1439,9 @@ func TestEngineReconfigOnStateChange(t *testing.T) { Routes: routesWithQuad100(), }, wantDNSCfg: &dns.Config{ - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: hostsFor(node3), + Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, + Hosts: hostsFor(node3), + SubdomainHosts: map[dnsname.FQDN]bool{}, }, }, { @@ -1500,8 +1504,9 @@ func TestEngineReconfigOnStateChange(t *testing.T) { Routes: routesWithQuad100(), }, wantDNSCfg: &dns.Config{ - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: hostsFor(node1), + Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, + Hosts: hostsFor(node1), + SubdomainHosts: map[dnsname.FQDN]bool{}, }, }, { @@ -1529,8 +1534,9 @@ func TestEngineReconfigOnStateChange(t *testing.T) { Routes: routesWithQuad100(), }, wantDNSCfg: &dns.Config{ - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: hostsFor(node1), + Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, + Hosts: hostsFor(node1), + SubdomainHosts: map[dnsname.FQDN]bool{}, }, }, { @@ -1560,8 +1566,9 @@ func TestEngineReconfigOnStateChange(t *testing.T) { Routes: routesWithQuad100(), }, wantDNSCfg: &dns.Config{ - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: hostsFor(node1), + Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, + Hosts: hostsFor(node1), + SubdomainHosts: map[dnsname.FQDN]bool{}, }, }, { diff --git a/net/dns/config.go b/net/dns/config.go index 2425b304d..b3fb208ee 100644 --- a/net/dns/config.go +++ b/net/dns/config.go @@ -48,6 +48,11 @@ type Config struct { // it to resolve, you also need to add appropriate routes to // Routes. Hosts map[dnsname.FQDN][]netip.Addr + // SubdomainHosts is a set of FQDNs from Hosts that should also + // resolve subdomain queries to the same IPs. For example, if + // "node.tailnet.ts.net" is in SubdomainHosts, then queries for + // "anything.node.tailnet.ts.net" will resolve to node's IPs. + SubdomainHosts map[dnsname.FQDN]bool // 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/dns_clone.go b/net/dns/dns_clone.go index 807bfce23..2d242abc2 100644 --- a/net/dns/dns_clone.go +++ b/net/dns/dns_clone.go @@ -6,6 +6,7 @@ package dns import ( + "maps" "net/netip" "tailscale.com/types/dnstype" @@ -43,6 +44,7 @@ func (src *Config) Clone() *Config { dst.Hosts[k] = append([]netip.Addr{}, src.Hosts[k]...) } } + dst.SubdomainHosts = maps.Clone(src.SubdomainHosts) return dst } @@ -52,6 +54,7 @@ var _ConfigCloneNeedsRegeneration = Config(struct { Routes map[dnsname.FQDN][]*dnstype.Resolver SearchDomains []dnsname.FQDN Hosts map[dnsname.FQDN][]netip.Addr + SubdomainHosts map[dnsname.FQDN]bool OnlyIPv6 bool }{}) diff --git a/net/dns/dns_view.go b/net/dns/dns_view.go index c7ce376cb..d83ad683f 100644 --- a/net/dns/dns_view.go +++ b/net/dns/dns_view.go @@ -123,6 +123,14 @@ func (v ConfigView) Hosts() views.MapSlice[dnsname.FQDN, netip.Addr] { return views.MapSliceOf(v.ж.Hosts) } +// SubdomainHosts is a set of FQDNs from Hosts that should also +// resolve subdomain queries to the same IPs. For example, if +// "node.tailnet.ts.net" is in SubdomainHosts, then queries for +// "anything.node.tailnet.ts.net" will resolve to node's IPs. +func (v ConfigView) SubdomainHosts() views.Map[dnsname.FQDN, bool] { + return views.MapOf(v.ж.SubdomainHosts) +} + // OnlyIPv6, if true, uses the IPv6 service IP (for MagicDNS) // instead of the IPv4 version (100.100.100.100). func (v ConfigView) OnlyIPv6() bool { return v.ж.OnlyIPv6 } @@ -134,5 +142,6 @@ var _ConfigViewNeedsRegeneration = Config(struct { Routes map[dnsname.FQDN][]*dnstype.Resolver SearchDomains []dnsname.FQDN Hosts map[dnsname.FQDN][]netip.Addr + SubdomainHosts map[dnsname.FQDN]bool OnlyIPv6 bool }{}) diff --git a/net/dns/manager.go b/net/dns/manager.go index 4441c4f69..8b68ecc92 100644 --- a/net/dns/manager.go +++ b/net/dns/manager.go @@ -291,6 +291,7 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig // authoritative suffixes, even if we don't propagate MagicDNS to // the OS. rcfg.Hosts = cfg.Hosts + rcfg.SubdomainHosts = cfg.SubdomainHosts routes := map[dnsname.FQDN][]*dnstype.Resolver{} // assigned conditionally to rcfg.Routes below. var propagateHostsToOS bool for suffix, resolvers := range cfg.Routes { diff --git a/net/dns/resolver/tsdns.go b/net/dns/resolver/tsdns.go index 3185cbe2b..97a19d43b 100644 --- a/net/dns/resolver/tsdns.go +++ b/net/dns/resolver/tsdns.go @@ -79,6 +79,12 @@ type Config struct { // LocalDomains is a list of DNS name suffixes that should not be // routed to upstream resolvers. LocalDomains []dnsname.FQDN + // SubdomainHosts is a set of FQDNs from Hosts that should also + // resolve subdomain queries to the same IPs. If a query like + // "sub.node.tailnet.ts.net" doesn't match Hosts directly, and + // "node.tailnet.ts.net" is in SubdomainHosts, the query resolves + // to the IPs for "node.tailnet.ts.net". + SubdomainHosts map[dnsname.FQDN]bool } // WriteToBufioWriter write a debug version of c for logs to w, omitting @@ -214,10 +220,11 @@ type Resolver struct { closed chan struct{} // mu guards the following fields from being updated while used. - mu syncs.Mutex - localDomains []dnsname.FQDN - hostToIP map[dnsname.FQDN][]netip.Addr - ipToHost map[netip.Addr]dnsname.FQDN + mu syncs.Mutex + localDomains []dnsname.FQDN + hostToIP map[dnsname.FQDN][]netip.Addr + ipToHost map[netip.Addr]dnsname.FQDN + subdomainHosts map[dnsname.FQDN]bool } type ForwardLinkSelector interface { @@ -278,6 +285,7 @@ func (r *Resolver) SetConfig(cfg Config) error { r.localDomains = cfg.LocalDomains r.hostToIP = cfg.Hosts r.ipToHost = reverse + r.subdomainHosts = cfg.SubdomainHosts return nil } @@ -642,9 +650,18 @@ func (r *Resolver) resolveLocal(domain dnsname.FQDN, typ dns.Type) (netip.Addr, r.mu.Lock() hosts := r.hostToIP localDomains := r.localDomains + subdomainHosts := r.subdomainHosts r.mu.Unlock() addrs, found := hosts[domain] + if !found { + for parent := domain.Parent(); parent != ""; parent = parent.Parent() { + if subdomainHosts[parent] { + addrs, found = hosts[parent] + break + } + } + } if !found { for _, suffix := range localDomains { if suffix.Contains(domain) { diff --git a/net/dns/resolver/tsdns_test.go b/net/dns/resolver/tsdns_test.go index f0dbb48b3..1faf14882 100644 --- a/net/dns/resolver/tsdns_test.go +++ b/net/dns/resolver/tsdns_test.go @@ -429,6 +429,59 @@ func TestResolveLocal(t *testing.T) { } } +func TestResolveLocalSubdomain(t *testing.T) { + r := newResolver(t) + defer r.Close() + + // Configure with SubdomainHosts set for test1.ipn.dev + cfg := Config{ + Hosts: map[dnsname.FQDN][]netip.Addr{ + "test1.ipn.dev.": {testipv4}, + "test2.ipn.dev.": {testipv6}, + }, + LocalDomains: []dnsname.FQDN{"ipn.dev."}, + SubdomainHosts: map[dnsname.FQDN]bool{ + "test1.ipn.dev.": true, // test1 allows subdomain resolution + // test2 does NOT allow subdomain resolution + }, + } + r.SetConfig(cfg) + + tests := []struct { + name string + qname dnsname.FQDN + qtype dns.Type + ip netip.Addr + code dns.RCode + }{ + // Exact matches still work + {"exact-ipv4", "test1.ipn.dev.", dns.TypeA, testipv4, dns.RCodeSuccess}, + {"exact-ipv6", "test2.ipn.dev.", dns.TypeAAAA, testipv6, dns.RCodeSuccess}, + + // Subdomain of test1 resolves (test1 has SubdomainHosts set) + {"subdomain-ipv4", "foo.test1.ipn.dev.", dns.TypeA, testipv4, dns.RCodeSuccess}, + {"subdomain-deep", "bar.foo.test1.ipn.dev.", dns.TypeA, testipv4, dns.RCodeSuccess}, // Multi-level subdomain + + // Subdomain of test2 does NOT resolve (test2 lacks SubdomainHosts) + {"subdomain-no-cap", "foo.test2.ipn.dev.", dns.TypeAAAA, netip.Addr{}, dns.RCodeNameError}, + + // Non-existent parent still returns NXDOMAIN + {"subdomain-no-parent", "foo.test3.ipn.dev.", dns.TypeA, netip.Addr{}, dns.RCodeNameError}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ip, code := r.resolveLocal(tt.qname, tt.qtype) + if code != tt.code { + t.Errorf("code = %v; want %v", code, tt.code) + } + if ip != tt.ip { + t.Errorf("ip = %v; want %v", ip, tt.ip) + } + }) + } +} + func TestResolveLocalReverse(t *testing.T) { r := newResolver(t) defer r.Close() diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 8468aa09e..5a6abcb83 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -2707,6 +2707,12 @@ const ( // server to answer AAAA queries about its peers. See tailscale/tailscale#1152. NodeAttrMagicDNSPeerAAAA NodeCapability = "magicdns-aaaa" + // NodeAttrDNSSubdomainResolve indicates that subdomains of this node's + // MagicDNS name should resolve to the same IP addresses as the node itself. + // For example, if node "myserver.tailnet.ts.net" has this capability, + // then "anything.myserver.tailnet.ts.net" will resolve to myserver's IPs. + NodeAttrDNSSubdomainResolve NodeCapability = "dns-subdomain-resolve" + // NodeAttrTrafficSteering configures the node to use the traffic // steering subsystem for via routes. See tailscale/corp#29966. NodeAttrTrafficSteering NodeCapability = "traffic-steering" diff --git a/util/dnsname/dnsname.go b/util/dnsname/dnsname.go index ef898ebbd..74b694b12 100644 --- a/util/dnsname/dnsname.go +++ b/util/dnsname/dnsname.go @@ -94,6 +94,22 @@ func (f FQDN) Contains(other FQDN) bool { return strings.HasSuffix(other.WithTrailingDot(), cmp) } +// Parent returns the parent domain by stripping the first label. +// For "foo.bar.baz.", it returns "bar.baz." +// Returns empty FQDN for root or single-label domains. +func (f FQDN) Parent() FQDN { + s := f.WithTrailingDot() + idx := strings.Index(s, ".") + if idx < 0 || idx+1 >= len(s) { + return "" + } + rest := s[idx+1:] + if rest == "." || rest == "" { + return "" + } + return FQDN(rest) +} + // ValidLabel reports whether label is a valid DNS label. All errors are // [vizerror.Error]. func ValidLabel(label string) error { diff --git a/util/dnsname/dnsname_test.go b/util/dnsname/dnsname_test.go index b038bb1bd..2d95c3f71 100644 --- a/util/dnsname/dnsname_test.go +++ b/util/dnsname/dnsname_test.go @@ -123,6 +123,34 @@ func TestFQDNContains(t *testing.T) { } } +func TestFQDNParent(t *testing.T) { + tests := []struct { + in string + want FQDN + }{ + {"", ""}, + {".", ""}, + {"com.", ""}, + {"foo.com.", "com."}, + {"www.foo.com.", "foo.com."}, + {"a.b.c.d.", "b.c.d."}, + {"sub.node.tailnet.ts.net.", "node.tailnet.ts.net."}, + } + + for _, test := range tests { + t.Run(test.in, func(t *testing.T) { + in, err := ToFQDN(test.in) + if err != nil { + t.Fatalf("ToFQDN(%q): %v", test.in, err) + } + got := in.Parent() + if got != test.want { + t.Errorf("ToFQDN(%q).Parent() = %q, want %q", test.in, got, test.want) + } + }) + } +} + func TestSanitizeLabel(t *testing.T) { tests := []struct { name string