mirror of
https://github.com/juanfont/headscale.git
synced 2026-05-27 04:28:53 +09:00
compileFilterRules skipped autogroup:internet destinations to keep them out of the wire-format PacketFilter, but those same compiled rules are the source of pm.matchers — and Node.CanAccess relies on a matcher whose DestsIsTheInternet covers the public internet to surface exit-node peers to ACL sources. With the skip in place no such matcher existed, exit nodes silently dropped out of the source's peer list, and the docs' exit-node walkthrough stopped working: `tailscale exit-node list` returned "no exit nodes found" and `tailscale set --exit-node=<ip>` returned "no node found in netmap with IP". Drop the compile-time skip so autogroup:internet flows through normal matcher derivation, and teach ReduceFilterRules to keep the resulting client packet-filter rule on exit-route advertisers — Tailscale SaaS sends those rules to exit nodes so the kernel filter accepts traffic forwarded by autogroup:internet sources. Verified against a live tailnet on 2026-04-28 via tscap; the b17/b18 captures land under testdata/issue_3212/ as a regression guard. The captures are isolated from testdata/routes_results/ because the broader TestRoutesCompat machinery assumes a CIDR-prefix wire format that differs from the IPSet-range form SaaS emits for autogroup:internet — aligning that wire format is tracked separately. Fixes #3212
175 lines
5.0 KiB
Go
175 lines
5.0 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 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. 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
|
|
// 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 CapGrant rule to only include 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 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. 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,
|
|
}
|
|
}
|