mirror of
https://git.vectorsigma.ru/public/headscale.git
synced 2026-07-28 21:48:00 +00:00
Every Go-identifier reference in // and /* */ comments now uses
godoc's [Name] linking syntax so pkg.go.dev and `go doc` render
them as clickable cross-references. No behaviour change.
Pattern applied across the tree:
In-package [Foo], [Foo.Bar]
Cross-package [pkg.Foo], [pkg.Foo.Bar]
Stdlib [netip.Prefix], [errors.Is], [context.Context]
Tailscale [tailcfg.MapResponse], [tailcfg.Node.CapMap],
[tailcfg.NodeAttrSuggestExitNode]
Skip rules:
- File:line refs left as plain text
- HuJSON wire keys inside backtick raw strings untouched
- ACL/policy syntax tokens (tag:foo, autogroup:self, ...) not Go
symbols, left as plain text
- JSON/OIDC wire keys, gorm tags, RFC IPv6 placeholders, markdown
link tags, decorative dividers — all left as-is
877 lines
22 KiB
Go
877 lines
22 KiB
Go
package v2
|
||
|
||
import (
|
||
"fmt"
|
||
"net/netip"
|
||
"slices"
|
||
|
||
"github.com/juanfont/headscale/hscontrol/types"
|
||
"github.com/juanfont/headscale/hscontrol/util"
|
||
"github.com/rs/zerolog/log"
|
||
"go4.org/netipx"
|
||
"tailscale.com/tailcfg"
|
||
"tailscale.com/types/views"
|
||
)
|
||
|
||
// grantCategory classifies a grant by what per-node work it needs.
|
||
type grantCategory int
|
||
|
||
const (
|
||
// grantCategoryRegular requires no per-node work. The pre-compiled
|
||
// rules are complete and only need [policyutil.ReduceFilterRules].
|
||
grantCategoryRegular grantCategory = iota
|
||
|
||
// grantCategorySelf has autogroup:self destinations that must be
|
||
// expanded per-node to same-user untagged device IPs.
|
||
grantCategorySelf
|
||
|
||
// grantCategoryVia has Via tags that route rules to specific
|
||
// nodes based on their tags and advertised routes.
|
||
grantCategoryVia
|
||
)
|
||
|
||
// compiledGrant is a grant with its sources already resolved to IP
|
||
// addresses. The expensive work (alias → IP resolution) is done once
|
||
// here. Extracting rules for a specific node reads from pre-resolved
|
||
// data without re-resolving.
|
||
type compiledGrant struct {
|
||
category grantCategory
|
||
|
||
// srcIPStrings is the final SrcIPs for non-self rules, with
|
||
// nonWildcardSrcs appended to match Tailscale SaaS behavior.
|
||
srcIPStrings []string
|
||
|
||
hasWildcard bool
|
||
hasDangerAll bool
|
||
|
||
// rules are the pre-compiled filter rules for non-self, non-via
|
||
// destinations. For regular grants this is the complete output.
|
||
// For self grants with mixed destinations (self + other), this
|
||
// is the non-self portion only.
|
||
rules []tailcfg.FilterRule
|
||
|
||
// self is non-nil when the grant has autogroup:self destinations.
|
||
self *selfGrantData
|
||
|
||
// via is non-nil when the grant has Via tags.
|
||
via *viaGrantData
|
||
}
|
||
|
||
// selfGrantData holds data needed for per-node autogroup:self
|
||
// compilation. Sources are already resolved.
|
||
type selfGrantData struct {
|
||
resolvedSrcs []ResolvedAddresses
|
||
internetProtocols []ProtocolPort
|
||
app tailcfg.PeerCapMap
|
||
}
|
||
|
||
// viaGrantData holds data needed for per-node via-grant compilation.
|
||
// Sources are already resolved into srcIPStrings; destinations are
|
||
// pre-resolved into prefixes plus a flag for autogroup:internet, which
|
||
// the consumers handle separately because its gate is per-node
|
||
// (IsExitNode()) rather than per-prefix overlap.
|
||
type viaGrantData struct {
|
||
viaTags []Tag
|
||
resolvedDsts []netip.Prefix
|
||
hasAutoGroupInternet bool
|
||
internetProtocols []ProtocolPort
|
||
srcIPStrings []string
|
||
}
|
||
|
||
// resolveViaDestinations splits a via grant's destinations into the
|
||
// flat list of IP prefixes they resolve to plus a flag for
|
||
// autogroup:internet. Every alias kind goes through [Alias.Resolve] so
|
||
// adding a new alias type to the policy parser does not silently
|
||
// disappear from the via path. Non-IP alias kinds (tag, user, group,
|
||
// wildcard) resolve to /32 host IPs that never overlap with subnet
|
||
// route advertisements and therefore contribute nothing here.
|
||
func resolveViaDestinations(
|
||
pol *Policy,
|
||
users types.Users,
|
||
nodes views.Slice[types.NodeView],
|
||
dsts Aliases,
|
||
) ([]netip.Prefix, bool) {
|
||
var (
|
||
prefixes []netip.Prefix
|
||
hasAutoGroupInternet bool
|
||
)
|
||
|
||
for _, d := range dsts {
|
||
if ag, ok := d.(*AutoGroup); ok && ag.Is(AutoGroupInternet) {
|
||
hasAutoGroupInternet = true
|
||
|
||
continue
|
||
}
|
||
|
||
ips, err := d.Resolve(pol, users, nodes)
|
||
if err != nil || ips == nil {
|
||
continue
|
||
}
|
||
|
||
prefixes = append(prefixes, ips.Prefixes()...)
|
||
}
|
||
|
||
return prefixes, hasAutoGroupInternet
|
||
}
|
||
|
||
// userNodeIndex maps user IDs to their untagged nodes. Built once per
|
||
// policy or node-set change and read from many goroutines under
|
||
// [PolicyManager.mu]; readers must hold the lock (or the snapshot
|
||
// returned to them).
|
||
type userNodeIndex map[uint][]types.NodeView
|
||
|
||
func buildUserNodeIndex(
|
||
nodes views.Slice[types.NodeView],
|
||
) userNodeIndex {
|
||
idx := make(userNodeIndex)
|
||
|
||
for _, n := range nodes.All() {
|
||
if !n.IsTagged() && n.User().Valid() {
|
||
uid := n.User().ID()
|
||
idx[uid] = append(idx[uid], n)
|
||
}
|
||
}
|
||
|
||
return idx
|
||
}
|
||
|
||
// compileNodeAttrs returns the per-node CapMap derived from policy
|
||
// nodeAttrs plus the tailnet-wide [Policy.RandomizeClientPort] flag.
|
||
//
|
||
// Returns an error when a target alias fails to resolve so the caller
|
||
// surfaces a corrupt policy instead of silently granting a partial set
|
||
// of attrs.
|
||
func (pol *Policy) compileNodeAttrs(
|
||
users types.Users,
|
||
nodes views.Slice[types.NodeView],
|
||
) (map[types.NodeID]tailcfg.NodeCapMap, error) {
|
||
empty := map[types.NodeID]tailcfg.NodeCapMap{}
|
||
|
||
if pol == nil {
|
||
return empty, nil
|
||
}
|
||
|
||
if len(pol.NodeAttrs) == 0 && !pol.RandomizeClientPort {
|
||
return empty, nil
|
||
}
|
||
|
||
result := make(map[types.NodeID]tailcfg.NodeCapMap)
|
||
stamp := func(id types.NodeID, attr tailcfg.NodeCapability) {
|
||
capMap, ok := result[id]
|
||
if !ok {
|
||
capMap = tailcfg.NodeCapMap{}
|
||
result[id] = capMap
|
||
}
|
||
|
||
// nil [tailcfg.RawMessage] matches the wire format from a
|
||
// Tailscale-hosted control plane: capabilities without companion
|
||
// data marshal as null rather than []. Storing nil keeps the
|
||
// merge stable and lets the compat test diff cleanly against
|
||
// captured netmaps.
|
||
if _, exists := capMap[attr]; !exists {
|
||
capMap[attr] = nil
|
||
}
|
||
}
|
||
|
||
// Cache each node's IPs once per call. Without the cache, the
|
||
// node-attr inner loop would call [types.NodeView.IPs] once per attr
|
||
// per node — O(grants × nodes) allocations of a 2-element slice
|
||
// for what is invariant per node within a single policy compile.
|
||
type nodeIPs struct {
|
||
id types.NodeID
|
||
ips []netip.Addr
|
||
}
|
||
|
||
nodeList := make([]nodeIPs, 0, nodes.Len())
|
||
for _, n := range nodes.All() {
|
||
nodeList = append(nodeList, nodeIPs{id: n.ID(), ips: n.IPs()})
|
||
}
|
||
|
||
if pol.RandomizeClientPort {
|
||
for _, ni := range nodeList {
|
||
stamp(ni.id, tailcfg.NodeAttrRandomizeClientPort)
|
||
}
|
||
}
|
||
|
||
for _, na := range pol.NodeAttrs {
|
||
if len(na.Attrs) == 0 {
|
||
continue
|
||
}
|
||
|
||
resolved, err := na.Targets.Resolve(pol, users, nodes)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("nodeAttrs target %s: %w", na.Targets, err)
|
||
}
|
||
|
||
if resolved == nil {
|
||
continue
|
||
}
|
||
|
||
for _, ni := range nodeList {
|
||
if !slices.ContainsFunc(ni.ips, resolved.Contains) {
|
||
continue
|
||
}
|
||
|
||
for _, attr := range na.Attrs {
|
||
stamp(ni.id, attr)
|
||
}
|
||
}
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// compileGrants resolves all policy grants into [compiledGrant] structs.
|
||
// Source resolution and non-self destination resolution happens once
|
||
// here. This is the single resolution path that replaces the
|
||
// duplicated work in [Policy.compileFilterRules] and the autogroup:self
|
||
// expansion.
|
||
func (pol *Policy) compileGrants(
|
||
users types.Users,
|
||
nodes views.Slice[types.NodeView],
|
||
) []compiledGrant {
|
||
if pol == nil || (pol.ACLs == nil && pol.Grants == nil) {
|
||
return nil
|
||
}
|
||
|
||
grants := pol.Grants
|
||
for _, acl := range pol.ACLs {
|
||
grants = append(grants, aclToGrants(acl)...)
|
||
}
|
||
|
||
compiled := make([]compiledGrant, 0, len(grants))
|
||
|
||
for _, grant := range grants {
|
||
cg, err := pol.compileOneGrant(grant, users, nodes)
|
||
if err != nil {
|
||
log.Trace().Err(err).Msg("compiling grant")
|
||
|
||
continue
|
||
}
|
||
|
||
if cg != nil {
|
||
compiled = append(compiled, *cg)
|
||
}
|
||
}
|
||
|
||
return compiled
|
||
}
|
||
|
||
// compileOneGrant resolves a single grant into a [compiledGrant].
|
||
// All source resolution happens here. Non-self, non-via destination
|
||
// resolution also happens here. Per-node data (self dests, via
|
||
// matching) is stored for deferred compilation.
|
||
//
|
||
//nolint:gocyclo,cyclop
|
||
func (pol *Policy) compileOneGrant(
|
||
grant Grant,
|
||
users types.Users,
|
||
nodes views.Slice[types.NodeView],
|
||
) (*compiledGrant, error) {
|
||
// Via grants: resolve sources, store deferred data.
|
||
if len(grant.Via) > 0 {
|
||
return pol.compileOneViaGrant(grant, users, nodes)
|
||
}
|
||
|
||
// Split destinations into self vs other.
|
||
var autogroupSelfDests, otherDests []Alias
|
||
|
||
for _, dest := range grant.Destinations {
|
||
if ag, ok := dest.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
|
||
autogroupSelfDests = append(autogroupSelfDests, dest)
|
||
} else {
|
||
otherDests = append(otherDests, dest)
|
||
}
|
||
}
|
||
|
||
// Resolve sources per-alias, tracking non-wildcard sources
|
||
// separately so we can preserve their IPs alongside the
|
||
// wildcard CGNAT ranges (matching Tailscale SaaS behavior).
|
||
resolvedSrcs, nonWildcardSrcs, err := resolveSources(
|
||
pol, grant.Sources, users, nodes,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Literally empty src=[] or dst=[] produces no rules.
|
||
if len(grant.Sources) == 0 || len(grant.Destinations) == 0 {
|
||
return nil, nil //nolint:nilnil
|
||
}
|
||
|
||
if len(resolvedSrcs) == 0 && grant.App == nil {
|
||
return nil, nil //nolint:nilnil
|
||
}
|
||
|
||
hasWildcard := sourcesHaveWildcard(grant.Sources)
|
||
hasDangerAll := sourcesHaveDangerAll(grant.Sources)
|
||
srcIPStrings := buildSrcIPStrings(
|
||
resolvedSrcs, nonWildcardSrcs,
|
||
hasWildcard, hasDangerAll, nodes,
|
||
)
|
||
|
||
cg := &compiledGrant{
|
||
srcIPStrings: srcIPStrings,
|
||
hasWildcard: hasWildcard,
|
||
hasDangerAll: hasDangerAll,
|
||
}
|
||
|
||
// Compile non-self destination rules (done once, shared).
|
||
if len(otherDests) > 0 {
|
||
cg.rules = pol.compileOtherDests(
|
||
users, nodes, grant, otherDests,
|
||
resolvedSrcs, srcIPStrings,
|
||
)
|
||
}
|
||
|
||
// Classify and store deferred self data.
|
||
switch {
|
||
case len(autogroupSelfDests) > 0:
|
||
cg.category = grantCategorySelf
|
||
cg.self = &selfGrantData{
|
||
resolvedSrcs: resolvedSrcs,
|
||
internetProtocols: grant.InternetProtocols,
|
||
app: grant.App,
|
||
}
|
||
default:
|
||
cg.category = grantCategoryRegular
|
||
}
|
||
|
||
return cg, nil
|
||
}
|
||
|
||
// compileOneViaGrant resolves sources for a via grant and stores the
|
||
// deferred per-node data. The actual via-node matching and route
|
||
// intersection happens in [compileViaForNode].
|
||
func (pol *Policy) compileOneViaGrant(
|
||
grant Grant,
|
||
users types.Users,
|
||
nodes views.Slice[types.NodeView],
|
||
) (*compiledGrant, error) {
|
||
if len(grant.InternetProtocols) == 0 {
|
||
return nil, nil //nolint:nilnil
|
||
}
|
||
|
||
resolvedSrcs, _, err := resolveSources(
|
||
pol, grant.Sources, users, nodes,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if len(resolvedSrcs) == 0 {
|
||
return nil, nil //nolint:nilnil
|
||
}
|
||
|
||
// Build merged SrcIPs.
|
||
var srcIPs netipx.IPSetBuilder
|
||
|
||
for _, ips := range resolvedSrcs {
|
||
for _, pref := range ips.Prefixes() {
|
||
srcIPs.AddPrefix(pref)
|
||
}
|
||
}
|
||
|
||
srcResolved, err := newResolved(&srcIPs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if srcResolved.Empty() {
|
||
return nil, nil //nolint:nilnil
|
||
}
|
||
|
||
hasWildcard := sourcesHaveWildcard(grant.Sources)
|
||
hasDangerAll := sourcesHaveDangerAll(grant.Sources)
|
||
|
||
resolvedDsts, hasAutoGroupInternet := resolveViaDestinations(
|
||
pol, users, nodes, grant.Destinations,
|
||
)
|
||
|
||
return &compiledGrant{
|
||
category: grantCategoryVia,
|
||
via: &viaGrantData{
|
||
viaTags: grant.Via,
|
||
resolvedDsts: resolvedDsts,
|
||
hasAutoGroupInternet: hasAutoGroupInternet,
|
||
internetProtocols: grant.InternetProtocols,
|
||
srcIPStrings: srcIPsWithRoutes(
|
||
srcResolved, hasWildcard, hasDangerAll, nodes,
|
||
),
|
||
},
|
||
}, nil
|
||
}
|
||
|
||
// resolveSources resolves grant sources per-alias, returning the
|
||
// resolved addresses and a separate slice of non-wildcard sources.
|
||
// This is the canonical source-resolution path. Its output lands in
|
||
// [compiledGrant.srcIPStrings] (among other places) and callers on the
|
||
// hot path should prefer reading that over calling [Alias.Resolve] again.
|
||
func resolveSources(
|
||
pol *Policy,
|
||
sources Aliases,
|
||
users types.Users,
|
||
nodes views.Slice[types.NodeView],
|
||
) ([]ResolvedAddresses, []ResolvedAddresses, error) {
|
||
var all, nonWild []ResolvedAddresses
|
||
|
||
for i, src := range sources {
|
||
if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
|
||
return nil, nil, errSelfInSources
|
||
}
|
||
|
||
ips, err := src.Resolve(pol, users, nodes)
|
||
if err != nil {
|
||
log.Trace().Caller().Err(err).
|
||
Msg("resolving source ips")
|
||
}
|
||
|
||
if ips != nil {
|
||
all = append(all, ips)
|
||
|
||
if _, isWildcard := sources[i].(Asterix); !isWildcard {
|
||
nonWild = append(nonWild, ips)
|
||
}
|
||
}
|
||
}
|
||
|
||
return all, nonWild, nil
|
||
}
|
||
|
||
// buildSrcIPStrings builds the final SrcIPs string slice from
|
||
// resolved sources, preserving non-wildcard IPs alongside wildcard
|
||
// CGNAT ranges to match Tailscale SaaS behavior.
|
||
func buildSrcIPStrings(
|
||
resolvedSrcs, nonWildcardSrcs []ResolvedAddresses,
|
||
hasWildcard, hasDangerAll bool,
|
||
nodes views.Slice[types.NodeView],
|
||
) []string {
|
||
var merged netipx.IPSetBuilder
|
||
|
||
for _, ips := range resolvedSrcs {
|
||
for _, pref := range ips.Prefixes() {
|
||
merged.AddPrefix(pref)
|
||
}
|
||
}
|
||
|
||
srcResolved, err := newResolved(&merged)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
|
||
if srcResolved.Empty() {
|
||
return nil
|
||
}
|
||
|
||
srcIPStrs := srcIPsWithRoutes(
|
||
srcResolved, hasWildcard, hasDangerAll, nodes,
|
||
)
|
||
|
||
// When sources include a wildcard (*) alongside explicit
|
||
// sources (tags, groups, etc.), Tailscale preserves the
|
||
// individual IPs from non-wildcard sources alongside the
|
||
// merged CGNAT ranges rather than absorbing them.
|
||
if hasWildcard && len(nonWildcardSrcs) > 0 {
|
||
seen := make(map[string]bool, len(srcIPStrs))
|
||
for _, s := range srcIPStrs {
|
||
seen[s] = true
|
||
}
|
||
|
||
for _, ips := range nonWildcardSrcs {
|
||
for _, s := range ips.Strings() {
|
||
if !seen[s] {
|
||
seen[s] = true
|
||
srcIPStrs = append(srcIPStrs, s)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return srcIPStrs
|
||
}
|
||
|
||
// compileOtherDests compiles filter rules for non-self, non-via
|
||
// destinations. This produces both [tailcfg.FilterRule.DstPorts] rules
|
||
// (from [Grant.InternetProtocols]) and [tailcfg.CapGrant] rules (from
|
||
// [Grant.App]).
|
||
func (pol *Policy) compileOtherDests(
|
||
users types.Users,
|
||
nodes views.Slice[types.NodeView],
|
||
grant Grant,
|
||
otherDests Aliases,
|
||
resolvedSrcs []ResolvedAddresses,
|
||
srcIPStrings []string,
|
||
) []tailcfg.FilterRule {
|
||
var rules []tailcfg.FilterRule
|
||
|
||
// DstPorts rules from InternetProtocols.
|
||
for _, ipp := range grant.InternetProtocols {
|
||
destPorts := pol.destinationsToNetPortRange(
|
||
users, nodes, otherDests, ipp.Ports,
|
||
)
|
||
|
||
if len(destPorts) > 0 && len(srcIPStrings) > 0 {
|
||
rules = append(rules, tailcfg.FilterRule{
|
||
SrcIPs: srcIPStrings,
|
||
DstPorts: destPorts,
|
||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||
})
|
||
}
|
||
}
|
||
|
||
// CapGrant rules from App.
|
||
if grant.App != nil {
|
||
capSrcIPStrs := srcIPStrings
|
||
|
||
// When sources resolved to empty but App is set,
|
||
// Tailscale still produces the CapGrant rule with
|
||
// empty SrcIPs.
|
||
if capSrcIPStrs == nil {
|
||
capSrcIPStrs = []string{}
|
||
}
|
||
|
||
var (
|
||
capGrants []tailcfg.CapGrant
|
||
dstIPStrings []string
|
||
)
|
||
|
||
for _, dst := range otherDests {
|
||
ips, err := dst.Resolve(pol, users, nodes)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
|
||
capGrants = append(capGrants, tailcfg.CapGrant{
|
||
Dsts: ips.Prefixes(),
|
||
CapMap: grant.App,
|
||
})
|
||
|
||
dstIPStrings = append(dstIPStrings, ips.Strings()...)
|
||
}
|
||
|
||
if len(capGrants) > 0 {
|
||
srcPrefixes := make([]netip.Prefix, 0, len(resolvedSrcs)*2)
|
||
for _, ips := range resolvedSrcs {
|
||
srcPrefixes = append(
|
||
srcPrefixes, ips.Prefixes()...,
|
||
)
|
||
}
|
||
|
||
rules = append(rules, tailcfg.FilterRule{
|
||
SrcIPs: capSrcIPStrs,
|
||
CapGrant: capGrants,
|
||
})
|
||
|
||
dstsHaveWildcard := sourcesHaveWildcard(otherDests)
|
||
if dstsHaveWildcard {
|
||
dstIPStrings = append(
|
||
dstIPStrings,
|
||
approvedSubnetRoutes(nodes)...,
|
||
)
|
||
}
|
||
|
||
rules = append(
|
||
rules,
|
||
companionCapGrantRules(
|
||
dstIPStrings, srcPrefixes, grant.App,
|
||
)...,
|
||
)
|
||
}
|
||
}
|
||
|
||
return rules
|
||
}
|
||
|
||
// hasPerNodeGrants reports whether any [compiledGrant] requires
|
||
// per-node filter compilation (via grants or autogroup:self).
|
||
func hasPerNodeGrants(grants []compiledGrant) bool {
|
||
for i := range grants {
|
||
if grants[i].category != grantCategoryRegular {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// globalFilterRules extracts global filter rules from [compiledGrant]s.
|
||
// Via grants produce no global rules (they are per-node only); regular
|
||
// grants contribute their full pre-compiled ruleset; self grants
|
||
// contribute their non-self portion.
|
||
func globalFilterRules(grants []compiledGrant) []tailcfg.FilterRule {
|
||
var rules []tailcfg.FilterRule
|
||
|
||
for i := range grants {
|
||
if grants[i].category == grantCategoryVia {
|
||
continue
|
||
}
|
||
|
||
rules = append(rules, grants[i].rules...)
|
||
}
|
||
|
||
return mergeFilterRules(rules)
|
||
}
|
||
|
||
// filterRulesForNode produces unreduced filter rules for a specific
|
||
// node by combining pre-compiled global rules with per-node self and
|
||
// via rules. Regular grants emit their pre-compiled rules as-is.
|
||
// Self grants add autogroup:self expansion. Via grants add
|
||
// tag-matched, route-intersected rules.
|
||
func filterRulesForNode(
|
||
grants []compiledGrant,
|
||
node types.NodeView,
|
||
userIdx userNodeIndex,
|
||
) []tailcfg.FilterRule {
|
||
var rules []tailcfg.FilterRule
|
||
|
||
for i := range grants {
|
||
cg := &grants[i]
|
||
|
||
// Pre-compiled rules apply to all grant categories
|
||
// (empty for via-only grants).
|
||
rules = append(rules, cg.rules...)
|
||
|
||
switch cg.category {
|
||
case grantCategoryRegular:
|
||
// Nothing more to do.
|
||
|
||
case grantCategorySelf:
|
||
rules = append(
|
||
rules,
|
||
compileAutogroupSelf(cg, node, userIdx)...,
|
||
)
|
||
|
||
case grantCategoryVia:
|
||
rules = append(
|
||
rules,
|
||
compileViaForNode(cg, node)...,
|
||
)
|
||
}
|
||
}
|
||
|
||
return mergeFilterRules(rules)
|
||
}
|
||
|
||
// compileAutogroupSelf produces filter rules for autogroup:self
|
||
// destinations for a specific node. Only called for grants with
|
||
// self destinations and only produces rules for untagged nodes.
|
||
func compileAutogroupSelf(
|
||
cg *compiledGrant,
|
||
node types.NodeView,
|
||
userIdx userNodeIndex,
|
||
) []tailcfg.FilterRule {
|
||
if node.IsTagged() || cg.self == nil {
|
||
return nil
|
||
}
|
||
|
||
if !node.User().Valid() {
|
||
return nil
|
||
}
|
||
|
||
sameUserNodes := userIdx[node.User().ID()]
|
||
if len(sameUserNodes) == 0 {
|
||
return nil
|
||
}
|
||
|
||
var rules []tailcfg.FilterRule
|
||
|
||
// Filter sources to only same-user untagged devices.
|
||
srcResolved := filterSourcesToSameUser(
|
||
cg.self.resolvedSrcs, sameUserNodes,
|
||
)
|
||
if srcResolved == nil || srcResolved.Empty() {
|
||
return nil
|
||
}
|
||
|
||
// DstPorts rules from InternetProtocols.
|
||
for _, ipp := range cg.self.internetProtocols {
|
||
var destPorts []tailcfg.NetPortRange
|
||
|
||
for _, n := range sameUserNodes {
|
||
for _, port := range ipp.Ports {
|
||
for _, ip := range n.IPs() {
|
||
destPorts = append(
|
||
destPorts,
|
||
tailcfg.NetPortRange{
|
||
IP: ip.String(),
|
||
Ports: port,
|
||
},
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
if len(destPorts) > 0 {
|
||
rules = append(rules, tailcfg.FilterRule{
|
||
SrcIPs: srcResolved.Strings(),
|
||
DstPorts: destPorts,
|
||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||
})
|
||
}
|
||
}
|
||
|
||
// CapGrant rules from App.
|
||
if cg.self.app != nil {
|
||
var (
|
||
capGrants []tailcfg.CapGrant
|
||
dstIPStrings []string
|
||
)
|
||
|
||
for _, n := range sameUserNodes {
|
||
var dsts []netip.Prefix
|
||
for _, ip := range n.IPs() {
|
||
dsts = append(
|
||
dsts,
|
||
netip.PrefixFrom(ip, ip.BitLen()),
|
||
)
|
||
dstIPStrings = append(
|
||
dstIPStrings, ip.String(),
|
||
)
|
||
}
|
||
|
||
capGrants = append(capGrants, tailcfg.CapGrant{
|
||
Dsts: dsts,
|
||
CapMap: cg.self.app,
|
||
})
|
||
}
|
||
|
||
if len(capGrants) > 0 {
|
||
rules = append(rules, tailcfg.FilterRule{
|
||
SrcIPs: srcResolved.Strings(),
|
||
CapGrant: capGrants,
|
||
})
|
||
|
||
rules = append(
|
||
rules,
|
||
companionCapGrantRules(
|
||
dstIPStrings,
|
||
srcResolved.Prefixes(),
|
||
cg.self.app,
|
||
)...,
|
||
)
|
||
}
|
||
}
|
||
|
||
return rules
|
||
}
|
||
|
||
// filterSourcesToSameUser intersects resolved source addresses with
|
||
// same-user untagged device IPs, returning only the addresses that
|
||
// belong to those devices.
|
||
func filterSourcesToSameUser(
|
||
resolvedSrcs []ResolvedAddresses,
|
||
sameUserNodes []types.NodeView,
|
||
) ResolvedAddresses {
|
||
var srcIPs netipx.IPSetBuilder
|
||
|
||
for _, ips := range resolvedSrcs {
|
||
for _, n := range sameUserNodes {
|
||
if slices.ContainsFunc(n.IPs(), ips.Contains) {
|
||
n.AppendToIPSet(&srcIPs)
|
||
}
|
||
}
|
||
}
|
||
|
||
srcResolved, err := newResolved(&srcIPs)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
|
||
return srcResolved
|
||
}
|
||
|
||
// compileViaForNode produces via-grant filter rules for a specific
|
||
// node. Only produces rules when the node matches one of the via
|
||
// tags and advertises routes that match the grant destinations.
|
||
func compileViaForNode(
|
||
cg *compiledGrant,
|
||
node types.NodeView,
|
||
) []tailcfg.FilterRule {
|
||
if cg.via == nil {
|
||
return nil
|
||
}
|
||
|
||
// Check if node matches any via tag.
|
||
matchesVia := false
|
||
|
||
for _, viaTag := range cg.via.viaTags {
|
||
if node.HasTag(string(viaTag)) {
|
||
matchesVia = true
|
||
|
||
break
|
||
}
|
||
}
|
||
|
||
if !matchesVia {
|
||
return nil
|
||
}
|
||
|
||
// [types.NodeView.SubnetRoutes] excludes exit routes, so the overlap
|
||
// gate below sees only subnet advertisements. autogroup:internet on
|
||
// a via-tagged exit advertiser is handled separately because its
|
||
// eligibility is per-node ([types.NodeView.IsExitNode]) rather than
|
||
// per-prefix overlap.
|
||
nodeSubnetRoutes := node.SubnetRoutes()
|
||
|
||
var viaDstPrefixes []netip.Prefix
|
||
|
||
for _, dstPrefix := range cg.via.resolvedDsts {
|
||
// Equality would reject any broader or narrower dst relative
|
||
// to the advertised route. Containment in either direction
|
||
// matches the operator's authorisation: a broader dst restricts
|
||
// traffic to the subset the router serves; a narrower dst rides
|
||
// on a router covering more than the operator asked for. The
|
||
// rule emits the literal dst either way because that is what
|
||
// the policy authorised.
|
||
if slices.ContainsFunc(nodeSubnetRoutes, dstPrefix.Overlaps) {
|
||
viaDstPrefixes = append(viaDstPrefixes, dstPrefix)
|
||
}
|
||
}
|
||
|
||
// autogroup:internet on a via-tagged exit advertiser becomes a rule
|
||
// whose DstPorts enumerate [util.TheInternet]. The matchers derived
|
||
// from this rule let [types.NodeView.CanAccess] surface the exit node
|
||
// to the grant source via [matcher.Match.DestsIsTheInternet].
|
||
// [policyutil.ReduceFilterRules] strips the rule from the wire format
|
||
// on non-exit advertisers, preserving SaaS PacketFilter encoding.
|
||
if cg.via.hasAutoGroupInternet && node.IsExitNode() {
|
||
viaDstPrefixes = append(
|
||
viaDstPrefixes,
|
||
util.TheInternet().Prefixes()...,
|
||
)
|
||
}
|
||
|
||
if len(viaDstPrefixes) == 0 {
|
||
return nil
|
||
}
|
||
|
||
// Build rules using pre-resolved srcIPStrings.
|
||
var rules []tailcfg.FilterRule
|
||
|
||
for _, ipp := range cg.via.internetProtocols {
|
||
var destPorts []tailcfg.NetPortRange
|
||
|
||
for _, prefix := range viaDstPrefixes {
|
||
for _, port := range ipp.Ports {
|
||
destPorts = append(
|
||
destPorts,
|
||
tailcfg.NetPortRange{
|
||
IP: prefix.String(),
|
||
Ports: port,
|
||
},
|
||
)
|
||
}
|
||
}
|
||
|
||
if len(destPorts) > 0 {
|
||
rules = append(rules, tailcfg.FilterRule{
|
||
SrcIPs: cg.via.srcIPStrings,
|
||
DstPorts: destPorts,
|
||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||
})
|
||
}
|
||
}
|
||
|
||
return rules
|
||
}
|