net/dns,ipn/ipnlocal: add nodecap to resolve subdomains

This adds a new node capability 'dns-subdomain-resolve' that signals
that all of hosts' subdomains should resolve to the same IP address.
It allows wildcard matching on any node marked with this capability.

This change also includes an util/dnsname utility function that lets
us access the parent of a full qualified domain name. MagicDNS takes
this function and recursively searchs for a matching real node name.

One important thing to observe is that, in this context, a subdomain
can have multiple sub labels. This means that for a given node named
machine, both my.machine and be.my.machine will be a positive match.

Updates #1196

Signed-off-by: Fernando Serboncini <fserb@tailscale.com>
This commit is contained in:
Fernando Serboncini 2025-12-12 19:54:42 -05:00 committed by Fernando Serboncini
parent ee59470270
commit 262ec04abc
12 changed files with 225 additions and 24 deletions

View File

@ -18,6 +18,7 @@ import (
"tailscale.com/types/netmap" "tailscale.com/types/netmap"
"tailscale.com/util/cloudenv" "tailscale.com/util/cloudenv"
"tailscale.com/util/dnsname" "tailscale.com/util/dnsname"
"tailscale.com/util/set"
) )
func ipps(ippStrs ...string) (ipps []netip.Prefix) { func ipps(ippStrs ...string) (ipps []netip.Prefix) {
@ -63,8 +64,9 @@ func TestDNSConfigForNetmap(t *testing.T) {
nm: &netmap.NetworkMap{}, nm: &netmap.NetworkMap{},
prefs: &ipn.Prefs{}, prefs: &ipn.Prefs{},
want: &dns.Config{ want: &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: map[dnsname.FQDN][]netip.Addr{}, 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"), "peera.net.": ips("100.102.0.1", "100.102.0.2"),
"v6-only.net.": ips("fe75::3"), "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"), "peera.net.": ips("fe75::1001"),
"v6-only.net.": ips("fe75::3"), "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"), "foo.com.": ips("1.2.3.4"),
"bar.com.": ips("1::6"), "bar.com.": ips("1::6"),
}, },
SubdomainHosts: map[dnsname.FQDN]bool{},
}, },
}, },
{ {
@ -256,6 +297,7 @@ func TestDNSConfigForNetmap(t *testing.T) {
"foo.com.", "foo.com.",
"bar.com.", "bar.com.",
}, },
SubdomainHosts: map[dnsname.FQDN]bool{},
}, },
}, },
{ {
@ -290,6 +332,7 @@ func TestDNSConfigForNetmap(t *testing.T) {
Routes: map[dnsname.FQDN][]*dnstype.Resolver{ Routes: map[dnsname.FQDN][]*dnstype.Resolver{
"foo.com.": {{Addr: "1.2.3.4"}}, "foo.com.": {{Addr: "1.2.3.4"}},
}, },
SubdomainHosts: map[dnsname.FQDN]bool{},
}, },
}, },
{ {
@ -311,6 +354,7 @@ func TestDNSConfigForNetmap(t *testing.T) {
DefaultResolvers: []*dnstype.Resolver{ DefaultResolvers: []*dnstype.Resolver{
{Addr: "8.8.4.4"}, {Addr: "8.8.4.4"},
}, },
SubdomainHosts: map[dnsname.FQDN]bool{},
}, },
}, },
{ {
@ -326,8 +370,9 @@ func TestDNSConfigForNetmap(t *testing.T) {
CorpDNS: true, CorpDNS: true,
}, },
want: &dns.Config{ want: &dns.Config{
Hosts: map[dnsname.FQDN][]netip.Addr{}, Hosts: map[dnsname.FQDN][]netip.Addr{},
Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
SubdomainHosts: map[dnsname.FQDN]bool{},
}, },
}, },
{ {

View File

@ -694,8 +694,9 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.
} }
dcfg := &dns.Config{ dcfg := &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: map[dnsname.FQDN][]netip.Addr{}, Hosts: map[dnsname.FQDN][]netip.Addr{},
SubdomainHosts: map[dnsname.FQDN]bool{},
} }
// selfV6Only is whether we only have IPv6 addresses ourselves. // 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 dcfg.Hosts[fqdn] = ips
} }
set(nm.SelfName(), nm.GetAddresses()) 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 { for _, peer := range peers {
set(peer.Name(), peer.Addresses()) 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 { for _, rec := range nm.DNS.ExtraRecords {
switch rec.Type { switch rec.Type {

View File

@ -1300,8 +1300,9 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
Routes: routesWithQuad100(), Routes: routesWithQuad100(),
}, },
wantDNSCfg: &dns.Config{ wantDNSCfg: &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: hostsFor(node1), Hosts: hostsFor(node1),
SubdomainHosts: map[dnsname.FQDN]bool{},
}, },
}, },
{ {
@ -1356,8 +1357,9 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
Routes: routesWithQuad100(), Routes: routesWithQuad100(),
}, },
wantDNSCfg: &dns.Config{ wantDNSCfg: &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: hostsFor(node2), Hosts: hostsFor(node2),
SubdomainHosts: map[dnsname.FQDN]bool{},
}, },
}, },
{ {
@ -1404,8 +1406,9 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
Routes: routesWithQuad100(), Routes: routesWithQuad100(),
}, },
wantDNSCfg: &dns.Config{ wantDNSCfg: &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: hostsFor(node1), Hosts: hostsFor(node1),
SubdomainHosts: map[dnsname.FQDN]bool{},
}, },
}, },
{ {
@ -1436,8 +1439,9 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
Routes: routesWithQuad100(), Routes: routesWithQuad100(),
}, },
wantDNSCfg: &dns.Config{ wantDNSCfg: &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: hostsFor(node3), Hosts: hostsFor(node3),
SubdomainHosts: map[dnsname.FQDN]bool{},
}, },
}, },
{ {
@ -1500,8 +1504,9 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
Routes: routesWithQuad100(), Routes: routesWithQuad100(),
}, },
wantDNSCfg: &dns.Config{ wantDNSCfg: &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: hostsFor(node1), Hosts: hostsFor(node1),
SubdomainHosts: map[dnsname.FQDN]bool{},
}, },
}, },
{ {
@ -1529,8 +1534,9 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
Routes: routesWithQuad100(), Routes: routesWithQuad100(),
}, },
wantDNSCfg: &dns.Config{ wantDNSCfg: &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: hostsFor(node1), Hosts: hostsFor(node1),
SubdomainHosts: map[dnsname.FQDN]bool{},
}, },
}, },
{ {
@ -1560,8 +1566,9 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
Routes: routesWithQuad100(), Routes: routesWithQuad100(),
}, },
wantDNSCfg: &dns.Config{ wantDNSCfg: &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: hostsFor(node1), Hosts: hostsFor(node1),
SubdomainHosts: map[dnsname.FQDN]bool{},
}, },
}, },
{ {

View File

@ -48,6 +48,11 @@ type Config struct {
// it to resolve, you also need to add appropriate routes to // it to resolve, you also need to add appropriate routes to
// Routes. // Routes.
Hosts map[dnsname.FQDN][]netip.Addr 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) // OnlyIPv6, if true, uses the IPv6 service IP (for MagicDNS)
// instead of the IPv4 version (100.100.100.100). // instead of the IPv4 version (100.100.100.100).
OnlyIPv6 bool OnlyIPv6 bool

View File

@ -6,6 +6,7 @@
package dns package dns
import ( import (
"maps"
"net/netip" "net/netip"
"tailscale.com/types/dnstype" "tailscale.com/types/dnstype"
@ -43,6 +44,7 @@ func (src *Config) Clone() *Config {
dst.Hosts[k] = append([]netip.Addr{}, src.Hosts[k]...) dst.Hosts[k] = append([]netip.Addr{}, src.Hosts[k]...)
} }
} }
dst.SubdomainHosts = maps.Clone(src.SubdomainHosts)
return dst return dst
} }
@ -52,6 +54,7 @@ var _ConfigCloneNeedsRegeneration = Config(struct {
Routes map[dnsname.FQDN][]*dnstype.Resolver Routes map[dnsname.FQDN][]*dnstype.Resolver
SearchDomains []dnsname.FQDN SearchDomains []dnsname.FQDN
Hosts map[dnsname.FQDN][]netip.Addr Hosts map[dnsname.FQDN][]netip.Addr
SubdomainHosts map[dnsname.FQDN]bool
OnlyIPv6 bool OnlyIPv6 bool
}{}) }{})

