control/controlclient, types/netmap: start plumbing delta netmap updates

Currently only the top four most popular changes: endpoints, DERP
home, online, and LastSeen.

Updates #1909

Change-Id: I03152da176b2b95232b56acabfb55dcdfaa16b79
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2023-09-01 19:28:00 -07:00
committed by Brad Fitzpatrick
parent c0ade132e6
commit 3af051ea27
14 changed files with 715 additions and 13 deletions

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"net/netip"
"sort"
"strings"
"time"
@@ -15,6 +16,7 @@ import (
"tailscale.com/tka"
"tailscale.com/types/key"
"tailscale.com/types/views"
"tailscale.com/util/cmpx"
"tailscale.com/wgengine/filter"
)
@@ -124,6 +126,23 @@ func (nm *NetworkMap) PeerByTailscaleIP(ip netip.Addr) (peer tailcfg.NodeView, o
return tailcfg.NodeView{}, false
}
// PeerIndexByNodeID returns the index of the peer with the given nodeID
// in nm.Peers, or -1 if nm is nil or not found.
//
// It assumes nm.Peers is sorted by Node.ID.
func (nm *NetworkMap) PeerIndexByNodeID(nodeID tailcfg.NodeID) int {
if nm == nil {
return -1
}
idx, ok := sort.Find(len(nm.Peers), func(i int) int {
return cmpx.Compare(nodeID, nm.Peers[i].ID())
})
if !ok {
return -1
}
return idx
}
// MagicDNSSuffix returns the domain's MagicDNS suffix (even if MagicDNS isn't
// necessarily in use) of the provided Node.Name value.
//

View File

@@ -280,3 +280,31 @@ func TestConciseDiffFrom(t *testing.T) {
})
}
}
func TestPeerIndexByNodeID(t *testing.T) {
var nilPtr *NetworkMap
if nilPtr.PeerIndexByNodeID(123) != -1 {
t.Errorf("nil PeerIndexByNodeID should return -1")
}
var nm NetworkMap
const min = 2
const max = 10000
const hole = max / 2
for nid := tailcfg.NodeID(2); nid <= max; nid++ {
if nid == hole {
continue
}
nm.Peers = append(nm.Peers, (&tailcfg.Node{ID: nid}).View())
}
for want, nv := range nm.Peers {
got := nm.PeerIndexByNodeID(nv.ID())
if got != want {
t.Errorf("PeerIndexByNodeID(%v) = %v; want %v", nv.ID(), got, want)
}
}
for _, miss := range []tailcfg.NodeID{min - 1, hole, max + 1} {
if got := nm.PeerIndexByNodeID(miss); got != -1 {
t.Errorf("PeerIndexByNodeID(%v) = %v; want -1", miss, got)
}
}
}

168
types/netmap/nodemut.go Normal file
View File

