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

+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]
}