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 @@ web - - + +
diff --git a/web/src/App.tsx b/web/src/App.tsx index 807e62b..586b386 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -20,6 +20,7 @@ import Domains from './pages/Domains.tsx' import Login from './pages/Login.tsx' import Logs from './pages/Logs.tsx' import LocalZones from './pages/LocalZones.tsx' +import Lookup from './pages/Lookup.tsx' import NotFound from './pages/NotFound.tsx' import Settings from './pages/Settings.tsx' import Upstreams from './pages/Upstreams.tsx' @@ -52,6 +53,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 080f59f..cfe763c 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -15,6 +15,7 @@ import { Network, PanelLeftClose, ScrollText, + Search, Settings, Users, X, @@ -39,6 +40,7 @@ const NAV: NavItem[] = [ { to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true }, { to: '/blocklists', label: 'Blocklists', icon: ListChecks }, { to: '/domains', label: 'Domains', icon: Globe }, + { to: '/lookup', label: 'Lookup', icon: Search }, { to: '/local-zones', label: 'Local zones', icon: FileCode2 }, { to: '/zones', label: 'Zones', icon: Layers }, { to: '/clients', label: 'Clients', icon: Users }, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a0b2126..22492cd 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -18,6 +18,7 @@ import type { DomainCheckResponse, DomainFilters, DomainsResponse, + DnsTestResponse, FlushCacheResponse, LoginInput, LoginResponse, @@ -278,6 +279,14 @@ export function checkDomain(name: string): Promise { return apiFetch(apiUrl('/api/domains/check', { name })) } +/** Runs one query through the live pipeline, the console's connectivity test. */ +export function testDns(name: string, qtype: string): Promise { + return apiFetch('/api/dns/test', { + method: 'POST', + body: JSON.stringify({ name, type: qtype }), + }) +} + export function getZones(): Promise { return apiFetch('/api/zones') } diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 3ae8d33..0a4189c 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -315,6 +315,30 @@ export interface DomainCheckError { export type DomainCheckResponse = DomainCheckMatch | DomainCheckMiss | DomainCheckError +// --- POST /api/dns/test ----------------------------------------------------- + +/** + * The result of one query through the live pipeline. A failed lookup is a + * 200 with an rcode and no answers, so `error` only appears when the query + * never ran (bad name, engine down). + */ +export interface DnsTestResponse { + name: string + type: string + rcode: string + action: LogAction | string + answers: string[] + duration_ms: number + upstream: string + group: string + cache: string + local: boolean + truncated: boolean + blocked: boolean + block_source: string + error?: string +} + // --- GET /api/clients ------------------------------------------------------ export interface ClientMatch { diff --git a/web/src/pages/Lookup.tsx b/web/src/pages/Lookup.tsx new file mode 100644 index 0000000..61f289f --- /dev/null +++ b/web/src/pages/Lookup.tsx @@ -0,0 +1,335 @@ +/** + * Lookup — the console's dig, and the place a domain's config is copied from. + * + * A test goes through the live pipeline (blocklists, ACL, local zones, cache, + * upstream), so it answers the question an operator actually has: "would a + * client pointed at this server get an answer for this name". The entries list + * is the same query over the record types a domain is normally configured + * with, and every entry can be turned into a local-zone override from there. + */ + +import { useState } from 'react' +import type { ReactNode } from 'react' +import { Plus, Search } from 'lucide-react' + +import { addZoneRecord, testDns } from '../lib/api' +import { useWhoami } from '../lib/session.ts' +import type { DnsTestResponse } from '../lib/types' +import { useAsyncAction } from '../hooks/useApi' +import { + Badge, + Button, + Card, + Dialog, + EmptyState, + IconButton, + Input, + Select, + Table, + useToast, +} from '../components/ui' +import { PageHeader } from '../components/layout' + +type Row = Record + +/** The record types a "list the entries of this domain" lookup asks for. */ +const ENTRY_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA'] + +const TEST_TYPES = ['A', 'AAAA', 'TXT', 'MX', 'NS', 'SOA', 'CNAME', 'PTR', 'ANY'] + +interface Entry { + key: string + name: string + type: string + ttl: number + value: string +} + +/** + * Splits one rendered answer into its parts. The server sends the record as it + * appears in a zone file — `name ttl class type rdata` — so the rdata is + * whatever follows the fourth field and keeps its spaces (a TXT value, a + * quoted MX preference and exchange). + */ +function parseEntry(answer: string, type: string, index: number): Entry | null { + const parts = answer.trim().split(/\s+/) + if (parts.length < 5) return null + const ttl = Number.parseInt(parts[1], 10) + return { + key: `${parts[0]}-${type}-${index}`, + name: parts[0], + type: parts[3].toUpperCase(), + ttl: Number.isFinite(ttl) ? ttl : 0, + value: parts.slice(4).join(' '), + } +} + +/** + * The apex to offer as the override zone. There is no public-suffix list in the + * binary, so this takes the last two labels: for `www.example.com` that is + * `example.com`, and the dialog opens with the field editable because + * `example.co.uk` needs three. + */ +function defaultZone(name: string): string { + return name.replace(/\.$/, '').split('.').slice(-2).join('.') +} + +function rcodeTone(rcode: string): 'default' | 'ok' | 'warn' | 'danger' { + if (rcode === 'NOERROR') return 'ok' + if (rcode === 'NXDOMAIN') return 'warn' + return 'danger' +} + +export default function Lookup() { + const { toast } = useToast() + const { canWrite } = useWhoami() + const [name, setName] = useState('') + const [qtype, setQtype] = useState('A') + const [result, setResult] = useState(null) + const [entries, setEntries] = useState([]) + const [listed, setListed] = useState('') + + const [override, setOverride] = useState(null) + const [zone, setZone] = useState('') + const [relName, setRelName] = useState('') + const [ttl, setTtl] = useState('') + + const test = useAsyncAction(async (target: string, type: string) => testDns(target, type)) + const write = useAsyncAction(async (input: { zone: string; name: string; type: string; ttl: number; value: string }) => + addZoneRecord({ + zone: input.zone, + name: input.name, + type: input.type, + ttl: input.ttl, + value: input.value, + }), + ) + + const trimmed = name.trim() + + const runTest = async (): Promise => { + if (trimmed === '') return + const res = await test.run(trimmed, qtype) + if (res === null) { + toast({ title: test.error ?? 'the test query failed', tone: 'danger' }) + return + } + setResult(res) + } + + const listEntries = async (): Promise => { + if (trimmed === '') return + const results = await Promise.all(ENTRY_TYPES.map((t) => testDns(trimmed, t))) + const found: Entry[] = [] + for (const [i, res] of results.entries()) { + if (res.error !== undefined) continue + for (const [j, answer] of res.answers.entries()) { + const entry = parseEntry(answer, ENTRY_TYPES[i], j) + if (entry !== null) found.push(entry) + } + } + setEntries(found) + setListed(trimmed) + toast({ + title: found.length === 0 ? `no records for ${trimmed}` : `${found.length} record${found.length === 1 ? '' : 's'} for ${trimmed}`, + tone: found.length === 0 ? 'warn' : 'ok', + }) + } + + const openOverride = (entry: Entry): void => { + const apex = defaultZone(entry.name) + setOverride(entry) + setZone(apex) + setRelName(entry.name === `${apex}.` || entry.name === apex ? '@' : entry.name.replace(/\.$/, '').slice(0, -(apex.length + 1))) + setTtl(entry.ttl > 0 ? String(entry.ttl) : '300') + } + + const saveOverride = async (): Promise => { + const target = override + if (target === null) return + const res = await write.run({ + zone: zone.trim(), + name: relName.trim() === '' ? '@' : relName.trim(), + type: target.type, + ttl: Number.parseInt(ttl, 10) || target.ttl, + value: target.value, + }) + if (res === null) { + toast({ title: write.error ?? 'the server rejected that record', tone: 'danger' }) + return + } + setOverride(null) + toast({ title: `override created in ${res.zone}`, description: `${target.type} ${target.name}`, tone: 'ok' }) + } + + const columns = [ + { key: 'name', header: 'Name', width: '40%' }, + { key: 'type', header: 'Type', width: '10%' }, + { key: 'value', header: 'Value', width: '38%' }, + { key: 'ttl', header: 'TTL', width: '6%', align: 'right' as const }, + { key: 'override', header: '', width: '6%', align: 'right' as const }, + ] + + const rows: Row[] = entries.map((entry) => ({ + __key: entry.key, + name: {entry.name}, + type: {entry.type}, + value: {entry.value}, + ttl: {entry.ttl || '—'}, + override: ( + openOverride(entry)} + /> + ), + })) + + return ( +
+ + + +
+ setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void runTest() + }} + autoComplete="off" + spellCheck={false} + /> + setZone(e.target.value)} + autoComplete="off" + spellCheck={false} + /> + setRelName(e.target.value)} + autoComplete="off" + spellCheck={false} + /> + undefined} + disabled + /> + setTtl(e.target.value)} + inputMode="numeric" + /> +
+
+ {override?.name} {override?.ttl} IN {override?.type} {override?.value} +
+
+ + + ) +}