@@ -0,0 +1,168 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package netmap
import (
"net/netip"
"reflect"
"slices"
"sync"
"time"
"tailscale.com/tailcfg"
"tailscale.com/util/cmpx"
)
// NodeMutation is the common interface for types that describe
// the change of a node's state.
type NodeMutation interface {
NodeIDBeingMutated() tailcfg.NodeID
}
type mutatingNodeID tailcfg.NodeID
func (m mutatingNodeID) NodeIDBeingMutated() tailcfg.NodeID { return tailcfg.NodeID(m) }
// NodeMutationDERPHome is a NodeMutation that says a node
// has changed its DERP home region.
type NodeMutationDERPHome struct {
mutatingNodeID
DERPRegion int
}
// NodeMutation is a NodeMutation that says a node's endpoints have changed.
type NodeMutationEndpoints struct {
mutatingNodeID
Endpoints []netip.AddrPort
}
// NodeMutationOnline is a NodeMutation that says a node is now online or
// offline.
type NodeMutationOnline struct {
mutatingNodeID
Online bool
}
// NodeMutationLastSeen is a NodeMutation that says a node's LastSeen
// value should be set to the current time.
type NodeMutationLastSeen struct {
mutatingNodeID
LastSeen time.Time
}
var peerChangeFields = sync.OnceValue(func() []reflect.StructField {
var fields []reflect.StructField
rt := reflect.TypeOf((*tailcfg.PeerChange)(nil)).Elem()
for i := 0; i < rt.NumField(); i++ {
fields = append(fields, rt.Field(i))
}
return fields
})
// NodeMutationsFromPatch returns the NodeMutations that
// p describes. If p describes something not yet supported
// by a specific NodeMutation type, it returns (nil, false).
func NodeMutationsFromPatch(p *tailcfg.PeerChange) (_ []NodeMutation, ok bool) {
if p == nil || p.NodeID == 0 {
return nil, false
}
var ret []NodeMutation
rv := reflect.ValueOf(p).Elem()
for i, sf := range peerChangeFields() {
if rv.Field(i).IsZero() {
continue
}
switch sf.Name {
default:
// Unhandled field.
return nil, false
case "NodeID":
continue
case "DERPRegion":
ret = append(ret, NodeMutationDERPHome{mutatingNodeID(p.NodeID), p.DERPRegion})
case "Endpoints":
eps := make([]netip.AddrPort, len(p.Endpoints))
for i, epStr := range p.Endpoints {
var err error
eps[i], err = netip.ParseAddrPort(epStr)
if err != nil {
return nil, false
}
}
ret = append(ret, NodeMutationEndpoints{mutatingNodeID(p.NodeID), eps})
case "Online":
ret = append(ret, NodeMutationOnline{mutatingNodeID(p.NodeID), *p.Online})
case "LastSeen":
ret = append(ret, NodeMutationLastSeen{mutatingNodeID(p.NodeID), *p.LastSeen})
}
}
return ret, true
}
// MutationsFromMapResponse returns all the discrete node mutations described
// by res. It returns ok=false if res contains any non-patch field as defined
// by mapResponseContainsNonPatchFields.
func MutationsFromMapResponse(res *tailcfg.MapResponse, now time.Time) (ret []NodeMutation, ok bool) {
if now.IsZero() {
now = time.Now()
}
if mapResponseContainsNonPatchFields(res) {
return nil, false
}
// All that remains is PeersChangedPatch, OnlineChange, and LastSeenChange.
for _, p := range res.PeersChangedPatch {
deltas, ok := NodeMutationsFromPatch(p)
if !ok {
return nil, false
}
ret = append(ret, deltas...)
}
for nid, v := range res.OnlineChange {
ret = append(ret, NodeMutationOnline{mutatingNodeID(nid), v})
}
for nid, v := range res.PeerSeenChange {
if v {
ret = append(ret, NodeMutationLastSeen{mutatingNodeID(nid), now})
}
}
slices.SortStableFunc(ret, func(a, b NodeMutation) int {
return cmpx.Compare(a.NodeIDBeingMutated(), b.NodeIDBeingMutated())
})
return ret, true
}
// mapResponseContainsNonPatchFields reports whether res contains only "patch"
// fields set (PeersChangedPatch primarily, but also including the legacy
// PeerSeenChange and OnlineChange fields).
//
// It ignores any of the meta fields that are handled by PollNetMap before the
// peer change handling gets involved.
//
// The purpose of this function is to ask whether this is a tricky enough
// MapResponse to warrant a full netmap update. When this returns false, it
// means the response can be handled incrementally, patching up the local state.
func mapResponseContainsNonPatchFields(res *tailcfg.MapResponse) bool {
return res.Node != nil ||
res.DERPMap != nil ||
res.DNSConfig != nil ||
res.Domain != "" ||
res.CollectServices != "" ||
res.PacketFilter != nil ||
res.UserProfiles != nil ||
res.Health != nil ||
res.SSHPolicy != nil ||
res.TKAInfo != nil ||
res.DomainDataPlaneAuditLogID != "" ||
res.Debug != nil ||
res.ControlDialPlan != nil ||
res.ClientVersion != nil ||
res.Peers != nil ||
res.PeersRemoved != nil ||
// PeersChanged is too coarse to be considered a patch. Also, we convert
// PeersChanged to PeersChangedPatch in patchifyPeersChanged before this
// function is called, so it should never be set anyway. But for
// completedness, and for tests, check it too:
res.PeersChanged != nil
}

View File

