Bladeren bron

tailcfg: add DNS address list for IsWireGuardOnly nodes

Tailscale exit nodes provide DNS service over the peer API, however
IsWireGuardOnly nodes do not have a peer API, and instead need client
DNS parameters passed in their node description.

For Mullvad nodes this will contain the in network 10.64.0.1 address.

Updates #9377

Signed-off-by: James Tucker <[email protected]>
James Tucker 2 jaren geleden
bovenliggende
commit
e7727db553

+ 13 - 0
control/controlclient/map.go

@@ -693,6 +693,19 @@ func peerChangeDiff(was tailcfg.NodeView, n *tailcfg.Node) (_ *tailcfg.PeerChang
 			if va == nil || vb == nil || *va != *vb {
 				return nil, false
 			}
+		case "ExitNodeDNSResolvers":
+			va, vb := was.ExitNodeDNSResolvers(), views.SliceOfViews(n.ExitNodeDNSResolvers)
+
+			if va.Len() != vb.Len() {
+				return nil, false
+			}
+
+			for i := range va.LenIter() {
+				if !va.At(i).Equal(vb.At(i)) {
+					return nil, false
+				}
+			}
+
 		}
 	}
 	if ret != nil {

+ 35 - 0
control/controlclient/map_test.go

@@ -20,6 +20,7 @@ import (
 	"tailscale.com/tailcfg"
 	"tailscale.com/tstest"
 	"tailscale.com/tstime"
+	"tailscale.com/types/dnstype"
 	"tailscale.com/types/key"
 	"tailscale.com/types/logger"
 	"tailscale.com/types/netmap"
@@ -835,6 +836,40 @@ func TestPatchifyPeersChanged(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "change_exitnodednsresolvers",
+			mr0: &tailcfg.MapResponse{
+				Node: &tailcfg.Node{Name: "foo.bar.ts.net."},
+				Peers: []*tailcfg.Node{
+					{ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.exmaple.com"}}, Hostinfo: hi},
+				},
+			},
+			mr1: &tailcfg.MapResponse{
+				PeersChanged: []*tailcfg.Node{
+					{ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns2.exmaple.com"}}, Hostinfo: hi},
+				},
+			},
+			want: &tailcfg.MapResponse{
+				PeersChanged: []*tailcfg.Node{
+					{ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns2.exmaple.com"}}, Hostinfo: hi},
+				},
+			},
+		},
+		{
+			name: "same_exitnoderesolvers",
+			mr0: &tailcfg.MapResponse{
+				Node: &tailcfg.Node{Name: "foo.bar.ts.net."},
+				Peers: []*tailcfg.Node{
+					{ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.exmaple.com"}}, Hostinfo: hi},
+				},
+			},
+			mr1: &tailcfg.MapResponse{
+				PeersChanged: []*tailcfg.Node{
+					{ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.exmaple.com"}}, Hostinfo: hi},
+				},
+			},
+			want: &tailcfg.MapResponse{},
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {

+ 4 - 0
tailcfg/tailcfg.go

@@ -336,6 +336,10 @@ type Node struct {
 	// is not expected to speak Disco or DERP, and it must have Endpoints in
 	// order to be reachable.
 	IsWireGuardOnly bool `json:",omitempty"`
+
+	// 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"`
 }
 
 // DisplayName returns the user-facing name for a node which should

+ 7 - 0
tailcfg/tailcfg_clone.go

@@ -65,6 +65,12 @@ func (src *Node) Clone() *Node {
 	if dst.SelfNodeV4MasqAddrForThisPeer != nil {
 		dst.SelfNodeV4MasqAddrForThisPeer = ptr.To(*src.SelfNodeV4MasqAddrForThisPeer)
 	}
+	if src.ExitNodeDNSResolvers != nil {
+		dst.ExitNodeDNSResolvers = make([]*dnstype.Resolver, len(src.ExitNodeDNSResolvers))
+		for i := range dst.ExitNodeDNSResolvers {
+			dst.ExitNodeDNSResolvers[i] = src.ExitNodeDNSResolvers[i].Clone()
+		}
+	}
 	return dst
 }
 
@@ -101,6 +107,7 @@ var _NodeCloneNeedsRegeneration = Node(struct {
 	Expired                       bool
 	SelfNodeV4MasqAddrForThisPeer *netip.Addr
 	IsWireGuardOnly               bool
+	ExitNodeDNSResolvers          []*dnstype.Resolver
 }{})
 
 // Clone makes a deep copy of Hostinfo.

+ 1 - 1
tailcfg/tailcfg_test.go

@@ -350,7 +350,7 @@ func TestNodeEqual(t *testing.T) {
 		"UnsignedPeerAPIOnly",
 		"ComputedName", "computedHostIfDifferent", "ComputedNameWithHost",
 		"DataPlaneAuditLogID", "Expired", "SelfNodeV4MasqAddrForThisPeer",
-		"IsWireGuardOnly",
+		"IsWireGuardOnly", "ExitNodeDNSResolvers",
 	}
 	if have := fieldsOf(reflect.TypeOf(Node{})); !reflect.DeepEqual(have, nodeHandles) {
 		t.Errorf("Node.Equal check might be out of sync\nfields: %q\nhandled: %q\n",

+ 5 - 1
tailcfg/tailcfg_view.go

@@ -180,7 +180,10 @@ func (v NodeView) SelfNodeV4MasqAddrForThisPeer() *netip.Addr {
 	return &x
 }
 
-func (v NodeView) IsWireGuardOnly() bool  { return v.ж.IsWireGuardOnly }
+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) 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.
@@ -216,6 +219,7 @@ var _NodeViewNeedsRegeneration = Node(struct {
 	Expired                       bool
 	SelfNodeV4MasqAddrForThisPeer *netip.Addr
 	IsWireGuardOnly               bool
+	ExitNodeDNSResolvers          []*dnstype.Resolver
 }{})
 
 // View returns a readonly view of Hostinfo.

+ 13 - 0
types/dnstype/dnstype.go

@@ -8,6 +8,7 @@ package dnstype
 
 import (
 	"net/netip"
+	"slices"
 )
 
 // Resolver is the configuration for one DNS resolver.
@@ -51,3 +52,15 @@ func (r *Resolver) IPPort() (ipp netip.AddrPort, ok bool) {
 	}
 	return
 }
+
+// Equal reports whether r and other are equal.
+func (r *Resolver) Equal(other *Resolver) bool {
+	if r == nil || other == nil {
+		return r == other
+	}
+	if r == other {
+		return true
+	}
+
+	return r.Addr == other.Addr && slices.Equal(r.BootstrapResolution, other.BootstrapResolution)
+}

+ 81 - 0
types/dnstype/dnstype_test.go

@@ -0,0 +1,81 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package dnstype
+
+import (
+	"net/netip"
+	"reflect"
+	"slices"
+	"sort"
+	"testing"
+)
+
+func TestResolverEqual(t *testing.T) {
+	var fieldNames []string
+	for _, field := range reflect.VisibleFields(reflect.TypeOf(Resolver{})) {
+		fieldNames = append(fieldNames, field.Name)
+	}
+	sort.Strings(fieldNames)
+	if !slices.Equal(fieldNames, []string{"Addr", "BootstrapResolution"}) {
+		t.Errorf("Resolver fields changed; update test")
+	}
+
+	tests := []struct {
+		name string
+		a, b *Resolver
+		want bool
+	}{
+		{
+			name: "nil",
+			a:    nil,
+			b:    nil,
+			want: true,
+		},
+		{
+			name: "nil vs non-nil",
+			a:    nil,
+			b:    &Resolver{},
+			want: false,
+		},
+		{
+			name: "non-nil vs nil",
+			a:    &Resolver{},
+			b:    nil,
+			want: false,
+		},
+		{
+			name: "equal",
+			a:    &Resolver{Addr: "dns.example.com"},
+			b:    &Resolver{Addr: "dns.example.com"},
+			want: true,
+		},
+		{
+			name: "not equal addrs",
+			a:    &Resolver{Addr: "dns.example.com"},
+			b:    &Resolver{Addr: "dns2.example.com"},
+			want: false,
+		},
+		{
+			name: "not equal bootstrap",
+			a: &Resolver{
+				Addr:                "dns.example.com",
+				BootstrapResolution: []netip.Addr{netip.MustParseAddr("8.8.8.8")},
+			},
+			b: &Resolver{
+				Addr:                "dns.example.com",
+				BootstrapResolution: []netip.Addr{netip.MustParseAddr("8.8.4.4")},
+			},
+			want: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := tt.a.Equal(tt.b)
+			if got != tt.want {
+				t.Errorf("got %v; want %v", got, tt.want)
+			}
+		})
+	}
+}

+ 1 - 0
types/dnstype/dnstype_view.go

@@ -64,6 +64,7 @@ func (v ResolverView) Addr() string { return v.ж.Addr }
 func (v ResolverView) BootstrapResolution() views.Slice[netip.Addr] {
 	return views.SliceOf(v.ж.BootstrapResolution)
 }
+func (v ResolverView) Equal(v2 ResolverView) 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 _ResolverViewNeedsRegeneration = Resolver(struct {