policy/v2: preserve non-wildcard source IPs alongside wildcard ranges

When an ACL source list contains a wildcard (*) alongside explicit
sources (tags, groups, hosts, etc.), Tailscale preserves the individual
IPs from non-wildcard sources in SrcIPs alongside the merged wildcard
CGNAT ranges. Previously, headscale's IPSetBuilder would merge all
sources into a single set, absorbing the explicit IPs into the wildcard
range.

Track non-wildcard resolved addresses separately during source
resolution, then append their individual IP strings to the output
when a wildcard is also present. This fixes the remaining 5 ACL
compat test failures (K01 and M06 subtests).

Updates #2180
This commit is contained in:
Kristoffer Dalby
2026-03-18 16:14:37 +00:00
parent dda35847b0
commit ebe0f4078d
2 changed files with 34 additions and 2 deletions

View File

@@ -232,8 +232,13 @@ func (pol *Policy) compileGrantWithAutogroupSelf(
var rules []tailcfg.FilterRule
var resolvedSrcs []ResolvedAddresses
// Track non-wildcard source IPs separately. When the grant has a
// wildcard (*) source plus explicit sources (tags, groups, etc.),
// Tailscale preserves the explicit IPs alongside the wildcard
// CGNAT ranges rather than merging them into the IPSet.
var nonWildcardSrcs []ResolvedAddresses
for _, src := range grant.Sources {
for i, src := range grant.Sources {
if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
return nil, errSelfInSources
}
@@ -245,6 +250,9 @@ func (pol *Policy) compileGrantWithAutogroupSelf(
if ips != nil {
resolvedSrcs = append(resolvedSrcs, ips)
if _, isWildcard := grant.Sources[i].(Asterix); !isWildcard {
nonWildcardSrcs = append(nonWildcardSrcs, ips)
}
}
}
@@ -275,8 +283,31 @@ func (pol *Policy) compileGrantWithAutogroupSelf(
destPorts := pol.destinationsToNetPortRange(users, nodes, otherDests, ipp.Ports)
if len(destPorts) > 0 {
srcIPStrs := srcIPsWithRoutes(srcResolved, hasWildcard, nodes)
// When sources include a wildcard (*) alongside
// explicit sources (tags, groups, etc.), Tailscale
// preserves the individual IPs from non-wildcard
// sources alongside the merged wildcard 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)
}
}
}
}
rules = append(rules, tailcfg.FilterRule{
SrcIPs: srcIPsWithRoutes(srcResolved, hasWildcard, nodes),
SrcIPs: srcIPStrs,
DstPorts: destPorts,
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
})

View File

@@ -1897,6 +1897,7 @@ func aclToGrants(acl ACL) []Grant {
// preserve policy order, but ACLs with groups, users, tags, or
// hosts emit non-self rules first.
hasNonAutogroup := false
for _, dst := range acl.Destinations {
if _, ok := dst.Alias.(*AutoGroup); !ok {
hasNonAutogroup = true