policy: fix wildcard DstPorts format and proto:icmp handling

Fix two compatibility issues discovered in Tailscale SaaS testing:

1. Wildcard DstPorts format: Headscale was expanding wildcard
   destinations to CGNAT ranges (100.64.0.0/10, fd7a:115c:a1e0::/48)
   while Tailscale uses {IP: "*"} directly. Add detection for
   wildcard (Asterix) alias type in filter compilation to use the
   correct format.

2. proto:icmp handling: The "icmp" protocol name was returning both
   ICMPv4 (1) and ICMPv6 (58), but Tailscale only returns ICMPv4.
   Users should use "ipv6-icmp" or protocol number 58 explicitly
   for IPv6 ICMP.

Update all test expectations accordingly. This significantly reduces
test file line count by replacing duplicated CGNAT range patterns
with single wildcard entries.
This commit is contained in:
Kristoffer Dalby 2026-01-28 12:05:08 +00:00
parent 834ac27779
commit 95b1fd636e
5 changed files with 155 additions and 368 deletions

View File

@ -49,6 +49,18 @@ func (pol *Policy) compileFilterRules(
var destPorts []tailcfg.NetPortRange var destPorts []tailcfg.NetPortRange
for _, dest := range acl.Destinations { for _, dest := range acl.Destinations {
// Check if destination is a wildcard - use "*" directly instead of expanding
if _, isWildcard := dest.Alias.(Asterix); isWildcard {
for _, port := range dest.Ports {
destPorts = append(destPorts, tailcfg.NetPortRange{
IP: "*",
Ports: port,
})
}
continue
}
ips, err := dest.Resolve(pol, users, nodes) ips, err := dest.Resolve(pol, users, nodes)
if err != nil { if err != nil {
log.Trace().Caller().Err(err).Msgf("resolving destination ips") log.Trace().Caller().Err(err).Msgf("resolving destination ips")
@ -235,6 +247,18 @@ func (pol *Policy) compileACLWithAutogroupSelf(
var destPorts []tailcfg.NetPortRange var destPorts []tailcfg.NetPortRange
for _, dest := range otherDests { for _, dest := range otherDests {
// Check if destination is a wildcard - use "*" directly instead of expanding
if _, isWildcard := dest.Alias.(Asterix); isWildcard {
for _, port := range dest.Ports {
destPorts = append(destPorts, tailcfg.NetPortRange{
IP: "*",
Ports: port,
})
}
continue
}
ips, err := dest.Resolve(pol, users, nodes) ips, err := dest.Resolve(pol, users, nodes)
if err != nil { if err != nil {
log.Trace().Caller().Err(err).Msgf("resolving destination ips") log.Trace().Caller().Err(err).Msgf("resolving destination ips")

View File

@ -97,10 +97,8 @@ func TestParsing(t *testing.T) {
{ {
SrcIPs: []string{"100.100.101.0/24", "192.168.1.0/24"}, SrcIPs: []string{"100.100.101.0/24", "192.168.1.0/24"},
DstPorts: []tailcfg.NetPortRange{ DstPorts: []tailcfg.NetPortRange{
{IP: "100.64.0.0/10", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "*", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "100.64.0.0/10", Ports: tailcfg.PortRange{First: 3389, Last: 3389}}, {IP: "*", Ports: tailcfg.PortRange{First: 3389, Last: 3389}},
{IP: "fd7a:115c:a1e0::/48", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "fd7a:115c:a1e0::/48", Ports: tailcfg.PortRange{First: 3389, Last: 3389}},
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny}, {IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
}, },
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
@ -171,7 +169,8 @@ func TestParsing(t *testing.T) {
DstPorts: []tailcfg.NetPortRange{ DstPorts: []tailcfg.NetPortRange{
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny}, {IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
}, },
IPProto: []int{ProtocolICMP, ProtocolIPv6ICMP}, // proto:icmp only includes ICMP (1), not ICMPv6 (58)
IPProto: []int{ProtocolICMP},
}, },
}, },
wantErr: false, wantErr: false,

File diff suppressed because it is too large Load Diff

View File

@ -1181,48 +1181,15 @@ func TestTailscaleRoutesCompatExitNodes(t *testing.T) {
// with expanded autogroup:internet CIDRs in Headscale (Tailscale: nil) // with expanded autogroup:internet CIDRs in Headscale (Tailscale: nil)
}, },
}, },
// TODO: Verify Tailscale DstPorts format for wildcard destinations
//
// B3: Exit node advertises exit routes (verify RoutableIPs) // B3: Exit node advertises exit routes (verify RoutableIPs)
// //
// This test verifies that exit-node has 0.0.0.0/0 and ::/0 in RoutableIPs. // This test verifies that exit-node has 0.0.0.0/0 and ::/0 in RoutableIPs.
// The filter test is a proxy for this - all nodes get wildcard filters. // All nodes get wildcard filters with {IP: "*"} format matching Tailscale.
//
// TAILSCALE BEHAVIOR:
// - Uses "*" in DstPorts.IP for wildcard destinations
//
// HEADSCALE BEHAVIOR:
// - Uses explicit CIDR ranges (100.64.0.0/10, fd7a:115c:a1e0::/48)
//
// ROOT CAUSE:
// Different representation of wildcard destinations in filter rules
//
// FIX REQUIRED:
// Verify if Tailscale actually uses "*" or if our expected values are wrong
{ {
name: "B3_exit_node_advertises_routes", name: "B3_exit_node_advertises_routes",
policy: makeRoutesPolicy(` policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["*:*"]} {"action": "accept", "src": ["*"], "dst": ["*:*"]}
`), `),
/* EXPECTED (Tailscale) - if it uses "*" format:
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "*", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Other nodes would also get filters - need Tailscale verification
},
*/
// ACTUAL (Headscale):
// All nodes receive the same wildcard filter with explicit CIDRs
wantFilters: map[string][]tailcfg.FilterRule{ wantFilters: map[string][]tailcfg.FilterRule{
"client1": wildcardFilter, "client1": wildcardFilter,
"client2": wildcardFilter, "client2": wildcardFilter,
@ -1235,46 +1202,15 @@ func TestTailscaleRoutesCompatExitNodes(t *testing.T) {
"user1": wildcardFilter, "user1": wildcardFilter,
}, },
}, },
// TODO: Verify Tailscale DstPorts format for wildcard destinations
//
// B5: Exit node with wildcard destination has ExitNodeOption // B5: Exit node with wildcard destination has ExitNodeOption
// //
// Exit nodes should have ExitNodeOption=true in MapResponse. // Exit nodes should have ExitNodeOption=true in MapResponse.
// The filter test is a proxy - all nodes should get wildcard filters. // All nodes get wildcard filters with {IP: "*"} format matching Tailscale.
//
// TAILSCALE BEHAVIOR:
// - Exit nodes with approved exit routes have ExitNodeOption=true
// - DstPorts may use "*" format
//
// HEADSCALE BEHAVIOR:
// - All nodes get filters with explicit CIDR ranges
//
// ROOT CAUSE:
// Different DstPorts format; need to verify Tailscale's actual format
{ {
name: "B5_exit_with_wildcard_dst", name: "B5_exit_with_wildcard_dst",
policy: makeRoutesPolicy(` policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["*:*"]} {"action": "accept", "src": ["*"], "dst": ["*:*"]}
`), `),
/* EXPECTED (Tailscale) - if it uses "*" format:
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "*", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Other nodes - need Tailscale verification
},
*/
// ACTUAL (Headscale):
// All nodes receive the same wildcard filter with explicit CIDRs
wantFilters: map[string][]tailcfg.FilterRule{ wantFilters: map[string][]tailcfg.FilterRule{
"client1": wildcardFilter, "client1": wildcardFilter,
"client2": wildcardFilter, "client2": wildcardFilter,
@ -1602,42 +1538,15 @@ func TestTailscaleRoutesCompatExitNodes(t *testing.T) {
}, },
}, },
}, },
// TODO: Verify Tailscale DstPorts format for wildcard destinations
//
// B9: Exit routes appear in peer AllowedIPs // B9: Exit routes appear in peer AllowedIPs
// //
// When viewing exit-node as a peer, AllowedIPs should include exit routes. // When viewing exit-node as a peer, AllowedIPs should include exit routes.
// This is a MapResponse property test, filter test is a proxy. // All nodes get wildcard filters with {IP: "*"} format matching Tailscale.
//
// TAILSCALE BEHAVIOR:
// - Need to verify actual format (may use "*" in DstPorts.IP)
//
// HEADSCALE BEHAVIOR:
// - All nodes get wildcard filter with explicit CIDR ranges
{ {
name: "B9_exit_routes_in_allowedips", name: "B9_exit_routes_in_allowedips",
policy: makeRoutesPolicy(` policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["*:*"]} {"action": "accept", "src": ["*"], "dst": ["*:*"]}
`), `),
/* EXPECTED (Tailscale) - if it uses "*" format:
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "*", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Other nodes - need Tailscale verification
},
*/
// ACTUAL (Headscale):
// All nodes receive the same wildcard filter with explicit CIDRs
wantFilters: map[string][]tailcfg.FilterRule{ wantFilters: map[string][]tailcfg.FilterRule{
"client1": wildcardFilter, "client1": wildcardFilter,
"client2": wildcardFilter, "client2": wildcardFilter,
@ -1650,42 +1559,16 @@ func TestTailscaleRoutesCompatExitNodes(t *testing.T) {
"user1": wildcardFilter, "user1": wildcardFilter,
}, },
}, },
// TODO: Verify Tailscale DstPorts format for wildcard destinations
//
// B10: Exit routes NOT in PrimaryRoutes field // B10: Exit routes NOT in PrimaryRoutes field
// //
// PrimaryRoutes is for subnet routes only, not exit routes. // PrimaryRoutes is for subnet routes only, not exit routes.
// Exit routes (0.0.0.0/0, ::/0) should NOT appear in PrimaryRoutes. // Exit routes (0.0.0.0/0, ::/0) should NOT appear in PrimaryRoutes.
// // All nodes get wildcard filters with {IP: "*"} format matching Tailscale.
// TAILSCALE BEHAVIOR:
// - Need to verify actual format
//
// HEADSCALE BEHAVIOR:
// - All nodes get wildcard filter with explicit CIDR ranges
{ {
name: "B10_exit_routes_not_in_primaryroutes", name: "B10_exit_routes_not_in_primaryroutes",
policy: makeRoutesPolicy(` policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["*:*"]} {"action": "accept", "src": ["*"], "dst": ["*:*"]}
`), `),
/* EXPECTED (Tailscale) - if it uses "*" format:
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "*", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Other nodes - need Tailscale verification
},
*/
// ACTUAL (Headscale):
// All nodes receive the same wildcard filter with explicit CIDRs
wantFilters: map[string][]tailcfg.FilterRule{ wantFilters: map[string][]tailcfg.FilterRule{
"client1": wildcardFilter, "client1": wildcardFilter,
"client2": wildcardFilter, "client2": wildcardFilter,

View File

@ -1375,8 +1375,9 @@ func (p Protocol) parseProtocol() ([]int, bool) {
return []int{ProtocolAH}, true return []int{ProtocolAH}, true
case ProtocolNameSCTP: case ProtocolNameSCTP:
return []int{ProtocolSCTP}, false return []int{ProtocolSCTP}, false
case ProtoNameICMP: case ProtocolNameICMP:
return []int{ProtocolICMP, ProtocolIPv6ICMP}, true // ICMP only - use "ipv6-icmp" or protocol number 58 for ICMPv6
return []int{ProtocolICMP}, true
default: default:
// Try to parse as a numeric protocol number // Try to parse as a numeric protocol number
// This should not fail since validation happened during unmarshaling // This should not fail since validation happened during unmarshaling