servertest: compare filter and SSH rules in expiry replays

Changed-or-not alone let empty rules pass for SaaS's new ones.
This commit is contained in:
Kristoffer Dalby committed 2026-10-30 14:55:35 +00:00
1 parent 80cf7d5c0e
commit 0169c6576b
2 files changed
+194 -59

No files matched your search

+122 -7
View File
@@ -13,6 +13,7 @@ import (
"fmt"
"maps"
"net/http"
"net/netip"
"slices"
"strings"
"testing"
@@ -193,7 +194,7 @@ func runNodeExpiryCompat(t *testing.T, id, observer string) {
r.apply(t, step)
e := &expiryStep{
r: r, c: c, step: step, at: stepCaptureTime(step),
r: r, c: c, step: step, at: stepCaptureTime(step), saasNames: r.saasAddrNames(step),
prevCaps: prevCaps, start: start, historyStart: historyStart,
}
@@ -205,7 +206,7 @@ func runNodeExpiryCompat(t *testing.T, id, observer string) {
continue
}
checks = append(checks, e.selfChecks(x)...)
checks = append(checks, e.selfChecks(t, x)...)
for _, v := range captured {
if v != x && step.Captures[v].Netmap != nil && r.clients[v] != nil {
@@ -254,6 +255,10 @@ type expiryStep struct {
// at is when SaaS's captures were taken.
at time.Time
// saasNames names the capture's nodes the step's netmaps show by their
// SaaS addresses.
saasNames map[netip.Addr]string
// prevCaps holds SaaS's captures of the step before; start and
// historyStart headscale's netmaps when the step began.
prevCaps map[string]testcapture.Node
@@ -261,17 +266,31 @@ type expiryStep struct {
historyStart map[string]int
}
// selfChecks compares x's own view: its key expiry flags and its peers among
// the capture's nodes and, when the step before captured x too, whether it
// lost any during the step and whether its DNS, packet filter and SSH policy
// changed.
func (e *expiryStep) selfChecks(x string) []stepCheck {
// selfChecks compares x's own view: its key expiry flags, its peers among
// the capture's nodes, its packet filter and SSH rules and, when the step
// before captured x too, whether it lost any peer during the step and
// whether its DNS, packet filter and SSH policy changed.
func (e *expiryStep) selfChecks(t *testing.T, x string) []stepCheck {
t.Helper()
saas := e.step.Captures[x].Netmap
cl := e.r.clients[x]
checks := []stepCheck{
{x + "/self", renderSelfExpiry(saas, e.at), func() string { return renderSelfExpiry(cl.Netmap(), time.Now()) }},
{x + "/peers", peerAliases(saas, e.r.saasAlias), func() string { return peerAliases(cl.Netmap(), e.r.hsAlias) }},
{x + "/filter-rules", strings.Join(saasFilterTriples(t, e.step.Captures[x].PacketFilterRules, e.saasNames), "\n"), func() string {
nm := cl.Netmap()
if nm == nil {
return "no netmap"
}
// Polled outside the test's own flow, so failures become text.
var errs errText
return strings.Join(append(hsFilterTriples(&errs, nm.PacketFilter, e.r.addrNames()), errs...), "\n")
}},
{x + "/ssh-rules", renderSSHRules(saas, e.saasNames), func() string { return renderSSHRules(cl.Netmap(), e.r.addrNames()) }},
}
prev := e.prevCaps[x].Netmap
@@ -594,6 +613,102 @@ func discoState(before, after string) string {
}
}
// saasAddrNames names by alias each SaaS address of the capture's nodes that
// a netmap of step shows, itself or as a peer: the devices then present.
func (r *stepReplay) saasAddrNames(step testcapture.Step) map[netip.Addr]string {
out := map[netip.Addr]string{}
add := func(n tailcfg.NodeView) {
if alias, ok := r.saasAlias(n.ID()); ok {
for _, p := range n.Addresses().All() {
out[p.Addr()] = alias
}
}
}
for _, node := range step.Captures {
if nm := node.Netmap; nm != nil {
add(nm.SelfNode)
for _, p := range nm.Peers {
add(p)
}
}
}
return out
}
// errText is a [require.TestingT] that keeps failures as text.
type errText []string
func (e *errText) Errorf(format string, args ...any) { *e = append(*e, fmt.Sprintf(format, args...)) }
func (e *errText) FailNow() {}
// renderSSHRules gives a node's SSH rules in order: principals by name and
// address family, local user mappings and the action. Addresses of nodes
// outside the capture are left out, and so is a rule left with no
// principal. SaaS's login "0" to no user mapping is left out too:
// TestSSHDataCompat checks it apart.
func renderSSHRules(nm *netmap.NetworkMap, names map[netip.Addr]string) string {
if nm == nil {
return "no netmap"
}
if nm.SSHPolicy == nil {
return "none"
}
var out []string
for _, rule := range nm.SSHPolicy.Rules {
var who []string
for _, p := range rule.Principals {
addr, err := netip.ParseAddr(p.NodeIP)
switch {
case err != nil:
who = append(who, fmt.Sprintf("%+v", *p))
case names[addr] != "":
family := "/ipv6"
if addr.Is4() {
family = "/ipv4"
}
who = append(who, names[addr]+family)
case !prefixInTailscaleRange(netip.PrefixFrom(addr, addr.BitLen())):
who = append(who, p.NodeIP)
}
}
if len(who) == 0 {
continue
}
users := make([]string, 0, len(rule.SSHUsers))
for from, to := range rule.SSHUsers {
if from != "0" || to != "" {
users = append(users, from+"="+to)
}
}
action := "no action"
if a := rule.Action; a != nil {
action = fmt.Sprintf("accept %t, reject %t, check %t", a.Accept, a.Reject, a.HoldAndDelegate != "")
}
out = append(out, fmt.Sprintf("%s as %s: %s", sortedStrings(who), sortedStrings(users), action))
}
if len(out) == 0 {
return "none"
}
return strings.Join(out, "\n")
}
func changed(before, after string) string {
if before == after {
return "unchanged"
+72 -52
View File
@@ -31,6 +31,7 @@ import (
"tailscale.com/tailcfg/peercap"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/wgengine/filter"
)
// viaCompatTests lists golden captures that exercise via grant steering.
@@ -1087,58 +1088,8 @@ func comparePacketFilter(
h.Helper()
}
hsAddrs := hsAddrsByPeer(clients)
wantTriples := map[string]struct{}{}
for i, rule := range want.PacketFilterRules {
srcs := strings.Join(canonicaliseSrcStrings(t, rule.SrcIPs, saasAddrs, i), ",")
for _, dp := range rule.DstPorts {
pfxs, err := parseDstPrefixes(dp.IP)
require.NoErrorf(t, err,
"golden DstPorts[%d].IP %q should parse as prefix, addr or range", i, dp.IP)
for _, pfx := range pfxs {
for _, dst := range peerIdents(t, pfx, saasAddrs, i) {
wantTriples[filterTriple(srcs, dst, dp.Ports.First, dp.Ports.Last)] = struct{}{}
}
}
}
for _, cg := range rule.CapGrant {
caps := slices.Collect(maps.Keys(cg.CapMap))
caps = append(caps, cg.Caps...)
for _, pfx := range cg.Dsts {
for _, dst := range peerIdents(t, pfx, saasAddrs, i) {
for _, c := range caps {
wantTriples[capTriple(srcs, dst, c)] = struct{}{}
}
}
}
}
}
gotTriples := map[string]struct{}{}
for i, match := range got.PacketFilter {
srcs := strings.Join(canonicaliseSrcPrefixes(t, match.Srcs, hsAddrs, i), ",")
for _, dp := range match.Dsts {
for _, dst := range peerIdents(t, dp.Net, hsAddrs, i) {
gotTriples[filterTriple(srcs, dst, dp.Ports.First, dp.Ports.Last)] = struct{}{}
}
}
for _, cm := range match.Caps {
for _, dst := range peerIdents(t, cm.Dst, hsAddrs, i) {
gotTriples[capTriple(srcs, dst, cm.Cap)] = struct{}{}
}
}
}
wantList, gotList := sortedKeys(wantTriples), sortedKeys(gotTriples)
wantList := saasFilterTriples(t, want.PacketFilterRules, saasAddrs)
gotList := hsFilterTriples(t, got.PacketFilter, hsAddrsByPeer(clients))
if reason, listed := skips[key]; listed {
if slices.Equal(wantList, gotList) {
@@ -1152,6 +1103,75 @@ func comparePacketFilter(
"PacketFilter (source, destination, ports) mismatch")
}
// saasFilterTriples renders captured PacketFilter rules as sorted (source,
// destination, ports or capability) triples, peers named by saasAddrs.
func saasFilterTriples(t require.TestingT, rules []tailcfg.FilterRule, saasAddrs map[netip.Addr]string) []string {
if h, ok := t.(interface{ Helper() }); ok {
h.Helper()
}
triples := map[string]struct{}{}
for i, rule := range rules {
srcs := strings.Join(canonicaliseSrcStrings(t, rule.SrcIPs, saasAddrs, i), ",")
for _, dp := range rule.DstPorts {
pfxs, err := parseDstPrefixes(dp.IP)
require.NoErrorf(t, err,
"golden DstPorts[%d].IP %q should parse as prefix, addr or range", i, dp.IP)
for _, pfx := range pfxs {
for _, dst := range peerIdents(t, pfx, saasAddrs, i) {
triples[filterTriple(srcs, dst, dp.Ports.First, dp.Ports.Last)] = struct{}{}
}
}
}
for _, cg := range rule.CapGrant {
caps := slices.Collect(maps.Keys(cg.CapMap))
caps = append(caps, cg.Caps...)
for _, pfx := range cg.Dsts {
for _, dst := range peerIdents(t, pfx, saasAddrs, i) {
for _, c := range caps {
triples[capTriple(srcs, dst, c)] = struct{}{}
}
}
}
}
}
return sortedKeys(triples)
}
// hsFilterTriples renders a client's packet filter as the triples
// [saasFilterTriples] gives, peers named by hsAddrs.
func hsFilterTriples(t require.TestingT, matches []filter.Match, hsAddrs map[netip.Addr]string) []string {
if h, ok := t.(interface{ Helper() }); ok {
h.Helper()
}
triples := map[string]struct{}{}
for i, match := range matches {
srcs := strings.Join(canonicaliseSrcPrefixes(t, match.Srcs, hsAddrs, i), ",")
for _, dp := range match.Dsts {
for _, dst := range peerIdents(t, dp.Net, hsAddrs, i) {
triples[filterTriple(srcs, dst, dp.Ports.First, dp.Ports.Last)] = struct{}{}
}
}
for _, cm := range match.Caps {
for _, dst := range peerIdents(t, cm.Dst, hsAddrs, i) {
triples[capTriple(srcs, dst, cm.Cap)] = struct{}{}
}
}
}
return sortedKeys(triples)
}
func filterTriple(srcs, dst string, first, last uint16) string {
return fmt.Sprintf("%s => %s:%d-%d", srcs, dst, first, last)
}