overrides
build / build-push (push) Successful in 29s

This commit is contained in:
mats committed 2026-09-28 16:25:55 +02:00
1 parent 98b65945f6
commit 0b6dcbd547
13 files changed
+417 -68

No files matched your search

+11 -4
View File
@@ -67,7 +67,8 @@ type Hooks struct {
DomainCheck func(name string) (any, error) DomainCheck func(name string) (any, error)
// Resolve runs one query through the live pipeline and reports the // Resolve runs one query through the live pipeline and reports the
// outcome, so the console can test the server it is talking to. // outcome, so the console can test the server it is talking to.
Resolve func(ctx context.Context, name, qtype string) (any, error) Resolve func(ctx context.Context, name, qtype string) (any, error)
ResolveUpstream func(ctx context.Context, name, qtype, upstream 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.
@@ -75,9 +76,12 @@ type Hooks struct {
// DeleteDomain removes a rule and persists the change. // DeleteDomain removes a rule and persists the change.
DeleteDomain func(ctx context.Context, name, action, list string) error DeleteDomain func(ctx context.Context, name, action, list string) error
// LocalZones lists, saves and deletes UI-managed DNS master files. // LocalZones lists, saves and deletes UI-managed DNS master files.
LocalZones func(ctx context.Context) ([]ZoneFile, error) LocalZones func(ctx context.Context) ([]ZoneFile, error)
SaveLocalZone func(ctx context.Context, name, content string) error SaveLocalZone func(ctx context.Context, name, content string) error
DeleteLocalZone func(ctx context.Context, name string) error DeleteLocalZone func(ctx context.Context, name string) error
AddressOverrides func(ctx context.Context) (any, error)
SaveAddressOverride func(ctx context.Context, domain, address string) error
DeleteAddressOverride func(ctx context.Context, domain string) error
// ListZones returns the compiled local zones with their records and // ListZones returns the compiled local zones with their records and
// delegations, and whether the API may rewrite them. // delegations, and whether the API may rewrite them.
ListZones func() (any, error) ListZones func() (any, error)
@@ -290,6 +294,9 @@ func (s *Server) routes(cfg *config.Config) *http.ServeMux {
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("POST "+base+"dns/test", s.authed(s.handleDNSTest, false))
mux.Handle("GET "+base+"address-overrides", s.authed(s.handleAddressOverrides, false))
mux.Handle("PUT "+base+"address-overrides", s.authed(s.handleAddressOverridePut, true))
mux.Handle("DELETE "+base+"address-overrides/{domain}", s.authed(s.handleAddressOverrideDelete, true))
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))
+63 -3
View File
@@ -1,7 +1,9 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@@ -188,8 +190,9 @@ func (s *Server) handleDNSTest(w http.ResponseWriter, r *http.Request) {
return return
} }
var in struct { var in struct {
Name string `json:"name"` Name string `json:"name"`
QType string `json:"type"` QType string `json:"type"`
Upstream string `json:"upstream"`
} }
if err := decodeJSON(r, &in); err != nil { if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body") writeError(w, http.StatusBadRequest, "malformed request body")
@@ -200,7 +203,20 @@ func (s *Server) handleDNSTest(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "name is required") writeError(w, http.StatusBadRequest, "name is required")
return return
} }
v, err := s.hooks.Resolve(r.Context(), name, in.QType) resolve := s.hooks.Resolve
if in.Upstream != "" {
resolve = func(ctx context.Context, n, qt string) (any, error) {
if s.hooks.ResolveUpstream == nil {
return nil, errors.New("direct upstream lookup unavailable")
}
return s.hooks.ResolveUpstream(ctx, n, qt, in.Upstream)
}
}
if resolve == nil {
writeError(w, http.StatusNotImplemented, "dns test not available")
return
}
v, err := resolve(r.Context(), name, in.QType)
if err != nil { if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"name": name, "error": err.Error()}) writeJSON(w, http.StatusOK, map[string]any{"name": name, "error": err.Error()})
return return
@@ -208,6 +224,50 @@ func (s *Server) handleDNSTest(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, v) writeJSON(w, http.StatusOK, v)
} }
func (s *Server) handleAddressOverrides(w http.ResponseWriter, r *http.Request) {
if s.hooks.AddressOverrides == nil {
writeError(w, http.StatusNotImplemented, "address overrides unavailable")
return
}
v, err := s.hooks.AddressOverrides(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
func (s *Server) handleAddressOverridePut(w http.ResponseWriter, r *http.Request) {
if s.hooks.SaveAddressOverride == nil {
writeError(w, http.StatusNotImplemented, "address overrides are read-only")
return
}
var in struct {
Domain string `json:"domain"`
Address string `json:"address"`
}
if decodeJSON(r, &in) != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if err := s.hooks.SaveAddressOverride(r.Context(), in.Domain, in.Address); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleAddressOverrideDelete(w http.ResponseWriter, r *http.Request) {
if s.hooks.DeleteAddressOverride == nil {
writeError(w, http.StatusNotImplemented, "address overrides are read-only")
return
}
domain := r.PathValue("domain")
if err := s.hooks.DeleteAddressOverride(r.Context(), domain); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
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")
+25 -6
View File
@@ -56,12 +56,13 @@ type App struct {
cfg *config.Store cfg *config.Store
ver string ver string
blocks *blocklist.Manager blocks *blocklist.Manager
cache *cache.Manager overrideMu sync.Mutex
cstore *cache.Store cache *cache.Manager
logger *qlog.Logger cstore *cache.Store
aclH *acl.Holder logger *qlog.Logger
local *localzone.Holder aclH *acl.Holder
local *localzone.Holder
// zones is the Kubernetes-sourced zone client. // zones is the Kubernetes-sourced zone client.
zones *kubezones.Client zones *kubezones.Client
// managedZones is the console-managed zone directory: the only zone data // managedZones is the console-managed zone directory: the only zone data
@@ -316,6 +317,23 @@ func (a *App) startBlocklist() {
Config: a.cfg, Config: a.cfg,
Logger: a.log, Logger: a.log,
}) })
if a.zones != nil {
ctx := context.Background()
content, exists, err := a.zones.Data(ctx, "address-overrides.rpz")
if err == nil && !exists {
if seed, readErr := os.ReadFile("/etc/fancy-dns/rpz/address-overrides.rpz"); readErr == nil {
content = string(seed)
if saveErr := a.zones.SaveData(ctx, "address-overrides.rpz", content); saveErr != nil {
a.log.Warn("seed address overrides failed", "error", saveErr)
}
}
}
if err == nil {
a.blocks.SetManagedRPZ(content)
} else {
a.log.Warn("load address overrides failed", "error", err)
}
}
// Compile once before serving so the first queries are not unprotected. // Compile once before serving so the first queries are not unprotected.
if _, err := a.blocks.RefreshAll(context.Background()); err != nil { if _, err := a.blocks.RefreshAll(context.Background()); err != nil {
a.markDegraded("blocklist: " + err.Error()) a.markDegraded("blocklist: " + err.Error())
@@ -576,6 +594,7 @@ func (a *App) startCluster(ctx context.Context, cfg *config.Config) {
// probes and the metrics gauge. // probes and the metrics gauge.
func (a *App) startBackground(ctx context.Context, cfg *config.Config) { func (a *App) startBackground(ctx context.Context, cfg *config.Config) {
go a.watchLocalZones(ctx) go a.watchLocalZones(ctx)
go a.watchAddressOverrides(ctx)
// Cache snapshots. // Cache snapshots.
if a.cstore != nil { if a.cstore != nil {
interval := cfg.Cache.Persist.Interval.D() interval := cfg.Cache.Persist.Interval.D()
+164 -38
View File
@@ -26,47 +26,173 @@ import (
// components. Everything the UI can read or change goes through here. // components. Everything the UI can read or change goes through here.
func (a *App) hooks() api.Hooks { func (a *App) hooks() api.Hooks {
return api.Hooks{ return api.Hooks{
Store: a.cfg, Store: a.cfg,
Logger: a.log, Logger: a.log,
Version: a.ver, Version: a.ver,
StartedAt: a.startedAt, StartedAt: a.startedAt,
Dashboard: a.dashboard, Dashboard: a.dashboard,
Status: a.status, Status: a.status,
Health: a.health, Health: a.health,
BlocklistState: a.blocklistState, BlocklistState: a.blocklistState,
BlocklistStats: a.blocklistStats, BlocklistStats: a.blocklistStats,
RefreshBlocklists: a.refreshBlocklists, RefreshBlocklists: a.refreshBlocklists,
ToggleBlocklist: a.toggleBlocklist, ToggleBlocklist: a.toggleBlocklist,
DomainCheck: a.domainCheck, DomainCheck: a.domainCheck,
Resolve: a.resolveTest, Resolve: a.resolveTest,
ListDomains: a.listDomains, ResolveUpstream: a.resolveTestUpstream,
AddDomain: a.addDomain, AddressOverrides: a.listAddressOverrides,
DeleteDomain: a.deleteDomain, SaveAddressOverride: a.saveAddressOverride,
ListZones: a.listZones, DeleteAddressOverride: a.deleteAddressOverride,
AddZoneRecord: a.addZoneRecord, ListDomains: a.listDomains,
PutZoneRecord: a.putZoneRecord, AddDomain: a.addDomain,
DeleteZoneRecord: a.deleteZoneRecord, DeleteDomain: a.deleteDomain,
DeleteZone: a.deleteZone, ListZones: a.listZones,
AddZoneDelegation: a.addZoneDelegation, AddZoneRecord: a.addZoneRecord,
DeleteZoneDelegation: a.deleteZoneDelegation, PutZoneRecord: a.putZoneRecord,
LocalZones: a.listLocalZones, DeleteZoneRecord: a.deleteZoneRecord,
SaveLocalZone: a.saveLocalZone, DeleteZone: a.deleteZone,
DeleteLocalZone: a.deleteLocalZone, AddZoneDelegation: a.addZoneDelegation,
GetACL: a.getACL, DeleteZoneDelegation: a.deleteZoneDelegation,
PutACL: a.putACL, LocalZones: a.listLocalZones,
GetUpstream: a.getUpstreams, SaveLocalZone: a.saveLocalZone,
PutUpstream: a.putUpstream, DeleteLocalZone: a.deleteLocalZone,
SearchLog: a.searchLog, GetACL: a.getACL,
LogStats: a.logStats, PutACL: a.putACL,
SubscribeLog: a.subscribeLog, GetUpstream: a.getUpstreams,
ClearLog: a.clearLog, PutUpstream: a.putUpstream,
CacheStats: a.cacheStats, SearchLog: a.searchLog,
CacheTop: a.cacheTop, LogStats: a.logStats,
FlushCache: a.flushCache, SubscribeLog: a.subscribeLog,
Flush: a.flush, ClearLog: a.clearLog,
CacheStats: a.cacheStats,
CacheTop: a.cacheTop,
FlushCache: a.flushCache,
Flush: a.flush,
} }
} }
func (a *App) resolveTestUpstream(ctx context.Context, name, qtype, upstream string) (any, error) {
if a.pool == nil {
return nil, errors.New("upstream pool is not running")
}
up := a.pool.Get(strings.TrimSpace(upstream))
if up == nil {
return nil, fmt.Errorf("unknown upstream %q", upstream)
}
qt := dns.TypeA
if qtype != "" {
var ok bool
qt, ok = dns.StringToType[strings.ToUpper(strings.TrimSpace(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
started := time.Now()
resp, addr, err := a.pool.Exchange(ctx, q, []string{up.Addr()}, 5*time.Second)
if err != nil {
return nil, err
}
out := map[string]any{"name": dns.Fqdn(name), "type": dns.TypeToString[qt], "rcode": "SERVFAIL", "action": "upstream", "duration_ms": time.Since(started).Milliseconds(), "upstream": addr, "group": "", "cache": "bypass", "local": false, "truncated": false, "blocked": false, "block_source": "", "answers": []string{}}
if resp != nil {
out["rcode"] = dns.RcodeToString[resp.Rcode]
ans := []string{}
for _, rr := range resp.Answer {
ans = append(ans, strings.TrimSpace(rr.String()))
}
out["answers"] = ans
}
return out, nil
}
type addressOverride struct {
Domain string `json:"domain"`
Address string `json:"address"`
}
func (a *App) listAddressOverrides(ctx context.Context) (any, error) {
if a.zones == nil {
return map[string]any{"items": []addressOverride{}, "writable": false}, nil
}
raw, ok, err := a.zones.Data(ctx, "address-overrides.rpz")
if err != nil {
return nil, err
}
items := parseAddressOverrides(raw)
return map[string]any{"items": items, "writable": true, "initialized": ok}, nil
}
func parseAddressOverrides(raw string) []addressOverride {
out := []addressOverride{}
for _, line := range strings.Split(raw, "\n") {
f := strings.Fields(line)
if len(f) >= 3 && !strings.HasPrefix(f[0], "$") && (strings.EqualFold(f[1], "A") || strings.EqualFold(f[1], "AAAA")) {
out = append(out, addressOverride{Domain: strings.TrimSuffix(f[0], "."), Address: f[2]})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Domain < out[j].Domain })
return out
}
func (a *App) saveAddressOverride(ctx context.Context, domain, address string) error {
return a.mutateAddressOverride(ctx, domain, address, false)
}
func (a *App) deleteAddressOverride(ctx context.Context, domain string) error {
return a.mutateAddressOverride(ctx, domain, "", true)
}
func (a *App) mutateAddressOverride(ctx context.Context, domain, address string, remove bool) error {
a.overrideMu.Lock()
defer a.overrideMu.Unlock()
if a.zones == nil {
return errors.New("address overrides are writable only in Kubernetes")
}
domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
if _, ok := dns.IsDomainName(dns.Fqdn(domain)); !ok || domain == "" {
return errors.New("enter a valid domain name")
}
ip, err := netip.ParseAddr(strings.TrimSpace(address))
if !remove && err != nil {
return errors.New("enter a valid IPv4 or IPv6 address")
}
raw, _, err := a.zones.Data(ctx, "address-overrides.rpz")
if err != nil {
return err
}
items := parseAddressOverrides(raw)
next := make([]addressOverride, 0, len(items)+1)
for _, it := range items {
if it.Domain != domain {
next = append(next, it)
}
}
if !remove {
next = append(next, addressOverride{Domain: domain, Address: ip.String()})
}
sort.Slice(next, func(i, j int) bool { return next[i].Domain < next[j].Domain })
var b strings.Builder
b.WriteString("$TTL 60\n")
for _, it := range next {
typ := "A"
if strings.Contains(it.Address, ":") {
typ = "AAAA"
}
fmt.Fprintf(&b, "%s %s %s\n", it.Domain, typ, it.Address)
}
if err := a.zones.SaveData(ctx, "address-overrides.rpz", b.String()); err != nil {
return err
}
if a.blocks != nil {
a.blocks.SetManagedRPZ(b.String())
if _, err := a.blocks.RefreshAll(ctx); err != nil {
return err
}
}
if a.cache != nil {
a.cache.Flush()
}
return nil
}
func (a *App) listLocalZones(ctx context.Context) ([]api.ZoneFile, error) { func (a *App) listLocalZones(ctx context.Context) ([]api.ZoneFile, error) {
if a.zones == nil { if a.zones == nil {
return nil, fmt.Errorf("local zone editor is available only in Kubernetes") return nil, fmt.Errorf("local zone editor is available only in Kubernetes")
+35
View File
@@ -0,0 +1,35 @@
package app
import (
"context"
"time"
)
func (a *App) watchAddressOverrides(ctx context.Context) {
if a.zones == nil || a.blocks == nil {
return
}
t := time.NewTicker(2 * time.Second)
defer t.Stop()
var last string
for {
select {
case <-ctx.Done():
return
case <-t.C:
content, ok, err := a.zones.Data(ctx, "address-overrides.rpz")
if err != nil || !ok || content == last {
continue
}
a.overrideMu.Lock()
a.blocks.SetManagedRPZ(content)
if _, err := a.blocks.RefreshAll(ctx); err == nil {
if a.cache != nil {
a.cache.Flush()
}
last = content
}
a.overrideMu.Unlock()
}
}
}
+25 -2
View File
@@ -56,8 +56,10 @@ type Manager struct {
http *http.Client http *http.Client
stats *StateHolder stats *StateHolder
mu sync.Mutex mu sync.Mutex
running map[string]bool // in-flight refreshes, by source name running map[string]bool // in-flight refreshes, by source name
managedMu sync.RWMutex
managedRPZ string
// trigger lets the cluster ask one instance to refresh (leader election). // trigger lets the cluster ask one instance to refresh (leader election).
trigger chan string trigger chan string
@@ -126,6 +128,13 @@ func (m *Manager) Holder() *Holder { return m.hold }
// State returns the detailed state the API serves. // State returns the detailed state the API serves.
func (m *Manager) State() *State { return m.stats.Get() } func (m *Manager) State() *State { return m.stats.Get() }
// SetManagedRPZ replaces the shared UI-managed address override source.
func (m *Manager) SetManagedRPZ(content string) {
m.managedMu.Lock()
m.managedRPZ = content
m.managedMu.Unlock()
}
// RefreshAll compiles every enabled source plus the local zones. The previous // RefreshAll compiles every enabled source plus the local zones. The previous
// set is kept if every source fails, so a bad deploy never leaves the // set is kept if every source fails, so a bad deploy never leaves the
// resolver with no blocklists at all. // resolver with no blocklists at all.
@@ -137,6 +146,20 @@ func (m *Manager) RefreshAll(ctx context.Context) (State, error) {
sources []Source sources []Source
errs = map[string]string{} errs = map[string]string{}
) )
m.managedMu.RLock()
managedRPZ := m.managedRPZ
m.managedMu.RUnlock()
if strings.TrimSpace(managedRPZ) != "" {
rules, st, err := Parse(FormatRPZ, []byte(managedRPZ), ParseOptions{Subdomains: true, DefaultTTL: 60})
if err != nil {
errs["managed:address-overrides"] = err.Error()
} else {
for _, r := range rules {
b.Add(r, "managed:address-overrides", nil)
}
b.NoteSource(SourceMeta{Name: "managed:address-overrides", Format: string(FormatRPZ), Rules: len(rules), Bytes: st.Bytes, UpdatedAt: start, Duration: config.Duration(time.Since(start)), Enabled: true})
}
}
// Inline block/allow entries first: they are cheap, and putting them // Inline block/allow entries first: they are cheap, and putting them
// before the lists means a later list cannot override an operator's // before the lists means a later list cannot override an operator's
+46 -4
View File
@@ -42,6 +42,16 @@ type Client struct {
// InCluster returns nil outside Kubernetes. The projected service account // InCluster returns nil outside Kubernetes. The projected service account
// token must be available; RBAC limits it to the fancy-dns-zones ConfigMap. // token must be available; RBAC limits it to the fancy-dns-zones ConfigMap.
func InCluster() (*Client, error) { func InCluster() (*Client, error) {
name := os.Getenv("FANCYDNS_ZONE_CONFIGMAP")
if name == "" {
name = defaultName
}
return InClusterConfigMap(name)
}
// InClusterConfigMap returns a client for a named ConfigMap. RBAC on the
// service account still limits which ConfigMaps it can read or patch.
func InClusterConfigMap(name string) (*Client, error) {
host := os.Getenv("KUBERNETES_SERVICE_HOST") host := os.Getenv("KUBERNETES_SERVICE_HOST")
if host == "" { if host == "" {
return nil, nil return nil, nil
@@ -69,9 +79,8 @@ func InCluster() (*Client, error) {
if !pool.AppendCertsFromPEM(ca) { if !pool.AppendCertsFromPEM(ca) {
return nil, errors.New("kube zones: invalid service account CA") return nil, errors.New("kube zones: invalid service account CA")
} }
name := os.Getenv("FANCYDNS_ZONE_CONFIGMAP") if strings.TrimSpace(name) == "" {
if name == "" { return nil, errors.New("kube zones: ConfigMap name is required")
name = defaultName
} }
return &Client{ return &Client{
baseURL: "https://" + net.JoinHostPort(host, port), baseURL: "https://" + net.JoinHostPort(host, port),
@@ -119,14 +128,47 @@ func (c *Client) Delete(ctx context.Context, name string) error {
return c.change(ctx, filename(name), nil) return c.change(ctx, filename(name), nil)
} }
// Data reads one arbitrary ConfigMap data key. It is used for console-owned
// shared files that are not zone master files.
func (c *Client) Data(ctx context.Context, key string) (string, bool, error) {
if c == nil {
return "", false, errors.New("kube zones: Kubernetes ConfigMap is unavailable")
}
cm, _, err := c.get(ctx)
if err != nil {
return "", false, err
}
value, ok := cm.Data[key]
return value, ok, nil
}
// SaveData creates or replaces one arbitrary ConfigMap data key, retrying
// resource-version conflicts so independent edits to different keys merge.
func (c *Client) SaveData(ctx context.Context, key, content string) error {
if c == nil {
return errors.New("kube zones: Kubernetes ConfigMap is unavailable")
}
if key == "" || len(key) > 253 || strings.ContainsAny(key, `/\\`) {
return errors.New("kube zones: invalid ConfigMap data key")
}
if len(content) > 512*1024 {
return errors.New("kube zones: ConfigMap data exceeds 512 KiB")
}
return c.changeData(ctx, key, &content)
}
func filename(name string) string { return "zone--" + strings.TrimSuffix(name, ".") + ".zone" } func filename(name string) string { return "zone--" + strings.TrimSuffix(name, ".") + ".zone" }
func (c *Client) change(ctx context.Context, name string, content *string) error { func (c *Client) change(ctx context.Context, name string, content *string) error {
if !validKey(name) { if !validKey(name) {
return errors.New("zone filename must be a DNS name ending in .zone") return errors.New("zone filename must be a DNS name ending in .zone")
} }
return c.changeData(ctx, name, content)
}
func (c *Client) changeData(ctx context.Context, name string, content *string) error {
if content != nil && len(*content) > 512*1024 { if content != nil && len(*content) > 512*1024 {
return errors.New("zone file exceeds 512 KiB") return errors.New("ConfigMap data exceeds 512 KiB")
} }
for attempt := 0; attempt < 3; attempt++ { for attempt := 0; attempt < 3; attempt++ {
_, rv, err := c.get(ctx) _, rv, err := c.get(ctx)
+2 -2
View File
@@ -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-CzcgOHn-.js"></script> <script type="module" crossorigin src="/assets/index-DnNommMg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-FxcuHhfL.css"> <link rel="stylesheet" crossorigin href="/assets/index-Bf5OWE1r.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2
View File
@@ -22,6 +22,7 @@ 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 Lookup from './pages/Lookup.tsx'
import NotFound from './pages/NotFound.tsx' import NotFound from './pages/NotFound.tsx'
import Overrides from './pages/Overrides.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'
import Zones from './pages/Zones.tsx' import Zones from './pages/Zones.tsx'
@@ -54,6 +55,7 @@ function AppRoutes() {
<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="/lookup" element={<Lookup />} />
<Route path="/overrides" element={<Overrides />} />
<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 />} />
+2
View File
@@ -13,6 +13,7 @@ import {
LogOut, LogOut,
Menu, Menu,
Network, Network,
MapPin,
PanelLeftClose, PanelLeftClose,
ScrollText, ScrollText,
Search, Search,
@@ -41,6 +42,7 @@ const NAV: NavItem[] = [
{ 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: '/lookup', label: 'Lookup', icon: Search },
{ to: '/overrides', label: 'Address overrides', icon: MapPin },
{ 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 },
+8 -2
View File
@@ -280,13 +280,19 @@ export function checkDomain(name: string): Promise<DomainCheckResponse> {
} }
/** Runs one query through the live pipeline, the console's connectivity test. */ /** Runs one query through the live pipeline, the console's connectivity test. */
export function testDns(name: string, qtype: string): Promise<DnsTestResponse> { export function testDns(name: string, qtype: string, upstream = ''): Promise<DnsTestResponse> {
return apiFetch<DnsTestResponse>('/api/dns/test', { return apiFetch<DnsTestResponse>('/api/dns/test', {
method: 'POST', method: 'POST',
body: JSON.stringify({ name, type: qtype }), body: JSON.stringify({ name, type: qtype, upstream }),
}) })
} }
export interface AddressOverride { domain: string; address: string }
export interface AddressOverridesResponse { items: AddressOverride[]; writable: boolean; initialized?: boolean }
export function getAddressOverrides(): Promise<AddressOverridesResponse> { return apiFetch<AddressOverridesResponse>('/api/address-overrides') }
export function putAddressOverride(domain: string,address: string): Promise<{ok:boolean}> { return apiFetch('/api/address-overrides',{method:'PUT',body:JSON.stringify({domain,address})}) }
export function deleteAddressOverride(domain: string): Promise<{ok:boolean}> { return apiFetch(`/api/address-overrides/${encodeURIComponent(domain)}`,{method:'DELETE'}) }
export function getZones(): Promise<ZonesResponse> { export function getZones(): Promise<ZonesResponse> {
return apiFetch<ZonesResponse>('/api/zones') return apiFetch<ZonesResponse>('/api/zones')
} }
+11 -7
View File
@@ -8,11 +8,11 @@
* with, and every entry can be turned into a local-zone override from there. * with, and every entry can be turned into a local-zone override from there.
*/ */
import { useState } from 'react' import { useEffect, useState } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Plus, Search } from 'lucide-react' import { Plus, Search } from 'lucide-react'
import { addZoneRecord, testDns } from '../lib/api' import { addZoneRecord, getUpstreams, testDns } from '../lib/api'
import { useWhoami } from '../lib/session.ts' import { useWhoami } from '../lib/session.ts'
import type { DnsTestResponse } from '../lib/types' import type { DnsTestResponse } from '../lib/types'
import { useAsyncAction } from '../hooks/useApi' import { useAsyncAction } from '../hooks/useApi'
@@ -88,13 +88,16 @@ export default function Lookup() {
const [result, setResult] = useState<DnsTestResponse | null>(null) const [result, setResult] = useState<DnsTestResponse | null>(null)
const [entries, setEntries] = useState<Entry[]>([]) const [entries, setEntries] = useState<Entry[]>([])
const [listed, setListed] = useState('') const [listed, setListed] = useState('')
const [upstreams, setUpstreams] = useState<string[]>([])
const [upstream, setUpstream] = useState('')
useEffect(() => { void getUpstreams().then((r) => setUpstreams(r.servers.map((s) => s.addr))).catch(() => setUpstreams([])) }, [])
const [override, setOverride] = useState<Entry | null>(null) const [override, setOverride] = useState<Entry | null>(null)
const [zone, setZone] = useState('') const [zone, setZone] = useState('')
const [relName, setRelName] = useState('') const [relName, setRelName] = useState('')
const [ttl, setTtl] = useState('') const [ttl, setTtl] = useState('')
const test = useAsyncAction(async (target: string, type: string) => testDns(target, type)) const test = useAsyncAction(async (target: string, type: string) => testDns(target, type, upstream))
const write = useAsyncAction(async (input: { zone: string; name: string; type: string; ttl: number; value: string }) => const write = useAsyncAction(async (input: { zone: string; name: string; type: string; ttl: number; value: string }) =>
addZoneRecord({ addZoneRecord({
zone: input.zone, zone: input.zone,
@@ -119,7 +122,7 @@ export default function Lookup() {
const listEntries = async (): Promise<void> => { const listEntries = async (): Promise<void> => {
if (trimmed === '') return if (trimmed === '') return
const results = await Promise.all(ENTRY_TYPES.map((t) => testDns(trimmed, t))) const results = await Promise.all(ENTRY_TYPES.map((t) => testDns(trimmed, t, upstream)))
const found: Entry[] = [] const found: Entry[] = []
for (const [i, res] of results.entries()) { for (const [i, res] of results.entries()) {
if (res.error !== undefined) continue if (res.error !== undefined) continue
@@ -191,11 +194,11 @@ export default function Lookup() {
<div className="space-y-4"> <div className="space-y-4">
<PageHeader <PageHeader
title="Lookup" title="Lookup"
description="Test the server, list a domain's records, and copy one into a local zone as an override." description="Test the server or query one configured upstream directly."
/> />
<Card title="Test a name" subtitle="Resolved through the live pipeline, exactly as a client would see it."> <Card title="Test a name" subtitle={upstream ? 'Sent directly to the selected upstream, bypassing local policy and cache.' : 'Resolved through the live pipeline, as a client would see it.'}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_auto_auto]"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_auto_auto_auto]">
<Input <Input
label="Name" label="Name"
placeholder="example.com or www.example.com" placeholder="example.com or www.example.com"
@@ -214,6 +217,7 @@ export default function Lookup() {
onChange={(e) => setQtype(e.target.value)} onChange={(e) => setQtype(e.target.value)}
options={TEST_TYPES.map((t) => ({ value: t, label: t }))} options={TEST_TYPES.map((t) => ({ value: t, label: t }))}
/> />
<Select label="Resolver" value={upstream} onChange={(e) => setUpstream(e.target.value)} options={[{value:'',label:'Full pipeline'},...upstreams.map((u)=>({value:u,label:u}))]} />
<div className="flex items-end"> <div className="flex items-end">
<Button onClick={() => void runTest()} loading={test.pending} disabled={trimmed === ''}> <Button onClick={() => void runTest()} loading={test.pending} disabled={trimmed === ''}>
Test Test
+23
View File
@@ -0,0 +1,23 @@
import { useState } from 'react'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { deleteAddressOverride, getAddressOverrides, putAddressOverride } from '../lib/api'
import { useApi } from '../hooks/useApi'
import { useWhoami } from '../lib/session.ts'
import { Button, Card, EmptyState, IconButton, Input, Table, useToast } from '../components/ui'
import { PageHeader } from '../components/layout'
export default function Overrides() {
const {canWrite}=useWhoami(); const {toast}=useToast(); const {data,error,loading,refetch}=useApi(()=>getAddressOverrides(),[])
const [domain,setDomain]=useState(''); const [address,setAddress]=useState('')
const save=async()=>{try{await putAddressOverride(domain,address);setDomain('');setAddress('');refetch();toast({title:'Address override saved',tone:'ok'})}catch(e){toast({title:e instanceof Error?e.message:'Could not save override',tone:'danger'})}}
const remove=async(d:string)=>{try{await deleteAddressOverride(d);refetch();toast({title:`Removed ${d}`,tone:'ok'})}catch(e){toast({title:e instanceof Error?e.message:'Could not remove override',tone:'danger'})}}
const items=data?.items??[]
return <div className="space-y-4"><PageHeader title="Address overrides" description="Map a domain and all its subdomains to an IP address. These rules answer through DNS policy without creating an authoritative zone." />
<Card title="Add or edit an override" subtitle={data?.writable?'Changes are shared across replicas.':'The shared Kubernetes ConfigMap is unavailable; overrides are read-only.'}>
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_auto] items-end"><Input label="Domain" placeholder="example.com" value={domain} onChange={e=>setDomain(e.target.value)} disabled={!canWrite||!data?.writable}/><Input label="Address" placeholder="10.10.3.10 or 2001:db8::1" value={address} onChange={e=>setAddress(e.target.value)} disabled={!canWrite||!data?.writable}/><Button icon={Plus} onClick={()=>void save()} disabled={!canWrite||!data?.writable||!domain.trim()||!address.trim()}>{items.some(i=>i.domain===domain.trim().toLowerCase())?'Save':'Add override'}</Button></div>
</Card>
<Card title="Overrides" subtitle={`${items.length} domain${items.length===1?'':'s'}`}>
{error?<p className="text-sm text-red-600">{error}</p>:loading?<p className="text-sm text-slate-500">Loading…</p>:items.length===0?<EmptyState title="No address overrides" description="Add a domain and destination IP above."/>:<Table columns={[{key:'domain',header:'Domain'},{key:'address',header:'Address'},{key:'actions',header:'',align:'right'}]} rows={items.map(i=>({__key:i.domain,domain:<span className="font-mono">{i.domain}</span>,address:<span className="font-mono">{i.address}</span>,actions:<span className="flex justify-end gap-1"><IconButton icon={Pencil} label={`Edit ${i.domain}`} onClick={()=>{setDomain(i.domain);setAddress(i.address)}} disabled={!canWrite||!data?.writable}/><IconButton icon={Trash2} label={`Delete ${i.domain}`} onClick={()=>void remove(i.domain)} disabled={!canWrite||!data?.writable}/></span>}))} rowKey={(row)=>String(row.__key)}/>}
</Card>
</div>
}