diff --git a/README.md b/README.md index a5d0f26..57b76a7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/deploy/k8s/05-serviceaccount.yaml b/deploy/k8s/05-serviceaccount.yaml index 49189b6..0be9911 100644 --- a/deploy/k8s/05-serviceaccount.yaml +++ b/deploy/k8s/05-serviceaccount.yaml @@ -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 diff --git a/deploy/k8s/06-zones.yaml b/deploy/k8s/06-zones.yaml new file mode 100644 index 0000000..b9fb7c2 --- /dev/null +++ b/deploy/k8s/06-zones.yaml @@ -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--.zone +# key per zone through the API; server-side apply preserves those user fields. diff --git a/deploy/k8s/10-deployment.yaml b/deploy/k8s/10-deployment.yaml index f17241c..10ce422 100644 --- a/deploy/k8s/10-deployment.yaml +++ b/deploy/k8s/10-deployment.yaml @@ -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--.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 diff --git a/deploy/k8s/kustomization.yaml b/deploy/k8s/kustomization.yaml index c85d7f3..1a4df47 100644 --- a/deploy/k8s/kustomization.yaml +++ b/deploy/k8s/kustomization.yaml @@ -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 diff --git a/internal/api/api.go b/internal/api/api.go index 2c51b44..8eef073 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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)) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 3b8eb2b..884ad49 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -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 { diff --git a/internal/app/app.go b/internal/app/app.go index f81f222..ec4ac05 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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() diff --git a/internal/app/hooks.go b/internal/app/hooks.go index 459882e..9514c77 100644 --- a/internal/app/hooks.go +++ b/internal/app/hooks.go @@ -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" diff --git a/internal/app/localzones_watch.go b/internal/app/localzones_watch.go new file mode 100644 index 0000000..951d278 --- /dev/null +++ b/internal/app/localzones_watch.go @@ -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 +} diff --git a/internal/kubezones/client.go b/internal/kubezones/client.go new file mode 100644 index 0000000..ca5a9c7 --- /dev/null +++ b/internal/kubezones/client.go @@ -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 +} diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index 9b21560..2f54c32 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -5,8 +5,8 @@ web - - + +
diff --git a/internal/zonefile/zonefile.go b/internal/zonefile/zonefile.go index fd60915..3ef84e9 100644 --- a/internal/zonefile/zonefile.go +++ b/internal/zonefile/zonefile.go @@ -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] } diff --git a/web/src/App.tsx b/web/src/App.tsx index 6c4a9d6..623c18b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 1437e76..bcb72dc 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -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 }, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2bb2e60..56bac84 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -21,6 +21,7 @@ import type { FlushCacheResponse, LoginInput, LoginResponse, + LocalZonesResponse, LogFilters, LogItem, LogsResponse, @@ -319,6 +320,21 @@ export function getSettings(): Promise { return apiFetch('/api/settings') } +export function getLocalZones(): Promise { + return apiFetch('/api/local-zones') +} + +export function saveLocalZone(name: string, content: string): Promise { + return apiFetch(`/api/local-zones/${encodeURIComponent(name)}`, { + method: 'PUT', + body: JSON.stringify({ content }), + }) +} + +export function deleteLocalZone(name: string): Promise { + return apiFetch(`/api/local-zones/${encodeURIComponent(name)}`, { method: 'DELETE' }) +} + export function putSettings(yaml: string): Promise { return apiFetch('/api/settings', { method: 'PUT', diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 76b3619..a18d402 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -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 diff --git a/web/src/pages/LocalZones.tsx b/web/src/pages/LocalZones.tsx new file mode 100644 index 0000000..eda8ebe --- /dev/null +++ b/web/src/pages/LocalZones.tsx @@ -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(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 => { + 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 => { + 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 + if (error !== null) return void refetch()} /> + + const changed = draft !== null && (active === null || draft.name !== active.name || draft.content !== active.content) + + return ( +
+ New zone} + /> + +
+ +
+ {zones.length === 0 &&

No local zones yet.

} + {zones.map((zone) => ( + + ))} +
+
+ + void onDelete()} loading={remove.pending}>Delete zone + ) : undefined} + > + {creating ? ( + + setNewName(event.currentTarget.value)} autoFocus /> + + + ) : draft === null ? ( +

Choose a zone or create one to add records.

+ ) : ( +
+
+
+ setDraft({ ...draft, name: event.currentTarget.value })} /> +
+ +
+