more code

This commit is contained in:
developer committed 2026-09-27 11:34:26 +00:00
1 parent f24fd63e9a
commit ee72257c64
29 files changed
+5636 -440

No files matched your search

+579
View File
@@ -0,0 +1,579 @@
package blocklist
import (
"strconv"
"strings"
"testing"
)
// ruleStrings renders parsed rules compactly for table comparison.
func ruleStrings(rules []Rule) []string {
out := make([]string, 0, len(rules))
for _, r := range rules {
s := r.Name + " " + r.Action.String() + " ttl=" + strconv.Itoa(int(r.TTL))
if r.Response != "" {
s += " resp=" + r.Response
}
if r.Priority != 0 {
s += " prio=" + strconv.Itoa(r.Priority)
}
out = append(out, s)
}
return out
}
// sameRules compares parsed rules against the expectation, treating "no rules"
// as equal whether the parser returned a nil or an empty slice.
func sameRules(got, want []string) bool {
return strings.Join(got, "\n") == strings.Join(want, "\n")
}
func TestParseHosts(t *testing.T) {
tests := []struct {
name string
data string
opt ParseOptions
want []string
wantFormat Format
wantComment int
wantSkip int
wantBad int
wantNotice string
}{
{
name: "block entry",
data: "0.0.0.0 ads.example.com\n",
opt: ParseOptions{Subdomains: true},
want: []string{"**.ads.example.com block ttl=60"},
},
{
name: "several names on one line",
data: "0.0.0.0 a.example.com b.example.com\n",
opt: ParseOptions{Subdomains: true},
want: []string{"**.a.example.com block ttl=60", "**.b.example.com block ttl=60"},
},
{
name: "loopback bootstrap lines are never blocked",
data: "127.0.0.1 localhost\n::1 ip6-localhost\n",
opt: ParseOptions{Subdomains: true},
wantSkip: 2,
},
{
name: "comments, blank lines and inline comments",
data: "# a comment\n; another\n\n \n0.0.0.0 ads.example.com # trailing note\n",
opt: ParseOptions{Subdomains: true},
want: []string{"**.ads.example.com block ttl=60"},
wantComment: 2,
},
{
name: "custom TTL and no subdomain expansion",
data: "0.0.0.0 ads.example.com\n",
opt: ParseOptions{Subdomains: false, DefaultTTL: 300},
want: []string{"ads.example.com block ttl=300"},
},
{
name: "a single field is not a hosts line",
data: "ads.example.com\n",
opt: ParseOptions{Subdomains: true},
wantBad: 1,
},
{
name: "a non-address first field falls back to a plain domain list",
data: "0.0.0.0 ads.example.com\nfoo bar.example.com\n",
opt: ParseOptions{Subdomains: true},
// The fallback re-parses the whole file as a plain list, so the
// real hosts lines survive and the odd line is malformed.
want: []string{"**.ads.example.com block ttl=60"},
wantFormat: FormatPlain,
wantBad: 1,
wantNotice: "no address in first field, falling back to a plain domain list",
},
{
name: "malformed lines",
data: "0.0.0.0\n0.0.0.0 not_a_domain..x\n0.0.0.0 -bad.example.com\n",
opt: ParseOptions{Subdomains: true},
wantBad: 3,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
rules, stats, err := Parse(FormatHosts, []byte(tc.data), tc.opt)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got := ruleStrings(rules); !sameRules(got, tc.want) {
t.Errorf("rules = %v, want %v", got, tc.want)
}
wantFormat := tc.wantFormat
if wantFormat == "" {
wantFormat = FormatHosts
}
if stats.Format != wantFormat {
t.Errorf("stats.Format = %q, want %q", stats.Format, wantFormat)
}
if stats.Comments != tc.wantComment {
t.Errorf("Comments = %d, want %d", stats.Comments, tc.wantComment)
}
if stats.Skipped != tc.wantSkip {
t.Errorf("Skipped = %d, want %d", stats.Skipped, tc.wantSkip)
}
if stats.Malformed != tc.wantBad {
t.Errorf("Malformed = %d, want %d", stats.Malformed, tc.wantBad)
}
if stats.Rules != len(rules) {
t.Errorf("Rules = %d, want %d", stats.Rules, len(rules))
}
if stats.Bytes != int64(len(tc.data)) {
t.Errorf("Bytes = %d, want %d", stats.Bytes, len(tc.data))
}
if tc.wantNotice != "" {
found := false
for _, n := range stats.Notices {
if n == tc.wantNotice {
found = true
}
}
if !found {
t.Errorf("Notices = %v, want %q", stats.Notices, tc.wantNotice)
}
}
})
}
}
func TestParseAdblock(t *testing.T) {
data := strings.Join([]string{
"[Adblock Plus 2.0]",
"! Title: test list",
"! Version: 1",
"||ads.example.com^",
"||track.example.com^$third-party",
"|http://banner.example.com/img.png|",
"|https://user:pw@secure.example.com:8443/x?y#z|",
"@@||ok.example.com^",
"example.com##.ad",
"banner.example.com#@#.sponsor",
"/ad[0-9]+/",
"/banner[0-9]+/",
"||",
"bare.example.com",
}, "\n")
rules, stats, err := Parse(FormatAdblock, []byte(data), ParseOptions{Subdomains: true, DefaultTTL: 60})
if err != nil {
t.Fatalf("Parse: %v", err)
}
want := []string{
"**.ads.example.com block ttl=60",
"**.track.example.com block ttl=60",
"**.banner.example.com block ttl=60",
"**.secure.example.com block ttl=60",
"**.ok.example.com allow ttl=60 prio=10",
"**.bare.example.com block ttl=60",
}
if got := ruleStrings(rules); !sameRules(got, want) {
t.Errorf("rules = %v,\nwant %v", got, want)
}
if stats.Rules != len(want) {
t.Errorf("Rules = %d, want %d", stats.Rules, len(want))
}
if stats.Comments != 3 { // the header plus two "! " comments
t.Errorf("Comments = %d, want 3", stats.Comments)
}
// 2 cosmetic rules, 2 regex rules, 1 empty "||" host.
if stats.Skipped != 5 {
t.Errorf("Skipped = %d, want 5", stats.Skipped)
}
foundNotice := false
for _, n := range stats.Notices {
if n == "skipped regular-expression rules" {
foundNotice = true
}
}
if !foundNotice {
t.Errorf("Notices = %v, want the regex notice", stats.Notices)
}
if stats.Format != FormatAdblock {
t.Errorf("Format = %q", stats.Format)
}
}
func TestParsePlain(t *testing.T) {
data := strings.Join([]string{
"# a comment",
"; another comment",
"",
" spaced.example.com ",
"!ok.example.com",
"0.0.0.0 hoststyle.example.com",
"||ads.example.com",
"trailing.example.com.",
"!not a domain line !!",
"-bad.example.com",
}, "\n")
rules, stats, err := Parse(FormatPlain, []byte(data), ParseOptions{Subdomains: true})
if err != nil {
t.Fatalf("Parse: %v", err)
}
want := []string{
"**.spaced.example.com block ttl=60",
"**.ok.example.com allow ttl=60",
"**.hoststyle.example.com block ttl=60",
"**.ads.example.com block ttl=60",
// A trailing dot survives parsing; Builder.Add strips it later.
"**.trailing.example.com. block ttl=60",
}
if got := ruleStrings(rules); !sameRules(got, want) {
t.Errorf("rules = %v,\nwant %v", got, want)
}
if stats.Comments != 2 {
t.Errorf("Comments = %d, want 2", stats.Comments)
}
if stats.Malformed != 2 {
t.Errorf("Malformed = %d, want 2", stats.Malformed)
}
// Without subdomain expansion the same list yields exact rules.
exact, _, err := Parse(FormatPlain, []byte("spaced.example.com\n!ok.example.com\n"), ParseOptions{Subdomains: false})
if err != nil {
t.Fatalf("Parse: %v", err)
}
wantExact := []string{"spaced.example.com block ttl=60", "ok.example.com allow ttl=60"}
if got := ruleStrings(exact); !sameRules(got, wantExact) {
t.Errorf("exact rules = %v, want %v", got, wantExact)
}
// The documented "!allow <domain>" spelling is not what the parser
// implements: it only strips the "!" marker, so the word "allow" is left
// in the name and the line is counted as malformed. Real behaviour,
// pinned so a fix shows up as a failure.
allowForm, allowStats, err := Parse(FormatPlain, []byte("!allow ok.example.com\n"), ParseOptions{Subdomains: true})
if err != nil {
t.Fatalf("Parse: %v", err)
}
t.Logf("\"!allow ok.example.com\": rules=%v malformed=%d", ruleStrings(allowForm), allowStats.Malformed)
if allowStats.Rules != 0 || allowStats.Malformed != 1 {
t.Errorf("\"!allow x\": rules=%d malformed=%d, want 0/1 (current behaviour)",
allowStats.Rules, allowStats.Malformed)
}
}
func TestParseRPZ(t *testing.T) {
data := strings.Join([]string{
"$TTL 3600",
"; an RPZ policy zone",
"example.com CNAME .",
"*.sub.example.org CNAME rpz-drop.",
"**.both.example.net CNAME rpz-passthru.",
"redirect.example.gov A 127.0.0.2",
"redirect6.example.gov AAAA ::1",
"example.com CNAME .",
`skip.example.com TXT "not-a-known-action"`,
}, "\n")
rules, stats, err := Parse(FormatRPZ, []byte(data), ParseOptions{Subdomains: true, DefaultTTL: 60})
if err != nil {
t.Fatalf("Parse: %v", err)
}
want := []string{
// A bare owner name becomes the STRICT-subdomain form "*." (RPZ
// semantics: the policy covers what is under the name), unlike the
// hosts parser which uses "**.". Zone owner names keep their
// trailing dot; Builder.Add strips it at compile time.
"*.example.com. nxdomain ttl=3600",
"*.sub.example.org. drop ttl=3600",
"**.both.example.net. passthrough ttl=3600",
"*.redirect.example.gov. redirect ttl=3600 resp=127.0.0.2",
"*.redirect6.example.gov. redirect ttl=3600 resp=::1",
}
if got := ruleStrings(rules); !sameRules(got, want) {
t.Errorf("rules = %v,\nwant %v", got, want)
}
if stats.Duplicates != 1 {
t.Errorf("Duplicates = %d, want 1 (the repeated example.com record)", stats.Duplicates)
}
if stats.Skipped != 1 {
t.Errorf("Skipped = %d, want 1 (TXT with an unknown action)", stats.Skipped)
}
if stats.Format != FormatRPZ {
t.Errorf("Format = %q, want rpz", stats.Format)
}
// Without subdomain expansion the wildcard prefix is left off entirely.
plain, _, err := Parse(FormatRPZ, []byte("example.com CNAME .\n"), ParseOptions{Subdomains: false})
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got := ruleStrings(plain); !sameRules(got, []string{"example.com. nxdomain ttl=60"}) {
t.Errorf("no-subdomain rules = %v", got)
}
// A CNAME to a real target is a rewrite rule and $TTL is honoured.
cname, _, err := Parse(FormatRPZ, []byte("$TTL 60\nshop.example.com CNAME parked.example.net.\n"), ParseOptions{Subdomains: true})
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got := ruleStrings(cname); !sameRules(got, []string{"*.shop.example.com. cname ttl=60 resp=parked.example.net."}) {
t.Errorf("cname rules = %v", got)
}
// The compiled forms behave as documented: "*." skips the apex while
// "**." covers it.
s := mustBuild(rules...)
if e := s.Match("example.com"); e != nil {
t.Errorf("RPZ \"example.com CNAME .\" matched the apex: %q, want nil (strict \"*.\")", e.Name)
}
if e := s.Match("www.example.com"); e == nil || e.Action != ActionNXDomain {
t.Errorf("www.example.com: %+v, want nxdomain", e)
}
if e := s.Match("both.example.net"); e == nil || e.Action != ActionPassthrough {
t.Errorf("both.example.net: %+v, want passthrough (\"**.\" covers the apex)", e)
}
if e := s.Match("deep.both.example.net"); e == nil || e.Action != ActionPassthrough {
t.Errorf("deep.both.example.net: %+v, want passthrough", e)
}
}
func TestDetectFormat(t *testing.T) {
tests := []struct {
name string
data string
want Format
}{
{"hosts", "0.0.0.0 ads.example.com\n127.0.0.1 localhost\n", FormatHosts},
{"hosts after comments", "# comment\n\n 127.0.0.1 localhost\n", FormatHosts},
{"hosts with ::1", "::1 ip6-localhost\n", FormatHosts},
{"adblock double pipe", "||ads.example.com^\n", FormatAdblock},
{"adblock allow", "@@||ok.example.com^\n", FormatAdblock},
{"adblock cosmetic", "example.com##.ad\n", FormatAdblock},
{"adblock after header", "[Adblock Plus 2.0]\n! comment\n|http://a.example.com/x|\n", FormatAdblock},
{"rpz ttl", "$TTL 3600\nexample.com CNAME .\n", FormatRPZ},
{"rpz origin", "$ORIGIN example.com.\nexample.com CNAME .\n", FormatRPZ},
{"rpz record with ttl and class", "example.com 3600 IN CNAME .\n", FormatRPZ},
{"single plain domain", "ads.example.com\n", FormatPlain},
{"empty", "", FormatPlain},
{"only comments", "# nothing here\n; really\n", FormatPlain},
// A two-line plain list is sniffed as adblock: the second meaningful
// line is taken to be an adblock rule. Both parsers turn a bare
// hostname into a block rule, so the rules still come out right.
{"two plain domains", "ads.example.com\ntrack.example.com\n", FormatAdblock},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := DetectFormat([]byte(tc.data)); got != tc.want {
t.Errorf("DetectFormat(%q) = %q, want %q", tc.data, got, tc.want)
}
})
}
}
func TestDetectFormatSuspectedBugs(t *testing.T) {
// Real behaviour today, pinned so a future fix shows up as a failure.
// allDigitsOrName() does not know RR type names, so a policy record
// without an explicit TTL/class column is sniffed as a plain domain
// list. Parsing the same content as RPZ works, which isolates the
// detection step.
if got := DetectFormat([]byte("example.com CNAME .\n")); got != FormatPlain {
t.Errorf("DetectFormat(rpz record) = %q, want plain (current behaviour)", got)
}
rules, _, err := Parse(FormatRPZ, []byte("example.com CNAME .\n"), ParseOptions{Subdomains: true})
if err != nil || len(rules) != 1 {
t.Errorf("explicit RPZ parse of the same record: rules=%v err=%v", ruleStrings(rules), err)
}
// The "0 address" form that FormatHosts documents as accepted is not an
// IP literal for netip, so the hosts parser counts the line as malformed
// instead of blocking the name.
_, stats, err := Parse(FormatHosts, []byte("0 ads.example.com\n"), ParseOptions{Subdomains: true})
if err != nil {
t.Fatalf("Parse: %v", err)
}
if stats.Rules != 0 || stats.Malformed != 1 {
t.Errorf("\"0 ads.example.com\": rules=%d malformed=%d, want 0/1 (current behaviour)",
stats.Rules, stats.Malformed)
}
}
func TestParseAutoPicksFormat(t *testing.T) {
rules, stats, err := Parse(FormatAuto, []byte("0.0.0.0 ads.example.com\n"), ParseOptions{Subdomains: true})
if err != nil {
t.Fatalf("Parse: %v", err)
}
if stats.Format != FormatHosts {
t.Errorf("Format = %q, want hosts", stats.Format)
}
if len(rules) != 1 || rules[0].Name != "**.ads.example.com" {
t.Errorf("rules = %v", ruleStrings(rules))
}
// An empty format string means "sniff" too.
if _, stats, _ := Parse("", []byte("||ads.example.com^\n"), ParseOptions{Subdomains: true}); stats.Format != FormatAdblock {
t.Errorf("Format = %q, want adblock", stats.Format)
}
if _, _, err := Parse("bogus", []byte("x"), ParseOptions{}); err == nil {
t.Errorf("Parse with an unknown format returned no error")
}
}
func TestParseRobustness(t *testing.T) {
long := "long" + strings.Repeat("x", 100000) + ".example.com"
bigDomain := strings.Repeat("d", 250) + ".example.com" // over the 253-char limit
hugeDomain := strings.Repeat("d", 3000) + ".example.com"
listSamples := map[string]string{
"crlf": "0.0.0.0 ads.example.com\r\n0.0.0.0 b.example.com\r\n",
"lone cr": "0.0.0.0 ads.example.com\r0.0.0.0 b.example.com\r",
"leading/trailing space": "\t 0.0.0.0 ads.example.com \n",
"blank and space-only": "\n\n \n\t\n",
"very long line": "0.0.0.0 " + long + "\n",
"over-long label": "0.0.0.0 " + bigDomain + "\n",
"comments everywhere": "# c\n; c\n0.0.0.0 ads.example.com # tail\n\n",
}
for name, data := range listSamples {
t.Run("list/"+name, func(t *testing.T) {
for _, format := range []Format{FormatHosts, FormatPlain, FormatAdblock} {
rules, stats, err := Parse(format, []byte(data), ParseOptions{Subdomains: true})
if err != nil {
t.Errorf("%s: Parse: %v", format, err)
continue
}
if stats.Rules != len(rules) {
t.Errorf("%s: Rules = %d, want %d", format, stats.Rules, len(rules))
}
for _, r := range rules {
if strings.ContainsAny(r.Name, "\r\n\t ") {
t.Errorf("%s: rule name kept whitespace: %q", format, r.Name)
}
}
}
})
}
// The adblock and plain parsers must not be confused by CRLF either.
for _, data := range []string{
"||ads.example.com^\r\n@@||ok.example.com^\r\n",
"a.example.com\r\nb.example.com\r\n",
} {
for _, format := range []Format{FormatPlain, FormatAdblock} {
rules, _, err := Parse(format, []byte(data), ParseOptions{Subdomains: true})
if err != nil {
t.Errorf("%s: Parse(%q): %v", format, data, err)
continue
}
if len(rules) == 0 {
t.Errorf("%s: Parse(%q) produced no rules", format, data)
}
for _, r := range rules {
if strings.ContainsAny(r.Name, "\r\n\t ") {
t.Errorf("%s: rule name kept whitespace: %q", format, r.Name)
}
}
}
}
// Specifically: CRLF does not leak a CR into the rule names.
rules, _, err := Parse(FormatHosts, []byte("0.0.0.0 ads.example.com\r\n"), ParseOptions{Subdomains: true})
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(rules) != 1 || rules[0].Name != "**.ads.example.com" {
t.Errorf("CRLF rules = %v", ruleStrings(rules))
}
s := mustBuild(rules...)
if s.Match("ads.example.com") == nil || s.Match("x.ads.example.com") == nil {
t.Errorf("CRLF-parsed rule does not match: %v", ruleStrings(rules))
}
// The RPZ parser goes through the zone-file reader: CRLF, comments,
// directives and blank lines are handled there too.
for _, data := range []string{
"$TTL 60\r\nexample.com CNAME .\r\n*.sub.example.org CNAME rpz-drop.\r\n",
"; comment\r\n\r\nexample.com 60 IN CNAME .\n",
"$ORIGIN example.com.\n$TTL 60\nwww IN A 127.0.0.1\n",
} {
zrules, _, err := Parse(FormatRPZ, []byte(data), ParseOptions{Subdomains: true})
if err != nil {
t.Errorf("Parse(rpz %q): %v", data, err)
continue
}
for _, r := range zrules {
if strings.ContainsAny(r.Name, "\r\n\t ") {
t.Errorf("rule name kept whitespace: %q", r.Name)
}
}
}
// A line longer than the scanner's 4 MiB cap is reported, not silently
// truncated.
_, _, err = Parse(FormatPlain, []byte("a.example.com\n"+hugeDomain+"\n"), ParseOptions{Subdomains: true})
if err == nil {
t.Errorf("an over-long line parsed without error")
} else {
t.Logf("over-long line error (expected): %v", err)
}
}
func TestParseBOMBehaviour(t *testing.T) {
// A UTF-8 BOM on the first line is not stripped by the parsers, so the
// first rule of the list is lost. Real behaviour, pinned.
const bom = "\ufeff"
_, stats, err := Parse(FormatAuto, []byte(bom+"0.0.0.0 ads.example.com\n"), ParseOptions{Subdomains: true})
if err != nil {
t.Fatalf("Parse: %v", err)
}
t.Logf("BOM sample: format=%s rules=%d malformed=%d notices=%v",
stats.Format, stats.Rules, stats.Malformed, stats.Notices)
if stats.Rules != 0 || stats.Malformed != 1 {
t.Errorf("BOM sample: rules=%d malformed=%d, want 0/1 (current behaviour)",
stats.Rules, stats.Malformed)
}
// Without the BOM the same content parses.
_, ok, err := Parse(FormatAuto, []byte("0.0.0.0 ads.example.com\n"), ParseOptions{Subdomains: true})
if err != nil {
t.Fatalf("Parse: %v", err)
}
if ok.Rules != 1 {
t.Errorf("control: rules = %d, want 1", ok.Rules)
}
// A BOM on an adblock list behaves the same way: the first rule is lost.
abRules, abStats, err := Parse(FormatAdblock, []byte(bom+"||ads.example.com^\n"), ParseOptions{Subdomains: true})
if err != nil {
t.Fatalf("Parse: %v", err)
}
t.Logf("BOM adblock: rules=%v skipped=%d", ruleStrings(abRules), abStats.Skipped)
if abStats.Rules != 0 {
t.Errorf("BOM adblock rules = %v, want none (current behaviour)", ruleStrings(abRules))
}
}
func TestParsedRulesCompileAndMatch(t *testing.T) {
// End to end: an adblock list with a broad root rule plus a narrow
// allow rule compiles into a set where the allow wins for the name it
// names and the root still covers everything else.
data := "||example.com^\n@@||metrics.example.com^\n"
rules, _, err := Parse(FormatAdblock, []byte(data), ParseOptions{Subdomains: true, DefaultTTL: 300})
if err != nil {
t.Fatalf("Parse: %v", err)
}
b := NewBuilder()
for _, r := range rules {
b.Add(r, "list", []string{"ads"})
}
s := b.Build()
if e := s.Match("ads.example.com"); e == nil || e.Action != ActionBlock {
t.Errorf("ads.example.com: %+v, want block", e)
}
if e := s.Match("example.com"); e == nil || e.Action != ActionBlock {
t.Errorf("example.com: %+v, want block (root rule covers the apex)", e)
}
if e := s.Match("metrics.example.com"); e == nil || !e.IsAllow() {
t.Errorf("metrics.example.com: %+v, want allow", e)
}
if e := s.Match("a.b.metrics.example.com"); e == nil || !e.IsAllow() {
t.Errorf("a.b.metrics.example.com: %+v, want allow (the allow rule is a root rule)", e)
}
for _, r := range rules {
if e := s.Lookup(r.Name); e != nil && e.TTL != 300 {
t.Errorf("TTL = %d, want 300", e.TTL)
}
}
}
+525
View File
@@ -0,0 +1,525 @@
package blocklist
import (
"fmt"
"sync"
"testing"
)
// mustBuild compiles rules into an immutable Set.
func mustBuild(rules ...Rule) *Set {
b := NewBuilder()
for _, r := range rules {
b.Add(r, "test", nil)
}
return b.Build()
}
func block(name string) Rule { return Rule{Name: name, Action: ActionBlock} }
func TestSetMatch(t *testing.T) {
s := mustBuild(
block("example.com"), // exact: the name and nothing else
block("*.wild.example.com"), // strict subdomains only
block("**.root.example.net"), // apex and subdomains
block("**.sub.example.org"), // whole subtree
block("ads.example.com"), // more specific than the exact "example.com"
Rule{Name: "tracker.example.com", Action: ActionAllow}, // exact allow
)
tests := []struct {
name string
query string
want string // stored rule name, "" = no match
kind PatternKind
action Action
explains string
}{
{name: "exact apex", query: "example.com", want: "example.com", kind: PatternExact},
{name: "exact apex trailing dot", query: "example.com.", want: "example.com", kind: PatternExact},
{name: "exact apex uppercase", query: "ExAmPlE.CoM", want: "example.com", kind: PatternExact},
{name: "exact rule does not cover subdomains", query: "sub.example.com", want: "", kind: PatternExact},
{name: "sub wildcard matches subdomain", query: "a.wild.example.com", want: "wild.example.com", kind: PatternSub},
{name: "sub wildcard does not match apex", query: "wild.example.com", want: "", kind: PatternExact},
{name: "root wildcard matches apex", query: "root.example.net", want: "root.example.net", kind: PatternRoot},
{name: "root wildcard matches subdomain", query: "deep.a.root.example.net", want: "root.example.net", kind: PatternRoot},
{name: "subtree", query: "a.b.sub.example.org", want: "sub.example.org", kind: PatternRoot},
{name: "subtree apex", query: "sub.example.org", want: "sub.example.org", kind: PatternRoot},
{name: "most specific exact wins over root", query: "ads.example.com", want: "ads.example.com", kind: PatternExact},
{name: "allow rule reported with its action", query: "tracker.example.com", want: "tracker.example.com", kind: PatternExact, action: ActionAllow},
{name: "unrelated name", query: "unrelated.test", want: ""},
{name: "empty name", query: "", want: ""},
{name: "the root label is normalised away", query: ".", want: ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := s.Match(tc.query)
if tc.want == "" {
if got != nil {
t.Fatalf("Match(%q) = %q, want no match", tc.query, got.Name)
}
return
}
if got == nil {
t.Fatalf("Match(%q) = nil, want %q", tc.query, tc.want)
}
if got.Name != tc.want {
t.Errorf("Match(%q) rule = %q, want %q", tc.query, got.Name, tc.want)
}
wantAction := tc.action
if wantAction == ActionBlock && got.Action != ActionAllow {
wantAction = ActionBlock
}
if got.Action != wantAction {
t.Errorf("Match(%q) action = %v, want %v", tc.query, got.Action, wantAction)
}
if k := s.MatchPattern(tc.query); k != tc.kind {
t.Errorf("MatchPattern(%q) = %v, want %v", tc.query, k, tc.kind)
}
})
}
}
func TestSetMatchLeadingDotAndSubdomainToggle(t *testing.T) {
// splitPattern maps a leading dot to the "**." (root) form.
s := mustBuild(block(".dot.example.com"))
if e := s.Match("dot.example.com"); e == nil {
t.Errorf("leading-dot rule did not match its apex")
}
if e := s.Match("x.dot.example.com"); e == nil {
t.Errorf("leading-dot rule did not match a subdomain")
}
// With Subdomains off the parsers emit exact rules, so nothing below the
// apex is covered.
exact := mustBuild(hostsRule("ads.example.com", ParseOptions{Subdomains: false}))
if e := exact.Match("sub.ads.example.com"); e != nil {
t.Errorf("exact hosts rule matched a subdomain: %q", e.Name)
}
if e := exact.Match("ads.example.com"); e == nil {
t.Errorf("exact hosts rule did not match its own name")
}
if exact.HasWildcards() {
t.Errorf("HasWildcards() = true for an exact-only set")
}
}
func TestSetDropsUnusablePatterns(t *testing.T) {
// "." is "the whole internet" and is never useful: it is dropped. A bare
// TLD, on the other hand, is kept and matches the TLD itself.
s := mustBuild(block("."), block("*.com"), block("com"), block(""), block(" "))
if s.Len() != 2 {
t.Fatalf("Len() = %d, want 2 (exact \"com\" and sub \"com\")", s.Len())
}
if e := s.Match("com"); e == nil || e.Name != "com" {
t.Errorf("Match(\"com\") = %v, want the exact rule \"com\"", e)
}
if e := s.Match("ads.com"); e == nil || e.Name != "com" {
t.Errorf("Match(\"ads.com\") = %v, want sub rule \"com\"", e)
}
}
func TestSetMatchesWholeSubtree(t *testing.T) {
// A single root rule on a TLD covers its whole subtree.
s := mustBuild(block("**.com"))
for _, q := range []string{"com", "example.com", "a.b.c.example.com", "EXAMPLE.COM."} {
if e := s.Match(q); e == nil || e.Name != "com" {
t.Errorf("Match(%q) = %v, want the \"**.com\" rule", q, e)
}
}
for _, q := range []string{"de", "example.de", "com.evil.test"} {
if e := s.Match(q); e != nil {
t.Errorf("Match(%q) = %q, want no match", q, e.Name)
}
}
// The apex is covered by "**." but not by "*.".
sub := mustBuild(block("*.com"))
if e := sub.Match("com"); e != nil {
t.Errorf("\"*.com\" matched the apex: %q", e.Name)
}
if e := sub.Match("example.com"); e == nil {
t.Errorf("\"*.com\" did not match a subdomain")
}
// A single label or an empty name never reaches any map.
if e := s.Match(""); e != nil {
t.Errorf("Match(\"\") = %q, want nil", e.Name)
}
}
func TestNormalizeName(t *testing.T) {
tests := []struct{ in, want string }{
{"example.com", "example.com"},
{"example.com.", "example.com"},
{"EXAMPLE.com..", "example.com."}, // one trailing dot, then lowercased
{"", ""},
{".", ""},
{"..", "."},
}
for _, tc := range tests {
if got := NormalizeName(tc.in); got != tc.want {
t.Errorf("NormalizeName(%q) = %q, want %q", tc.in, got, tc.want)
}
}
// Match must treat "example.com." and "example.com" as the same name,
// and must be case-insensitive.
s := mustBuild(block("example.com"))
if s.Match("example.com.") == nil || s.Match("EXAMPLE.COM") == nil {
t.Errorf("Match is not case/trailing-dot insensitive")
}
}
func TestAllowOverBlockPriority(t *testing.T) {
b := NewBuilder()
b.Add(Rule{Name: "cdn.example.com", Action: ActionBlock}, "blocklist", nil)
b.Add(Rule{Name: "cdn.example.com", Action: ActionAllow}, "allowlist", []string{"user"})
s := b.Build()
e := s.Match("cdn.example.com")
if e == nil {
t.Fatal("no rule matched")
}
if !e.IsAllow() {
t.Errorf("IsAllow() = false for %v", e.Action)
}
if e.Action != ActionAllow {
t.Errorf("action = %v, want allow", e.Action)
}
if e.Source != "allowlist" {
t.Errorf("Source = %q, want %q (the winning rule's list)", e.Source, "allowlist")
}
if e.Priority != 10 {
t.Errorf("Priority = %d, want 10 (allow default)", e.Priority)
}
if got := e.Categories; len(got) != 1 || got[0] != "user" {
t.Errorf("Categories = %v, want [user]", got)
}
c := s.Counts()
if c.Allow != 1 || c.Block != 0 {
t.Errorf("Counts() = %+v, want Allow=1 Block=0 (block rule was replaced)", c)
}
if s.Duplicates() != 1 {
t.Errorf("Duplicates() = %d, want 1", s.Duplicates())
}
if s.Lookup("cdn.example.com").Action != ActionAllow {
t.Errorf("Lookup() disagrees with Match()")
}
}
func TestPriorityTieKeepsFirstRule(t *testing.T) {
b := NewBuilder()
b.Add(Rule{Name: "tie.example.com", Action: ActionBlock, Priority: 5}, "first", nil)
b.Add(Rule{Name: "tie.example.com", Action: ActionBlock, Priority: 5}, "second", nil)
s := b.Build()
if s.Lookup("tie.example.com").Source != "first" {
t.Errorf("on a priority tie the later rule won: source = %q", s.Lookup("tie.example.com").Source)
}
if s.Duplicates() != 1 {
t.Errorf("Duplicates() = %d, want 1", s.Duplicates())
}
}
func TestPassthroughCountsAsAllow(t *testing.T) {
b := NewBuilder()
b.Add(Rule{Name: "pt.example.com", Action: ActionPassthrough}, "rpz", nil)
s := b.Build()
e := s.Match("pt.example.com")
if e == nil || !e.IsAllow() {
t.Fatalf("passthrough rule: IsAllow() = false, e = %v", e)
}
if c := s.Counts(); c.Passthru != 1 || c.Allow != 0 {
t.Errorf("Counts() = %+v, want Passthru=1", c)
}
}
func TestHits(t *testing.T) {
s := mustBuild(block("hit.example.com"), block("*.wild.hit.example.com"))
const n = 3
for i := 0; i < n; i++ {
if s.Match("hit.example.com") == nil {
t.Fatal("setup: no match")
}
}
e := s.Match("hit.example.com")
if got := e.Hits(); got != n+1 {
t.Errorf("Hits() = %d, want %d (every Match counts, including this one)", got, n+1)
}
// Non-matching queries must not bump any counter.
s.Match("miss.example.com")
if got := s.Match("hit.example.com").Hits(); got != n+2 {
t.Errorf("Hits() = %d after a miss, want %d", got, n+2)
}
// A wildcard rule has its own counter, and the shared *Entry is visible
// through every pointer to it.
if got := s.Match("a.wild.hit.example.com").Hits(); got != 1 {
t.Errorf("wildcard Hits() = %d, want 1", got)
}
if got := s.Match("hit.example.com").Hits(); got != n+3 {
t.Errorf("exact Hits() = %d, want %d", got, n+3)
}
// A zero-valued Entry is safe.
var zero Entry
if zero.Hits() != 0 {
t.Errorf("zero Entry Hits() = %d, want 0", zero.Hits())
}
if got := (*Entry)(nil).Hits(); got != 0 {
t.Errorf("nil Entry Hits() = %d, want 0", got)
}
}
func TestDuplicateDomainsAcrossLists(t *testing.T) {
// The same domain from two lists compiles to ONE rule: the dedup happens
// in Builder.Add, keyed on the normalised pattern, and the losing rule is
// dropped (Duplicates counts it) rather than kept as a second entry.
b := NewBuilder()
b.Add(Rule{Name: "Dup.Example.com.", Action: ActionBlock}, "list-a", nil)
b.Add(Rule{Name: "dup.example.com", Action: ActionBlock}, "list-b", nil)
s := b.Build()
if s.Len() != 1 {
t.Fatalf("Len() = %d, want 1 (duplicates are deduped)", s.Len())
}
if s.Duplicates() != 1 {
t.Errorf("Duplicates() = %d, want 1", s.Duplicates())
}
e := s.Match("dup.example.com")
if e == nil {
t.Fatal("no match")
}
if e.Source != "list-a" {
t.Errorf("Source = %q, want list-a (first list wins on a tie)", e.Source)
}
// One Entry => one hit counter, regardless of how many lists listed it.
if got := e.Hits(); got != 1 {
t.Errorf("Hits() = %d, want 1 (deduped rules share one counter)", got)
}
// A higher-priority rule from a later list replaces the earlier one.
b2 := NewBuilder()
b2.Add(Rule{Name: "dup.example.com", Action: ActionBlock}, "list-a", nil)
b2.Add(Rule{Name: "dup.example.com", Action: ActionAllow}, "list-b", nil)
s2 := b2.Build()
if a := s2.Match("dup.example.com"); a == nil || a.Source != "list-b" {
t.Errorf("higher-priority later rule did not win: %+v", a)
}
if s2.Duplicates() != 1 {
t.Errorf("Duplicates() = %d, want 1", s2.Duplicates())
}
}
func TestSetIntrospection(t *testing.T) {
b := NewBuilder()
b.Add(block("exact.example.com"), "a", nil)
b.Add(block("*.sub.example.com"), "b", nil)
b.Add(block("**.root.example.com"), "c", nil)
b.NoteSource(SourceMeta{Name: "a", Format: "hosts", Rules: 1, Bytes: 42, Enabled: true})
s := b.Build()
if got := s.Sources(); len(got) != 3 || got[0] != "a" || got[2] != "c" {
t.Errorf("Sources() = %v, want insertion order [a b c]", got)
}
m, ok := s.SourceMeta("b")
if !ok {
t.Errorf("SourceMeta(\"b\") missing")
}
_ = m
if a, ok := s.SourceMeta("a"); !ok || a.Rules != 1 || a.Bytes != 42 || a.Format != "hosts" {
t.Errorf("SourceMeta(\"a\") = %+v, ok=%v", a, ok)
}
if _, ok := s.SourceMeta("nope"); ok {
t.Errorf("SourceMeta(\"nope\") reported ok")
}
if c := s.Counts(); c.Exact != 1 || c.Sub != 1 || c.Root != 1 || c.Block != 3 {
t.Errorf("Counts() = %+v, want 1/1/1 exact/sub/root and 3 block", c)
}
st := s.Stats()
if st.Total != 3 || st.Exact != 1 || st.Subdomains != 1 || st.RootRules != 1 {
t.Errorf("Stats() = %+v", st)
}
if st.BuiltAt.IsZero() {
t.Errorf("Stats().BuiltAt is zero")
}
// Each renders patterns back with their wildcard prefix.
seen := map[string]PatternKind{}
s.Each(func(pattern string, e *Entry, kind PatternKind) bool {
seen[pattern] = kind
if got := kind.Pattern(e.Name); got != pattern {
t.Errorf("Pattern(%q) = %q, want %q", e.Name, got, pattern)
}
return true
})
if seen["exact.example.com"] != PatternExact || seen["sub.example.com"] != PatternSub ||
seen["root.example.com"] != PatternRoot {
t.Errorf("Each() visited %v", seen)
}
if PatternSub.String() != "subdomain" || PatternRoot.String() != "domain" || PatternExact.String() != "exact" {
t.Errorf("PatternKind.String() values changed")
}
// Lookup is exact-only: it does not walk wildcards.
if s.Lookup("deep.sub.example.com") != nil {
t.Errorf("Lookup() walked wildcards")
}
if s.Lookup("EXACT.example.com.") == nil {
t.Errorf("Lookup() is not normalised")
}
}
func TestDigestStableAndContentSensitive(t *testing.T) {
a := mustBuild(block("x.example.com"), block("*.y.example.com"), block("**.z.example.com"))
b := mustBuild(block("**.z.example.com"), block("x.example.com"), block("*.y.example.com"))
if a.Digest() != b.Digest() {
t.Errorf("Digest() depends on insertion order: %q vs %q", a.Digest(), b.Digest())
}
c := mustBuild(block("x.example.com"), block("*.y.example.com"), block("**.different.example.com"))
if a.Digest() == c.Digest() {
t.Errorf("Digest() ignored a pattern change")
}
// The action is part of the digest.
d := mustBuild(Rule{Name: "x.example.com", Action: ActionAllow})
if a.Digest() == d.Digest() {
t.Errorf("Digest() ignored the action change")
}
}
func TestNilSetIsSafe(t *testing.T) {
var s *Set
if s.Match("example.com") != nil || s.Len() != 0 || s.HasWildcards() ||
s.DuplicatesN() != 0 || s.Sources() != nil || s.AllSourceMeta() != nil ||
s.Digest() != "" || !s.BuiltAt().IsZero() {
t.Errorf("nil *Set accessors are not nil-safe")
}
if _, ok := s.SourceMeta("x"); ok {
t.Errorf("nil Set SourceMeta reported ok")
}
if s.Lookup("x") != nil || s.MatchPattern("x") != PatternExact {
t.Errorf("nil Set lookup misbehaved")
}
if st := s.Stats(); st.Total != 0 || st.PerSource == nil {
t.Errorf("nil Stats() = %+v", st)
}
s.Each(func(string, *Entry, PatternKind) bool { t.Errorf("nil Each called fn"); return true })
}
func TestActionStringAndParseAction(t *testing.T) {
all := []struct {
a Action
str string
}{
{ActionBlock, "block"},
{ActionAllow, "allow"},
{ActionPassthrough, "passthrough"},
{ActionDrop, "drop"},
{ActionNXDomain, "nxdomain"},
{ActionZero, "zero"},
{ActionCNAME, "cname"},
{ActionRedirect, "redirect"},
{Action(99), "unknown"},
}
for _, tc := range all {
if got := tc.a.String(); got != tc.str {
t.Errorf("Action(%d).String() = %q, want %q", tc.a, got, tc.str)
}
}
roundtrip := map[string]Action{
"": ActionBlock,
"block": ActionBlock,
"ALLOW": ActionAllow,
" whitelist ": ActionAllow,
"pass": ActionPassthrough,
"drop": ActionDrop,
"nxdomain": ActionNXDomain,
"null": ActionZero,
"cname": ActionCNAME,
"redirect": ActionRedirect,
}
for in, want := range roundtrip {
got, ok := ParseAction(in)
if !ok || got != want {
t.Errorf("ParseAction(%q) = %v,%v; want %v,true", in, got, ok, want)
}
}
if got, ok := ParseAction("nonsense"); ok || got != ActionBlock {
t.Errorf("ParseAction(\"nonsense\") = %v,%v; want block,false", got, ok)
}
}
func TestHolderSwapIsAtomic(t *testing.T) {
old := mustBuild(block("a.example.com"))
newer := mustBuild(block("b.example.com"))
h := NewHolder(old)
if h.Get() != old {
t.Fatalf("NewHolder did not install the set")
}
var empty *Holder
if empty.Get() != nil {
t.Errorf("zero Holder is not empty")
}
// A reader either sees the whole old set or the whole new one.
var wg sync.WaitGroup
stop := make(chan struct{})
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
s := h.Get()
if s == nil {
t.Error("holder returned nil")
return
}
if e := s.Match("a.example.com"); e == nil {
t.Error("observed a half-applied set")
return
}
}
}()
}
for i := 0; i < 50; i++ {
h.Set(newer)
h.Set(old)
}
close(stop)
wg.Wait()
if h.Get() != old {
t.Errorf("holder lost the last swap")
}
}
func BenchmarkSetMatch(b *testing.B) {
// A mixed set, like a compiled blocklist: mostly exact rules plus some
// subdomain and root rules so the suffix walk is exercised.
bld := NewBuilder()
for i := 0; i < 2000; i++ {
bld.Add(block(fmt.Sprintf("host%d.ads-%d.example.com", i, i%7)), "bench", nil)
}
for i := 0; i < 200; i++ {
bld.Add(block(fmt.Sprintf("*.wild%d.example.net", i)), "bench", nil)
bld.Add(block(fmt.Sprintf("**.root%d.example.org", i)), "bench", nil)
}
s := bld.Build()
queries := []string{
"host0.ads-0.example.com", // exact hit
"host1999.ads-5.example.com", // exact hit, different list
"a.b.wild199.example.net", // wildcard suffix hit
"x.root42.example.org", // root rule
"nothing.in.here.test", // pure miss, walks every suffix
}
b.ReportAllocs()
b.ResetTimer()
var sink int
for i := 0; i < b.N; i++ {
if e := s.Match(queries[i%len(queries)]); e != nil {
sink++
}
}
if sink == 0 {
b.Fatal("no matches at all")
}
}
+6 -2
View File
@@ -18,6 +18,7 @@ package cache
import (
"encoding/binary"
"strings"
"sync/atomic"
"time"
"github.com/miekg/dns"
@@ -160,8 +161,11 @@ type Entry struct {
StaleUntil time.Time
// Bytes is the accounted size, used by the LRU and the size gauges.
Bytes int
// Hits counts serves since it was stored.
Hits uint32
// Hits counts serves since it was stored. It is atomic because an Entry is
// shared by every goroutine that hits the key (the LRU hands out the same
// pointer on purpose, so a hit never copies the message), and the
// counter is bumped outside the shard lock on the hot path.
Hits atomic.Uint32
// Neg marks a cached NXDOMAIN or NODATA answer.
Neg bool
// Truncated records that the upstream answer did not fit in 512 bytes,
+868
View File
@@ -0,0 +1,868 @@
package cache
import (
"errors"
"math/rand/v2"
"net"
"os"
"path/filepath"
"strconv"
"sync"
"testing"
"time"
"github.com/miekg/dns"
)
// fakeClock is a manually advanced clock so TTL, ageing and serve-stale
// behaviour is tested without sleeping.
type fakeClock struct {
mu sync.Mutex
t time.Time
}
func newClock() *fakeClock { return &fakeClock{t: time.Date(2024, 5, 1, 12, 0, 0, 0, time.UTC)} }
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.t
}
func (c *fakeClock) Advance(d time.Duration) {
c.mu.Lock()
c.t = c.t.Add(d)
c.mu.Unlock()
}
// answer builds a packed A response for name.
func answer(t *testing.T, name string, ttl uint32, ip string) *dns.Msg {
t.Helper()
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), dns.TypeA)
m.Response = true
m.Answer = []dns.RR{&dns.A{
Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl},
A: net.ParseIP(ip).To4(),
}}
return m
}
func key(name string) Key { return QueryKey(name, dns.TypeA, dns.ClassINET, false, 0) }
func TestLRUGetPutAndHitKinds(t *testing.T) {
clk := newClock()
l := NewLRU(100, 4)
m := answer(t, "example.com", 60, "1.2.3.4")
wire, err := m.Pack()
if err != nil {
t.Fatalf("pack: %v", err)
}
k := key("example.com")
now := clk.Now()
l.Put(k, &Entry{Wire: wire, TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(90 * time.Second), Bytes: len(wire)})
if e, kind := l.Get(k, now); e == nil || kind != Fresh {
t.Fatalf("fresh hit: got entry=%v kind=%v, want non-nil/Fresh", e, kind)
}
// One second later the entry is still fresh; after the TTL it is stale but
// answerable until StaleUntil, and past that it is a miss and dropped.
clk.Advance(59 * time.Second)
if _, kind := l.Get(k, clk.Now()); kind != Fresh {
t.Errorf("at TTL-1s: kind = %v, want Fresh", kind)
}
clk.Advance(2 * time.Second)
e, kind := l.Get(k, clk.Now())
if e == nil || kind != Stale {
t.Fatalf("just past TTL: got entry=%v kind=%v, want non-nil/Stale", e, kind)
}
if got := e.Hits.Load(); got != 3 {
t.Errorf("entry Hits = %d, want 3 (every Get counts, fresh or stale)", got)
}
clk.Advance(31 * time.Second) // now past StaleUntil (TTL+30s)
if e, kind := l.Get(k, clk.Now()); e != nil || kind != Miss {
t.Fatalf("past stale window: got entry=%v kind=%v, want nil/Miss", e, kind)
}
if got := l.Stats().Entries; got != 0 {
t.Errorf("entries after a dead read = %d, want 0 (the entry must be dropped)", got)
}
st := l.Stats()
if st.Hits != 2 || st.StaleHits != 1 || st.Misses != 2 {
t.Errorf("stats = %+v, want hits=2 staleHits=1 misses=2", st)
}
}
func TestLRUEvictionKeepsRecentlyUsed(t *testing.T) {
// One shard so the budget is exactly what we set: eviction order is then
// fully determined and the test can assert it.
l := NewLRU(4, 1)
clk := newClock()
now := clk.Now()
put := func(name string) {
l.Put(key(name), &Entry{TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(time.Minute), Bytes: 8})
}
put("a.example.com")
put("b.example.com")
put("c.example.com")
// Touch a so it is the most recent, then overflow the 4-entry budget.
if _, kind := l.Get(key("a.example.com"), now); kind != Fresh {
t.Fatalf("a: kind = %v, want Fresh", kind)
}
put("d.example.com")
put("e.example.com") // 5 inserts, budget 4
if got := l.Stats().Entries; got > 4 {
t.Fatalf("entries = %d, want <= 4 (per-shard budget)", got)
}
if _, kind := l.Get(key("a.example.com"), now); kind != Fresh {
t.Errorf("a was recently used, want it kept")
}
if _, kind := l.Get(key("b.example.com"), now); kind != Miss {
t.Errorf("b was the least recently used, want it evicted")
}
if l.Stats().Evictions == 0 {
t.Error("evictions = 0, want > 0")
}
}
func TestLRUDeleteWhereAndFlush(t *testing.T) {
l := NewLRU(100, 2)
now := newClock().Now()
names := []string{"x.example.com", "y.example.com", "z.other.net"}
for _, n := range names {
l.Put(key(n), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
}
dropped := l.DeleteWhere(func(k Key, _ *Entry) bool {
return k.Name == "x.example.com" || k.Name == "y.example.com"
})
if dropped != 2 {
t.Errorf("DeleteWhere = %d, want 2", dropped)
}
if _, kind := l.Get(key("x.example.com"), now); kind != Miss {
t.Error("x.example.com still present after DeleteWhere")
}
if _, kind := l.Get(key("z.other.net"), now); kind != Fresh {
t.Error("z.other.net must survive an unrelated DeleteWhere")
}
l.Flush()
if got := l.Stats().Entries; got != 0 {
t.Errorf("entries after Flush = %d, want 0", got)
}
if l.Stats().Bytes != 0 {
t.Errorf("bytes after Flush = %d, want 0", l.Stats().Bytes)
}
}
func TestLRUWalkVisitsEveryEntryOnce(t *testing.T) {
l := NewLRU(100, 4)
now := newClock().Now()
for i := range 200 {
n := "n" + strconv.Itoa(i) + ".example.com"
l.Put(key(n), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
}
seen := make(map[string]int, 200)
l.Walk(func(k Key, _ *Entry) bool {
seen[k.Name]++
return true
})
if len(seen) != 200 {
t.Fatalf("Walk visited %d names, want 200", len(seen))
}
for n, c := range seen {
if c != 1 {
t.Fatalf("%s visited %d times, want 1", n, c)
}
}
// Returning false stops the walk.
count := 0
l.Walk(func(Key, *Entry) bool { count++; return false })
if count != 1 {
t.Errorf("Walk with stop visited %d entries, want 1", count)
}
}
func TestSetCapacityEvictsDownToTheNewBudget(t *testing.T) {
l := NewLRU(100, 1)
now := newClock().Now()
for i := range 50 {
n := "n" + strconv.Itoa(i) + ".example.com"
l.Put(key(n), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
}
l.SetCapacity(10, 1)
if got := l.Stats().Entries; got > 10 {
t.Errorf("entries after SetCapacity(10) = %d, want <= 10", got)
}
if got := l.Stats().Shards; got != 1 {
t.Errorf("shards after SetCapacity(10, 1) = %d, want 1", got)
}
// The cache must still work after a resize.
l.Put(key("after-resize.example.com"), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
if _, kind := l.Get(key("after-resize.example.com"), now); kind != Fresh {
t.Error("a Put after a resize must be readable")
}
}
func TestLRUConcurrentPutGetResize(t *testing.T) {
// Exercises the resizing path: SetCapacity rewrites the per-shard budget
// while other goroutines insert and read. Run with -race.
l := NewLRU(512, 8)
now := newClock().Now()
var wg sync.WaitGroup
stop := make(chan struct{})
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
l.SetCapacity(200, 4)
l.SetCapacity(4096, 16)
}
}
}()
for g := range 8 {
wg.Add(1)
go func(g int) {
defer wg.Done()
for i := range 500 {
n := "g" + strconv.Itoa(g) + "-n" + strconv.Itoa(i) + ".example.com"
k := key(n)
l.Put(k, &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
e, _ := l.Get(k, now)
if e != nil {
_ = e.Hits.Load()
}
}
}(g)
}
// Readers keep walking while the resizer runs.
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
l.Walk(func(Key, *Entry) bool { return true })
}
}
}()
for g := 1; g < 8; g++ {
_ = g
}
wg.Wait()
close(stop)
if l.Stats().Bytes < 0 {
t.Errorf("byte accounting went negative: %d", l.Stats().Bytes)
}
}
func TestKeyPackUnpackRoundTrip(t *testing.T) {
cases := []Key{
{Name: "example.com", Type: dns.TypeA, Class: dns.ClassINET},
{Name: "a.b.c.d.example.com", Type: dns.TypeAAAA, Class: dns.ClassINET, DO: true},
{Name: "x", Type: dns.TypeMX, Class: dns.ClassCHAOS, Scope: 3},
{Name: "wildcard.example.com", Type: dns.TypeTXT, Class: dns.ClassINET, DO: true, Scope: 7},
}
for _, k := range cases {
buf, err := packKey(nil, k)
if err != nil {
t.Fatalf("packKey(%v): %v", k, err)
}
got, rest, err := unpackKey(buf)
if err != nil {
t.Fatalf("unpackKey(%v): %v", k, err)
}
if len(rest) != 0 {
t.Errorf("unpackKey(%v) left %d trailing bytes, want 0", k, len(rest))
}
if got != k {
t.Errorf("round trip: got %+v, want %+v", got, k)
}
}
// The wire name survives, so the canonical form comes back normalised.
buf, err := packKey(nil, Key{Name: "Example.COM", Type: dns.TypeA, Class: dns.ClassINET})
if err != nil {
t.Fatalf("packKey: %v", err)
}
got, _, err := unpackKey(buf)
if err != nil {
t.Fatalf("unpackKey: %v", err)
}
if got.Name != "example.com" {
t.Errorf("unpacked name = %q, want %q", got.Name, "example.com")
}
}
func TestKeyPackRejectsBadInput(t *testing.T) {
if _, err := packKey(nil, Key{Name: string(make([]byte, 256*4+1))}); !errors.Is(err, errKeyTooLong) {
t.Errorf("packKey(oversized name) error = %v, want errKeyTooLong", err)
}
if _, err := packKey(nil, Key{Name: "a..b", Type: dns.TypeA}); err == nil {
t.Error("packKey(name with an empty label) error = nil, want an error")
}
if _, _, err := unpackKey([]byte{0x05, 0x00}); !errors.Is(err, errBadKey) {
t.Errorf("unpackKey(truncated) error = %v, want errBadKey", err)
}
if _, _, err := unpackKey(nil); !errors.Is(err, errBadKey) {
t.Errorf("unpackKey(empty) error = %v, want errBadKey", err)
}
}
func TestQueryKeyCanonicalises(t *testing.T) {
a := QueryKey("Example.COM.", dns.TypeA, dns.ClassINET, false, 0)
b := QueryKey("example.com", dns.TypeA, dns.ClassINET, false, 0)
if a != b {
t.Errorf("QueryKey did not canonicalise: %+v != %+v", a, b)
}
if a.Name != "example.com" {
t.Errorf("canonical name = %q, want %q", a.Name, "example.com")
}
}
func TestEntryAgeTTL(t *testing.T) {
now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
cases := []struct {
name string
e Entry
now time.Time
want uint32
}{
{"fresh", Entry{TTL: 100, CreatedAt: now}, now.Add(30 * time.Second), 70},
{"expiring", Entry{TTL: 100, CreatedAt: now}, now.Add(100 * time.Second), 0},
{"past", Entry{TTL: 100, CreatedAt: now}, now.Add(500 * time.Second), 0},
{"zero created keeps the stored TTL", Entry{TTL: 100}, now, 100},
{"sub-second ageing truncates to 0 elapsed", Entry{TTL: 1, CreatedAt: now}, now.Add(400 * time.Millisecond), 1},
}
for _, tc := range cases {
if got := tc.e.AgeTTL(tc.now); got != tc.want {
t.Errorf("%s: AgeTTL = %d, want %d", tc.name, got, tc.want)
}
}
}
func TestManagerTTLFor(t *testing.T) {
clk := newClock()
m := NewManager(Options{MinTTL: 0, MaxTTL: time.Hour, NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
soa := func(ttl uint32) *dns.SOA {
return &dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: ttl}, Minttl: ttl}
}
t.Run("answers use the lowest TTL and clamp", func(t *testing.T) {
m := NewManager(Options{MinTTL: 10 * time.Second, MaxTTL: time.Hour, Now: clk.Now}, nil)
msg := answer(t, "a.example.com", 300, "1.2.3.4")
msg.Answer = append(msg.Answer, &dns.A{
Hdr: dns.RR_Header{Name: "a.example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 5},
A: net.ParseIP("1.2.3.5").To4(),
})
ttl, neg := m.TTLFor(msg)
if neg {
t.Error("neg = true for a positive answer")
}
if ttl != 10 {
t.Errorf("ttl = %d, want 10 (5s clamped up to MinTTL)", ttl)
}
})
t.Run("clamp down to MaxTTL", func(t *testing.T) {
m := NewManager(Options{MaxTTL: 60 * time.Second, Now: clk.Now}, nil)
ttl, _ := m.TTLFor(answer(t, "a.example.com", 86400, "1.2.3.4"))
if ttl != 60 {
t.Errorf("ttl = %d, want 60 (clamped to MaxTTL)", ttl)
}
})
t.Run("NXDOMAIN takes the SOA TTL", func(t *testing.T) {
m := NewManager(Options{NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
msg := new(dns.Msg)
msg.SetQuestion("nope.example.com.", dns.TypeA)
msg.Response = true
msg.Rcode = dns.RcodeNameError
msg.Ns = []dns.RR{soa(45)}
ttl, neg := m.TTLFor(msg)
if !neg {
t.Error("neg = false for NXDOMAIN")
}
if ttl != 45 {
t.Errorf("ttl = %d, want 45", ttl)
}
})
t.Run("NODATA takes the SOA TTL and counts as negative", func(t *testing.T) {
m := NewManager(Options{NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
msg := new(dns.Msg)
msg.SetQuestion("nope.example.com.", dns.TypeA)
msg.Response = true
msg.Ns = []dns.RR{soa(12)}
ttl, neg := m.TTLFor(msg)
if !neg || ttl != 12 {
t.Errorf("NODATA: ttl = %d neg = %v, want 12/true", ttl, neg)
}
})
t.Run("NODATA without an SOA falls back to NegativeTTL", func(t *testing.T) {
m := NewManager(Options{NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
msg := new(dns.Msg)
msg.SetQuestion("nope.example.com.", dns.TypeA)
msg.Response = true
ttl, neg := m.TTLFor(msg)
if !neg || ttl != 30 {
t.Errorf("ttl = %d neg = %v, want 30/true", ttl, neg)
}
})
t.Run("truncated answers are never cached", func(t *testing.T) {
msg := answer(t, "big.example.com", 600, "1.2.3.4")
msg.Truncated = true
if ttl, _ := m.TTLFor(msg); ttl != 0 {
t.Errorf("ttl = %d for a truncated answer, want 0", ttl)
}
})
t.Run("transient rcodes are never cached", func(t *testing.T) {
for _, rc := range []int{dns.RcodeServerFailure, dns.RcodeRefused, dns.RcodeNotAuth} {
msg := answer(t, "a.example.com", 600, "1.2.3.4")
msg.Rcode = rc
if ttl, _ := m.TTLFor(msg); ttl != 0 {
t.Errorf("rcode %d: ttl = %d, want 0", rc, ttl)
}
}
})
t.Run("nil message", func(t *testing.T) {
if ttl, _ := m.TTLFor(nil); ttl != 0 {
t.Errorf("ttl = %d, want 0", ttl)
}
})
}
func TestManagerPutResponseGetAndAgeing(t *testing.T) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
k := key("www.example.com")
m.PutResponse(k, answer(t, "www.example.com", 120, "93.184.216.34"), "127.0.0.53:53")
res := m.Get(k)
if res.Kind != Fresh {
t.Fatalf("kind = %v, want Fresh", res.Kind)
}
if res.TTL != 120 {
t.Errorf("TTL = %d, want 120", res.TTL)
}
if res.Upstream != "127.0.0.53:53" {
t.Errorf("upstream = %q, want the address it came from", res.Upstream)
}
if got := res.Msg.Answer[0].(*dns.A).A.String(); got != "93.184.216.34" {
t.Errorf("answer = %s, want 93.184.216.34", got)
}
// The stored copy is private: mutating the returned message must not
// change what the next reader sees.
res.Msg.Answer[0].(*dns.A).A = net.ParseIP("9.9.9.9").To4()
again := m.Get(k)
if got := again.Msg.Answer[0].(*dns.A).A.String(); got != "93.184.216.34" {
t.Errorf("answer after mutating the first result = %s, want an independent copy", got)
}
// TTLs age down monotonically.
clk.Advance(30 * time.Second)
if got := m.Get(k).TTL; got != 90 {
t.Errorf("TTL after 30s = %d, want 90", got)
}
if got := m.Get(k).Msg.Answer[0].Header().Ttl; got != 90 {
t.Errorf("record TTL after 30s = %d, want 90", got)
}
}
func TestManagerServeStale(t *testing.T) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, ServeStale: time.Minute, Now: clk.Now}, nil)
k := key("stale.example.com")
m.PutResponse(k, answer(t, "stale.example.com", 60, "10.0.0.1"), "up")
clk.Advance(90 * time.Second) // 30s past the TTL, inside the stale window
res := m.Get(k)
if res.Kind != Stale {
t.Fatalf("kind = %v, want Stale", res.Kind)
}
if !res.ServedStale {
t.Error("ServedStale = false on a stale hit")
}
if res.TTL != 0 {
t.Errorf("TTL = %d on a stale hit, want 0 (the handler forces TTL 1)", res.TTL)
}
clk.Advance(61 * time.Second) // now past TTL+ServeStale
if res := m.Get(k); res.Kind != Miss {
t.Errorf("kind = %v past the stale window, want Miss", res.Kind)
}
}
func TestManagerServeStaleDisabled(t *testing.T) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
k := key("nostale.example.com")
m.PutResponse(k, answer(t, "nostale.example.com", 60, "10.0.0.2"), "up")
clk.Advance(61 * time.Second)
if res := m.Get(k); res.Kind != Miss {
t.Errorf("kind = %v, want Miss when ServeStale is 0", res.Kind)
}
}
func TestManagerPutResponseRespectsZeroTTL(t *testing.T) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
k := key("nocache.example.com")
m.PutResponse(k, answer(t, "nocache.example.com", 0, "10.0.0.3"), "up")
if res := m.Get(k); res.Kind != Miss {
t.Errorf("kind = %v, want Miss: a zero TTL means do not cache", res.Kind)
}
}
func TestManagerDeleteNameAndSubtree(t *testing.T) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
now := clk.Now()
names := []string{"blocked.example.com", "deep.sub.blocked.example.com", "keep.example.com", "notblocked.example.com"}
for _, n := range names {
m.PutResponse(key(n), answer(t, n, 600, "10.0.0.4"), "up")
}
// "sub.blocked.example.com" must not be caught by the "blocked.example.com"
// subtree walk, but "blocked.example.com" must not catch
// "notblocked.example.com" either.
if n := m.DeleteName("Blocked.Example.Com."); n != 1 {
t.Errorf("DeleteName = %d, want 1 (canonicalised, one name)", n)
}
if res := m.Get(key("blocked.example.com")); res.Kind != Miss {
t.Error("deleted name still served")
}
if n := m.DeleteSubtree("deep.sub.blocked.example.com"); n != 1 {
t.Errorf("DeleteSubtree = %d, want 1", n)
}
for _, n := range []string{"keep.example.com", "notblocked.example.com"} {
if res := m.Get(key(n)); res.Kind != Fresh {
t.Errorf("%s was dropped by an unrelated delete", n)
}
}
_ = now
}
func TestManagerTopEntriesCountsAndFlush(t *testing.T) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
for range 5 {
m.PutResponse(key("hot.example.com"), answer(t, "hot.example.com", 300, "10.0.0.5"), "up")
}
for range 2 {
m.PutResponse(key("warm.example.com"), answer(t, "warm.example.com", 300, "10.0.0.6"), "up")
}
top := m.TopEntries(10)
if len(top) < 2 {
t.Fatalf("TopEntries returned %d rows, want at least 2", len(top))
}
// Each Put counts too, so a name queried once still shows up.
var hot, warm uint64
for _, e := range top {
switch e.Name {
case "hot.example.com":
hot = e.Hits
case "warm.example.com":
warm = e.Hits
}
}
if hot < 5 || warm < 2 {
t.Errorf("top hits: hot=%d warm=%d, want >=5 and >=2", hot, warm)
}
if top[0].Name != "hot.example.com" {
t.Errorf("first row = %s, want the hottest name first", top[0].Name)
}
m.Flush()
if got := len(m.TopEntries(10)); got != 0 {
t.Errorf("TopEntries after Flush = %d rows, want 0", got)
}
}
func TestManagerStatsShape(t *testing.T) {
clk := newClock()
m := NewManager(Options{Size: 100, Shards: 4, MaxTTL: time.Hour, MinTTL: time.Second, Now: clk.Now}, nil)
m.PutResponse(key("a.example.com"), answer(t, "a.example.com", 60, "10.0.0.7"), "up")
m.Get(key("a.example.com"))
m.Get(key("miss.example.com"))
s := m.Stats()
if !s.Enabled {
t.Error("Enabled = false on a live manager")
}
if s.Entries != 1 {
t.Errorf("Entries = %d, want 1", s.Entries)
}
if s.Hits != 1 || s.Misses != 1 {
t.Errorf("hits = %d misses = %d, want 1/1", s.Hits, s.Misses)
}
if s.Size != 100 || s.Shards != 4 {
t.Errorf("size/shards = %d/%d, want 100/4", s.Size, s.Shards)
}
if s.MemoryHuman == "" || s.Bytes <= 0 {
t.Errorf("memory accounting: bytes = %d human = %q, want a positive byte count and a human string", s.Bytes, s.MemoryHuman)
}
if s.Persist.Enabled {
t.Error("Persist.Enabled = true without a store")
}
}
func TestHumanBytes(t *testing.T) {
cases := []struct {
in int64
want string
}{
{0, "0 B"},
{512, "512 B"},
{1024, "1.0 KiB"},
{1536, "1.5 KiB"},
{1024 * 1024, "1.0 MiB"},
{3 * 1024 * 1024 * 1024 / 2, "1.50 GiB"},
{-1024, "-1.0 KiB"},
}
for _, tc := range cases {
if got := HumanBytes(tc.in); got != tc.want {
t.Errorf("HumanBytes(%d) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestStoreSnapshotAndLoad(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cache.db")
st, err := OpenStore(StoreOptions{Path: path})
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer st.Close()
clk := newClock()
now := clk.Now()
l := NewLRU(100, 2)
for i := range 25 {
n := "n" + strconv.Itoa(i) + ".example.com"
wire, err := answer(t, n, 600, "10.0.0.8").Pack()
if err != nil {
t.Fatalf("pack: %v", err)
}
l.Put(key(n), &Entry{
Wire: wire, TTL: 600, Origin: "upstream", Upstream: "1.1.1.1:53",
CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire),
})
}
written, err := st.Snapshot(l, now)
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
if written != 25 {
t.Errorf("Snapshot wrote %d entries, want 25", written)
}
if fi, err := os.Stat(path); err != nil || fi.Size() == 0 {
t.Fatalf("snapshot file missing or empty: %v", err)
}
// A fresh LRU restored from the file keeps the answers and their metadata.
restored := NewLRU(100, 2)
loaded, skipped, err := st.Load(restored, now)
if err != nil {
t.Fatalf("Load: %v", err)
}
if loaded != 25 || skipped != 0 {
t.Errorf("Load = %d loaded, %d skipped, want 25/0", loaded, skipped)
}
e, kind := restored.Get(key("n7.example.com"), now)
if e == nil || kind != Fresh {
t.Fatalf("restored entry: e = %v kind = %v, want non-nil/Fresh", e, kind)
}
if e.Upstream != "1.1.1.1:53" || e.Origin != "upstream" {
t.Errorf("restored metadata: origin = %q upstream = %q, want upstream/1.1.1.1:53", e.Origin, e.Upstream)
}
// The wire is intact.
msg := new(dns.Msg)
if err := msg.Unpack(e.Wire); err != nil {
t.Fatalf("unpack restored wire: %v", err)
}
if len(msg.Answer) != 1 {
t.Errorf("restored answer has %d records, want 1", len(msg.Answer))
}
}
func TestStoreLoadSkipsExpiredEntries(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cache.db")
st, err := OpenStore(StoreOptions{Path: path})
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer st.Close()
clk := newClock()
now := clk.Now()
l := NewLRU(100, 1)
for i, ttl := range []uint32{600, 600, 5} {
n := "n" + strconv.Itoa(i) + ".example.com"
wire, _ := answer(t, n, ttl, "10.0.0.9").Pack()
l.Put(key(n), &Entry{
Wire: wire, TTL: ttl, CreatedAt: now, ExpiresAt: now.Add(time.Duration(ttl) * time.Second),
StaleUntil: now.Add(time.Duration(ttl)*time.Second + 30*time.Second), Bytes: len(wire),
})
}
if _, err := st.Snapshot(l, now); err != nil {
t.Fatalf("Snapshot: %v", err)
}
// Load 10s later: the 5s entry is dead, the two 600s entries are fresh.
restored := NewLRU(100, 1)
loaded, skipped, err := st.Load(restored, now.Add(10*time.Second))
if err != nil {
t.Fatalf("Load: %v", err)
}
if loaded != 2 {
t.Errorf("loaded = %d, want 2", loaded)
}
if skipped != 1 {
t.Errorf("skipped = %d, want 1", skipped)
}
if _, kind := restored.Get(key("n2.example.com"), now.Add(10*time.Second)); kind != Miss {
t.Errorf("expired entry came back after a reload (kind = %v)", kind)
}
}
func TestStoreTruncateAndStats(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cache.db")
st, err := OpenStore(StoreOptions{Path: path})
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer st.Close()
clk := newClock()
now := clk.Now()
l := NewLRU(100, 1)
wire, _ := answer(t, "a.example.com", 600, "10.0.0.10").Pack()
l.Put(key("a.example.com"), &Entry{Wire: wire, TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire)})
if _, err := st.Snapshot(l, now); err != nil {
t.Fatalf("Snapshot: %v", err)
}
s := st.Stats()
if !s.Enabled || s.Path != path || s.Entries != 1 || s.Writes == 0 || s.Bytes <= 0 {
t.Errorf("Stats = %+v, want enabled with 1 entry and at least one write", s)
}
if !s.LastSnapshot.IsZero() {
t.Logf("LastSnapshot = %v", s.LastSnapshot)
}
if err := st.Truncate(); err != nil {
t.Fatalf("Truncate: %v", err)
}
if s := st.Stats(); s.Entries != 0 {
t.Errorf("entries after Truncate = %d, want 0", s.Entries)
}
restored := NewLRU(100, 1)
loaded, _, err := st.Load(restored, now)
if err != nil {
t.Fatalf("Load after Truncate: %v", err)
}
if loaded != 0 {
t.Errorf("loaded = %d after Truncate, want 0", loaded)
}
}
// TestManagerFlushDropsSnapshot checks the interaction the blocklist API
// depends on: flushing the cache must also clear the persisted snapshot, or a
// restart would resurrect answers an operator just invalidated.
func TestManagerFlushDropsSnapshot(t *testing.T) {
dir := t.TempDir()
st, err := OpenStore(StoreOptions{Path: filepath.Join(dir, "cache.db")})
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer st.Close()
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, st)
k := key("flushed.example.com")
m.PutResponse(k, answer(t, "flushed.example.com", 600, "10.0.0.11"), "up")
if _, err := st.Snapshot(m.LRU(), clk.Now()); err != nil {
t.Fatalf("Snapshot: %v", err)
}
m.Flush()
restored := NewLRU(10, 1)
loaded, _, err := st.Load(restored, clk.Now())
if err != nil {
t.Fatalf("Load: %v", err)
}
if loaded != 0 {
t.Errorf("loaded = %d entries after Flush, want 0", loaded)
}
if res := m.Get(k); res.Kind != Miss {
t.Errorf("kind = %v after Flush, want Miss", res.Kind)
}
}
func TestLRUStatsHitRate(t *testing.T) {
l := NewLRU(100, 1)
now := newClock().Now()
l.Put(key("a.example.com"), &Entry{TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(90 * time.Second), Bytes: 4})
for range 3 {
l.Get(key("a.example.com"), now)
}
for range 1 {
l.Get(key("nothere.example.com"), now)
}
s := l.Stats()
if s.Hits != 3 || s.Misses != 1 {
t.Fatalf("hits/misses = %d/%d, want 3/1", s.Hits, s.Misses)
}
if s.HitRate < 0.74 || s.HitRate > 0.76 {
t.Errorf("HitRate = %v, want ~0.75", s.HitRate)
}
}
func BenchmarkLRUGet(b *testing.B) {
l := NewLRU(1<<16, 16)
now := time.Now()
names := make([]string, 4096)
for i := range names {
names[i] = "n" + strconv.Itoa(i) + ".example.com"
wire, _ := answer(&testing.T{}, names[i], 600, "10.0.0.12").Pack()
l.Put(key(names[i]), &Entry{Wire: wire, TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire)})
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := rand.Int()
for pb.Next() {
l.Get(key(names[i%len(names)]), now)
i += 7
}
})
}
func BenchmarkLRUPut(b *testing.B) {
l := NewLRU(1<<16, 16)
now := time.Now()
b.ResetTimer()
for i := 0; b.Loop(); i++ {
l.Put(key("n"+strconv.Itoa(i)+".example.com"), &Entry{TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: 64})
}
}
func BenchmarkManagerGetUnpack(b *testing.B) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
names := make([]string, 4096)
for i := range names {
names[i] = "n" + strconv.Itoa(i) + ".example.com"
m.PutResponse(key(names[i]), answer(&testing.T{}, names[i], 600, "10.0.0.13"), "up")
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := rand.Int()
for pb.Next() {
m.Get(key(names[i%len(names)]))
i += 7
}
})
}
+10 -10
View File
@@ -22,8 +22,10 @@ type LRU struct {
shards []*shard
mask uint64
// perShard is the per-shard entry budget.
perShard int
// perShard is the per-shard entry budget. It is atomic because a config
// reload resizes the cache from the API goroutine while queries are
// inserting in every shard.
perShard atomic.Int64
// counters are the hot-path stats, kept as atomics so Get does not need
// the lock just to record a hit.
@@ -69,13 +71,10 @@ func NewLRU(size, shards int) *LRU {
l := &LRU{
shards: make([]*shard, shards),
mask: uint64(shards - 1),
perShard: size / shards,
seed: maphash.MakeSeed(),
prefetchGate: make(chan struct{}, 64),
}
if l.perShard < 1 {
l.perShard = 1
}
l.perShard.Store(int64(max(1, size/shards)))
for i := range l.shards {
l.shards[i] = &shard{
items: make(map[Key]*list.Element, 1024),
@@ -140,7 +139,7 @@ func (l *LRU) Get(k Key, now time.Time) (e *Entry, kind HitKind) {
}
s.mu.Unlock()
e.Hits++
e.Hits.Add(1)
if kind == Fresh {
l.hits.Add(1)
} else {
@@ -171,7 +170,8 @@ func (l *LRU) Put(k Key, e *Entry) {
s.bytes += e.Bytes
l.bytes.Add(int64(e.Bytes))
var dropped int
for s.ll.Len() > l.perShard {
budget := int(l.perShard.Load())
for s.ll.Len() > budget {
oldest := s.ll.Back()
if oldest == nil {
break
@@ -347,7 +347,7 @@ func (l *LRU) Stats() LRUStats {
Expired: l.expired.Load(),
HitRate: rate,
Shards: len(l.shards),
Capacity: l.perShard * len(l.shards),
Capacity: int(l.perShard.Load()) * len(l.shards),
}
}
@@ -358,5 +358,5 @@ func (l *LRU) SetCapacity(size, shards int) {
shards = len(l.shards)
}
shards = roundPow2(shards)
l.perShard = max(1, size/shards)
l.perShard.Store(int64(max(1, size/shards)))
}
+16 -6
View File
@@ -90,8 +90,11 @@ type Logger struct {
// seq assigns monotonic ids so the UI can detect drops.
seq atomic.Uint64
// live is a fixed-size ring of the most recent entries.
live []liveEntry
// live is a fixed-size ring of the most recent entries. Each slot is a
// pointer, so a writer on the query path never races a reader walking the
// ring: publishing is one atomic word, and a reader sees either the whole
// entry or the one it replaced.
live []atomic.Pointer[liveEntry]
livePos atomic.Uint32
liveMu sync.RWMutex
liveCond *sync.Cond
@@ -184,7 +187,7 @@ func New(opt Options) (*Logger, error) {
}
l := &Logger{
opts: opt,
live: make([]liveEntry, opt.LiveBuffer),
live: make([]atomic.Pointer[liveEntry], opt.LiveBuffer),
counts: newDomainCounts(opt.TopDomains, opt.TopTTL),
subs: make(map[int]chan Entry),
stop: make(chan struct{}),
@@ -258,9 +261,11 @@ func (l *Logger) Log(e Entry) {
e.ID = l.seq.Add(1)
l.counts.observe(e)
// Live ring.
// Live ring. The slot is published with a single atomic store; a reader
// walking the ring concurrently gets either the old or the new entry,
// never a half-written one.
pos := l.livePos.Add(1) - 1
l.live[pos%uint32(len(l.live))] = liveEntry{entry: e}
l.live[pos%uint32(len(l.live))].Store(&liveEntry{entry: e})
// Fan out to SSE subscribers (non-blocking: a slow consumer drops entries
// rather than stalling the query path).
@@ -435,7 +440,12 @@ func (l *Logger) Recent(limit int) []Entry {
}
for i := uint32(0); i < total; i++ {
idx := (pos - 1 - i) % uint32(len(l.live))
out = append(out, l.live[idx].entry)
le := l.live[idx].Load()
if le == nil {
// Never written (or already aged out of the window).
continue
}
out = append(out, le.entry)
if len(out) >= limit {
break
}
+299 -262
View File
@@ -26,6 +26,10 @@ var ErrNoUpstream = errors.New("resolver: no healthy upstream available")
// ErrCircuitOpen is returned by a single upstream whose breaker is open.
var ErrCircuitOpen = errors.New("resolver: upstream circuit breaker open")
// lookupTimeout bounds a name resolution when building the pool, so a broken
// system resolver cannot stall a config reload.
const lookupTimeout = 2 * time.Second
// Health is an upstream's current state.
type Health string
@@ -40,28 +44,46 @@ const (
HealthUnknown Health = "unknown"
)
// Upstream is one configured server plus its live health.
// upstreamStats is everything mutable about an upstream. It lives behind a
// pointer shared by successive Upstream values for the same server, so a
// reload keeps the live health state while the immutable Upstream itself is
// replaced under the readers' feet.
type upstreamStats struct {
queries atomic.Uint64
failures atomic.Uint64
consecutive atomic.Uint32
latencySum atomic.Uint64
latencyN atomic.Uint64
// ring keeps the last 512 latencies in ms for the UI's p95.
mu sync.Mutex
ring []float64
breakerOpenUntil atomic.Int64
health atomic.Pointer[Health]
lastErr atomic.Pointer[string]
}
func newUpstreamStats() *upstreamStats {
s := &upstreamStats{}
s.health.Store(ptr(HealthUnknown))
return s
}
// Upstream is one configured server: its address, its dial-time
// configuration and a pointer to the live stats it shares across reloads.
//
// An Upstream is immutable after construction. Rebind builds a fresh Upstream
// for every server and swaps the whole pool state with one atomic store, so a
// query in flight keeps using a consistent, unchanging view.
type Upstream struct {
cfg config.UpstreamServer
// addr is the address actually dialled: a bootstrap IP for TLS (SNI keeps
// the hostname) and the literal address otherwise.
addr string
// resolved holds the current bootstrap addresses when the configured
// address is a name.
// resolved holds the bootstrap addresses behind a named upstream.
resolved []string
queries atomic.Uint64
failures atomic.Uint64
consecutive atomic.Uint32
latencySum atomic.Uint64 // microseconds
latencyN atomic.Uint64
p95 []float64 // recent latencies in ms, for the UI
p95mu sync.Mutex
p95ring []float64
breakerOpenUntil atomic.Int64
health atomic.Pointer[Health]
lastErr atomic.Pointer[string]
stats *upstreamStats
}
// Addr is the address the resolver dials.
@@ -70,29 +92,35 @@ func (u *Upstream) Addr() string { return u.addr }
// Config returns the upstream's configuration entry.
func (u *Upstream) Config() config.UpstreamServer { return u.cfg }
// Resolved returns the addresses a named upstream resolved to, if any.
func (u *Upstream) Resolved() []string { return u.resolved }
// Health returns the current health state.
func (u *Upstream) Health() Health {
if h := u.health.Load(); h != nil {
if u == nil || u.stats == nil {
return HealthUnknown
}
if h := u.stats.health.Load(); h != nil {
return *h
}
return HealthUnknown
}
func (u *Upstream) setHealth(h Health) { u.health.Store(&h) }
func (u *Upstream) setHealth(h Health) { u.stats.health.Store(&h) }
// SetError records the last error message for the UI.
func (u *Upstream) SetError(err error) {
if err == nil {
u.lastErr.Store(nil)
u.stats.lastErr.Store(nil)
return
}
s := err.Error()
u.lastErr.Store(&s)
u.stats.lastErr.Store(&s)
}
// LastError returns the last error message, or "".
func (u *Upstream) LastError() string {
if p := u.lastErr.Load(); p != nil {
if p := u.stats.lastErr.Load(); p != nil {
return *p
}
return ""
@@ -100,10 +128,11 @@ func (u *Upstream) LastError() string {
// Success records a successful query.
func (u *Upstream) Success(d time.Duration) {
u.queries.Add(1)
u.consecutive.Store(0)
u.latencySum.Add(uint64(d.Microseconds()))
u.latencyN.Add(1)
s := u.stats
s.queries.Add(1)
s.consecutive.Store(0)
s.latencySum.Add(uint64(d.Microseconds()))
s.latencyN.Add(1)
u.recordLatency(d)
u.SetError(nil)
if u.Health() != HealthUp {
@@ -114,12 +143,13 @@ func (u *Upstream) Success(d time.Duration) {
// Failure records a failed query and trips the breaker once the threshold is
// crossed.
func (u *Upstream) Failure(err error, threshold int, cooldown time.Duration) {
u.queries.Add(1)
u.failures.Add(1)
n := u.consecutive.Add(1)
s := u.stats
s.queries.Add(1)
s.failures.Add(1)
n := s.consecutive.Add(1)
u.SetError(err)
if threshold > 0 && int(n) >= threshold {
u.breakerOpenUntil.Store(time.Now().Add(cooldown).UnixNano())
s.breakerOpenUntil.Store(time.Now().Add(cooldown).UnixNano())
u.setHealth(HealthDown)
return
}
@@ -130,32 +160,33 @@ func (u *Upstream) Failure(err error, threshold int, cooldown time.Duration) {
// BreakerOpen reports whether the upstream's breaker is open.
func (u *Upstream) BreakerOpen() bool {
until := u.breakerOpenUntil.Load()
return until > time.Now().UnixNano()
return u.stats.breakerOpenUntil.Load() > time.Now().UnixNano()
}
func (u *Upstream) recordLatency(d time.Duration) {
ms := float64(d.Microseconds()) / 1000
u.p95mu.Lock()
if len(u.p95ring) < 512 {
u.p95ring = append(u.p95ring, ms)
s := u.stats
s.mu.Lock()
if len(s.ring) < 512 {
s.ring = append(s.ring, ms)
} else {
copy(u.p95ring, u.p95ring[1:])
u.p95ring[511] = ms
copy(s.ring, s.ring[1:])
s.ring[511] = ms
}
u.p95mu.Unlock()
s.mu.Unlock()
}
// LatencyStats returns average and p95 latency in milliseconds.
func (u *Upstream) LatencyStats() (avg, p95 float64) {
n := u.latencyN.Load()
s := u.stats
n := s.latencyN.Load()
if n == 0 {
return 0, 0
}
avg = float64(u.latencySum.Load()) / float64(n) / 1000
u.p95mu.Lock()
samples := append([]float64(nil), u.p95ring...)
u.p95mu.Unlock()
avg = float64(s.latencySum.Load()) / float64(n) / 1000
s.mu.Lock()
samples := append([]float64(nil), s.ring...)
s.mu.Unlock()
if len(samples) == 0 {
return avg, 0
}
@@ -187,8 +218,11 @@ type Stats struct {
// Stats snapshots one upstream.
func (u *Upstream) Stats() Stats {
q := u.queries.Load()
f := u.failures.Load()
if u == nil {
return Stats{}
}
q := u.stats.queries.Load()
f := u.stats.failures.Load()
var rate float64
if q > 0 {
rate = float64(f) / float64(q)
@@ -212,83 +246,127 @@ func (u *Upstream) Stats() Stats {
}
}
// Pool is the set of upstreams plus the policy that selects among them.
type Pool struct {
cfg config.Upstream
mu sync.RWMutex
ups []*Upstream
byTag map[string][]*Upstream
// clients are reused per upstream: one UDP socket, one TCP dialer, one TLS
// session pool. Reusing them is what keeps the per-query cost low.
clients map[*Upstream]*dns.Client
// bootstrap resolves names to addresses.
bootstrap map[string][]string
rng *rand.Rand
mu2 sync.Mutex
// poolState is an immutable snapshot of the pool: the config it was built
// from, the upstreams, and the address index used for per-client upstream
// overrides. Readers take the pointer once and are then immune to reloads.
type poolState struct {
cfg config.Upstream
ups []*Upstream
// byAddr indexes the upstreams by every address they answer to, so an ACL
// group naming either the configured Addr or the resolved one works.
byAddr map[string][]*Upstream
}
// NewPool builds a pool from config. bootstrapIPs may be empty; then upstream
// hostnames must be literal IPs (or resolved by the OS).
func (st *poolState) all() []*Upstream {
if st == nil {
return nil
}
return st.ups
}
// Pool is the set of upstreams plus the policy that selects among them.
type Pool struct {
state atomic.Pointer[poolState]
// clients are memoised per upstream: one UDP socket, one TCP dialer, one
// TLS session pool. Reusing them is what keeps the per-query cost low; a
// dns.Client is safe for concurrent use.
clients sync.Map // *Upstream -> *dns.Client
// mu serialises Rebind so two overlapping reloads cannot interleave their
// health hand-over. It is never taken on the query path.
mu sync.Mutex
// rng backs the "random" policy.
rngMu sync.Mutex
rng *rand.Rand
}
// NewPool builds a pool from config. Bootstraps are used for named upstreams;
// anything unresolved falls back to the system resolver.
func NewPool(cfg config.Upstream) *Pool {
p := &Pool{
cfg: cfg,
byTag: make(map[string][]*Upstream),
clients: make(map[*Upstream]*dns.Client),
bootstrap: make(map[string][]string),
rng: rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), 0x9e3779b9)),
rng: rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), 0x9e3779b9)),
}
for _, b := range cfg.Bootstraps {
p.bootstrap[b.Name] = append([]string(nil), b.Addresses...)
}
for _, s := range cfg.Servers {
up := &Upstream{cfg: s}
up.setHealth(HealthUnknown)
p.ups = append(p.ups, up)
}
p.resolveAll()
p.state.Store(buildState(cfg, nil, false))
return p
}
func (p *Pool) resolveAll() {
for _, up := range p.ups {
host, port, err := net.SplitHostPort(up.cfg.Addr)
if err != nil {
continue
}
if net.ParseIP(host) != nil {
up.addr = up.cfg.Addr
continue
}
if addrs := p.bootstrap[host]; len(addrs) > 0 {
up.resolved = addrs
up.addr = net.JoinHostPort(addrs[0], port)
continue
}
// Fall back to the system resolver.
ips, err := net.LookupIP(host)
if err != nil || len(ips) == 0 {
up.addr = up.cfg.Addr
continue
}
strs := make([]string, 0, len(ips))
for _, ip := range ips {
strs = append(strs, ip.String())
}
up.resolved = strs
up.addr = net.JoinHostPort(strs[0], port)
// buildState turns an upstream config into an immutable pool snapshot. When
// carry is non-nil, servers that keep the same address and protocol also keep
// their live health, latency and breaker state.
func buildState(cfg config.Upstream, carry *poolState, lookup bool) *poolState {
bootstraps := make(map[string][]string, len(cfg.Bootstraps))
for _, b := range cfg.Bootstraps {
bootstraps[b.Name] = append([]string(nil), b.Addresses...)
}
prev := make(map[string]*Upstream)
if carry != nil {
for _, u := range carry.ups {
prev[u.cfg.Addr+"/"+u.cfg.Proto] = u
}
}
st := &poolState{cfg: cfg, ups: make([]*Upstream, 0, len(cfg.Servers))}
for _, s := range cfg.Servers {
addr, resolved := resolveAddr(s, bootstraps, lookup)
up := &Upstream{cfg: s, addr: addr, resolved: resolved}
if old, ok := prev[s.Addr+"/"+s.Proto]; ok {
up.stats = old.stats
} else {
up.stats = newUpstreamStats()
}
st.ups = append(st.ups, up)
}
st.byAddr = make(map[string][]*Upstream, len(st.ups)*2)
for _, up := range st.ups {
for _, a := range append([]string{up.cfg.Addr, up.addr}, up.resolved...) {
if a == "" {
continue
}
st.byAddr[a] = append(st.byAddr[a], up)
}
}
return st
}
// resolveAddr returns the address to dial for a server plus the bootstrap
// addresses behind it, if the configured host is a name.
func resolveAddr(s config.UpstreamServer, bootstraps map[string][]string, lookup bool) (addr string, resolved []string) {
host, port, err := net.SplitHostPort(s.Addr)
if err != nil {
// Not host:port — hand it to the dialer verbatim.
return s.Addr, nil
}
if net.ParseIP(host) != nil {
return s.Addr, nil
}
if addrs := bootstraps[host]; len(addrs) > 0 {
return net.JoinHostPort(addrs[0], port), addrs
}
if lookup {
ctx, cancel := context.WithTimeout(context.Background(), lookupTimeout)
defer cancel()
addrs, err := net.DefaultResolver.LookupHost(ctx, host)
if err == nil && len(addrs) > 0 {
return net.JoinHostPort(addrs[0], port), addrs
}
}
return s.Addr, nil
}
// Config returns the pool's upstream config.
func (p *Pool) Config() config.Upstream { return p.cfg }
func (p *Pool) Config() config.Upstream {
if st := p.state.Load(); st != nil {
return st.cfg
}
return config.Upstream{}
}
// All returns every upstream.
func (p *Pool) All() []*Upstream { return p.ups }
func (p *Pool) All() []*Upstream { return p.state.Load().all() }
// Get returns an upstream by address, or nil.
// Get returns an upstream by configured or resolved address, or nil.
func (p *Pool) Get(addr string) *Upstream {
for _, u := range p.ups {
for _, u := range p.All() {
if u.addr == addr || u.cfg.Addr == addr {
return u
}
@@ -298,8 +376,9 @@ func (p *Pool) Get(addr string) *Upstream {
// Healthy returns upstreams whose breaker is closed, in configured order.
func (p *Pool) Healthy() []*Upstream {
out := make([]*Upstream, 0, len(p.ups))
for _, u := range p.ups {
ups := p.All()
out := make([]*Upstream, 0, len(ups))
for _, u := range ups {
if !u.BreakerOpen() {
out = append(out, u)
}
@@ -309,8 +388,9 @@ func (p *Pool) Healthy() []*Upstream {
// Stats snapshots every upstream.
func (p *Pool) Stats() []Stats {
out := make([]Stats, 0, len(p.ups))
for _, u := range p.ups {
ups := p.All()
out := make([]Stats, 0, len(ups))
for _, u := range ups {
out = append(out, u.Stats())
}
return out
@@ -318,95 +398,54 @@ func (p *Pool) Stats() []Stats {
// clientFor returns the memoised dns.Client for an upstream.
func (p *Pool) clientFor(u *Upstream) *dns.Client {
p.mu.RLock()
c, ok := p.clients[u]
p.mu.RUnlock()
if ok {
return c
if c, ok := p.clients.Load(u); ok {
return c.(*dns.Client)
}
var nc *dns.Client
nc := &dns.Client{Net: "udp", UDPSize: 1232, Timeout: 3 * time.Second}
switch u.cfg.Proto {
case "tls":
tlsCfg := &tls.Config{
ServerName: u.cfg.ServerName,
InsecureSkipVerify: u.cfg.InsecureSkipVerify,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"dot"},
}
nc = &dns.Client{
Net: "tcp-tls",
TLSConfig: tlsCfg,
Dialer: &net.Dialer{Timeout: 3 * time.Second},
Timeout: 5 * time.Second,
Net: "tcp-tls",
TLSConfig: &tls.Config{
ServerName: u.cfg.ServerName,
InsecureSkipVerify: u.cfg.InsecureSkipVerify,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"dot"},
},
Dialer: &net.Dialer{Timeout: 3 * time.Second},
Timeout: 5 * time.Second,
}
case "tcp":
nc = &dns.Client{Net: "tcp", Timeout: 5 * time.Second}
default:
nc = &dns.Client{Net: "udp", UDPSize: 1232, Timeout: 3 * time.Second}
}
p.mu.Lock()
p.clients[u] = nc
p.mu.Unlock()
return nc
}
// selectFor returns the upstreams to try, honouring the policy. tags, if
// non-empty, restricts the pool to a group's own upstreams.
func (p *Pool) selectFor(policy string, maxAttempts int) []*Upstream {
healthy := p.Healthy()
if len(healthy) == 0 {
// Every breaker is open: try them all anyway rather than failing the
// query outright. A blip should not become an outage.
healthy = append(healthy, p.ups...)
}
if len(healthy) == 0 {
return nil
}
if maxAttempts <= 0 || maxAttempts > len(healthy) {
maxAttempts = len(healthy)
}
switch policy {
case "failover":
// Configured order, and rotate past the unhealthy ones.
return healthy[:maxAttempts]
case "random":
p.mu2.Lock()
p.rng.Shuffle(len(healthy), func(i, j int) { healthy[i], healthy[j] = healthy[j], healthy[i] })
p.mu2.Unlock()
return healthy[:maxAttempts]
default: // parallel
return healthy
}
actual, _ := p.clients.LoadOrStore(u, nc)
return actual.(*dns.Client)
}
// Exchange forwards q to the pool and returns the first acceptable answer.
//
// policy is the pool's configured policy; tags restricts the pool to a
// per-client upstream override. edns0Size is the client's advertised buffer,
// used to decide TCP fallback on truncation.
func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, tags []string, timeout time.Duration) (*dns.Msg, string, error) {
candidates := p.ups
if len(tags) > 0 {
if tagged := p.byTag[strings.Join(tags, ",")]; len(tagged) > 0 {
candidates = tagged
}
// want restricts the query to the upstreams an ACL group named, by configured
// or resolved address; an empty list means the whole pool. timeout bounds one
// attempt.
func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, want []string, timeout time.Duration) (*dns.Msg, string, error) {
st := p.state.Load()
candidates := st.all()
if len(want) > 0 {
candidates = p.restrict(st, want)
}
if len(candidates) == 0 {
return nil, "", ErrNoUpstream
}
attempts := p.cfg.MaxAttempts
chosen := selectFrom(candidates, p.cfg.Policy, attempts)
chosen := p.pick(st, candidates, st.cfg.MaxAttempts)
if len(chosen) == 0 {
return nil, "", ErrNoUpstream
}
if len(chosen) == 1 {
msg, up, err := p.exchangeOne(ctx, chosen[0], q, timeout)
return msg, up, err
return p.exchangeOne(ctx, st.cfg, chosen[0], q, timeout)
}
// Parallel: send to all at once, take the first answer that is not a
// transport failure. Cancellable so we do not leak requests to a slow
// Parallel: send to all at once and take the first real answer. The
// context is cancelled on return so we do not leak a request to a slow
// upstream after another one has answered.
type res struct {
msg *dns.Msg
@@ -418,7 +457,7 @@ func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, tags []string, timeout
defer cancel()
for _, up := range chosen {
go func(up *Upstream) {
msg, addr, err := p.exchangeOne(sub, up, q, timeout)
msg, addr, err := p.exchangeOne(sub, st.cfg, up, q, timeout)
results <- res{msg: msg, up: addr, err: err}
}(up)
}
@@ -426,13 +465,13 @@ func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, tags []string, timeout
for i := 0; i < len(chosen); i++ {
select {
case r := <-results:
if r.err == nil && r.msg != nil && !isTransportFailure(r.msg.Rcode) {
return r.msg, r.up, nil
}
if r.err != nil {
switch {
case r.err != nil:
lastErr = r.err
} else if r.msg != nil {
lastErr = fmt.Errorf("upstream %s returned %s", r.up, dns.RcodeToString[r.msg.Rcode])
case r.msg != nil && r.msg.Rcode != dns.RcodeServerFailure:
return r.msg, r.up, nil
default:
lastErr = fmt.Errorf("upstream %s returned SERVFAIL", r.up)
}
case <-ctx.Done():
return nil, "", ctx.Err()
@@ -444,8 +483,29 @@ func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, tags []string, timeout
return nil, "", lastErr
}
func selectFrom(ups []*Upstream, policy string, maxAttempts int) []*Upstream {
// Filter out open breakers but keep everything if that empties the list.
// restrict resolves an ACL group's upstream list to upstreams, keeping the
// configured order and dropping names that match nothing. When nothing in the
// list resolves the full pool is used, so a typo cannot black-hole a client.
func (p *Pool) restrict(st *poolState, want []string) []*Upstream {
seen := make(map[*Upstream]bool, len(want))
out := make([]*Upstream, 0, len(want))
for _, name := range want {
for _, up := range st.byAddr[name] {
if !seen[up] {
seen[up] = true
out = append(out, up)
}
}
}
if len(out) == 0 {
return st.all()
}
return out
}
// pick filters out open breakers (keeping everything if that empties the
// list) and applies the pool policy.
func (p *Pool) pick(st *poolState, ups []*Upstream, maxAttempts int) []*Upstream {
open := make([]*Upstream, 0, len(ups))
for _, u := range ups {
if !u.BreakerOpen() {
@@ -458,11 +518,13 @@ func selectFrom(ups []*Upstream, policy string, maxAttempts int) []*Upstream {
if maxAttempts <= 0 || maxAttempts > len(open) {
maxAttempts = len(open)
}
switch policy {
switch st.cfg.Policy {
case "failover":
return open[:maxAttempts]
case "random":
rand.Shuffle(len(open), func(i, j int) { open[i], open[j] = open[j], open[i] })
p.rngMu.Lock()
defer p.rngMu.Unlock()
p.rng.Shuffle(len(open), func(i, j int) { open[i], open[j] = open[j], open[i] })
return open[:maxAttempts]
default:
return open
@@ -471,63 +533,54 @@ func selectFrom(ups []*Upstream, policy string, maxAttempts int) []*Upstream {
// exchangeOne sends q to a single upstream, retrying over TCP when the UDP
// answer comes back truncated (RFC 1035 §4.2.1).
func (p *Pool) exchangeOne(ctx context.Context, up *Upstream, q *dns.Msg, timeout time.Duration) (*dns.Msg, string, error) {
func (p *Pool) exchangeOne(ctx context.Context, cfg config.Upstream, up *Upstream, q *dns.Msg, timeout time.Duration) (*dns.Msg, string, error) {
if timeout <= 0 {
timeout = 2 * time.Second
}
client := p.clientFor(up)
msg := q.Copy()
start := time.Now()
resp, rtt, err := client.ExchangeContext(ctx, msg, up.addr)
up.Success(rtt)
resp, rtt, err := client.ExchangeContext(ctx, q.Copy(), up.addr)
if err != nil {
up.Failure(err, thresholdFor(up, cfg.BreakerThreshold), cfg.BreakerCooldown.D())
return nil, up.addr, err
}
_ = start
up.Success(rtt)
if !resp.Truncated {
return resp, up.addr, nil
}
// Retry over TCP to get the full answer. Some upstreams are configured
// UDP-only in which case this simply fails and we use the TC answer.
// Retry over TCP for the full answer. An upstream that is UDP-only just
// fails here and we return the truncated answer; the client retries over
// TCP itself.
tcpClient := &dns.Client{Net: "tcp", Timeout: timeout}
resp2, rtt2, err2 := tcpClient.ExchangeContext(ctx, q.Copy(), up.addr)
if err2 == nil && resp2 != nil && !resp2.Truncated {
up.Success(rtt2)
return resp2, up.addr, nil
if err2 != nil || resp2 == nil || resp2.Truncated {
if err2 == nil {
err2 = errors.New("still truncated over TCP")
}
up.Failure(fmt.Errorf("truncated answer, TCP retry failed: %w", err2),
thresholdFor(up, cfg.BreakerThreshold), cfg.BreakerCooldown.D())
return resp, up.addr, nil
}
up.Failure(fmt.Errorf("truncated answer, TCP retry failed: %w", err2), up.breakerThreshold(), p.cfg.BreakerCooldown.D())
// Return the truncated answer; the client will retry over TCP itself.
return resp, up.addr, nil
}
func isTransportFailure(rcode int) bool {
return rcode == dns.RcodeServerFailure
}
// breakerThreshold is the effective failure threshold for an upstream.
func (u *Upstream) breakerThreshold() int {
if u.cfg.MaxFails > 0 {
return u.cfg.MaxFails
}
return 0
up.Success(rtt2)
return resp2, up.addr, nil
}
// HealthCheck probes every upstream with a cheap query, refreshing health
// state even when no client traffic is flowing. It returns the number of
// upstreams that answered.
func (p *Pool) HealthCheck(ctx context.Context, timeout time.Duration) int {
st := p.state.Load()
probe := new(dns.Msg)
probe.SetQuestion(".", dns.TypeNS)
probe.RecursionDesired = true
ok := 0
for _, up := range p.ups {
for _, up := range st.all() {
if up.BreakerOpen() {
continue
}
c := p.clientFor(up)
_, rtt, err := c.ExchangeContext(ctx, probe, up.addr)
if err != nil {
up.Failure(err, p.breakerThresholdFor(up), p.cfg.BreakerCooldown.D())
up.Failure(err, thresholdFor(up, st.cfg.BreakerThreshold), st.cfg.BreakerCooldown.D())
continue
}
up.Success(rtt)
@@ -536,63 +589,47 @@ func (p *Pool) HealthCheck(ctx context.Context, timeout time.Duration) int {
return ok
}
func (p *Pool) breakerThresholdFor(u *Upstream) int {
if t := u.breakerThreshold(); t > 0 {
return t
func thresholdFor(up *Upstream, def int) int {
if up.cfg.MaxFails > 0 {
return up.cfg.MaxFails
}
return p.cfg.BreakerThreshold
return def
}
// Rebind applies a new upstream configuration in place, keeping the live
// health state of upstreams whose address is unchanged.
// Rebind applies a new upstream configuration, keeping the live health state
// of upstreams whose address and protocol are unchanged.
//
// It builds a whole new immutable state and swaps it in with a single atomic
// store: queries already in flight finish against the state they started
// with, and the next query sees the new one. There is no window in which half
// the upstreams are old and half new.
func (p *Pool) Rebind(cfg config.Upstream) {
p.mu.Lock()
defer p.mu.Unlock()
p.cfg = cfg
old := make(map[string]*Upstream, len(p.ups))
for _, u := range p.ups {
old[u.cfg.Addr+"/"+u.cfg.Proto] = u
old := p.state.Load()
next := buildState(cfg, old, true)
p.state.Store(next)
// Drop the memoised clients of upstreams that are gone; their shells are
// only reachable from in-flight queries, which let them go on their own.
live := make(map[*Upstream]bool, len(next.all()))
for _, u := range next.all() {
live[u] = true
}
next := make([]*Upstream, 0, len(cfg.Servers))
for _, s := range cfg.Servers {
if u, ok := old[s.Addr+"/"+s.Proto]; ok {
u.cfg = s
next = append(next, u)
delete(old, s.Addr+"/"+s.Proto)
continue
}
u := &Upstream{cfg: s}
u.setHealth(HealthUnknown)
next = append(next, u)
}
p.ups = next
// Drop clients for removed upstreams.
for u := range p.clients {
if old[u.cfg.Addr+"/"+u.cfg.Proto] != nil {
delete(p.clients, u)
for _, u := range old.all() {
if !live[u] {
p.clients.Delete(u)
}
}
p.resolveAllLocked()
}
func (p *Pool) resolveAllLocked() {
for _, b := range p.cfg.Bootstraps {
p.bootstrap[b.Name] = append([]string(nil), b.Addresses...)
}
for _, up := range p.ups {
host, port, err := net.SplitHostPort(up.cfg.Addr)
if err != nil {
continue
}
if net.ParseIP(host) != nil {
up.addr = up.cfg.Addr
continue
}
if addrs := p.bootstrap[host]; len(addrs) > 0 {
up.resolved = addrs
up.addr = net.JoinHostPort(addrs[0], port)
continue
}
up.addr = up.cfg.Addr
// String renders the pool for logs.
func (p *Pool) String() string {
ups := p.All()
addrs := make([]string, 0, len(ups))
for _, u := range ups {
addrs = append(addrs, u.addr)
}
return "pool[" + strings.Join(addrs, " ") + "]"
}
func ptr[T any](v T) *T { return &v }
+14 -3
View File
@@ -1,3 +1,14 @@
<!doctype html><html><head><meta charset="utf-8"><title>fancy-dns</title></head>
<body style="font:16px system-ui;padding:2rem"><h1>fancy-dns</h1>
<p>Web console assets are not built in this binary. Run <code>make web</code> and rebuild.</p></body></html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<script type="module" crossorigin src="./assets/index-BVuSgjZf.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CZ8p4VMZ.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+806
View File
@@ -0,0 +1,806 @@
package zonefile
import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/miekg/dns"
)
func mustParse(t *testing.T, src string, opt Options) *Zone {
t.Helper()
z, err := Parse(strings.NewReader(src), opt)
if err != nil {
t.Fatalf("Parse: unexpected error: %v\nsource:\n%s", err, src)
}
return z
}
func parseErr(t *testing.T, src string, opt Options) error {
t.Helper()
z, err := Parse(strings.NewReader(src), opt)
if err == nil {
t.Fatalf("Parse: expected an error, got %d records: %v", len(z.Records), z.Records)
}
return err
}
// summary renders every record as "line name ttl class type rdata" so a single
// comparison covers folding, owner/TTL/class inheritance and origin resolution.
func summary(t *testing.T, z *Zone) []string {
t.Helper()
out := make([]string, 0, len(z.Records))
for _, rec := range z.Records {
parts := strings.SplitN(rec.RR.String(), "\t", 5)
if len(parts) != 5 {
t.Fatalf("record line %d does not render as 5 tab-separated fields: %q", rec.Line, rec.Text())
}
out = append(out, fmt.Sprintf("%d %s %d %s %s %s",
rec.Line, parts[0], rec.TTL, parts[2], parts[3], parts[4]))
}
return out
}
func checkSummary(t *testing.T, z *Zone, want []string) {
t.Helper()
got := summary(t, z)
if !slices.Equal(got, want) {
t.Errorf("records mismatch\n got: %q\nwant: %q", got, want)
}
}
func TestParseTTL(t *testing.T) {
tests := []struct {
in string
want uint32
wantErr bool
}{
{in: "", wantErr: true},
{in: " ", wantErr: true},
{in: "300", want: 300},
{in: " 60 ", want: 60},
{in: "0", want: 0},
{in: "300s", want: 300},
{in: "300sec", want: 300},
{in: "300seconds", want: 300},
{in: "5m", want: 300},
{in: "5min", want: 300},
{in: "5minutes", want: 300},
{in: "1h", want: 3600},
{in: "1hour", want: 3600},
{in: "1hours", want: 3600},
{in: "1d", want: 86400},
{in: "1days", want: 86400},
{in: "1w", want: 604800},
{in: "1weeks", want: 604800},
{in: "1h30m", want: 5400},
{in: "1w2d3h4m5s", want: 788645},
{in: "1h30", want: 3630},
{in: "4294967295", want: 4294967295},
{in: "4294967296", wantErr: true},
{in: "-1", wantErr: true},
{in: "-5m", wantErr: true},
{in: "abc", wantErr: true},
{in: "m", wantErr: true},
{in: "1x", wantErr: true},
{in: "1.5h", wantErr: true},
{in: "1h-", wantErr: true},
{in: "0x10", wantErr: true},
}
for _, tc := range tests {
got, err := ParseTTL(tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("ParseTTL(%q) = %d, want an error", tc.in, got)
}
continue
}
if err != nil {
t.Errorf("ParseTTL(%q) error: %v", tc.in, err)
continue
}
if got != tc.want {
t.Errorf("ParseTTL(%q) = %d, want %d", tc.in, got, tc.want)
}
}
}
func TestFormatTTL(t *testing.T) {
tests := []struct {
in uint32
want string
}{
{0, "0"},
{1, "1"},
{59, "59"},
{60, "1m"},
{300, "5m"},
{3600, "1h"},
{86400, "1d"},
{604800, "1w"},
{3601, "3601"},
{1209600, "2w"},
}
for _, tc := range tests {
if got := FormatTTL(tc.in); got != tc.want {
t.Errorf("FormatTTL(%d) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestFormatTTLRoundTrip(t *testing.T) {
for _, ttl := range []uint32{0, 1, 59, 60, 300, 3600, 3601, 86400, 604800, 1209600, 4294967295} {
s := FormatTTL(ttl)
got, err := ParseTTL(s)
if err != nil {
t.Errorf("ParseTTL(FormatTTL(%d)=%q) error: %v", ttl, s, err)
continue
}
if got != ttl {
t.Errorf("round trip of %d via %q = %d", ttl, s, got)
}
}
}
func TestSerialNext(t *testing.T) {
tests := []struct {
name string
current uint32
spec string
want uint32
wantErr bool
}{
{name: "increment", current: 1, spec: "+1", want: 2},
{name: "increment large", current: 2024010101, spec: "+100", want: 2024010201},
{name: "increment from zero", current: 0, spec: "+7", want: 7},
{name: "decrement", current: 10, spec: "-1", want: 9},
{name: "decrement below zero wraps", current: 0, spec: "-1", want: 4294967295},
{name: "absolute", current: 5, spec: "42", want: 42},
{name: "absolute max", current: 5, spec: "4294967295", want: 4294967295},
{name: "surrounding space", current: 1, spec: " +1 ", want: 2},
{name: "empty", current: 1, spec: "", wantErr: true},
{name: "only sign", current: 1, spec: "+", wantErr: true},
{name: "not a number", current: 1, spec: "+x", wantErr: true},
{name: "word", current: 1, spec: "next", wantErr: true},
{name: "bare minus", current: 1, spec: "-", wantErr: true},
{name: "overflow", current: 1, spec: "+4294967296", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := SerialNext(tc.current, tc.spec)
if tc.wantErr {
if err == nil {
t.Fatalf("SerialNext(%d, %q) = %d, want an error", tc.current, tc.spec, got)
}
return
}
if err != nil {
t.Fatalf("SerialNext(%d, %q): %v", tc.current, tc.spec, err)
}
if got != tc.want {
t.Errorf("SerialNext(%d, %q) = %d, want %d", tc.current, tc.spec, got, tc.want)
}
})
}
}
// addSerial is plain modular (RFC 1982 §2.1) arithmetic: 0xFFFFFFFF + 1 is 0,
// not 1. The high-half special case only keeps two large operands from being
// mistaken for a wrap.
func TestAddSerial(t *testing.T) {
tests := []struct {
a, b, want uint32
}{
{1, 1, 2},
{0, 0, 0},
{4294967295, 1, 0},
{4294967295, 2, 1},
{4294967294, 3, 1},
{0, 4294967295, 4294967295},
{0x7FFFFFFF, 1, 0x80000000},
{0x80000000, 0x80000000, 0},
{0x80000000, 0x7FFFFFFF, 0xFFFFFFFF},
}
for _, tc := range tests {
if got := addSerial(tc.a, tc.b); got != tc.want {
t.Errorf("addSerial(%d, %d) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
const fullZone = `$ORIGIN example.com.
$TTL 1h
@ IN SOA ns1.example.com. hostmaster.example.com. (
2024010101 ; serial
7200 ; refresh
3600 ; retry
1209600 ; expire
300 ) ; minimum
@ IN NS ns1.example.com.
www IN A 192.0.2.1
www 600 IN A 192.0.2.2
AAAA 2001:db8::1
alias IN CNAME www.example.com.
mail IN MX 10 mx1.example.com.
note IN TXT "hello ; world @ example"
*.wild IN A 192.0.2.3
198.51.100.5. IN A 198.51.100.5
low in a 192.0.2.7
mixed 600 IN A 192.0.2.8
cls in 3600 A 192.0.2.9
at IN TXT "@"
; a whole-line comment
; an indented comment
`
func TestParseFullZone(t *testing.T) {
z := mustParse(t, fullZone, Options{File: "full.zone", Origin: "ignored.example."})
if z.Origin != "example.com." {
t.Errorf("Origin = %q, want %q", z.Origin, "example.com.")
}
if z.File != "full.zone" {
t.Errorf("File = %q, want %q", z.File, "full.zone")
}
checkSummary(t, z, []string{
`3 example.com. 3600 IN SOA ns1.example.com. hostmaster.example.com. 2024010101 7200 3600 1209600 300`,
`9 example.com. 3600 IN NS ns1.example.com.`,
`10 www.example.com. 3600 IN A 192.0.2.1`,
`11 www.example.com. 600 IN A 192.0.2.2`,
`12 www.example.com. 3600 IN AAAA 2001:db8::1`,
`13 alias.example.com. 3600 IN CNAME www.example.com.`,
`14 mail.example.com. 3600 IN MX 10 mx1.example.com.`,
`15 note.example.com. 3600 IN TXT "hello ; world @ example"`,
`16 *.wild.example.com. 3600 IN A 192.0.2.3`,
`17 198.51.100.5. 3600 IN A 198.51.100.5`,
`18 low.example.com. 3600 IN A 192.0.2.7`,
`19 mixed.example.com. 600 IN A 192.0.2.8`,
`20 cls.example.com. 3600 IN A 192.0.2.9`,
`21 at.example.com. 3600 IN TXT "@"`,
})
if z.SOA == nil {
t.Fatal("SOA = nil, want the parsed start of authority")
}
if z.SOA.Serial != 2024010101 {
t.Errorf("SOA.Serial = %d, want 2024010101", z.SOA.Serial)
}
if z.DefaultTTL != 3600 {
t.Errorf("DefaultTTL = %d, want 3600 ($TTL 1h)", z.DefaultTTL)
}
if z.MinTTL != 600 {
t.Errorf("MinTTL = %d, want 600 (the smallest explicit TTL)", z.MinTTL)
}
for _, rec := range z.Records {
if rec.RR.Header().Class != dns.ClassINET {
t.Errorf("record %q has class %s, want IN", rec.Text(), dns.ClassToString[rec.RR.Header().Class])
}
if rec.Name != strings.ToLower(rec.Name) {
t.Errorf("record name %q is not lowercased", rec.Name)
}
if !strings.HasSuffix(rec.Name, ".") {
t.Errorf("record name %q is not fully qualified", rec.Name)
}
if rec.File != "full.zone" {
t.Errorf("record %q has File %q, want full.zone", rec.Text(), rec.File)
}
}
}
func TestParseTTLInheritance(t *testing.T) {
z := mustParse(t, `$ORIGIN example.com.
$TTL 1h
one IN A 192.0.2.1
two 300 IN A 192.0.2.2
three IN A 192.0.2.3
$TTL 2h
four IN A 192.0.2.4
`, Options{})
checkSummary(t, z, []string{
`2 one.example.com. 3600 IN A 192.0.2.1`,
`3 two.example.com. 300 IN A 192.0.2.2`,
`4 three.example.com. 3600 IN A 192.0.2.3`,
`6 four.example.com. 7200 IN A 192.0.2.4`,
})
if z.DefaultTTL != 7200 {
t.Errorf("DefaultTTL = %d, want 7200", z.DefaultTTL)
}
if z.MinTTL != 300 {
t.Errorf("MinTTL = %d, want 300", z.MinTTL)
}
}
func TestParseDefaultTTLSources(t *testing.T) {
t.Run("option default ttl", func(t *testing.T) {
z := mustParse(t, "host IN A 192.0.2.1\n", Options{Origin: "example.com.", DefaultTTL: 42})
if len(z.Records) != 1 || z.Records[0].TTL != 42 {
t.Fatalf("records = %v, want one record with TTL 42", summary(t, z))
}
if z.DefaultTTL != 42 {
t.Errorf("DefaultTTL = %d, want 42", z.DefaultTTL)
}
})
t.Run("no ttl source leaves zero", func(t *testing.T) {
z := mustParse(t, "host IN A 192.0.2.1\n", Options{Origin: "example.com."})
if len(z.Records) != 1 || z.Records[0].TTL != 0 {
t.Fatalf("records = %v, want one record with TTL 0", summary(t, z))
}
if z.MinTTL != 0 {
t.Errorf("MinTTL = %d, want 0", z.MinTTL)
}
})
t.Run("soa ttl is the default", func(t *testing.T) {
z := mustParse(t, "@ 300 IN SOA ns1.example.com. hostmaster.example.com. 1 2 3 4 5\nhost IN A 192.0.2.1\n",
Options{Origin: "example.com."})
if z.DefaultTTL != 300 {
t.Errorf("DefaultTTL = %d, want 300 from the SOA", z.DefaultTTL)
}
if z.Records[1].TTL != 300 {
t.Errorf("records = %v, want 300 inherited from the SOA", summary(t, z))
}
})
t.Run("soa minttl is the last resort", func(t *testing.T) {
z := mustParse(t, "@ IN SOA ns1.example.com. hostmaster.example.com. 1 2 3 4 7\nhost IN A 192.0.2.1\n",
Options{Origin: "example.com."})
if z.Records[1].TTL != 7 {
t.Errorf("records = %v, want the SOA minimum 7", summary(t, z))
}
})
}
func TestParseOwnerForms(t *testing.T) {
tests := []struct {
name string
src string
want []string
}{
{
name: "at sign is the origin",
src: "@ IN A 192.0.2.1\n@ IN A 192.0.2.2\n",
want: []string{"origin.example. 192.0.2.1", "origin.example. 192.0.2.2"},
},
{
name: "at sign follows $ORIGIN",
src: "$ORIGIN sub.example.com.\n@ IN A 192.0.2.1\n",
want: []string{"sub.example.com. 192.0.2.1"},
},
{
name: "omitted owner repeats the previous one",
src: "one IN A 192.0.2.1\n IN A 192.0.2.2\n IN AAAA 2001:db8::1\n A 192.0.2.3\n",
want: []string{
"one.origin.example. 192.0.2.1",
"one.origin.example. 192.0.2.2",
"one.origin.example. 2001:db8::1",
"one.origin.example. 192.0.2.3",
},
},
{
name: "omitted owner after a parenthesised record",
src: "one IN TXT ( \"one\" ; comment\n \"two\" )\n IN A 192.0.2.1\n",
want: []string{"one.origin.example. \"one\" \"two\"", "one.origin.example. 192.0.2.1"},
},
{
name: "wildcard owner is expanded but keeps the star",
src: "*.sub IN A 192.0.2.1\n",
want: []string{"*.sub.origin.example. 192.0.2.1"},
},
{
name: "uppercase owner is lowercased",
src: "WWW IN A 192.0.2.1\n",
want: []string{"www.origin.example. 192.0.2.1"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
z := mustParse(t, tc.src, Options{Origin: "origin.example."})
got := make([]string, 0, len(z.Records))
for _, rec := range z.Records {
parts := strings.SplitN(rec.RR.String(), "\t", 5)
got = append(got, parts[0]+" "+parts[4])
}
if !slices.Equal(got, tc.want) {
t.Errorf("got %q, want %q", got, tc.want)
}
// Record.Name is the resolved, lowercased, fully qualified owner:
// "@" always resolves to the origin, never to a literal "@" label.
for _, rec := range z.Records {
if strings.ContainsAny(rec.Name, "@") {
t.Errorf("Record.Name = %q, want an expanded owner", rec.Name)
}
if !strings.HasSuffix(rec.Name, ".") {
t.Errorf("Record.Name = %q, want a fully qualified name", rec.Name)
}
if rec.Name != strings.ToLower(rec.Name) {
t.Errorf("Record.Name = %q, want lowercase", rec.Name)
}
}
})
}
}
func TestParseParenthesised(t *testing.T) {
t.Run("multi line rdata", func(t *testing.T) {
z := mustParse(t, `@ SOA ns1.example.com. hostmaster.example.com. (
2024010101
7200
3600
1209600
300 )
`, Options{Origin: "example.com."})
if z.SOA == nil {
t.Fatal("SOA = nil")
}
if z.SOA.Minttl != 300 {
t.Errorf("SOA.Minttl = %d, want 300", z.SOA.Minttl)
}
if z.Records[0].Line != 1 {
t.Errorf("Line = %d, want 1 (the start of the folded record)", z.Records[0].Line)
}
})
t.Run("comment inside parentheses", func(t *testing.T) {
z := mustParse(t, "host TXT ( \"one\" ; first\n \"two\" ; second\n )\n", Options{Origin: "example.com."})
if len(z.Records) != 1 {
t.Fatalf("got %d records, want 1", len(z.Records))
}
if got := summary(t, z)[0]; !strings.HasSuffix(got, `IN TXT "one" "two"`) {
t.Errorf("record = %q, want a two-string TXT", got)
}
})
t.Run("rdata on one line with parens", func(t *testing.T) {
z := mustParse(t, "host MX ( 10 mx1.example.com. 20 mx2.example.com. )\n", Options{Origin: "example.com."})
if len(z.Records) != 1 {
t.Fatalf("got %d records, want 1", len(z.Records))
}
if got := summary(t, z)[0]; !strings.HasSuffix(got, "IN MX 10 mx1.example.com. 20 mx2.example.com.") {
t.Errorf("record = %q, want two MX rdata fields", got)
}
})
}
func TestParseOriginDirective(t *testing.T) {
t.Run("relative names follow $ORIGIN", func(t *testing.T) {
z := mustParse(t, "$ORIGIN sub.example.com.\nwww IN A 192.0.2.1\n", Options{Origin: "example.com."})
if z.Origin != "sub.example.com." {
t.Errorf("Origin = %q, want sub.example.com.", z.Origin)
}
if z.Records[0].Name != "www.sub.example.com." {
t.Errorf("name = %q, want www.sub.example.com.", z.Records[0].Name)
}
})
t.Run("absolute names are left alone", func(t *testing.T) {
z := mustParse(t, "$ORIGIN sub.example.com.\nwww.other.example. IN A 192.0.2.1\n", Options{})
if z.Records[0].Name != "www.other.example." {
t.Errorf("name = %q, want www.other.example.", z.Records[0].Name)
}
})
t.Run("no origin at all is the root", func(t *testing.T) {
z := mustParse(t, "host IN A 192.0.2.1\n", Options{})
if z.Origin != "." {
t.Errorf("Origin = %q, want .", z.Origin)
}
if z.Records[0].Name != "host." {
t.Errorf("name = %q, want host.", z.Records[0].Name)
}
})
t.Run("$ORIGIN without a name is an error", func(t *testing.T) {
// BIND resets to the zone origin when $ORIGIN carries no argument;
// this parser rejects it instead. Documented as current behaviour.
err := parseErr(t, "$ORIGIN example.com.\n$ORIGIN\n", Options{})
if !strings.Contains(err.Error(), "$ORIGIN needs a name") {
t.Errorf("error = %q, want it to mention \"$ORIGIN needs a name\"", err)
}
})
t.Run("origin option applies when the file has no $ORIGIN", func(t *testing.T) {
z := mustParse(t, "www IN A 192.0.2.1\n", Options{Origin: "example.com"})
if z.Origin != "example.com." || z.Records[0].Name != "www.example.com." {
t.Errorf("origin = %q, name = %q", z.Origin, z.Records[0].Name)
}
})
}
func TestParseClassHandling(t *testing.T) {
z := mustParse(t, `one IN A 192.0.2.1
two in A 192.0.2.2
three A 192.0.2.3
four 300 in A 192.0.2.4
five ch A 192.0.2.5
six hs A 192.0.2.6
seven 300 A 192.0.2.7
`, Options{Origin: "example.com."})
if len(z.Records) != 7 {
t.Fatalf("got %d records, want 7", len(z.Records))
}
want := []uint16{
dns.ClassINET, dns.ClassINET, dns.ClassINET, dns.ClassINET,
dns.ClassCHAOS, dns.ClassHESIOD, dns.ClassINET,
}
for i, rec := range z.Records {
if rec.RR.Header().Class != want[i] {
t.Errorf("%q has class %s, want %s", rec.Text(),
dns.ClassToString[rec.RR.Header().Class], dns.ClassToString[want[i]])
}
}
}
func TestParseCommentsAndBlankLines(t *testing.T) {
z := mustParse(t, `; leading comment
$TTL 1h
; indented comment
one IN A 192.0.2.1 ; trailing comment
two IN A 192.0.2.2 ;comment without space
three IN TXT "semi ; colon" ; real comment
`, Options{Origin: "example.com."})
if len(z.Records) != 3 {
t.Fatalf("got %d records, want 3: %v", len(z.Records), summary(t, z))
}
if !strings.Contains(summary(t, z)[2], `"semi ; colon"`) {
t.Errorf("record = %q, want the quoted semicolon preserved", summary(t, z)[2])
}
for _, rec := range z.Records {
if rec.RR.Header().Ttl != 3600 {
t.Errorf("%q TTL = %d, want 3600", rec.Text(), rec.RR.Header().Ttl)
}
}
checkSummary(t, z, []string{
`6 one.example.com. 3600 IN A 192.0.2.1`,
`9 two.example.com. 3600 IN A 192.0.2.2`,
`10 three.example.com. 3600 IN TXT "semi ; colon"`,
})
}
func TestParseErrors(t *testing.T) {
tests := []struct {
name string
src string
opt Options
wantSub string
}{
{name: "no type", src: "host 3600\n", wantSub: "has no type"},
{name: "bad token before type", src: "host BOGUS 1.2.3.4\n", wantSub: `unexpected token "BOGUS"`},
{name: "bad rdata", src: "host 3600 IN A not-an-ip\n", wantSub: ""},
{name: "unterminated parenthesis", src: "host 3600 IN A ( 192.0.2.1\n", wantSub: "unterminated parenthesis"},
{name: "unterminated parenthesis with a blank line", src: "host 3600 IN A (\n\n 192.0.2.1\n", wantSub: "unterminated parenthesis"},
{name: "unterminated quote", src: "host 3600 IN TXT \"unterminated\n", wantSub: ""},
{name: "no owner and no previous owner", src: "A 192.0.2.1\n", wantSub: "no owner name"},
{name: "bad ttl directive", src: "$TTL forever\n", wantSub: "bad TTL"},
{name: "ttl directive without a value", src: "$TTL\n", wantSub: "$TTL needs a value"},
{name: "origin directive without a name", src: "$ORIGIN\n", wantSub: "$ORIGIN needs a name"},
{name: "include disabled", src: "$INCLUDE other.zone\n", wantSub: "disabled"},
{name: "include unsupported", src: "$INCLUDE other.zone\n", opt: Options{MaxIncludeDepth: 3}, wantSub: "not supported"},
{name: "include without a name", src: "$INCLUDE\n", wantSub: "$INCLUDE needs a file name"},
{name: "error carries file and line", src: "host 3600 IN A 192.0.2.1\nother 3600 IN A nope\n", opt: Options{File: "bad.zone"}, wantSub: "bad.zone:2:"},
{name: "directive error carries the line", src: "$TTL 1h\n$TTL\n", opt: Options{File: "bad.zone"}, wantSub: "bad.zone:2:"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := parseErr(t, tc.src, tc.opt)
if tc.wantSub != "" && !strings.Contains(err.Error(), tc.wantSub) {
t.Errorf("error = %q, want it to contain %q", err, tc.wantSub)
}
})
}
}
func TestParseEmptyFile(t *testing.T) {
for _, src := range []string{"", "\n\n", " \n\t\n", "; only a comment\n"} {
z := mustParse(t, src, Options{Origin: "example.com."})
if len(z.Records) != 0 {
t.Errorf("Parse(%q) returned %d records, want 0", src, len(z.Records))
}
if z.SOA != nil {
t.Errorf("Parse(%q) returned an SOA, want nil", src)
}
if z.Origin != "example.com." {
t.Errorf("Parse(%q) origin = %q, want example.com.", src, z.Origin)
}
}
}
func TestParseFileAndParseFiles(t *testing.T) {
dir := t.TempDir()
a := filepath.Join(dir, "a.zone")
b := filepath.Join(dir, "b.zone")
if err := os.WriteFile(a, []byte("$TTL 1h\nwww IN A 192.0.2.1\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(b, []byte("$TTL 2h\nmail IN A 192.0.2.2\n"), 0o600); err != nil {
t.Fatal(err)
}
z, err := ParseFile(a, "example.com.")
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if z.File != a || z.Origin != "example.com." || len(z.Records) != 1 {
t.Fatalf("ParseFile = %+v", z)
}
if z.Records[0].Name != "www.example.com." {
t.Errorf("name = %q, want www.example.com.", z.Records[0].Name)
}
if _, err := ParseFile(filepath.Join(dir, "missing.zone"), "example.com."); err == nil {
t.Error("ParseFile on a missing file: want an error")
}
merged, err := ParseFiles([]string{a, b}, OriginFromPath)
if err != nil {
t.Fatalf("ParseFiles: %v", err)
}
if len(merged.Records) != 2 {
t.Fatalf("ParseFiles returned %d records, want 2", len(merged.Records))
}
if merged.DefaultTTL != 7200 {
t.Errorf("merged DefaultTTL = %d, want 7200 (the last file wins)", merged.DefaultTTL)
}
if _, err := ParseFiles([]string{a, filepath.Join(dir, "missing.zone")}, OriginFromPath); err == nil {
t.Error("ParseFiles with a missing file: want an error")
}
}
func TestOriginFromPath(t *testing.T) {
tests := []struct{ in, want string }{
{"ads.zone", "ads."},
{"rpz/ads.zone", "ads."},
{"example.com.zone", "example.com."},
{"/var/lib/fancy-dns/example.com.zone", "example.com."},
{"plain", "plain."},
{".hidden", ".hidden."},
}
for _, tc := range tests {
if got := OriginFromPath(tc.in); got != tc.want {
t.Errorf("OriginFromPath(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestEnsureDot(t *testing.T) {
tests := []struct{ in, want string }{
{"", ""},
{".", "."},
{"example.com", "example.com."},
{"example.com.", "example.com."},
}
for _, tc := range tests {
if got := ensureDot(tc.in); got != tc.want {
t.Errorf("ensureDot(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestRelative(t *testing.T) {
tests := []struct{ name, origin, want string }{
{"@", "example.com.", "example.com."},
{"www", "example.com.", "www.example.com."},
{"www.other.", "example.com.", "www.other."},
{"www", ".", "www."},
{"*", "example.com.", "*.example.com."},
}
for _, tc := range tests {
if got := relative(tc.name, tc.origin); got != tc.want {
t.Errorf("relative(%q, %q) = %q, want %q", tc.name, tc.origin, got, tc.want)
}
}
}
func TestStripComment(t *testing.T) {
tests := []struct{ in, want string }{
{"a ; comment", "a "},
{"a;comment", "a"},
{`a "has ; semicolon" b`, `a "has ; semicolon" b`},
{`a \; escaped`, `a \`},
{"no comment here", "no comment here"},
}
for _, tc := range tests {
if got := stripComment(tc.in); got != tc.want {
t.Errorf("stripComment(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestSplitFields(t *testing.T) {
tests := []struct {
in string
want []string
}{
{"a b c", []string{"a", "b", "c"}},
{`a "b c" d`, []string{"a", `"b c"`, "d"}},
{" ", nil},
{`a\ b c`, []string{`a\ b`, "c"}},
}
for _, tc := range tests {
if got := splitFields(tc.in); !slices.Equal(got, tc.want) {
t.Errorf("splitFields(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestDeparen(t *testing.T) {
tests := []struct{ in, want string }{
{"a ( b", "a b"},
{"a ) b", "a b"},
{"a b", "a b"},
}
for _, tc := range tests {
if got := deparen(tc.in); got != tc.want {
t.Errorf("deparen(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestTokenClassification(t *testing.T) {
for _, s := range []string{"0", "300", "1h30m", "300s"} {
if !isTTLToken(s) {
t.Errorf("isTTLToken(%q) = false, want true", s)
}
}
for _, s := range []string{"", "-1", "IN", "in", "CH", "HS", "1x", "forever"} {
if isTTLToken(s) {
t.Errorf("isTTLToken(%q) = true, want false", s)
}
}
for _, s := range []string{"IN", "in", "In", "CH", "ch", "HS", "hs"} {
if !isClassToken(s) {
t.Errorf("isClassToken(%q) = false, want true", s)
}
}
for _, s := range []string{"", "300", "ANY", "cs", "in4"} {
if isClassToken(s) {
t.Errorf("isClassToken(%q) = true, want false", s)
}
}
for _, s := range []string{"A", "a", "AAAA", "ns", "CNAME", "SOA", "ANY", "SVCB", "HTTPS", "nsec3"} {
if !isRRTYPE(s) {
t.Errorf("isRRTYPE(%q) = false, want true", s)
}
}
for _, s := range []string{"", "www", "IN", "300", "BOGUS", "www.example.com."} {
if isRRTYPE(s) {
t.Errorf("isRRTYPE(%q) = true, want false", s)
}
}
}
func TestCloneSOA(t *testing.T) {
if cloneSOA(nil) != nil {
t.Error("cloneSOA(nil) != nil")
}
in := &dns.SOA{Hdr: dns.RR_Header{Name: "example.com."}, Serial: 7, Minttl: 60}
out := cloneSOA(in)
if out == in {
t.Fatal("cloneSOA returned the same pointer")
}
if out.Serial != 7 || out.Minttl != 60 {
t.Errorf("cloneSOA copied %+v", out)
}
}
func TestWhereAndDirOf(t *testing.T) {
if got := where(""); got != "zone" {
t.Errorf("where(\"\") = %q, want zone", got)
}
if got := where("a.zone"); got != "a.zone" {
t.Errorf("where(a.zone) = %q", got)
}
if got := dirOf("a.zone"); got != "." {
t.Errorf("dirOf(a.zone) = %q, want .", got)
}
if got := dirOf("/x/y/a.zone"); got != "/x/y" {
t.Errorf("dirOf(/x/y/a.zone) = %q", got)
}
}