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.
**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
first dot: `zones/lan.zone` and `lan.zone` both become the origin `lan.`
(the doc comment on that function says `ads.zone.`, which is wrong — see
[Known inconsistencies](#known-inconsistencies)). An explicit `$ORIGIN` inside
the file wins, because `Parse` starts from that derived origin and the
directive replaces it. Records that fall outside their own zone are dropped
(`localzone.add` returns false).
`zonefile.OriginFromPath`. Legacy names truncate at the first dot, so
`zones/lan.zone` becomes `lan.`. The console marks fully-qualified zone names
with `zone--`, so `zone--example.com.zone` becomes `example.com.`. An explicit
`$ORIGIN` inside the file wins, because `Parse` starts from that derived origin
and the directive replaces it. Records that fall outside their own zone are
dropped (`localzone.add` returns false).
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
@@ -431,6 +430,15 @@ records mean and where they are matched:
one-label and deeper wildcards, SRV, TXT). `deploy/zones/local.rpz.zone.example`
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
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
knows the record types, but nothing serves or accepts a transfer, and
`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
query. There is no RFC 2136 handling, no journal and no NOTIFY. A local zone
only changes when the file changes and the config is reloaded (SIGHUP,
`POST /api/settings/reload`, or a restart).
query. There is no wire-protocol update handling, journal or NOTIFY. Local
zones are editable from the console's **Local zones** page. Kubernetes pods
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
listener is h2 with an h2c fallback.
* **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
never applied; `internal/cluster/cluster.go:dial` builds a plain
`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
advertises `$INCLUDE`, the parser rejects it** ("`$INCLUDE … is not
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
(`.github/workflows/ci.yml`, "Smoke test the real binary"), or a port you
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
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
+29 -5
View File
@@ -1,7 +1,6 @@
# A stable identity for the pods. The server never talks to the Kubernetes API
# (blocklist replication goes through Valkey, not the API server), so the token
# is not just unused but actively undesirable: automounting it would hand every
# pod a credential it has no reason to hold.
# The UI edits the shared `fancy-dns-zones` ConfigMap. Its projected volume is
# read-only; these narrowly scoped permissions let the API update only that
# one ConfigMap so both DNS replicas receive the same records.
apiVersion: v1
kind: ServiceAccount
metadata:
@@ -9,4 +8,29 @@ metadata:
namespace: dns
labels:
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:
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"
dns.k6n.net/zones-api: "enabled"
# A config change is a SIGHUP, not a restart: the server re-reads the
# file and hot-swaps every component. This annotation is for
# stakater/reloader-class controllers; without one, `kubectl rollout
@@ -152,15 +153,13 @@ spec:
- name: config
configMap:
name: fancy-dns-config
# Local zones are ordinary master files; ship them in a ConfigMap or
# edit them in the mounted volume. There is no zone API — the admin API
# drives blocklists, the cache and the query log, not the zone files.
# the data volume. Optional because the glob in the config matches
# nothing cleanly.
# Local zones are ordinary master files. The API writes them to the
# shared ConfigMap, which projects one zone--<apex>.zone file per zone here.
# The GitOps manifest creates the ConfigMap empty; the UI owns its data.
- name: zones
configMap:
name: fancy-dns-zones
optional: true
optional: false
- name: tls
secret:
secretName: fancy-dns-tls
+1
View File
@@ -11,6 +11,7 @@ namespace: dns
resources:
- 00-config.yaml
- 05-serviceaccount.yaml
- 06-zones.yaml
- 10-deployment.yaml
- 20-service.yaml
- 30-ingress.yaml
+13
View File
@@ -71,6 +71,10 @@ type Hooks struct {
AddDomain func(ctx context.Context, in DomainInput) (any, error)
// DeleteDomain removes a rule and persists the change.
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 func() any
// PutACL validates and persists new ACL groups.
@@ -119,6 +123,12 @@ type DomainInput struct {
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.
type LogQuery struct {
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("DELETE "+base+"domains/{name...}", s.authed(s.handleDomainDelete, true))
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("PUT "+base+"clients", s.authed(s.handleClientsPut, true))
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)
}
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.
func (s *Server) handleDomainAdd(w http.ResponseWriter, r *http.Request) {
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/config"
"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/metrics"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
@@ -61,6 +62,7 @@ type App struct {
logger *qlog.Logger
aclH *acl.Holder
local *localzone.Holder
zones *kubezones.Client
pool *resolver.Pool
reverse *engine.ReverseCache
engine *engine.Engine
@@ -75,6 +77,8 @@ type App struct {
// reloadMu serialises reload work, since SIGHUP, the API and the cluster
// can all trigger one at the same time.
reloadMu sync.Mutex
// zoneReloadMu serialises file-watcher and config-swap recompiles.
zoneReloadMu sync.Mutex
startedAt time.Time
// degraded is set when a background component failed; surfaced in health.
@@ -96,7 +100,11 @@ func New(opt Options) (*App, error) {
return nil, err
}
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.
@@ -322,6 +330,8 @@ func (a *App) startLocalZones(cfg *config.Config) {
// reloadLocalZones recompiles the local zone store. A failure keeps the
// previous store: serving stale local data beats serving nothing.
func (a *App) reloadLocalZones() {
a.zoneReloadMu.Lock()
defer a.zoneReloadMu.Unlock()
cfg := a.cfg.Get()
if len(cfg.Block.LocalZones) == 0 {
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
// probes and the metrics gauge.
func (a *App) startBackground(ctx context.Context, cfg *config.Config) {
go a.watchLocalZones(ctx)
// Cache snapshots.
if a.cstore != nil {
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/qlog"
"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
@@ -37,6 +38,9 @@ func (a *App) hooks() api.Hooks {
ListDomains: a.listDomains,
AddDomain: a.addDomain,
DeleteDomain: a.deleteDomain,
LocalZones: a.listLocalZones,
SaveLocalZone: a.saveLocalZone,
DeleteLocalZone: a.deleteLocalZone,
GetACL: a.getACL,
PutACL: a.putACL,
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.
func (a *App) health() any {
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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<script type="module" crossorigin src="/assets/index-D_F84V7q.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CZ8p4VMZ.css">
<script type="module" crossorigin src="/assets/index-B-5PHdii.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-YXlSytfO.css">
</head>
<body>
<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
}
// OriginFromPath derives a zone origin from a file path: "rpz/ads.zone" and
// "ads.zone" both become "ads.zone.".
// OriginFromPath derives a zone origin from a file path. Legacy names such as
// "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 {
base := p
if i := strings.LastIndexByte(base, '/'); i >= 0 {
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 {
base = base[:i]
}
+2
View File
@@ -19,6 +19,7 @@ import Dashboard from './pages/Dashboard.tsx'
import Domains from './pages/Domains.tsx'
import Login from './pages/Login.tsx'
import Logs from './pages/Logs.tsx'
import LocalZones from './pages/LocalZones.tsx'
import NotFound from './pages/NotFound.tsx'
import Settings from './pages/Settings.tsx'
import Upstreams from './pages/Upstreams.tsx'
@@ -50,6 +51,7 @@ function AppRoutes() {
<Route path="/" element={<Dashboard />} />
<Route path="/blocklists" element={<Blocklists />} />
<Route path="/domains" element={<Domains />} />
<Route path="/local-zones" element={<LocalZones />} />
<Route path="/clients" element={<Clients />} />
<Route path="/upstreams" element={<Upstreams />} />
<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 {
Database,
FileCode2,
Globe,
LayoutDashboard,
ListChecks,
@@ -37,6 +38,7 @@ const NAV: NavItem[] = [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true },
{ to: '/blocklists', label: 'Blocklists', icon: ListChecks },
{ to: '/domains', label: 'Domains', icon: Globe },
{ to: '/local-zones', label: 'Local zones', icon: FileCode2 },
{ to: '/clients', label: 'Clients', icon: Users },
{ to: '/upstreams', label: 'Upstreams', icon: Network },
{ to: '/logs', label: 'Logs', icon: ScrollText },
+16
View File
@@ -21,6 +21,7 @@ import type {
FlushCacheResponse,
LoginInput,
LoginResponse,
LocalZonesResponse,
LogFilters,
LogItem,
LogsResponse,
@@ -319,6 +320,21 @@ export function getSettings(): Promise<SettingsResponse> {
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> {
return apiFetch<SaveSettingsResponse>('/api/settings', {
method: 'PUT',
+11
View File
@@ -512,6 +512,17 @@ export interface SettingsResponse {
writable: boolean
}
// --- GET /api/local-zones -------------------------------------------------
export interface ZoneFile {
name: string
content: string
}
export interface LocalZonesResponse {
zones: ZoneFile[]
}
export interface SaveSettingsResponse {
ok: boolean
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>
)
}