1074 lines
40 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|