mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-23 00:58:43 +02:00
policy/v2: use approved node routes in wildcard SrcIPs
Per Tailscale documentation, the wildcard (*) source includes "any approved subnets" — the actually-advertised-and-approved routes from nodes, not the autoApprover policy prefixes. Change Asterix.resolve() to return just the base CGNAT+ULA set, and add approved subnet routes as separate SrcIPs entries in the filter compilation path. This preserves individual route prefixes that would otherwise be merged by IPSet (e.g., 10.0.0.0/8 absorbing 10.33.0.0/16). Also swap rule ordering in compileGrantWithAutogroupSelf() to emit non-self destination rules before autogroup:self rules, matching the Tailscale FilterRule wire format ordering. Remove the unused AutoApproverPolicy.prefixes() method. Updates #2180
This commit is contained in:
@@ -21,6 +21,34 @@ var (
|
|||||||
errSelfInSources = errors.New("autogroup:self cannot be used in sources")
|
errSelfInSources = errors.New("autogroup:self cannot be used in sources")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// sourcesHaveWildcard returns true if any of the source aliases is
|
||||||
|
// a wildcard (*). Used to determine whether approved subnet routes
|
||||||
|
// should be appended to SrcIPs.
|
||||||
|
func sourcesHaveWildcard(srcs Aliases) bool {
|
||||||
|
for _, src := range srcs {
|
||||||
|
if _, ok := src.(Asterix); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// srcIPsWithRoutes returns the SrcIPs string slice, appending
|
||||||
|
// approved subnet routes when the sources include a wildcard.
|
||||||
|
func srcIPsWithRoutes(
|
||||||
|
resolved ResolvedAddresses,
|
||||||
|
hasWildcard bool,
|
||||||
|
nodes views.Slice[types.NodeView],
|
||||||
|
) []string {
|
||||||
|
ips := resolved.Strings()
|
||||||
|
if hasWildcard {
|
||||||
|
ips = append(ips, approvedSubnetRoutes(nodes)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
|
||||||
// compileFilterRules takes a set of nodes and an ACLPolicy and generates a
|
// compileFilterRules takes a set of nodes and an ACLPolicy and generates a
|
||||||
// set of Tailscale compatible FilterRules used to allow traffic on clients.
|
// set of Tailscale compatible FilterRules used to allow traffic on clients.
|
||||||
func (pol *Policy) compileFilterRules(
|
func (pol *Policy) compileFilterRules(
|
||||||
@@ -48,12 +76,14 @@ func (pol *Policy) compileFilterRules(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasWildcard := sourcesHaveWildcard(grant.Sources)
|
||||||
|
|
||||||
for _, ipp := range grant.InternetProtocols {
|
for _, ipp := range grant.InternetProtocols {
|
||||||
destPorts := pol.destinationsToNetPortRange(users, nodes, grant.Destinations, ipp.Ports)
|
destPorts := pol.destinationsToNetPortRange(users, nodes, grant.Destinations, ipp.Ports)
|
||||||
|
|
||||||
if len(destPorts) > 0 {
|
if len(destPorts) > 0 {
|
||||||
rules = append(rules, tailcfg.FilterRule{
|
rules = append(rules, tailcfg.FilterRule{
|
||||||
SrcIPs: srcIPs.Strings(),
|
SrcIPs: srcIPsWithRoutes(srcIPs, hasWildcard, nodes),
|
||||||
DstPorts: destPorts,
|
DstPorts: destPorts,
|
||||||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||||||
})
|
})
|
||||||
@@ -76,7 +106,7 @@ func (pol *Policy) compileFilterRules(
|
|||||||
}
|
}
|
||||||
|
|
||||||
rules = append(rules, tailcfg.FilterRule{
|
rules = append(rules, tailcfg.FilterRule{
|
||||||
SrcIPs: srcIPs.Strings(),
|
SrcIPs: srcIPsWithRoutes(srcIPs, hasWildcard, nodes),
|
||||||
CapGrant: capGrants,
|
CapGrant: capGrants,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -222,7 +252,38 @@ func (pol *Policy) compileGrantWithAutogroupSelf(
|
|||||||
return rules, nil
|
return rules, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasWildcard := sourcesHaveWildcard(grant.Sources)
|
||||||
|
|
||||||
for _, ipp := range grant.InternetProtocols {
|
for _, ipp := range grant.InternetProtocols {
|
||||||
|
// Handle non-self destinations first to match Tailscale's
|
||||||
|
// rule ordering in the FilterRule wire format.
|
||||||
|
if len(otherDests) > 0 {
|
||||||
|
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() {
|
||||||
|
destPorts := pol.destinationsToNetPortRange(users, nodes, otherDests, ipp.Ports)
|
||||||
|
|
||||||
|
if len(destPorts) > 0 {
|
||||||
|
rules = append(rules, tailcfg.FilterRule{
|
||||||
|
SrcIPs: srcIPsWithRoutes(srcResolved, hasWildcard, nodes),
|
||||||
|
DstPorts: destPorts,
|
||||||
|
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle autogroup:self destinations (if any)
|
// Handle autogroup:self destinations (if any)
|
||||||
// Tagged nodes don't participate in autogroup:self (identity is tag-based, not user-based)
|
// Tagged nodes don't participate in autogroup:self (identity is tag-based, not user-based)
|
||||||
if len(autogroupSelfDests) > 0 && !node.IsTagged() {
|
if len(autogroupSelfDests) > 0 && !node.IsTagged() {
|
||||||
@@ -277,33 +338,6 @@ func (pol *Policy) compileGrantWithAutogroupSelf(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(otherDests) > 0 {
|
|
||||||
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() {
|
|
||||||
destPorts := pol.destinationsToNetPortRange(users, nodes, otherDests, ipp.Ports)
|
|
||||||
|
|
||||||
if len(destPorts) > 0 {
|
|
||||||
rules = append(rules, tailcfg.FilterRule{
|
|
||||||
SrcIPs: srcResolved.Strings(),
|
|
||||||
DstPorts: destPorts,
|
|
||||||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rules, nil
|
return rules, nil
|
||||||
|
|||||||
@@ -89,7 +89,11 @@ func setupACLCompatNodes(users types.Users) types.Nodes {
|
|||||||
{
|
{
|
||||||
ID: 7, GivenName: "subnet-router",
|
ID: 7, GivenName: "subnet-router",
|
||||||
IPv4: ptrAddr("100.92.142.61"), IPv6: ptrAddr("fd7a:115c:a1e0::3e37:8e3d"),
|
IPv4: ptrAddr("100.92.142.61"), IPv6: ptrAddr("fd7a:115c:a1e0::3e37:8e3d"),
|
||||||
Tags: []string{"tag:router"}, Hostinfo: &tailcfg.Hostinfo{},
|
Tags: []string{"tag:router"},
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
|
||||||
|
},
|
||||||
|
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: 8, GivenName: "exit-node",
|
ID: 8, GivenName: "exit-node",
|
||||||
@@ -146,11 +150,13 @@ type aclTestFile struct {
|
|||||||
} `json:"input"`
|
} `json:"input"`
|
||||||
Topology struct {
|
Topology struct {
|
||||||
Nodes map[string]struct {
|
Nodes map[string]struct {
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
IPv4 string `json:"ipv4"`
|
IPv4 string `json:"ipv4"`
|
||||||
IPv6 string `json:"ipv6"`
|
IPv6 string `json:"ipv6"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
|
RoutableIPs []string `json:"routable_ips"`
|
||||||
|
ApprovedRoutes []string `json:"approved_routes"`
|
||||||
} `json:"nodes"`
|
} `json:"nodes"`
|
||||||
} `json:"topology"`
|
} `json:"topology"`
|
||||||
Captures map[string]struct {
|
Captures map[string]struct {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ package v2
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -136,7 +137,10 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
|
|||||||
IPv4: ptrAddr("100.92.142.61"),
|
IPv4: ptrAddr("100.92.142.61"),
|
||||||
IPv6: ptrAddr("fd7a:115c:a1e0::3e37:8e3d"),
|
IPv6: ptrAddr("fd7a:115c:a1e0::3e37:8e3d"),
|
||||||
Tags: []string{"tag:router"},
|
Tags: []string{"tag:router"},
|
||||||
Hostinfo: &tailcfg.Hostinfo{},
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
|
||||||
|
},
|
||||||
|
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
|
||||||
}
|
}
|
||||||
|
|
||||||
nodeExitNode := &types.Node{
|
nodeExitNode := &types.Node{
|
||||||
|
|||||||
@@ -296,19 +296,33 @@ func (a Asterix) Resolve(p *Policy, u types.Users, n views.Slice[types.NodeView]
|
|||||||
return newResolvedAddresses(a.resolve(p, u, n))
|
return newResolvedAddresses(a.resolve(p, u, n))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a Asterix) resolve(p *Policy, _ types.Users, _ views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
func (a Asterix) resolve(_ *Policy, _ types.Users, _ views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
||||||
if p != nil && len(p.AutoApprovers.prefixes()) > 0 {
|
return asterixResolved(), nil
|
||||||
var ipb netipx.IPSetBuilder
|
}
|
||||||
ipb.AddSet(asterixResolved())
|
|
||||||
|
|
||||||
for _, pfx := range p.AutoApprovers.prefixes() {
|
// approvedSubnetRoutes collects all approved non-exit subnet routes
|
||||||
ipb.AddPrefix(pfx)
|
// advertised across all nodes. Per Tailscale documentation, wildcard
|
||||||
|
// (*) SrcIPs include "any approved subnets".
|
||||||
|
//
|
||||||
|
// These are collected separately from the Asterix IPSet because
|
||||||
|
// IPSet merges overlapping ranges (e.g. 10.0.0.0/8 absorbs
|
||||||
|
// 10.33.0.0/16), but Tailscale preserves individual route entries.
|
||||||
|
func approvedSubnetRoutes(nodes views.Slice[types.NodeView]) []string {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
var routes []string
|
||||||
|
|
||||||
|
for _, node := range nodes.All() {
|
||||||
|
for _, route := range node.SubnetRoutes() {
|
||||||
|
s := route.String()
|
||||||
|
if !seen[s] {
|
||||||
|
seen[s] = true
|
||||||
|
routes = append(routes, s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ipb.IPSet()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return asterixResolved(), nil
|
return routes
|
||||||
}
|
}
|
||||||
|
|
||||||
// Username is a string that represents a username, it must contain an @.
|
// Username is a string that represents a username, it must contain an @.
|
||||||
@@ -1481,22 +1495,6 @@ func (ap AutoApproverPolicy) MarshalJSON() ([]byte, error) {
|
|||||||
return json.Marshal(&obj)
|
return json.Marshal(&obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
// prefixes returns the prefixes that have auto-approvers defined in the policy.
|
|
||||||
// It filters out exit routes since they are not associated with a specific prefix and are handled separately.
|
|
||||||
func (ap AutoApproverPolicy) prefixes() []netip.Prefix {
|
|
||||||
prefixes := make([]netip.Prefix, 0, len(ap.Routes))
|
|
||||||
|
|
||||||
for prefix := range ap.Routes {
|
|
||||||
if tsaddr.IsExitRoute(prefix) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
prefixes = append(prefixes, prefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
return prefixes
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveAutoApprovers resolves the AutoApprovers to a map of netip.Prefix to netipx.IPSet.
|
// resolveAutoApprovers resolves the AutoApprovers to a map of netip.Prefix to netipx.IPSet.
|
||||||
// The resulting map can be used to quickly look up if a node can self-approve a route.
|
// The resulting map can be used to quickly look up if a node can self-approve a route.
|
||||||
// It is intended for internal use in a PolicyManager.
|
// It is intended for internal use in a PolicyManager.
|
||||||
|
|||||||
Reference in New Issue
Block a user