headscale/hscontrol/policy/matcher/matcher_test.go
Kristoffer Dalby c3df84e354 policy/matcher: include CapGrant.Dsts in match destinations
MatchFromFilterRule only read DstPorts[].IP into the destination
IPSet. Cap-grant-only filter rules (e.g. tailscale.com/cap/relay)
carry their destinations in CapGrant[].Dsts, so the derived matchers
had empty dest sets and BuildPeerMap / ReduceNodes never exposed the
cap target to its source nodes. Without a companion IP-level grant
the relay node stayed invisible, so clients never tried to use it
and connections sat on DERP.

Union CapGrant[].Dsts into the destination IPSet alongside DstPorts.
Restores peer-visibility for any cap-grant-only relationship; the
peer-relay flow is the most visible instance.

Fixes #3256
2026-05-11 14:55:06 +01:00

545 lines
13 KiB
Go

package matcher
import (
"net/netip"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
)
func TestMatchFromStrings(t *testing.T) {
t.Parallel()
tests := []struct {
name string
srcs []string
dsts []string
wantSrc netip.Addr
wantDst netip.Addr
srcIn bool
dstIn bool
}{
{
name: "basic CIDR match",
srcs: []string{"10.0.0.0/8"},
dsts: []string{"192.168.1.0/24"},
wantSrc: netip.MustParseAddr("10.1.2.3"),
wantDst: netip.MustParseAddr("192.168.1.100"),
srcIn: true,
dstIn: true,
},
{
name: "basic CIDR no match",
srcs: []string{"10.0.0.0/8"},
dsts: []string{"192.168.1.0/24"},
wantSrc: netip.MustParseAddr("172.16.0.1"),
wantDst: netip.MustParseAddr("10.0.0.1"),
srcIn: false,
dstIn: false,
},
{
name: "wildcard matches everything",
srcs: []string{"*"},
dsts: []string{"*"},
wantSrc: netip.MustParseAddr("8.8.8.8"),
wantDst: netip.MustParseAddr("1.1.1.1"),
srcIn: true,
dstIn: true,
},
{
name: "wildcard matches IPv6",
srcs: []string{"*"},
dsts: []string{"*"},
wantSrc: netip.MustParseAddr("2001:db8::1"),
wantDst: netip.MustParseAddr("fd7a:115c:a1e0::1"),
srcIn: true,
dstIn: true,
},
{
name: "single IP source",
srcs: []string{"100.64.0.1"},
dsts: []string{"10.0.0.0/8"},
wantSrc: netip.MustParseAddr("100.64.0.1"),
wantDst: netip.MustParseAddr("10.33.0.1"),
srcIn: true,
dstIn: true,
},
{
name: "single IP source no match",
srcs: []string{"100.64.0.1"},
dsts: []string{"10.0.0.0/8"},
wantSrc: netip.MustParseAddr("100.64.0.2"),
wantDst: netip.MustParseAddr("10.33.0.1"),
srcIn: false,
dstIn: true,
},
{
name: "multiple CIDRs",
srcs: []string{"10.0.0.0/8", "172.16.0.0/12"},
dsts: []string{"192.168.0.0/16", "100.64.0.0/10"},
wantSrc: netip.MustParseAddr("172.20.0.1"),
wantDst: netip.MustParseAddr("100.100.0.1"),
srcIn: true,
dstIn: true,
},
{
name: "IPv6 CIDR",
srcs: []string{"fd7a:115c:a1e0::/48"},
dsts: []string{"2001:db8::/32"},
wantSrc: netip.MustParseAddr("fd7a:115c:a1e0::1"),
wantDst: netip.MustParseAddr("2001:db8::1"),
srcIn: true,
dstIn: true,
},
{
name: "empty sources and destinations",
srcs: []string{},
dsts: []string{},
wantSrc: netip.MustParseAddr("10.0.0.1"),
wantDst: netip.MustParseAddr("10.0.0.1"),
srcIn: false,
dstIn: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
m := MatchFromStrings(tt.srcs, tt.dsts)
assert.Equal(t, tt.srcIn, m.SrcsContainsIPs(tt.wantSrc),
"SrcsContainsIPs(%s)", tt.wantSrc)
assert.Equal(t, tt.dstIn, m.DestsContainsIP(tt.wantDst),
"DestsContainsIP(%s)", tt.wantDst)
})
}
}
func TestMatchFromFilterRule(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rule tailcfg.FilterRule
checkSrc netip.Addr
checkDst netip.Addr
srcMatch bool
dstMatch bool
}{
{
name: "standard rule with port range",
rule: tailcfg.FilterRule{
SrcIPs: []string{"100.64.0.1", "fd7a:115c:a1e0::1"},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 0, Last: 65535}},
},
},
checkSrc: netip.MustParseAddr("100.64.0.1"),
checkDst: netip.MustParseAddr("10.33.0.50"),
srcMatch: true,
dstMatch: true,
},
{
name: "wildcard destination",
rule: tailcfg.FilterRule{
SrcIPs: []string{"10.0.0.0/8"},
DstPorts: []tailcfg.NetPortRange{
{IP: "*"},
},
},
checkSrc: netip.MustParseAddr("10.1.1.1"),
checkDst: netip.MustParseAddr("8.8.8.8"),
srcMatch: true,
dstMatch: true,
},
{
name: "multiple DstPorts entries",
rule: tailcfg.FilterRule{
SrcIPs: []string{"100.64.0.1"},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16"},
{IP: "192.168.1.0/24"},
},
},
checkSrc: netip.MustParseAddr("100.64.0.1"),
checkDst: netip.MustParseAddr("192.168.1.50"),
srcMatch: true,
dstMatch: true,
},
{
name: "empty DstPorts",
rule: tailcfg.FilterRule{
SrcIPs: []string{"100.64.0.1"},
DstPorts: nil,
},
checkSrc: netip.MustParseAddr("100.64.0.1"),
checkDst: netip.MustParseAddr("10.0.0.1"),
srcMatch: true,
dstMatch: false,
},
{
// Regression: cap-grant-only rules (e.g. cap/relay)
// carry their destinations in CapGrant.Dsts. The
// matcher must surface those for peer-visibility
// derivation. https://github.com/juanfont/headscale/issues/3256
name: "CapGrant Dsts populate destination set",
rule: tailcfg.FilterRule{
SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32"},
CapGrant: []tailcfg.CapGrant{
{
Dsts: []netip.Prefix{
netip.MustParsePrefix("100.64.0.3/32"),
},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityRelay: nil,
},
},
},
},
checkSrc: netip.MustParseAddr("100.64.0.1"),
checkDst: netip.MustParseAddr("100.64.0.3"),
srcMatch: true,
dstMatch: true,
},
{
// Companion cap-grant shape produced by
// companionCapGrantRules: SrcIPs are the original
// destinations, CapGrant.Dsts are the original sources.
name: "companion CapGrant Dsts populate destination set",
rule: tailcfg.FilterRule{
SrcIPs: []string{"100.64.0.3/32"},
CapGrant: []tailcfg.CapGrant{
{
Dsts: []netip.Prefix{
netip.MustParsePrefix("100.64.0.1/32"),
netip.MustParsePrefix("100.64.0.2/32"),
},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityRelayTarget: nil,
},
},
},
},
checkSrc: netip.MustParseAddr("100.64.0.3"),
checkDst: netip.MustParseAddr("100.64.0.2"),
srcMatch: true,
dstMatch: true,
},
{
// Mixed rule: DstPorts and CapGrant both contribute to dests.
name: "DstPorts and CapGrant Dsts both contribute",
rule: tailcfg.FilterRule{
SrcIPs: []string{"100.64.0.1/32"},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8"},
},
CapGrant: []tailcfg.CapGrant{
{
Dsts: []netip.Prefix{
netip.MustParsePrefix("100.64.0.3/32"),
},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityRelay: nil,
},
},
},
},
checkSrc: netip.MustParseAddr("100.64.0.1"),
checkDst: netip.MustParseAddr("100.64.0.3"),
srcMatch: true,
dstMatch: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
m := MatchFromFilterRule(tt.rule)
assert.Equal(t, tt.srcMatch, m.SrcsContainsIPs(tt.checkSrc),
"SrcsContainsIPs(%s)", tt.checkSrc)
assert.Equal(t, tt.dstMatch, m.DestsContainsIP(tt.checkDst),
"DestsContainsIP(%s)", tt.checkDst)
})
}
}
func TestMatchesFromFilterRules(t *testing.T) {
t.Parallel()
rules := []tailcfg.FilterRule{
{
SrcIPs: []string{"10.0.0.0/8"},
DstPorts: []tailcfg.NetPortRange{{IP: "192.168.1.0/24"}},
},
{
SrcIPs: []string{"172.16.0.0/12"},
DstPorts: []tailcfg.NetPortRange{{IP: "10.33.0.0/16"}},
},
}
matches := MatchesFromFilterRules(rules)
require.Len(t, matches, 2)
// First matcher: 10.0.0.0/8 -> 192.168.1.0/24
assert.True(t, matches[0].SrcsContainsIPs(netip.MustParseAddr("10.1.2.3")))
assert.False(t, matches[0].SrcsContainsIPs(netip.MustParseAddr("172.16.0.1")))
assert.True(t, matches[0].DestsContainsIP(netip.MustParseAddr("192.168.1.100")))
// Second matcher: 172.16.0.0/12 -> 10.33.0.0/16
assert.True(t, matches[1].SrcsContainsIPs(netip.MustParseAddr("172.20.0.1")))
assert.True(t, matches[1].DestsContainsIP(netip.MustParseAddr("10.33.0.1")))
assert.False(t, matches[1].DestsContainsIP(netip.MustParseAddr("192.168.1.1")))
}
func TestSrcsOverlapsPrefixes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
srcs []string
prefixes []netip.Prefix
want bool
}{
{
name: "exact match",
srcs: []string{"10.33.0.0/16"},
prefixes: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
want: true,
},
{
name: "parent contains child",
srcs: []string{"10.0.0.0/8"},
prefixes: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
want: true,
},
{
name: "child overlaps parent",
srcs: []string{"10.33.0.0/16"},
prefixes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
want: true,
},
{
name: "no overlap",
srcs: []string{"10.0.0.0/8"},
prefixes: []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")},
want: false,
},
{
name: "multiple prefixes one overlaps",
srcs: []string{"10.0.0.0/8"},
prefixes: []netip.Prefix{
netip.MustParsePrefix("192.168.1.0/24"),
netip.MustParsePrefix("10.33.0.0/16"),
},
want: true,
},
{
name: "IPv6 overlap",
srcs: []string{"fd7a:115c:a1e0::/48"},
prefixes: []netip.Prefix{netip.MustParsePrefix("fd7a:115c:a1e0:ab12::/64")},
want: true,
},
{
name: "empty prefixes",
srcs: []string{"10.0.0.0/8"},
prefixes: []netip.Prefix{},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
m := MatchFromStrings(tt.srcs, nil)
got := m.SrcsOverlapsPrefixes(tt.prefixes...)
assert.Equal(t, tt.want, got)
})
}
}
func TestDestsOverlapsPrefixes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
dsts []string
prefixes []netip.Prefix
want bool
}{
{
name: "exact match",
dsts: []string{"10.33.0.0/16"},
prefixes: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
want: true,
},
{
name: "parent contains child",
dsts: []string{"10.0.0.0/8"},
prefixes: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
want: true,
},
{
name: "no overlap",
dsts: []string{"10.0.0.0/8"},
prefixes: []netip.Prefix{netip.MustParsePrefix("192.168.0.0/16")},
want: false,
},
{
name: "wildcard overlaps everything",
dsts: []string{"*"},
prefixes: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
},
want: true,
},
{
name: "wildcard overlaps exit route",
dsts: []string{"*"},
prefixes: []netip.Prefix{netip.MustParsePrefix("::/0")},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
m := MatchFromStrings(nil, tt.dsts)
got := m.DestsOverlapsPrefixes(tt.prefixes...)
assert.Equal(t, tt.want, got)
})
}
}
func TestDestsIsTheInternet(t *testing.T) {
t.Parallel()
tests := []struct {
name string
dsts []string
want bool
}{
{
name: "all IPv4 is the internet",
dsts: []string{"0.0.0.0/0"},
want: true,
},
{
name: "all IPv6 is the internet",
dsts: []string{"::/0"},
want: true,
},
{
name: "wildcard is the internet",
dsts: []string{"*"},
want: true,
},
{
name: "private range is not the internet",
dsts: []string{"10.0.0.0/8"},
want: false,
},
{
name: "CGNAT range is not the internet",
dsts: []string{"100.64.0.0/10"},
want: false,
},
{
name: "single public IP is not the internet",
dsts: []string{"8.8.8.8"},
want: false,
},
{
name: "empty dests is not the internet",
dsts: []string{},
want: false,
},
{
name: "multiple private ranges are not the internet",
dsts: []string{
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
},
want: false,
},
{
name: "all IPv4 combined with subnet is the internet",
dsts: []string{"0.0.0.0/0", "10.33.0.0/16"},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
m := MatchFromStrings(nil, tt.dsts)
got := m.DestsIsTheInternet()
assert.Equal(t, tt.want, got,
"DestsIsTheInternet() for dsts=%v", tt.dsts)
})
}
}
func TestDebugString(t *testing.T) {
t.Parallel()
m := MatchFromStrings(
[]string{"10.0.0.0/8"},
[]string{"192.168.1.0/24"},
)
s := m.DebugString()
assert.Contains(t, s, "Match:")
assert.Contains(t, s, "Sources:")
assert.Contains(t, s, "Destinations:")
assert.Contains(t, s, "10.0.0.0/8")
assert.Contains(t, s, "192.168.1.0/24")
// Sources appear before Destinations in the output.
assert.Less(
t,
strings.Index(s, "Sources:"),
strings.Index(s, "Destinations:"),
"Sources section must precede Destinations",
)
}
func TestDebugString_Empty(t *testing.T) {
t.Parallel()
m := MatchFromStrings(nil, nil)
s := m.DebugString()
assert.Contains(t, s, "Match:")
assert.Contains(t, s, "Sources:")
assert.Contains(t, s, "Destinations:")
assert.NotContains(t, s, "/")
}
// TestMatchFromStrings_MalformedFailsOpen asserts that unparseable
// entries are silently dropped and do not crash or widen the Match.
func TestMatchFromStrings_MalformedFailsOpen(t *testing.T) {
t.Parallel()
m := MatchFromStrings(
[]string{"not-a-cidr", "10.0.0.0/8"},
[]string{"also-bogus", "192.168.1.0/24"},
)
assert.True(t, m.SrcsContainsIPs(netip.MustParseAddr("10.1.2.3")),
"valid src entry must still match")
assert.False(t, m.SrcsContainsIPs(netip.MustParseAddr("1.1.1.1")),
"malformed src entry must not widen the set")
assert.True(t, m.DestsContainsIP(netip.MustParseAddr("192.168.1.10")),
"valid dst entry must still match")
assert.False(t, m.DestsContainsIP(netip.MustParseAddr("8.8.8.8")),
"malformed dst entry must not widen the set")
}