more code
This commit is contained in:
1 parent
f24fd63e9a
commit
ee72257c64
29 files changed
+5636
-440
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Vendored
+6
-2
@@ -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,
|
||||
|
||||
Vendored
+868
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
Vendored
+10
-10
@@ -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
@@ -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
@@ -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 }
|
||||
Vendored
+14
-3
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user