From 04ebf68c0fad4729ec793fa053216407be0b8a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Fri, 16 Oct 2026 10:19:47 +0200 Subject: [PATCH] Deploy: read cluster objects, drop the zones and RPZ ConfigMaps DO NOT SYNC THIS BEFORE MIGRATING. Removing fancy-dns-zones and fancy-dns-rpz from the kustomization lets a pruning GitOps tool delete them, and they hold the only copy of the zones and address redirects until the objects exist. Follow deploy/MIGRATION.md: install the definitions, run `fancy-dns-console migrate` against the live cluster, then roll this out. The StatefulSet starts the resolver with -kube and no longer mounts the two ConfigMaps; its service account loses the ConfigMap role and keeps only the read-only Role on the fancy-dns objects (crds/rbac.yaml). The kustomization now installs the definitions and that Role. The shipped config no longer lists the migrated sources. Config validation no longer refuses an enabled blocklist with no sources in the file, because BlockList, DomainRule and AddressOverride objects can supply every rule and the file cannot know. The existing "no source" warning says so. That removed the only validation message the settings tests used as a fixture; they now use an invalid acl.default_policy, and an empty settings document is refused by the handler instead of by that check. Co-Authored-By: Claude Sonnet 5.5 --- deploy/k8s/00-config.yaml | 18 ++++-------------- deploy/k8s/05-serviceaccount.yaml | 30 ++---------------------------- deploy/k8s/06-zones.yaml | 11 ----------- deploy/k8s/07-rpz.yaml | 16 ---------------- deploy/k8s/10-deployment.yaml | 22 ++++------------------ deploy/k8s/kustomization.yaml | 4 ++-- internal/api/handlers.go | 5 ++++- internal/api/settings_test.go | 16 +++++++--------- internal/config/config.go | 10 ++++------ 9 files changed, 27 insertions(+), 105 deletions(-) delete mode 100644 deploy/k8s/06-zones.yaml delete mode 100644 deploy/k8s/07-rpz.yaml diff --git a/deploy/k8s/00-config.yaml b/deploy/k8s/00-config.yaml index 0edcc8a..9649e2d 100644 --- a/deploy/k8s/00-config.yaml +++ b/deploy/k8s/00-config.yaml @@ -86,20 +86,10 @@ data: blocklist: enabled: true default_action: nxdomain - local_zones: [/etc/fancy-dns/zones/*.zone] - lists: - - name: edge-router-x-address-overrides - format: rpz - path: /etc/fancy-dns/rpz/address-overrides.rpz - enabled: true - refresh: 0s - - name: haas - format: plain - url: https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts - path: /var/lib/fancy-dns/lists/hosts - enabled: true - refresh: 24h - categories: [ads, tracking] + # Lists, zones, address redirects, inline rules and client groups are + # cluster objects now (BlockList, DNSZone, AddressOverride, DomainRule, + # ClientGroup); see deploy/MIGRATION.md. `fancy-dns-console migrate` + # converts what was here. acl: default_policy: allow diff --git a/deploy/k8s/05-serviceaccount.yaml b/deploy/k8s/05-serviceaccount.yaml index 0be9911..6e66e8a 100644 --- a/deploy/k8s/05-serviceaccount.yaml +++ b/deploy/k8s/05-serviceaccount.yaml @@ -1,6 +1,5 @@ -# 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. +# The resolver's identity. It reads the fancy-dns objects through the Role in +# crds/rbac.yaml and writes nothing; the console has its own account. apiVersion: v1 kind: ServiceAccount metadata: @@ -9,28 +8,3 @@ metadata: labels: app.kubernetes.io/name: fancy-dns 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 deleted file mode 100644 index b9fb7c2..0000000 --- a/deploy/k8s/06-zones.yaml +++ /dev/null @@ -1,11 +0,0 @@ -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/07-rpz.yaml b/deploy/k8s/07-rpz.yaml deleted file mode 100644 index 9a5b512..0000000 --- a/deploy/k8s/07-rpz.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: fancy-dns-rpz - namespace: dns - labels: - app.kubernetes.io/name: fancy-dns - app.kubernetes.io/component: address-overrides -data: - address-overrides.rpz: | - $TTL 60 - tornberg.me A 10.10.3.10 - knatofs.se A 10.10.3.10 - k6n.net A 10.10.3.10 - slask.local A 10.10.3.10 - iamu.dev A 10.10.3.10 diff --git a/deploy/k8s/10-deployment.yaml b/deploy/k8s/10-deployment.yaml index 724d813..e96d1e2 100644 --- a/deploy/k8s/10-deployment.yaml +++ b/deploy/k8s/10-deployment.yaml @@ -27,7 +27,6 @@ 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 @@ -66,6 +65,10 @@ spec: args: - -config - /etc/fancy-dns/config.yaml + # Read ClientGroup, BlockList, DNSZone, AddressOverride and + # DomainRule objects from this namespace with the pod's service + # account. The console (console/console.yaml) is what writes them. + - -kube env: # Substituted into the config by the loader, so the admin API key # comes from the Secret and not from the ConfigMap. @@ -136,12 +139,6 @@ spec: mountPath: /etc/fancy-dns/config.yaml subPath: config.yaml readOnly: true - - name: zones - mountPath: /etc/fancy-dns/zones - readOnly: true - - name: rpz - mountPath: /etc/fancy-dns/rpz - readOnly: true - name: tls mountPath: /etc/fancy-dns/tls readOnly: true @@ -153,17 +150,6 @@ spec: - name: config configMap: name: fancy-dns-config - # 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: false - - name: rpz - configMap: - name: fancy-dns-rpz - optional: false - name: tls secret: secretName: fancy-dns-tls diff --git a/deploy/k8s/kustomization.yaml b/deploy/k8s/kustomization.yaml index bbba73f..bd5c9ad 100644 --- a/deploy/k8s/kustomization.yaml +++ b/deploy/k8s/kustomization.yaml @@ -11,8 +11,8 @@ namespace: dns resources: - 00-config.yaml - 05-serviceaccount.yaml - - 06-zones.yaml - - 07-rpz.yaml + - crds/crds.yaml + - crds/rbac.yaml - 10-deployment.yaml - 20-service.yaml - 30-ingress.yaml diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 529235b..9954a3b 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -578,6 +578,9 @@ func (s *Server) Commit(next *config.Config) error { return s.commit(next) } // parseConfigYAML decodes a config document on top of the defaults, which is // what makes a partial document from the UI editor work. func parseConfigYAML(text string) (*config.Config, error) { + if strings.TrimSpace(text) == "" { + return nil, errors.New("the document is empty") + } next := config.Default() if err := yaml.Unmarshal([]byte(text), next); err != nil { return nil, err @@ -640,7 +643,7 @@ func configWarnings(cfg *config.Config) []string { add("blocklist.enabled: blocklists are off, so this instance is an open resolver for everything") } else if len(cfg.Block.Lists) == 0 && len(cfg.Block.LocalZones) == 0 && len(cfg.Block.BlockEntries) == 0 { - add("blocklist: enabled but no source is configured") + add("blocklist: enabled but this config names no source (fine if BlockList or DomainRule objects supply it)") } for _, l := range cfg.Block.Lists { if strings.HasPrefix(l.URL, "http://") { diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go index ae6fd55..6e6d9b9 100644 --- a/internal/api/settings_test.go +++ b/internal/api/settings_test.go @@ -146,7 +146,7 @@ func TestSettingsPutRejects(t *testing.T) { {"broken yaml", `{"config_yaml": "api: [\n"}`, "cannot parse config"}, { "invalid config", - `{"config_yaml": "blocklist:\n enabled: true\n lists: []\n"}`, + `{"config_yaml": "acl:\n default_policy: maybe\n"}`, "invalid config", }, } @@ -261,11 +261,9 @@ func TestSettingsValidate(t *testing.T) { }) t.Run("empty document is not valid", func(t *testing.T) { - // Not the obvious answer, and worth pinning: an empty document - // decodes onto config.Default, and the shipped defaults enable the - // blocklist without a single source, so Validate rejects them. The - // UI therefore cannot be used to "reset to nothing"; a reset to - // defaults has to send a real default document. + // An empty document would decode onto the defaults and be valid, so the + // editor could be used to wipe the config by accident. It is refused + // outright; a reset to defaults has to send a real default document. res := h.do(http.MethodPost, "/api/settings/validate", `{"config_yaml": ""}`, "X-API-Key: rw-key") if res.status != http.StatusOK { t.Fatalf("validate = %d, want 200: %s", res.status, res.body) @@ -297,7 +295,7 @@ func TestSettingsValidate(t *testing.T) { t.Run("invalid config", func(t *testing.T) { res := h.do(http.MethodPost, "/api/settings/validate", - `{"config_yaml": "blocklist:\n enabled: true\n lists: []\n"}`, "X-API-Key: rw-key") + `{"config_yaml": "acl:\n default_policy: maybe\n"}`, "X-API-Key: rw-key") if res.status != http.StatusOK { t.Fatalf("validate = %d, want 200: %s", res.status, res.body) } @@ -316,8 +314,8 @@ func TestSettingsValidate(t *testing.T) { msg, _ := m["message"].(string) joined += msg + "\n" } - if !strings.Contains(joined, "blocklist is enabled but no lists") { - t.Errorf("errors = %q, want the blocklist complaint", joined) + if !strings.Contains(joined, "acl.default_policy must be") { + t.Errorf("errors = %q, want the default_policy complaint", joined) } // fieldOf falls back to "config" for a message with no dotted path, // which is what this one is. Pinned so the editor's field highlighting diff --git a/internal/config/config.go b/internal/config/config.go index 69c9534..8462565 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1152,12 +1152,10 @@ func (c *Config) Validate() error { add("blocklist.lists[%d] %q: refresh must not be negative", i, l.Name) } } - if c.Block.Enabled { - if len(c.Block.Lists) == 0 && len(c.Block.LocalZones) == 0 && - len(c.Block.BlockEntries) == 0 && len(c.Block.AllowEntries) == 0 { - add("blocklist is enabled but no lists, local_zones or inline entries are configured") - } - } + // An enabled blocklist with nothing configured is not an error: BlockList, + // DomainRule and AddressOverride objects can supply every rule, and the + // config file cannot know about them. configWarnings says so for a + // standalone config. switch c.ACL.DefaultPolicy { case "allow", "deny", "block_only":