diff --git a/client/tailscale/apitype/apitype.go b/client/tailscale/apitype/apitype.go index cae573a4e..bd10b4d3d 100644 --- a/client/tailscale/apitype/apitype.go +++ b/client/tailscale/apitype/apitype.go @@ -11,6 +11,9 @@ import "tailscale.com/tailcfg" 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"` } // FileTarget is a node to which files can be sent, and the PeerAPI diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 23e1489f5..cc7eea7a9 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -526,6 +526,30 @@ func (b *LocalBackend) WhoIs(ipp netaddr.IPPort) (n *tailcfg.Node, u tailcfg.Use return n, u, true } +// PeerCaps returns the capabilities that remote src IP has to +// ths current node. +func (b *LocalBackend) PeerCaps(src netaddr.IP) []string { + b.mu.Lock() + defer b.mu.Unlock() + if b.netMap == nil { + return nil + } + filt, ok := b.filterAtomic.Load().(*filter.Filter) + if !ok { + return nil + } + for _, a := range b.netMap.Addresses { + if !a.IsSingleIP() { + continue + } + dstIP := a.IP() + if dstIP.BitLen() == src.BitLen() { + return filt.AppendCaps(nil, src, a.IP()) + } + } + return nil +} + // SetDecompressor sets a decompression function, which must be a zstd // reader. // diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 2c18a80ac..95fc4dadb 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -223,6 +223,7 @@ func (h *Handler) serveWhoIs(w http.ResponseWriter, r *http.Request) { res := &apitype.WhoIsResponse{ Node: n, UserProfile: &u, + Caps: b.PeerCaps(ipp.IP()), } j, err := json.MarshalIndent(res, "", "\t") if err != nil { diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 6349c0c6d..57c991c28 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -68,7 +68,8 @@ type CapabilityVersion int // 29: 2022-03-21: MapResponse.PopBrowserURL // 30: 2022-03-22: client can request id tokens. // 31: 2022-04-15: PingRequest & PingResponse TSMP & disco support -const CurrentCapabilityVersion CapabilityVersion = 31 +// 32: 2022-04-17: client knows FilterRule.CapMatch +const CurrentCapabilityVersion CapabilityVersion = 32 type StableID string @@ -1051,6 +1052,18 @@ type NetPortRange struct { Ports PortRange } +// CapGrant grants capabilities in a FilterRule. +type CapGrant struct { + // Dsts are the destination IP ranges that this capabilty + // grant matches. + Dsts []netaddr.IPPrefix + + // Caps are the capabilities the source IP matched by + // FilterRule.SrcIPs are granted to the destination IP, + // matched by Dsts. + Caps []string `json:",omitempty"` +} + // FilterRule represents one rule in a packet filter. // // A rule is logically a set of source CIDRs to match (described by @@ -1081,7 +1094,9 @@ type FilterRule struct { // DstPorts are the port ranges to allow once a source IP // matches (is in the CIDR described by SrcIPs & SrcBits). - DstPorts []NetPortRange + // + // CapGrant and DstPorts are mutually exclusive: at most one can be non-nil. + DstPorts []NetPortRange `json:",omitempty"` // IPProto are the IP protocol numbers to match. // @@ -1093,6 +1108,18 @@ type FilterRule struct { // Depending on the IPProto values, DstPorts may or may not be // used. IPProto []int `json:",omitempty"` + + // CapGrant, if non-empty, are the capabilities to + // conditionally grant to the source IP in SrcIPs. + // + // Think of DstPorts as "capabilities for networking" and + // CapGrant as arbitrary application-defined capabilities + // defined between the admin's ACLs and the application + // doing WhoIs lookups, looking up the remote IP address's + // application-level capabilities. + // + // CapGrant and DstPorts are mutually exclusive: at most one can be non-nil. + CapGrant []CapGrant `json:",omitempty"` } var FilterAllowAll = []FilterRule{ diff --git a/wgengine/filter/filter.go b/wgengine/filter/filter.go index a54559651..cda28eb30 100644 --- a/wgengine/filter/filter.go +++ b/wgengine/filter/filter.go @@ -27,10 +27,12 @@ type Filter struct { // destination within local, regardless of the policy filter // below. local *netaddr.IPSet + // logIPs is the set of IPs that are allowed to appear in flow // logs. If a packet is to or from an IP not in logIPs, it will // never be logged. logIPs *netaddr.IPSet + // matches4 and matches6 are lists of match->action rules // applied to all packets arriving over tailscale // tunnels. Matches are checked in order, and processing stops @@ -38,6 +40,11 @@ type Filter struct { // match is to drop the packet. matches4 matches matches6 matches + + // cap4 and cap6 are the subsets of the matches that are about + // capability grants, partitioned by source IP address family. + cap4, cap6 matches + // state is the connection tracking state attached to this // filter. It is used to allow incoming traffic that is a response // to an outbound connection that this node made, even if those @@ -174,6 +181,8 @@ func New(matches []Match, localNets *netaddr.IPSet, logIPs *netaddr.IPSet, share logf: logf, matches4: matchesFamily(matches, netaddr.IP.Is4), matches6: matchesFamily(matches, netaddr.IP.Is6), + cap4: capMatchesFunc(matches, netaddr.IP.Is4), + cap6: capMatchesFunc(matches, netaddr.IP.Is6), local: localNets, logIPs: logIPs, state: state, @@ -205,6 +214,27 @@ func matchesFamily(ms matches, keep func(netaddr.IP) bool) matches { return ret } +// capMatchesFunc returns a copy of the subset of ms for which keep(srcNet.IP) +// and the match is a capability grant. +func capMatchesFunc(ms matches, keep func(netaddr.IP) bool) matches { + var ret matches + for _, m := range ms { + if len(m.Caps) == 0 { + continue + } + retm := Match{Caps: m.Caps} + for _, src := range m.Srcs { + if keep(src.IP()) { + retm.Srcs = append(retm.Srcs, src) + } + } + if len(retm.Srcs) > 0 { + ret = append(ret, retm) + } + } + return ret +} + func maybeHexdump(flag RunFlags, b []byte) string { if flag == 0 { return "" @@ -291,6 +321,30 @@ func (f *Filter) CheckTCP(srcIP, dstIP netaddr.IP, dstPort uint16) Response { return f.RunIn(pkt, 0) } +// AppendCaps appends to base the capabilities that srcIP has talking +// to dstIP. +func (f *Filter) AppendCaps(base []string, srcIP, dstIP netaddr.IP) []string { + ret := base + var mm matches + switch { + case srcIP.Is4(): + mm = f.cap4 + case srcIP.Is6(): + mm = f.cap6 + } + 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) + } + } + } + return ret +} + // ShieldsUp reports whether this is a "shields up" (block everything // incoming) filter. func (f *Filter) ShieldsUp() bool { return f.shieldsUp } diff --git a/wgengine/filter/filter_test.go b/wgengine/filter/filter_test.go index 180b1de2e..00826714b 100644 --- a/wgengine/filter/filter_test.go +++ b/wgengine/filter/filter_test.go @@ -872,3 +872,83 @@ func TestMatchesMatchProtoAndIPsOnlyIfAllPorts(t *testing.T) { }) } } + +func TestCaps(t *testing.T) { + mm, err := MatchesFromFilterRules([]tailcfg.FilterRule{ + { + SrcIPs: []string{"*"}, + CapGrant: []tailcfg.CapGrant{{ + Dsts: []netaddr.IPPrefix{ + netaddr.MustParseIPPrefix("0.0.0.0/0"), + }, + Caps: []string{"is_ipv4"}, + }}, + }, + { + SrcIPs: []string{"*"}, + CapGrant: []tailcfg.CapGrant{{ + Dsts: []netaddr.IPPrefix{ + netaddr.MustParseIPPrefix("::/0"), + }, + Caps: []string{"is_ipv6"}, + }}, + }, + { + SrcIPs: []string{"100.199.0.0/16"}, + CapGrant: []tailcfg.CapGrant{{ + Dsts: []netaddr.IPPrefix{ + netaddr.MustParseIPPrefix("100.200.0.0/16"), + }, + Caps: []string{"some_super_admin"}, + }}, + }, + }) + if err != nil { + t.Fatal(err) + } + filt := New(mm, nil, nil, nil, t.Logf) + tests := []struct { + name string + src, dst string // IP + want []string + }{ + { + name: "v4", + src: "1.2.3.4", + dst: "2.4.5.5", + want: []string{"is_ipv4"}, + }, + { + name: "v6", + src: "1::1", + dst: "2::2", + want: []string{"is_ipv6"}, + }, + { + name: "admin", + src: "100.199.1.2", + dst: "100.200.3.4", + want: []string{"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"}, + }, + { + name: "not_admin_bad_dst", + src: "100.199.1.2", + dst: "100.201.3.4", // 201, not 200 + want: []string{"is_ipv4"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := filt.AppendCaps(nil, netaddr.MustParseIP(tt.src), netaddr.MustParseIP(tt.dst)) + if !reflect.DeepEqual(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 dae60870e..6b27f94f2 100644 --- a/wgengine/filter/match.go +++ b/wgengine/filter/match.go @@ -47,12 +47,24 @@ func (npr NetPortRange) String() string { return fmt.Sprintf("%v:%v", npr.Net, npr.Ports) } +// CapMatch is a capability grant match predicate. +type CapMatch struct { + // Dst is the IP prefix that the destination IP address matches against + // to get the capability. + Dst netaddr.IPPrefix + + // Cap is the capability that's granted if the destination IP addresses + // matches Dst. + Cap string +} + // Match matches packets from any IP address in Srcs to any ip:port in // Dsts. type Match struct { IPProto []ipproto.Proto // required set (no default value at this layer) - Dsts []NetPortRange Srcs []netaddr.IPPrefix + Dsts []NetPortRange // optional, if Srcs match + Caps []CapMatch // optional, if Srcs match } func (m Match) String() string { diff --git a/wgengine/filter/match_clone.go b/wgengine/filter/match_clone.go index f47761264..8b77e6fa7 100644 --- a/wgengine/filter/match_clone.go +++ b/wgengine/filter/match_clone.go @@ -21,14 +21,16 @@ func (src *Match) Clone() *Match { dst := new(Match) *dst = *src dst.IPProto = append(src.IPProto[:0:0], src.IPProto...) - dst.Dsts = append(src.Dsts[:0:0], src.Dsts...) 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...) return dst } // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _MatchCloneNeedsRegeneration = Match(struct { IPProto []ipproto.Proto - Dsts []NetPortRange Srcs []netaddr.IPPrefix + Dsts []NetPortRange + Caps []CapMatch }{}) diff --git a/wgengine/filter/tailcfg.go b/wgengine/filter/tailcfg.go index 8f52ff1fa..799fbf21a 100644 --- a/wgengine/filter/tailcfg.go +++ b/wgengine/filter/tailcfg.go @@ -70,6 +70,16 @@ func MatchesFromFilterRules(pf []tailcfg.FilterRule) ([]Match, error) { }) } } + for _, cm := range r.CapGrant { + for _, dstNet := range cm.Dsts { + for _, cap := range cm.Caps { + m.Caps = append(m.Caps, CapMatch{ + Dst: dstNet, + Cap: cap, + }) + } + } + } mm = append(mm, m) }