WIP: rebase fork onto upstream/main (v1.103.0) #15

Closed
codinget wants to merge 670 commits from webnet into save/webnet-2026-07-29
11 changed files with 511 additions and 125 deletions
Showing only changes of commit 7e609b2581 - Show all commits
+9 -10
View File
@@ -72,6 +72,7 @@ func TestDNSConfigForNetmap(t *testing.T) {
},
{
name: "self_name_and_peers",
os: "windows", // the full Hosts map is built only for the Windows hosts-file path
nm: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Name: "myname.net.",
@@ -108,6 +109,7 @@ func TestDNSConfigForNetmap(t *testing.T) {
},
{
name: "subdomain_resolve_capability",
os: "windows", // the full Hosts map is built only for the Windows hosts-file path
nm: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Name: "myname.net.",
@@ -136,7 +138,6 @@ func TestDNSConfigForNetmap(t *testing.T) {
"peer-with-cap.net.": ips("100.102.0.1"),
"peer-without-cap.net.": ips("100.102.0.2"),
},
SubdomainHosts: set.Of[dnsname.FQDN]("myname.net.", "peer-with-cap.net."),
},
},
{
@@ -144,6 +145,7 @@ func TestDNSConfigForNetmap(t *testing.T) {
// should get IPv6 records for all its peers,
// even if they have IPv4.
name: "v6_only_self",
os: "windows", // the full Hosts map is built only for the Windows hosts-file path
nm: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Name: "myname.net.",
@@ -198,9 +200,8 @@ func TestDNSConfigForNetmap(t *testing.T) {
want: &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: map[dnsname.FQDN][]netip.Addr{
"myname.net.": ips("100.101.101.101"),
"foo.com.": ips("1.2.3.4"),
"bar.com.": ips("1::6"),
"foo.com.": ips("1.2.3.4"),
"bar.com.": ips("1::6"),
},
},
},
@@ -425,23 +426,21 @@ func TestDNSConfigForNetmap(t *testing.T) {
},
want: &dns.Config{
AcceptDNS: true,
Hosts: map[dnsname.FQDN][]netip.Addr{
"a.": ips("100.101.101.101"),
"p1.": ips("100.102.0.1"),
},
Hosts: map[dnsname.FQDN][]netip.Addr{},
Routes: map[dnsname.FQDN][]*dnstype.Resolver{
dnsname.FQDN("example.com."): {
{Addr: "tailscale-app:app1"},
},
},
MagicDNSHostsUnrouted: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
verOS := cmp.Or(tt.os, "linux")
goos := cmp.Or(tt.os, "linux")
var log tstest.MemLogger
got := dnsConfigForNetmap(tt.nm, peersMap(tt.peers), tt.prefs.View(), tt.expired, log.Logf, verOS)
got := dnsConfigForNetmap(tt.nm, peersMap(tt.peers), tt.prefs.View(), tt.expired, log.Logf, goos)
if !reflect.DeepEqual(got, tt.want) {
gotj, _ := json.MarshalIndent(got, "", "\t")
wantj, _ := json.MarshalIndent(tt.want, "", "\t")
+7 -1
View File
@@ -228,6 +228,7 @@ type LocalBackend struct {
keyLogf logger.Logf // for printing list of peers on change
statsLogf logger.Logf // for printing peers stats on change
sys *tsd.System
goos string // runtime.GOOS usually, except in tests
eventClient *eventbus.Client
appcTask execqueue.ExecQueue // handles updates from appc
@@ -639,6 +640,11 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
e.SetNetLogSource(netLogNodeSource{b})
e.SetWGPeerLookup(b.lookupPeerWireGuardString)
b.dialer.SetResolveMagicDNS(b.resolveMagicDNS)
if buildfeatures.HasDNS {
if dm, ok := sys.DNSManager.GetOK(); ok {
dm.Resolver().SetMagicDNSHosts(magicDNSHosts{b})
}
}
if sys.InitialConfig != nil {
if err := b.initPrefsFromConfig(sys.InitialConfig); err != nil {
@@ -6095,7 +6101,7 @@ func (b *LocalBackend) authReconfigLocked() {
hasPAC := b.interfaceState.HasPAC()
disableSubnetsIfPAC := cn.SelfHasCap(tailcfg.NodeAttrDisableSubnetsIfPAC)
dohURL, dohURLOK := cn.exitNodeCanProxyDNS(prefs.ExitNodeID())
dcfg := cn.dnsConfigForNetmap(prefs, b.keyExpired, version.OS())
dcfg := cn.dnsConfigForNetmap(prefs, b.keyExpired, cmp.Or(b.goos, runtime.GOOS))
// If the current node is an app connector, ensure the app connector machine is started
b.reconfigAppConnectorLocked(nm.SelfNode, prefs)
+151 -59
View File
@@ -305,6 +305,12 @@ func (nb *nodeBackend) NodeByWireGuardString(s string) (_ tailcfg.NodeID, ok boo
func (nb *nodeBackend) NodeByID(id tailcfg.NodeID) (_ tailcfg.NodeView, ok bool) {
nb.mu.Lock()
defer nb.mu.Unlock()
return nb.nodeByIDLocked(id)
}
// nodeByIDLocked returns the node (peer or self) with the given node
// ID. nb.mu must be held.
func (nb *nodeBackend) nodeByIDLocked(id tailcfg.NodeID) (_ tailcfg.NodeView, ok bool) {
if nb.netMap != nil {
if self := nb.netMap.SelfNode; self.Valid() && self.ID() == id {
return self, true
@@ -1126,10 +1132,122 @@ func (nb *nodeBackend) setFilter(f *filter.Filter) {
nb.filterAtomic.Store(f)
}
func (nb *nodeBackend) dnsConfigForNetmap(prefs ipn.PrefsView, selfExpired bool, versionOS string) *dns.Config {
func (nb *nodeBackend) dnsConfigForNetmap(prefs ipn.PrefsView, selfExpired bool, goos string) *dns.Config {
nb.mu.Lock()
defer nb.mu.Unlock()
return dnsConfigForNetmap(nb.netMap, nb.peers, prefs, selfExpired, nb.logf, versionOS)
return dnsConfigForNetmap(nb.netMap, nb.peers, prefs, selfExpired, nb.logf, goos)
}
// magicDNSHostAddrs returns the MagicDNS A/AAAA answer for fqdn from
// the live node indexes, and whether the name is known. It backs
// [resolver.MagicDNSHosts.LookupHost], replacing the full
// dns.Config.Hosts map so that netmap deltas need no per-peer DNS
// rebuild.
func (nb *nodeBackend) magicDNSHostAddrs(fqdn dnsname.FQDN) (ips []netip.Addr, ok bool) {
if !buildfeatures.HasDNS {
return nil, false
}
nb.mu.Lock()
defer nb.mu.Unlock()
n, ok := nb.nodeByFQDNLocked(fqdn)
if !ok || n.Addresses().Len() == 0 {
return nil, false
}
nm := nb.netMap
selfV6Only := nm.GetAddresses().ContainsFunc(tsaddr.PrefixIs6) &&
!nm.GetAddresses().ContainsFunc(tsaddr.PrefixIs4)
wantAAAA := nm.AllCaps.Contains(tailcfg.NodeAttrMagicDNSPeerAAAA)
return magicDNSAddrs(n.Addresses(), selfV6Only, wantAAAA), true
}
// magicDNSPTR returns the MagicDNS name of the node (peer or self)
// that owns ip, backing [resolver.MagicDNSHosts.LookupPTR].
func (nb *nodeBackend) magicDNSPTR(ip netip.Addr) (_ dnsname.FQDN, ok bool) {
if !buildfeatures.HasDNS {
return "", false
}
nb.mu.Lock()
defer nb.mu.Unlock()
nid, ok := nb.nodeByAddr[ip]
if !ok {
return "", false
}
n, ok := nb.nodeByIDLocked(nid)
if !ok {
return "", false
}
fqdn, err := dnsname.ToFQDN(n.Name())
if err != nil {
return "", false
}
return fqdn, true
}
// magicDNSSubdomainHost reports whether fqdn names a node with the
// [tailcfg.NodeAttrDNSSubdomainResolve] attribute, backing
// [resolver.MagicDNSHosts.SubdomainHost].
func (nb *nodeBackend) magicDNSSubdomainHost(fqdn dnsname.FQDN) bool {
if !buildfeatures.HasDNS {
return false
}
nb.mu.Lock()
defer nb.mu.Unlock()
n, ok := nb.nodeByFQDNLocked(fqdn)
if !ok {
return false
}
if nm := nb.netMap; nm != nil && nm.SelfNode.Valid() && nm.SelfNode.ID() == n.ID() {
return nm.AllCaps.Contains(tailcfg.NodeAttrDNSSubdomainResolve)
}
return n.CapMap().Contains(tailcfg.NodeAttrDNSSubdomainResolve)
}
// nodeByFQDNLocked returns the node (peer or self) with the given
// MagicDNS FQDN. nb.mu must be held.
func (nb *nodeBackend) nodeByFQDNLocked(fqdn dnsname.FQDN) (_ tailcfg.NodeView, ok bool) {
// The resolver already lowercases query names, but lowercase
// again (nearly free when already lowercase) so that no other
// caller of the [resolver.MagicDNSHosts] hook can miss on case.
nid, ok := nb.nodeByName[strings.ToLower(strings.TrimSuffix(string(fqdn), "."))]
if !ok {
return tailcfg.NodeView{}, false
}
return nb.nodeByIDLocked(nid)
}
// magicDNSAddrs returns the MagicDNS answer addresses for a node
// owning the given addresses: all of its IPv6 addresses if this node
// (the querier) is IPv6-only, otherwise everything except the node's
// IPv6 addresses when it also has IPv4, unless wantAAAA. The result
// may be empty (name exists, no records of the desired family).
func magicDNSAddrs(addrs views.Slice[netip.Prefix], selfV6Only, wantAAAA bool) (ips []netip.Addr) {
var have4 bool
for _, addr := range addrs.All() {
if addr.Addr().Is4() {
have4 = true
break
}
}
for _, addr := range addrs.All() {
if selfV6Only {
if addr.Addr().Is6() {
ips = append(ips, addr.Addr())
}
continue
}
// If this node has an IPv4 address, then remove peers'
// IPv6 addresses for now, as we don't guarantee that
// the peer node actually can speak IPv6 correctly.
//
// https://github.com/tailscale/tailscale/issues/1152
// tracks adding the right capability reporting to
// enable AAAA in MagicDNS.
if addr.Addr().Is6() && have4 && !wantAAAA {
continue
}
ips = append(ips, addr.Addr())
}
return ips
}
func (nb *nodeBackend) exitNodeCanProxyDNS(exitNodeID tailcfg.StableNodeID) (dohURL string, ok bool) {
@@ -1234,9 +1352,9 @@ func useWithExitNodeRoutes(routes map[string][]*dnstype.Resolver) map[string][]*
// dnsConfigForNetmap returns a *dns.Config for the given netmap,
// prefs, client OS version, and cloud hosting environment.
//
// The versionOS is a Tailscale-style version ("iOS", "macOS") and not
// a runtime.GOOS.
func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.NodeView, prefs ipn.PrefsView, selfExpired bool, logf logger.Logf, versionOS string) *dns.Config {
// The goos is runtime.GOOS usually, except in tests, where it may
// vary to test any OS's behavior from any host.
func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.NodeView, prefs ipn.PrefsView, selfExpired bool, logf logger.Logf, goos string) *dns.Config {
if nm == nil {
return nil
}
@@ -1269,63 +1387,29 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.
wantAAAA := nm.AllCaps.Contains(tailcfg.NodeAttrMagicDNSPeerAAAA)
// Populate MagicDNS records. We do this unconditionally so that
// quad-100 can always respond to MagicDNS queries, even if the OS
// isn't configured to make MagicDNS resolution truly
// magic. Details in
// https://github.com/tailscale/tailscale/issues/1886.
set := func(name string, addrs views.Slice[netip.Prefix]) {
if addrs.Len() == 0 || name == "" {
return
}
fqdn, err := dnsname.ToFQDN(name)
if err != nil {
return // TODO: propagate error?
}
var have4 bool
for _, addr := range addrs.All() {
if addr.Addr().Is4() {
have4 = true
break
// Populate MagicDNS records. The internal quad-100 resolver pulls
// per-node records on demand from the live node indexes (see
// [nodeBackend.magicDNSHostAddrs]), so quad-100 can always respond
// to MagicDNS queries without dcfg.Hosts; details in
// https://github.com/tailscale/tailscale/issues/1886. dcfg.Hosts
// carries only what must be enumerable in full: control's
// DNS.ExtraRecords below, plus every node's records on Windows,
// whose hosts-file fallback path (see compileHostEntries in
// net/dns) needs the complete set.
if goos == "windows" {
set := func(name string, addrs views.Slice[netip.Prefix]) {
if addrs.Len() == 0 || name == "" {
return
}
}
var ips []netip.Addr
for _, addr := range addrs.All() {
if selfV6Only {
if addr.Addr().Is6() {
ips = append(ips, addr.Addr())
}
continue
fqdn, err := dnsname.ToFQDN(name)
if err != nil {
return // TODO: propagate error?
}
// If this node has an IPv4 address, then
// remove peers' IPv6 addresses for now, as we
// don't guarantee that the peer node actually
// can speak IPv6 correctly.
//
// https://github.com/tailscale/tailscale/issues/1152
// tracks adding the right capability reporting to
// enable AAAA in MagicDNS.
if addr.Addr().Is6() && have4 && !wantAAAA {
continue
}
ips = append(ips, addr.Addr())
dcfg.Hosts[fqdn] = magicDNSAddrs(addrs, selfV6Only, wantAAAA)
}
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.Make()
dcfg.SubdomainHosts.Add(fqdn)
}
}
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.Make()
dcfg.SubdomainHosts.Add(fqdn)
}
set(nm.SelfName(), nm.GetAddresses())
for _, peer := range peers {
set(peer.Name(), peer.Addresses())
}
}
for _, rec := range nm.DNS.ExtraRecords {
@@ -1363,6 +1447,14 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.
for _, dom := range magicDNSRootDomains(nm) {
dcfg.Routes[dom] = nil // resolve internally with dcfg.Hosts
}
} else {
// Without MagicDNS domain routing, no Routes entry covers the
// node records served on demand via [magicDNSHosts]. Tell
// dns.Manager they exist, so it keeps quad-100 in the OS
// resolver path and the names still resolve, as they did when
// they were listed in dcfg.Hosts. Any netmap with a self node
// has such records.
dcfg.MagicDNSHostsUnrouted = nm.SelfNode.Valid()
}
addDefault := func(resolvers []*dnstype.Resolver) {
+80
View File
@@ -18,6 +18,7 @@ import (
"tailscale.com/types/key"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/util/dnsname"
"tailscale.com/util/eventbus"
"tailscale.com/util/mak"
"tailscale.com/util/set"
@@ -559,3 +560,82 @@ func TestNodeBackendRouteManagerExtras(t *testing.T) {
t.Errorf("Outbound still routes %v after extras cleared", transit.Addr())
}
}
// Tests the live MagicDNS lookup methods backing
// [resolver.MagicDNSHosts]: forward, reverse, and subdomain-cap
// lookups must serve from the node indexes and stay correct across
// netmap deltas without any full Hosts map rebuild.
func TestNodeBackendMagicDNSHosts(t *testing.T) {
nb := newNodeBackend(t.Context(), tstest.WhileTestRunningLogger(t), eventbus.New())
self := &tailcfg.Node{
ID: 1,
Name: "self.example.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}
p1 := &tailcfg.Node{
ID: 2,
Key: key.NewNode().Public(),
Name: "p1.example.ts.net.",
Addresses: []netip.Prefix{
netip.MustParsePrefix("100.64.0.2/32"),
netip.MustParsePrefix("fd7a:115c:a1e0::2/128"),
},
CapMap: tailcfg.NodeCapMap{tailcfg.NodeAttrDNSSubdomainResolve: nil},
}
nb.SetNetMap(&netmap.NetworkMap{
SelfNode: self.View(),
Peers: []tailcfg.NodeView{p1.View()},
})
wantHost := func(fqdn dnsname.FQDN, want ...netip.Addr) {
t.Helper()
ips, ok := nb.magicDNSHostAddrs(fqdn)
if len(want) == 0 {
if ok {
t.Errorf("magicDNSHostAddrs(%q) = %v; want no match", fqdn, ips)
}
return
}
if !ok || !slices.Equal(ips, want) {
t.Errorf("magicDNSHostAddrs(%q) = %v, %v; want %v", fqdn, ips, ok, want)
}
}
// The self node has IPv4, so the peer's IPv6 address is
// filtered out (issue 1152).
wantHost("p1.example.ts.net.", netip.MustParseAddr("100.64.0.2"))
wantHost("self.example.ts.net.", netip.MustParseAddr("100.64.0.1"))
wantHost("unknown.example.ts.net.")
if fqdn, ok := nb.magicDNSPTR(netip.MustParseAddr("100.64.0.2")); !ok || fqdn != "p1.example.ts.net." {
t.Errorf("magicDNSPTR(100.64.0.2) = %q, %v; want p1's name", fqdn, ok)
}
if got, want := nb.magicDNSSubdomainHost("p1.example.ts.net."), true; got != want {
t.Errorf("magicDNSSubdomainHost(p1) = %v; want %v", got, want)
}
if got, want := nb.magicDNSSubdomainHost("self.example.ts.net."), false; got != want {
t.Errorf("magicDNSSubdomainHost(self) = %v; want %v", got, want)
}
// Removing the peer via a delta drops its records.
if _, handled := nb.UpdateNetmapDelta([]netmap.NodeMutation{netmap.MakeNodeMutationRemove(2)}); !handled {
t.Fatal("UpdateNetmapDelta not handled")
}
wantHost("p1.example.ts.net.")
if fqdn, ok := nb.magicDNSPTR(netip.MustParseAddr("100.64.0.2")); ok {
t.Errorf("magicDNSPTR(100.64.0.2) after removal = %q; want no match", fqdn)
}
// Adding a peer via a delta serves it immediately.
p3 := &tailcfg.Node{
ID: 3,
Key: key.NewNode().Public(),
Name: "p3.example.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.3/32")},
}
if _, handled := nb.UpdateNetmapDelta([]netmap.NodeMutation{netmap.NodeMutationUpsert{Node: p3.View()}}); !handled {
t.Fatal("UpdateNetmapDelta not handled")
}
wantHost("p3.example.ts.net.", netip.MustParseAddr("100.64.0.3"))
}
+34
View File
@@ -7,9 +7,12 @@ import (
"net/netip"
"strings"
"tailscale.com/feature/buildfeatures"
"tailscale.com/net/dns/resolver"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/util/dnsname"
"tailscale.com/wgengine"
)
@@ -72,6 +75,37 @@ func (b *LocalBackend) resolveMagicDNS(hostname, network string) (_ netip.Addr,
return netip.Addr{}, false
}
// magicDNSHosts implements [resolver.MagicDNSHosts] on top of the
// current nodeBackend's live node indexes, so the quad-100 resolver
// pulls each MagicDNS answer on demand rather than LocalBackend
// pushing a Hosts map of every node into it on every netmap change.
// It is installed once at LocalBackend construction; going through
// currentNode makes profile switches take effect immediately.
type magicDNSHosts struct{ b *LocalBackend }
func (m magicDNSHosts) LookupHost(fqdn dnsname.FQDN) (ips []netip.Addr, ok bool) {
if !buildfeatures.HasDNS {
return nil, false
}
return m.b.currentNode().magicDNSHostAddrs(fqdn)
}
func (m magicDNSHosts) LookupPTR(ip netip.Addr) (_ dnsname.FQDN, ok bool) {
if !buildfeatures.HasDNS {
return "", false
}
return m.b.currentNode().magicDNSPTR(ip)
}
func (m magicDNSHosts) SubdomainHost(fqdn dnsname.FQDN) bool {
if !buildfeatures.HasDNS {
return false
}
return m.b.currentNode().magicDNSSubdomainHost(fqdn)
}
var _ resolver.MagicDNSHosts = magicDNSHosts{}
// nodeAddrForNetwork returns the best address from n for the given
// network ("tcp", "tcp4", "tcp6", "udp", "udp4", "udp6"). For
// unqualified networks ("tcp", "udp"), it prefers IPv4.
+25 -37
View File
@@ -42,8 +42,6 @@ import (
"tailscale.com/types/preftype"
"tailscale.com/util/dnsname"
"tailscale.com/util/eventbus/eventbustest"
"tailscale.com/util/mak"
"tailscale.com/util/must"
"tailscale.com/wgengine"
"tailscale.com/wgengine/filter"
"tailscale.com/wgengine/magicsock"
@@ -1251,23 +1249,6 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
routesWithQuad100 := func(extra ...netip.Prefix) []netip.Prefix {
return append(extra, netip.MustParsePrefix("100.100.100.100/32"))
}
hostsFor := func(nm *netmap.NetworkMap) map[dnsname.FQDN][]netip.Addr {
var hosts map[dnsname.FQDN][]netip.Addr
appendNode := func(n tailcfg.NodeView) {
addrs := make([]netip.Addr, 0, n.Addresses().Len())
for _, addr := range n.Addresses().All() {
addrs = append(addrs, addr.Addr())
}
mak.Set(&hosts, must.Get(dnsname.ToFQDN(n.Name())), addrs)
}
if nm != nil && nm.SelfNode.Valid() {
appendNode(nm.SelfNode)
}
for _, n := range nm.Peers {
appendNode(n)
}
return hosts
}
tests := []struct {
name string
@@ -1328,9 +1309,10 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
Routes: routesWithQuad100(),
},
wantDNSCfg: &dns.Config{
AcceptDNS: true,
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: hostsFor(node1),
AcceptDNS: true,
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: map[dnsname.FQDN][]netip.Addr{},
MagicDNSHostsUnrouted: true,
},
},
{
@@ -1385,9 +1367,10 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
Routes: routesWithQuad100(),
},
wantDNSCfg: &dns.Config{
AcceptDNS: true,
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: hostsFor(node2),
AcceptDNS: true,
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: map[dnsname.FQDN][]netip.Addr{},
MagicDNSHostsUnrouted: true,
},
},
{
@@ -1434,9 +1417,10 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
Routes: routesWithQuad100(),
},
wantDNSCfg: &dns.Config{
AcceptDNS: true,
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: hostsFor(node1),
AcceptDNS: true,
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: map[dnsname.FQDN][]netip.Addr{},
MagicDNSHostsUnrouted: true,
},
},
{
@@ -1467,9 +1451,10 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
Routes: routesWithQuad100(),
},
wantDNSCfg: &dns.Config{
AcceptDNS: true,
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: hostsFor(node3),
AcceptDNS: true,
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: map[dnsname.FQDN][]netip.Addr{},
MagicDNSHostsUnrouted: true,
},
},
{
@@ -1513,9 +1498,10 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
Routes: routesWithQuad100(),
},
wantDNSCfg: &dns.Config{
AcceptDNS: true,
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: hostsFor(node1),
AcceptDNS: true,
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: map[dnsname.FQDN][]netip.Addr{},
MagicDNSHostsUnrouted: true,
},
},
{
@@ -1544,9 +1530,10 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
Routes: routesWithQuad100(),
},
wantDNSCfg: &dns.Config{
AcceptDNS: true,
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: hostsFor(node1),
AcceptDNS: true,
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: map[dnsname.FQDN][]netip.Addr{},
MagicDNSHostsUnrouted: true,
},
},
{
@@ -1572,6 +1559,7 @@ func TestEngineReconfigOnStateChange(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lb, engine, cc := newLocalBackendWithMockEngineAndControl(t, enableLogging)
lb.goos = "linux" // so the expectations below are the same on every host OS
if tt.steps != nil {
tt.steps(t, lb, cc)
+15 -2
View File
@@ -61,6 +61,14 @@ type Config struct {
// OnlyIPv6, if true, uses the IPv6 service IP (for MagicDNS)
// instead of the IPv4 version (100.100.100.100).
OnlyIPv6 bool
// MagicDNSHostsUnrouted is whether MagicDNS host records are
// served on demand via [resolver.MagicDNSHosts] (so are not
// listed in Hosts) without being covered by any Routes entry;
// that is, MagicDNS domain routing is off. It preserves the
// effect the node records had when they were listed in Hosts:
// hasHostsWithoutSplitDNSRoutes reports true, keeping quad-100
// in the OS resolver path so the names still resolve.
MagicDNSHostsUnrouted bool
}
var magicDNSDualStack = envknob.RegisterBool("TS_DEBUG_MAGIC_DNS_DUAL_STACK")
@@ -127,8 +135,13 @@ func (c Config) hasDefaultIPResolversOnly() bool {
// hasHostsWithoutSplitDNSRoutes reports whether c contains any Host entries
// that aren't covered by a SplitDNS route suffix.
func (c Config) hasHostsWithoutSplitDNSRoutes() bool {
// TODO(bradfitz): this could be more efficient, but we imagine
// the number of SplitDNS routes and/or hosts will be small.
if c.MagicDNSHostsUnrouted {
return true
}
// Hosts is small here on most platforms: per-node records are
// served via [resolver.MagicDNSHosts] (accounted for above), so
// only control's DNS.ExtraRecords remain. On Windows it still
// carries every node's records for the hosts-file path.
for host := range c.Hosts {
if !c.hasSplitDNSRouteForHost(host) {
return true
+8 -7
View File
@@ -61,13 +61,14 @@ func (src *Config) Clone() *Config {
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _ConfigCloneNeedsRegeneration = Config(struct {
AcceptDNS bool
DefaultResolvers []*dnstype.Resolver
Routes map[dnsname.FQDN][]*dnstype.Resolver
SearchDomains []dnsname.FQDN
Hosts map[dnsname.FQDN][]netip.Addr
SubdomainHosts set.Set[dnsname.FQDN]
OnlyIPv6 bool
AcceptDNS bool
DefaultResolvers []*dnstype.Resolver
Routes map[dnsname.FQDN][]*dnstype.Resolver
SearchDomains []dnsname.FQDN
Hosts map[dnsname.FQDN][]netip.Addr
SubdomainHosts set.Set[dnsname.FQDN]
OnlyIPv6 bool
MagicDNSHostsUnrouted bool
}{})
// Clone duplicates src into dst and reports whether it succeeded.
+19 -9
View File
@@ -139,16 +139,26 @@ func (v ConfigView) SubdomainHosts() views.Map[dnsname.FQDN, struct{}] {
// 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 }
func (v ConfigView) Equal(v2 ConfigView) bool { return v.ж.Equal(v2.ж) }
func (v ConfigView) OnlyIPv6() bool { return v.ж.OnlyIPv6 }
// MagicDNSHostsUnrouted is whether MagicDNS host records are
// served on demand via [resolver.MagicDNSHosts] (so are not
// listed in Hosts) without being covered by any Routes entry;
// that is, MagicDNS domain routing is off. It preserves the
// effect the node records had when they were listed in Hosts:
// hasHostsWithoutSplitDNSRoutes reports true, keeping quad-100
// in the OS resolver path so the names still resolve.
func (v ConfigView) MagicDNSHostsUnrouted() bool { return v.ж.MagicDNSHostsUnrouted }
func (v ConfigView) Equal(v2 ConfigView) bool { return v.ж.Equal(v2.ж) }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _ConfigViewNeedsRegeneration = Config(struct {
AcceptDNS bool
DefaultResolvers []*dnstype.Resolver
Routes map[dnsname.FQDN][]*dnstype.Resolver
SearchDomains []dnsname.FQDN
Hosts map[dnsname.FQDN][]netip.Addr
SubdomainHosts set.Set[dnsname.FQDN]
OnlyIPv6 bool
AcceptDNS bool
DefaultResolvers []*dnstype.Resolver
Routes map[dnsname.FQDN][]*dnstype.Resolver
SearchDomains []dnsname.FQDN
Hosts map[dnsname.FQDN][]netip.Addr
SubdomainHosts set.Set[dnsname.FQDN]
OnlyIPv6 bool
MagicDNSHostsUnrouted bool
}{})
+58
View File
@@ -229,6 +229,53 @@ type Resolver struct {
hostToIP map[dnsname.FQDN][]netip.Addr
ipToHost map[netip.Addr]dnsname.FQDN
subdomainHosts set.Set[dnsname.FQDN]
magicHosts MagicDNSHosts // or nil if none installed
}
// MagicDNSHosts is a live source of MagicDNS host records, installed
// via [Resolver.SetMagicDNSHosts].
//
// It replaces the per-node entries of [Config.Hosts]: instead of the
// caller pushing a full snapshot of every node's name and addresses
// into the resolver on every (possibly incremental) netmap change,
// the resolver pulls the answer for one name on demand from the
// caller's live indexes. [Config.Hosts] remains for control's
// DNS.ExtraRecords entries, which are few, and is consulted first.
//
// Implementations must be safe for concurrent use and cheap: the
// methods are called on the DNS query serving path. Name lookups are
// case-insensitive: the resolver passes lowercase names, but
// implementations must not rely on that.
type MagicDNSHosts interface {
// LookupHost returns the IPs to answer for the node with the
// given MagicDNS FQDN, and whether the name is known. It returns
// all answerable IPs regardless of record type; the resolver
// filters them by the query's type, and a known name with no IPs
// of the query's family is "name exists, no records", not
// NXDOMAIN.
LookupHost(dnsname.FQDN) (ips []netip.Addr, ok bool)
// LookupPTR returns the MagicDNS FQDN of the node that owns
// the given Tailscale IP, and whether the IP is known.
LookupPTR(netip.Addr) (_ dnsname.FQDN, ok bool)
// SubdomainHost reports whether fqdn names a node with the
// [tailcfg.NodeAttrDNSSubdomainResolve] attribute, whose
// subdomains all resolve to the node's own addresses.
SubdomainHost(dnsname.FQDN) bool
}
// SetMagicDNSHosts installs the live MagicDNS host source consulted
// by forward and reverse MagicDNS lookups that miss [Config.Hosts].
// It is expected to be called once, before the resolver serves
// queries.
func (r *Resolver) SetMagicDNSHosts(h MagicDNSHosts) {
if !buildfeatures.HasDNS {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.magicHosts = h
}
type ForwardLinkSelector interface {
@@ -682,15 +729,23 @@ func (r *Resolver) resolveLocal(domain dnsname.FQDN, typ dns.Type) (netip.Addr,
hosts := r.hostToIP
localDomains := r.localDomains
subdomainHosts := r.subdomainHosts
magicHosts := r.magicHosts
r.mu.Unlock()
addrs, found := hosts[domain]
if !found && magicHosts != nil {
addrs, found = magicHosts.LookupHost(domain)
}
if !found {
for parent := domain.Parent(); parent != ""; parent = parent.Parent() {
if subdomainHosts.Contains(parent) {
addrs, found = hosts[parent]
break
}
if magicHosts != nil && magicHosts.SubdomainHost(parent) {
addrs, found = magicHosts.LookupHost(parent)
break
}
}
}
if !found {
@@ -865,6 +920,9 @@ func (r *Resolver) fqdnForIPLocked(ip netip.Addr, name dnsname.FQDN) (dnsname.FQ
}
ret, ok := r.ipToHost[ip]
if !ok && r.magicHosts != nil {
ret, ok = r.magicHosts.LookupPTR(ip)
}
if !ok {
for _, suffix := range r.localDomains {
if suffix.Contains(name) {
+105
View File
@@ -476,6 +476,111 @@ func TestResolveLocalSubdomain(t *testing.T) {
}
}
// fakeMagicDNSHosts is a MagicDNSHosts for tests, serving from fixed maps.
type fakeMagicDNSHosts struct {
hosts map[dnsname.FQDN][]netip.Addr
subdomain set.Set[dnsname.FQDN]
ptr map[netip.Addr]dnsname.FQDN
}
func (f fakeMagicDNSHosts) LookupHost(fqdn dnsname.FQDN) (ips []netip.Addr, ok bool) {
ips, ok = f.hosts[fqdn]
return ips, ok
}
func (f fakeMagicDNSHosts) LookupPTR(ip netip.Addr) (_ dnsname.FQDN, ok bool) {
name, ok := f.ptr[ip]
return name, ok
}
func (f fakeMagicDNSHosts) SubdomainHost(fqdn dnsname.FQDN) bool {
return f.subdomain.Contains(fqdn)
}
// Tests forward, subdomain, and reverse resolution served on demand
// via the MagicDNSHosts hook, and that entries pushed via Config.Hosts
// take precedence over the hook.
func TestResolveLocalMagicDNSHosts(t *testing.T) {
r := newResolver(t)
defer r.Close()
r.SetConfig(Config{
Hosts: map[dnsname.FQDN][]netip.Addr{
"extra.ipn.dev.": {netip.MustParseAddr("100.100.1.1")},
"both.ipn.dev.": {netip.MustParseAddr("100.100.2.2")},
},
LocalDomains: []dnsname.FQDN{"ipn.dev.", "64.100.in-addr.arpa."},
})
node4 := netip.MustParseAddr("100.64.0.7")
node6 := netip.MustParseAddr("fd7a:115c:a1e0::7")
r.SetMagicDNSHosts(fakeMagicDNSHosts{
hosts: map[dnsname.FQDN][]netip.Addr{
"node.ipn.dev.": {node4, node6},
"v4only.ipn.dev.": {node4},
"subber.ipn.dev.": {node4},
"both.ipn.dev.": {netip.MustParseAddr("100.100.9.9")}, // masked by Config.Hosts
},
subdomain: set.Of[dnsname.FQDN]("subber.ipn.dev."),
ptr: map[netip.Addr]dnsname.FQDN{node4: "node.ipn.dev."},
})
tests := []struct {
name string
qname dnsname.FQDN
qtype dns.Type
ip netip.Addr
code dns.RCode
}{
{"hook-ipv4", "node.ipn.dev.", dns.TypeA, node4, dns.RCodeSuccess},
{"hook-ipv6", "node.ipn.dev.", dns.TypeAAAA, node6, dns.RCodeSuccess},
// A known name with no records of the queried family is
// "name exists, no records", not NXDOMAIN.
{"hook-no-ipv6", "v4only.ipn.dev.", dns.TypeAAAA, netip.Addr{}, dns.RCodeSuccess},
{"hook-nxdomain", "gone.ipn.dev.", dns.TypeA, netip.Addr{}, dns.RCodeNameError},
{"hook-foreign", "google.com.", dns.TypeA, netip.Addr{}, dns.RCodeRefused},
{"hook-subdomain", "foo.subber.ipn.dev.", dns.TypeA, node4, dns.RCodeSuccess},
{"hook-subdomain-deep", "bar.foo.subber.ipn.dev.", dns.TypeA, node4, dns.RCodeSuccess},
{"hook-subdomain-no-cap", "foo.node.ipn.dev.", dns.TypeA, netip.Addr{}, dns.RCodeNameError},
// Config.Hosts entries (control's ExtraRecords) are
// consulted before the hook.
{"config-hosts", "extra.ipn.dev.", dns.TypeA, netip.MustParseAddr("100.100.1.1"), dns.RCodeSuccess},
{"config-hosts-precedence", "both.ipn.dev.", dns.TypeA, netip.MustParseAddr("100.100.2.2"), dns.RCodeSuccess},
}
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)
}
})
}
revTests := []struct {
name string
q dnsname.FQDN
want dnsname.FQDN
code dns.RCode
}{
{"hook-ptr", "7.0.64.100.in-addr.arpa.", "node.ipn.dev.", dns.RCodeSuccess},
{"hook-ptr-nxdomain", "8.0.64.100.in-addr.arpa.", "", dns.RCodeNameError},
{"hook-ptr-foreign", "5.4.3.2.in-addr.arpa.", "", dns.RCodeRefused},
}
for _, tt := range revTests {
t.Run(tt.name, func(t *testing.T) {
name, code := r.resolveLocalReverse(tt.q)
if code != tt.code {
t.Errorf("code = %v; want %v", code, tt.code)
}
if name != tt.want {
t.Errorf("name = %v; want %v", name, tt.want)
}
})
}
}
func TestResolveLocalReverse(t *testing.T) {
r := newResolver(t)
defer r.Close()