mroe ui #2
No files matched your search
Binary file not shown.
+23
-23
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"root": "/workspace/repos/git.k6n.net/mats/fancy-dns",
|
"root": "/workspace/repos/git.k6n.net/mats/fancy-dns",
|
||||||
"recent_files": [
|
"recent_files": [
|
||||||
|
"internal/qlog/qlog_test.go",
|
||||||
|
"internal/app/app.go",
|
||||||
|
"web/src/pages/Zones.tsx",
|
||||||
"internal/cluster/cluster_test.go",
|
"internal/cluster/cluster_test.go",
|
||||||
"README.md",
|
"README.md",
|
||||||
"internal/server/shutdown_test.go",
|
"internal/server/shutdown_test.go",
|
||||||
@@ -23,34 +26,13 @@
|
|||||||
"internal/blocklist/parse.go",
|
"internal/blocklist/parse.go",
|
||||||
"internal/app/memory.go",
|
"internal/app/memory.go",
|
||||||
"internal/api/auth.go",
|
"internal/api/auth.go",
|
||||||
"internal/app/app.go",
|
|
||||||
"internal/api/api.go",
|
"internal/api/api.go",
|
||||||
"internal/zonefile/zonefile_test.go",
|
"internal/zonefile/zonefile_test.go",
|
||||||
"internal/engine/pins_test.go",
|
"internal/engine/pins_test.go",
|
||||||
"internal/engine/harness_test.go",
|
"internal/engine/harness_test.go",
|
||||||
"internal/metrics/metrics.go",
|
"internal/metrics/metrics.go"
|
||||||
"internal/qlog/counts.go",
|
|
||||||
"internal/metrics/metrics_test.go"
|
|
||||||
],
|
],
|
||||||
"queries": [
|
"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",
|
"query": "Outcome|func Record\\(|local",
|
||||||
"command": "grep",
|
"command": "grep",
|
||||||
@@ -329,8 +311,26 @@
|
|||||||
"query": "(?i)qtype|match|count|can_write|types\\.ts|hand-written",
|
"query": "(?i)qtype|match|count|can_write|types\\.ts|hand-written",
|
||||||
"command": "grep",
|
"command": "grep",
|
||||||
"at": "2026-09-27T17:14:02.764934975Z"
|
"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": [],
|
"ast_pins": [],
|
||||||
"updated_at": "2026-09-27T17:14:02.764941775Z"
|
"updated_at": "2026-09-28T08:15:04.827473319Z"
|
||||||
}
|
}
|
||||||
@@ -543,6 +543,7 @@ is on.
|
|||||||
| GET | `/api/domains?q=&action=&list=&page=&per_page=` | — | page the compiled rules |
|
| 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) |
|
| 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/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 |
|
| 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`) |
|
| 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) |
|
| POST | `/api/zones/records` | yes | add a record (`{"zone","name","type","ttl","value"}`; a name without a trailing dot is relative to the zone) |
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ type Hooks struct {
|
|||||||
ToggleBlocklist func(name string, enabled bool) error
|
ToggleBlocklist func(name string, enabled bool) error
|
||||||
// DomainCheck explains how a name would be treated.
|
// DomainCheck explains how a name would be treated.
|
||||||
DomainCheck func(name string) (any, error)
|
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 pages the compiled rules.
|
||||||
ListDomains func(q DomainQuery) (any, error)
|
ListDomains func(q DomainQuery) (any, error)
|
||||||
// AddDomain adds an inline rule and persists it.
|
// 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("POST "+base+"domains", s.authed(s.handleDomainAdd, true))
|
||||||
mux.Handle("DELETE "+base+"domains/{name...}", s.authed(s.handleDomainDelete, 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("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("GET "+base+"local-zones", s.authed(s.handleLocalZones, false))
|
||||||
mux.Handle("PUT "+base+"local-zones/{name}", s.authed(s.handleLocalZonePut, true))
|
mux.Handle("PUT "+base+"local-zones/{name}", s.authed(s.handleLocalZonePut, true))
|
||||||
mux.Handle("DELETE "+base+"local-zones/{name}", s.authed(s.handleLocalZoneDelete, true))
|
mux.Handle("DELETE "+base+"local-zones/{name}", s.authed(s.handleLocalZoneDelete, true))
|
||||||
|
|||||||
@@ -177,6 +177,37 @@ func (s *Server) handleDomainCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, v)
|
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) {
|
func (s *Server) handleLocalZones(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.hooks.LocalZones == nil {
|
if s.hooks.LocalZones == nil {
|
||||||
writeError(w, http.StatusNotImplemented, "local zone editor is unavailable")
|
writeError(w, http.StatusNotImplemented, "local zone editor is unavailable")
|
||||||
|
|||||||
+58
-1
@@ -2,7 +2,9 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"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/api"
|
||||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/blocklist"
|
"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/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/metrics"
|
||||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
|
||||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
|
||||||
@@ -35,6 +38,7 @@ func (a *App) hooks() api.Hooks {
|
|||||||
RefreshBlocklists: a.refreshBlocklists,
|
RefreshBlocklists: a.refreshBlocklists,
|
||||||
ToggleBlocklist: a.toggleBlocklist,
|
ToggleBlocklist: a.toggleBlocklist,
|
||||||
DomainCheck: a.domainCheck,
|
DomainCheck: a.domainCheck,
|
||||||
|
Resolve: a.resolveTest,
|
||||||
ListDomains: a.listDomains,
|
ListDomains: a.listDomains,
|
||||||
AddDomain: a.addDomain,
|
AddDomain: a.addDomain,
|
||||||
DeleteDomain: a.deleteDomain,
|
DeleteDomain: a.deleteDomain,
|
||||||
@@ -398,7 +402,60 @@ func (a *App) domainCheck(name string) (any, error) {
|
|||||||
return out, nil
|
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
|
// 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.
|
// the query is a prefix/exact lookup, which the LRU can answer directly.
|
||||||
func (a *App) listDomains(q api.DomainQuery) (any, error) {
|
func (a *App) listDomains(q api.DomainQuery) (any, error) {
|
||||||
|
|||||||
@@ -608,6 +608,57 @@ func TestDomainAddInvalid(t *testing.T) {
|
|||||||
|
|
||||||
// TestACLUpdateRoundTrip checks the clients editor's write path and that a
|
// TestACLUpdateRoundTrip checks the clients editor's write path and that a
|
||||||
// saved group is enforced immediately.
|
// 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) {
|
func TestACLUpdateRoundTrip(t *testing.T) {
|
||||||
h := newHarness(t, nil)
|
h := newHarness(t, nil)
|
||||||
// Write a deny group through the API.
|
// Write a deny group through the API.
|
||||||
|
|||||||
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>web</title>
|
<title>web</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BFc4v6ED.js"></script>
|
<script type="module" crossorigin src="/assets/index-CzcgOHn-.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CYPXMp15.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-FxcuHhfL.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import Domains from './pages/Domains.tsx'
|
|||||||
import Login from './pages/Login.tsx'
|
import Login from './pages/Login.tsx'
|
||||||
import Logs from './pages/Logs.tsx'
|
import Logs from './pages/Logs.tsx'
|
||||||
import LocalZones from './pages/LocalZones.tsx'
|
import LocalZones from './pages/LocalZones.tsx'
|
||||||
|
import Lookup from './pages/Lookup.tsx'
|
||||||
import NotFound from './pages/NotFound.tsx'
|
import NotFound from './pages/NotFound.tsx'
|
||||||
import Settings from './pages/Settings.tsx'
|
import Settings from './pages/Settings.tsx'
|
||||||
import Upstreams from './pages/Upstreams.tsx'
|
import Upstreams from './pages/Upstreams.tsx'
|
||||||
@@ -52,6 +53,7 @@ function AppRoutes() {
|
|||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/blocklists" element={<Blocklists />} />
|
<Route path="/blocklists" element={<Blocklists />} />
|
||||||
<Route path="/domains" element={<Domains />} />
|
<Route path="/domains" element={<Domains />} />
|
||||||
|
<Route path="/lookup" element={<Lookup />} />
|
||||||
<Route path="/local-zones" element={<LocalZones />} />
|
<Route path="/local-zones" element={<LocalZones />} />
|
||||||
<Route path="/zones" element={<Zones />} />
|
<Route path="/zones" element={<Zones />} />
|
||||||
<Route path="/clients" element={<Clients />} />
|
<Route path="/clients" element={<Clients />} />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Network,
|
Network,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
Users,
|
Users,
|
||||||
X,
|
X,
|
||||||
@@ -39,6 +40,7 @@ const NAV: NavItem[] = [
|
|||||||
{ to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true },
|
{ to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true },
|
||||||
{ to: '/blocklists', label: 'Blocklists', icon: ListChecks },
|
{ to: '/blocklists', label: 'Blocklists', icon: ListChecks },
|
||||||
{ to: '/domains', label: 'Domains', icon: Globe },
|
{ to: '/domains', label: 'Domains', icon: Globe },
|
||||||
|
{ to: '/lookup', label: 'Lookup', icon: Search },
|
||||||
{ to: '/local-zones', label: 'Local zones', icon: FileCode2 },
|
{ to: '/local-zones', label: 'Local zones', icon: FileCode2 },
|
||||||
{ to: '/zones', label: 'Zones', icon: Layers },
|
{ to: '/zones', label: 'Zones', icon: Layers },
|
||||||
{ to: '/clients', label: 'Clients', icon: Users },
|
{ to: '/clients', label: 'Clients', icon: Users },
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
DomainCheckResponse,
|
DomainCheckResponse,
|
||||||
DomainFilters,
|
DomainFilters,
|
||||||
DomainsResponse,
|
DomainsResponse,
|
||||||
|
DnsTestResponse,
|
||||||
FlushCacheResponse,
|
FlushCacheResponse,
|
||||||
LoginInput,
|
LoginInput,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
@@ -278,6 +279,14 @@ export function checkDomain(name: string): Promise<DomainCheckResponse> {
|
|||||||
return apiFetch<DomainCheckResponse>(apiUrl('/api/domains/check', { name }))
|
return apiFetch<DomainCheckResponse>(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<DnsTestResponse> {
|
||||||
|
return apiFetch<DnsTestResponse>('/api/dns/test', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name, type: qtype }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function getZones(): Promise<ZonesResponse> {
|
export function getZones(): Promise<ZonesResponse> {
|
||||||
return apiFetch<ZonesResponse>('/api/zones')
|
return apiFetch<ZonesResponse>('/api/zones')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,6 +315,30 @@ export interface DomainCheckError {
|
|||||||
|
|
||||||
export type DomainCheckResponse = DomainCheckMatch | DomainCheckMiss | 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 ------------------------------------------------------
|
// --- GET /api/clients ------------------------------------------------------
|
||||||
|
|
||||||
export interface ClientMatch {
|
export interface ClientMatch {
|
||||||
|
|||||||
@@ -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<string, ReactNode>
|
||||||
|
|
||||||
|
/** 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<DnsTestResponse | null>(null)
|
||||||
|
const [entries, setEntries] = useState<Entry[]>([])
|
||||||
|
const [listed, setListed] = useState('')
|
||||||
|
|
||||||
|
const [override, setOverride] = useState<Entry | null>(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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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: <span className="block max-w-[280px] truncate font-mono text-xs" title={entry.name}>{entry.name}</span>,
|
||||||
|
type: <Badge tone="info">{entry.type}</Badge>,
|
||||||
|
value: <span className="block max-w-[320px] truncate font-mono text-xs" title={entry.value}>{entry.value}</span>,
|
||||||
|
ttl: <span className="font-mono text-xs">{entry.ttl || '—'}</span>,
|
||||||
|
override: (
|
||||||
|
<IconButton
|
||||||
|
icon={Plus}
|
||||||
|
label={`create an override for ${entry.name} ${entry.type}`}
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!canWrite}
|
||||||
|
onClick={() => openOverride(entry)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title="Lookup"
|
||||||
|
description="Test the server, list a domain's records, and copy one into a local zone as an override."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card title="Test a name" subtitle="Resolved through the live pipeline, exactly as a client would see it.">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_auto_auto]">
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
placeholder="example.com or www.example.com"
|
||||||
|
icon={Search}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') void runTest()
|
||||||
|
}}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Type"
|
||||||
|
value={qtype}
|
||||||
|
onChange={(e) => setQtype(e.target.value)}
|
||||||
|
options={TEST_TYPES.map((t) => ({ value: t, label: t }))}
|
||||||
|
/>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button onClick={() => void runTest()} loading={test.pending} disabled={trimmed === ''}>
|
||||||
|
Test
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result ? (
|
||||||
|
<div className="mt-4 space-y-2 border-t border-slate-200 pt-4 dark:border-slate-800">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge tone={rcodeTone(result.rcode)}>{result.rcode}</Badge>
|
||||||
|
<Badge tone="muted">{result.type}</Badge>
|
||||||
|
<Badge tone={result.blocked ? 'danger' : 'default'}>{result.action}</Badge>
|
||||||
|
{result.local ? <Badge tone="ok">local zone</Badge> : null}
|
||||||
|
{result.cache === 'hit' || result.cache === 'stale' ? <Badge tone="info">cache {result.cache}</Badge> : null}
|
||||||
|
{result.truncated ? <Badge tone="warn">truncated</Badge> : null}
|
||||||
|
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{result.duration_ms} ms{result.upstream ? ` · ${result.upstream}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{result.error ? (
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-300">{result.error}</p>
|
||||||
|
) : null}
|
||||||
|
{result.block_source ? (
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">Blocked by {result.block_source}</p>
|
||||||
|
) : null}
|
||||||
|
{result.answers.length > 0 ? (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{result.answers.map((answer) => (
|
||||||
|
<li key={answer} className="break-all font-mono text-xs">{answer}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : result.error === undefined ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">No answer records.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="Domain entries"
|
||||||
|
subtitle={listed === '' ? undefined : `Records for ${listed}`}
|
||||||
|
padded={false}
|
||||||
|
actions={
|
||||||
|
<Button variant="secondary" onClick={() => void listEntries()} loading={test.pending} disabled={trimmed === ''}>
|
||||||
|
List entries
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
rowKey={(row) => String(row.__key)}
|
||||||
|
empty={
|
||||||
|
<EmptyState
|
||||||
|
title={listed === '' ? 'No domain looked up yet' : 'No records found'}
|
||||||
|
description="Enter a name above and list its entries. Every entry can become a local-zone override."
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={override !== null}
|
||||||
|
onClose={() => setOverride(null)}
|
||||||
|
title="Create an override"
|
||||||
|
description="The record is written to the console's managed zone, which makes that apex authoritative for the names it holds."
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => setOverride(null)} disabled={write.pending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void saveOverride()} loading={write.pending} disabled={!canWrite}>
|
||||||
|
Create override
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
label="Zone"
|
||||||
|
hint="The apex to make authoritative."
|
||||||
|
value={zone}
|
||||||
|
onChange={(e) => setZone(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
hint="Relative to the zone; @ is the apex."
|
||||||
|
value={relName}
|
||||||
|
onChange={(e) => setRelName(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Type"
|
||||||
|
value={override?.type ?? ''}
|
||||||
|
onChange={() => undefined}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="TTL"
|
||||||
|
value={ttl}
|
||||||
|
onChange={(e) => setTtl(e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-slate-100 px-3 py-2 font-mono text-xs break-all dark:bg-slate-800">
|
||||||
|
{override?.name} {override?.ttl} IN {override?.type} {override?.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user