From 19a9d9037f9770adb2cc4b812aeb1f1ff02da5af Mon Sep 17 00:00:00 2001 From: Maisem Ali Date: Mon, 18 Sep 2023 08:52:22 -0700 Subject: [PATCH] tailcfg: add NodeCapMap Like PeerCapMap, add a field to `tailcfg.Node` which provides a map of Capability to raw JSON messages which are deferred to be parsed later by the application code which cares about the specific capabilities. This effectively allows us to prototype new behavior without having to commit to a schema in tailcfg, and it also opens up the possibilities to develop custom behavior in tsnet applications w/o having to plumb through application specific data in the MapResponse. Updates #4217 Signed-off-by: Maisem Ali --- cmd/derper/depaware.txt | 1 + cmd/tailscale/depaware.txt | 2 +- cmd/viewer/viewer.go | 5 +- control/controlclient/map.go | 12 ++++- control/controlknobs/controlknobs.go | 44 +++++++---------- ipn/ipnlocal/local.go | 7 +++ ipn/ipnstate/ipnstate.go | 5 +- tailcfg/tailcfg.go | 71 ++++++++++++++++++++++++++-- tailcfg/tailcfg_clone.go | 7 +++ tailcfg/tailcfg_test.go | 41 +++++++++++++++- tailcfg/tailcfg_view.go | 17 +++++-- types/netmap/netmap.go | 7 ++- 12 files changed, 176 insertions(+), 43 deletions(-) diff --git a/cmd/derper/depaware.txt b/cmd/derper/depaware.txt index 238e0cf76..a147524e1 100644 --- a/cmd/derper/depaware.txt +++ b/cmd/derper/depaware.txt @@ -169,6 +169,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa golang.org/x/crypto/nacl/box from tailscale.com/types/key golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ + golang.org/x/exp/maps from tailscale.com/tailcfg L golang.org/x/net/bpf from github.com/mdlayher/netlink+ golang.org/x/net/dns/dnsmessage from net+ golang.org/x/net/http/httpguts from net/http diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 16e3121ce..f0e3810ea 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -175,7 +175,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep golang.org/x/crypto/pbkdf2 from software.sslmate.com/src/go-pkcs12 golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ W golang.org/x/exp/constraints from github.com/dblohm7/wingoes/pe - golang.org/x/exp/maps from tailscale.com/cmd/tailscale/cli + golang.org/x/exp/maps from tailscale.com/cmd/tailscale/cli+ golang.org/x/net/bpf from github.com/mdlayher/netlink+ golang.org/x/net/dns/dnsmessage from net+ golang.org/x/net/http/httpguts from net/http+ diff --git a/cmd/viewer/viewer.go b/cmd/viewer/viewer.go index b9c01a9b8..56b5e2bd2 100644 --- a/cmd/viewer/viewer.go +++ b/cmd/viewer/viewer.go @@ -237,9 +237,10 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi slice := u sElem := slice.Elem() switch x := sElem.(type) { - case *types.Basic: + case *types.Basic, *types.Named: + sElem := it.QualifiedName(sElem) args.MapValueView = fmt.Sprintf("views.Slice[%v]", sElem) - args.MapValueType = "[]" + sElem.String() + args.MapValueType = "[]" + sElem args.MapFn = "views.SliceOf(t)" template = "mapFnField" case *types.Pointer: diff --git a/control/controlclient/map.go b/control/controlclient/map.go index 7a71dd29f..df3fe6429 100644 --- a/control/controlclient/map.go +++ b/control/controlclient/map.go @@ -187,8 +187,9 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t if resp.Node != nil { if DevKnob.StripCaps() { resp.Node.Capabilities = nil + resp.Node.CapMap = nil } - ms.controlKnobs.UpdateFromNodeAttributes(resp.Node.Capabilities) + ms.controlKnobs.UpdateFromNodeAttributes(resp.Node.Capabilities, resp.Node.CapMap) } // Call Node.InitDisplayNames on any changed nodes. @@ -324,6 +325,7 @@ var ( patchLastSeen = clientmetric.NewCounter("controlclient_patch_lastseen") patchKeyExpiry = clientmetric.NewCounter("controlclient_patch_keyexpiry") patchCapabilities = clientmetric.NewCounter("controlclient_patch_capabilities") + patchCapMap = clientmetric.NewCounter("controlclient_patch_capmap") patchKeySignature = clientmetric.NewCounter("controlclient_patch_keysig") patchifiedPeer = clientmetric.NewCounter("controlclient_patchified_peer") @@ -452,6 +454,10 @@ func (ms *mapSession) updatePeersStateFromResponse(resp *tailcfg.MapResponse) (s mut.KeySignature = v patchKeySignature.Add(1) } + if v := pc.CapMap; v != nil { + mut.CapMap = v + patchCapMap.Add(1) + } *vp = mut.View() } @@ -647,6 +653,10 @@ func peerChangeDiff(was tailcfg.NodeView, n *tailcfg.Node) (_ *tailcfg.PeerChang if was.Cap() != n.Cap { pc().Cap = n.Cap } + case "CapMap": + if n.CapMap != nil { + pc().CapMap = n.CapMap + } case "Tags": if !views.SliceEqual(was.Tags(), views.SliceOf(n.Tags)) { return nil, false diff --git a/control/controlknobs/controlknobs.go b/control/controlknobs/controlknobs.go index 50b0298a9..4d57b30a3 100644 --- a/control/controlknobs/controlknobs.go +++ b/control/controlknobs/controlknobs.go @@ -6,6 +6,7 @@ package controlknobs import ( + "slices" "sync/atomic" "tailscale.com/syncs" @@ -48,39 +49,30 @@ type Knobs struct { // UpdateFromNodeAttributes updates k (if non-nil) based on the provided self // node attributes (Node.Capabilities). -func (k *Knobs) UpdateFromNodeAttributes(selfNodeAttrs []tailcfg.NodeCapability) { +func (k *Knobs) UpdateFromNodeAttributes(selfNodeAttrs []tailcfg.NodeCapability, capMap tailcfg.NodeCapMap) { if k == nil { return } + has := func(attr tailcfg.NodeCapability) bool { + _, ok := capMap[attr] + return ok || slices.Contains(selfNodeAttrs, attr) + } var ( - keepFullWG bool - disableDRPO bool - disableUPnP bool - randomizeClientPort bool - disableDeltaUpdates bool + keepFullWG = has(tailcfg.NodeAttrDebugDisableWGTrim) + disableDRPO = has(tailcfg.NodeAttrDebugDisableDRPO) + disableUPnP = has(tailcfg.NodeAttrDisableUPnP) + randomizeClientPort = has(tailcfg.NodeAttrRandomizeClientPort) + disableDeltaUpdates = has(tailcfg.NodeAttrDisableDeltaUpdates) oneCGNAT opt.Bool - forceBackgroundSTUN bool + forceBackgroundSTUN = has(tailcfg.NodeAttrDebugForceBackgroundSTUN) ) - for _, attr := range selfNodeAttrs { - switch attr { - case tailcfg.NodeAttrDebugDisableWGTrim: - keepFullWG = true - case tailcfg.NodeAttrDebugDisableDRPO: - disableDRPO = true - case tailcfg.NodeAttrDisableUPnP: - disableUPnP = true - case tailcfg.NodeAttrRandomizeClientPort: - randomizeClientPort = true - case tailcfg.NodeAttrOneCGNATEnable: - oneCGNAT.Set(true) - case tailcfg.NodeAttrOneCGNATDisable: - oneCGNAT.Set(false) - case tailcfg.NodeAttrDebugForceBackgroundSTUN: - forceBackgroundSTUN = true - case tailcfg.NodeAttrDisableDeltaUpdates: - disableDeltaUpdates = true - } + + if has(tailcfg.NodeAttrOneCGNATEnable) { + oneCGNAT.Set(true) + } else if has(tailcfg.NodeAttrOneCGNATDisable) { + oneCGNAT.Set(false) } + k.KeepFullWGConfig.Store(keepFullWG) k.DisableDRPO.Store(disableDRPO) k.DisableUPnP.Store(disableUPnP) diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index d6a727023..345799752 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -746,6 +746,13 @@ func (b *LocalBackend) UpdateStatus(sb *ipnstate.StatusBuilder) { if c := sn.Capabilities(); c.Len() > 0 { ss.Capabilities = c.AsSlice() } + if cm := sn.CapMap(); cm.Len() > 0 { + ss.CapMap = make(tailcfg.NodeCapMap, sn.CapMap().Len()) + cm.Range(func(k tailcfg.NodeCapability, v views.Slice[tailcfg.RawMessage]) bool { + ss.CapMap[k] = v.AsSlice() + return true + }) + } } for _, addr := range tailscaleIPs { ss.TailscaleIPs = append(ss.TailscaleIPs, addr) diff --git a/ipn/ipnstate/ipnstate.go b/ipn/ipnstate/ipnstate.go index a5a9b1efd..03b41d6a9 100644 --- a/ipn/ipnstate/ipnstate.go +++ b/ipn/ipnstate/ipnstate.go @@ -258,6 +258,9 @@ type PeerStatus struct { // "funnel" Capabilities []tailcfg.NodeCapability `json:",omitempty"` + // CapMap is a map of capabilities to their values. + CapMap tailcfg.NodeCapMap `json:",omitempty"` + // SSH_HostKeys are the node's SSH host keys, if known. SSH_HostKeys []string `json:"sshHostKeys,omitempty"` @@ -293,7 +296,7 @@ type PeerStatus struct { // HasCap reports whether ps has the given capability. func (ps *PeerStatus) HasCap(cap tailcfg.NodeCapability) bool { - return slices.Contains(ps.Capabilities, cap) + return ps.CapMap.Contains(cap) || slices.Contains(ps.Capabilities, cap) } // StatusBuilder is a request to construct a Status. A new StatusBuilder is diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 414dc4ca3..a45a09b94 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -16,6 +16,7 @@ import ( "strings" "time" + "golang.org/x/exp/maps" "tailscale.com/types/dnstype" "tailscale.com/types/key" "tailscale.com/types/opt" @@ -112,7 +113,8 @@ type CapabilityVersion int // - 71: 2023-08-17: added NodeAttrOneCGNATEnable, NodeAttrOneCGNATDisable // - 72: 2023-08-23: TS-2023-006 UPnP issue fixed; UPnP can now be used again // - 73: 2023-09-01: Non-Windows clients expect to receive ClientVersion -const CurrentCapabilityVersion CapabilityVersion = 73 +// - 74: 2023-09-18: Client understands NodeCapMap +const CurrentCapabilityVersion CapabilityVersion = 74 type StableID string @@ -315,8 +317,22 @@ type Node struct { // such as: // "https://tailscale.com/cap/is-admin" // "https://tailscale.com/cap/file-sharing" + // + // Deprecated: use CapMap instead. Capabilities []NodeCapability `json:",omitempty"` + // CapMap is a map of capabilities to their optional argument/data values. + // + // It is valid for a capability to not have any argument/data values; such + // capabilities can be tested for using the HasCap method. These type of + // capabilities are used to indicate that a node has a capability, but there + // is no additional data associated with it. These were previously + // represented by the Capabilities field, but can now be represented by + // CapMap with an empty value. + // + // See NodeCapability for more information on keys. + CapMap NodeCapMap `json:",omitempty"` + // UnsignedPeerAPIOnly means that this node is not signed nor subject to TKA // restrictions. However, in exchange for that privilege, it does not get // network access. It can only access this node's peerapi, which may not let @@ -369,13 +385,15 @@ type Node struct { } // HasCap reports whether the node has the given capability. +// It is safe to call on an invalid NodeView. func (v NodeView) HasCap(cap NodeCapability) bool { return v.ж.HasCap(cap) } // HasCap reports whether the node has the given capability. +// It is safe to call on a nil Node. func (v *Node) HasCap(cap NodeCapability) bool { - return v != nil && slices.Contains(v.Capabilities, cap) + return v != nil && (v.CapMap.Contains(cap) || slices.Contains(v.Capabilities, cap)) } // DisplayName returns the user-facing name for a node which should @@ -1285,6 +1303,45 @@ const ( PeerCapabilityIngress PeerCapability = "https://tailscale.com/cap/ingress" ) +// NodeCapMap is a map of capabilities to their optional values. It is valid for +// a capability to have no values (nil slice); such capabilities can be tested +// for by using the Contains method. +// +// See [NodeCapability] for more information on keys. +type NodeCapMap map[NodeCapability][]RawMessage + +// Equal reports whether c and c2 are equal. +func (c NodeCapMap) Equal(c2 NodeCapMap) bool { + return maps.EqualFunc(c, c2, slices.Equal) +} + +// UnmarshalNodeCapJSON unmarshals each JSON value in cm[cap] as T. +// If cap does not exist in cm, it returns (nil, nil). +// It returns an error if the values cannot be unmarshaled into the provided type. +func UnmarshalNodeCapJSON[T any](cm NodeCapMap, cap NodeCapability) ([]T, error) { + vals, ok := cm[cap] + if !ok { + return nil, nil + } + out := make([]T, 0, len(vals)) + for _, v := range vals { + var t T + if err := json.Unmarshal([]byte(v), &t); err != nil { + return nil, err + } + out = append(out, t) + } + return out, nil +} + +// Contains reports whether c has the capability cap. This is used to test for +// the existence of a capability, especially when the capability has no +// associated argument/data values. +func (c NodeCapMap) Contains(cap NodeCapability) bool { + _, ok := c[cap] + return ok +} + // PeerCapMap is a map of capabilities to their optional values. It is valid for // a capability to have no values (nil slice); such capabilities can be tested // for by using the HasCapability method. @@ -1312,9 +1369,9 @@ func UnmarshalCapJSON[T any](cm PeerCapMap, cap PeerCapability) ([]T, error) { return out, nil } -// HasCapability reports whether c has the capability cap. -// This is used to test for the existence of a capability, especially -// when the capability has no values. +// HasCapability reports whether c has the capability cap. This is used to test +// for the existence of a capability, especially when the capability has no +// associated argument/data values. func (c PeerCapMap) HasCapability(cap PeerCapability) bool { _, ok := c[cap] return ok @@ -1876,6 +1933,7 @@ func (n *Node) Equal(n2 *Node) bool { eqTimePtr(n.LastSeen, n2.LastSeen) && n.MachineAuthorized == n2.MachineAuthorized && slices.Equal(n.Capabilities, n2.Capabilities) && + n.CapMap.Equal(n2.CapMap) && n.ComputedName == n2.ComputedName && n.computedHostIfDifferent == n2.computedHostIfDifferent && n.ComputedNameWithHost == n2.ComputedNameWithHost && @@ -2450,6 +2508,9 @@ type PeerChange struct { // Cap, if non-zero, means that NodeID's capability version has changed. Cap CapabilityVersion `json:",omitempty"` + // CapMap, if non-nil, means that NodeID's capability map has changed. + CapMap NodeCapMap `json:",omitempty"` + // Endpoints, if non-empty, means that NodeID's UDP Endpoints // have changed to these. Endpoints []string `json:",omitempty"` diff --git a/tailcfg/tailcfg_clone.go b/tailcfg/tailcfg_clone.go index 69e5d6344..1a90838cf 100644 --- a/tailcfg/tailcfg_clone.go +++ b/tailcfg/tailcfg_clone.go @@ -62,6 +62,12 @@ func (src *Node) Clone() *Node { dst.Online = ptr.To(*src.Online) } dst.Capabilities = append(src.Capabilities[:0:0], src.Capabilities...) + if dst.CapMap != nil { + dst.CapMap = map[NodeCapability][]RawMessage{} + for k := range src.CapMap { + dst.CapMap[k] = append([]RawMessage{}, src.CapMap[k]...) + } + } if dst.SelfNodeV4MasqAddrForThisPeer != nil { dst.SelfNodeV4MasqAddrForThisPeer = ptr.To(*src.SelfNodeV4MasqAddrForThisPeer) } @@ -99,6 +105,7 @@ var _NodeCloneNeedsRegeneration = Node(struct { Online *bool MachineAuthorized bool Capabilities []NodeCapability + CapMap NodeCapMap UnsignedPeerAPIOnly bool ComputedName string computedHostIfDifferent string diff --git a/tailcfg/tailcfg_test.go b/tailcfg/tailcfg_test.go index db8a27299..6023fa08f 100644 --- a/tailcfg/tailcfg_test.go +++ b/tailcfg/tailcfg_test.go @@ -346,7 +346,7 @@ func TestNodeEqual(t *testing.T) { "Addresses", "AllowedIPs", "Endpoints", "DERP", "Hostinfo", "Created", "Cap", "Tags", "PrimaryRoutes", "LastSeen", "Online", "MachineAuthorized", - "Capabilities", + "Capabilities", "CapMap", "UnsignedPeerAPIOnly", "ComputedName", "computedHostIfDifferent", "ComputedNameWithHost", "DataPlaneAuditLogID", "Expired", "SelfNodeV4MasqAddrForThisPeer", @@ -545,6 +545,45 @@ func TestNodeEqual(t *testing.T) { &Node{SelfNodeV4MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("100.64.0.1"))}, true, }, + { + &Node{ + CapMap: NodeCapMap{ + "foo": []RawMessage{`"foo"`}, + }, + }, + &Node{ + CapMap: NodeCapMap{ + "foo": []RawMessage{`"foo"`}, + }, + }, + true, + }, + { + &Node{ + CapMap: NodeCapMap{ + "bar": []RawMessage{`"foo"`}, + }, + }, + &Node{ + CapMap: NodeCapMap{ + "foo": []RawMessage{`"bar"`}, + }, + }, + false, + }, + { + &Node{ + CapMap: NodeCapMap{ + "foo": nil, + }, + }, + &Node{ + CapMap: NodeCapMap{ + "foo": []RawMessage{`"bar"`}, + }, + }, + false, + }, } for i, tt := range tests { got := tt.a.Equal(tt.b) diff --git a/tailcfg/tailcfg_view.go b/tailcfg/tailcfg_view.go index 15d12ec7a..d99d9b3b5 100644 --- a/tailcfg/tailcfg_view.go +++ b/tailcfg/tailcfg_view.go @@ -167,11 +167,17 @@ func (v NodeView) Online() *bool { func (v NodeView) MachineAuthorized() bool { return v.ж.MachineAuthorized } func (v NodeView) Capabilities() views.Slice[NodeCapability] { return views.SliceOf(v.ж.Capabilities) } -func (v NodeView) UnsignedPeerAPIOnly() bool { return v.ж.UnsignedPeerAPIOnly } -func (v NodeView) ComputedName() string { return v.ж.ComputedName } -func (v NodeView) ComputedNameWithHost() string { return v.ж.ComputedNameWithHost } -func (v NodeView) DataPlaneAuditLogID() string { return v.ж.DataPlaneAuditLogID } -func (v NodeView) Expired() bool { return v.ж.Expired } + +func (v NodeView) CapMap() views.MapFn[NodeCapability, []RawMessage, views.Slice[RawMessage]] { + return views.MapFnOf(v.ж.CapMap, func(t []RawMessage) views.Slice[RawMessage] { + return views.SliceOf(t) + }) +} +func (v NodeView) UnsignedPeerAPIOnly() bool { return v.ж.UnsignedPeerAPIOnly } +func (v NodeView) ComputedName() string { return v.ж.ComputedName } +func (v NodeView) ComputedNameWithHost() string { return v.ж.ComputedNameWithHost } +func (v NodeView) DataPlaneAuditLogID() string { return v.ж.DataPlaneAuditLogID } +func (v NodeView) Expired() bool { return v.ж.Expired } func (v NodeView) SelfNodeV4MasqAddrForThisPeer() *netip.Addr { if v.ж.SelfNodeV4MasqAddrForThisPeer == nil { return nil @@ -211,6 +217,7 @@ var _NodeViewNeedsRegeneration = Node(struct { Online *bool MachineAuthorized bool Capabilities []NodeCapability + CapMap NodeCapMap UnsignedPeerAPIOnly bool ComputedName string computedHostIfDifferent string diff --git a/types/netmap/netmap.go b/types/netmap/netmap.go index d8747d0dc..9e2c15406 100644 --- a/types/netmap/netmap.go +++ b/types/netmap/netmap.go @@ -185,8 +185,13 @@ func (nm *NetworkMap) SelfCapabilities() views.Slice[tailcfg.NodeCapability] { if nm == nil || !nm.SelfNode.Valid() { return zero } + out := nm.SelfNode.Capabilities().AsSlice() + nm.SelfNode.CapMap().Range(func(k tailcfg.NodeCapability, _ views.Slice[tailcfg.RawMessage]) (cont bool) { + out = append(out, k) + return true + }) - return nm.SelfNode.Capabilities() + return views.SliceOf(out) } func (nm *NetworkMap) String() string {