@@ -0,0 +1,199 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package netmap
import (
"fmt"
"net/netip"
"reflect"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/ptr"
)
// tests mapResponseContainsNonPatchFields
func TestMapResponseContainsNonPatchFields(t *testing.T) {
// reflectNonzero returns a non-zero value of the given type.
reflectNonzero := func(t reflect.Type) reflect.Value {
switch t.Kind() {
case reflect.Bool:
return reflect.ValueOf(true)
case reflect.String:
return reflect.ValueOf("foo").Convert(t)
case reflect.Int64:
return reflect.ValueOf(int64(1))
case reflect.Slice:
return reflect.MakeSlice(t, 1, 1)
case reflect.Ptr:
return reflect.New(t.Elem())
case reflect.Map:
return reflect.MakeMap(t)
}
panic(fmt.Sprintf("unhandled %v", t))
}
rt := reflect.TypeOf(tailcfg.MapResponse{})
for i := 0; i < rt.NumField(); i++ {
f := rt.Field(i)
var want bool
switch f.Name {
case "MapSessionHandle", "Seq", "KeepAlive", "PingRequest", "PopBrowserURL", "ControlTime":
// There are meta fields that apply to all MapResponse values.
// They should be ignored.
want = false
case "PeersChangedPatch", "PeerSeenChange", "OnlineChange":
// The actual three delta fields we care about handling.
want = false
default:
// Everything else should be conseratively handled as a
// non-delta field. We want it to return true so if
// the field is not listed in the function being tested,
// it'll return false and we'll fail this test.
// This makes sure any new fields added to MapResponse
// are accounted for here.
want = true
}
var v tailcfg.MapResponse
rv := reflect.ValueOf(&v).Elem()
rv.FieldByName(f.Name).Set(reflectNonzero(f.Type))
got := mapResponseContainsNonPatchFields(&v)
if got != want {
t.Errorf("field %q: got %v; want %v\nJSON: %v", f.Name, got, want, logger.AsJSON(v))
}
}
}
// tests MutationsFromMapResponse
func TestMutationsFromMapResponse(t *testing.T) {
someTime := time.Unix(123, 0)
fromChanges := func(changes ...*tailcfg.PeerChange) *tailcfg.MapResponse {
return &tailcfg.MapResponse{
PeersChangedPatch: changes,
}
}
muts := func(muts ...NodeMutation) []NodeMutation { return muts }
tests := []struct {
name string
mr *tailcfg.MapResponse
want []NodeMutation // nil means !ok, zero-length means none
}{
{
name: "patch-ep",
mr: fromChanges(&tailcfg.PeerChange{
NodeID: 1,
Endpoints: []string{"1.2.3.4:567"},
}, &tailcfg.PeerChange{
NodeID: 2,
Endpoints: []string{"8.9.10.11:1234"},
}),
want: muts(
NodeMutationEndpoints{1, []netip.AddrPort{netip.MustParseAddrPort("1.2.3.4:567")}},
NodeMutationEndpoints{2, []netip.AddrPort{netip.MustParseAddrPort("8.9.10.11:1234")}},
),
},
{
name: "patch-derp",
mr: fromChanges(&tailcfg.PeerChange{
NodeID: 1,
DERPRegion: 2,
}),
want: muts(NodeMutationDERPHome{1, 2}),
},
{
name: "patch-online",
mr: fromChanges(&tailcfg.PeerChange{
NodeID: 1,
Online: ptr.To(true),
}),
want: muts(NodeMutationOnline{1, true}),
},
{
name: "patch-online-false",
mr: fromChanges(&tailcfg.PeerChange{
NodeID: 1,
Online: ptr.To(false),
}),
want: muts(NodeMutationOnline{1, false}),
},
{
name: "patch-lastseen",
mr: fromChanges(&tailcfg.PeerChange{
NodeID: 1,
LastSeen: ptr.To(time.Unix(12345, 0)),
}),
want: muts(NodeMutationLastSeen{1, time.Unix(12345, 0)}),
},
{
name: "legacy-online-change", // the old pre-Patch style
mr: &tailcfg.MapResponse{
OnlineChange: map[tailcfg.NodeID]bool{
1: true,
2: false,
},
},
want: muts(
NodeMutationOnline{1, true},
NodeMutationOnline{2, false},
),
},
{
name: "legacy-lastseen-change", // the old pre-Patch style
mr: &tailcfg.MapResponse{
PeerSeenChange: map[tailcfg.NodeID]bool{
1: true,
},
},
want: muts(
NodeMutationLastSeen{1, someTime},
),
},
{
name: "no-changes",
mr: fromChanges(),
want: make([]NodeMutation, 0), // non-nil to mean want ok but no changes
},
{
name: "not-okay-patch-node-change",
mr: &tailcfg.MapResponse{
Node: &tailcfg.Node{}, // non-nil
PeersChangedPatch: []*tailcfg.PeerChange{{
NodeID: 1,
DERPRegion: 2,
}},
},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, gotOK := MutationsFromMapResponse(tt.mr, someTime)
wantOK := tt.want != nil
if gotOK != wantOK {
t.Errorf("got ok=%v; want %v", gotOK, wantOK)
} else if got == nil && gotOK {
got = make([]NodeMutation, 0) // for cmd.Diff
}
if diff := cmp.Diff(tt.want, got,
cmp.Comparer(func(a, b netip.Addr) bool { return a == b }),
cmp.Comparer(func(a, b netip.AddrPort) bool { return a == b }),
cmp.AllowUnexported(
NodeMutationEndpoints{},
NodeMutationDERPHome{},
NodeMutationOnline{},
NodeMutationLastSeen{},
)); diff != "" {
t.Errorf("wrong result (-want +got):\n%s", diff)
}
})
}
}