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 <maisem@tailscale.com>
pull/9455/head
Maisem Ali 8 months ago committed by Maisem Ali
parent 4da0689c2c
commit 19a9d9037f

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

@ -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+

@ -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:

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

@ -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)

@ -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)

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

@ -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"`

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

@ -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)

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

@ -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 {

Loading…
Cancel
Save