diff --git a/client/tailscale/apitype/apitype.go b/client/tailscale/apitype/apitype.go index e4c4e538f..c3dbd5a78 100644 --- a/client/tailscale/apitype/apitype.go +++ b/client/tailscale/apitype/apitype.go @@ -14,8 +14,9 @@ type WhoIsResponse struct { Node *tailcfg.Node UserProfile *tailcfg.UserProfile - // Caps are extra capabilities that the remote Node has to this node. - Caps []string `json:",omitempty"` + // CapMap is a map of capabilities to their values. + // See tailcfg.PeerCapMap and tailcfg.PeerCapability for details. + CapMap tailcfg.PeerCapMap } // FileTarget is a node to which files can be sent, and the PeerAPI diff --git a/cmd/cloner/cloner.go b/cmd/cloner/cloner.go index 11ee55078..a2620c8d7 100644 --- a/cmd/cloner/cloner.go +++ b/cmd/cloner/cloner.go @@ -131,6 +131,8 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) { } else { writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname) } + } else if ft.Elem().String() == "encoding/json.RawMessage" { + writef("\tdst.%s[i] = append(src.%s[i][:0:0], src.%s[i]...)", fname, fname, fname) } else { writef("\tdst.%s[i] = *src.%s[i].Clone()", fname, fname) } diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 86a550a05..813d01265 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -814,13 +814,13 @@ func (b *LocalBackend) WhoIs(ipp netip.AddrPort) (n *tailcfg.Node, u tailcfg.Use // PeerCaps returns the capabilities that remote src IP has to // ths current node. -func (b *LocalBackend) PeerCaps(src netip.Addr) []string { +func (b *LocalBackend) PeerCaps(src netip.Addr) tailcfg.PeerCapMap { b.mu.Lock() defer b.mu.Unlock() return b.peerCapsLocked(src) } -func (b *LocalBackend) peerCapsLocked(src netip.Addr) []string { +func (b *LocalBackend) peerCapsLocked(src netip.Addr) tailcfg.PeerCapMap { if b.netMap == nil { return nil } @@ -834,7 +834,7 @@ func (b *LocalBackend) peerCapsLocked(src netip.Addr) []string { } dst := a.Addr() if dst.BitLen() == src.BitLen() { // match on family - return filt.AppendCaps(nil, src, dst) + return filt.CapsWithValues(src, dst) } } return nil @@ -4328,20 +4328,15 @@ func (b *LocalBackend) peerIsTaildropTargetLocked(p *tailcfg.Node) bool { return true } if len(p.Addresses) > 0 && - b.peerHasCapLocked(p.Addresses[0].Addr(), tailcfg.CapabilityFileSharingTarget) { + b.peerHasCapLocked(p.Addresses[0].Addr(), tailcfg.PeerCapabilityFileSharingTarget) { // Explicitly noted in the netmap ACL caps as a target. return true } return false } -func (b *LocalBackend) peerHasCapLocked(addr netip.Addr, wantCap string) bool { - for _, hasCap := range b.peerCapsLocked(addr) { - if hasCap == wantCap { - return true - } - } - return false +func (b *LocalBackend) peerHasCapLocked(addr netip.Addr, wantCap tailcfg.PeerCapability) bool { + return b.peerCapsLocked(addr).HasCapability(wantCap) } // SetDNS adds a DNS record for the given domain name & TXT record diff --git a/ipn/ipnlocal/peerapi.go b/ipn/ipnlocal/peerapi.go index 6d5f8c0fb..233a34a3d 100644 --- a/ipn/ipnlocal/peerapi.go +++ b/ipn/ipnlocal/peerapi.go @@ -1028,7 +1028,7 @@ func (h *peerAPIHandler) canPutFile() bool { // Unsigned peers can't send files. return false } - return h.isSelf || h.peerHasCap(tailcfg.CapabilityFileSharingSend) + return h.isSelf || h.peerHasCap(tailcfg.PeerCapabilityFileSharingSend) } // canDebug reports whether h can debug this node (goroutines, metrics, @@ -1042,7 +1042,7 @@ func (h *peerAPIHandler) canDebug() bool { // Unsigned peers can't debug. return false } - return h.isSelf || h.peerHasCap(tailcfg.CapabilityDebugPeer) + return h.isSelf || h.peerHasCap(tailcfg.PeerCapabilityDebugPeer) } // canWakeOnLAN reports whether h can send a Wake-on-LAN packet from this node. @@ -1050,23 +1050,18 @@ func (h *peerAPIHandler) canWakeOnLAN() bool { if h.peerNode.UnsignedPeerAPIOnly { return false } - return h.isSelf || h.peerHasCap(tailcfg.CapabilityWakeOnLAN) + return h.isSelf || h.peerHasCap(tailcfg.PeerCapabilityWakeOnLAN) } var allowSelfIngress = envknob.RegisterBool("TS_ALLOW_SELF_INGRESS") // canIngress reports whether h can send ingress requests to this node. func (h *peerAPIHandler) canIngress() bool { - return h.peerHasCap(tailcfg.CapabilityIngress) || (allowSelfIngress() && h.isSelf) + return h.peerHasCap(tailcfg.PeerCapabilityIngress) || (allowSelfIngress() && h.isSelf) } -func (h *peerAPIHandler) peerHasCap(wantCap string) bool { - for _, hasCap := range h.ps.b.PeerCaps(h.remoteAddr.Addr()) { - if hasCap == wantCap { - return true - } - } - return false +func (h *peerAPIHandler) peerHasCap(wantCap tailcfg.PeerCapability) bool { + return h.ps.b.PeerCaps(h.remoteAddr.Addr()).HasCapability(wantCap) } func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) { diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 4c0dbd480..1161c330a 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -427,7 +427,7 @@ func (h *Handler) serveWhoIs(w http.ResponseWriter, r *http.Request) { res := &apitype.WhoIsResponse{ Node: n, UserProfile: &u, - Caps: b.PeerCaps(ipp.Addr()), + CapMap: b.PeerCaps(ipp.Addr()), } j, err := json.MarshalIndent(res, "", "\t") if err != nil { diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index ca7b157e0..b69f77f31 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -8,6 +8,7 @@ package tailcfg import ( "bytes" "encoding/hex" + "encoding/json" "errors" "fmt" "net/netip" @@ -103,7 +104,8 @@ type CapabilityVersion int // - 64: 2023-07-11: Client understands s/CapabilityTailnetLockAlpha/CapabilityTailnetLock // - 65: 2023-07-12: Client understands DERPMap.HomeParams + incremental DERPMap updates with params // - 66: 2023-07-23: UserProfile.Groups added (available via WhoIs) -const CurrentCapabilityVersion CapabilityVersion = 66 +// - 67: 2023-07-25: Client understands PeerCapMap +const CurrentCapabilityVersion CapabilityVersion = 67 type StableID string @@ -1182,7 +1184,51 @@ type CapGrant struct { // Caps are the capabilities the source IP matched by // FilterRule.SrcIPs are granted to the destination IP, // matched by Dsts. - Caps []string `json:",omitempty"` + // Deprecated: use CapMap instead. + Caps []PeerCapability `json:",omitempty"` + + // CapMap is a map of capabilities to their values. + // The key is the capability name, and the value is a list of + // values for that capability. + CapMap PeerCapMap `json:",omitempty"` +} + +// PeerCapability is a capability granted to a node by a FilterRule. +// It's a string, but its meaning is application-defined. +// It must be a URL, like "https://tailscale.com/cap/file-sharing-target" or +// "https://example.com/cap/read-access". +type PeerCapability string + +const ( + // PeerCapabilityFileSharingTarget grants the current node the ability to send + // files to the peer which has this capability. + PeerCapabilityFileSharingTarget PeerCapability = "https://tailscale.com/cap/file-sharing-target" + // PeerCapabilityFileSharingSend grants the ability to receive files from a + // node that's owned by a different user. + PeerCapabilityFileSharingSend PeerCapability = "https://tailscale.com/cap/file-send" + // PeerCapabilityDebugPeer grants the ability for a peer to read this node's + // goroutines, metrics, magicsock internal state, etc. + PeerCapabilityDebugPeer PeerCapability = "https://tailscale.com/cap/debug-peer" + // PeerCapabilityWakeOnLAN grants the ability to send a Wake-On-LAN packet. + PeerCapabilityWakeOnLAN PeerCapability = "https://tailscale.com/cap/wake-on-lan" + // PeerCapabilityIngress grants the ability for a peer to send ingress traffic. + PeerCapabilityIngress PeerCapability = "https://tailscale.com/cap/ingress" +) + +// 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. +// +// The values are opaque to Tailscale, but are passed through from the ACLs to +// the application via the WhoIs API. +type PeerCapMap map[PeerCapability][]json.RawMessage + +// 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. +func (c PeerCapMap) HasCapability(cap PeerCapability) bool { + _, ok := c[cap] + return ok } // FilterRule represents one rule in a packet filter. @@ -1895,25 +1941,6 @@ const ( // CapabilityTailnetLock indicates the node may initialize tailnet lock. CapabilityTailnetLock = "https://tailscale.com/cap/tailnet-lock" - // Inter-node capabilities as specified in the MapResponse.PacketFilter[].CapGrants. - - // CapabilityFileSharingTarget grants the current node the ability to send - // files to the peer which has this capability. - CapabilityFileSharingTarget = "https://tailscale.com/cap/file-sharing-target" - // CapabilityFileSharingSend grants the ability to receive files from a - // node that's owned by a different user. - CapabilityFileSharingSend = "https://tailscale.com/cap/file-send" - // CapabilityDebugPeer grants the ability for a peer to read this node's - // goroutines, metrics, magicsock internal state, etc. - CapabilityDebugPeer = "https://tailscale.com/cap/debug-peer" - // CapabilityWakeOnLAN grants the ability to send a Wake-On-LAN packet. - CapabilityWakeOnLAN = "https://tailscale.com/cap/wake-on-lan" - // CapabilityIngress grants the ability for a peer to send ingress traffic. - CapabilityIngress = "https://tailscale.com/cap/ingress" - // CapabilitySSHSessionHaul grants the ability to receive SSH session logs - // from a peer. - CapabilitySSHSessionHaul = "https://tailscale.com/cap/ssh-session-haul" - // Funnel warning capabilities used for reporting errors to the user. // CapabilityWarnFunnelNoInvite indicates whether Funnel is enabled for the tailnet. diff --git a/tstest/integration/testcontrol/testcontrol.go b/tstest/integration/testcontrol/testcontrol.go index 8f5037380..654eeb8d5 100644 --- a/tstest/integration/testcontrol/testcontrol.go +++ b/tstest/integration/testcontrol/testcontrol.go @@ -796,7 +796,7 @@ func packetFilterWithIngressCaps() []tailcfg.FilterRule { CapGrant: []tailcfg.CapGrant{ { Dsts: []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}, - Caps: []string{tailcfg.CapabilityIngress}, + Caps: []tailcfg.PeerCapability{tailcfg.PeerCapabilityIngress}, }, }, }) diff --git a/util/deephash/deephash_test.go b/util/deephash/deephash_test.go index e673ec531..dc3c997e9 100644 --- a/util/deephash/deephash_test.go +++ b/util/deephash/deephash_test.go @@ -768,7 +768,7 @@ var filterRules = []tailcfg.FilterRule{ IPProto: []int{1, 2, 3, 4}, CapGrant: []tailcfg.CapGrant{{ Dsts: []netip.Prefix{netip.MustParsePrefix("1.2.3.4/32")}, - Caps: []string{"foo"}, + Caps: []tailcfg.PeerCapability{"foo"}, }}, }, { diff --git a/wgengine/filter/filter.go b/wgengine/filter/filter.go index 350f319e1..cc341b6fc 100644 --- a/wgengine/filter/filter.go +++ b/wgengine/filter/filter.go @@ -11,13 +11,16 @@ import ( "time" "go4.org/netipx" + "golang.org/x/exp/slices" "tailscale.com/envknob" "tailscale.com/net/flowtrack" "tailscale.com/net/netaddr" "tailscale.com/net/packet" + "tailscale.com/tailcfg" "tailscale.com/tstime/rate" "tailscale.com/types/ipproto" "tailscale.com/types/logger" + "tailscale.com/util/mak" ) // Filter is a stateful packet filter. @@ -322,10 +325,9 @@ func (f *Filter) CheckTCP(srcIP, dstIP netip.Addr, dstPort uint16) Response { return f.RunIn(pkt, 0) } -// AppendCaps appends to base the capabilities that srcIP has talking +// CapsWithValues appends to base the capabilities that srcIP has talking // to dstIP. -func (f *Filter) AppendCaps(base []string, srcIP, dstIP netip.Addr) []string { - ret := base +func (f *Filter) CapsWithValues(srcIP, dstIP netip.Addr) tailcfg.PeerCapMap { var mm matches switch { case srcIP.Is4(): @@ -333,17 +335,23 @@ func (f *Filter) AppendCaps(base []string, srcIP, dstIP netip.Addr) []string { case srcIP.Is6(): mm = f.cap6 } + var out tailcfg.PeerCapMap for _, m := range mm { if !ipInList(srcIP, m.Srcs) { continue } for _, cm := range m.Caps { if cm.Cap != "" && cm.Dst.Contains(dstIP) { - ret = append(ret, cm.Cap) + prev, ok := out[cm.Cap] + if !ok { + mak.Set(&out, cm.Cap, slices.Clone(cm.Values)) + continue + } + out[cm.Cap] = append(prev, cm.Values...) } } } - return ret + return out } // ShieldsUp reports whether this is a "shields up" (block everything diff --git a/wgengine/filter/filter_clone.go b/wgengine/filter/filter_clone.go index 794550f57..d945afd3d 100644 --- a/wgengine/filter/filter_clone.go +++ b/wgengine/filter/filter_clone.go @@ -6,8 +6,10 @@ package filter import ( + "encoding/json" "net/netip" + "tailscale.com/tailcfg" "tailscale.com/types/ipproto" ) @@ -22,7 +24,10 @@ func (src *Match) Clone() *Match { dst.IPProto = append(src.IPProto[:0:0], src.IPProto...) dst.Srcs = append(src.Srcs[:0:0], src.Srcs...) dst.Dsts = append(src.Dsts[:0:0], src.Dsts...) - dst.Caps = append(src.Caps[:0:0], src.Caps...) + dst.Caps = make([]CapMatch, len(src.Caps)) + for i := range dst.Caps { + dst.Caps[i] = *src.Caps[i].Clone() + } return dst } @@ -33,3 +38,25 @@ var _MatchCloneNeedsRegeneration = Match(struct { Dsts []NetPortRange Caps []CapMatch }{}) + +// Clone makes a deep copy of CapMatch. +// The result aliases no memory with the original. +func (src *CapMatch) Clone() *CapMatch { + if src == nil { + return nil + } + dst := new(CapMatch) + *dst = *src + dst.Values = make([]json.RawMessage, len(src.Values)) + for i := range dst.Values { + dst.Values[i] = append(src.Values[i][:0:0], src.Values[i]...) + } + return dst +} + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _CapMatchCloneNeedsRegeneration = CapMatch(struct { + Dst netip.Prefix + Cap tailcfg.PeerCapability + Values []json.RawMessage +}{}) diff --git a/wgengine/filter/filter_test.go b/wgengine/filter/filter_test.go index 5edd505f4..28d9098f4 100644 --- a/wgengine/filter/filter_test.go +++ b/wgengine/filter/filter_test.go @@ -7,13 +7,14 @@ import ( "encoding/hex" "fmt" "net/netip" - "reflect" "strconv" "strings" "testing" "github.com/google/go-cmp/cmp" "go4.org/netipx" + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" "tailscale.com/net/packet" "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" @@ -880,7 +881,7 @@ func TestCaps(t *testing.T) { Dsts: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), }, - Caps: []string{"is_ipv4"}, + Caps: []tailcfg.PeerCapability{"is_ipv4"}, }}, }, { @@ -889,7 +890,7 @@ func TestCaps(t *testing.T) { Dsts: []netip.Prefix{ netip.MustParsePrefix("::/0"), }, - Caps: []string{"is_ipv6"}, + Caps: []tailcfg.PeerCapability{"is_ipv6"}, }}, }, { @@ -898,7 +899,7 @@ func TestCaps(t *testing.T) { Dsts: []netip.Prefix{ netip.MustParsePrefix("100.200.0.0/16"), }, - Caps: []string{"some_super_admin"}, + Caps: []tailcfg.PeerCapability{"some_super_admin"}, }}, }, }) @@ -909,43 +910,45 @@ func TestCaps(t *testing.T) { tests := []struct { name string src, dst string // IP - want []string + want []tailcfg.PeerCapability }{ { name: "v4", src: "1.2.3.4", dst: "2.4.5.5", - want: []string{"is_ipv4"}, + want: []tailcfg.PeerCapability{"is_ipv4"}, }, { name: "v6", src: "1::1", dst: "2::2", - want: []string{"is_ipv6"}, + want: []tailcfg.PeerCapability{"is_ipv6"}, }, { name: "admin", src: "100.199.1.2", dst: "100.200.3.4", - want: []string{"is_ipv4", "some_super_admin"}, + want: []tailcfg.PeerCapability{"is_ipv4", "some_super_admin"}, }, { name: "not_admin_bad_src", src: "100.198.1.2", // 198, not 199 dst: "100.200.3.4", - want: []string{"is_ipv4"}, + want: []tailcfg.PeerCapability{"is_ipv4"}, }, { name: "not_admin_bad_dst", src: "100.199.1.2", dst: "100.201.3.4", // 201, not 200 - want: []string{"is_ipv4"}, + want: []tailcfg.PeerCapability{"is_ipv4"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := filt.AppendCaps(nil, netip.MustParseAddr(tt.src), netip.MustParseAddr(tt.dst)) - if !reflect.DeepEqual(got, tt.want) { + got := maps.Keys(filt.CapsWithValues(netip.MustParseAddr(tt.src), netip.MustParseAddr(tt.dst))) + slices.Sort(got) + slices.Sort(tt.want) + if !slices.Equal(got, tt.want) { t.Errorf("got %q; want %q", got, tt.want) } }) diff --git a/wgengine/filter/match.go b/wgengine/filter/match.go index 7c73b9630..4475c2332 100644 --- a/wgengine/filter/match.go +++ b/wgengine/filter/match.go @@ -4,15 +4,17 @@ package filter import ( + "encoding/json" "fmt" "net/netip" "strings" "tailscale.com/net/packet" + "tailscale.com/tailcfg" "tailscale.com/types/ipproto" ) -//go:generate go run tailscale.com/cmd/cloner --type=Match +//go:generate go run tailscale.com/cmd/cloner --type=Match,CapMatch // PortRange is a range of TCP and UDP ports. type PortRange struct { @@ -54,7 +56,11 @@ type CapMatch struct { // Cap is the capability that's granted if the destination IP addresses // matches Dst. - Cap string + Cap tailcfg.PeerCapability + + // Values are the raw JSON values of the capability. + // See tailcfg.PeerCapability and tailcfg.PeerCapMap for details. + Values []json.RawMessage } // Match matches packets from any IP address in Srcs to any ip:port in diff --git a/wgengine/filter/tailcfg.go b/wgengine/filter/tailcfg.go index de3cde97f..9f64587d7 100644 --- a/wgengine/filter/tailcfg.go +++ b/wgengine/filter/tailcfg.go @@ -86,6 +86,13 @@ func MatchesFromFilterRules(pf []tailcfg.FilterRule) ([]Match, error) { Cap: cap, }) } + for cap, val := range cm.CapMap { + m.Caps = append(m.Caps, CapMatch{ + Dst: dstNet, + Cap: tailcfg.PeerCapability(cap), + Values: val, + }) + } } }