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 1 year 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/box from tailscale.com/types/key
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box 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/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+ L golang.org/x/net/bpf from github.com/mdlayher/netlink+
golang.org/x/net/dns/dnsmessage from net+ golang.org/x/net/dns/dnsmessage from net+
golang.org/x/net/http/httpguts from net/http 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/pbkdf2 from software.sslmate.com/src/go-pkcs12
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ 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 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/bpf from github.com/mdlayher/netlink+
golang.org/x/net/dns/dnsmessage from net+ golang.org/x/net/dns/dnsmessage from net+
golang.org/x/net/http/httpguts from net/http+ 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 slice := u
sElem := slice.Elem() sElem := slice.Elem()
switch x := sElem.(type) { 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.MapValueView = fmt.Sprintf("views.Slice[%v]", sElem)
args.MapValueType = "[]" + sElem.String() args.MapValueType = "[]" + sElem
args.MapFn = "views.SliceOf(t)" args.MapFn = "views.SliceOf(t)"
template = "mapFnField" template = "mapFnField"
case *types.Pointer: case *types.Pointer:

@ -187,8 +187,9 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
if resp.Node != nil { if resp.Node != nil {
if DevKnob.StripCaps() { if DevKnob.StripCaps() {
resp.Node.Capabilities = nil 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. // Call Node.InitDisplayNames on any changed nodes.
@ -324,6 +325,7 @@ var (
patchLastSeen = clientmetric.NewCounter("controlclient_patch_lastseen") patchLastSeen = clientmetric.NewCounter("controlclient_patch_lastseen")
patchKeyExpiry = clientmetric.NewCounter("controlclient_patch_keyexpiry") patchKeyExpiry = clientmetric.NewCounter("controlclient_patch_keyexpiry")
patchCapabilities = clientmetric.NewCounter("controlclient_patch_capabilities") patchCapabilities = clientmetric.NewCounter("controlclient_patch_capabilities")
patchCapMap = clientmetric.NewCounter("controlclient_patch_capmap")
patchKeySignature = clientmetric.NewCounter("controlclient_patch_keysig") patchKeySignature = clientmetric.NewCounter("controlclient_patch_keysig")
patchifiedPeer = clientmetric.NewCounter("controlclient_patchified_peer") patchifiedPeer = clientmetric.NewCounter("controlclient_patchified_peer")
@ -452,6 +454,10 @@ func (ms *mapSession) updatePeersStateFromResponse(resp *tailcfg.MapResponse) (s
mut.KeySignature = v mut.KeySignature = v
patchKeySignature.Add(1) patchKeySignature.Add(1)
} }
if v := pc.CapMap; v != nil {
mut.CapMap = v
patchCapMap.Add(1)
}
*vp = mut.View() *vp = mut.View()
} }
@ -647,6 +653,10 @@ func peerChangeDiff(was tailcfg.NodeView, n *tailcfg.Node) (_ *tailcfg.PeerChang
if was.Cap() != n.Cap { if was.Cap() != n.Cap {
pc().Cap = n.Cap pc().Cap = n.Cap
} }
case "CapMap":
if n.CapMap != nil {
pc().CapMap = n.CapMap
}
case "Tags": case "Tags":
if !views.SliceEqual(was.Tags(), views.SliceOf(n.Tags)) { if !views.SliceEqual(was.Tags(), views.SliceOf(n.Tags)) {
return nil, false return nil, false

@ -6,6 +6,7 @@
package controlknobs package controlknobs
import ( import (
"slices"
"sync/atomic" "sync/atomic"
"tailscale.com/syncs" "tailscale.com/syncs"
@ -48,39 +49,30 @@ type Knobs struct {
// UpdateFromNodeAttributes updates k (if non-nil) based on the provided self // UpdateFromNodeAttributes updates k (if non-nil) based on the provided self
// node attributes (Node.Capabilities). // node attributes (Node.Capabilities).
func (k *Knobs) UpdateFromNodeAttributes(selfNodeAttrs []tailcfg.NodeCapability) { func (k *Knobs) UpdateFromNodeAttributes(selfNodeAttrs []tailcfg.NodeCapability, capMap tailcfg.NodeCapMap) {
if k == nil { if k == nil {
return return
} }
has := func(attr tailcfg.NodeCapability) bool {
_, ok := capMap[attr]
return ok || slices.Contains(selfNodeAttrs, attr)
}
var ( var (
keepFullWG bool keepFullWG = has(tailcfg.NodeAttrDebugDisableWGTrim)
disableDRPO bool disableDRPO = has(tailcfg.NodeAttrDebugDisableDRPO)
disableUPnP bool disableUPnP = has(tailcfg.NodeAttrDisableUPnP)
randomizeClientPort bool randomizeClientPort = has(tailcfg.NodeAttrRandomizeClientPort)
disableDeltaUpdates bool disableDeltaUpdates = has(tailcfg.NodeAttrDisableDeltaUpdates)
oneCGNAT opt.Bool oneCGNAT opt.Bool
forceBackgroundSTUN bool forceBackgroundSTUN = has(tailcfg.NodeAttrDebugForceBackgroundSTUN)
) )
for _, attr := range selfNodeAttrs {
switch attr { if has(tailcfg.NodeAttrOneCGNATEnable) {
case tailcfg.NodeAttrDebugDisableWGTrim: oneCGNAT.Set(true)
keepFullWG = true } else if has(tailcfg.NodeAttrOneCGNATDisable) {
case tailcfg.NodeAttrDebugDisableDRPO: oneCGNAT.Set(false)
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
}
} }
k.KeepFullWGConfig.Store(keepFullWG) k.KeepFullWGConfig.Store(keepFullWG)
k.DisableDRPO.Store(disableDRPO) k.DisableDRPO.Store(disableDRPO)
k.DisableUPnP.Store(disableUPnP) k.DisableUPnP.Store(disableUPnP)

@ -746,6 +746,13 @@ func (b *LocalBackend) UpdateStatus(sb *ipnstate.StatusBuilder) {
if c := sn.Capabilities(); c.Len() > 0 { if c := sn.Capabilities(); c.Len() > 0 {
ss.Capabilities = c.AsSlice() 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 { for _, addr := range tailscaleIPs {
ss.TailscaleIPs = append(ss.TailscaleIPs, addr) ss.TailscaleIPs = append(ss.TailscaleIPs, addr)

@ -258,6 +258,9 @@ type PeerStatus struct {
// "funnel" // "funnel"
Capabilities []tailcfg.NodeCapability `json:",omitempty"` 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 are the node's SSH host keys, if known.
SSH_HostKeys []string `json:"sshHostKeys,omitempty"` SSH_HostKeys []string `json:"sshHostKeys,omitempty"`
@ -293,7 +296,7 @@ type PeerStatus struct {
// HasCap reports whether ps has the given capability. // HasCap reports whether ps has the given capability.
func (ps *PeerStatus) HasCap(cap tailcfg.NodeCapability) bool { 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 // StatusBuilder is a request to construct a Status. A new StatusBuilder is

@ -16,6 +16,7 @@ import (
"strings" "strings"
"time" "time"
"golang.org/x/exp/maps"
"tailscale.com/types/dnstype" "tailscale.com/types/dnstype"
"tailscale.com/types/key" "tailscale.com/types/key"
"tailscale.com/types/opt" "tailscale.com/types/opt"
@ -112,7 +113,8 @@ type CapabilityVersion int
// - 71: 2023-08-17: added NodeAttrOneCGNATEnable, NodeAttrOneCGNATDisable // - 71: 2023-08-17: added NodeAttrOneCGNATEnable, NodeAttrOneCGNATDisable
// - 72: 2023-08-23: TS-2023-006 UPnP issue fixed; UPnP can now be used again // - 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 // - 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 type StableID string
@ -315,8 +317,22 @@ type Node struct {
// such as: // such as:
// "https://tailscale.com/cap/is-admin" // "https://tailscale.com/cap/is-admin"
// "https://tailscale.com/cap/file-sharing" // "https://tailscale.com/cap/file-sharing"
//
// Deprecated: use CapMap instead.
Capabilities []NodeCapability `json:",omitempty"` 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 // UnsignedPeerAPIOnly means that this node is not signed nor subject to TKA
// restrictions. However, in exchange for that privilege, it does not get // 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 // 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. // 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 { func (v NodeView) HasCap(cap NodeCapability) bool {
return v.ж.HasCap(cap) return v.ж.HasCap(cap)
} }
// HasCap reports whether the node has the given capability. // 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 { 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 // DisplayName returns the user-facing name for a node which should
@ -1285,6 +1303,45 @@ const (
PeerCapabilityIngress PeerCapability = "https://tailscale.com/cap/ingress" 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 // 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 // a capability to have no values (nil slice); such capabilities can be tested
// for by using the HasCapability method. // for by using the HasCapability method.
@ -1312,9 +1369,9 @@ func UnmarshalCapJSON[T any](cm PeerCapMap, cap PeerCapability) ([]T, error) {
return out, nil return out, nil
} }
// HasCapability reports whether c has the capability cap. // HasCapability reports whether c has the capability cap. This is used to test
// This is used to test for the existence of a capability, especially // for the existence of a capability, especially when the capability has no
// when the capability has no values. // associated argument/data values.
func (c PeerCapMap) HasCapability(cap PeerCapability) bool { func (c PeerCapMap) HasCapability(cap PeerCapability) bool {
_, ok := c[cap] _, ok := c[cap]
return ok return ok
@ -1876,6 +1933,7 @@ func (n *Node) Equal(n2 *Node) bool {
eqTimePtr(n.LastSeen, n2.LastSeen) && eqTimePtr(n.LastSeen, n2.LastSeen) &&
n.MachineAuthorized == n2.MachineAuthorized && n.MachineAuthorized == n2.MachineAuthorized &&
slices.Equal(n.Capabilities, n2.Capabilities) && slices.Equal(n.Capabilities, n2.Capabilities) &&
n.CapMap.Equal(n2.CapMap) &&
n.ComputedName == n2.ComputedName && n.ComputedName == n2.ComputedName &&
n.computedHostIfDifferent == n2.computedHostIfDifferent && n.computedHostIfDifferent == n2.computedHostIfDifferent &&
n.ComputedNameWithHost == n2.ComputedNameWithHost && n.ComputedNameWithHost == n2.ComputedNameWithHost &&
@ -2450,6 +2508,9 @@ type PeerChange struct {
// Cap, if non-zero, means that NodeID's capability version has changed. // Cap, if non-zero, means that NodeID's capability version has changed.
Cap CapabilityVersion `json:",omitempty"` 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 // Endpoints, if non-empty, means that NodeID's UDP Endpoints
// have changed to these. // have changed to these.
Endpoints []string `json:",omitempty"` Endpoints []string `json:",omitempty"`

@ -62,6 +62,12 @@ func (src *Node) Clone() *Node {
dst.Online = ptr.To(*src.Online) dst.Online = ptr.To(*src.Online)
} }
dst.Capabilities = append(src.Capabilities[:0:0], src.Capabilities...) 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 { if dst.SelfNodeV4MasqAddrForThisPeer != nil {
dst.SelfNodeV4MasqAddrForThisPeer = ptr.To(*src.SelfNodeV4MasqAddrForThisPeer) dst.SelfNodeV4MasqAddrForThisPeer = ptr.To(*src.SelfNodeV4MasqAddrForThisPeer)
} }
@ -99,6 +105,7 @@ var _NodeCloneNeedsRegeneration = Node(struct {
Online *bool Online *bool
MachineAuthorized bool MachineAuthorized bool
Capabilities []NodeCapability Capabilities []NodeCapability
CapMap NodeCapMap
UnsignedPeerAPIOnly bool UnsignedPeerAPIOnly bool
ComputedName string ComputedName string
computedHostIfDifferent string computedHostIfDifferent string

@ -346,7 +346,7 @@ func TestNodeEqual(t *testing.T) {
"Addresses", "AllowedIPs", "Endpoints", "DERP", "Hostinfo", "Addresses", "AllowedIPs", "Endpoints", "DERP", "Hostinfo",
"Created", "Cap", "Tags", "PrimaryRoutes", "Created", "Cap", "Tags", "PrimaryRoutes",
"LastSeen", "Online", "MachineAuthorized", "LastSeen", "Online", "MachineAuthorized",
"Capabilities", "Capabilities", "CapMap",
"UnsignedPeerAPIOnly", "UnsignedPeerAPIOnly",
"ComputedName", "computedHostIfDifferent", "ComputedNameWithHost", "ComputedName", "computedHostIfDifferent", "ComputedNameWithHost",
"DataPlaneAuditLogID", "Expired", "SelfNodeV4MasqAddrForThisPeer", "DataPlaneAuditLogID", "Expired", "SelfNodeV4MasqAddrForThisPeer",
@ -545,6 +545,45 @@ func TestNodeEqual(t *testing.T) {
&Node{SelfNodeV4MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("100.64.0.1"))}, &Node{SelfNodeV4MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("100.64.0.1"))},
true, 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 { for i, tt := range tests {
got := tt.a.Equal(tt.b) 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) MachineAuthorized() bool { return v.ж.MachineAuthorized }
func (v NodeView) Capabilities() views.Slice[NodeCapability] { return views.SliceOf(v.ж.Capabilities) } 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) CapMap() views.MapFn[NodeCapability, []RawMessage, views.Slice[RawMessage]] {
func (v NodeView) ComputedNameWithHost() string { return v.ж.ComputedNameWithHost } return views.MapFnOf(v.ж.CapMap, func(t []RawMessage) views.Slice[RawMessage] {
func (v NodeView) DataPlaneAuditLogID() string { return v.ж.DataPlaneAuditLogID } return views.SliceOf(t)
func (v NodeView) Expired() bool { return v.ж.Expired } })
}
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 { func (v NodeView) SelfNodeV4MasqAddrForThisPeer() *netip.Addr {
if v.ж.SelfNodeV4MasqAddrForThisPeer == nil { if v.ж.SelfNodeV4MasqAddrForThisPeer == nil {
return nil return nil
@ -211,6 +217,7 @@ var _NodeViewNeedsRegeneration = Node(struct {
Online *bool Online *bool
MachineAuthorized bool MachineAuthorized bool
Capabilities []NodeCapability Capabilities []NodeCapability
CapMap NodeCapMap
UnsignedPeerAPIOnly bool UnsignedPeerAPIOnly bool
ComputedName string ComputedName string
computedHostIfDifferent string computedHostIfDifferent string

@ -185,8 +185,13 @@ func (nm *NetworkMap) SelfCapabilities() views.Slice[tailcfg.NodeCapability] {
if nm == nil || !nm.SelfNode.Valid() { if nm == nil || !nm.SelfNode.Valid() {
return zero 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 { func (nm *NetworkMap) String() string {

Loading…
Cancel
Save