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:
1 parent
2a7ac824f7
commit
32f940b763
18 files changed
+727
-36
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user