Files
fancy-dns/internal/acl/acl_test.go
T
mats 66f36cfac6
build / build-push (push) Successful in 1m11s
more stuff
2026-10-16 14:49:50 +02:00

1074 lines
40 KiB
Go

package acl
import (
"fmt"
"net/netip"
"slices"
"sync"
"testing"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// grp builds a config.Group the way the YAML loader would, so every test
// doubles as a check that the config shape is usable as-is.
func grp(name, policy string, m config.Match) config.Group {
return config.Group{Name: name, Policy: policy, Match: m}
}
func mustAddr(t testing.TB, s string) netip.Addr {
t.Helper()
a, err := netip.ParseAddr(s)
if err != nil {
t.Fatalf("ParseAddr(%q): %v", s, err)
}
return a
}
// byIP builds a client that is known only by address.
func byIP(t testing.TB, s string) Client {
t.Helper()
return Client{IP: mustAddr(t, s)}
}
// checkDecision compares every field of a decision, so a group mix-up (or a
// field that is carried over from the wrong group) cannot slip through.
func checkDecision(t *testing.T, what string, got, want Decision) {
t.Helper()
if got.Group != want.Group {
t.Errorf("%s: Group = %q, want %q", what, got.Group, want.Group)
}
if got.Policy != want.Policy {
t.Errorf("%s: Policy = %q, want %q", what, got.Policy, want.Policy)
}
if got.SkipBlocklist != want.SkipBlocklist {
t.Errorf("%s: SkipBlocklist = %v, want %v", what, got.SkipBlocklist, want.SkipBlocklist)
}
if got.SkipCache != want.SkipCache {
t.Errorf("%s: SkipCache = %v, want %v", what, got.SkipCache, want.SkipCache)
}
if got.RateLimit != want.RateLimit {
t.Errorf("%s: RateLimit = %d, want %d", what, got.RateLimit, want.RateLimit)
}
if got.MaxConcurrent != want.MaxConcurrent {
t.Errorf("%s: MaxConcurrent = %d, want %d", what, got.MaxConcurrent, want.MaxConcurrent)
}
if got.LogQueries != want.LogQueries {
t.Errorf("%s: LogQueries = %v, want %v", what, got.LogQueries, want.LogQueries)
}
if !slices.Equal(got.Upstreams, want.Upstreams) {
t.Errorf("%s: Upstreams = %v, want %v", what, got.Upstreams, want.Upstreams)
}
if !slices.Equal(got.AllowDomains, want.AllowDomains) {
t.Errorf("%s: AllowDomains = %v, want %v", what, got.AllowDomains, want.AllowDomains)
}
if !slices.Equal(got.DenyDomains, want.DenyDomains) {
t.Errorf("%s: DenyDomains = %v, want %v", what, got.DenyDomains, want.DenyDomains)
}
if got.Default != want.Default {
t.Errorf("%s: Default = %v, want %v", what, got.Default, want.Default)
}
}
// TestEngineMatchFirstGroupWins pins first-match-wins: the two groups below
// both match 10.1.2.3, so the configured order is the only thing that decides
// the verdict, and the losing group contributes nothing to the decision.
func TestEngineMatchFirstGroupWins(t *testing.T) {
denyLAN := grp("deny-lan", "deny", config.Match{CIDRs: []string{"10.0.0.0/8"}})
denyLAN.RateLimit = 1
denyLAN.SkipBlocklist = true
desk := grp("desk", "allow", config.Match{CIDRs: []string{"10.1.2.3/32"}})
desk.SkipCache = true
desk.Upstreams = []string{"127.0.0.1:5353"}
desk.AllowDomains = []string{"*.example.com"}
overlapping := Client{IP: mustAddr(t, "10.1.2.3")}
t.Run("broad deny listed first wins over the narrower allow", func(t *testing.T) {
e := Compile(config.ACL{
DefaultPolicy: "allow",
Groups: []config.Group{denyLAN, desk},
})
checkDecision(t, "10.1.2.3", e.Match(overlapping), Decision{
Group: "deny-lan", Policy: PolicyDeny, RateLimit: 1, SkipBlocklist: true,
})
})
t.Run("narrow allow listed first wins over the broader deny", func(t *testing.T) {
e := Compile(config.ACL{
DefaultPolicy: "allow",
Groups: []config.Group{desk, denyLAN},
})
checkDecision(t, "10.1.2.3", e.Match(overlapping), Decision{
Group: "desk", Policy: PolicyAllow, SkipCache: true,
Upstreams: []string{"127.0.0.1:5353"}, AllowDomains: []string{"*.example.com"},
})
})
t.Run("client matching only the first group", func(t *testing.T) {
e := Compile(config.ACL{
DefaultPolicy: "allow",
Groups: []config.Group{desk, denyLAN},
})
checkDecision(t, "10.9.9.9", e.Match(byIP(t, "10.9.9.9")), Decision{
Group: "deny-lan", Policy: PolicyDeny, RateLimit: 1, SkipBlocklist: true,
})
})
}
// TestEngineMatchFirstGroupWinsAcrossCriteria shows first-match-wins is not
// specific to CIDRs: whichever criterion matched first hides the others.
func TestEngineMatchFirstGroupWinsAcrossCriteria(t *testing.T) {
byName := grp("by-name", "deny", config.Match{PTRSuffixes: []string{"lan.example."}})
byAddr := grp("by-addr", "allow", config.Match{CIDRs: []string{"10.0.0.0/8"}})
// This client satisfies every criterion at once.
c := Client{
IP: mustAddr(t, "10.1.2.3"),
Names: []string{"laptop.lan.example."},
Tags: []string{"laptop"},
}
t.Run("ptr group first hides the cidr group", func(t *testing.T) {
e := Compile(config.ACL{DefaultPolicy: "allow", Groups: []config.Group{byName, byAddr}})
checkDecision(t, "all-criteria client", e.Match(c), Decision{Group: "by-name", Policy: PolicyDeny})
})
t.Run("cidr group first hides the ptr group", func(t *testing.T) {
e := Compile(config.ACL{DefaultPolicy: "allow", Groups: []config.Group{byAddr, byName}})
checkDecision(t, "all-criteria client", e.Match(c), Decision{Group: "by-addr", Policy: PolicyAllow})
})
t.Run("any group listed first shadows every later group", func(t *testing.T) {
catchAll := grp("catch-all", "deny", config.Match{Any: true})
e := Compile(config.ACL{DefaultPolicy: "allow", Groups: []config.Group{catchAll, byAddr}})
checkDecision(t, "10.1.2.3", e.Match(c), Decision{Group: "catch-all", Policy: PolicyDeny})
// config validation warns about this, but the engine still obeys
// the configured order.
if e.Count() != 2 {
t.Fatalf("Count() = %d, want 2", e.Count())
}
})
}
// TestEngineMatchMostSpecificCIDRWithinGroup pins the per-group prefix sort:
// a group may list overlapping networks, and the client that lands on the
// /32 still gets that group's policy. Policy lives on the group, not on the
// prefix, so specificity only decides *which* group is considered, and the
// sorted order is observable in Summaries.
func TestEngineMatchMostSpecificCIDRWithinGroup(t *testing.T) {
g := grp("nested", "block_only", config.Match{CIDRs: []string{
"10.0.0.0/8", "10.1.2.0/24", "10.1.2.3/32", "2001:db8::/32",
}})
e := Compile(config.ACL{DefaultPolicy: "allow", Groups: []config.Group{g}})
inside := []string{"10.1.2.3", "10.1.2.4", "10.1.2.200", "10.9.9.9", "2001:db8:1::1"}
for _, ip := range inside {
if d := e.Match(byIP(t, ip)); d.Group != "nested" || d.Policy != PolicyBlockOnly {
t.Errorf("Match(%s) = %+v, want group nested / block_only", ip, d)
}
}
// Outside the group entirely.
for _, ip := range []string{"11.0.0.1", "2001:db9::1"} {
if d := e.Match(byIP(t, ip)); !d.Default {
t.Errorf("Match(%s) = %+v, want the default decision", ip, d)
}
}
// 10.1.3.1 matches nothing more specific than 10.0.0.0/8, but that is
// still the group: a group matches on *any* of its networks, and policy
// lives on the group rather than on the prefix.
if d := e.Match(byIP(t, "10.1.3.1")); d.Group != "nested" {
t.Errorf("Match(10.1.3.1) = %+v, want group nested via 10.0.0.0/8", d)
}
// The compiled prefixes are kept in config order: Summaries is a UI view,
// and a group matches on any of them, so the order carries no meaning.
s := e.Summaries()
if len(s) != 1 {
t.Fatalf("Summaries() = %d groups, want 1", len(s))
}
// Echoed in matching order (longest prefix first), which is the order the
// group itself walks them in.
wantCIDRs := []string{"10.1.2.3/32", "2001:db8::/32", "10.1.2.0/24", "10.0.0.0/8"}
if !slices.Equal(s[0].Match.CIDRs, wantCIDRs) {
t.Errorf("Summaries()[0].Match.CIDRs = %v, want %v (longest prefix first)", s[0].Match.CIDRs, wantCIDRs)
}
}
// TestEngineMatchCIDRSpecificityDoesNotCrossGroups documents that the
// longest-prefix sort is scoped to one group: a /32 in a later group cannot
// outrank a /8 in an earlier one, because first-match-wins stops the walk.
func TestEngineMatchCIDRSpecificityDoesNotCrossGroups(t *testing.T) {
broad := grp("broad", "deny", config.Match{CIDRs: []string{"10.0.0.0/8"}})
narrow := grp("narrow", "allow", config.Match{CIDRs: []string{"10.1.2.3/32"}})
c := byIP(t, "10.1.2.3")
e := Compile(config.ACL{DefaultPolicy: "allow", Groups: []config.Group{broad, narrow}})
if d := e.Match(c); d.Group != "broad" || d.Policy != PolicyDeny {
t.Errorf("broad first: Match = %+v, want broad/deny", d)
}
e = Compile(config.ACL{DefaultPolicy: "allow", Groups: []config.Group{narrow, broad}})
if d := e.Match(c); d.Group != "narrow" || d.Policy != PolicyAllow {
t.Errorf("narrow first: Match = %+v, want narrow/allow", d)
}
}
// TestEngineMatchCIDREdges covers the network boundaries, v4/v6 mixing and
// junk in the config.
func TestEngineMatchCIDREdges(t *testing.T) {
g := grp("v4", "allow", config.Match{CIDRs: []string{
"10.0.0.0/8", "not-a-cidr", "2001:db8::/32",
}})
e := Compile(config.ACL{DefaultPolicy: "deny", Groups: []config.Group{g}})
tests := []struct {
name string
ip string
want string // group name, "" = default
}{
{"network address", "10.0.0.0", "v4"},
{"top of the range", "10.255.255.255", "v4"},
{"one past the range", "11.0.0.0", ""},
{"just below the range", "9.255.255.255", ""},
{"v4-mapped v6 client is unmapped", "::ffff:10.1.2.3", "v4"},
{"v6 client in the v6 range", "2001:db8:1234::1", "v4"},
{"v6 client outside the v6 range", "2001:db9::1", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
d := e.Match(byIP(t, tc.ip))
if tc.want == "" {
if !d.Default {
t.Fatalf("Match(%s) = %+v, want the default decision", tc.ip, d)
}
return
}
if d.Group != tc.want {
t.Fatalf("Match(%s) = %+v, want group %q", tc.ip, d, tc.want)
}
})
}
t.Run("a client with no address only matches non-CIDR criteria", func(t *testing.T) {
if d := e.Match(Client{}); !d.Default {
t.Fatalf("Match(Client{}) = %+v, want the default decision", d)
}
})
t.Run("unparsable CIDRs are skipped, valid ones still work", func(t *testing.T) {
if cidrs := e.Summaries()[0].Match.CIDRs; !slices.Contains(cidrs, "10.0.0.0/8") || !slices.Contains(cidrs, "2001:db8::/32") {
t.Errorf("CIDRs = %v, want the two parsable prefixes", cidrs)
}
})
t.Run("a v4 client never matches a v6-only group", func(t *testing.T) {
v6 := Compile(config.ACL{DefaultPolicy: "deny", Groups: []config.Group{
grp("v6", "allow", config.Match{CIDRs: []string{"::/0", "2001:db8::/32"}}),
}})
for _, ip := range []string{"10.1.2.3", "::ffff:10.1.2.3"} {
if d := v6.Match(byIP(t, ip)); !d.Default {
t.Errorf("Match(%s) = %+v, want the default decision", ip, d)
}
}
})
}
// TestEngineMatchPTRSuffix covers reverse-name matching. Suffixes are
// normalised to FQDN form at compile time (as config validation does), and
// Client.Names arrives in the same form from the reverse cache.
func TestEngineMatchPTRSuffix(t *testing.T) {
g := grp("by-name", "allow", config.Match{PTRSuffixes: []string{
"Lan.Example.", // upper case and with the dot validation would add
"corp.internal",
}})
e := Compile(config.ACL{DefaultPolicy: "deny", Groups: []config.Group{g}})
tests := []struct {
name string
names []string
want string // group name, "" = default
}{
{"exact suffix", []string{"lan.example."}, "by-name"},
{"client below the suffix", []string{"laptop.lan.example."}, "by-name"},
{"deep below the suffix", []string{"a.b.lan.example."}, "by-name"},
{"suffix spelled without a trailing dot", []string{"host.corp.internal."}, "by-name"},
// A name that is not fully qualified does not match an FQDN suffix:
// the suffix is normalised to end in a dot, so "host.corp.internal"
// simply does not have it.
{"suffix without the dot, name without the dot", []string{"host.corp.internal"}, ""},
{"suffix as a prefix of the name", []string{"lan.example.com."}, ""},
{"near-miss parent", []string{"host.notexample.com."}, ""},
{"name shorter than the suffix", []string{"lan."}, ""},
{"empty name", []string{""}, ""},
{"no names at all", nil, ""},
{"any of several names matches", []string{"other.invalid.", "printer.lan.example."}, "by-name"},
{"last name matches", []string{"other.invalid.", "printer.lan.example."}, "by-name"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
d := e.Match(Client{IP: mustAddr(t, "203.0.113.9"), Names: tc.names})
if tc.want == "" {
if !d.Default {
t.Fatalf("Match(names=%v) = %+v, want the default decision", tc.names, d)
}
return
}
if d.Group != tc.want {
t.Fatalf("Match(names=%v) = %+v, want group %q", tc.names, d, tc.want)
}
})
}
t.Run("suffixes are echoed normalised", func(t *testing.T) {
// Sorted, because the summary is a UI view.
want := []string{"lan.example.", "corp.internal."}
if got := e.Summaries()[0].Match.PTRSuffixes; !slices.Equal(got, want) {
t.Errorf("PTRSuffixes = %v, want %v", got, want)
}
})
}
// TestEngineMatchPTRRequiresLabelBoundary is the regression test for PTR
// matching: a suffix used to be a plain strings.HasSuffix, so a group scoped
// to "example.com." also swallowed "notexample.com." and "ample.com.".
func TestEngineMatchPTRRequiresLabelBoundary(t *testing.T) {
g := grp("by-name", "allow", config.Match{PTRSuffixes: []string{"example.com."}})
e := Compile(config.ACL{DefaultPolicy: "deny", Groups: []config.Group{g}})
// A client in a different organisation must not inherit the group.
names := []string{
"host.notexample.com.",
"host.ample.com.",
"notexample.com.",
"host.example.com.evil.net.",
}
// "not-lan.example.com." does match: "not-lan" is a label of its own, so
// the suffix starts on a label boundary. It is here to say so explicitly,
// because it is the case a naive HasSuffix gets wrong in the other
// direction.
if d := e.Match(Client{Names: []string{"not-lan.example.com."}}); d.Group != "by-name" {
t.Errorf("Match(%q) = %+v, want group by-name", "not-lan.example.com.", d)
}
for _, n := range names {
t.Run(n, func(t *testing.T) {
if d := e.Match(Client{Names: []string{n}}); !d.Default {
t.Fatalf("Match(%q) = %+v, want the default decision", n, d)
}
})
}
// The genuine suffix, and the suffix that is the whole name, still match.
for _, n := range []string{"example.com.", "host.example.com.", "a.b.example.com."} {
t.Run(n, func(t *testing.T) {
if d := e.Match(Client{Names: []string{n}}); d.Group != "by-name" {
t.Fatalf("Match(%q) = %+v, want group by-name", n, d)
}
})
}
}
// TestMatchPTRLabelBoundary unit-tests the suffix helper directly, including
// the degenerate inputs.
func TestMatchPTRLabelBoundary(t *testing.T) {
tests := []struct {
name, n, suffix string
want bool
}{
{"identical", "example.com.", "example.com.", true},
{"one label more", "host.example.com.", "example.com.", true},
{"no trailing dot on the name", "host.example.com", "example.com.", false},
{"partial label", "notexample.com.", "example.com.", false},
{"partial label at the front", "ample.com.", "example.com.", false},
{"name is shorter", "lan.", "lan.example.", false},
{"empty suffix", "host.example.com.", "", false},
{"empty name", "", "example.com.", false},
{"a hyphen is not a label boundary", "host-example.com.", "example.com.", false},
// The console suggests ".lan" as a suffix; the leading dot was being
// counted as part of the label, so it never matched anything.
{"leading-dot suffix matches a name under it", "host.lan.", ".lan.", true},
{"leading-dot suffix matches deeper names", "a.b.lan.", ".lan.", true},
{"leading-dot suffix does not match the apex", "lan.", ".lan.", false},
{"leading-dot suffix still respects the label", "hostlan.", ".lan.", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := matchPTR(tc.n, tc.suffix); got != tc.want {
t.Errorf("matchPTR(%q, %q) = %v, want %v", tc.n, tc.suffix, got, tc.want)
}
})
}
}
// TestEngineMatchTags covers tag-only groups: the server derives tags from
// the PTR labels, so a group needs no address or name of its own.
func TestEngineMatchTags(t *testing.T) {
g := grp("laptops", "allow", config.Match{Tags: []string{"Laptop", " kiosk "}})
e := Compile(config.ACL{DefaultPolicy: "deny", Groups: []config.Group{g}})
tests := []struct {
name string
tags []string
names []string
want string
}{
{"tag matches", []string{"laptop"}, nil, "laptops"},
{"tags are exact, not prefixes", []string{"laptop2"}, nil, ""},
{"second configured tag, whitespace trimmed", []string{"kiosk"}, nil, "laptops"},
{"any client tag may match", []string{"printer", "kiosk"}, nil, "laptops"},
{"tags derived from the PTR name", nil, []string{"Kiosk.Lan.Internal."}, "laptops"},
{"untagged client", nil, nil, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// The server derives tags from the PTR labels, so a name can
// carry the tag on its own.
var tags []string
for _, n := range tc.names {
tags = append(tags, TagsFromName(n)...)
}
tags = append(tags, tc.tags...)
d := e.Match(Client{Names: tc.names, Tags: tags})
if tc.want == "" {
if !d.Default {
t.Fatalf("Match(tags=%v) = %+v, want the default decision", tc.tags, d)
}
return
}
if d.Group != tc.want {
t.Fatalf("Match(tags=%v) = %+v, want group %q", tc.tags, d, tc.want)
}
})
}
t.Run("tags are echoed lowercased in config order", func(t *testing.T) {
want := []string{"laptop", "kiosk"}
if got := e.Summaries()[0].Match.Tags; !slices.Equal(got, want) {
t.Errorf("Tags = %v, want %v", got, want)
}
})
}
// TestEngineMatchNoCriteria pins that a group with an empty match block is
// inert: there is no "disabled" flag, a group only goes away by being removed.
func TestEngineMatchNoCriteria(t *testing.T) {
inert := grp("inert", "deny", config.Match{})
e := Compile(config.ACL{DefaultPolicy: "allow", Groups: []config.Group{inert}})
if e.Count() != 1 {
t.Fatalf("Count() = %d, want 1", e.Count())
}
c := Client{
IP: mustAddr(t, "10.1.2.3"),
Names: []string{"laptop.lan.example."},
Tags: []string{"laptop"},
}
d := e.Match(c)
if !d.Default || d.Group != "" {
t.Fatalf("Match() = %+v, want the default decision (an empty match block matches nothing)", d)
}
}
// TestEngineMatchAnyCatchAll pins the fallback group, which matches even a
// client with no address, no names and no tags.
func TestEngineMatchAnyCatchAll(t *testing.T) {
catchAll := grp("catch-all", "block_only", config.Match{Any: true})
catchAll.RateLimit = 25
e := Compile(config.ACL{DefaultPolicy: "deny", Groups: []config.Group{catchAll}})
for _, c := range []Client{
{},
{IP: mustAddr(t, "192.0.2.1")},
{Names: []string{"host.elsewhere."}},
{Tags: []string{"whatever"}},
} {
d := e.Match(c)
if d.Group != "catch-all" || d.Policy != PolicyBlockOnly || d.RateLimit != 25 {
t.Errorf("Match(%+v) = %+v, want catch-all/block_only", c, d)
}
}
if e.Count() != 1 {
t.Errorf("Count() = %d, want 1", e.Count())
}
}
// TestEngineDefaultPolicy pins the documented behaviour for a client that
// matches no group: the ACL's own default policy, flagged Default, with no
// group and no domain overrides.
func TestEngineDefaultPolicy(t *testing.T) {
t.Run("empty config allows everything", func(t *testing.T) {
e := Compile(config.ACL{})
want := Decision{Policy: PolicyAllow, Default: true}
checkDecision(t, "unmatched", e.Match(byIP(t, "198.51.100.1")), want)
checkDecision(t, "Default()", e.Default(), want)
if e.Count() != 0 {
t.Errorf("Count() = %d, want 0", e.Count())
}
})
tests := []struct {
policy string
want Policy
}{
{"allow", PolicyAllow},
{"block_only", PolicyBlockOnly},
{"deny", PolicyDeny},
}
for _, tc := range tests {
t.Run("default policy "+tc.policy, func(t *testing.T) {
e := Compile(config.ACL{DefaultPolicy: tc.policy, DefaultRateLimit: 7})
want := Decision{Policy: tc.want, RateLimit: 7, Default: true}
checkDecision(t, "unmatched", e.Match(Client{}), want)
checkDecision(t, "Default()", e.Default(), want)
if e.Match(Client{}).Allows() != (tc.want != PolicyDeny) {
t.Errorf("Allows() = %v for policy %q", e.Match(Client{}).Allows(), tc.want)
}
})
}
t.Run("a matched group does not inherit the default rate limit", func(t *testing.T) {
e := Compile(config.ACL{
DefaultPolicy: "allow",
DefaultRateLimit: 7,
Groups: []config.Group{grp("g", "allow", config.Match{CIDRs: []string{"10.0.0.0/8"}})},
})
if d := e.Match(byIP(t, "10.1.1.1")); d.RateLimit != 0 {
t.Errorf("RateLimit = %d, want 0 (unlimited)", d.RateLimit)
}
})
}
// TestEngineNilSafe pins the nil-receiver behaviour of the public methods: a
// holder that was never given an engine must not panic on the query path.
func TestEngineNilSafe(t *testing.T) {
var e *Engine
want := Decision{Policy: PolicyAllow, Default: true}
checkDecision(t, "Match", e.Match(byIP(t, "10.1.2.3")), want)
checkDecision(t, "Default", e.Default(), want)
if e.Count() != 0 {
t.Errorf("Count() = %d, want 0", e.Count())
}
if e.Summaries() != nil {
t.Errorf("Summaries() = %v, want nil", e.Summaries())
}
if d := (*Engine)(nil).Match(Client{}); !d.Allows() {
t.Errorf("nil engine denies, want allow")
}
}
// TestDecisionAllows pins the one rule the query path relies on: only "deny"
// refuses.
func TestDecisionAllows(t *testing.T) {
tests := []struct {
policy Policy
want bool
}{
{PolicyAllow, true},
{PolicyBlockOnly, true},
{PolicyDeny, false},
{"", true}, // the zero Decision is permissive
}
for _, tc := range tests {
if got := (Decision{Policy: tc.policy}).Allows(); got != tc.want {
t.Errorf("Decision{Policy: %q}.Allows() = %v, want %v", tc.policy, got, tc.want)
}
}
}
// TestEngineGroupPolicyDefaults pins that a group with no policy in the config
// is treated as "allow" rather than as an unknown value.
func TestEngineGroupPolicyDefaults(t *testing.T) {
e := Compile(config.ACL{Groups: []config.Group{{Name: "g", Match: config.Match{Any: true}}}})
d := e.Match(Client{})
if d.Group != "g" || d.Policy != PolicyAllow {
t.Errorf("Match() = %+v, want group g / allow", d)
}
if s := e.Summaries(); len(s) != 1 || s[0].Policy != "allow" {
t.Errorf("Summaries() = %+v, want policy allow", s)
}
}
// TestEngineSummaries pins the API view: config order is preserved, CIDRs come
// out most-specific-first, and the counters are left for the engine to fill.
func TestEngineSummaries(t *testing.T) {
a := grp("a", "deny", config.Match{CIDRs: []string{"10.0.0.0/8"}, PTRSuffixes: []string{"lan.example."}, Tags: []string{"laptop"}})
a.Comment = "the office"
a.AllowDomains = []string{"example.com", "*.example.net"}
a.DenyDomains = []string{"*.ads.example.net"}
a.SkipBlocklist = true
a.MaxConcurrent = 3
a.LogQueries = true
a.Upstreams = []string{"127.0.0.1:5301"}
b := grp("b", "allow", config.Match{Any: true})
e := Compile(config.ACL{DefaultPolicy: "allow", Groups: []config.Group{a, b}})
s := e.Summaries()
if len(s) != 2 {
t.Fatalf("Summaries() = %d groups, want 2", len(s))
}
got := s[0]
want := GroupSummary{
Name: "a",
Comment: "the office",
Policy: "deny",
Match: Match{
CIDRs: []string{"10.0.0.0/8"},
PTRSuffixes: []string{"lan.example."},
Tags: []string{"laptop"},
},
AllowDomains: []string{"*.example.net", "example.com"},
DenyDomains: []string{"*.ads.example.net"},
SkipBlocklist: true,
MaxConcurrent: 3,
LogQueries: true,
Upstreams: []string{"127.0.0.1:5301"},
}
if got.Name != want.Name || got.Comment != want.Comment || got.Policy != want.Policy ||
got.Match.Any != want.Match.Any || got.SkipBlocklist != want.SkipBlocklist ||
got.MaxConcurrent != want.MaxConcurrent || got.LogQueries != want.LogQueries {
t.Errorf("Summaries()[0] scalars = %+v, want %+v", got, want)
}
if !slices.Equal(got.Match.CIDRs, want.Match.CIDRs) {
t.Errorf("CIDRs = %v, want %v", got.Match.CIDRs, want.Match.CIDRs)
}
if !slices.Equal(got.Match.PTRSuffixes, want.Match.PTRSuffixes) {
t.Errorf("PTRSuffixes = %v, want %v", got.Match.PTRSuffixes, want.Match.PTRSuffixes)
}
if !slices.Equal(got.Match.Tags, want.Match.Tags) {
t.Errorf("Tags = %v, want %v", got.Match.Tags, want.Match.Tags)
}
if !slices.Equal(got.AllowDomains, want.AllowDomains) {
t.Errorf("AllowDomains = %v, want %v (sorted, wildcards rendered)", got.AllowDomains, want.AllowDomains)
}
if !slices.Equal(got.DenyDomains, want.DenyDomains) {
t.Errorf("DenyDomains = %v, want %v", got.DenyDomains, want.DenyDomains)
}
if !slices.Equal(got.Upstreams, want.Upstreams) {
t.Errorf("Upstreams = %v, want %v", got.Upstreams, want.Upstreams)
}
// Counters and the dry-run marker belong to the engine/app layer.
if got.RateLimit != 0 || got.ClientCount != 0 || got.QueryCount != 0 || got.BlockedCount != 0 || got.Matched {
t.Errorf("Summaries()[0] = %+v, want zeroed counters and Matched=false", got)
}
if s[1].Name != "b" || !s[1].Match.Any || s[1].Policy != "allow" {
t.Errorf("Summaries()[1] = %+v, want group b / any / allow", s[1])
}
if len(s[1].Match.CIDRs) != 0 || len(s[1].Match.PTRSuffixes) != 0 || len(s[1].AllowDomains) != 0 {
t.Errorf("Summaries()[1] = %+v, want empty lists", s[1])
}
}
// TestEngineIsImmutableSnapshot pins the reload contract: the engine copies
// everything out of the config, so mutating the config the caller still holds
// cannot change a running engine.
func TestEngineIsImmutableSnapshot(t *testing.T) {
upstreams := []string{"127.0.0.1:5301"}
ptr := []string{"lan.example."}
tags := []string{"laptop"}
allow := []string{"*.example.com"}
deny := []string{"*.ads.example.com"}
cidrs := []string{"10.0.0.0/8"}
cfg := config.ACL{
DefaultPolicy: "allow",
Groups: []config.Group{{
Name: "g",
Policy: "allow",
Match: config.Match{CIDRs: cidrs, PTRSuffixes: ptr, Tags: tags},
AllowDomains: allow,
DenyDomains: deny,
Upstreams: upstreams,
}},
}
e := Compile(cfg)
// Everything the caller kept a reference to gets rewritten behind the
// engine's back, as a reload in flight would.
upstreams[0] = "127.0.0.1:9999"
ptr[0] = "evil.example."
tags[0] = "evil"
allow[0] = "*.evil.example.com"
deny[0] = "*.evil.example.com"
cidrs[0] = "0.0.0.0/0"
c := Client{
IP: mustAddr(t, "10.1.2.3"),
Names: []string{"laptop.lan.example."},
Tags: []string{"laptop"},
}
d := e.Match(c)
checkDecision(t, "immutable engine", d, Decision{
Group: "g",
Policy: PolicyAllow,
Upstreams: []string{"127.0.0.1:5301"},
AllowDomains: []string{"*.example.com"},
DenyDomains: []string{"*.ads.example.com"},
})
if !NewDomainSet(d.AllowDomains).Match("www.example.com") {
t.Error("allow_domains lost the original pattern after the config was mutated")
}
// The rewritten CIDR must not have widened the group either.
if d := e.Match(byIP(t, "198.51.100.1")); !d.Default {
t.Errorf("Match(198.51.100.1) = %+v, want the default decision", d)
}
}
// TestDomainSetMatch covers every pattern form DomainSet accepts, including
// the boundary cases the wildcard spellings are easy to get wrong.
func TestDomainSetMatch(t *testing.T) {
tests := []struct {
name string
patterns []string
query string
want bool
}{
// Exact rules.
{"exact apex", []string{"example.com"}, "example.com", true},
{"exact apex with trailing dot", []string{"example.com"}, "example.com.", true},
{"exact apex uppercase query", []string{"example.com"}, "ExAmPlE.CoM", true},
{"exact rule configured uppercase", []string{"EXAMPLE.com"}, "example.com", true},
{"exact rule configured with spaces and a dot", []string{" Example.COM. "}, "example.com", true},
{"exact rule does not cover a subdomain", []string{"example.com"}, "www.example.com", false},
{"exact rule does not cover the parent", []string{"www.example.com"}, "example.com", false},
{"exact rule does not cover a name ending in it", []string{"example.com"}, "notexample.com", false},
{"exact rule does not cover a longer name starting with it", []string{"example.com"}, "example.com.evil.net", false},
// "*.x" and "**.x" are the same rule here: apex plus subdomains.
{"sub wildcard matches a subdomain", []string{"*.example.com"}, "www.example.com", true},
{"sub wildcard matches a deep subdomain", []string{"*.example.com"}, "a.b.c.example.com", true},
{"sub wildcard also matches the apex", []string{"*.example.com"}, "example.com", true},
{"sub wildcard apex with trailing dot", []string{"*.example.com"}, "example.com.", true},
{"sub wildcard does not match a name that is only a prefix", []string{"*.example.com"}, "notexample.com", false},
{"sub wildcard does not match a sibling domain", []string{"*.example.com"}, "example.com.evil.net", false},
{"sub wildcard does not match a shorter name", []string{"*.example.com"}, "example.co", false},
{"root wildcard matches the apex", []string{"**.example.com"}, "example.com", true},
{"root wildcard matches a subdomain", []string{"**.example.com"}, "a.example.com", true},
// Catch-alls.
{"star matches anything", []string{"*"}, "anything.test", true},
{"double star matches anything", []string{"**"}, "anything.test", true},
{"star dot star matches anything", []string{"*.*"}, "a.b.c", true},
{"star matches the root label", []string{"*"}, ".", true},
{"star short-circuits an empty name", []string{"*"}, "", true},
// Oddities.
{"empty set matches nothing", nil, "example.com", false},
{"empty set does not match the empty name", nil, "", false},
{"blank patterns are dropped", []string{"", " "}, "example.com", false},
{"the root label alone is normalised to the empty name", []string{"example.com"}, ".", false},
{"duplicates collapse", []string{"example.com", "example.com."}, "example.com", true},
{"union of the three forms", []string{"example.com", "*.example.net", "**.safe.example"}, "a.safe.example", true},
{"union still rejects unrelated names", []string{"example.com", "*.example.net", "**.safe.example"}, "example.org", false},
{"exact plus sub wildcard", []string{"example.com", "*.example.com"}, "www.example.com", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := NewDomainSet(tc.patterns).Match(tc.query); got != tc.want {
t.Errorf("NewDomainSet(%v).Match(%q) = %v, want %v", tc.patterns, tc.query, got, tc.want)
}
})
}
}
// TestDomainSetLen pins what counts as a rule: the catch-all is a flag, not an
// entry, and duplicate spellings collapse to one.
func TestDomainSetLen(t *testing.T) {
tests := []struct {
name string
patterns []string
want int
}{
{"nothing", nil, 0},
{"one exact", []string{"example.com"}, 1},
{"one sub", []string{"*.example.com"}, 1},
{"one root", []string{"**.example.com"}, 1},
{"sub and root share one entry", []string{"*.example.com", "**.example.com"}, 1},
{"exact and sub are two", []string{"example.com", "*.example.com"}, 2},
{"duplicates collapse", []string{"example.com", "example.com."}, 1},
{"the catch-all is not an entry", []string{"*"}, 0},
{"blanks and the catch-all are not entries", []string{"", " ", "*", "*.a.example"}, 1},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := NewDomainSet(tc.patterns).Len(); got != tc.want {
t.Errorf("NewDomainSet(%v).Len() = %d, want %d", tc.patterns, got, tc.want)
}
})
}
}
// TestDomainSetNilReceiver pins the guard used by callers holding an optional
// set (a group without domain rules compiles to an empty set, never a nil one,
// but the API is called with typed nils too).
func TestDomainSetNilReceiver(t *testing.T) {
var ds *DomainSet
if ds.Match("example.com") {
t.Error("nil DomainSet matched")
}
if ds.Len() != 0 {
t.Errorf("nil DomainSet Len() = %d, want 0", ds.Len())
}
if got := domainPatterns(nil); got != nil {
t.Errorf("domainPatterns(nil) = %v, want nil", got)
}
}
// TestLabelEnd pins the suffix walk that DomainSet.Match uses.
func TestLabelEnd(t *testing.T) {
tests := []struct {
s string
want int
}{
{"", 0},
{"example.com", 8},
{"a.b.c", 2},
{"single", 6},
{".leading", 1},
}
for _, tc := range tests {
if got := labelEnd(tc.s); got != tc.want {
t.Errorf("labelEnd(%q) = %d, want %d", tc.s, got, tc.want)
}
}
}
// TestDomainPatternsRoundTrip pins that a decision's domain lists can be fed
// straight back into NewDomainSet - which is exactly what the engine does per
// query - without changing what matches.
func TestDomainPatternsRoundTrip(t *testing.T) {
patterns := []string{
"example.com", "Example.NET.", "*.example.org", "**.safe.example", "*",
}
probes := []string{
"example.com", "www.example.com", "notexample.com", "example.com.evil.net",
"a.b.example.org", "a.safe.example", "safe.example", "unrelated.test", "",
}
ds := NewDomainSet(patterns)
again := NewDomainSet(domainPatterns(ds))
for _, p := range probes {
if got, want := again.Match(p), ds.Match(p); got != want {
t.Errorf("round trip %v: Match(%q) = %v, want %v", domainPatterns(ds), p, got, want)
}
}
// The rendered form is stable, which is what the API shows, and keeps the
// catch-all visible instead of silently dropping it.
want := []string{"*", "*.example.org", "*.safe.example", "example.com", "example.net"}
if got := domainPatterns(ds); !slices.Equal(got, want) {
t.Errorf("domainPatterns() = %v, want %v", got, want)
}
}
// TestEngineMatchCarriesDomainRules pins that the per-group allow/deny lists
// travel with the decision, and that a group without them reports none.
func TestEngineMatchCarriesDomainRules(t *testing.T) {
restricted := grp("restricted", "allow", config.Match{CIDRs: []string{"10.0.0.0/8"}})
restricted.AllowDomains = []string{"example.com", "**.safe.example"}
restricted.DenyDomains = []string{"*.ads.safe.example"}
open := grp("open", "allow", config.Match{Any: true})
e := Compile(config.ACL{DefaultPolicy: "deny", Groups: []config.Group{restricted, open}})
d := e.Match(byIP(t, "10.1.2.3"))
if !slices.Equal(d.AllowDomains, []string{"*.safe.example", "example.com"}) {
t.Errorf("AllowDomains = %v", d.AllowDomains)
}
if !slices.Equal(d.DenyDomains, []string{"*.ads.safe.example"}) {
t.Errorf("DenyDomains = %v", d.DenyDomains)
}
// The engine applies them with a fresh set; check the verdict it derives.
if acl := NewDomainSet(d.DenyDomains); !acl.Match("x.ads.safe.example") {
t.Error("deny list does not match a subdomain of the denied domain")
}
allow := NewDomainSet(d.AllowDomains)
if !allow.Match("example.com") || !allow.Match("a.safe.example") || allow.Match("other.test") {
t.Error("allow list does not behave like the group rule")
}
d = e.Match(byIP(t, "198.51.100.1"))
// Empty rather than nil: the decision always carries the lists, because
// the engine hands them straight to NewDomainSet without a nil check.
if len(d.AllowDomains) != 0 || len(d.DenyDomains) != 0 {
t.Errorf("group without domain rules reported %v / %v, want empty", d.AllowDomains, d.DenyDomains)
}
}
// TestHolderSwap pins the atomic reload contract used by the DNS hot path.
func TestHolderSwap(t *testing.T) {
deny := Compile(config.ACL{DefaultPolicy: "allow", Groups: []config.Group{grp("g", "deny", config.Match{Any: true})}})
allow := Compile(config.ACL{DefaultPolicy: "allow"})
h := NewHolder(deny)
if h.Get() != deny {
t.Fatal("Get() did not return the engine passed to NewHolder")
}
if d := h.Get().Match(Client{}); d.Policy != PolicyDeny {
t.Errorf("policy = %q, want deny", d.Policy)
}
h.Set(allow)
if h.Get() != allow {
t.Fatal("Get() did not return the swapped engine")
}
if d := h.Get().Match(Client{}); d.Policy != PolicyAllow {
t.Errorf("policy after swap = %q, want allow", d.Policy)
}
// An empty holder is usable: Match has to tolerate a nil snapshot.
h.Set(nil)
if h.Get() != nil {
t.Fatal("Get() != nil after Set(nil)")
}
if d := h.Get().Match(Client{}); !d.Allows() || !d.Default {
t.Errorf("nil engine decision = %+v, want the permissive default", d)
}
}
// TestHolderConcurrentReload exercises the swap under -race: readers keep
// matching against whichever immutable snapshot they loaded, and a decision
// is always one of the two configured policies (never a half-written one).
func TestHolderConcurrentReload(t *testing.T) {
deny := Compile(config.ACL{DefaultPolicy: "allow", Groups: []config.Group{grp("g", "deny", config.Match{CIDRs: []string{"10.0.0.0/8"}})}})
allow := Compile(config.ACL{DefaultPolicy: "deny", Groups: []config.Group{grp("g", "allow", config.Match{CIDRs: []string{"10.0.0.0/8"}})}})
h := NewHolder(deny)
// Parse outside the goroutines: t.Fatal is not allowed off the main one.
client := byIP(t, "10.1.2.3")
stop := make(chan struct{})
var wg sync.WaitGroup
for range 4 {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
eng := h.Get()
d := eng.Match(client)
if d.Policy != PolicyAllow && d.Policy != PolicyDeny {
t.Errorf("Match() = %+v, want one of the configured policies", d)
return
}
if d.Group != "g" {
t.Errorf("Match() = %+v, want group g", d)
return
}
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
for i := range 2000 {
if i%2 == 0 {
h.Set(deny)
} else {
h.Set(allow)
}
}
close(stop)
}()
wg.Wait()
}
// TestTagsFromName pins the tag derivation the Match.Tags rule depends on.
func TestTagsFromName(t *testing.T) {
tests := []struct {
name string
in string
want []string
}{
{"three labels", "laptop.lan.internal", []string{"laptop"}},
{"trailing dot is dropped", "laptop.lan.internal.", []string{"laptop"}},
{"upper case is folded", "Kiosk.Lan.Internal.", []string{"kiosk"}},
{"four labels drop the parent domain", "laptop.lan.example.net", []string{"laptop", "lan"}},
{"single label is kept", "host", []string{"host"}},
{"two labels leave nothing", "example.com", nil},
{"empty name", "", nil},
{"root label", ".", nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := TagsFromName(tc.in)
if len(got) != len(tc.want) {
t.Fatalf("TagsFromName(%q) = %v, want %v", tc.in, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("TagsFromName(%q) = %v, want %v", tc.in, got, tc.want)
}
}
})
}
}
// benchEngine builds a realistic table: seven unrelated groups, then the one
// the benchmarked client lands on.
func benchEngine() *Engine {
groups := make([]config.Group, 0, 8)
for i := range 7 {
groups = append(groups, grp(
fmt.Sprintf("tenant-%d", i),
"allow",
config.Match{CIDRs: []string{fmt.Sprintf("10.%d.0.0/16", i)}},
))
}
target := grp("target", "allow", config.Match{
CIDRs: []string{"10.7.1.5/32"},
PTRSuffixes: []string{"lan.example."},
Tags: []string{"laptop"},
})
target.AllowDomains = []string{"*.example.com"}
target.DenyDomains = []string{"ads.example.com"}
groups = append(groups, target)
return Compile(config.ACL{DefaultPolicy: "allow", Groups: groups})
}
// BenchmarkEngineMatch measures the hot-path lookup for a client that is
// identified by address, reverse name and tag and matches the last group.
func BenchmarkEngineMatch(b *testing.B) {
e := benchEngine()
c := Client{
IP: netip.MustParseAddr("10.7.1.5"),
Names: []string{"laptop.lan.example."},
Tags: []string{"laptop"},
}
b.ReportAllocs()
for b.Loop() {
if d := e.Match(c); d.Policy != PolicyAllow {
b.Fatalf("Match() = %+v, want allow", d)
}
}
}
// BenchmarkEngineMatchDefault measures the miss path, where no group matches
// and the default decision is returned.
func BenchmarkEngineMatchDefault(b *testing.B) {
e := benchEngine()
c := Client{IP: netip.MustParseAddr("203.0.113.7")}
b.ReportAllocs()
for b.Loop() {
if d := e.Match(c); !d.Default {
b.Fatalf("Match() = %+v, want the default decision", d)
}
}
}
// BenchmarkHolderGet measures the snapshot load the query path starts with.
func BenchmarkHolderGet(b *testing.B) {
h := NewHolder(benchEngine())
b.ReportAllocs()
for b.Loop() {
if h.Get() == nil {
b.Fatal("Get() = nil")
}
}
}
// BenchmarkDomainSetMatch measures the name matcher used for the per-group
// allow/deny lists.
func BenchmarkDomainSetMatch(b *testing.B) {
ds := NewDomainSet([]string{"*.example.com", "**.safe.example", "tracker.net", "*"})
for _, q := range []string{"www.example.com", "tracker.net"} {
b.Run(q, func(b *testing.B) {
for b.Loop() {
if !ds.Match(q) {
b.Fatalf("Match(%q) = false", q)
}
}
})
}
}