mirror of https://github.com/tailscale/tailscale/
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>pull/9366/head
parent
c0ade132e6
commit
3af051ea27
@ -0,0 +1,21 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package controlknobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAsDebugJSON(t *testing.T) {
|
||||||
|
var nilPtr *Knobs
|
||||||
|
if got := nilPtr.AsDebugJSON(); got != nil {
|
||||||
|
t.Errorf("AsDebugJSON(nil) = %v; want nil", got)
|
||||||
|
}
|
||||||
|
k := new(Knobs)
|
||||||
|
got := k.AsDebugJSON()
|
||||||
|
if want := reflect.TypeOf(Knobs{}).NumField(); len(got) != want {
|
||||||
|
t.Errorf("AsDebugJSON map has %d fields; want %v", len(got), want)
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue