mroe ui
This commit is contained in:
1 parent
d94e4cf6a8
commit
b2cd7d4b91
13 files changed
+542
-26
No files matched your search
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
+58
-1
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web</title>
|
||||
<script type="module" crossorigin src="/assets/index-BFc4v6ED.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CYPXMp15.css">
|
||||
<script type="module" crossorigin src="/assets/index-CzcgOHn-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-FxcuHhfL.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in new issue
Block a user