diff --git a/.codesearch/codesearch.db b/.codesearch/codesearch.db index 14925ed..696f600 100644 Binary files a/.codesearch/codesearch.db and b/.codesearch/codesearch.db differ diff --git a/.codesearch/session.json b/.codesearch/session.json index 36e8fa9..d489b43 100644 --- a/.codesearch/session.json +++ b/.codesearch/session.json @@ -1,6 +1,9 @@ { "root": "/workspace/repos/git.k6n.net/mats/fancy-dns", "recent_files": [ + "internal/qlog/qlog_test.go", + "internal/app/app.go", + "web/src/pages/Zones.tsx", "internal/cluster/cluster_test.go", "README.md", "internal/server/shutdown_test.go", @@ -23,34 +26,13 @@ "internal/blocklist/parse.go", "internal/app/memory.go", "internal/api/auth.go", - "internal/app/app.go", "internal/api/api.go", "internal/zonefile/zonefile_test.go", "internal/engine/pins_test.go", "internal/engine/harness_test.go", - "internal/metrics/metrics.go", - "internal/qlog/counts.go", - "internal/metrics/metrics_test.go" + "internal/metrics/metrics.go" ], "queries": [ - { - "query": "func Load|KnownFields|yaml.NewDecoder|func Marshal", - "command": "grep", - "at": "2026-09-27T16:22:55.810381382Z", - "focus_path": "internal/config/config.go" - }, - { - "query": "NSID|OutcomeLocal|ActionAllow|ActionLocal", - "command": "grep", - "at": "2026-09-27T16:22:55.85487649Z", - "focus_path": "internal/engine/engine_test.go" - }, - { - "query": "^func Test", - "command": "grep", - "at": "2026-09-27T16:23:07.872849833Z", - "focus_path": "internal/config/size_test.go" - }, { "query": "Outcome|func Record\\(|local", "command": "grep", @@ -329,8 +311,26 @@ "query": "(?i)qtype|match|count|can_write|types\\.ts|hand-written", "command": "grep", "at": "2026-09-27T17:14:02.764934975Z" + }, + { + "query": "(?i)name:|zone:", + "command": "grep", + "at": "2026-09-28T08:14:05.218032007Z", + "focus_path": "web/src/pages/Zones.tsx" + }, + { + "query": "func \\(a Action\\) String|StringToType|func \\(s \\*Server\\) request\\(", + "command": "grep", + "at": "2026-09-28T08:14:17.238295025Z", + "focus_path": "internal/app/app.go" + }, + { + "query": "Action|type Action", + "command": "grep", + "at": "2026-09-28T08:15:04.827470149Z", + "focus_path": "internal/qlog/qlog_test.go" } ], "ast_pins": [], - "updated_at": "2026-09-27T17:14:02.764941775Z" + "updated_at": "2026-09-28T08:15:04.827473319Z" } \ No newline at end of file diff --git a/README.md b/README.md index 56b9981..88959c5 100644 --- a/README.md +++ b/README.md @@ -543,6 +543,7 @@ is on. | GET | `/api/domains?q=&action=&list=&page=&per_page=` | — | page the compiled rules | | GET | `/api/domains/check?name=…` | — | explain how a name would be treated (the fastest "why isn't this blocked" tool) | | POST | `/api/domains` | yes | add an inline rule | +| POST | `/api/dns/test` | — | run one query through the live pipeline (`{"name","type"}`) and report the rcode, action, answers, upstream and latency — the console's "is my DNS answering" test, and a failed lookup is a 200 with a result, not a failed request | | DELETE | `/api/domains/{name…}?action=&list=` | yes | remove a rule | | GET | `/api/zones` | — | every local zone with its records, delegations, source files, the managed directory, whether it is writable, the writable record types, and why the store is stale (`error`) | | POST | `/api/zones/records` | yes | add a record (`{"zone","name","type","ttl","value"}`; a name without a trailing dot is relative to the zone) | diff --git a/internal/api/api.go b/internal/api/api.go index 46474b3..76fe4e1 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -65,6 +65,9 @@ type Hooks struct { ToggleBlocklist func(name string, enabled bool) error // DomainCheck explains how a name would be treated. DomainCheck func(name string) (any, error) + // Resolve runs one query through the live pipeline and reports the + // outcome, so the console can test the server it is talking to. + Resolve func(ctx context.Context, name, qtype string) (any, error) // ListDomains pages the compiled rules. ListDomains func(q DomainQuery) (any, error) // AddDomain adds an inline rule and persists it. @@ -286,6 +289,7 @@ func (s *Server) routes(cfg *config.Config) *http.ServeMux { mux.Handle("POST "+base+"domains", s.authed(s.handleDomainAdd, true)) mux.Handle("DELETE "+base+"domains/{name...}", s.authed(s.handleDomainDelete, true)) mux.Handle("GET "+base+"domains/check", s.authed(s.handleDomainCheck, false)) + mux.Handle("POST "+base+"dns/test", s.authed(s.handleDNSTest, false)) mux.Handle("GET "+base+"local-zones", s.authed(s.handleLocalZones, false)) mux.Handle("PUT "+base+"local-zones/{name}", s.authed(s.handleLocalZonePut, true)) mux.Handle("DELETE "+base+"local-zones/{name}", s.authed(s.handleLocalZoneDelete, true)) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 884ad49..bfbee4b 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -177,6 +177,37 @@ func (s *Server) handleDomainCheck(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, v) } +// handleDNSTest answers one query through the live pipeline. It is the +// console's "is my DNS actually answering" button: the answer comes from the +// same engine the listeners use, so what it shows is what a client gets, and +// a query error is reported inside the payload rather than as a status code — +// a failed lookup is a result here, not a failed request. +func (s *Server) handleDNSTest(w http.ResponseWriter, r *http.Request) { + if s.hooks.Resolve == nil { + writeError(w, http.StatusNotImplemented, "dns test not available") + return + } + var in struct { + Name string `json:"name"` + QType string `json:"type"` + } + if err := decodeJSON(r, &in); err != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + name := strings.TrimSpace(in.Name) + if name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + v, err := s.hooks.Resolve(r.Context(), name, in.QType) + if err != nil { + writeJSON(w, http.StatusOK, map[string]any{"name": name, "error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, v) +} + func (s *Server) handleLocalZones(w http.ResponseWriter, r *http.Request) { if s.hooks.LocalZones == nil { writeError(w, http.StatusNotImplemented, "local zone editor is unavailable") diff --git a/internal/app/hooks.go b/internal/app/hooks.go index 71dcea1..270ae01 100644 --- a/internal/app/hooks.go +++ b/internal/app/hooks.go @@ -2,7 +2,9 @@ package app import ( "context" + "errors" "fmt" + "net/netip" "sort" "strings" "sync" @@ -13,6 +15,7 @@ import ( "git.k8s-git-k6n.net/mats/fancy-dns/internal/api" "git.k8s-git-k6n.net/mats/fancy-dns/internal/blocklist" "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" + "git.k8s-git-k6n.net/mats/fancy-dns/internal/engine" "git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics" "git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog" "git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver" @@ -35,6 +38,7 @@ func (a *App) hooks() api.Hooks { RefreshBlocklists: a.refreshBlocklists, ToggleBlocklist: a.toggleBlocklist, DomainCheck: a.domainCheck, + Resolve: a.resolveTest, ListDomains: a.listDomains, AddDomain: a.addDomain, DeleteDomain: a.deleteDomain, @@ -398,7 +402,60 @@ func (a *App) domainCheck(name string) (any, error) { return out, nil } -// listDomains pages the compiled rules. Walking a million-entry map on every +// resolveTest runs one query through the live pipeline. It goes through the +// engine rather than straight to an upstream: a test that skipped the +// pipeline would report a healthy upstream while the blocklist, the ACL or the +// local zones were what a client actually hit. +func (a *App) resolveTest(ctx context.Context, name, qtype string) (any, error) { + if a.engine == nil { + return nil, errors.New("the query engine is not running") + } + qt := dns.TypeA + if qtype = strings.TrimSpace(qtype); qtype != "" { + var ok bool + qt, ok = dns.StringToType[strings.ToUpper(qtype)] + if !ok { + return nil, fmt.Errorf("unknown record type %q", qtype) + } + } + q := new(dns.Msg) + q.SetQuestion(dns.Fqdn(name), qt) + q.RecursionDesired = true + res := a.engine.Handle(ctx, engine.Request{ + Msg: q, + Remote: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + Transport: "api", + Start: time.Now(), + }) + out := map[string]any{ + "name": dns.Fqdn(name), + "type": dns.TypeToString[qt], + "rcode": "SERVFAIL", + "action": string(res.Action), + "duration_ms": res.Duration.Milliseconds(), + "upstream": res.Upstream, + "group": res.Group, + "cache": res.CacheKind, + "local": res.Local, + "truncated": res.Truncated, + "blocked": res.BlockSource != "", + "block_source": res.BlockSource, + "answers": []string{}, + } + if res.Msg == nil { + out["error"] = "no response" + return out, nil + } + out["rcode"] = dns.RcodeToString[res.Msg.Rcode] + answers := make([]string, 0, len(res.Msg.Answer)) + for _, rr := range res.Msg.Answer { + answers = append(answers, strings.TrimSpace(rr.String())) + } + out["answers"] = answers + return out, nil +} + +// listDomains pages the compiled rules. // request would be too slow, so the filter runs over a bounded sample unless // the query is a prefix/exact lookup, which the LRU can answer directly. func (a *App) listDomains(q api.DomainQuery) (any, error) { diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go index 2643d21..f1a759c 100644 --- a/internal/app/integration_test.go +++ b/internal/app/integration_test.go @@ -608,6 +608,57 @@ func TestDomainAddInvalid(t *testing.T) { // 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. diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index ed9d371..6d9e6ff 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -5,8 +5,8 @@
{result.error}
+ ) : null} + {result.block_source ? ( +Blocked by {result.block_source}
+ ) : null} + {result.answers.length > 0 ? ( +No answer records.
+ ) : null} +