View File

@ -123,6 +123,14 @@ func (v ConfigView) Hosts() views.MapSlice[dnsname.FQDN, netip.Addr] {
return views.MapSliceOf(v.ж.Hosts) 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) // OnlyIPv6, if true, uses the IPv6 service IP (for MagicDNS)
// instead of the IPv4 version (100.100.100.100). // instead of the IPv4 version (100.100.100.100).
func (v ConfigView) OnlyIPv6() bool { return v.ж.OnlyIPv6 } func (v ConfigView) OnlyIPv6() bool { return v.ж.OnlyIPv6 }
@ -134,5 +142,6 @@ var _ConfigViewNeedsRegeneration = Config(struct {
Routes map[dnsname.FQDN][]*dnstype.Resolver Routes map[dnsname.FQDN][]*dnstype.Resolver
SearchDomains []dnsname.FQDN SearchDomains []dnsname.FQDN
Hosts map[dnsname.FQDN][]netip.Addr Hosts map[dnsname.FQDN][]netip.Addr
SubdomainHosts map[dnsname.FQDN]bool
OnlyIPv6 bool OnlyIPv6 bool
}{}) }{})

View File

@ -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 // authoritative suffixes, even if we don't propagate MagicDNS to
// the OS. // the OS.
rcfg.Hosts = cfg.Hosts rcfg.Hosts = cfg.Hosts
rcfg.SubdomainHosts = cfg.SubdomainHosts
routes := map[dnsname.FQDN][]*dnstype.Resolver{} // assigned conditionally to rcfg.Routes below. routes := map[dnsname.FQDN][]*dnstype.Resolver{} // assigned conditionally to rcfg.Routes below.
var propagateHostsToOS bool var propagateHostsToOS bool
for suffix, resolvers := range cfg.Routes { for suffix, resolvers := range cfg.Routes {

View File

@ -79,6 +79,12 @@ type Config struct {
// LocalDomains is a list of DNS name suffixes that should not be // LocalDomains is a list of DNS name suffixes that should not be
// routed to upstream resolvers. // routed to upstream resolvers.
LocalDomains []dnsname.FQDN 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 // WriteToBufioWriter write a debug version of c for logs to w, omitting
@ -214,10 +220,11 @@ type Resolver struct {
closed chan struct{} closed chan struct{}
// mu guards the following fields from being updated while used. // mu guards the following fields from being updated while used.
mu syncs.Mutex mu syncs.Mutex
localDomains []dnsname.FQDN localDomains []dnsname.FQDN
hostToIP map[dnsname.FQDN][]netip.Addr hostToIP map[dnsname.FQDN][]netip.Addr
ipToHost map[netip.Addr]dnsname.FQDN ipToHost map[netip.Addr]dnsname.FQDN
subdomainHosts map[dnsname.FQDN]bool
} }
type ForwardLinkSelector interface { type ForwardLinkSelector interface {
@ -278,6 +285,7 @@ func (r *Resolver) SetConfig(cfg Config) error {
r.localDomains = cfg.LocalDomains r.localDomains = cfg.LocalDomains
r.hostToIP = cfg.Hosts r.hostToIP = cfg.Hosts
r.ipToHost = reverse r.ipToHost = reverse
r.subdomainHosts = cfg.SubdomainHosts
return nil return nil
} }
@ -642,9 +650,18 @@ func (r *Resolver) resolveLocal(domain dnsname.FQDN, typ dns.Type) (netip.Addr,
r.mu.Lock() r.mu.Lock()
hosts := r.hostToIP hosts := r.hostToIP
localDomains := r.localDomains localDomains := r.localDomains
subdomainHosts := r.subdomainHosts
r.mu.Unlock() r.mu.Unlock()
addrs, found := hosts[domain] addrs, found := hosts[domain]
if !found {
for parent := domain.Parent(); parent != ""; parent = parent.Parent() {
if subdomainHosts[parent] {
addrs, found = hosts[parent]
break
}
}
}
if !found { if !found {
for _, suffix := range localDomains { for _, suffix := range localDomains {
if suffix.Contains(domain) { if suffix.Contains(domain) {

View File

@ -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) { func TestResolveLocalReverse(t *testing.T) {
r := newResolver(t) r := newResolver(t)
defer r.Close() defer r.Close()

View File

@ -2707,6 +2707,12 @@ const (
// server to answer AAAA queries about its peers. See tailscale/tailscale#1152. // server to answer AAAA queries about its peers. See tailscale/tailscale#1152.
NodeAttrMagicDNSPeerAAAA NodeCapability = "magicdns-aaaa" 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 // NodeAttrTrafficSteering configures the node to use the traffic
// steering subsystem for via routes. See tailscale/corp#29966. // steering subsystem for via routes. See tailscale/corp#29966.
NodeAttrTrafficSteering NodeCapability = "traffic-steering" NodeAttrTrafficSteering NodeCapability = "traffic-steering"

View File

@ -94,6 +94,22 @@ func (f FQDN) Contains(other FQDN) bool {
return strings.HasSuffix(other.WithTrailingDot(), cmp) 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 // ValidLabel reports whether label is a valid DNS label. All errors are
// [vizerror.Error]. // [vizerror.Error].
func ValidLabel(label string) error { func ValidLabel(label string) error {

View File

@ -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) { func TestSanitizeLabel(t *testing.T) {
tests := []struct { tests := []struct {
name string name string