mirror of
https://git.vectorsigma.ru/public/headscale.git
synced 2026-07-28 20:28: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
177 lines
5.1 KiB
Go
177 lines
5.1 KiB
Go
package policyutil
|
|
|
|
import (
|
|
"net/netip"
|
|
"slices"
|
|
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/juanfont/headscale/hscontrol/util"
|
|
"go4.org/netipx"
|
|
"tailscale.com/tailcfg"
|
|
)
|
|
|
|
// ReduceFilterRules takes a node and a set of global filter rules and removes all rules
|
|
// and destinations that are not relevant to that particular node.
|
|
//
|
|
// IMPORTANT: This function is designed for global filters only. Per-node filters
|
|
// (from autogroup:self policies) are already node-specific and should not be passed
|
|
// to this function. Use [policy.PolicyManager.FilterForNode] instead, which handles
|
|
// both cases.
|
|
func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcfg.FilterRule {
|
|
ret := []tailcfg.FilterRule{}
|
|
subnetRoutes := node.SubnetRoutes()
|
|
hasExitRoutes := node.IsExitNode()
|
|
|
|
for _, rule := range rules {
|
|
// Handle CapGrant rules separately — they use CapGrant[].Dsts
|
|
// instead of DstPorts for destination matching.
|
|
if len(rule.CapGrant) > 0 {
|
|
reduced := reduceCapGrantRule(node, rule)
|
|
if reduced != nil {
|
|
ret = append(ret, *reduced)
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
// record if the rule is actually relevant for the given node.
|
|
var dests []tailcfg.NetPortRange
|
|
|
|
for _, dest := range rule.DstPorts {
|
|
expanded, err := util.ParseIPSet(dest.IP, nil)
|
|
// Fail closed: unparseable dests are dropped.
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if node.InIPSet(expanded) {
|
|
dests = append(dests, dest)
|
|
continue
|
|
}
|
|
|
|
// If the node has approved subnet routes, preserve
|
|
// filter rules targeting those routes.
|
|
// [types.NodeView.SubnetRoutes] returns only approved,
|
|
// non-exit routes — matching Tailscale SaaS behavior,
|
|
// which does not generate filter rules for
|
|
// advertised-but-unapproved routes. Exit routes
|
|
// (0.0.0.0/0, ::/0) are excluded by
|
|
// [types.NodeView.SubnetRoutes] and handled separately
|
|
// via AllowedIPs/routing.
|
|
if slices.ContainsFunc(subnetRoutes, expanded.OverlapsPrefix) {
|
|
dests = append(dests, dest)
|
|
continue
|
|
}
|
|
|
|
// Exit-route advertisers need rules targeting the
|
|
// public internet so the kernel filter accepts
|
|
// traffic forwarded by autogroup:internet sources.
|
|
if hasExitRoutes && ipSetSubsetOf(expanded, util.TheInternet()) {
|
|
dests = append(dests, dest)
|
|
}
|
|
}
|
|
|
|
if len(dests) > 0 {
|
|
// Struct-copy preserves any unknown future FilterRule
|
|
// fields.
|
|
out := rule
|
|
out.DstPorts = dests
|
|
ret = append(ret, out)
|
|
}
|
|
}
|
|
|
|
return ret
|
|
}
|
|
|
|
func ipSetSubsetOf(candidate, container *netipx.IPSet) bool {
|
|
if candidate == nil || container == nil {
|
|
return false
|
|
}
|
|
|
|
for _, pref := range candidate.Prefixes() {
|
|
if !container.ContainsPrefix(pref) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// reduceCapGrantRule filters a [tailcfg.CapGrant] rule to only include
|
|
// [tailcfg.CapGrant] entries whose Dsts match the given node's IPs. When a
|
|
// broad prefix (e.g. 100.64.0.0/10 from dst:*) contains a node's IP, it is
|
|
// narrowed to the node's specific /32 or /128 prefix. Returns nil if
|
|
// no [tailcfg.CapGrant] entries are relevant to this node.
|
|
func reduceCapGrantRule(
|
|
node types.NodeView,
|
|
rule tailcfg.FilterRule,
|
|
) *tailcfg.FilterRule {
|
|
var capGrants []tailcfg.CapGrant
|
|
|
|
nodeIPs := node.IPs()
|
|
subnetRoutes := node.SubnetRoutes()
|
|
|
|
for _, cg := range rule.CapGrant {
|
|
// Collect the node's IPs that fall within any of this
|
|
// CapGrant's Dsts. Broad prefixes are narrowed to specific
|
|
// /32 and /128 entries for the node.
|
|
var matchingDsts []netip.Prefix
|
|
|
|
for _, dst := range cg.Dsts {
|
|
if dst.IsSingleIP() {
|
|
// Already a specific IP — keep it if it matches
|
|
// any of the node's IPs.
|
|
if slices.Contains(nodeIPs, dst.Addr()) {
|
|
matchingDsts = append(matchingDsts, dst)
|
|
}
|
|
} else {
|
|
// Broad prefix — narrow to node's specific IPs.
|
|
for _, ip := range nodeIPs {
|
|
if dst.Contains(ip) {
|
|
matchingDsts = append(matchingDsts, netip.PrefixFrom(ip, ip.BitLen()))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Asymmetric on purpose: the IP-match loop above narrows broad
|
|
// prefixes to node-specific /32 or /128 so peers receive only
|
|
// the minimum routing surface. The route-match loop below
|
|
// preserves the original prefix so the subnet-serving node
|
|
// receives the full CapGrant scope. [types.NodeView.SubnetRoutes]
|
|
// excludes both unapproved and exit routes, matching Tailscale
|
|
// SaaS behavior.
|
|
for _, dst := range cg.Dsts {
|
|
for _, subnetRoute := range subnetRoutes {
|
|
if dst.Overlaps(subnetRoute) {
|
|
// For route overlaps, keep the original prefix.
|
|
matchingDsts = append(matchingDsts, dst)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(matchingDsts) > 0 {
|
|
// A Dst can be appended twice when a broad prefix both
|
|
// contains a node IP and overlaps one of its approved
|
|
// subnet routes. Sort + Compact dedups; [netip.Prefix] is
|
|
// comparable so Compact works with ==.
|
|
slices.SortFunc(matchingDsts, netip.Prefix.Compare)
|
|
matchingDsts = slices.Compact(matchingDsts)
|
|
|
|
capGrants = append(capGrants, tailcfg.CapGrant{
|
|
Dsts: matchingDsts,
|
|
CapMap: cg.CapMap,
|
|
})
|
|
}
|
|
}
|
|
|
|
if len(capGrants) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return &tailcfg.FilterRule{
|
|
SrcIPs: rule.SrcIPs,
|
|
CapGrant: capGrants,
|
|
}
|
|
}
|