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) } } }) } }