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>main
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