diff --git a/ipn/ipnlocal/dnsconfig_test.go b/ipn/ipnlocal/dnsconfig_test.go index dbb40cd76..8064af673 100644 --- a/ipn/ipnlocal/dnsconfig_test.go +++ b/ipn/ipnlocal/dnsconfig_test.go @@ -14,10 +14,12 @@ import ( "tailscale.com/tailcfg" "tailscale.com/tstest" "tailscale.com/types/dnstype" + "tailscale.com/types/logger" "tailscale.com/types/netmap" "tailscale.com/util/cloudenv" "tailscale.com/util/cmpx" "tailscale.com/util/dnsname" + "tailscale.com/wgengine" ) func ipps(ippStrs ...string) (ipps []netip.Prefix) { @@ -83,7 +85,8 @@ func TestDNSConfigForNetmap(t *testing.T) { { ID: 2, Name: "b.net", - Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::2"), + Addresses: ipps("100.102.0.1", "100.102.0.2"), // host has broken IPv6 + NoIPv6: true, }, { ID: 3, @@ -97,7 +100,7 @@ func TestDNSConfigForNetmap(t *testing.T) { Hosts: map[dnsname.FQDN][]netip.Addr{ "b.net.": ips("100.102.0.1", "100.102.0.2"), "myname.net.": ips("100.101.101.101"), - "peera.net.": ips("100.102.0.1", "100.102.0.2"), + "peera.net.": ips("100.102.0.1", "100.102.0.2", "fe75::1001", "fe75::1002"), "v6-only.net.": ips("fe75::3"), }, }, @@ -327,12 +330,88 @@ func TestDNSConfigForNetmap(t *testing.T) { Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, }, }, + { + name: "self_no_ipv6", + nm: &netmap.NetworkMap{ + Name: "myname.net", + SelfNode: (&tailcfg.Node{ + Addresses: ipps("100.101.101.101", "fe75::1010"), + NoIPv6: true, + }).View(), + }, + peers: nodeViews([]*tailcfg.Node{ + { + ID: 1, + Name: "peera.net", + Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::1001", "fe75::1002"), + }, + }), + 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"), + "peera.net.": ips("100.102.0.1", "100.102.0.2", "fe75::1001", "fe75::1002"), + }, + }, + }, + { + name: "self_no_ipv6_hostinfo", + nm: &netmap.NetworkMap{ + Name: "myname.net", + SelfNode: (&tailcfg.Node{ + Addresses: ipps("100.101.101.101", "fe75::1010"), + Hostinfo: (&tailcfg.Hostinfo{ + NetInfo: &tailcfg.NetInfo{ + OSHasIPv6: "false", + }, + }).View(), + }).View(), + }, + peers: nodeViews([]*tailcfg.Node{ + { + ID: 1, + Name: "peera.net", + Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::1001", "fe75::1002"), + }, + }), + 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"), + "peera.net.": ips("100.102.0.1", "100.102.0.2", "fe75::1001", "fe75::1002"), + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { verOS := cmpx.Or(tt.os, "linux") + + eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0) + var log tstest.MemLogger - got := dnsConfigForNetmap(tt.nm, peersMap(tt.peers), tt.prefs.View(), log.Logf, verOS) + b := &LocalBackend{ + e: eng, + netMap: tt.nm, + logf: log.Logf, + peers: peersMap(tt.peers), + } + b.mu.Lock() + b.updateFilterLocked(b.netMap, tt.prefs.View()) + + if sn := tt.nm.SelfNode; sn.Valid() && sn.Hostinfo().Valid() { + b.hostinfo = sn.Hostinfo().AsStruct() + } + + // the updateFilterLocked function logs something; clear it + log.Lock() + log.Reset() + log.Unlock() + + got := b.dnsConfigForNetmapLocked(tt.prefs.View(), verOS) + b.mu.Unlock() if !reflect.DeepEqual(got, tt.want) { gotj, _ := json.MarshalIndent(got, "", "\t") wantj, _ := json.MarshalIndent(tt.want, "", "\t") diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index cc904b18e..05351a976 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -3466,7 +3466,7 @@ func (b *LocalBackend) authReconfig() { hasPAC := b.prevIfState.HasPAC() disableSubnetsIfPAC := hasCapability(nm, tailcfg.NodeAttrDisableSubnetsIfPAC) dohURL, dohURLOK := exitNodeCanProxyDNS(nm, b.peers, prefs.ExitNodeID()) - dcfg := dnsConfigForNetmap(nm, b.peers, prefs, b.logf, version.OS()) + dcfg := b.dnsConfigForNetmapLocked(prefs, version.OS()) // If the current node is an app connector, ensure the app connector machine is started b.reconfigAppConnectorLocked(nm, prefs) b.mu.Unlock() @@ -3558,12 +3558,13 @@ func shouldUseOneCGNATRoute(logf logger.Logf, controlKnobs *controlknobs.Knobs, return false } -// dnsConfigForNetmap returns a *dns.Config for the given netmap, -// prefs, client OS version, and cloud hosting environment. +// dnsConfigForNetmapLocked 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, logf logger.Logf, versionOS string) *dns.Config { +func (b *LocalBackend) dnsConfigForNetmapLocked(prefs ipn.PrefsView, versionOS string) *dns.Config { + nm := b.netMap if nm == nil { return nil } @@ -3582,7 +3583,7 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg. // 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]) { + set := func(name string, addrs views.Slice[netip.Prefix], noIPv6 bool) { if addrs.Len() == 0 || name == "" { return } @@ -3599,31 +3600,61 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg. } var ips []netip.Addr for i := range addrs.LenIter() { - addr := addrs.At(i) + addr := addrs.At(i).Addr() if selfV6Only { - if addr.Addr().Is6() { - ips = append(ips, addr.Addr()) + if addr.Is6() { + ips = append(ips, 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. + + // If this is an IPv4 address, we always use it. + if addr.Is4() { + ips = append(ips, addr) + continue + } + + // If the node has no IPv4 addresses at all, we use + // this address in all cases (even if noIPv6 is set), + // since it's better to return a valid-but-unusable + // IPv6 addr instead of an empty result. + if !have4 { + ips = append(ips, addr) + continue + } + + // If this peer doesn't have host-level IPv6 support, + // but (checked above) does have IPv4 addresses, then + // we don't return IPv6 addresses to stop the local + // node from trying to communicate with the peer over + // IPv6 (which won't work). // // https://github.com/tailscale/tailscale/issues/1152 // tracks adding the right capability reporting to // enable AAAA in MagicDNS. - if addr.Addr().Is6() && have4 { + if noIPv6 { continue } - ips = append(ips, addr.Addr()) + ips = append(ips, addr) } dcfg.Hosts[fqdn] = ips } - set(nm.Name, nm.GetAddresses()) - for _, peer := range peers { - set(peer.Name(), peer.Addresses()) + + var noIPv6Self bool + if b.hostinfo != nil && b.hostinfo.NetInfo != nil { + b, ok := b.hostinfo.NetInfo.OSHasIPv6.Get() + + // No data == assume we have v6 + if ok && !b { + noIPv6Self = true + } + } + if nm.SelfNode.Valid() && nm.SelfNode.NoIPv6() { + noIPv6Self = true + } + set(nm.Name, nm.GetAddresses(), noIPv6Self) + for _, peer := range b.peers { + set(peer.Name(), peer.Addresses(), peer.NoIPv6()) } for _, rec := range nm.DNS.ExtraRecords { switch rec.Type { @@ -3652,7 +3683,7 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg. for _, dom := range nm.DNS.Domains { fqdn, err := dnsname.ToFQDN(dom) if err != nil { - logf("[unexpected] non-FQDN search domain %q", dom) + b.logf("[unexpected] non-FQDN search domain %q", dom) } dcfg.SearchDomains = append(dcfg.SearchDomains, fqdn) } @@ -3668,7 +3699,7 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg. // If we're using an exit node and that exit node is new enough (1.19.x+) // to run a DoH DNS proxy, then send all our DNS traffic through it. - if dohURL, ok := exitNodeCanProxyDNS(nm, peers, prefs.ExitNodeID()); ok { + if dohURL, ok := exitNodeCanProxyDNS(nm, b.peers, prefs.ExitNodeID()); ok { addDefault([]*dnstype.Resolver{{Addr: dohURL}}) return dcfg } @@ -3679,7 +3710,7 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg. if len(nm.DNS.Resolvers) > 0 { addDefault(nm.DNS.Resolvers) } else { - if resolvers, ok := wireguardExitNodeDNSResolvers(nm, peers, prefs.ExitNodeID()); ok { + if resolvers, ok := wireguardExitNodeDNSResolvers(nm, b.peers, prefs.ExitNodeID()); ok { addDefault(resolvers) } } @@ -3687,7 +3718,7 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg. for suffix, resolvers := range nm.DNS.Routes { fqdn, err := dnsname.ToFQDN(suffix) if err != nil { - logf("[unexpected] non-FQDN route suffix %q", suffix) + b.logf("[unexpected] non-FQDN route suffix %q", suffix) } // Create map entry even if len(resolvers) == 0; Issue 2706. diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 17d374866..0ba631832 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -1136,7 +1136,14 @@ func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) { } prefs := &ipn.Prefs{ExitNodeID: tc.exitNode, CorpDNS: true} - got := dnsConfigForNetmap(nm, peersMap(tc.peers), prefs.View(), t.Logf, "") + b := &LocalBackend{ + netMap: nm, + logf: t.Logf, + peers: peersMap(tc.peers), + } + b.mu.Lock() + got := b.dnsConfigForNetmapLocked(prefs.View(), "") + b.mu.Unlock() if !resolversEqual(t, got.DefaultResolvers, tc.wantDefaultResolvers) { t.Errorf("DefaultResolvers: got %#v, want %#v", got.DefaultResolvers, tc.wantDefaultResolvers) } diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index dae9cf1b1..b5185ed50 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -124,7 +124,8 @@ type CapabilityVersion int // - 81: 2023-11-17: MapResponse.PacketFilters (incremental packet filter updates) // - 82: 2023-12-01: Client understands NodeAttrLinuxMustUseIPTables, NodeAttrLinuxMustUseNfTables, c2n /netfilter-kind // - 83: 2023-12-18: Client understands DefaultAutoUpdate -const CurrentCapabilityVersion CapabilityVersion = 83 +// - 84: 2023-01-03: Client understands Node.NoIPv6 +const CurrentCapabilityVersion CapabilityVersion = 84 type StableID string @@ -407,6 +408,11 @@ type Node struct { // ExitNodeDNSResolvers is the list of DNS servers that should be used when this // node is marked IsWireGuardOnly and being used as an exit node. ExitNodeDNSResolvers []*dnstype.Resolver `json:",omitempty"` + + // NoIPv6 is set when this node has broken IPv6 support at the + // operating system level, and thus cannot receive IPv6 packets even + // inside a Wireguard tunnel. + NoIPv6 bool `json:",omitempty"` } // HasCap reports whether the node has the given capability. @@ -2015,7 +2021,8 @@ func (n *Node) Equal(n2 *Node) bool { n.Expired == n2.Expired && eqPtr(n.SelfNodeV4MasqAddrForThisPeer, n2.SelfNodeV4MasqAddrForThisPeer) && eqPtr(n.SelfNodeV6MasqAddrForThisPeer, n2.SelfNodeV6MasqAddrForThisPeer) && - n.IsWireGuardOnly == n2.IsWireGuardOnly + n.IsWireGuardOnly == n2.IsWireGuardOnly && + n.NoIPv6 == n2.NoIPv6 } func eqPtr[T comparable](a, b *T) bool { diff --git a/tailcfg/tailcfg_clone.go b/tailcfg/tailcfg_clone.go index 122e57edc..c46f38d81 100644 --- a/tailcfg/tailcfg_clone.go +++ b/tailcfg/tailcfg_clone.go @@ -119,6 +119,7 @@ var _NodeCloneNeedsRegeneration = Node(struct { SelfNodeV6MasqAddrForThisPeer *netip.Addr IsWireGuardOnly bool ExitNodeDNSResolvers []*dnstype.Resolver + NoIPv6 bool }{}) // Clone makes a deep copy of Hostinfo. diff --git a/tailcfg/tailcfg_view.go b/tailcfg/tailcfg_view.go index ac4d58030..bef81e8f3 100644 --- a/tailcfg/tailcfg_view.go +++ b/tailcfg/tailcfg_view.go @@ -198,6 +198,7 @@ func (v NodeView) IsWireGuardOnly() bool { return v.ж.IsWireGuardOnly } func (v NodeView) ExitNodeDNSResolvers() views.SliceView[*dnstype.Resolver, dnstype.ResolverView] { return views.SliceOfViews[*dnstype.Resolver, dnstype.ResolverView](v.ж.ExitNodeDNSResolvers) } +func (v NodeView) NoIPv6() bool { return v.ж.NoIPv6 } func (v NodeView) Equal(v2 NodeView) bool { return v.ж.Equal(v2.ж) } // A compilation failure here means this code must be regenerated, with the command at the top of this file. @@ -236,6 +237,7 @@ var _NodeViewNeedsRegeneration = Node(struct { SelfNodeV6MasqAddrForThisPeer *netip.Addr IsWireGuardOnly bool ExitNodeDNSResolvers []*dnstype.Resolver + NoIPv6 bool }{}) // View returns a readonly view of Hostinfo.