Add a read-only Kubernetes source for zones, client groups and blocklists

The runtime can now take ClientGroup, BlockList and DNSZone objects
(fancydns.k6n.net/v1alpha1) from a cluster with -kube, from a pod's service
account or a kubeconfig alike. internal/kubesource watches them with dynamic
informers, converts and validates each object on its own, and publishes an
immutable snapshot. A bad object is skipped and keeps its last good version,
so one broken edit cannot remove a running policy; an invalid merged config is
dropped whole. The last snapshot is saved so a restart without apiserver access
still serves.

config.Store gains an overlay: Base() is what the YAML says, Get() is base plus
cluster objects. Everything that writes the config back (mutateConfig,
SetAdminAddr, the settings editor) now starts from Base(), otherwise cluster
objects would be copied into the file and shadow their own objects. YAML wins
every name collision. putACL and toggleBlocklist refuse to edit cluster-owned
objects. BlockList has no path field and refuses file:// URLs so a namespaced
object cannot pick files the runtime reads or writes. DNSZone is rendered
through the zone writer's own prove-by-reparse step, with the SOA serial taken
from metadata.generation so every replica renders identical bytes.

qlog: Flush reset the pending slice to [:0], so entries logged while it was
still storing the batch were appended over the same backing array. The race
detector found it through the new Kubernetes integration test. pending is now
set to nil, with a regression test that fails without the change.

