ipn/ipnlocal: return IPv6 addresses in MagicDNS [capver 84]

Unless a node has the "NoIPv6" flag set, return IPv6 addresses for that
node in MagicDNS. Also bump the capability version so control can
determine whether to send this field to the client.

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: Ia36b9c787af32d5904320893be88be3d3873d72e
This commit is contained in:
Andrew Dunham 2023-12-18 14:12:24 -05:00
parent 3d57c885bf
commit 384b8dd49c
6 changed files with 154 additions and 27 deletions

View File

@ -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")

View File

@ -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.

View File

@ -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)
}

View File

@ -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 {

View File

@ -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.

View File

@ -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.