Просмотр исходного кода

ipn: fix netmap change tracking and dns map generation (#609)

Signed-off-by: Dmytro Shynkevych <[email protected]>
Dmytro Shynkevych 5 лет назад
Родитель
Сommit
c7582dc234

+ 3 - 0
control/controlclient/direct.go

@@ -4,6 +4,8 @@
 
 package controlclient
 
+//go:generate go run tailscale.com/cmd/cloner -type=Persist -output=direct_clone.go
+
 import (
 	"bytes"
 	"context"
@@ -628,6 +630,7 @@ func (c *Direct) PollNetMap(ctx context.Context, maxPolls int, cb func(*NetworkM
 			NodeKey:      tailcfg.NodeKey(persist.PrivateNodeKey.Public()),
 			PrivateKey:   persist.PrivateNodeKey,
 			Expiry:       resp.Node.KeyExpiry,
+			Name:         resp.Node.Name,
 			Addresses:    resp.Node.Addresses,
 			Peers:        resp.Peers,
 			LocalPort:    localPort,

+ 20 - 0
control/controlclient/direct_clone.go

@@ -0,0 +1,20 @@
+// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Code generated by tailscale.com/cmd/cloner -type Persist; DO NOT EDIT.
+
+package controlclient
+
+import ()
+
+// Clone makes a deep copy of Persist.
+// The result aliases no memory with the original.
+func (src *Persist) Clone() *Persist {
+	if src == nil {
+		return nil
+	}
+	dst := new(Persist)
+	*dst = *src
+	return dst
+}

+ 5 - 3
control/controlclient/netmap.go

@@ -23,9 +23,11 @@ import (
 type NetworkMap struct {
 	// Core networking
 
-	NodeKey       tailcfg.NodeKey
-	PrivateKey    wgcfg.PrivateKey
-	Expiry        time.Time
+	NodeKey    tailcfg.NodeKey
+	PrivateKey wgcfg.PrivateKey
+	Expiry     time.Time
+	// Name is the DNS name assigned to this node.
+	Name          string
 	Addresses     []wgcfg.CIDR
 	LocalPort     uint16 // used for debugging
 	MachineStatus tailcfg.MachineStatus

+ 12 - 2
internal/deepprint/deepprint.go

@@ -18,13 +18,23 @@ import (
 	"reflect"
 )
 
-func Hash(v interface{}) string {
+func Hash(v ...interface{}) string {
 	h := sha256.New()
 	Print(h, v)
 	return fmt.Sprintf("%x", h.Sum(nil))
 }
 
-func Print(w io.Writer, v interface{}) {
+// UpdateHash sets last to the hash of v and reports whether its value changed.
+func UpdateHash(last *string, v ...interface{}) (changed bool) {
+	sig := Hash(v)
+	if *last != sig {
+		*last = sig
+		return true
+	}
+	return false
+}
+
+func Print(w io.Writer, v ...interface{}) {
 	print(w, reflect.ValueOf(v), make(map[uintptr]bool))
 }
 

+ 147 - 80
ipn/local.go

@@ -16,6 +16,7 @@ import (
 	"golang.org/x/oauth2"
 	"inet.af/netaddr"
 	"tailscale.com/control/controlclient"
+	"tailscale.com/internal/deepprint"
 	"tailscale.com/ipn/ipnstate"
 	"tailscale.com/ipn/policy"
 	"tailscale.com/portlist"
@@ -51,12 +52,10 @@ type LocalBackend struct {
 	backendLogID    string
 	portpoll        *portlist.Poller // may be nil
 	portpollOnce    sync.Once
+	serverURL       string // tailcontrol URL
 	newDecompressor func() (controlclient.Decompressor, error)
 
-	// TODO: these fields are accessed unsafely by concurrent
-	// goroutines. They need to be protected.
-	serverURL       string // tailcontrol URL
-	lastFilterPrint time.Time
+	filterHash string
 
 	// The mutex protects the following elements.
 	mu       sync.Mutex
@@ -186,21 +185,56 @@ func (b *LocalBackend) SetDecompressor(fn func() (controlclient.Decompressor, er
 // setClientStatus is the callback invoked by the control client whenever it posts a new status.
 // Among other things, this is where we update the netmap, packet filters, DNS and DERP maps.
 func (b *LocalBackend) setClientStatus(st controlclient.Status) {
+	// The following do not depend on any data for which we need to lock b.
+	if st.Err != "" {
+		// TODO(crawshaw): display in the UI.
+		b.logf("Received error: %v", st.Err)
+		return
+	}
 	if st.LoginFinished != nil {
 		// Auth completed, unblock the engine
 		b.blockEngineUpdates(false)
 		b.authReconfig()
 		b.send(Notify{LoginFinished: &empty.Message{}})
 	}
+
+	prefsChanged := false
+
+	// Lock b once and do only the things that require locking.
+	b.mu.Lock()
+
+	prefs := b.prefs
+	stateKey := b.stateKey
+	netMap := b.netMap
+	interact := b.interact
+
 	if st.Persist != nil {
-		persist := *st.Persist // copy
+		if b.prefs.Persist.Equals(st.Persist) {
+			prefsChanged = true
+			b.prefs.Persist = st.Persist.Clone()
+		}
+	}
+	if st.NetMap != nil {
+		b.netMap = st.NetMap
+	}
+	if st.URL != "" {
+		b.authURL = st.URL
+	}
+	if b.state == NeedsLogin {
+		if !b.prefs.WantRunning {
+			prefsChanged = true
+		}
+		b.prefs.WantRunning = true
+	}
+	// Prefs will be written out; this is not safe unless locked or cloned.
+	if prefsChanged {
+		prefs = b.prefs.Clone()
+	}
 
-		b.mu.Lock()
-		b.prefs.Persist = &persist
-		prefs := b.prefs.Clone()
-		stateKey := b.stateKey
-		b.mu.Unlock()
+	b.mu.Unlock()
 
+	// Now complete the lock-free parts of what we started while locked.
+	if prefsChanged {
 		if stateKey != "" {
 			if err := b.store.WriteState(stateKey, prefs.ToBytes()); err != nil {
 				b.logf("Failed to save new controlclient state: %v", err)
@@ -209,63 +243,41 @@ func (b *LocalBackend) setClientStatus(st controlclient.Status) {
 		b.send(Notify{Prefs: prefs})
 	}
 	if st.NetMap != nil {
-		// Netmap is unchanged only when the diff is empty.
-		changed := true
-		b.mu.Lock()
-		if b.netMap != nil {
-			diff := st.NetMap.ConciseDiffFrom(b.netMap)
+		if netMap != nil {
+			diff := st.NetMap.ConciseDiffFrom(netMap)
 			if strings.TrimSpace(diff) == "" {
-				changed = false
 				b.logf("netmap diff: (none)")
 			} else {
 				b.logf("netmap diff:\n%v", diff)
 			}
 		}
-		disableDERP := b.prefs != nil && b.prefs.DisableDERP
-		b.netMap = st.NetMap
-		b.mu.Unlock()
 
-		b.send(Notify{NetMap: st.NetMap})
-		// There is nothing to update if the map hasn't changed.
-		if changed {
-			b.updateFilter(st.NetMap)
+		b.updateFilter(st.NetMap, prefs)
+		b.e.SetNetworkMap(st.NetMap)
+
+		if !dnsMapsEqual(st.NetMap, netMap) {
 			b.updateDNSMap(st.NetMap)
-			b.e.SetNetworkMap(st.NetMap)
 		}
+
+		disableDERP := prefs != nil && prefs.DisableDERP
 		if disableDERP {
 			b.e.SetDERPMap(nil)
 		} else {
 			b.e.SetDERPMap(st.NetMap.DERPMap)
 		}
+
+		b.send(Notify{NetMap: st.NetMap})
 	}
 	if st.URL != "" {
 		b.logf("Received auth URL: %.20v...", st.URL)
-
-		b.mu.Lock()
-		interact := b.interact
-		b.authURL = st.URL
-		b.mu.Unlock()
-
 		if interact > 0 {
 			b.popBrowserAuthNow()
 		}
 	}
-	if st.Err != "" {
-		// TODO(crawshaw): display in the UI.
-		b.logf("Received error: %v", st.Err)
-		return
-	}
-	if st.NetMap != nil {
-		b.mu.Lock()
-		if b.state == NeedsLogin {
-			b.prefs.WantRunning = true
-		}
-		prefs := b.prefs
-		b.mu.Unlock()
-
-		b.SetPrefs(prefs)
-	}
 	b.stateMachine()
+	// This is currently (2020-07-28) necessary; conditionally disabling it is fragile!
+	// This is where netmap information gets propagated to router and magicsock.
+	b.authReconfig()
 }
 
 // setWgengineStatus is the callback by the wireguard engine whenever it posts a new status.
@@ -360,7 +372,7 @@ func (b *LocalBackend) Start(opts Options) error {
 	persist := b.prefs.Persist
 	b.mu.Unlock()
 
-	b.updateFilter(nil)
+	b.updateFilter(nil, nil)
 
 	var discoPublic tailcfg.DiscoKey
 	if controlclient.Debug.Disco {
@@ -424,20 +436,30 @@ func (b *LocalBackend) Start(opts Options) error {
 
 // updateFilter updates the packet filter in wgengine based on the
 // given netMap and user preferences.
-func (b *LocalBackend) updateFilter(netMap *controlclient.NetworkMap) {
+func (b *LocalBackend) updateFilter(netMap *controlclient.NetworkMap, prefs *Prefs) {
 	if netMap == nil {
 		// Not configured yet, block everything
 		b.logf("netmap packet filter: (not ready yet)")
 		b.e.SetFilter(filter.NewAllowNone(b.logf))
 		return
 	}
+	packetFilter := netMap.PacketFilter
+
+	var advRoutes []wgcfg.CIDR
+	if prefs != nil {
+		advRoutes = prefs.AdvertiseRoutes
+	}
+	// Be conservative while not ready.
+	shieldsUp := prefs == nil || prefs.ShieldsUp
+
+	changed := deepprint.UpdateHash(&b.filterHash, packetFilter, advRoutes, shieldsUp)
+	if !changed {
+		return
+	}
 
-	b.mu.Lock()
-	advRoutes := b.prefs.AdvertiseRoutes
-	b.mu.Unlock()
 	localNets := wgCIDRsToFilter(netMap.Addresses, advRoutes)
 
-	if b.shieldsAreUp() {
+	if shieldsUp {
 		// Shields up, block everything
 		b.logf("netmap packet filter: (shields up)")
 		var prevFilter *filter.Filter // don't reuse old filter state
@@ -445,44 +467,81 @@ func (b *LocalBackend) updateFilter(netMap *controlclient.NetworkMap) {
 		return
 	}
 
-	// TODO(apenwarr): don't replace filter at all if unchanged.
-	// TODO(apenwarr): print a diff instead of full filter.
-	now := time.Now()
-	if now.Sub(b.lastFilterPrint) > 1*time.Minute {
-		b.logf("netmap packet filter: %v", netMap.PacketFilter)
-		b.lastFilterPrint = now
-	} else {
-		b.logf("netmap packet filter: (length %d)", len(netMap.PacketFilter))
+	b.logf("netmap packet filter: %v", packetFilter)
+	b.e.SetFilter(filter.New(packetFilter, localNets, b.e.GetFilter(), b.logf))
+}
+
+// dnsCIDRsEqual determines whether two CIDR lists are equal
+// for DNS map construction purposes (that is, only the first entry counts).
+func dnsCIDRsEqual(newAddr, oldAddr []wgcfg.CIDR) bool {
+	if len(newAddr) != len(oldAddr) {
+		return false
+	}
+	if len(newAddr) == 0 || newAddr[0] == oldAddr[0] {
+		return true
+	}
+	return false
+}
+
+// dnsMapsEqual determines whether the new and the old network map
+// induce the same DNS map. It does so without allocating memory,
+// at the expense of giving false negatives if peers are reordered.
+func dnsMapsEqual(new, old *controlclient.NetworkMap) bool {
+	if (old == nil) != (new == nil) {
+		return false
 	}
-	b.e.SetFilter(filter.New(netMap.PacketFilter, localNets, b.e.GetFilter(), b.logf))
+	if old == nil && new == nil {
+		return true
+	}
+
+	if len(new.Peers) != len(old.Peers) {
+		return false
+	}
+
+	if new.Name != old.Name {
+		return false
+	}
+	if !dnsCIDRsEqual(new.Addresses, old.Addresses) {
+		return false
+	}
+
+	for i, newPeer := range new.Peers {
+		oldPeer := old.Peers[i]
+		if newPeer.Name != oldPeer.Name {
+			return false
+		}
+		if !dnsCIDRsEqual(newPeer.Addresses, oldPeer.Addresses) {
+			return false
+		}
+	}
+
+	return true
 }
 
 // updateDNSMap updates the domain map in the DNS resolver in wgengine
 // based on the given netMap and user preferences.
 func (b *LocalBackend) updateDNSMap(netMap *controlclient.NetworkMap) {
 	if netMap == nil {
+		b.logf("dns map: (not ready)")
 		return
 	}
 
-	domainToIP := make(map[string]netaddr.IP)
-	set := func(hostname string, addrs []wgcfg.CIDR) {
+	nameToIP := make(map[string]netaddr.IP)
+	set := func(name string, addrs []wgcfg.CIDR) {
 		if len(addrs) == 0 {
 			return
 		}
-		domain := hostname
-		// Like PeerStatus.SimpleHostName()
-		domain = strings.TrimSuffix(domain, ".local")
-		domain = strings.TrimSuffix(domain, ".localdomain")
-		domain = domain + ".b.tailscale.net"
-		domainToIP[domain] = netaddr.IPFrom16(addrs[0].IP.Addr)
+		nameToIP[name] = netaddr.IPFrom16(addrs[0].IP.Addr)
 	}
 
 	for _, peer := range netMap.Peers {
-		set(peer.Hostinfo.Hostname, peer.Addresses)
+		set(peer.Name, peer.Addresses)
 	}
-	set(netMap.Hostinfo.Hostname, netMap.Addresses)
+	set(netMap.Name, netMap.Addresses)
 
-	b.e.SetDNSMap(tsdns.NewMap(domainToIP))
+	dnsMap := tsdns.NewMap(nameToIP)
+	// map diff will be logged in tsdns.Resolver.SetMap.
+	b.e.SetDNSMap(dnsMap)
 }
 
 // readPoller is a goroutine that receives service lists from
@@ -721,37 +780,45 @@ func (b *LocalBackend) SetPrefs(new *Prefs) {
 	}
 
 	b.mu.Lock()
+
+	netMap := b.netMap
+	stateKey := b.stateKey
+
 	old := b.prefs
 	new.Persist = old.Persist // caller isn't allowed to override this
 	b.prefs = new
-	if b.stateKey != "" {
-		if err := b.store.WriteState(b.stateKey, b.prefs.ToBytes()); err != nil {
-			b.logf("Failed to save new controlclient state: %v", err)
-		}
-	}
+	// We do this to avoid holding the lock while doing everything else.
+	new = b.prefs.Clone()
+
 	oldHi := b.hostinfo
 	newHi := oldHi.Clone()
 	newHi.RoutableIPs = append([]wgcfg.CIDR(nil), b.prefs.AdvertiseRoutes...)
 	applyPrefsToHostinfo(newHi, new)
 	b.hostinfo = newHi
 	hostInfoChanged := !oldHi.Equal(newHi)
+
 	b.mu.Unlock()
 
+	if stateKey != "" {
+		if err := b.store.WriteState(stateKey, new.ToBytes()); err != nil {
+			b.logf("Failed to save new controlclient state: %v", err)
+		}
+	}
+
 	b.logf("SetPrefs: %v", new.Pretty())
 
 	if old.ShieldsUp != new.ShieldsUp || hostInfoChanged {
 		b.doSetHostinfoFilterServices(newHi)
 	}
 
-	b.updateFilter(b.netMap)
-	// TODO(dmytro): when Prefs gain an EnableTailscaleDNS toggle, updateDNSMap here.
+	b.updateFilter(netMap, new)
 
 	turnDERPOff := new.DisableDERP && !old.DisableDERP
 	turnDERPOn := !new.DisableDERP && old.DisableDERP
 	if turnDERPOff {
 		b.e.SetDERPMap(nil)
-	} else if turnDERPOn && b.netMap != nil {
-		b.e.SetDERPMap(b.netMap.DERPMap)
+	} else if turnDERPOn && netMap != nil {
+		b.e.SetDERPMap(netMap.DERPMap)
 	}
 
 	if old.WantRunning != new.WantRunning {

+ 118 - 0
wgengine/tsdns/map.go

@@ -0,0 +1,118 @@
+// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package tsdns
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+
+	"inet.af/netaddr"
+)
+
+// Map is all the data Resolver needs to resolve DNS queries within the Tailscale network.
+type Map struct {
+	// nameToIP is a mapping of Tailscale domain names to their IP addresses.
+	// For example, monitoring.tailscale.us -> 100.64.0.1.
+	nameToIP map[string]netaddr.IP
+	// names are the keys of nameToIP in sorted order.
+	names []string
+}
+
+// NewMap returns a new Map with name to address mapping given by nameToIP.
+func NewMap(nameToIP map[string]netaddr.IP) *Map {
+	names := make([]string, 0, len(nameToIP))
+	for name := range nameToIP {
+		names = append(names, name)
+	}
+	sort.Strings(names)
+
+	return &Map{
+		nameToIP: nameToIP,
+		names:    names,
+	}
+}
+
+func printSingleNameIP(buf *strings.Builder, name string, ip netaddr.IP) {
+	// Output width is exactly 80 columns.
+	fmt.Fprintf(buf, "%s\t%s\n", name, ip)
+}
+
+func (m *Map) Pretty() string {
+	buf := new(strings.Builder)
+	for _, name := range m.names {
+		printSingleNameIP(buf, name, m.nameToIP[name])
+	}
+	return buf.String()
+}
+
+func (m *Map) PrettyDiffFrom(old *Map) string {
+	var (
+		oldNameToIP map[string]netaddr.IP
+		newNameToIP map[string]netaddr.IP
+		oldNames    []string
+		newNames    []string
+	)
+	if old != nil {
+		oldNameToIP = old.nameToIP
+		oldNames = old.names
+	}
+	if m != nil {
+		newNameToIP = m.nameToIP
+		newNames = m.names
+	}
+
+	buf := new(strings.Builder)
+
+	for len(oldNames) > 0 && len(newNames) > 0 {
+		var name string
+
+		newName, oldName := newNames[0], oldNames[0]
+		switch {
+		case oldName < newName:
+			name = oldName
+			oldNames = oldNames[1:]
+		case oldName > newName:
+			name = newName
+			newNames = newNames[1:]
+		case oldNames[0] == newNames[0]:
+			name = oldNames[0]
+			oldNames = oldNames[1:]
+			newNames = newNames[1:]
+		}
+
+		ipOld, inOld := oldNameToIP[name]
+		ipNew, inNew := newNameToIP[name]
+		switch {
+		case !inOld:
+			buf.WriteByte('+')
+			printSingleNameIP(buf, name, ipNew)
+		case !inNew:
+			buf.WriteByte('-')
+			printSingleNameIP(buf, name, ipOld)
+		case ipOld != ipNew:
+			buf.WriteByte('-')
+			printSingleNameIP(buf, name, ipOld)
+			buf.WriteByte('+')
+			printSingleNameIP(buf, name, ipNew)
+		}
+	}
+
+	for _, name := range oldNames {
+		if _, ok := newNameToIP[name]; !ok {
+			buf.WriteByte('-')
+			printSingleNameIP(buf, name, oldNameToIP[name])
+		}
+	}
+
+	for _, name := range newNames {
+		if _, ok := oldNameToIP[name]; !ok {
+			buf.WriteByte('+')
+			printSingleNameIP(buf, name, newNameToIP[name])
+		}
+	}
+
+	return buf.String()
+}

+ 138 - 0
wgengine/tsdns/map_test.go

@@ -0,0 +1,138 @@
+// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package tsdns
+
+import (
+	"testing"
+
+	"inet.af/netaddr"
+)
+
+func TestPretty(t *testing.T) {
+	tests := []struct {
+		name string
+		dmap *Map
+		want string
+	}{
+		{"empty", NewMap(nil), ""},
+		{
+			"single",
+			NewMap(map[string]netaddr.IP{
+				"hello.ipn.dev": netaddr.IPv4(100, 101, 102, 103),
+			}),
+			"hello.ipn.dev\t100.101.102.103\n",
+		},
+		{
+			"multiple",
+			NewMap(map[string]netaddr.IP{
+				"test1.domain":     netaddr.IPv4(100, 101, 102, 103),
+				"test2.sub.domain": netaddr.IPv4(100, 99, 9, 1),
+			}),
+			"test1.domain\t100.101.102.103\ntest2.sub.domain\t100.99.9.1\n",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := tt.dmap.Pretty()
+			if tt.want != got {
+				t.Errorf("want %v; got %v", tt.want, got)
+			}
+		})
+	}
+}
+
+func TestPrettyDiffFrom(t *testing.T) {
+	tests := []struct {
+		name string
+		map1 *Map
+		map2 *Map
+		want string
+	}{
+		{
+			"from_empty",
+			nil,
+			NewMap(map[string]netaddr.IP{
+				"test1.ipn.dev": netaddr.IPv4(100, 101, 102, 103),
+				"test2.ipn.dev": netaddr.IPv4(100, 103, 102, 101),
+			}),
+			"+test1.ipn.dev\t100.101.102.103\n+test2.ipn.dev\t100.103.102.101\n",
+		},
+		{
+			"equal",
+			NewMap(map[string]netaddr.IP{
+				"test1.ipn.dev": netaddr.IPv4(100, 101, 102, 103),
+				"test2.ipn.dev": netaddr.IPv4(100, 103, 102, 101),
+			}),
+			NewMap(map[string]netaddr.IP{
+				"test2.ipn.dev": netaddr.IPv4(100, 103, 102, 101),
+				"test1.ipn.dev": netaddr.IPv4(100, 101, 102, 103),
+			}),
+			"",
+		},
+		{
+			"changed_ip",
+			NewMap(map[string]netaddr.IP{
+				"test1.ipn.dev": netaddr.IPv4(100, 101, 102, 103),
+				"test2.ipn.dev": netaddr.IPv4(100, 103, 102, 101),
+			}),
+			NewMap(map[string]netaddr.IP{
+				"test2.ipn.dev": netaddr.IPv4(100, 104, 102, 101),
+				"test1.ipn.dev": netaddr.IPv4(100, 101, 102, 103),
+			}),
+			"-test2.ipn.dev\t100.103.102.101\n+test2.ipn.dev\t100.104.102.101\n",
+		},
+		{
+			"new_domain",
+			NewMap(map[string]netaddr.IP{
+				"test1.ipn.dev": netaddr.IPv4(100, 101, 102, 103),
+				"test2.ipn.dev": netaddr.IPv4(100, 103, 102, 101),
+			}),
+			NewMap(map[string]netaddr.IP{
+				"test3.ipn.dev": netaddr.IPv4(100, 105, 106, 107),
+				"test2.ipn.dev": netaddr.IPv4(100, 103, 102, 101),
+				"test1.ipn.dev": netaddr.IPv4(100, 101, 102, 103),
+			}),
+			"+test3.ipn.dev\t100.105.106.107\n",
+		},
+		{
+			"gone_domain",
+			NewMap(map[string]netaddr.IP{
+				"test1.ipn.dev": netaddr.IPv4(100, 101, 102, 103),
+				"test2.ipn.dev": netaddr.IPv4(100, 103, 102, 101),
+			}),
+			NewMap(map[string]netaddr.IP{
+				"test1.ipn.dev": netaddr.IPv4(100, 101, 102, 103),
+			}),
+			"-test2.ipn.dev\t100.103.102.101\n",
+		},
+		{
+			"mixed",
+			NewMap(map[string]netaddr.IP{
+				"test1.ipn.dev": netaddr.IPv4(100, 101, 102, 103),
+				"test4.ipn.dev": netaddr.IPv4(100, 107, 106, 105),
+				"test5.ipn.dev": netaddr.IPv4(100, 64, 1, 1),
+				"test2.ipn.dev": netaddr.IPv4(100, 103, 102, 101),
+			}),
+			NewMap(map[string]netaddr.IP{
+				"test2.ipn.dev": netaddr.IPv4(100, 104, 102, 101),
+				"test1.ipn.dev": netaddr.IPv4(100, 100, 101, 102),
+				"test3.ipn.dev": netaddr.IPv4(100, 64, 1, 1),
+			}),
+			"-test1.ipn.dev\t100.101.102.103\n+test1.ipn.dev\t100.100.101.102\n" +
+				"-test2.ipn.dev\t100.103.102.101\n+test2.ipn.dev\t100.104.102.101\n" +
+				"+test3.ipn.dev\t100.64.1.1\n-test4.ipn.dev\t100.107.106.105\n-test5.ipn.dev\t100.64.1.1\n",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := tt.map2.PrettyDiffFrom(tt.map1)
+			if tt.want != got {
+				t.Errorf("want %v; got %v", tt.want, got)
+			}
+		})
+	}
+}

+ 3 - 13
wgengine/tsdns/tsdns.go

@@ -45,18 +45,6 @@ var (
 	errNotQuery       = errors.New("not a DNS query")
 )
 
-// Map is all the data Resolver needs to resolve DNS queries within the Tailscale network.
-type Map struct {
-	// domainToIP is a mapping of Tailscale domains to their IP addresses.
-	// For example, monitoring.tailscale.us -> 100.64.0.1.
-	domainToIP map[string]netaddr.IP
-}
-
-// NewMap returns a new Map with domain to address mapping given by domainToIP.
-func NewMap(domainToIP map[string]netaddr.IP) *Map {
-	return &Map{domainToIP: domainToIP}
-}
-
 // Packet represents a DNS payload together with the address of its origin.
 type Packet struct {
 	// Payload is the application layer DNS payload.
@@ -142,8 +130,10 @@ func (r *Resolver) Close() {
 // SetMap sets the resolver's DNS map, taking ownership of it.
 func (r *Resolver) SetMap(m *Map) {
 	r.mu.Lock()
+	oldMap := r.dnsMap
 	r.dnsMap = m
 	r.mu.Unlock()
+	r.logf("map diff:\n%s", m.PrettyDiffFrom(oldMap))
 }
 
 // SetUpstreamNameservers sets the addresses of the resolver's
@@ -189,7 +179,7 @@ func (r *Resolver) Resolve(domain string) (netaddr.IP, dns.RCode, error) {
 		r.mu.RUnlock()
 		return netaddr.IP{}, dns.RCodeServerFailure, errMapNotSet
 	}
-	addr, found := r.dnsMap.domainToIP[domain]
+	addr, found := r.dnsMap.nameToIP[domain]
 	r.mu.RUnlock()
 
 	if !found {

+ 1 - 1
wgengine/tsdns/tsdns_test.go

@@ -23,7 +23,7 @@ var testipv6 = netaddr.IPv6Raw([16]byte{
 })
 
 var dnsMap = &Map{
-	domainToIP: map[string]netaddr.IP{
+	nameToIP: map[string]netaddr.IP{
 		"test1.ipn.dev": testipv4,
 		"test2.ipn.dev": testipv6,
 	},

+ 3 - 12
wgengine/userspace.go

@@ -560,15 +560,6 @@ func (e *userspaceEngine) pinger(peerKey wgcfg.Key, ips []wgcfg.IP) {
 	p.run(ctx, peerKey, ips, srcIP)
 }
 
-func updateSig(last *string, v interface{}) (changed bool) {
-	sig := deepprint.Hash(v)
-	if *last != sig {
-		*last = sig
-		return true
-	}
-	return false
-}
-
 // isTrimmablePeer reports whether p is a peer that we can trim out of the
 // network map.
 //
@@ -693,7 +684,7 @@ func (e *userspaceEngine) maybeReconfigWireguardLocked() error {
 		}
 	}
 
-	if !updateSig(&e.lastEngineSigTrim, min) {
+	if !deepprint.UpdateHash(&e.lastEngineSigTrim, min) {
 		// No changes
 		return nil
 	}
@@ -795,8 +786,8 @@ func (e *userspaceEngine) Reconfig(cfg *wgcfg.Config, routerCfg *router.Config)
 		routerCfg.Domains = append([]string{magicDNSDomain}, routerCfg.Domains...)
 	}
 
-	engineChanged := updateSig(&e.lastEngineSigFull, cfg)
-	routerChanged := updateSig(&e.lastRouterSig, routerCfg)
+	engineChanged := deepprint.UpdateHash(&e.lastEngineSigFull, cfg)
+	routerChanged := deepprint.UpdateHash(&e.lastRouterSig, routerCfg)
 	if !engineChanged && !routerChanged {
 		return ErrNoChanges
 	}