CRD manifests, RBAC and examples are in deploy/k8s/crds; tests pin the schemas
to the Go spec structs so the two cannot drift.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-16 08:35:36 +02:00
1 parent 340b4eaf80
commit 4d38945d94
27 files changed
+2250 -19

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
# The fancy-dns custom resources. Cluster-scoped definitions, namespaced
# objects. The runtime (`fancy-dns -kube`) watches them read-only.
#
# Field names are snake_case where they mirror config.yaml (allow_domains,
# max_concurrent, ...), so a group copied out of the YAML pastes in unchanged.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: dnszones.fancydns.k6n.net
spec:
group: fancydns.k6n.net
scope: Namespaced
names: {kind: DNSZone, listKind: DNSZoneList, plural: dnszones, singular: dnszone, shortNames: [dnsz]}
versions:
- name: v1alpha1
served: true
storage: true
additionalPrinterColumns:
- {name: Zone, type: string, jsonPath: .spec.zone}
- {name: Age, type: date, jsonPath: .metadata.creationTimestamp}
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [zone]
properties:
zone: {type: string, minLength: 3, maxLength: 253, description: "Zone apex, e.g. home.example.com"}
ttl: {type: integer, minimum: 1, maximum: 604800, description: "Zone $TTL and SOA minimum (seconds)"}
soa: {type: string, maxLength: 512, description: "SOA rdata; omit to synthesise one from the apex NS"}
records:
type: array
maxItems: 5000
items:
type: object
required: [name, type, value]
properties:
name: {type: string, minLength: 1, maxLength: 253, description: "Owner; @ is the apex, a name without a trailing dot is relative to the zone"}
type: {type: string, enum: [A, AAAA, CAA, CNAME, DHCID, HINFO, HTTPS, LOC, MX, NAPTR, NS, PTR, SRV, SPF, SSHFP, SVCB, TXT, URI]}
ttl: {type: integer, minimum: 1, maximum: 604800}
value: {type: string, minLength: 1, maxLength: 4096, description: "Presentation-format RDATA"}
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: clientgroups.fancydns.k6n.net
spec:
group: fancydns.k6n.net
scope: Namespaced
names: {kind: ClientGroup, listKind: ClientGroupList, plural: clientgroups, singular: clientgroup, shortNames: [cg]}
versions:
- name: v1alpha1
served: true
storage: true
additionalPrinterColumns:
- {name: Policy, type: string, jsonPath: .spec.policy}
- {name: Priority, type: integer, jsonPath: .spec.priority}
- {name: Age, type: date, jsonPath: .metadata.creationTimestamp}
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [policy, match]
properties:
priority: {type: integer, default: 0, description: "Groups are first-match-wins; lower runs first, ties break on name. YAML groups always run before these."}
comment: {type: string, maxLength: 512}
policy: {type: string, enum: [allow, block_only, deny]}
match:
type: object
properties:
cidrs: {type: array, items: {type: string}}
ptr_suffixes: {type: array, items: {type: string}}
tags: {type: array, items: {type: string}}
any: {type: boolean}
allow_domains: {type: array, items: {type: string}}
deny_domains: {type: array, items: {type: string}}
skip_blocklist: {type: boolean}
skip_cache: {type: boolean}
rate_limit: {type: integer, minimum: 0}
max_concurrent: {type: integer, minimum: 0}
log_queries: {type: boolean}
upstreams: {type: array, items: {type: string}}
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: blocklists.fancydns.k6n.net
spec:
group: fancydns.k6n.net
scope: Namespaced
names: {kind: BlockList, listKind: BlockListList, plural: blocklists, singular: blocklist, shortNames: [bl]}
versions:
- name: v1alpha1
served: true
storage: true
additionalPrinterColumns:
- {name: URL, type: string, jsonPath: .spec.url}
- {name: Format, type: string, jsonPath: .spec.format}
- {name: Disabled, type: boolean, jsonPath: .spec.disabled}
- {name: Age, type: date, jsonPath: .metadata.creationTimestamp}
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [url]
properties:
url: {type: string, pattern: '^https?://', description: "http(s) only; file paths are not accepted from the cluster"}
format: {type: string, enum: [auto, hosts, adblock, plain, rpz]}
mirror: {type: array, items: {type: string, pattern: '^https?://'}}
disabled: {type: boolean}
refresh: {type: string, description: "Go duration, e.g. 12h (default 24h)"}
timeout: {type: string, description: "Go duration, e.g. 30s"}
max_bytes: {type: integer, minimum: 1}
categories: {type: array, items: {type: string}}
comment: {type: string, maxLength: 512}
insecure_skip_tls_verify: {type: boolean}
+36
View File
@@ -0,0 +1,36 @@
apiVersion: fancydns.k6n.net/v1alpha1
kind: DNSZone
metadata:
name: home
namespace: dns
spec:
zone: home.example.com
ttl: 300
records:
- {name: "@", type: NS, value: ns1.home.example.com.}
- {name: ns1, type: A, value: 10.0.0.53}
- {name: nas, type: A, value: 10.0.0.20}
- {name: "*.apps", type: A, value: 10.0.0.30}
---
apiVersion: fancydns.k6n.net/v1alpha1
kind: ClientGroup
metadata:
name: kids
namespace: dns
spec:
priority: 10
policy: block_only
match:
cidrs: [10.0.20.0/24]
deny_domains: ["**.tiktok.com"]
---
apiVersion: fancydns.k6n.net/v1alpha1
kind: BlockList
metadata:
name: stevenblack
namespace: dns
spec:
url: https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
format: hosts
refresh: 24h
categories: [ads, malware]
+8
View File
@@ -0,0 +1,8 @@
# Apply the CRDs before anything that uses them:
# kubectl apply -f deploy/k8s/crds/crds.yaml
# kubectl apply -k deploy/k8s/crds # RBAC for the runtime
# examples.yaml is deliberately not included.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- rbac.yaml
+26
View File
@@ -0,0 +1,26 @@
# Read-only access to the fancy-dns objects in one namespace, for the existing
# `fancy-dns` service account. For a runtime outside the cluster, bind the same
# Role (or a ClusterRole for -kube-namespace="") to the user in its kubeconfig.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: fancy-dns-objects
namespace: dns
rules:
- apiGroups: [fancydns.k6n.net]
resources: [dnszones, clientgroups, blocklists]
verbs: [get, list, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: fancy-dns-objects
namespace: dns
subjects:
- kind: ServiceAccount
name: fancy-dns
namespace: dns
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: fancy-dns-objects