Add UI editing for local DNS zones
build / build-push (push) Successful in 29s

Local zones were only editable as mounted files, so operators could not manage DNS entries from the console. Add a Local zones editor backed by a shared Kubernetes ConfigMap, validate master files before saving, scope the pod service account to that ConfigMap, and reload changed zones on every replica while flushing stale cache entries.
This commit is contained in:
mats committed 2026-09-28 08:50:50 +02:00
1 parent 2a7ac824f7
commit 32f940b763
18 files changed
+727 -36

No files matched your search

+20 -20
View File
@@ -397,13 +397,12 @@ local traffic counted correctly — see STATUS.md for the change note.
but there is no zone transfer, so nothing calls it. but there is no zone transfer, so nothing calls it.
**Zone name from a path.** `localzone.Load` derives each file's origin with **Zone name from a path.** `localzone.Load` derives each file's origin with
`zonefile.OriginFromPath`, which takes the base name and truncates it at the `zonefile.OriginFromPath`. Legacy names truncate at the first dot, so
first dot: `zones/lan.zone` and `lan.zone` both become the origin `lan.` `zones/lan.zone` becomes `lan.`. The console marks fully-qualified zone names
(the doc comment on that function says `ads.zone.`, which is wrong — see with `zone--`, so `zone--example.com.zone` becomes `example.com.`. An explicit
[Known inconsistencies](#known-inconsistencies)). An explicit `$ORIGIN` inside `$ORIGIN` inside the file wins, because `Parse` starts from that derived origin
the file wins, because `Parse` starts from that derived origin and the and the directive replaces it. Records that fall outside their own zone are
directive replaces it. Records that fall outside their own zone are dropped dropped (`localzone.add` returns false).
(`localzone.add` returns false).
The practical consequence: a *relative* name is qualified with the file's base The practical consequence: a *relative* name is qualified with the file's base
name, not with the zone you had in mind. In `local.example.zone` (origin name, not with the zone you had in mind. In `local.example.zone` (origin
@@ -431,6 +430,15 @@ records mean and where they are matched:
one-label and deeper wildcards, SRV, TXT). `deploy/zones/local.rpz.zone.example` one-label and deeper wildcards, SRV, TXT). `deploy/zones/local.rpz.zone.example`
is a policy zone. is a policy zone.
**Edit zones in the console.** Open **Local zones** to create a zone or edit its
RFC 1035 master-file records. In Kubernetes, the editor stores each file in the
`fancy-dns-zones` ConfigMap; both replicas watch the projected files and reload
them automatically. The API validates every file and its record owners before
saving. The pod's service account can read and patch only this ConfigMap. The
ConfigMap's `data` is intentionally left out of the GitOps manifest so console
edits persist across reconciliation. For a standalone install, manage zone
files on disk and reload the configuration.
## Blocklists ## Blocklists
A source is compiled into rules, and every source is merged into one immutable A source is compiled into rules, and every source is merged into one immutable
@@ -662,11 +670,12 @@ Things to know before you apply it:
* **No zone transfer.** AXFR/IXFR are not implemented: the master-file parser * **No zone transfer.** AXFR/IXFR are not implemented: the master-file parser
knows the record types, but nothing serves or accepts a transfer, and knows the record types, but nothing serves or accepts a transfer, and
`zonefile.SerialNext` is unused. `zonefile.SerialNext` is unused.
* **No dynamic update.** An UPDATE message passes the engine's shape check * **No RFC 2136 dynamic update.** An UPDATE message passes the engine's shape check
(`validate` tolerates `OpcodeUpdate`) and is then handled as an ordinary (`validate` tolerates `OpcodeUpdate`) and is then handled as an ordinary
query. There is no RFC 2136 handling, no journal and no NOTIFY. A local zone query. There is no wire-protocol update handling, journal or NOTIFY. Local
only changes when the file changes and the config is reloaded (SIGHUP, zones are editable from the console's **Local zones** page. Kubernetes pods
`POST /api/settings/reload`, or a restart). reload changed zone files automatically; standalone file edits need a config
reload or restart.
* **No HTTP/3** for DoH despite `dns.doh.transport.mode` / `alt_svc`; the * **No HTTP/3** for DoH despite `dns.doh.transport.mode` / `alt_svc`; the
listener is h2 with an h2c fallback. listener is h2 with an h2c fallback.
* **No certificate automation**: certificates are files on disk, one per * **No certificate automation**: certificates are files on disk, one per
@@ -774,11 +783,6 @@ Found while writing this file; each is a to-do, not something fixed here.
15. **`internal/config/config.go` (`Valkey.TLS`)** — `cluster.valkey.tls` is 15. **`internal/config/config.go` (`Valkey.TLS`)** — `cluster.valkey.tls` is
never applied; `internal/cluster/cluster.go:dial` builds a plain never applied; `internal/cluster/cluster.go:dial` builds a plain
`redis.Client` with no `TLSConfig`. `redis.Client` with no `TLSConfig`.
16. **`internal/zonefile/zonefile.go:525-526` — the `OriginFromPath` doc
comment is wrong.** It claims `"rpz/ads.zone"` and `"ads.zone"` both become
`"ads.zone."`; the code truncates at the first dot, so both become `"ads."`.
The behaviour is what `localzone.Load` and `blocklist.parseRPZFiles` rely
on — fix the comment.
17. **`internal/zonefile/zonefile.go:1-8` and `:238-243` — the package comment 17. **`internal/zonefile/zonefile.go:1-8` and `:238-243` — the package comment
advertises `$INCLUDE`, the parser rejects it** ("`$INCLUDE … is not advertises `$INCLUDE`, the parser rejects it** ("`$INCLUDE … is not
supported; use blocklist.local_zones or a list source instead`"), and supported; use blocklist.local_zones or a list source instead`"), and
@@ -790,10 +794,6 @@ Found while writing this file; each is a to-do, not something fixed here.
Unprivileged use needs the same `sed` rewrite the CI smoke test does Unprivileged use needs the same `sed` rewrite the CI smoke test does
(`.github/workflows/ci.yml`, "Smoke test the real binary"), or a port you (`.github/workflows/ci.yml`, "Smoke test the real binary"), or a port you
can bind. can bind.
19. **`deploy/k8s/10-deployment.yaml` — the comment tells the reader to manage
zones "through the admin API (`/api/zones`)", which does not exist.** There
is no zone endpoint in `internal/api/api.go:routes`; local zones are edited
on disk.
20. **`deploy/k8s/20-service.yaml` — the load-balancer comment says to leave 20. **`deploy/k8s/20-service.yaml` — the load-balancer comment says to leave
the LB health check on the HTTP admin port, but the `fancy-dns-dns` the LB health check on the HTTP admin port, but the `fancy-dns-dns`
LoadBalancer Service publishes only 53/UDP, 53/TCP and 853/TCP.** The admin LoadBalancer Service publishes only 53/UDP, 53/TCP and 853/TCP.** The admin
+29 -5
View File
@@ -1,7 +1,6 @@
# A stable identity for the pods. The server never talks to the Kubernetes API # The UI edits the shared `fancy-dns-zones` ConfigMap. Its projected volume is
# (blocklist replication goes through Valkey, not the API server), so the token # read-only; these narrowly scoped permissions let the API update only that
# is not just unused but actively undesirable: automounting it would hand every # one ConfigMap so both DNS replicas receive the same records.
# pod a credential it has no reason to hold.
apiVersion: v1 apiVersion: v1
kind: ServiceAccount kind: ServiceAccount
metadata: metadata:
@@ -9,4 +8,29 @@ metadata:
namespace: dns namespace: dns
labels: labels:
app.kubernetes.io/name: fancy-dns app.kubernetes.io/name: fancy-dns
automountServiceAccountToken: false automountServiceAccountToken: true
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: fancy-dns-zones
namespace: dns
rules:
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["fancy-dns-zones"]
verbs: ["get", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: fancy-dns-zones
namespace: dns
subjects:
- kind: ServiceAccount
name: fancy-dns
namespace: dns
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: fancy-dns-zones
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: fancy-dns-zones
namespace: dns
labels:
app.kubernetes.io/name: fancy-dns
app.kubernetes.io/component: local-zones
# Leave data unmanaged by GitOps. The Local zones page writes one
# zone--<apex>.zone
# key per zone through the API; server-side apply preserves those user fields.
+5 -6
View File
@@ -27,6 +27,7 @@ spec:
annotations: annotations:
k8s-helper.io/git-url: "https://git.k6n.net/mats/fancy-dns" k8s-helper.io/git-url: "https://git.k6n.net/mats/fancy-dns"
k8s-helper.io/git-ssh: "git@git.k6n.net:mats/fancy-dns.git" k8s-helper.io/git-ssh: "git@git.k6n.net:mats/fancy-dns.git"
dns.k6n.net/zones-api: "enabled"
# A config change is a SIGHUP, not a restart: the server re-reads the # A config change is a SIGHUP, not a restart: the server re-reads the
# file and hot-swaps every component. This annotation is for # file and hot-swaps every component. This annotation is for
# stakater/reloader-class controllers; without one, `kubectl rollout # stakater/reloader-class controllers; without one, `kubectl rollout
@@ -152,15 +153,13 @@ spec:
- name: config - name: config
configMap: configMap:
name: fancy-dns-config name: fancy-dns-config
# Local zones are ordinary master files; ship them in a ConfigMap or # Local zones are ordinary master files. The API writes them to the
# edit them in the mounted volume. There is no zone API — the admin API # shared ConfigMap, which projects one zone--<apex>.zone file per zone here.
# drives blocklists, the cache and the query log, not the zone files. # The GitOps manifest creates the ConfigMap empty; the UI owns its data.
# the data volume. Optional because the glob in the config matches
# nothing cleanly.
- name: zones - name: zones
configMap: configMap:
name: fancy-dns-zones name: fancy-dns-zones
optional: true optional: false
- name: tls - name: tls
secret: secret:
secretName: fancy-dns-tls secretName: fancy-dns-tls
+1
View File
@@ -11,6 +11,7 @@ namespace: dns
resources: resources:
- 00-config.yaml - 00-config.yaml
- 05-serviceaccount.yaml - 05-serviceaccount.yaml
- 06-zones.yaml
- 10-deployment.yaml - 10-deployment.yaml
- 20-service.yaml - 20-service.yaml
- 30-ingress.yaml - 30-ingress.yaml
+13
View File
@@ -71,6 +71,10 @@ type Hooks struct {
AddDomain func(ctx context.Context, in DomainInput) (any, error) AddDomain func(ctx context.Context, in DomainInput) (any, error)
// 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 func(ctx context.Context) ([]ZoneFile, error)
SaveLocalZone func(ctx context.Context, name, content string) error
DeleteLocalZone func(ctx context.Context, name string) error
// GetACL returns the ACL groups. // GetACL returns the ACL groups.
GetACL func() any GetACL func() any
// PutACL validates and persists new ACL groups. // PutACL validates and persists new ACL groups.
@@ -119,6 +123,12 @@ type DomainInput struct {
Content string `json:"content,omitempty"` Content string `json:"content,omitempty"`
} }
// ZoneFile is a UI-managed authoritative master file.
type ZoneFile struct {
Name string `json:"name"`
Content string `json:"content"`
}
// LogQuery pages the query log. // LogQuery pages the query log.
type LogQuery struct { type LogQuery struct {
Limit int Limit int
@@ -259,6 +269,9 @@ 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("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))
mux.Handle("GET "+base+"clients", s.authed(s.handleClients, false)) mux.Handle("GET "+base+"clients", s.authed(s.handleClients, false))
mux.Handle("PUT "+base+"clients", s.authed(s.handleClientsPut, true)) mux.Handle("PUT "+base+"clients", s.authed(s.handleClientsPut, true))
mux.Handle("GET "+base+"upstreams", s.authed(s.handleUpstreams, false)) mux.Handle("GET "+base+"upstreams", s.authed(s.handleUpstreams, false))
+46
View File
@@ -177,6 +177,52 @@ func (s *Server) handleDomainCheck(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, v) 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")
return
}
zones, err := s.hooks.LocalZones(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"zones": zones})
}
func (s *Server) handleLocalZonePut(w http.ResponseWriter, r *http.Request) {
if s.hooks.SaveLocalZone == nil {
writeError(w, http.StatusNotImplemented, "local zone editor is unavailable")
return
}
var in struct {
Content string `json:"content"`
}
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
name := r.PathValue("name")
if err := s.hooks.SaveLocalZone(r.Context(), name, in.Content); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name})
}
func (s *Server) handleLocalZoneDelete(w http.ResponseWriter, r *http.Request) {
if s.hooks.DeleteLocalZone == nil {
writeError(w, http.StatusNotImplemented, "local zone editor is unavailable")
return
}
name := r.PathValue("name")
if err := s.hooks.DeleteLocalZone(r.Context(), name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name})
}
// handleDomainAdd adds an inline rule. // handleDomainAdd adds an inline rule.
func (s *Server) handleDomainAdd(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDomainAdd(w http.ResponseWriter, r *http.Request) {
if s.hooks.AddDomain == nil { if s.hooks.AddDomain == nil {
+12 -1
View File
@@ -25,6 +25,7 @@ import (
"git.k8s-git-k6n.net/mats/fancy-dns/internal/cluster" "git.k8s-git-k6n.net/mats/fancy-dns/internal/cluster"
"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/engine"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubezones"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone" "git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
"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"
@@ -61,6 +62,7 @@ type App struct {
logger *qlog.Logger logger *qlog.Logger
aclH *acl.Holder aclH *acl.Holder
local *localzone.Holder local *localzone.Holder
zones *kubezones.Client
pool *resolver.Pool pool *resolver.Pool
reverse *engine.ReverseCache reverse *engine.ReverseCache
engine *engine.Engine engine *engine.Engine
@@ -75,6 +77,8 @@ type App struct {
// reloadMu serialises reload work, since SIGHUP, the API and the cluster // reloadMu serialises reload work, since SIGHUP, the API and the cluster
// can all trigger one at the same time. // can all trigger one at the same time.
reloadMu sync.Mutex reloadMu sync.Mutex
// zoneReloadMu serialises file-watcher and config-swap recompiles.
zoneReloadMu sync.Mutex
startedAt time.Time startedAt time.Time
// degraded is set when a background component failed; surfaced in health. // degraded is set when a background component failed; surfaced in health.
@@ -96,7 +100,11 @@ func New(opt Options) (*App, error) {
return nil, err return nil, err
} }
store := config.NewStore(cfg, opt.ConfigPath) store := config.NewStore(cfg, opt.ConfigPath)
return &App{opt: opt, log: log, cfg: store, ver: opt.Version}, nil zones, err := kubezones.InCluster()
if err != nil {
return nil, err
}
return &App{opt: opt, log: log, cfg: store, ver: opt.Version, zones: zones}, nil
} }
// newLogger builds the structured logger from config plus CLI overrides. // newLogger builds the structured logger from config plus CLI overrides.
@@ -322,6 +330,8 @@ func (a *App) startLocalZones(cfg *config.Config) {
// reloadLocalZones recompiles the local zone store. A failure keeps the // reloadLocalZones recompiles the local zone store. A failure keeps the
// previous store: serving stale local data beats serving nothing. // previous store: serving stale local data beats serving nothing.
func (a *App) reloadLocalZones() { func (a *App) reloadLocalZones() {
a.zoneReloadMu.Lock()
defer a.zoneReloadMu.Unlock()
cfg := a.cfg.Get() cfg := a.cfg.Get()
if len(cfg.Block.LocalZones) == 0 { if len(cfg.Block.LocalZones) == 0 {
a.local.Set(nil) a.local.Set(nil)
@@ -550,6 +560,7 @@ func (a *App) startCluster(ctx context.Context, cfg *config.Config) {
// startBackground runs the periodic jobs: cache snapshots, upstream health // startBackground runs the periodic jobs: cache snapshots, upstream health
// 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)
// Cache snapshots. // Cache snapshots.
if a.cstore != nil { if a.cstore != nil {
interval := cfg.Cache.Persist.Interval.D() interval := cfg.Cache.Persist.Interval.D()
+68
View File
@@ -16,6 +16,7 @@ import (
"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"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile"
) )
// hooks builds the api.Hooks that connect the admin API to the live // hooks builds the api.Hooks that connect the admin API to the live
@@ -37,6 +38,9 @@ func (a *App) hooks() api.Hooks {
ListDomains: a.listDomains, ListDomains: a.listDomains,
AddDomain: a.addDomain, AddDomain: a.addDomain,
DeleteDomain: a.deleteDomain, DeleteDomain: a.deleteDomain,
LocalZones: a.listLocalZones,
SaveLocalZone: a.saveLocalZone,
DeleteLocalZone: a.deleteLocalZone,
GetACL: a.getACL, GetACL: a.getACL,
PutACL: a.putACL, PutACL: a.putACL,
GetUpstream: a.getUpstreams, GetUpstream: a.getUpstreams,
@@ -52,6 +56,70 @@ func (a *App) hooks() api.Hooks {
} }
} }
func (a *App) listLocalZones(ctx context.Context) ([]api.ZoneFile, error) {
if a.zones == nil {
return nil, fmt.Errorf("local zone editor is available only in Kubernetes")
}
zones, err := a.zones.List(ctx)
if err != nil {
return nil, err
}
out := make([]api.ZoneFile, 0, len(zones))
for _, z := range zones {
out = append(out, api.ZoneFile{Name: z.Name, Content: z.Content})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (a *App) saveLocalZone(ctx context.Context, name, content string) error {
if a.zones == nil {
return fmt.Errorf("local zone editor is available only in Kubernetes")
}
apex := strings.ToLower(dns.Fqdn(strings.TrimSuffix(strings.TrimSpace(name), ".")))
_, valid := dns.IsDomainName(apex)
if !valid || apex == "." || len(apex) > 243 {
return fmt.Errorf("invalid zone name %q", name)
}
if strings.TrimSpace(content) == "" {
return fmt.Errorf("zone file cannot be empty")
}
parsed, err := zonefile.Parse(strings.NewReader(content), zonefile.Options{Origin: apex, File: name + ".zone"})
if err != nil {
return fmt.Errorf("invalid zone file: %w", err)
}
if len(parsed.Records) == 0 {
return fmt.Errorf("zone file must contain at least one record")
}
for _, record := range parsed.Records {
owner := strings.ToLower(dns.Fqdn(record.Name))
if owner != strings.ToLower(apex) && !strings.HasSuffix(owner, "."+strings.ToLower(apex)) {
return fmt.Errorf("record %q is outside zone %s", owner, apex)
}
}
if err := a.zones.Save(ctx, strings.TrimSuffix(apex, "."), content); err != nil {
return err
}
if a.cache != nil {
a.cache.Flush()
}
return nil
}
func (a *App) deleteLocalZone(ctx context.Context, name string) error {
if a.zones == nil {
return fmt.Errorf("local zone editor is available only in Kubernetes")
}
name = strings.TrimSuffix(strings.TrimSpace(name), ".")
if err := a.zones.Delete(ctx, name); err != nil {
return err
}
if a.cache != nil {
a.cache.Flush()
}
return nil
}
// health is the unauthenticated probe payload. // health is the unauthenticated probe payload.
func (a *App) health() any { func (a *App) health() any {
status := "ok" status := "ok"
+76
View File
@@ -0,0 +1,76 @@
package app
import (
"context"
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
// watchLocalZones reloads projected ConfigMap files on every replica. A
// Kubernetes ConfigMap volume swaps its ..data symlink atomically when the UI
// edits a zone; polling the contents keeps both pods in sync without routing
// DNS changes through a single pod.
func (a *App) watchLocalZones(ctx context.Context) {
if a.zones == nil {
return
}
last, _ := a.localZoneFingerprint()
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
next, err := a.localZoneFingerprint()
if err != nil {
a.log.Warn("local zone change check failed", "error", err)
continue
}
if next == last {
continue
}
a.reloadLocalZones()
if a.cache != nil {
a.cache.Flush()
}
last = next
}
}
}
func (a *App) localZoneFingerprint() (string, error) {
paths := make([]string, 0)
for _, configured := range a.cfg.Get().Block.LocalZones {
path := a.cfg.Get().AbsPath(configured)
if matches, err := filepath.Glob(path); err == nil && len(matches) > 0 {
paths = append(paths, matches...)
continue
}
if info, err := os.Stat(path); err == nil && info.IsDir() {
matches, err := filepath.Glob(filepath.Join(path, "*.zone"))
if err != nil {
return "", err
}
paths = append(paths, matches...)
continue
}
}
sort.Strings(paths)
h := sha256.New()
for _, path := range paths {
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s: %w", path, err)
}
_, _ = h.Write([]byte(path))
_, _ = h.Write([]byte{0})
_, _ = h.Write(b)
_, _ = h.Write([]byte{0})
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
+236
View File
@@ -0,0 +1,236 @@
// Package kubezones stores UI-managed zone files in a Kubernetes ConfigMap.
// The ConfigMap volume is shared by every fancy-dns replica, so UI edits do
// not depend on which pod receives the request.
package kubezones
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const (
serviceAccountDir = "/var/run/secrets/kubernetes.io/serviceaccount"
defaultName = "fancy-dns-zones"
)
// Zone is a ConfigMap key and its master-file contents.
type Zone struct {
Name string `json:"name"`
Content string `json:"content"`
}
// Client is a narrow client for the one ConfigMap the console may edit.
type Client struct {
baseURL string
ns string
name string
http *http.Client
}
// InCluster returns nil outside Kubernetes. The projected service account
// token must be available; RBAC limits it to the fancy-dns-zones ConfigMap.
func InCluster() (*Client, error) {
host := os.Getenv("KUBERNETES_SERVICE_HOST")
if host == "" {
return nil, nil
}
port := os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")
if port == "" {
port = "443"
}
ns := strings.TrimSpace(os.Getenv("POD_NAMESPACE"))
if ns == "" {
b, err := os.ReadFile(serviceAccountDir + "/namespace")
if err != nil {
return nil, fmt.Errorf("kube zones: read service account namespace: %w", err)
}
ns = strings.TrimSpace(string(b))
}
ca, err := os.ReadFile(serviceAccountDir + "/ca.crt")
if err != nil {
return nil, fmt.Errorf("kube zones: read service account CA: %w", err)
}
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM(ca) {
return nil, errors.New("kube zones: invalid service account CA")
}
name := os.Getenv("FANCYDNS_ZONE_CONFIGMAP")
if name == "" {
name = defaultName
}
return &Client{
baseURL: "https://" + net.JoinHostPort(host, port),
ns: ns,
name: name,
http: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
Proxy: nil,
TLSClientConfig: &tls.Config{RootCAs: pool, ServerName: "kubernetes.default.svc"},
},
},
}, nil
}
func (c *Client) resourceURL() string {
return c.baseURL + "/api/v1/namespaces/" + url.PathEscape(c.ns) + "/configmaps/" + url.PathEscape(c.name)
}
// List returns all zone files stored in the managed ConfigMap.
func (c *Client) List(ctx context.Context) ([]Zone, error) {
cm, _, err := c.get(ctx)
if err != nil {
return nil, err
}
out := make([]Zone, 0, len(cm.Data))
for name, content := range cm.Data {
if strings.HasPrefix(name, "zone--") && strings.HasSuffix(name, ".zone") {
out = append(out, Zone{Name: strings.TrimSuffix(strings.TrimPrefix(name, "zone--"), ".zone"), Content: content})
}
}
return out, nil
}
// Save updates or adds one zone file. Kubernetes resourceVersion makes the
// update conditional and retrying resource-version conflicts. Separate zone
// edits merge safely; when two editors change the same zone, the last save
// wins.
func (c *Client) Save(ctx context.Context, name, content string) error {
return c.change(ctx, filename(name), &content)
}
// Delete removes one zone file.
func (c *Client) Delete(ctx context.Context, name string) error {
return c.change(ctx, filename(name), nil)
}
func filename(name string) string { return "zone--" + strings.TrimSuffix(name, ".") + ".zone" }
func (c *Client) change(ctx context.Context, name string, content *string) error {
if !validKey(name) {
return errors.New("zone filename must be a DNS name ending in .zone")
}
if content != nil && len(*content) > 512*1024 {
return errors.New("zone file exceeds 512 KiB")
}
for attempt := 0; attempt < 3; attempt++ {
_, rv, err := c.get(ctx)
if err != nil {
return err
}
var value any
if content == nil {
value = nil
} else {
value = *content
}
body, err := json.Marshal(map[string]any{
"metadata": map[string]string{"resourceVersion": rv},
"data": map[string]any{name: value},
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.resourceURL(), bytes.NewReader(body))
if err != nil {
return err
}
if err := c.authorize(req); err != nil {
return err
}
req.Header.Set("Content-Type", "application/merge-patch+json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("kube zones: update ConfigMap: %w", err)
}
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode == http.StatusConflict {
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("kube zones: update ConfigMap: Kubernetes returned %s", resp.Status)
}
return nil
}
return errors.New("kube zones: concurrent edits; reload and try again")
}
type configMap struct {
Metadata struct {
ResourceVersion string `json:"resourceVersion"`
} `json:"metadata"`
Data map[string]string `json:"data"`
}
func (c *Client) get(ctx context.Context) (configMap, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.resourceURL(), nil)
if err != nil {
return configMap{}, "", err
}
if err := c.authorize(req); err != nil {
return configMap{}, "", err
}
resp, err := c.http.Do(req)
if err != nil {
return configMap{}, "", fmt.Errorf("kube zones: read ConfigMap: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return configMap{}, "", fmt.Errorf("kube zones: read ConfigMap: Kubernetes returned %s", resp.Status)
}
var cm configMap
if err := json.NewDecoder(io.LimitReader(resp.Body, 2*1024*1024)).Decode(&cm); err != nil {
return configMap{}, "", fmt.Errorf("kube zones: decode ConfigMap: %w", err)
}
if cm.Data == nil {
cm.Data = map[string]string{}
}
return cm, cm.Metadata.ResourceVersion, nil
}
func (c *Client) authorize(req *http.Request) error {
token, err := os.ReadFile(serviceAccountDir + "/token")
if err != nil {
return fmt.Errorf("kube zones: read service account token: %w", err)
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(token)))
return nil
}
func validKey(name string) bool {
if !strings.HasPrefix(name, "zone--") || !strings.HasSuffix(name, ".zone") ||
strings.ContainsAny(name, `/\\`) || len(name) > 253 {
return false
}
name = strings.TrimSuffix(strings.TrimPrefix(name, "zone--"), ".zone")
if name == "" || strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
return false
}
for _, label := range strings.Split(name, ".") {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for _, r := range label {
if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') {
return false
}
}
}
return true
}
+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-D_F84V7q.js"></script> <script type="module" crossorigin src="/assets/index-B-5PHdii.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CZ8p4VMZ.css"> <link rel="stylesheet" crossorigin href="/assets/index-YXlSytfO.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+9 -2
View File
@@ -577,13 +577,20 @@ func ParseFiles(paths []string, originFor func(string) string) (*Zone, error) {
return z, nil return z, nil
} }
// OriginFromPath derives a zone origin from a file path: "rpz/ads.zone" and // OriginFromPath derives a zone origin from a file path. Legacy names such as
// "ads.zone" both become "ads.zone.". // "rpz/ads.zone" and "ads.zone" become "ads."; UI-managed names use the
// "zone--" marker so "zone--example.com.zone" becomes "example.com.".
func OriginFromPath(p string) string { func OriginFromPath(p string) string {
base := p base := p
if i := strings.LastIndexByte(base, '/'); i >= 0 { if i := strings.LastIndexByte(base, '/'); i >= 0 {
base = base[i+1:] base = base[i+1:]
} }
// The UI stores fully-qualified zone names with a marker so they do not
// collide with the legacy filename convention where "local.example.zone"
// means the "local." zone.
if strings.HasPrefix(base, "zone--") && strings.HasSuffix(base, ".zone") {
return ensureDot(strings.TrimSuffix(strings.TrimPrefix(base, "zone--"), ".zone"))
}
if i := strings.IndexByte(base, '.'); i > 0 { if i := strings.IndexByte(base, '.'); i > 0 {
base = base[:i] base = base[:i]
} }
+2
View File
@@ -19,6 +19,7 @@ import Dashboard from './pages/Dashboard.tsx'
import Domains from './pages/Domains.tsx' 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 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'
@@ -50,6 +51,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="/local-zones" element={<LocalZones />} />
<Route path="/clients" element={<Clients />} /> <Route path="/clients" element={<Clients />} />
<Route path="/upstreams" element={<Upstreams />} /> <Route path="/upstreams" element={<Upstreams />} />
<Route path="/logs" element={<Logs />} /> <Route path="/logs" element={<Logs />} />
+2
View File
@@ -4,6 +4,7 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { import {
Database, Database,
FileCode2,
Globe, Globe,
LayoutDashboard, LayoutDashboard,
ListChecks, ListChecks,
@@ -37,6 +38,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: '/local-zones', label: 'Local zones', icon: FileCode2 },
{ to: '/clients', label: 'Clients', icon: Users }, { to: '/clients', label: 'Clients', icon: Users },
{ to: '/upstreams', label: 'Upstreams', icon: Network }, { to: '/upstreams', label: 'Upstreams', icon: Network },
{ to: '/logs', label: 'Logs', icon: ScrollText }, { to: '/logs', label: 'Logs', icon: ScrollText },
+16
View File
@@ -21,6 +21,7 @@ import type {
FlushCacheResponse, FlushCacheResponse,
LoginInput, LoginInput,
LoginResponse, LoginResponse,
LocalZonesResponse,
LogFilters, LogFilters,
LogItem, LogItem,
LogsResponse, LogsResponse,
@@ -319,6 +320,21 @@ export function getSettings(): Promise<SettingsResponse> {
return apiFetch<SettingsResponse>('/api/settings') return apiFetch<SettingsResponse>('/api/settings')
} }
export function getLocalZones(): Promise<LocalZonesResponse> {
return apiFetch<LocalZonesResponse>('/api/local-zones')
}
export function saveLocalZone(name: string, content: string): Promise<OkResponse> {
return apiFetch<OkResponse>(`/api/local-zones/${encodeURIComponent(name)}`, {
method: 'PUT',
body: JSON.stringify({ content }),
})
}
export function deleteLocalZone(name: string): Promise<OkResponse> {
return apiFetch<OkResponse>(`/api/local-zones/${encodeURIComponent(name)}`, { method: 'DELETE' })
}
export function putSettings(yaml: string): Promise<SaveSettingsResponse> { export function putSettings(yaml: string): Promise<SaveSettingsResponse> {
return apiFetch<SaveSettingsResponse>('/api/settings', { return apiFetch<SaveSettingsResponse>('/api/settings', {
method: 'PUT', method: 'PUT',
+11
View File
@@ -512,6 +512,17 @@ export interface SettingsResponse {
writable: boolean writable: boolean
} }
// --- GET /api/local-zones -------------------------------------------------
export interface ZoneFile {
name: string
content: string
}
export interface LocalZonesResponse {
zones: ZoneFile[]
}
export interface SaveSettingsResponse { export interface SaveSettingsResponse {
ok: boolean ok: boolean
version: number version: number
+168
View File
@@ -0,0 +1,168 @@
/** Edit the authoritative zone files shared by the DNS replicas. */
import { useEffect, useMemo, useState } from 'react'
import { FileCode2, Plus, Save, Trash2 } from 'lucide-react'
import { deleteLocalZone, getLocalZones, saveLocalZone } from '../lib/api'
import { useWhoami } from '../lib/session.ts'
import { useApi, useAsyncAction } from '../hooks/useApi'
import { Button, Card, CardBody, Input, Textarea, useToast } from '../components/ui'
import { LoadingBlock, PageError, PageHeader } from '../components/layout'
const EXAMPLE = '$ORIGIN example.net.\n$TTL 300\n@ IN A 192.0.2.10\nwww IN CNAME @\n'
function validApex(value: string): boolean {
const apex = value.trim().replace(/\.$/, '')
if (apex === '' || apex.length > 242) return false
return apex.split('.').every((label) =>
label.length > 0 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label),
)
}
export default function LocalZones() {
const { canWrite } = useWhoami()
const { toast } = useToast()
const { data, error, loading, refetch } = useApi(() => getLocalZones(), [])
const [selected, setSelected] = useState<string | null>(null)
const [draft, setDraft] = useState<{ name: string; content: string } | null>(null)
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const save = useAsyncAction(async (input: { name: string; content: string }) =>
saveLocalZone(input.name, input.content),
)
const remove = useAsyncAction(async (name: string) => deleteLocalZone(name))
const zones = data?.zones ?? []
const active = useMemo(() => zones.find((zone) => zone.name === selected) ?? null, [zones, selected])
useEffect(() => {
if (selected === null && zones.length > 0 && !creating && draft === null) setSelected(zones[0].name)
}, [creating, draft, selected, zones])
useEffect(() => {
if (active !== null && draft === null) setDraft({ name: active.name, content: active.content })
if (active === null && selected !== null && !creating) setDraft(null)
}, [active, creating, draft, selected])
const startNew = (): void => {
setSelected(null)
setDraft(null)
setNewName('')
setCreating(true)
}
const beginCreate = (): void => {
const name = newName.trim().replace(/\.$/, '').toLowerCase()
if (!validApex(name)) {
toast({ title: 'Enter a valid DNS zone name', tone: 'danger' })
return
}
if (zones.some((zone) => zone.name === name)) {
toast({ title: 'That zone already exists', tone: 'danger' })
return
}
setSelected(null)
setDraft({ name, content: `$ORIGIN ${name}.\n$TTL 300\n` })
setCreating(false)
}
const onSave = async (): Promise<void> => {
if (draft === null || !validApex(draft.name)) return
const result = await save.run(draft)
if (result === null) {
toast({ title: 'Could not save zone', description: save.error ?? undefined, tone: 'danger' })
return
}
setSelected(draft.name.trim().replace(/\.$/, '').toLowerCase())
setDraft(null)
await refetch()
toast({ title: 'Zone saved', description: 'The DNS pods will load the updated file.', tone: 'ok' })
}
const onDelete = async (): Promise<void> => {
if (selected === null) return
if (!window.confirm(`Delete the ${selected} zone and all of its records?`)) return
const result = await remove.run(selected)
if (result === null) {
toast({ title: 'Could not delete zone', description: remove.error ?? undefined, tone: 'danger' })
return
}
setSelected(null)
setDraft(null)
await refetch()
toast({ title: 'Zone deleted', tone: 'ok' })
}
if (loading) return <LoadingBlock />
if (error !== null) return <PageError error={error} onRetry={() => void refetch()} />
const changed = draft !== null && (active === null || draft.name !== active.name || draft.content !== active.content)
return (
<div className="space-y-4">
<PageHeader
title="Local zones"
description="Add authoritative DNS records shared across replicas. Missing names under a zone apex return NXDOMAIN; for a single-host override, use that hostname as the apex."
actions={<Button icon={Plus} onClick={startNew} disabled={!canWrite}>New zone</Button>}
/>
<div className="grid gap-4 xl:grid-cols-[minmax(220px,0.7fr)_minmax(0,2fr)]">
<Card title="Zones" subtitle={`${zones.length} master files`} padded={false}>
<div className="space-y-1 p-2">
{zones.length === 0 && <p className="px-3 py-5 text-sm text-fg-subtle">No local zones yet.</p>}
{zones.map((zone) => (
<button
key={zone.name}
type="button"
onClick={() => { setCreating(false); setDraft(null); setSelected(zone.name) }}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors ${selected === zone.name ? 'bg-accent-soft text-accent' : 'text-fg-muted hover:bg-surface-2 hover:text-fg'}`}
>
<FileCode2 size={15} aria-hidden="true" />
<span className="truncate font-mono">{zone.name}.</span>
</button>
))}
</div>
</Card>
<Card
title={draft?.name ? `${draft.name}.zone` : 'Zone file'}
subtitle="RFC 1035 master-file syntax. The zone apex is used as the default origin."
padded={false}
actions={selected !== null && draft !== null && canWrite ? (
<Button variant="danger" icon={Trash2} onClick={() => void onDelete()} loading={remove.pending}>Delete zone</Button>
) : undefined}
>
{creating ? (
<CardBody className="space-y-3">
<Input label="Zone apex" placeholder="example.net" value={newName} onChange={(event) => setNewName(event.currentTarget.value)} autoFocus />
<Button variant="primary" icon={Plus} onClick={beginCreate}>Create zone</Button>
</CardBody>
) : draft === null ? (
<CardBody><p className="text-sm text-fg-subtle">Choose a zone or create one to add records.</p></CardBody>
) : (
<div className="space-y-3 p-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="min-w-56 flex-1">
<Input label="Zone apex" value={draft.name} disabled={active !== null} onChange={(event) => setDraft({ ...draft, name: event.currentTarget.value })} />
</div>
<Button variant="primary" icon={Save} onClick={() => void onSave()} loading={save.pending} disabled={!canWrite || !changed || !validApex(draft.name)}>Save records</Button>
</div>
<Textarea
monospace
rows={20}
spellCheck={false}
aria-label="DNS zone records"
value={draft.content}
onChange={(event) => setDraft({ ...draft, content: event.currentTarget.value })}
placeholder={EXAMPLE}
className="min-h-[420px] font-mono text-xs leading-6"
/>
<p className="text-xs text-fg-subtle">Example: <code>@ IN A 192.0.2.10</code> or <code>www IN CNAME @</code>. Invalid records are rejected before saving.</p>
{!canWrite && <p className="text-xs text-warn">Your account is read-only.</p>}
</div>
)}
</Card>
</div>
</div>
)
}