mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-15 13:29:54 +02:00
servertest: add regression tests for via grant filter rules
Add three tests that verify control plane behavior for grant policies: - TestGrantViaSubnetFilterRules: verifies the router's PacketFilter contains destination rules for via-steered subnets. Without per-node filter compilation for via grants, these rules were missing and the router would drop forwarded traffic. - TestGrantViaExitNodeFilterRules: same verification for exit nodes with via grants steering autogroup:internet traffic. - TestGrantIPv6OnlyPrefixACL: verifies that address-based aliases (Prefix, Host) resolve to exactly the literal prefix and do not expand to include the matching node's other IP addresses. An IPv6-only host definition produces only IPv6 filter rules. Updates #2180
This commit is contained in:
@@ -656,6 +656,241 @@ func TestGrantPolicies(t *testing.T) { //nolint:gocyclo
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGrantViaSubnetFilterRules verifies that routers with via grants
|
||||||
|
// receive PacketFilter rules that allow the steered subnet traffic.
|
||||||
|
// This is a regression test: without per-node filter compilation for
|
||||||
|
// via grants, the router's PacketFilter would lack rules for the
|
||||||
|
// via-steered subnet destinations, causing traffic to be dropped.
|
||||||
|
func TestGrantViaSubnetFilterRules(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := servertest.NewServer(t)
|
||||||
|
routerUser := srv.CreateUser(t, "rt-user")
|
||||||
|
clientUser := srv.CreateUser(t, "cl-user")
|
||||||
|
|
||||||
|
route := netip.MustParsePrefix("10.0.0.0/24")
|
||||||
|
|
||||||
|
changed, err := srv.State().SetPolicy([]byte(`{
|
||||||
|
"tagOwners": {
|
||||||
|
"tag:router-a": ["rt-user@"],
|
||||||
|
"tag:group-a": ["cl-user@"]
|
||||||
|
},
|
||||||
|
"grants": [
|
||||||
|
{
|
||||||
|
"src": ["tag:router-a", "tag:group-a"],
|
||||||
|
"dst": ["tag:router-a", "tag:group-a"],
|
||||||
|
"ip": ["*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": ["tag:group-a"],
|
||||||
|
"dst": ["10.0.0.0/24"],
|
||||||
|
"ip": ["*"],
|
||||||
|
"via": ["tag:router-a"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"autoApprovers": {
|
||||||
|
"routes": {
|
||||||
|
"10.0.0.0/24": ["tag:router-a"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
changes, err := srv.State().ReloadPolicy()
|
||||||
|
require.NoError(t, err)
|
||||||
|
srv.App.Change(changes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
routerA := servertest.NewClient(t, srv, "router-a",
|
||||||
|
servertest.WithUser(routerUser),
|
||||||
|
servertest.WithTags("tag:router-a"))
|
||||||
|
clientA := servertest.NewClient(t, srv, "client-a",
|
||||||
|
servertest.WithUser(clientUser),
|
||||||
|
servertest.WithTags("tag:group-a"))
|
||||||
|
|
||||||
|
routerA.WaitForPeers(t, 1, 15*time.Second)
|
||||||
|
clientA.WaitForPeers(t, 1, 15*time.Second)
|
||||||
|
|
||||||
|
// Advertise and approve route on router.
|
||||||
|
routerA.Direct().SetHostinfo(&tailcfg.Hostinfo{
|
||||||
|
BackendLogID: "servertest-router-a",
|
||||||
|
Hostname: "router-a",
|
||||||
|
RoutableIPs: []netip.Prefix{route},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_ = routerA.Direct().SendUpdate(ctx)
|
||||||
|
|
||||||
|
routerAID := findNodeID(t, srv, "router-a")
|
||||||
|
_, routeChange, err := srv.State().SetApprovedRoutes(
|
||||||
|
routerAID, []netip.Prefix{route})
|
||||||
|
require.NoError(t, err)
|
||||||
|
srv.App.Change(routeChange)
|
||||||
|
|
||||||
|
// Wait for clientA to see the route in AllowedIPs.
|
||||||
|
clientA.WaitForCondition(t, "clientA sees route via router-a",
|
||||||
|
15*time.Second,
|
||||||
|
func(nm *netmap.NetworkMap) bool {
|
||||||
|
for _, p := range nm.Peers {
|
||||||
|
hi := p.Hostinfo()
|
||||||
|
if hi.Valid() && hi.Hostname() == "router-a" {
|
||||||
|
for i := range p.AllowedIPs().Len() {
|
||||||
|
if p.AllowedIPs().At(i) == route {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Critical: the router's PacketFilter MUST contain rules with
|
||||||
|
// the via-steered subnet (10.0.0.0/24) as a destination.
|
||||||
|
// Without this, the router drops traffic forwarded through it.
|
||||||
|
routerNM := routerA.Netmap()
|
||||||
|
require.NotNil(t, routerNM)
|
||||||
|
require.NotNil(t, routerNM.PacketFilter,
|
||||||
|
"router PacketFilter should not be nil")
|
||||||
|
|
||||||
|
var foundSubnetDst bool
|
||||||
|
|
||||||
|
for _, m := range routerNM.PacketFilter {
|
||||||
|
for _, dst := range m.Dsts {
|
||||||
|
dstPrefix := netip.PrefixFrom(dst.Net.Addr(), dst.Net.Bits())
|
||||||
|
if route.Contains(dstPrefix.Addr()) && dstPrefix.Bits() >= route.Bits() {
|
||||||
|
foundSubnetDst = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, foundSubnetDst,
|
||||||
|
"router PacketFilter should contain destination rules for via-steered subnet 10.0.0.0/24; "+
|
||||||
|
"without per-node filter compilation for via grants, these rules are missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGrantViaExitNodeFilterRules verifies that exit nodes with via grants
|
||||||
|
// receive PacketFilter rules for exit traffic (0.0.0.0/0, ::/0).
|
||||||
|
func TestGrantViaExitNodeFilterRules(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := servertest.NewServer(t)
|
||||||
|
exitUser := srv.CreateUser(t, "exit-user")
|
||||||
|
clientUser := srv.CreateUser(t, "cl-user")
|
||||||
|
|
||||||
|
exitRouteV4 := netip.MustParsePrefix("0.0.0.0/0")
|
||||||
|
exitRouteV6 := netip.MustParsePrefix("::/0")
|
||||||
|
|
||||||
|
changed, err := srv.State().SetPolicy([]byte(`{
|
||||||
|
"tagOwners": {
|
||||||
|
"tag:exit-a": ["exit-user@"],
|
||||||
|
"tag:group-a": ["cl-user@"]
|
||||||
|
},
|
||||||
|
"grants": [
|
||||||
|
{
|
||||||
|
"src": ["tag:exit-a", "tag:group-a"],
|
||||||
|
"dst": ["tag:exit-a", "tag:group-a"],
|
||||||
|
"ip": ["*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": ["tag:group-a"],
|
||||||
|
"dst": ["autogroup:internet"],
|
||||||
|
"ip": ["*"],
|
||||||
|
"via": ["tag:exit-a"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"autoApprovers": {
|
||||||
|
"exitNode": ["tag:exit-a"]
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
changes, err := srv.State().ReloadPolicy()
|
||||||
|
require.NoError(t, err)
|
||||||
|
srv.App.Change(changes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
exitA := servertest.NewClient(t, srv, "exit-a",
|
||||||
|
servertest.WithUser(exitUser),
|
||||||
|
servertest.WithTags("tag:exit-a"))
|
||||||
|
clientA := servertest.NewClient(t, srv, "client-a",
|
||||||
|
servertest.WithUser(clientUser),
|
||||||
|
servertest.WithTags("tag:group-a"))
|
||||||
|
|
||||||
|
exitA.WaitForPeers(t, 1, 15*time.Second)
|
||||||
|
clientA.WaitForPeers(t, 1, 15*time.Second)
|
||||||
|
|
||||||
|
// Advertise and approve exit routes.
|
||||||
|
exitA.Direct().SetHostinfo(&tailcfg.Hostinfo{
|
||||||
|
BackendLogID: "servertest-exit-a",
|
||||||
|
Hostname: "exit-a",
|
||||||
|
RoutableIPs: []netip.Prefix{exitRouteV4, exitRouteV6},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_ = exitA.Direct().SendUpdate(ctx)
|
||||||
|
|
||||||
|
exitAID := findNodeID(t, srv, "exit-a")
|
||||||
|
_, routeChange, err := srv.State().SetApprovedRoutes(
|
||||||
|
exitAID, []netip.Prefix{exitRouteV4, exitRouteV6})
|
||||||
|
require.NoError(t, err)
|
||||||
|
srv.App.Change(routeChange)
|
||||||
|
|
||||||
|
// Wait for clientA to see the exit routes in AllowedIPs.
|
||||||
|
clientA.WaitForCondition(t, "clientA sees exit routes via exit-a",
|
||||||
|
15*time.Second,
|
||||||
|
func(nm *netmap.NetworkMap) bool {
|
||||||
|
for _, p := range nm.Peers {
|
||||||
|
hi := p.Hostinfo()
|
||||||
|
if hi.Valid() && hi.Hostname() == "exit-a" {
|
||||||
|
for i := range p.AllowedIPs().Len() {
|
||||||
|
if p.AllowedIPs().At(i) == exitRouteV4 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Critical: exit node's PacketFilter must contain rules for
|
||||||
|
// exit traffic (0.0.0.0/0 or ::/0) from the via grant.
|
||||||
|
exitNM := exitA.Netmap()
|
||||||
|
require.NotNil(t, exitNM)
|
||||||
|
require.NotNil(t, exitNM.PacketFilter,
|
||||||
|
"exit node PacketFilter should not be nil")
|
||||||
|
|
||||||
|
var foundExitDst bool
|
||||||
|
|
||||||
|
for _, m := range exitNM.PacketFilter {
|
||||||
|
for _, dst := range m.Dsts {
|
||||||
|
dstPrefix := netip.PrefixFrom(dst.Net.Addr(), dst.Net.Bits())
|
||||||
|
if dstPrefix == exitRouteV4 || dstPrefix == exitRouteV6 {
|
||||||
|
foundExitDst = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, foundExitDst,
|
||||||
|
"exit node PacketFilter should contain destination rules for exit routes (0.0.0.0/0 or ::/0); "+
|
||||||
|
"via grant filter rules for exit traffic are missing")
|
||||||
|
|
||||||
|
// Log the actual PacketFilter for debugging.
|
||||||
|
if !foundExitDst {
|
||||||
|
for i, m := range exitNM.PacketFilter {
|
||||||
|
t.Logf("PacketFilter[%d]: Srcs=%v, Dsts=%v, Caps=%d",
|
||||||
|
i, m.Srcs, m.Dsts, len(m.Caps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// hasCapMatches returns true if any Match in the slice contains a
|
// hasCapMatches returns true if any Match in the slice contains a
|
||||||
// non-empty Caps (CapMatch) list.
|
// non-empty Caps (CapMatch) list.
|
||||||
func hasCapMatches(matches []filtertype.Match) bool {
|
func hasCapMatches(matches []filtertype.Match) bool {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package servertest_test
|
package servertest_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -149,3 +150,87 @@ func TestPolicyChanges(t *testing.T) {
|
|||||||
[]*servertest.TestClient{c1, c2, c3})
|
[]*servertest.TestClient{c1, c2, c3})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIPv6OnlyPrefixACL verifies that an ACL using only IPv6 prefixes
|
||||||
|
// correctly generates filter rules for IPv6 traffic. Address-based aliases
|
||||||
|
// (Prefix, Host) resolve to exactly the literal prefix and do NOT expand
|
||||||
|
// to include the matching node's other IP addresses.
|
||||||
|
//
|
||||||
|
// PacketFilter rules are INBOUND: they tell the destination node what
|
||||||
|
// traffic to accept. So the IPv6 destination rule appears in test2's
|
||||||
|
// PacketFilter (the destination), not test1's (the source).
|
||||||
|
func TestIPv6OnlyPrefixACL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := servertest.NewServer(t)
|
||||||
|
user := srv.CreateUser(t, "ipv6-user")
|
||||||
|
|
||||||
|
// Set a policy that only uses IPv6 prefixes.
|
||||||
|
changed, err := srv.State().SetPolicy([]byte(`{
|
||||||
|
"hosts": {
|
||||||
|
"test1": "fd7a:115c:a1e0::1/128",
|
||||||
|
"test2": "fd7a:115c:a1e0::2/128"
|
||||||
|
},
|
||||||
|
"acls": [{
|
||||||
|
"action": "accept",
|
||||||
|
"src": ["test1"],
|
||||||
|
"dst": ["test2:*"]
|
||||||
|
}]
|
||||||
|
}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
changes, err := srv.State().ReloadPolicy()
|
||||||
|
require.NoError(t, err)
|
||||||
|
srv.App.Change(changes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
c1 := servertest.NewClient(t, srv, "test1",
|
||||||
|
servertest.WithUser(user))
|
||||||
|
c2 := servertest.NewClient(t, srv, "test2",
|
||||||
|
servertest.WithUser(user))
|
||||||
|
|
||||||
|
c1.WaitForPeers(t, 1, 10*time.Second)
|
||||||
|
c2.WaitForPeers(t, 1, 10*time.Second)
|
||||||
|
|
||||||
|
// PacketFilter is an INBOUND filter: test2 (the destination) should
|
||||||
|
// have the rule allowing traffic FROM test1's IPv6.
|
||||||
|
nm2 := c2.Netmap()
|
||||||
|
require.NotNil(t, nm2)
|
||||||
|
require.NotNil(t, nm2.PacketFilter,
|
||||||
|
"c2 PacketFilter should not be nil with IPv6-only policy")
|
||||||
|
|
||||||
|
// Verify that IPv6 destination is present in the filter rules on test2.
|
||||||
|
var foundIPv6Dst bool
|
||||||
|
|
||||||
|
expectedDst := netip.MustParseAddr("fd7a:115c:a1e0::2")
|
||||||
|
|
||||||
|
for _, m := range nm2.PacketFilter {
|
||||||
|
for _, dst := range m.Dsts {
|
||||||
|
if dst.Net.Addr() == expectedDst {
|
||||||
|
foundIPv6Dst = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, foundIPv6Dst,
|
||||||
|
"test2 PacketFilter should contain IPv6 destination fd7a:115c:a1e0::2 from IPv6-only host definition")
|
||||||
|
|
||||||
|
// With the current resolve behavior, the filter should NOT contain
|
||||||
|
// the corresponding IPv4 address as a destination, because
|
||||||
|
// address-based aliases resolve to exactly the literal prefix.
|
||||||
|
var foundIPv4Dst bool
|
||||||
|
|
||||||
|
ipv4Dst := netip.MustParseAddr("100.64.0.2")
|
||||||
|
|
||||||
|
for _, m := range nm2.PacketFilter {
|
||||||
|
for _, dst := range m.Dsts {
|
||||||
|
if dst.Net.Addr() == ipv4Dst {
|
||||||
|
foundIPv4Dst = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.False(t, foundIPv4Dst,
|
||||||
|
"test2 PacketFilter should NOT contain IPv4 destination 100.64.0.2 when policy only specifies IPv6 hosts")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user