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 {