ipn/ipnlocal: keep internal map updated of latest Nodes post mutations

We have some flaky integration tests elsewhere that have no one place
to ask about the state of the world. This makes LocalBackend be that
place (as it's basically there anyway) but doesn't yet add the ForTest
accessor method.

This adds a LocalBackend.peers map[NodeID]NodeView that is
incrementally updated as mutations arrive. And then we start moving
away from using NetMap.Peers at runtime (UpdateStatus no longer uses
it now). And remove another copy of NodeView in the LocalBackend
nodeByAddr map. Change that to point into b.peers instead.

Future changes will then start streaming whole-node-granularity peer
change updates to WatchIPNBus clients, tracking statefully per client
what each has seen. This will get the GUI clients from receiving less
of a JSON storm of updates all the time.

Updates #1909

Change-Id: I14a976ca9f493bdf02ba7e6e05217363dcf422e5
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick 2023-09-17 02:13:52 -05:00 committed by Brad Fitzpatrick
parent 926c990a09
commit 9538e9f970
5 changed files with 232 additions and 103 deletions

View File

@ -50,6 +50,7 @@ func TestDNSConfigForNetmap(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
nm *netmap.NetworkMap nm *netmap.NetworkMap
peers []tailcfg.NodeView
os string // version.OS value; empty means linux os string // version.OS value; empty means linux
cloud cloudenv.Cloud cloud cloudenv.Cloud
prefs *ipn.Prefs prefs *ipn.Prefs
@ -70,21 +71,24 @@ func TestDNSConfigForNetmap(t *testing.T) {
nm: &netmap.NetworkMap{ nm: &netmap.NetworkMap{
Name: "myname.net", Name: "myname.net",
Addresses: ipps("100.101.101.101"), Addresses: ipps("100.101.101.101"),
Peers: nodeViews([]*tailcfg.Node{
{
Name: "peera.net",
Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::1001", "fe75::1002"),
},
{
Name: "b.net",
Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::2"),
},
{
Name: "v6-only.net",
Addresses: ipps("fe75::3"), // no IPv4, so we don't ignore IPv6
},
}),
}, },
peers: nodeViews([]*tailcfg.Node{
{
ID: 1,
Name: "peera.net",
Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::1001", "fe75::1002"),
},
{
ID: 2,
Name: "b.net",
Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::2"),
},
{
ID: 3,
Name: "v6-only.net",
Addresses: ipps("fe75::3"), // no IPv4, so we don't ignore IPv6
},
}),
prefs: &ipn.Prefs{}, prefs: &ipn.Prefs{},
want: &dns.Config{ want: &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
@ -104,21 +108,24 @@ func TestDNSConfigForNetmap(t *testing.T) {
nm: &netmap.NetworkMap{ nm: &netmap.NetworkMap{
Name: "myname.net", Name: "myname.net",
Addresses: ipps("fe75::1"), Addresses: ipps("fe75::1"),
Peers: nodeViews([]*tailcfg.Node{
{
Name: "peera.net",
Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::1001"),
},
{
Name: "b.net",
Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::2"),
},
{
Name: "v6-only.net",
Addresses: ipps("fe75::3"), // no IPv4, so we don't ignore IPv6
},
}),
}, },
peers: nodeViews([]*tailcfg.Node{
{
ID: 1,
Name: "peera.net",
Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::1001"),
},
{
ID: 2,
Name: "b.net",
Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::2"),
},
{
ID: 3,
Name: "v6-only.net",
Addresses: ipps("fe75::3"), // no IPv4, so we don't ignore IPv6
},
}),
prefs: &ipn.Prefs{}, prefs: &ipn.Prefs{},
want: &dns.Config{ want: &dns.Config{
OnlyIPv6: true, OnlyIPv6: true,
@ -319,7 +326,7 @@ func TestDNSConfigForNetmap(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
verOS := cmpx.Or(tt.os, "linux") verOS := cmpx.Or(tt.os, "linux")
var log tstest.MemLogger var log tstest.MemLogger
got := dnsConfigForNetmap(tt.nm, tt.prefs.View(), log.Logf, verOS) got := dnsConfigForNetmap(tt.nm, peersMap(tt.peers), tt.prefs.View(), log.Logf, verOS)
if !reflect.DeepEqual(got, tt.want) { if !reflect.DeepEqual(got, tt.want) {
gotj, _ := json.MarshalIndent(got, "", "\t") gotj, _ := json.MarshalIndent(got, "", "\t")
wantj, _ := json.MarshalIndent(tt.want, "", "\t") wantj, _ := json.MarshalIndent(tt.want, "", "\t")
@ -332,6 +339,17 @@ func TestDNSConfigForNetmap(t *testing.T) {
} }
} }
func peersMap(s []tailcfg.NodeView) map[tailcfg.NodeID]tailcfg.NodeView {
m := make(map[tailcfg.NodeID]tailcfg.NodeView)
for _, n := range s {
if n.ID() == 0 {
panic("zero Node.ID")
}
m[n.ID()] = n
}
return m
}
func TestAllowExitNodeDNSProxyToServeName(t *testing.T) { func TestAllowExitNodeDNSProxyToServeName(t *testing.T) {
b := &LocalBackend{} b := &LocalBackend{}
if b.allowExitNodeDNSProxyToServeName("google.com") { if b.allowExitNodeDNSProxyToServeName("google.com") {

View File

@ -203,11 +203,21 @@ type LocalBackend struct {
capFileSharing bool // whether netMap contains the file sharing capability capFileSharing bool // whether netMap contains the file sharing capability
capTailnetLock bool // whether netMap contains the tailnet lock capability capTailnetLock bool // whether netMap contains the tailnet lock capability
// hostinfo is mutated in-place while mu is held. // hostinfo is mutated in-place while mu is held.
hostinfo *tailcfg.Hostinfo hostinfo *tailcfg.Hostinfo
netMap *netmap.NetworkMap // not mutated in place once set (except for Peers slice) // netMap is the most recently set full netmap from the controlclient.
// It can't be mutated in place once set. Because it can't be mutated in place,
// delta updates from the control server don't apply to it. Instead, use
// the peers map to get up-to-date information on the state of peers.
// In general, avoid using the netMap.Peers slice. We'd like it to go away
// as of 2023-09-17.
netMap *netmap.NetworkMap
// peers is the set of current peers and their current values after applying
// delta node mutations as they come in (with mu held). The map values can
// be given out to callers, but the map itself must not escape the LocalBackend.
peers map[tailcfg.NodeID]tailcfg.NodeView
nodeByAddr map[netip.Addr]tailcfg.NodeID
nmExpiryTimer tstime.TimerController // for updating netMap on node expiry; can be nil nmExpiryTimer tstime.TimerController // for updating netMap on node expiry; can be nil
nodeByAddr map[netip.Addr]tailcfg.NodeView activeLogin string // last logged LoginName from netMap
activeLogin string // last logged LoginName from netMap
engineStatus ipn.EngineStatus engineStatus ipn.EngineStatus
endpoints []tailcfg.Endpoint endpoints []tailcfg.Endpoint
blocked bool blocked bool
@ -763,7 +773,7 @@ func (b *LocalBackend) populatePeerStatusLocked(sb *ipnstate.StatusBuilder) {
sb.AddUser(id, up) sb.AddUser(id, up)
} }
exitNodeID := b.pm.CurrentPrefs().ExitNodeID() exitNodeID := b.pm.CurrentPrefs().ExitNodeID()
for _, p := range b.netMap.Peers { for _, p := range b.peers {
var lastSeen time.Time var lastSeen time.Time
if p.LastSeen() != nil { if p.LastSeen() != nil {
lastSeen = *p.LastSeen() lastSeen = *p.LastSeen()
@ -836,7 +846,7 @@ func (b *LocalBackend) WhoIs(ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.
var zero tailcfg.NodeView var zero tailcfg.NodeView
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
n, ok = b.nodeByAddr[ipp.Addr()] nid, ok := b.nodeByAddr[ipp.Addr()]
if !ok { if !ok {
var ip netip.Addr var ip netip.Addr
if ipp.Port() != 0 { if ipp.Port() != 0 {
@ -845,11 +855,15 @@ func (b *LocalBackend) WhoIs(ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.
if !ok { if !ok {
return zero, u, false return zero, u, false
} }
n, ok = b.nodeByAddr[ip] nid, ok = b.nodeByAddr[ip]
if !ok { if !ok {
return zero, u, false return zero, u, false
} }
} }
n, ok = b.peers[nid]
if !ok {
return zero, u, false
}
u, ok = b.netMap.UserProfiles[n.User()] u, ok = b.netMap.UserProfiles[n.User()]
if !ok { if !ok {
return zero, u, false return zero, u, false
@ -1118,40 +1132,79 @@ func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
return false return false
} }
var notify *ipn.Notify // non-nil if we need to send a Notify
defer func() {
if notify != nil {
b.send(*notify)
}
}()
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
return b.updateNetmapDeltaLocked(muts) if !b.updateNetmapDeltaLocked(muts) {
return false
}
if b.netMap != nil && mutationsAreWorthyOfTellingIPNBus(muts) {
nm := ptr.To(*b.netMap) // shallow clone
nm.Peers = make([]tailcfg.NodeView, 0, len(b.peers))
for _, p := range b.peers {
nm.Peers = append(nm.Peers, p)
}
slices.SortFunc(nm.Peers, func(a, b tailcfg.NodeView) int {
return cmpx.Compare(a.ID(), b.ID())
})
notify = &ipn.Notify{NetMap: nm}
} else if testenv.InTest() {
// In tests, send an empty Notify as a wake-up so end-to-end
// integration tests in another repo can check on the status of
// LocalBackend after processing deltas.
notify = new(ipn.Notify)
}
return true
}
// mutationsAreWorthyOfTellingIPNBus reports whether any mutation type in muts is
// worthy of spamming the IPN bus (the Windows & Mac GUIs, basically) to tell them
// about the update.
func mutationsAreWorthyOfTellingIPNBus(muts []netmap.NodeMutation) bool {
for _, m := range muts {
switch m.(type) {
case netmap.NodeMutationLastSeen,
netmap.NodeMutationOnline:
// The GUI clients might render peers differently depending on whether
// they're online.
return true
}
}
return false
} }
func (b *LocalBackend) updateNetmapDeltaLocked(muts []netmap.NodeMutation) (handled bool) { func (b *LocalBackend) updateNetmapDeltaLocked(muts []netmap.NodeMutation) (handled bool) {
if b.netMap == nil { if b.netMap == nil || len(b.peers) == 0 {
return false return false
} }
peers := b.netMap.Peers
// Locally cloned mutable nodes, to avoid calling AsStruct (clone)
// multiple times on a node if it's mutated multiple times in this
// call (e.g. its endpoints + online status both change)
var mutableNodes map[tailcfg.NodeID]*tailcfg.Node
for _, m := range muts { for _, m := range muts {
// LocalBackend only cares about some types of mutations. n, ok := mutableNodes[m.NodeIDBeingMutated()]
// (magicsock cares about different ones.) if !ok {
switch m.(type) { nv, ok := b.peers[m.NodeIDBeingMutated()]
case netmap.NodeMutationOnline, netmap.NodeMutationLastSeen: if !ok {
default: // TODO(bradfitz): unexpected metric?
continue return false
}
n = nv.AsStruct()
mak.Set(&mutableNodes, nv.ID(), n)
} }
m.Apply(n)
nodeID := m.NodeIDBeingMutated() }
idx := b.netMap.PeerIndexByNodeID(nodeID) for nid, n := range mutableNodes {
if idx == -1 { b.peers[nid] = n.View()
continue
}
mut := peers[idx].AsStruct()
switch m := m.(type) {
case netmap.NodeMutationOnline:
mut.Online = ptr.To(m.Online)
case netmap.NodeMutationLastSeen:
mut.LastSeen = ptr.To(m.LastSeen)
}
peers[idx] = mut.View()
} }
return true return true
} }
@ -1586,7 +1639,7 @@ func (b *LocalBackend) updateFilterLocked(netMap *netmap.NetworkMap, prefs ipn.P
} }
packetFilter = netMap.PacketFilter packetFilter = netMap.PacketFilter
if packetFilterPermitsUnlockedNodes(netMap.Peers, packetFilter) { if packetFilterPermitsUnlockedNodes(b.peers, packetFilter) {
err := errors.New("server sent invalid packet filter permitting traffic to unlocked nodes; rejecting all packets for safety") err := errors.New("server sent invalid packet filter permitting traffic to unlocked nodes; rejecting all packets for safety")
warnInvalidUnsignedNodes.Set(err) warnInvalidUnsignedNodes.Set(err)
packetFilter = nil packetFilter = nil
@ -1671,7 +1724,7 @@ func (b *LocalBackend) updateFilterLocked(netMap *netmap.NetworkMap, prefs ipn.P
// //
// If this reports true, the packet filter is invalid (the server is either broken // If this reports true, the packet filter is invalid (the server is either broken
// or malicious) and should be ignored for safety. // or malicious) and should be ignored for safety.
func packetFilterPermitsUnlockedNodes(peers []tailcfg.NodeView, packetFilter []filter.Match) bool { func packetFilterPermitsUnlockedNodes(peers map[tailcfg.NodeID]tailcfg.NodeView, packetFilter []filter.Match) bool {
var b netipx.IPSetBuilder var b netipx.IPSetBuilder
var numUnlocked int var numUnlocked int
for _, p := range peers { for _, p := range peers {
@ -3030,6 +3083,8 @@ func (b *LocalBackend) authReconfig() {
nm := b.netMap nm := b.netMap
hasPAC := b.prevIfState.HasPAC() hasPAC := b.prevIfState.HasPAC()
disableSubnetsIfPAC := hasCapability(nm, tailcfg.NodeAttrDisableSubnetsIfPAC) disableSubnetsIfPAC := hasCapability(nm, tailcfg.NodeAttrDisableSubnetsIfPAC)
dohURL, dohURLOK := exitNodeCanProxyDNS(nm, b.peers, prefs.ExitNodeID())
dcfg := dnsConfigForNetmap(nm, b.peers, prefs, b.logf, version.OS())
b.mu.Unlock() b.mu.Unlock()
if blocked { if blocked {
@ -3062,7 +3117,7 @@ func (b *LocalBackend) authReconfig() {
// Keep the dialer updated about whether we're supposed to use // Keep the dialer updated about whether we're supposed to use
// an exit node's DNS server (so SOCKS5/HTTP outgoing dials // an exit node's DNS server (so SOCKS5/HTTP outgoing dials
// can use it for name resolution) // can use it for name resolution)
if dohURL, ok := exitNodeCanProxyDNS(nm, prefs.ExitNodeID()); ok { if dohURLOK {
b.dialer.SetExitDNSDoH(dohURL) b.dialer.SetExitDNSDoH(dohURL)
} else { } else {
b.dialer.SetExitDNSDoH("") b.dialer.SetExitDNSDoH("")
@ -3076,7 +3131,6 @@ func (b *LocalBackend) authReconfig() {
oneCGNATRoute := shouldUseOneCGNATRoute(b.logf, b.sys.ControlKnobs(), version.OS()) oneCGNATRoute := shouldUseOneCGNATRoute(b.logf, b.sys.ControlKnobs(), version.OS())
rcfg := b.routerConfig(cfg, prefs, oneCGNATRoute) rcfg := b.routerConfig(cfg, prefs, oneCGNATRoute)
dcfg := dnsConfigForNetmap(nm, prefs, b.logf, version.OS())
err = b.e.Reconfig(cfg, rcfg, dcfg) err = b.e.Reconfig(cfg, rcfg, dcfg)
if err == wgengine.ErrNoChanges { if err == wgengine.ErrNoChanges {
@ -3125,7 +3179,10 @@ func shouldUseOneCGNATRoute(logf logger.Logf, controlKnobs *controlknobs.Knobs,
// //
// The versionOS is a Tailscale-style version ("iOS", "macOS") and not // The versionOS is a Tailscale-style version ("iOS", "macOS") and not
// a runtime.GOOS. // a runtime.GOOS.
func dnsConfigForNetmap(nm *netmap.NetworkMap, prefs ipn.PrefsView, logf logger.Logf, versionOS string) *dns.Config { func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.NodeView, prefs ipn.PrefsView, logf logger.Logf, versionOS string) *dns.Config {
if nm == nil {
return nil
}
dcfg := &dns.Config{ dcfg := &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: map[dnsname.FQDN][]netip.Addr{}, Hosts: map[dnsname.FQDN][]netip.Addr{},
@ -3181,7 +3238,7 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, prefs ipn.PrefsView, logf logger.
dcfg.Hosts[fqdn] = ips dcfg.Hosts[fqdn] = ips
} }
set(nm.Name, views.SliceOf(nm.Addresses)) set(nm.Name, views.SliceOf(nm.Addresses))
for _, peer := range nm.Peers { for _, peer := range peers {
set(peer.Name(), peer.Addresses()) set(peer.Name(), peer.Addresses())
} }
for _, rec := range nm.DNS.ExtraRecords { for _, rec := range nm.DNS.ExtraRecords {
@ -3229,14 +3286,14 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, prefs ipn.PrefsView, logf logger.
// If we're using an exit node and that exit node is new enough (1.19.x+) // 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. // to run a DoH DNS proxy, then send all our DNS traffic through it.
if dohURL, ok := exitNodeCanProxyDNS(nm, prefs.ExitNodeID()); ok { if dohURL, ok := exitNodeCanProxyDNS(nm, peers, prefs.ExitNodeID()); ok {
addDefault([]*dnstype.Resolver{{Addr: dohURL}}) addDefault([]*dnstype.Resolver{{Addr: dohURL}})
return dcfg return dcfg
} }
// If we're using an exit node and that exit node is IsWireGuardOnly with // If we're using an exit node and that exit node is IsWireGuardOnly with
// ExitNodeDNSResolver set, then add that as the default. // ExitNodeDNSResolver set, then add that as the default.
if resolvers, ok := wireguardExitNodeDNSResolvers(nm, prefs.ExitNodeID()); ok { if resolvers, ok := wireguardExitNodeDNSResolvers(nm, peers, prefs.ExitNodeID()); ok {
addDefault(resolvers) addDefault(resolvers)
return dcfg return dcfg
} }
@ -4034,6 +4091,7 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
login = cmpx.Or(nm.UserProfiles[nm.User()].LoginName, "<missing-profile>") login = cmpx.Or(nm.UserProfiles[nm.User()].LoginName, "<missing-profile>")
} }
b.netMap = nm b.netMap = nm
b.updatePeersFromNetmapLocked(nm)
if login != b.activeLogin { if login != b.activeLogin {
b.logf("active login: %v", login) b.logf("active login: %v", login)
b.activeLogin = login b.activeLogin = login
@ -4068,16 +4126,16 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
// Update the nodeByAddr index. // Update the nodeByAddr index.
if b.nodeByAddr == nil { if b.nodeByAddr == nil {
b.nodeByAddr = map[netip.Addr]tailcfg.NodeView{} b.nodeByAddr = map[netip.Addr]tailcfg.NodeID{}
} }
// First pass, mark everything unwanted. // First pass, mark everything unwanted.
for k := range b.nodeByAddr { for k := range b.nodeByAddr {
b.nodeByAddr[k] = tailcfg.NodeView{} b.nodeByAddr[k] = 0
} }
addNode := func(n tailcfg.NodeView) { addNode := func(n tailcfg.NodeView) {
for i := range n.Addresses().LenIter() { for i := range n.Addresses().LenIter() {
if ipp := n.Addresses().At(i); ipp.IsSingleIP() { if ipp := n.Addresses().At(i); ipp.IsSingleIP() {
b.nodeByAddr[ipp.Addr()] = n b.nodeByAddr[ipp.Addr()] = n.ID()
} }
} }
} }
@ -4089,12 +4147,33 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
} }
// Third pass, actually delete the unwanted items. // Third pass, actually delete the unwanted items.
for k, v := range b.nodeByAddr { for k, v := range b.nodeByAddr {
if !v.Valid() { if v == 0 {
delete(b.nodeByAddr, k) delete(b.nodeByAddr, k)
} }
} }
} }
func (b *LocalBackend) updatePeersFromNetmapLocked(nm *netmap.NetworkMap) {
if nm == nil {
b.peers = nil
return
}
// First pass, mark everything unwanted.
for k := range b.peers {
b.peers[k] = tailcfg.NodeView{}
}
// Second pass, add everything wanted.
for _, p := range nm.Peers {
mak.Set(&b.peers, p.ID(), p)
}
// Third pass, remove deleted things.
for k, v := range b.peers {
if !v.Valid() {
delete(b.peers, k)
}
}
}
// setDebugLogsByCapabilityLocked sets debug logging based on the self node's // setDebugLogsByCapabilityLocked sets debug logging based on the self node's
// capabilities in the provided NetMap. // capabilities in the provided NetMap.
func (b *LocalBackend) setDebugLogsByCapabilityLocked(nm *netmap.NetworkMap) { func (b *LocalBackend) setDebugLogsByCapabilityLocked(nm *netmap.NetworkMap) {
@ -4368,7 +4447,7 @@ func (b *LocalBackend) FileTargets() ([]*apitype.FileTarget, error) {
if !b.capFileSharing { if !b.capFileSharing {
return nil, errors.New("file sharing not enabled by Tailscale admin") return nil, errors.New("file sharing not enabled by Tailscale admin")
} }
for _, p := range nm.Peers { for _, p := range b.peers {
if !b.peerIsTaildropTargetLocked(p) { if !b.peerIsTaildropTargetLocked(p) {
continue continue
} }
@ -4381,7 +4460,9 @@ func (b *LocalBackend) FileTargets() ([]*apitype.FileTarget, error) {
PeerAPIURL: peerAPI, PeerAPIURL: peerAPI,
}) })
} }
// TODO: sort a different way than the netmap already is? slices.SortFunc(ret, func(a, b *apitype.FileTarget) int {
return cmpx.Compare(a.Node.Name, b.Node.Name)
})
return ret, nil return ret, nil
} }
@ -4620,11 +4701,11 @@ func (b *LocalBackend) SetExpirySooner(ctx context.Context, expiry time.Time) er
// to exitNodeID's DoH service, if available. // to exitNodeID's DoH service, if available.
// //
// If exitNodeID is the zero valid, it returns "", false. // If exitNodeID is the zero valid, it returns "", false.
func exitNodeCanProxyDNS(nm *netmap.NetworkMap, exitNodeID tailcfg.StableNodeID) (dohURL string, ok bool) { func exitNodeCanProxyDNS(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.NodeView, exitNodeID tailcfg.StableNodeID) (dohURL string, ok bool) {
if exitNodeID.IsZero() { if exitNodeID.IsZero() {
return "", false return "", false
} }
for _, p := range nm.Peers { for _, p := range peers {
if p.StableID() == exitNodeID && peerCanProxyDNS(p) { if p.StableID() == exitNodeID && peerCanProxyDNS(p) {
return peerAPIBase(nm, p) + "/dns-query", true return peerAPIBase(nm, p) + "/dns-query", true
} }
@ -4634,12 +4715,12 @@ func exitNodeCanProxyDNS(nm *netmap.NetworkMap, exitNodeID tailcfg.StableNodeID)
// wireguardExitNodeDNSResolvers returns the DNS resolvers to use for a // wireguardExitNodeDNSResolvers returns the DNS resolvers to use for a
// WireGuard-only exit node, if it has resolver addresses. // WireGuard-only exit node, if it has resolver addresses.
func wireguardExitNodeDNSResolvers(nm *netmap.NetworkMap, exitNodeID tailcfg.StableNodeID) ([]*dnstype.Resolver, bool) { func wireguardExitNodeDNSResolvers(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.NodeView, exitNodeID tailcfg.StableNodeID) ([]*dnstype.Resolver, bool) {
if exitNodeID.IsZero() { if exitNodeID.IsZero() {
return nil, false return nil, false
} }
for _, p := range nm.Peers { for _, p := range peers {
if p.StableID() == exitNodeID && p.IsWireGuardOnly() { if p.StableID() == exitNodeID && p.IsWireGuardOnly() {
resolvers := p.ExitNodeDNSResolvers() resolvers := p.ExitNodeDNSResolvers()
if !resolvers.IsNil() && resolvers.Len() > 0 { if !resolvers.IsNil() && resolvers.Len() > 0 {

View File

@ -654,7 +654,7 @@ func TestPacketFilterPermitsUnlockedNodes(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if got := packetFilterPermitsUnlockedNodes(nodeViews(tt.peers), tt.filter); got != tt.want { if got := packetFilterPermitsUnlockedNodes(peersMap(nodeViews(tt.peers)), tt.filter); got != tt.want {
t.Errorf("got %v, want %v", got, tt.want) t.Errorf("got %v, want %v", got, tt.want)
} }
}) })
@ -786,6 +786,7 @@ func TestUpdateNetmapDelta(t *testing.T) {
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
b.netMap.Peers = append(b.netMap.Peers, (&tailcfg.Node{ID: (tailcfg.NodeID(i) + 1)}).View()) b.netMap.Peers = append(b.netMap.Peers, (&tailcfg.Node{ID: (tailcfg.NodeID(i) + 1)}).View())
} }
b.updatePeersFromNetmapLocked(b.netMap)
someTime := time.Unix(123, 0) someTime := time.Unix(123, 0)
muts, ok := netmap.MutationsFromMapResponse(&tailcfg.MapResponse{ muts, ok := netmap.MutationsFromMapResponse(&tailcfg.MapResponse{
@ -819,7 +820,7 @@ func TestUpdateNetmapDelta(t *testing.T) {
wants := []*tailcfg.Node{ wants := []*tailcfg.Node{
{ {
ID: 1, ID: 1,
DERP: "", // unmodified by the delta DERP: "127.3.3.40:1",
}, },
{ {
ID: 2, ID: 2,
@ -835,12 +836,12 @@ func TestUpdateNetmapDelta(t *testing.T) {
}, },
} }
for _, want := range wants { for _, want := range wants {
idx := b.netMap.PeerIndexByNodeID(want.ID) gotv, ok := b.peers[want.ID]
if idx == -1 { if !ok {
t.Errorf("ID %v not found in netmap", want.ID) t.Errorf("netmap.Peer %v missing from b.peers", want.ID)
continue continue
} }
got := b.netMap.Peers[idx].AsStruct() got := gotv.AsStruct()
if !reflect.DeepEqual(got, want) { if !reflect.DeepEqual(got, want) {
t.Errorf("netmap.Peer %v wrong.\n got: %v\nwant: %v", want.ID, logger.AsJSON(got), logger.AsJSON(want)) t.Errorf("netmap.Peer %v wrong.\n got: %v\nwant: %v", want.ID, logger.AsJSON(got), logger.AsJSON(want))
} }
@ -868,6 +869,7 @@ func TestWireguardExitNodeDNSResolvers(t *testing.T) {
id: "1", id: "1",
peers: []*tailcfg.Node{ peers: []*tailcfg.Node{
{ {
ID: 1,
StableID: "1", StableID: "1",
IsWireGuardOnly: false, IsWireGuardOnly: false,
ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.example.com"}}, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.example.com"}},
@ -881,6 +883,7 @@ func TestWireguardExitNodeDNSResolvers(t *testing.T) {
id: "2", id: "2",
peers: []*tailcfg.Node{ peers: []*tailcfg.Node{
{ {
ID: 1,
StableID: "1", StableID: "1",
IsWireGuardOnly: true, IsWireGuardOnly: true,
ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.example.com"}}, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.example.com"}},
@ -894,6 +897,7 @@ func TestWireguardExitNodeDNSResolvers(t *testing.T) {
id: "1", id: "1",
peers: []*tailcfg.Node{ peers: []*tailcfg.Node{
{ {
ID: 1,
StableID: "1", StableID: "1",
IsWireGuardOnly: true, IsWireGuardOnly: true,
ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.example.com"}}, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.example.com"}},
@ -905,11 +909,9 @@ func TestWireguardExitNodeDNSResolvers(t *testing.T) {
} }
for _, tc := range tests { for _, tc := range tests {
peers := nodeViews(tc.peers) peers := peersMap(nodeViews(tc.peers))
nm := &netmap.NetworkMap{ nm := &netmap.NetworkMap{}
Peers: peers, gotResolvers, gotOK := wireguardExitNodeDNSResolvers(nm, peers, tc.id)
}
gotResolvers, gotOK := wireguardExitNodeDNSResolvers(nm, tc.id)
if gotOK != tc.wantOK || !resolversEqual(gotResolvers, tc.wantResolvers) { if gotOK != tc.wantOK || !resolversEqual(gotResolvers, tc.wantResolvers) {
t.Errorf("case: %s: got %v, %v, want %v, %v", tc.name, gotOK, gotResolvers, tc.wantOK, tc.wantResolvers) t.Errorf("case: %s: got %v, %v, want %v, %v", tc.name, gotOK, gotResolvers, tc.wantOK, tc.wantResolvers)
@ -919,23 +921,22 @@ func TestWireguardExitNodeDNSResolvers(t *testing.T) {
func TestDNSConfigForNetmapForWireguardExitNode(t *testing.T) { func TestDNSConfigForNetmapForWireguardExitNode(t *testing.T) {
resolvers := []*dnstype.Resolver{{Addr: "dns.example.com"}} resolvers := []*dnstype.Resolver{{Addr: "dns.example.com"}}
nm := &netmap.NetworkMap{ nm := &netmap.NetworkMap{}
Peers: nodeViews([]*tailcfg.Node{ peers := map[tailcfg.NodeID]tailcfg.NodeView{
{ 1: (&tailcfg.Node{
StableID: "1", ID: 1,
IsWireGuardOnly: true, StableID: "1",
ExitNodeDNSResolvers: resolvers, IsWireGuardOnly: true,
Hostinfo: (&tailcfg.Hostinfo{}).View(), ExitNodeDNSResolvers: resolvers,
}, Hostinfo: (&tailcfg.Hostinfo{}).View(),
}), }).View(),
} }
prefs := &ipn.Prefs{ prefs := &ipn.Prefs{
ExitNodeID: "1", ExitNodeID: "1",
CorpDNS: true, CorpDNS: true,
} }
got := dnsConfigForNetmap(nm, prefs.View(), t.Logf, "") got := dnsConfigForNetmap(nm, peers, prefs.View(), t.Logf, "")
if !resolversEqual(got.DefaultResolvers, resolvers) { if !resolversEqual(got.DefaultResolvers, resolvers) {
t.Errorf("got %v, want %v", got.DefaultResolvers, resolvers) t.Errorf("got %v, want %v", got.DefaultResolvers, resolvers)
} }

View File

@ -378,17 +378,23 @@ func newTestBackend(t *testing.T) *LocalBackend {
}, },
}, },
} }
b.nodeByAddr = map[netip.Addr]tailcfg.NodeView{ b.peers = map[tailcfg.NodeID]tailcfg.NodeView{
netip.MustParseAddr("100.150.151.152"): (&tailcfg.Node{ 152: (&tailcfg.Node{
ID: 152,
ComputedName: "some-peer", ComputedName: "some-peer",
User: tailcfg.UserID(1), User: tailcfg.UserID(1),
}).View(), }).View(),
netip.MustParseAddr("100.150.151.153"): (&tailcfg.Node{ 153: (&tailcfg.Node{
ID: 153,
ComputedName: "some-tagged-peer", ComputedName: "some-tagged-peer",
Tags: []string{"tag:server", "tag:test"}, Tags: []string{"tag:server", "tag:test"},
User: tailcfg.UserID(1), User: tailcfg.UserID(1),
}).View(), }).View(),
} }
b.nodeByAddr = map[netip.Addr]tailcfg.NodeID{
netip.MustParseAddr("100.150.151.152"): 152,
netip.MustParseAddr("100.150.151.153"): 153,
}
return b return b
} }

View File

@ -4,6 +4,7 @@
package netmap package netmap
import ( import (
"fmt"
"net/netip" "net/netip"
"reflect" "reflect"
"slices" "slices"
@ -11,6 +12,7 @@ import (
"time" "time"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/types/ptr"
"tailscale.com/util/cmpx" "tailscale.com/util/cmpx"
) )
@ -18,6 +20,7 @@ import (
// the change of a node's state. // the change of a node's state.
type NodeMutation interface { type NodeMutation interface {
NodeIDBeingMutated() tailcfg.NodeID NodeIDBeingMutated() tailcfg.NodeID
Apply(*tailcfg.Node)
} }
type mutatingNodeID tailcfg.NodeID type mutatingNodeID tailcfg.NodeID
@ -31,12 +34,24 @@ type NodeMutationDERPHome struct {
DERPRegion int DERPRegion int
} }
func (m NodeMutationDERPHome) Apply(n *tailcfg.Node) {
n.DERP = fmt.Sprintf("127.3.3.40:%v", m.DERPRegion)
}
// NodeMutation is a NodeMutation that says a node's endpoints have changed. // NodeMutation is a NodeMutation that says a node's endpoints have changed.
type NodeMutationEndpoints struct { type NodeMutationEndpoints struct {
mutatingNodeID mutatingNodeID
Endpoints []netip.AddrPort Endpoints []netip.AddrPort
} }
func (m NodeMutationEndpoints) Apply(n *tailcfg.Node) {
eps := make([]string, len(m.Endpoints))
for i, ep := range m.Endpoints {
eps[i] = ep.String()
}
n.Endpoints = eps
}
// NodeMutationOnline is a NodeMutation that says a node is now online or // NodeMutationOnline is a NodeMutation that says a node is now online or
// offline. // offline.
type NodeMutationOnline struct { type NodeMutationOnline struct {
@ -44,6 +59,10 @@ type NodeMutationOnline struct {
Online bool Online bool
} }
func (m NodeMutationOnline) Apply(n *tailcfg.Node) {
n.Online = ptr.To(m.Online)
}
// NodeMutationLastSeen is a NodeMutation that says a node's LastSeen // NodeMutationLastSeen is a NodeMutation that says a node's LastSeen
// value should be set to the current time. // value should be set to the current time.
type NodeMutationLastSeen struct { type NodeMutationLastSeen struct {
@ -51,6 +70,10 @@ type NodeMutationLastSeen struct {
LastSeen time.Time LastSeen time.Time
} }
func (m NodeMutationLastSeen) Apply(n *tailcfg.Node) {
n.LastSeen = ptr.To(m.LastSeen)
}
var peerChangeFields = sync.OnceValue(func() []reflect.StructField { var peerChangeFields = sync.OnceValue(func() []reflect.StructField {
var fields []reflect.StructField var fields []reflect.StructField
rt := reflect.TypeOf((*tailcfg.PeerChange)(nil)).Elem() rt := reflect.TypeOf((*tailcfg.PeerChange)(nil)).Elem()