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
545 lines
13 KiB
Go
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")
|
|
}
|