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
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)
if err != nil {
log.Trace().Caller().Err(err).Msgf("resolving destination ips")
@@ -235,6 +247,18 @@ func (pol *Policy) compileACLWithAutogroupSelf(
var destPorts []tailcfg.NetPortRange
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)
if err != nil {
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"},
DstPorts: []tailcfg.NetPortRange{
{IP: "100.64.0.0/10", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "100.64.0.0/10", 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: "*", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "*", Ports: tailcfg.PortRange{First: 3389, Last: 3389}},
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
@@ -171,7 +169,8 @@ func TestParsing(t *testing.T) {
DstPorts: []tailcfg.NetPortRange{
{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,

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)
},
},
// TODO: Verify Tailscale DstPorts format for wildcard destinations
//
// B3: Exit node advertises exit routes (verify 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.
//
// 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
// All nodes get wildcard filters with {IP: "*"} format matching Tailscale.
{
name: "B3_exit_node_advertises_routes",
policy: makeRoutesPolicy(`
{"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{
"client1": wildcardFilter,
"client2": wildcardFilter,
@@ -1235,46 +1202,15 @@ func TestTailscaleRoutesCompatExitNodes(t *testing.T) {
"user1": wildcardFilter,
},
},
// TODO: Verify Tailscale DstPorts format for wildcard destinations
//
// B5: Exit node with wildcard destination has ExitNodeOption
//
// Exit nodes should have ExitNodeOption=true in MapResponse.
// The filter test is a proxy - all nodes should get wildcard filters.
//
// 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
// All nodes get wildcard filters with {IP: "*"} format matching Tailscale.
{
name: "B5_exit_with_wildcard_dst",
policy: makeRoutesPolicy(`
{"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{
"client1": 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
//
// When viewing exit-node as a peer, AllowedIPs should include exit routes.
// This is a MapResponse property test, filter test is a proxy.
//
// TAILSCALE BEHAVIOR:
// - Need to verify actual format (may use "*" in DstPorts.IP)
//
// HEADSCALE BEHAVIOR:
// - All nodes get wildcard filter with explicit CIDR ranges
// All nodes get wildcard filters with {IP: "*"} format matching Tailscale.
{
name: "B9_exit_routes_in_allowedips",
policy: makeRoutesPolicy(`
{"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{
"client1": wildcardFilter,
"client2": wildcardFilter,
@@ -1650,42 +1559,16 @@ func TestTailscaleRoutesCompatExitNodes(t *testing.T) {
"user1": wildcardFilter,
},
},
// TODO: Verify Tailscale DstPorts format for wildcard destinations
//
// B10: Exit routes NOT in PrimaryRoutes field
//
// PrimaryRoutes is for subnet routes only, not exit routes.
// Exit routes (0.0.0.0/0, ::/0) should NOT appear in PrimaryRoutes.
//
// TAILSCALE BEHAVIOR:
// - Need to verify actual format
//
// HEADSCALE BEHAVIOR:
// - All nodes get wildcard filter with explicit CIDR ranges
// All nodes get wildcard filters with {IP: "*"} format matching Tailscale.
{
name: "B10_exit_routes_not_in_primaryroutes",
policy: makeRoutesPolicy(`
{"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{
"client1": wildcardFilter,
"client2": wildcardFilter,

View File

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