package app import ( "encoding/json" "fmt" "net/http" "os" "path/filepath" "strings" "testing" "time" "github.com/miekg/dns" "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" ) // TestZoneConsoleRoundTrip is the console's zone page, end to end: a record // added through the admin API is answered by this resolver on the next query, // with no reload and no restart, because a zone the console created is // compiled into the same store the operator's master files go into. func TestZoneConsoleRoundTrip(t *testing.T) { managed := filepath.Join(t.TempDir(), "zones") h := newHarness(t, func(c *config.Config) { c.Block.ManagedZones = managed }) do := func(method, path, body string) (int, string) { t.Helper() code, out := h.do(method, path, body) return code, string(out) } mustWrite := func(method, path, body string) string { t.Helper() code, out := do(method, path, body) if code != http.StatusOK { t.Fatalf("%s %s = %d: %s", method, path, code, out) } return out } // Before anything is written the page is empty but honest: no zones, the // managed directory named, and the types the API accepts so the console // builds its picker from the server. _, body := h.get("/api/zones") var empty zonesPayload if err := json.Unmarshal(body, &empty); err != nil { t.Fatalf("zones: %v: %s", err, body) } // The operator's own zone is listed before the console has written // anything, and it is not the console's to edit. if len(empty.Zones) != 1 || empty.Zones[0].Origin != "local." { t.Fatalf("zones = %+v, want just the operator's local. zone", empty.Zones) } if empty.Zones[0].Managed { t.Error("the operator's zone is reported as managed") } if empty.ManagedDir != managed { t.Errorf("managed_dir = %q, want %q", empty.ManagedDir, managed) } if !empty.Writable { t.Error("writable = false, but the harness's temp dir is writable") } if len(empty.Types) == 0 { t.Error("types is empty, so the console has no record types to offer") } // A zone starts with a server list: without one it cannot say NXDOMAIN, // and the writer refuses to invent it. mustWrite(http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"console.test.","type":"NS","value":"ns1.console.test."}`) mustWrite(http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"www.console.test.","type":"A","ttl":60,"value":"10.9.9.9"}`) m := h.query("www.console.test", dns.TypeA) if len(m.Answer) != 1 { t.Fatalf("query www.console.test = %v, want one answer", m) } a, ok := m.Answer[0].(*dns.A) if !ok || a.A.String() != "10.9.9.9" { t.Errorf("www.console.test A = %v, want the console's record, not the upstream's", m.Answer[0]) } if m.Authoritative != true { t.Error("the local answer is not authoritative") } // The file is on disk where the operator can read it, and it is a zone // file rather than a JSON dump. raw, err := os.ReadFile(filepath.Join(managed, "console.test.zone")) if err != nil { t.Fatalf("read the managed zone file: %v", err) } for _, want := range []string{"$ORIGIN console.test.", "IN SOA", "IN NS", "10.9.9.9"} { if !strings.Contains(string(raw), want) { t.Errorf("the zone file is missing %q:\n%s", want, raw) } } // A delegation is a cut: the parent stops answering below it and the // query goes upstream, which is the whole point of making one. mustWrite(http.MethodPost, "/api/zones/delegations", `{"zone":"console.test.","name":"sub.console.test.","targets":["ns1.sub.console.test."]}`) m = h.query("www.sub.console.test", dns.TypeA) if len(m.Answer) != 1 { t.Fatalf("query below the cut = %v, want the upstream answer", m) } if a, ok := m.Answer[0].(*dns.A); !ok || a.A.String() != "203.0.113.7" { t.Errorf("below the cut = %v, want the upstream's 203.0.113.7", m.Answer[0]) } // And the cut itself is answerable, or no resolver could ever learn it. m = h.query("sub.console.test", dns.TypeNS) if len(m.Answer) != 1 { t.Errorf("NS at the cut = %v, want the delegation itself", m) } // A record below a cut is dead data, so it is refused rather than // written and silently never answered. if code, _ := do(http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"host.sub.console.test.","type":"A","value":"10.9.9.10"}`); code != http.StatusBadRequest { t.Errorf("a record below a delegation = %d, want 400", code) } // Deleting the record leaves the zone authoritative, and a zone that // exists says NXDOMAIN for the names it does not hold. That is what // creating a zone means, and it is why deleting a zone is a separate // action below rather than a side effect of deleting a record. mustWrite(http.MethodDelete, "/api/zones/records?zone=console.test.&name=www.console.test.&type=A", "") m = h.query("www.console.test", dns.TypeA) if m.Rcode != dns.RcodeNameError || len(m.Answer) != 0 { t.Errorf("query after the record was deleted = %v, want NXDOMAIN from the zone", m) } if m.Authoritative != true { t.Error("the NXDOMAIN is not authoritative, so the zone is not serving") } // Deleting something that is not there is a 404, not a 500. if code, _ := do(http.MethodDelete, "/api/zones/records?zone=console.test.&name=nope.console.test.&type=A", ""); code != http.StatusNotFound { t.Errorf("deleting an absent record = %d, want 404", code) } // Removing the zone is what hands the name back to upstream, and the // cached NXDOMAIN goes with it. mustWrite(http.MethodDelete, "/api/zones?zone=console.test.", "") if _, err := os.Stat(filepath.Join(managed, "console.test.zone")); !os.IsNotExist(err) { t.Errorf("the managed zone file is still there (stat err = %v)", err) } m = h.query("www.console.test", dns.TypeA) if len(m.Answer) != 1 { t.Fatalf("query after the zone was deleted = %v, want the upstream answer", m) } if a, ok := m.Answer[0].(*dns.A); !ok || a.A.String() != "203.0.113.7" { t.Errorf("after the zone was deleted = %v, want the upstream's 203.0.113.7", m.Answer[0]) } // And the zone is gone from the page. _, body = h.get("/api/zones") var gone zonesPayload if err := json.Unmarshal(body, &gone); err != nil { t.Fatalf("zones: %v: %s", err, body) } for _, zone := range gone.Zones { if zone.Origin == "console.test." { t.Errorf("the deleted zone is still listed: %+v", zone) } } // An operator's zone is not the console's to delete. if code, _ := do(http.MethodDelete, "/api/zones?zone=local.", ""); code != http.StatusNotFound { t.Errorf("deleting the operator's zone = %d, want 404", code) } // The operator's master file is still theirs: it is listed, and it is not // reported as console-managed. var sawLocal bool for _, zone := range gone.Zones { if zone.Origin != "local." { continue } sawLocal = true if zone.Managed { t.Error("the operator's zone is reported as managed, so the console would offer to rewrite it") } } if !sawLocal { t.Errorf("the operator's zone is missing from the list: %+v", gone.Zones) } } // zonesPayload is the slice of GET /api/zones this test reads. It is declared // here rather than imported so the assertions name the fields the console // depends on; the contract test in this package pins the whole key set. type zonesPayload struct { Zones []struct { Origin string `json:"origin"` Managed bool `json:"managed"` Serial uint32 `json:"serial"` Records []any `json:"records"` RecordCount int `json:"record_count"` Delegations []struct { Name string `json:"name"` Targets []string `json:"targets"` Managed bool `json:"managed"` } `json:"delegations"` } `json:"zones"` Files []string `json:"files"` ManagedDir string `json:"managed_dir"` Writable bool `json:"writable"` Types []string `json:"types"` Records int `json:"records"` } // TestZoneWritesNeedAManagedDirectory pins the read-only default: a // deployment that never sets blocklist.managed_zones can browse its local zones // and cannot have one rewritten behind its back. func TestZoneWritesNeedAManagedDirectory(t *testing.T) { h := newHarness(t, nil) code, body := h.get("/api/zones") if code != http.StatusOK { t.Fatalf("GET /api/zones = %d: %s", code, body) } var zones zonesPayload if err := json.Unmarshal(body, &zones); err != nil { t.Fatalf("zones: %v: %s", err, body) } if zones.Writable { t.Error("writable = true with no managed zone directory configured") } if zones.ManagedDir != "" { t.Errorf("managed_dir = %q, want empty", zones.ManagedDir) } if len(zones.Zones) == 0 { t.Error("the operator's local zone is missing, so the page would be blank rather than read-only") } for _, zone := range zones.Zones { if zone.Managed { t.Errorf("zone %s is reported as managed without a managed directory", zone.Origin) } } // Every write route refuses, and says what to configure. writes := []struct { method string path string body string }{ {http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"console.test.","type":"NS","value":"ns1.console.test."}`}, {http.MethodPut, "/api/zones/records", `{"zone":"console.test.","name":"www","type":"A","value":"10.0.0.1"}`}, {http.MethodDelete, "/api/zones/records?zone=console.test.&name=www&type=A", ""}, {http.MethodPost, "/api/zones/delegations", `{"zone":"console.test.","name":"sub","targets":["ns1.sub.console.test."]}`}, {http.MethodDelete, "/api/zones/delegations?zone=console.test.&name=sub.console.test.", ""}, {http.MethodDelete, "/api/zones?zone=console.test.", ""}, } for _, w := range writes { code, out := h.do(w.method, w.path, w.body) if code != http.StatusBadRequest { t.Errorf("%s %s = %d, want 400: %s", w.method, w.path, code, out) continue } if !strings.Contains(string(out), "managed_zones") { t.Errorf("%s %s did not say what to configure: %s", w.method, w.path, out) } } } // TestResolveUpstream covers the happy path: a name nobody blocks is forwarded // to the upstream and the answer is cached. func TestResolveUpstream(t *testing.T) { h := newHarness(t, nil) res := h.query("www.example.test", dns.TypeA) if res.Rcode != dns.RcodeSuccess { t.Fatalf("rcode = %s, want NOERROR", dns.RcodeToString[res.Rcode]) } if len(res.Answer) == 0 { t.Fatal("no answer records") } if got := res.Answer[0].(*dns.A).A.String(); got != "203.0.113.7" { t.Errorf("answer = %s, want 203.0.113.7", got) } if h.up.hitCount("www.example.test.") == 0 { t.Error("upstream was never asked") } // Second query must come from cache: the upstream counter must not move. before := h.up.hitCount("www.example.test.") h.query("www.example.test", dns.TypeA) if after := h.up.hitCount("www.example.test."); after != before { t.Errorf("upstream hit count moved from %d to %d: the cache did not serve the repeat query", before, after) } } // TestBlockedNames covers every blocklist source shape: hosts-file entries // (apex plus subdomains), RPZ wildcards and inline rules. func TestBlockedNames(t *testing.T) { h := newHarness(t, nil) cases := []string{ "ads.example.com", // hosts file "deep.ads.example.com", // hosts file, subdomain "tracker.example.net", "inline-blocked.example.com", "anything.inline-sub.example.com", } for _, name := range cases { res := h.query(name, dns.TypeA) if res.Rcode != dns.RcodeNameError { t.Errorf("%s: rcode = %s, want NXDOMAIN", name, dns.RcodeToString[res.Rcode]) } } // A blocked name must never reach the upstream. if n := h.up.hitCount("ads.example.com."); n != 0 { t.Errorf("blocked name reached the upstream %d times", n) } } // TestAllowOverridesBlock checks that an inline allow entry beats a list rule. func TestAllowOverridesBlock(t *testing.T) { h := newHarness(t, nil) res := h.query("allow.example.com", dns.TypeA) // The fake upstream only knows *.test., so an allowed-but-unresolvable name // must come back as NXDOMAIN *from upstream*, which means it got past the // blocklist. Assert on the upstream counter instead of the rcode. if h.up.hitCount("allow.example.com.") == 0 { t.Error("allowed name did not reach the upstream, so the allow rule lost to a block rule") } _ = res } // TestLocalZone covers the local master file: exact, wildcard and NODATA. func TestLocalZone(t *testing.T) { h := newHarness(t, nil) res := h.query("ns.local", dns.TypeA) if len(res.Answer) != 1 { t.Fatalf("want 1 answer for ns.local, got %d", len(res.Answer)) } if got := res.Answer[0].(*dns.A).A.String(); got != "192.168.1.2" { t.Errorf("ns.local = %s, want 192.168.1.2", got) } if !res.Authoritative { t.Error("local zone answer should be authoritative") } // Wildcard covers a name one label below the wildcard's parent. w := h.query("anything.wild.local", dns.TypeA) if len(w.Answer) == 0 || w.Answer[0].(*dns.A).A.String() != "192.168.1.50" { t.Errorf("wildcard answer wrong: %v", w.Answer) } // NODATA: the name exists but has no A record. n := h.query("deep.svc.local", dns.TypeAAAA) if n.Rcode != dns.RcodeSuccess { t.Errorf("NODATA rcode = %s, want NOERROR", dns.RcodeToString[n.Rcode]) } if len(n.Answer) != 0 { t.Errorf("NODATA returned %d answers", len(n.Answer)) } // NXDOMAIN inside a zone we are authoritative for. x := h.query("nope.local", dns.TypeA) if x.Rcode != dns.RcodeNameError { t.Errorf("in-zone NXDOMAIN rcode = %s", dns.RcodeToString[x.Rcode]) } } // TestACLDenyGroup checks that a deny group refuses before anything else, // including a name the local zone could answer. func TestACLDenyGroup(t *testing.T) { h := newHarness(t, func(c *config.Config) { c.ACL.Groups = []config.Group{{ Name: "deny-all", Match: config.Match{CIDRs: []string{"127.0.0.0/8"}}, Policy: "deny", Comment: "test", }} }) res := h.query("www.example.test", dns.TypeA) if res.Rcode != dns.RcodeRefused { t.Errorf("rcode = %s, want REFUSED", dns.RcodeToString[res.Rcode]) } if h.up.hitCount("www.example.test.") != 0 { t.Error("a denied client still reached the upstream") } } // TestACLAllowDomains checks a group's allow_domains acts as a filter. func TestACLAllowDomains(t *testing.T) { h := newHarness(t, func(c *config.Config) { c.ACL.Groups = []config.Group{{ Name: "only-test", Match: config.Match{CIDRs: []string{"127.0.0.0/8"}}, Policy: "allow", AllowDomains: []string{"*.test"}, }} }) // Inside the allow list: resolves. if r := h.query("www.example.test", dns.TypeA); r.Rcode != dns.RcodeSuccess { t.Errorf("allowed domain rcode = %s", dns.RcodeToString[r.Rcode]) } // Outside it: blocked by the group. if r := h.query("ads.example.com", dns.TypeA); r.Rcode != dns.RcodeNameError { t.Errorf("domain outside allow_domains rcode = %s, want NXDOMAIN", dns.RcodeToString[r.Rcode]) } } // TestACLSkipBlocklist checks a group can opt out of blocklist filtering. func TestACLSkipBlocklist(t *testing.T) { h := newHarness(t, func(c *config.Config) { c.ACL.Groups = []config.Group{{ Name: "trusted", Match: config.Match{CIDRs: []string{"127.0.0.0/8"}}, Policy: "allow", SkipBlocklist: true, }} }) h.query("ads.example.com", dns.TypeA) if h.up.hitCount("ads.example.com.") == 0 { t.Error("skip_blocklist group still blocked the name") } } // TestBlockActions covers the non-default blocklist responses. func TestBlockActions(t *testing.T) { t.Run("zero", func(t *testing.T) { h := newHarness(t, func(c *config.Config) { c.Block.DefaultAction = "zero" c.Block.ResponseIP = "0.0.0.0" }) res := h.query("ads.example.com", dns.TypeA) if res.Rcode != dns.RcodeSuccess { t.Fatalf("rcode = %s, want NOERROR", dns.RcodeToString[res.Rcode]) } if len(res.Answer) != 1 || res.Answer[0].(*dns.A).A.String() != "0.0.0.0" { t.Errorf("sinkhole answer = %v", res.Answer) } // AAAA for the same name must get the v6 sinkhole, not the v4 one. res6 := h.query("ads.example.com", dns.TypeAAAA) if len(res6.Answer) != 1 || res6.Answer[0].(*dns.AAAA).AAAA.String() != "::" { t.Errorf("v6 sinkhole answer = %v", res6.Answer) } }) t.Run("refused", func(t *testing.T) { h := newHarness(t, func(c *config.Config) { c.Block.DefaultAction = "refused" }) if r := h.query("ads.example.com", dns.TypeA); r.Rcode != dns.RcodeRefused { t.Errorf("rcode = %s, want REFUSED", dns.RcodeToString[r.Rcode]) } }) t.Run("redirect", func(t *testing.T) { h := newHarness(t, func(c *config.Config) { c.Block.DefaultAction = "redirect" c.Block.RedirectName = "blockpage.local" }) res := h.query("ads.example.com", dns.TypeA) if len(res.Answer) == 0 { t.Fatal("redirect produced no answer") } cname, ok := res.Answer[0].(*dns.CNAME) if !ok { t.Fatalf("first answer is %T, want CNAME", res.Answer[0]) } if cname.Target != "blockpage.local." { t.Errorf("cname target = %s", cname.Target) } }) } // TestMalformedQueries covers the input validation path. func TestMalformedQueries(t *testing.T) { h := newHarness(t, nil) // Zero questions: the server must answer FORMERR, not crash or hang. m := new(dns.Msg) m.Id = dns.Id() m.Question = nil c := &dns.Client{Timeout: 3 * time.Second} res, _, err := c.Exchange(m, h.addr) if err != nil { t.Fatalf("exchange: %v", err) } if res.Rcode != dns.RcodeFormatError { t.Errorf("rcode = %s, want FORMERR", dns.RcodeToString[res.Rcode]) } } // TestTCPSameAnswer checks TCP returns the same answer as UDP, and that a large // UDP answer is truncated rather than dropped. func TestTCPSameAnswer(t *testing.T) { h := newHarness(t, nil) m := new(dns.Msg) m.SetQuestion("www.example.test.", dns.TypeA) m.RecursionDesired = true tcp := &dns.Client{Net: "tcp", Timeout: 5 * time.Second} res, _, err := tcp.Exchange(m, h.addr) if err != nil { t.Fatalf("tcp exchange: %v", err) } if res.Rcode != dns.RcodeSuccess || len(res.Answer) == 0 { t.Errorf("tcp answer wrong: rcode=%s answers=%d", dns.RcodeToString[res.Rcode], len(res.Answer)) } } // TestAPIAuth checks the admin API refuses unauthenticated reads and accepts // the configured key. func TestAPIAuth(t *testing.T) { h := newHarness(t, nil) // No key: 401. res, err := httpGet("http://"+h.api+"/api/whoami", "") if err != nil { t.Fatal(err) } if res.status != 401 { t.Errorf("unauthenticated /whoami = %d, want 401", res.status) } res, err = httpGet("http://"+h.api+"/api/whoami", "wrong-key") if err != nil { t.Fatal(err) } if res.status != 401 { t.Errorf("bad key /whoami = %d, want 401", res.status) } res, err = httpGet("http://"+h.api+"/api/whoami", h.key) if err != nil { t.Fatal(err) } if res.status != 200 { t.Errorf("good key /whoami = %d, want 200", res.status) } var who struct { Name string `json:"name"` CanWrite bool `json:"can_write"` } if err := json.Unmarshal(res.body, &who); err != nil { t.Fatal(err) } if who.Name != "rw-1" || !who.CanWrite { t.Errorf("whoami = %+v", who) } // Health is deliberately unauthenticated for probes. res, err = httpGet("http://"+h.api+"/api/healthz", "") if err != nil { t.Fatal(err) } if res.status != 200 { t.Errorf("/healthz = %d, want 200", res.status) } } // TestReadOnlyKeyCannotWrite checks a read-only key is refused on mutations. func TestReadOnlyKeyCannotWrite(t *testing.T) { h := newHarness(t, func(c *config.Config) { c.API.Auth.ReadOnlyAPIKeys = []string{"ro-key"} }) res, err := httpRequest("POST", "http://"+h.api+"/api/cache/flush", "ro-key", "") if err != nil { t.Fatal(err) } if res.status != 403 { t.Errorf("read-only flush = %d, want 403", res.status) } res, err = httpRequest("POST", "http://"+h.api+"/api/cache/flush", h.key, "") if err != nil { t.Fatal(err) } if res.status != 200 { t.Errorf("read-write flush = %d, want 200", res.status) } } // TestDomainAddDeleteRoundTrip is the operator's core loop: add a rule, see it // take effect on a live query, then remove it. func TestDomainAddDeleteRoundTrip(t *testing.T) { h := newHarness(t, nil) // The name lives under .test. so the fake upstream resolves it: that way // NOERROR unambiguously means "not blocked" and NXDOMAIN unambiguously // means "the blocklist answered", with no third possibility to confuse the // assertion. const name = "newly-blocked.example.test" before := h.up.hitCount(dns.Fqdn(name)) if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeSuccess { t.Fatalf("precondition: rcode = %s, want NOERROR", dns.RcodeToString[r.Rcode]) } if h.up.hitCount(dns.Fqdn(name)) == before { t.Error("precondition: the upstream was not consulted") } code, body := h.do("POST", "/api/domains", `{"name":"`+name+`","action":"block"}`) if code != 200 { t.Fatalf("add domain = %d: %s", code, body) } if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeNameError { t.Errorf("after add, rcode = %s, want NXDOMAIN", dns.RcodeToString[r.Rcode]) } // The rule must survive a cache flush, i.e. it is really in the config and // not just a transient matcher state. if code, _ := h.do("POST", "/api/cache/flush", ""); code != 200 { t.Fatalf("flush = %d", code) } if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeNameError { t.Errorf("after flush, rcode = %s, want NXDOMAIN", dns.RcodeToString[r.Rcode]) } code, body = h.do("DELETE", "/api/domains/"+name, "") if code != 200 { t.Fatalf("delete domain = %d: %s", code, body) } if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeSuccess { t.Errorf("after delete, rcode = %s, want NOERROR", dns.RcodeToString[r.Rcode]) } } // TestDomainAddInvalid checks the API rejects a bad payload with 400 rather // than writing a broken rule. func TestDomainAddInvalid(t *testing.T) { h := newHarness(t, nil) code, _ := h.do("POST", "/api/domains", `{"name":"","action":"block"}`) if code != 400 { t.Errorf("empty name = %d, want 400", code) } code, _ = h.do("POST", "/api/domains", `{"name":"x.example.com","action":"nonsense"}`) if code != 400 { t.Errorf("bad action = %d, want 400", code) } } // TestACLUpdateRoundTrip checks the clients editor's write path and that a // saved group is enforced immediately. // The console's "Test" button goes through the live pipeline, so this pins // the whole path: a name the fake upstream answers must come back with the // answer records, and one it does not must come back as a result (a 200 with // an rcode) rather than as a failed request. func TestDNSTestEndpoint(t *testing.T) { h := newHarness(t, nil) code, body := h.do(http.MethodPost, "/api/dns/test", `{"name":"www.example.test","type":"A"}`) if code != http.StatusOK { t.Fatalf("POST /api/dns/test = %d: %s", code, body) } var hit struct { Name string `json:"name"` Type string `json:"type"` RCode string `json:"rcode"` Action string `json:"action"` Answers []string `json:"answers"` } if err := json.Unmarshal(body, &hit); err != nil { t.Fatal(err) } if hit.Name != "www.example.test." || hit.Type != "A" { t.Errorf("canonical name = %q type = %q", hit.Name, hit.Type) } if hit.RCode != "NOERROR" { t.Fatalf("rcode = %q, want NOERROR: %s", hit.RCode, body) } if len(hit.Answers) != 1 || !strings.Contains(hit.Answers[0], "203.0.113.7") { t.Errorf("answers = %v, want the upstream A record", hit.Answers) } // A name the upstream does not hold is still a successful test. code, body = h.do(http.MethodPost, "/api/dns/test", `{"name":"nothing.example.com","type":"A"}`) if code != http.StatusOK { t.Fatalf("POST /api/dns/test (miss) = %d: %s", code, body) } if !strings.Contains(string(body), "NXDOMAIN") { t.Errorf("a miss is not reported as a result: %s", body) } // An unknown record type is a bad request the caller can fix, so it comes // back as an error in the payload rather than as a bogus lookup. code, body = h.do(http.MethodPost, "/api/dns/test", `{"name":"www.example.test","type":"NOTATYPE"}`) if code != http.StatusOK { t.Fatalf("POST /api/dns/test (bad type) = %d: %s", code, body) } if !strings.Contains(string(body), "error") { t.Errorf("a bad type is not reported: %s", body) } } func TestACLUpdateRoundTrip(t *testing.T) { h := newHarness(t, nil) // Write a deny group through the API. body := `{"groups":[{"name":"nobody","match":{"cidrs":["127.0.0.0/8"]},"policy":"deny","comment":"test"}],"default_policy":"allow"}` code, out := h.do("PUT", "/api/clients", body) if code != 200 { t.Fatalf("put clients = %d: %s", code, out) } if r := h.query("www.example.test", dns.TypeA); r.Rcode != dns.RcodeRefused { t.Errorf("after save, rcode = %s, want REFUSED", dns.RcodeToString[r.Rcode]) } // Read it back. code, out = h.get("/api/clients") if code != 200 { t.Fatalf("get clients = %d", code) } if !strings.Contains(string(out), "nobody") { t.Error("saved group is missing from the read-back") } // An invalid group must be rejected with 400 and not applied. bad := `{"groups":[{"name":"x","match":{"cidrs":["not-a-cidr"]},"policy":"deny"}],"default_policy":"allow"}` code, _ = h.do("PUT", "/api/clients", bad) if code != 400 { t.Errorf("bad CIDR = %d, want 400", code) } } // TestUpstreamUpdate checks the upstreams editor round-trips and rebinds the // pool without a restart. func TestUpstreamUpdate(t *testing.T) { h := newHarness(t, nil) body := fmt.Sprintf(`{"policy":"failover","servers":[{"addr":%q,"proto":"udp","weight":1}],"max_attempts":1,"breaker_threshold":5,"healthcheck_interval_s":0}`, h.up.addr) code, out := h.do("PUT", "/api/upstreams", body) if code != 200 { t.Fatalf("put upstreams = %d: %s", code, out) } code, out = h.get("/api/upstreams") if code != 200 { t.Fatalf("get upstreams = %d", code) } if !strings.Contains(string(out), "failover") { t.Error("policy did not persist") } // Still resolves after the rebind. if r := h.query("www.example.test", dns.TypeA); r.Rcode != dns.RcodeSuccess { t.Errorf("after upstream update, rcode = %s", dns.RcodeToString[r.Rcode]) } // Invalid config rejected. code, _ = h.do("PUT", "/api/upstreams", `{"policy":"nonsense","servers":[]}`) if code != 400 { t.Errorf("bad policy = %d, want 400", code) } } // TestSettingsValidateAndSave covers the config editor: validate, save, and // confirm the file on disk changed. func TestSettingsValidateAndSave(t *testing.T) { h := newHarness(t, nil) code, out := h.get("/api/settings") if code != 200 { t.Fatalf("get settings = %d", code) } var s struct { ConfigYAML string `json:"config_yaml"` Path string `json:"path"` Writable bool `json:"writable"` } if err := json.Unmarshal(out, &s); err != nil { t.Fatal(err) } if !s.Writable { t.Error("config file should be writable in the test harness") } // A valid edit round-trips. edited := strings.Replace(s.ConfigYAML, "max_concurrent: 1024", "max_concurrent: 2048", 1) if edited == s.ConfigYAML { t.Fatal("test could not find the setting it wanted to edit") } payload, _ := json.Marshal(map[string]string{"config_yaml": edited}) code, out = h.do("POST", "/api/settings/validate", string(payload)) if code != 200 { t.Fatalf("validate = %d: %s", code, out) } var v struct { OK bool `json:"ok"` Errors []struct { Field string `json:"field"` Message string `json:"message"` } `json:"errors"` } if err := json.Unmarshal(out, &v); err != nil { t.Fatal(err) } if !v.OK { t.Errorf("valid config reported errors: %+v", v.Errors) } code, out = h.do("PUT", "/api/settings", string(payload)) if code != 200 { t.Fatalf("save settings = %d: %s", code, out) } // The file on disk must now contain the edit. onDisk := readFileString(t, s.Path) if !strings.Contains(onDisk, "max_concurrent: 2048") { t.Error("config file on disk was not updated") } // An invalid document must be refused and the file left alone. badPayload, _ := json.Marshal(map[string]string{"config_yaml": "server:\n max_concurrent: -5\n"}) code, _ = h.do("PUT", "/api/settings", string(badPayload)) if code != 400 { t.Errorf("invalid save = %d, want 400", code) } if readFileString(t, s.Path) != onDisk { t.Error("a rejected config write still modified the file") } } // TestBlocklistToggle checks a disabled list stops applying and its cached // answers are dropped. func TestBlocklistToggle(t *testing.T) { h := newHarness(t, nil) const name = "blocked.example.test" if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeNameError { t.Fatal("precondition: name should be blocked") } code, out := h.do("POST", "/api/blocklists/toggle", `{"name":"test-hosts","enabled":false}`) if code != 200 { t.Fatalf("toggle = %d: %s", code, out) } // The recompile runs in the background, so poll rather than assume. waitFor(t, 5*time.Second, func() bool { return h.query(name, dns.TypeA).Rcode == dns.RcodeSuccess }) // Re-enabling must block it again: a one-way toggle would be a nasty // surprise. if code, _ := h.do("POST", "/api/blocklists/toggle", `{"name":"test-hosts","enabled":true}`); code != 200 { t.Fatalf("re-enable = %d", code) } waitFor(t, 5*time.Second, func() bool { return h.query(name, dns.TypeA).Rcode == dns.RcodeNameError }) } // TestStatus checks the status and health endpoints carry the sections the // console's status page reads. func TestStatus(t *testing.T) { h := newHarness(t, nil) h.query("www.example.test", dns.TypeA) code, out := h.get("/api/healthz") if code != 200 || !strings.Contains(string(out), `"status":"ok"`) { t.Fatalf("healthz = %d %s", code, out) } code, out = h.get("/api/status") if code != 200 { t.Fatalf("status = %d", code) } var st struct { Components map[string]json.RawMessage `json:"components"` } if err := json.Unmarshal(out, &st); err != nil { t.Fatal(err) } for _, want := range []string{"listeners", "blocklist", "cache", "upstream", "access_log"} { if _, ok := st.Components[want]; !ok { t.Errorf("status has no %q section", want) } } } // TestCacheEndpoints covers the cache stats and flush. func TestCacheEndpoints(t *testing.T) { h := newHarness(t, nil) h.query("www.example.test", dns.TypeA) code, out := h.get("/api/cache") if code != 200 { t.Fatalf("cache = %d", code) } var c struct { Entries int `json:"entries"` Shards int `json:"shards"` HitRate float64 `json:"hit_rate"` } if err := json.Unmarshal(out, &c); err != nil { t.Fatal(err) } if c.Shards <= 0 { t.Error("shards is zero") } if code, _ := h.do("POST", "/api/cache/flush", ""); code != 200 { t.Errorf("flush = %d, want 200", code) } } // TestReloadFromDisk checks SIGHUP-style reload applies a hand-edited file and // reports a broken one without dropping the running config. func TestReloadFromDisk(t *testing.T) { h := newHarness(t, nil) // Break the file, then reload: must fail and keep serving. broken := strings.Replace(h.OriginalConfig(), "max_concurrent: 1024", "max_concurrent: not-a-number", 1) if broken == h.OriginalConfig() { t.Fatal("test could not find the setting it wanted to break") } writeFileString(t, h.app.cfg.Path(), broken) if err := h.app.Reload(); err == nil { t.Fatal("reload of a broken config should fail") } if h.app.cfg.LastError() == "" { t.Error("a failed reload should record the error for the health endpoint") } // The server keeps working with the previous config. if r := h.query("www.example.test", dns.TypeA); r.Rcode != dns.RcodeSuccess { t.Errorf("after a failed reload, rcode = %s", dns.RcodeToString[r.Rcode]) } // Fix the file and reload: should succeed and clear the error. The whole // document is rewritten from the harness's own config, changing only the // broken value, so the reload is testing a realistic edit rather than a // minimal stub. fixed := strings.Replace(h.OriginalConfig(), "max_concurrent: 1024", "max_concurrent: 512", 1) if fixed == h.OriginalConfig() { t.Fatal("test could not find the setting it wanted to edit") } writeFileString(t, h.app.cfg.Path(), fixed) if err := h.app.Reload(); err != nil { t.Fatalf("reload of a fixed config failed: %v", err) } if h.app.cfg.LastError() != "" { t.Error("a successful reload should clear the recorded error") } } // TestMetricsEndpoint checks Prometheus output is served and protected. func TestMetricsEndpoint(t *testing.T) { h := newHarness(t, nil) res, err := httpGet("http://"+h.api+"/api/metrics", "") if err != nil { t.Fatal(err) } if res.status != 200 { t.Errorf("unprotected /metrics = %d, want 200", res.status) } if !strings.Contains(string(res.body), "fancydns_queries_total") { t.Error("metrics output has no fancydns_queries_total") } // With protection on, an unauthenticated scrape must fail. h2 := newHarness(t, func(c *config.Config) { c.Metrics.Protect = true }) res, err = httpGet("http://"+h2.api+"/api/metrics", "") if err != nil { t.Fatal(err) } if res.status != 401 { t.Errorf("protected /metrics without a key = %d, want 401", res.status) } res, err = httpGet("http://"+h2.api+"/api/metrics", h2.key) if err != nil { t.Fatal(err) } if res.status != 200 { t.Errorf("protected /metrics with a key = %d, want 200", res.status) } } // TestConcurrentQueries hammers the server from many goroutines to catch races // and to make sure the shared state is actually safe. Run with -race. func TestConcurrentQueries(t *testing.T) { h := newHarness(t, nil) const workers, each = 16, 25 done := make(chan error, workers) for w := 0; w < workers; w++ { go func(w int) { m := new(dns.Msg) m.SetQuestion("www.example.test.", dns.TypeA) m.RecursionDesired = true c := &dns.Client{Net: "udp", Timeout: 5 * time.Second} for i := 0; i < each; i++ { if _, _, err := c.Exchange(m, h.addr); err != nil { done <- err return } } done <- nil }(w) } for w := 0; w < workers; w++ { if err := <-done; err != nil { t.Fatalf("concurrent query failed: %v", err) } } // Also exercise the API concurrently, since it reads the same state. apiDone := make(chan error, workers) for w := 0; w < workers; w++ { go func() { res, err := httpGet("http://"+h.api+"/api/status", h.key) if err != nil { apiDone <- err return } if res.status != 200 { apiDone <- fmt.Errorf("status = %d", res.status) return } apiDone <- nil }() } for w := 0; w < workers; w++ { if err := <-apiDone; err != nil { t.Errorf("concurrent api call failed: %v", err) } } }