tailcfg,ipn/ipnlocal,wgengine: add values to PeerCapabilities

Define PeerCapabilty and PeerCapMap as the new way of sending down
inter-peer capability information.

Previously, this was unstructured and you could only send down strings
which got too limiting for certain usecases. Instead add the ability
to send down raw JSON messages that are opaque to Tailscale but provide
the applications to define them however they wish.

Also update accessors to use the new values.

Updates #4217

Signed-off-by: Maisem Ali <maisem@tailscale.com>
This commit is contained in:
Maisem Ali 2023-07-24 21:07:00 -07:00 committed by Maisem Ali
parent 306deea03a
commit 1ecc16da5f
13 changed files with 139 additions and 68 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@
import (
"bytes"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/netip"
@ -103,7 +104,8 @@
// - 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 @@ type Oauth2Token struct {
// 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.

View File

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

View File

@ -768,7 +768,7 @@ func BenchmarkHash(b *testing.B) {
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"},
}},
},
{

View File

@ -11,13 +11,16 @@
"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

View File

@ -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 @@ func (src *Match) Clone() *Match {
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
}{})

View File

@ -7,13 +7,14 @@
"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)
}
})

View File

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

View File

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