kudurru architecture

StatusAccepted baseline for v0.1 (2026-10-10); amend through the decision log in §16
Revisioncompleteness/factual critic pass 2026-10-10: keepalived build source, leader-election lifecycle, PROXYv2 trust, internal-listener TLS, secrets-at-rest, session payload, licence of embedded list data, missing packages/interfaces; new decisions D44-D48, new open questions Q17-Q21
Date2026-10-10
EvidenceResearch notes in docs/research/, cited inline as [gravity §18.1], [k8s §13] etc. Key: gravity, technitium, pihole, blocking (blocking-engines.md), dnsdist (dnsdist-keepalived.md), k8s (k8s-native-dns.md), auth (auth-oidc-rbac.md), integrations, go-dns-stack, observability (observability-querylog.md). Facts the notes mark UNVERIFIED are week-1 spikes, never assumptions
ProvenanceSynthesised from three competing proposals (Kubernetes-first, single-binary-first, MVP-and-risk-first) scored by three judges (SRE, tech lead, product owner). The decision log in §16 names those proposals when it records a rejected alternative
LicenceApache-2.0
Modulegithub.com/robertherbaugh/kudurru — confirmed by the repo's origin remote (https://github.com/robertherbaugh/kudurru); ratified 2026-10-10 (D49)
Images / chartsghcr.io/robertherbaugh/kudurru, ghcr.io/robertherbaugh/kudurru-dnsdist, ghcr.io/robertherbaugh/kudurru-keepalived, oci://ghcr.io/robertherbaugh/charts/{kudurru,kudurru-crds} (assumption, follows the module path)
CRD groupkudurru.io (confirmed by the project owner 2026-10-10, D49)

Thesis. kudurru is one Apache-2.0 Go binary that is a complete DNS server (authoritative with real serials, transfers and, later, DNSSEC; forwarding resolver with a node-local cache; blocky/AdGuard-grade blocking with client groups; Pi-hole-grade dashboard) and that is a first-class Kubernetes citizen and a docker run appliance without being two products. The object model is the product; persistence sits behind one Store interface with two drivers at v0.1 (Kubernetes CRDs via the apiserver; embedded SQLite) and zero consensus code, with follower (v0.2) and raft (v1.0) drivers arriving behind the same interface. dnsdist (edge/load balancing) and keepalived (VIP) are GPL siblings driven by a kudurru supervisor process inside their own images, never linked. Every deferral names the seam that keeps it additive.

The boundary-stone metaphor is the data model: a DNSZone is a recorded grant of authority (the stone kept in the temple = the store that is authoritative for configuration); clay copies are distributed by standard DNS (AXFR/IXFR, NOTIFY, TSIG, catalog zones) and by replication of the store itself.



1. Vision & principles

kudurru should feel to a platform engineer the way cert-manager or external-dns feel (install a chart, apply a few CRs, watch kubectl get zones turn Ready) and to a home-lab user the way Pi-hole feels (one container, one dashboard that explains every answer), with Technitium's zone depth and blocky/AdGuard's blocking underneath. Principles — each one is a rule used in a later section:

  1. Correct before complete. v0.1 ships fewer zone types and no DNSSEC signing rather than wrong serials, wrong CNAME handling, or NXDOMAIN from an unsynced source [gravity §18.1, k8s §2.2].
  2. One object model, one Store interface, zero consensus code in v0.1. In Kubernetes the apiserver is the replicated store (CRDs are the truth, Deployment, no PVC for config) [k8s §13 model 1, §16.2]; in Docker it is embedded SQLite. follower (v0.2) and raft (v1.0) drivers arrive behind the same interface [go-dns-stack §6.3].
  3. Exactly one writer per object. metadata.managedBy (api | file:<path> | integration:<name> | bootstrap | system; in Kubernetes the apiserver's field manager) makes "two sources of truth" structurally impossible [k8s §16.1, gravity §18.2].
  4. Serve from memory; degrade, never stop. The query path reads lock-free snapshots, never the store; replicas keep answering from the last good snapshot (persisted to local disk) when the store is unreachable; before the first sync they answer SERVFAIL, never NXDOMAIN [k8s §8, gravity §4.4].
  5. Replicate configuration; never caches, logs or heartbeats. Query logs are per-node SQLite with scatter-gather views; metrics go to Prometheus; node status is scatter-gathered, not written anywhere [gravity §18.1/§18.8, observability §5].
  6. Fixed pipeline, named stages. Stage order is code, not config; integrations attach to named stages and never run on the query path [technitium §3, integrations §1.2].
  7. Records are external-dns Endpoints (a superset). DNSEndpoint ingestion, the external-dns webhook, the Cloudflare sink and the CRD spec share one shape [k8s §3.2, integrations §1.6].
  8. Leader-only side effects. Status writes, serial stamping, blocklist fetch, NOTIFY, DNSSEC signing, ACME and integrations run on exactly one node (Lease / self / raft leader); serving runs on every node [k8s §8].
  9. GPL stays outside the binary. dnsdist and keepalived are separate processes driven by rendered files, the dnsdist HTTP API, the console binary, signals, track_file and the notify FIFO; Technitium, AdGuard Home, Gravity and Pi-hole are idea sources only; blocky, dnsproxy and CoreDNS are the only upstreams whose code is copied or imported [blocking §1.1, dnsdist §4].
  10. Secure by default, lessons from the 2026 CVEs of the comparables: no code-bearing or privileged setting reachable through the API (hostOnly keys), hashed tokens with constant-time compare, Argon2id with server-fixed parameters, sealed HttpOnly cookies, bounded metric labels, probes on an unauthenticated ops port, Restricted PSS out of the box [pihole §17.7, technitium §21.10, auth §10].
  11. Multi-node from day one in the UI. Node selector + "all nodes" on every stat, "which node answered" on every log row, even with one node [pihole §17.2].
  12. Every deferral has a seam. §13.1 lists, per deferred feature, the interface or schema field that already exists in v0.1.

2. Language, stack, key libraries

Go, because every reusable component (CoreDNS, blocky, dnsproxy, external-dns, controller-runtime) is Go and shares miekg/dns v1 types; Rust's hickory-dns has an experimental recursor and no IXFR [go-dns-stack §1]. Versions are the ones verified in the research on 2026-10-10.

ConcernChoiceVersionLicenceNotes
ToolchainGo1.27 (go 1.27 in go.mod, toolchain pinned to latest 1.27.x; CI on 1.27 + tip)BSD-31.26/1.27 are the supported majors; miekg v2 needs 1.27 anyway [go-dns-stack §13]
DNS wiregithub.com/miekg/dnsv1.1.73BSD-3fixes-only line; confined to dnsserver, zone/xfr, upstream, dnssec behind zone.RRset so a codeberg.org/miekg/dns v2 migration is contained [go-dns-stack §2.4]
Upstream clientsgithub.com/AdguardTeam/dnsproxy/upstreamv0.86.0Apache-2.0UDP/TCP/DoT/DoH(1.1/2/3)/DoQ/DNSCrypt/sdns + bootstrap; strategies are ours [blocking §6.5]
QUIC/HTTP3github.com/quic-go/quic-gov0.63.0MITDoQ/DoH3 servers patterned on CoreDNS server_quic.go/server_https3.go (v0.1, D67)
Matchers, list pipeline, cache ideascopied from github.com/0xERR0R/blockyv0.35.0Apache-2.0cache/stringcache, lists/, caching ideas; NOTICE attribution [blocking §7]
Adblock-DNS grammarclean-room parser from the AdGuard KBn/a—urlfilter/AdGuard Home are GPL-3.0 and are never imported [blocking §1.1]
Embedded store, local snapshot, query log, auditmodernc.org/sqlitev1.54.0 (SQLite 3.53.3, cgo-free)BSD-3one engine for state.db, local.db, querylog.db; distroless-static friendly [go-dns-stack §6.2]
Replication (v1.0)github.com/hashicorp/raft + raft-boltdb/v2v1.8.0 / v2.3.1MPL-2.0unmodified dependencies; SQLite FSM; single-voter bootstrap (rqlite model); deferred to v1.0 (§4.4)
Kubernetesk8s.io/client-go, sigs.k8s.io/controller-runtime, k8s.io/apiextensions-apiserver (schema + CEL validator packages), controller-genv0.37.1 / v0.25.xApache-2.0Lease election, NeedLeaderElection() runnable split [k8s §7.4]; the apiextensions validator runs locally in Docker mode [k8s §13]
CELgithub.com/google/cel-go, pinned to the version k8s.io/apiextensions-apiserver v0.37.1 imports so the binary carries one CEL copy; bumped only with the Kubernetes minor (D74). The cel-expr/cel-go path move noted in the auth research is not used by apiextensions yetv0.29.2Apache-2.0CRD x-kubernetes-validations in Docker mode; OIDC claim expressions (v0.2)
Configgithub.com/knadh/koanf/v2v2.3.xMITfile + env (KUDURRU_<SECTION>__<KEY>) + flags; no key lowercasing [go-dns-stack §8]
CLIgithub.com/spf13/cobrav1.10.2Apache-2.0kubectl-style get/apply/delete -f
HTTP API / OpenAPIgithub.com/danielgtaylor/huma/v2 on Go 1.22+ net/http muxv2.38.0MITcode-first OpenAPI 3.1; TS client via openapi-typescript; Go client reused by the CLI
OIDCgithub.com/coreos/go-oidc/v3 + golang.org/x/oauth2v3.21.0 / v0.37.0Apache-2.0 / BSD-3discovery, JWKS, PKCE, device flow [auth §3]
Passwordsgolang.org/x/crypto/argon2currentBSD-3Argon2id m=19 MiB, t=2, p=1, PHC strings [auth §7]
Sessionssealed cookies (AES-256-GCM) behind a SessionStore interface; db implementation on SQLite (v0.2)——§8.2
Metrics / tracing / logsprometheus/client_golang, OTel Go SDK, log/slogv1.24.1 / v1.45.0 / stdlibApache-2.0bounded-label policy [observability §3.2]; no logrus/zap
dnstap (v0.2)github.com/dnstap/golang-dnstapv0.4.0Apache-2.0SocketWriter, listen mode
TLS/ACME (v1.0)github.com/caddyserver/certmagicv0.25.4Apache-2.0DNS-01 against kudurru itself, leader-only
Cloudflare (v0.2)github.com/cloudflare/cloudflare-go/v7exact pin 7.12.0Apache-2.0thin wrapper; minors break [integrations §2.2]
UniFi (v0.2)github.com/unpoller/unifi/v66.3.1MITofficial Integration API + legacy read paths [integrations §3.4]
Filesgithub.com/fsnotify/fsnotifyv1.9.0BSD-3config, object dir, cert reload
Releasegoreleaser kos: + ko + cosign keyless2.18 / 0.19.1 / 3.1.3—gcr.io/distroless/static:nonroot pinned by digest; CGO_ENABLED=0 is a release requirement [go-dns-stack §12]
FrontendBun + React 19.2 + TypeScript + Astryx (@astryxdesign/core, @astryxdesign/theme-*; Meta's design system, Beta, pinned exactly) + TanStack Query (+ Table/Virtual for the query log) + uPlot 1.6.32 + ECharts 6.1—MITpure SPA via go:embed; information architecture from Pi-hole; stack per D69 (Astryx charts adopted once stable)
Edge (separate containers)powerdns/dnsdist-21 (2.1.2), keepalived 2.4.3 compiled from the upstream release tarball in an Alpine builder stage (Alpine's apk package is still 2.3.4 [dnsdist §2.1] and lacks auth_hmac, which the rendered config needs)pinned by digestGPL-2.0 / GPL-2.0+driven by kudurru edge run / kudurru vip run supervisors (§3.6); building from source means the image README must carry the GPL text and a written source offer (tarball + build script kept in images/keepalived/)

Explicitly not used: embedded etcd (1 GB floor, bespoke join, fsync-sensitive [go-dns-stack §6.2, gravity §16]), dragonboat, marmot, Litestream-as-replication, DuckDB/mattn (cgo), urlfilter/AdGuard Home packages (GPL), go-plugin as the primary extension API, Casbin/OpenFGA as the primary authorizer, viper, logrus/zap, a bespoke dnsdist console client (we exec the dnsdist binary).


3. Component architecture

3.1 One binary, roles, runnables

kudurru runs a set of roles selected by config (roles: [dns, api, controller, querylog, ops], Gravity-style [gravity §17.1]) scheduled by a controller-runtime-shaped manager where each runnable declares whether it is always-on (every replica) or leader-only [k8s §8]. Every role implements Ready() (bool, []string) and feeds /healthz/ready.

RunnableLeader-only?What it does
dnsserver (UDP/TCP/DoT/DoH/DoQ/DoH3; PROXYv2)noserves queries through the pipeline
zonecache (store watch → in-memory trees/indexes; local snapshot persistence)nomaterialises zones/records/policies/lists from the store; writes local.db snapshot; readiness = all watches synced once or serving from a fresh-enough snapshot (§4.4)
api (HTTP API + embedded UI + SSE)noany replica serves any user (sealed sessions, §8.2)
querylog (rings → SQLite → rollups → sinks)noper node
ops (:8081/healthz/*) + metrics (:9153/metrics)nounauthenticated ops listeners, never on the DNS or API port
internal (:8082, node-to-node: list snapshots, status, events)noalways TLS: node certificate and the bearer token are HKDF-derived from the cluster encryption key (KUDURRU_ENCRYPTION_KEY / kudurru.io/session-key Secret) unless tokenFile/tls are set explicitly, so every node sharing the key trusts every other and the chart needs no extra Secret; used by replicas, edge supervisors and scatter-gather; v0.2 /internal/snapshot carries internal kinds and therefore never runs without TLS
controller (one reconciler per kind; status, serial stamping, duplicate detection, Events)yeswrites status.conditions, zone serials
listfetcher (blocklist download + digest publication)yesconditional GET, disk cache, digest in status; replicas pull by digest (§7.3)
notifier (NOTIFY fan-out; XFR scheduling for secondaries v0.2)yescoalesced NOTIFY per zone
signer (DNSSEC, v1.0)yessigns on the leader; signed RRsets replicate by IXFR (§4.6)
integrations (Cloudflare, UniFi, webhook; v0.2)yes, per instance (Lease integration-<ns>-<name>)level-triggered reconcile loops off the query path
acme (v1.0)yesDNS-01 against kudurru's own zones
edge run / vip runn/asupervisors inside the dnsdist / keepalived images (§3.6)

store.Elector is backed by a Kubernetes Lease (kube driver), "always leader" (sqlite driver), "never leader" (follower driver, v0.2) and raft leadership (v1.0).

3.2 Diagram

                      clients (Do53 / DoT / DoH / DoQ / DoH3)
                                   │
            ┌──────────────────────▼───────────────────────┐   optional edge tier (GPL, separate container)
            │ dnsdist 2.1  :53/:853/:443  packet cache      │   PID 1 = `kudurru edge run` (render → check → exec)
            │ pools: resolver(chashed) authoritative(leastOutstanding)  DynBlocks (eBPF opt-in) │
            └──────────────────────┬───────────────────────┘
                     PROXYv2 (client IP preserved)  → :5353 (or :53 when no edge)
┌───────────────────────────────────▼─────────────────────────────────────────────────┐
│ kudurru node (every replica)                                                         │
│  dnsserver ─▶ PIPELINE  ingress ▸ identify ▸ control ▸ local ▸ authoritative ▸ blocking │
│                      ▸ rewrite ▸ cache ▸ dnssec-validate ▸ upstream ▸ postprocess ▸ log │
│       ▲ reads lock-free snapshots (atomic.Pointer) of:                                   │
│       │   zone trees │ policy/clientgroup index │ compiled blocklists │ upstream groups   │
│  zonecache ◀── Store.Watch(kind…) ──────────────┐  ──▶ local.db (last-good snapshot)      │
│  api (huma, UI, SSE)  querylog (ring→SQLite→rollups→sinks)  ops/metrics  internal :8082 │
│  controller / listfetcher / notifier / signer / integrations / acme  [leader-only] ─┼─┐ │
└──────────────────────────────────────────────────────────────────────────────────────┼─┼─┘
   STORE (config plane)   kube driver: CRDs via apiserver (replicated by k8s etcd)  ◀──┘ │
                          sqlite driver: /var/lib/kudurru/state.db (docker)        ◀────┘
                          follower (v0.2): sqlite mirror pulled from a primary; raft (v1.0): sqlite FSM
   RUNTIME (per node)     querylog.db, local.db (snapshot, audit, cache dump), lists/, keys/
   VIP (bare metal)       keepalived (VRRPv3) with PID 1 = `kudurru vip run`; in k8s: kube-vip / MetalLB / cloud LB

3.3 Dataplane packages

  • internal/dnsserver: listeners on dns.Server (UDP/TCP with ReusePort, N sockets = GOMAXPROCS like CoreDNS multisocket), tcp-tls for DoT, net/http for DoH h1.1/h2, PROXYv2 parser restricted to listeners.proxyProtocolFrom (hard requirement because dnsdist fronts us [dnsdist §1.13]) and to headers carrying TLV 0xE0 equal to the shared listeners.proxyProtocolSecretFile value (32 random bytes; dnsdist adds it with SetAdditionalProxyProtocolValueAction, the supervisor renders it). Source-CIDR trust alone is spoofable by any host in the trusted range (any pod in the pod CIDR, any LAN host on a /24), which would let an attacker forge the client address that queryACL/transferACL/client-group policies and the query log rely on; headers without a matching TLV are dropped and counted in kudurru_dns_dropped_total{reason="proxy_unauthenticated"}. Loopback (127.0.0.1/32, ::1/128) is exempt when the secret is unset but must still be listed. Semantics pinned by the spike (D72, D76): headers are optional and a header-less query is attributed to its transport peer; LOCAL and AF_UNSPEC headers need the same authentication and keep the peer address; the TLV value is the secret file's bytes with trailing CR/LF trimmed, at least 32 bytes; CIDR entries in proxyProtocolFrom are accepted only when proxyProtocolSecretFile is set (pod IPs are dynamic; the TLV authenticates, the CIDR only narrows exposure), exact /32s otherwise. Transport limits: MaxTCPQueries unlimited with a 10 s idle timeout (miekg's default closed TCP/DoT after 128 queries), UDP read buffer 4096 B plus 2 KiB when PROXYv2 is on (the 512 B default silently dropped large EDNS queries), DoH body cap 65,535 B and headers 16 KiB, DoQ grants no unidirectional streams, QUIC connection cap configurable. All four TLS listeners share one certificate reloader per cert/key pair (fsnotify plus a 30 s poll); a half-rotated pair keeps serving the previous certificate. DoQ/DoH3 (v0.1, D67) copy CoreDNS/dnsproxy server code (ALPN doq/h3, 256 streams, 1024 workers, RFC 9250 error codes; never reuse a fixed DNS ID when bridging DoQ→UDP [go-dns-stack §4.1, §17.9]).
  • internal/pipeline: the fixed stage chain (§7.1). Stages are packages under internal/stages/: ingress, identify, control, local, authoritative, blocking, rewrite, cache, dnssec, upstream, postprocess, log.
// internal/pipeline/stage.go
type Stage interface {
    Name() string                                   // fixed, documented stage id
    Serve(ctx *Request, next Next) (*Response, error)
}
type Next func(*Request) (*Response, error)
type Request struct {
    Ctx           context.Context
    Msg           *dns.Msg       // miekg only here and in dnsserver/xfr/upstream/dnssec
    Client        Client         // addr, proto, proxied-from, clientID (SNI/DoH path), MACs, GroupIDs (identify),
                                 // TSIGKey (set only after the transport verified the signature)
    Received, Deadline time.Time
    Zone          any            // *zone.Zone, set by authoritative when a zone matched
    UpstreamGroup string         // forward zone or Policy group for the upstream stage; beats the conditional map, never cached
    Policy        any            // *policy.Decision from identify (read with policy.FromRequest; shared, immutable)
    // Trace TraceInfo           (v0.2, with OTel)
}
type Response struct {
    Msg        *dns.Msg
    Status     Status              // taxonomy in §11.2 (forwarded, cached, authoritative, blocked_list, …)
    Upstream   string              // dictionary name, never a raw IP
    List, Rule string              // matched list and rule text (the UI "why" row)
    Cacheable  bool                // local/conditional answers are never cached (blocky rule)
    Transfer   iter.Seq[*dns.Msg]  // AXFR/IXFR stream; TCP/DoT write every message, Msg is the first
}

Optional capability interfaces discovered by type assertion (CoreDNS style): Ready() bool, Transfer(zone string, serial uint32) (<-chan []dns.RR, error), Reload(snapshot any).

  • internal/zone: the record model (§4.1), per-zone in-memory tree (CoreDNS plugin/file/tree red-black tree, Apache-2.0), wildcard lookup, CNAME chasing (loop cap 20), SOA/serial management, journal for IXFR, AXFR/IXFR out via dns.Transfer, coalesced NOTIFY out; secondary-in (v0.2) copies the CoreDNS file/secondary.go flow [go-dns-stack §5].
  • internal/blocking: compiled matcher chain (§7.3), clean-room adblock parser, snapshot compile.
  • internal/cache: sharded TTL message cache with min/max/negative TTL, prefetch (threshold/window), serve-stale (RFC 8767: stale TTL 3 d, stale answer TTL 30 s, max wait 1.8 s — Technitium numbers), in-flight coalescing, OPT stripped, DO-aware keys; persisted to local.db on shutdown [technitium §20.8, blocking §6.5].
  • internal/upstream: dnsproxy upstream groups with strategies parallel_best (default), strict, random, rtt_weighted; conditional forwarding by longest suffix; bootstrap list; dnsmasq [/domain/] import syntax.

3.4 Control-plane packages

  • api/v1alpha1: Go types for every kind with kubebuilder markers. The same structs are what the sqlite driver persists (JSON) and what the API/CLI/UI exchange. CRD OpenAPI + CEL validation is executed locally in Docker mode with the apiextensions validator so both modes reject the same inputs [k8s §13].
  • internal/store: the Store interface (§4.3), drivers kube, sqlite, follower (v0.2), raft (v1.0), the shared validator, the schema version/migration contract, and the conformance suite every driver must pass.
  • internal/controller: one reconciler per kind (never a mega-controller), idempotent, level-triggered, writing metav1.Conditions + observedGeneration; finalizers for external cleanup; Events on every operational transition (SerialBumped, NotifyFailed, ListFetchFailed, PublishConflict, Duplicated).
  • internal/config: koanf loader, JSON Schema, and the settings metadata table (schema.go: key, type, default, help, restartRequired, envLocked, writeOnly, hostOnly) that drives file, env, kudurru config, PATCH /node/config, the "All settings" page and the docs page; a test asserts docs cannot drift.
  • internal/auth, internal/rbac: §8. internal/rbac/permissions.yaml is the single source for roles, aggregated ClusterRoles, docs and the route-permission lint.
  • internal/api: huma routes under /api/v1, uniform envelope (took_ms, {error:{key,message,hint}}), 201+Location, 204 on delete, :batchDelete, keyset pagination, SSE; Scalar docs at /api/docs at the running version.
  • internal/integration: registry, manifest, lifecycle, leader-scoped runner, plan engine, ledger (§9).
  • internal/edge/{dnsdist,keepalived}: renderers + supervisors (§3.6).
  • internal/cluster: node identity, membership source abstraction (static | endpointslice | follower | raft), scatter-gather client, internal listener, promote/fencing (v0.2).
  • internal/querylog, internal/audit, internal/metrics, internal/health: §11.

3.5 UI and CLI

  • UI: SPA at /, talks only to /api/v1; node selector and "all nodes" scope first-class from v0.1 even with one node. Pages in §10.
  • CLI kudurru (seven letters): serve, login, get|apply|delete|describe -f, zones, records, lists, blocking enable|disable 5m, query (dig-like), health [--vrrp], lameduck, backup|restore|export, import bind|pihole|adguard|blocky|technitium, edge render|run, vip render|run, cluster status|promote|join-token (v0.2+), integrations list|check|sync|preview|webhook conformance (v0.2), admin reset-password|create-token (unix socket, uid-gated), config get|set|schema, doctor, api <path>, version, completion. All commands use the generated OpenAPI client except serve, admin, doctor and the supervisors. --context picks a kubeconfig (CRD verbs go straight to the apiserver) or a kudurru API endpoint.

3.6 dnsdist and keepalived: supervisors, not sidecars, not shell loops

Both tools are GPL-2.0 and must remain separate programs [dnsdist §4]. The dnsdist REST API cannot manage backends, YAML has no reload, and membership changes go through the console (whose behaviour when dnsdist was started from YAML is UNVERIFIED) [dnsdist §1.11, §1.12, §1.16, §7]. The lowest-risk orchestration is a kudurru supervisor process that is PID 1 in the same image as the tool (mere aggregation: two separate executables, GPL text and source offer shipped in the image README).

ghcr.io/robertherbaugh/kudurru-dnsdist:<dnsdist>-k<kudurru> = FROM powerdns/dnsdist-21@sha256:<digest> + /usr/local/bin/kudurru. Entrypoint kudurru edge run:

  1. Build a Topology{Binds, TLS, Pools, Backends[], Cache, Console, Webserver, Rules, DynBlocks} from flags/config and the membership source: --backends 10.0.0.11:5353,… (static, compose), --backends-from endpointslice:<ns>/<headless-svc> (Kubernetes), --backends-from cluster (follower/raft, v0.2+). Backend id = uuid5(kudurru-node-name) so the chashed ring is stable; use_proxy_protocol: true; health check health.kudurru.internal. TXT with must_resolve: true and a 2.1 validation_function checking the node id (interval 1, max_failures 2, rise 2).
  2. Render dnsdist.yml (every backends[], binds[], pools[] resolver (chashed, packet cache) and authoritative (leastOutstanding, uncached by default; edge.authoritativeCache enables a short max_ttl with flush-on-serial-change), query_rules refusing XFR/NOTIFY/UPDATE from unknown sources, dynamic_rules, webserver/console loopback-only with credentials generated per start; the API key is also written to /run/kudurru/dnsdist-api.key (0640, group keepalived_script) on the shared kudurru-run volume so kudurru health --vrrp in the keepalived container and the local kudurru's /edge page can read it) plus dnsdist.lua with functions only (never enableLuaConfiguration()) [dnsdist §6.5].
  3. dnsdist --check-config -C candidate.yml; atomic rename; exec dnsdist --supervised --disable-syslog -C /etc/dnsdist/dnsdist.yml as a child; forward SIGTERM; expose :8084/healthz/ready = GET /api/v1/servers/localhost (X-API-Key) reports ≥ 1 backend up — so Kubernetes probes the supervisor, not the API key.
  4. On membership change — v0.1 baseline: regenerate YAML and restart the child (readiness 503 → drain → sub-second restart; invisible behind a VIP/LB with ≥ 2 edge replicas; the packet cache is lost on every restart) [dnsdist §1.16 option 3]. Membership events are debounced 5 s and only endpoints with conditions.ready (or serving during a rollout) are rendered, so a kudurru Deployment rollout restarts each edge replica once per surge batch rather than once per pod; a lone edge replica (compose edge profile, single-node k8s) accepts the ~0.5 s blip, which keepalived's fall 3 does not count as a VIP failure. v0.2, gated on the week-1 spike: dnsdist -C dnsdist.yml -e 'newServer({...})' / getServer("<uuid>"):setDown() → 5 s purge delay → rmServer("<uuid>"), then regenerate YAML so a restart converges [dnsdist §1.16 option 1]. If the spike fails, the supervisor renders Lua (dnsdist.conf) instead — both renderers share Topology.
  5. Cert change (fsnotify) → v0.1 restart; v0.2 reloadAllCertificates() over the console. Zone serial change (polled from GET http://<backend>:8082/internal/zones every 5 s; SSE in v0.2) → DELETE /api/v1/cache?pool=authoritative&suffix=<zone> when the authoritative pool is cached. UI "block this client at the edge for 10 min" → Lua registerWebHandler('/kudurru/dynblock') → addDynamicBlock (v0.2).

ghcr.io/robertherbaugh/kudurru-keepalived:<keepalived>-k<kudurru> = Alpine multi-stage build compiling keepalived 2.4.3 from the release tarball (upstream Dockerfile.in pattern: --enable-json, VRRP only, no LVS/BFD, user keepalived_script) + /usr/local/bin/kudurru. Alpine's package is 2.3.4 [dnsdist §2.1], which has no auth_hmac; if an operator forces a 2.3.x binary (vip.keepalived.legacy: true) the renderer drops auth_hmac, keeps check_unicast_src + unicast_fault_no_peer, and doctor warns that adverts are unauthenticated. Entrypoint kudurru vip run, full contract:

  • Render keepalived.conf: global_defs { router_id <node>; enable_script_security; script_user keepalived_script; vrrp_version 3; vrrp_garp_master_refresh 30; vrrp_notify_fifo /run/kudurru/vrrp.fifo; vrrp_notify_fifo_script "/usr/local/bin/kudurru vip fifo" }; per VIP a vrrp_instance with state BACKUP, nopreempt, deterministic priority 200 - 10*idx (never 255), advert_int 1, unicast_src_ip, unicast_peer {…}, unicast_fault_no_peer, check_unicast_src, auth_hmac { key 1 file:/run/secrets/vrrp_hmac_<vrid>; active_key 1; anti_replay monotonic }, virtual_ipaddress { <addr>/<len> dev <iface> }, track_script { chk_<vip> }, track_file { tf_<vip> }.
  • vrrp_script chk_<vip> = kudurru health --vrrp --dns <vip>:53 --qname health.kudurru.internal. --dnsdist-api http://127.0.0.1:8083 --dnsdist-api-key-file /run/kudurru/dnsdist-api.key --require-backends 1 (probes the VIP address on :53 udp and tcp while MASTER and loopback while BACKUP — a loopback-only check would pass with a misbound dnsdist [dnsdist §2.7] — and requires ≥ 1 healthy backend via the dnsdist API; interval 1 timeout 1 fall 3 rise 2 weight 0 → FAULT on failure; runs as keepalived_script under enable_script_security, so the binary path is root-owned and the key file is group-readable).
  • track_file tf_<vip> = /run/kudurru/weight-<vip>, weight -1: the local kudurru writes 0 (healthy serving member), 60 (prefer-not: lowers the effective priority so this node loses the next election), 300 (→ FAULT: must not hold the VIP) [dnsdist §2.6]. Because every instance runs nopreempt, a priority reduction alone never moves a VIP away from the current MASTER (a higher-priority BACKUP does not preempt by design); only FAULT does. Decision D51 (project owner, 2026-10-10): kudurru lameduck never touches the VIP. Lameduck makes the local kudurru fail dnsdist's backend health check, so the node drains out of every edge's backend pool while the VIP stays with it for as long as its own dnsdist is healthy with ≥ 1 backend. The VIP moves only on real failure (dnsdist down, no healthy backend, node loss, FAULT from track_script) or when an operator explicitly runs kudurru vip drain / maintenance mode, which writes 300. 60 is reserved for "not the leader / not a voter" hints that only matter when the VIP is next re-elected. The 2-VM e2e asserts three behaviours: lameduck keeps the VIP, vip drain moves it within fall/down-timer time, node loss moves it (spike (h), §14).
  • keepalived -t -f candidate.conf before swap; atomic rename, previous file kept; SIGHUP the child on re-render; stop_grace_period: 20s; the VRID is a hostOnly config value and never auto-allocated or changed on re-render.
  • FIFO consumer emits kudurru_vip_master{vip}, audit cluster.vip.changed, event vip.master_changed; never blocks.
  • Optional second VIP with reversed priorities = active/active across two resolver addresses. With nopreempt, after a failover and recovery both VIPs stay on the surviving node until kudurru vip rebalance (writes 300 for the VIP that should move, then 0) or the operator sets vip.preemptDelay: 60 on the second VIP instead of nopreempt.
  • Every node's dnsdist lists all kudurru nodes as backends, so VIP ownership follows dnsdist health, not kudurru health: a node whose kudurru is unhealthy but whose dnsdist is fine still serves [dnsdist §3.2].

Both supervisors ship in v0.1 (compose edge and ha-static profiles; Kubernetes edge Deployment behind edge.enabled). keepalived is never the default VIP in Kubernetes [k8s §16.14, dnsdist §6.12]. Alternative for operators who must run unmodified upstream images: edge.image.mode: upstream runs the agent as a sidecar sharing an emptyDir and converges by restart only (documented, non-default).

The VIP abstraction the risk table (§14 #4) refers to:

// internal/edge/vip/provider.go — implemented by keepalived (track_file + FIFO), kube-vip and MetalLB (Service status + annotations), none
type Provider interface {
    Name() string                                   // keepalived | kube-vip | metallb | none
    VIPs() []VIP                                    // {Name, Address, VRID?, Owner string, Weight int}
    Owner(vip string) (node string, ok bool)        // current MASTER / announcing node, from FIFO or Service status
    SetWeight(ctx context.Context, vip string, w Weight) error   // Weight = Serving(0) | PreferNot(60) | Fault(300); no-op for Service-based providers
    Events() <-chan VIPEvent                        // {VIP, Node, State MASTER|BACKUP|FAULT|STOP, Priority, At}
}

4. Data model, storage, replication, HA

4.1 Objects

All objects are api/v1alpha1 Go structs with metadata{name,namespace?,labels,annotations,uid,generation,resourceVersion,creationTimestamp,managedBy}, spec, status{observedGeneration,conditions[]}. Names are stable keys. Record data is the external-dns Endpoint shape plus a kudurru extension block.

// api/v1alpha1/dnszone_types.go (cluster-scoped)
type DNSZoneSpec struct {
    ZoneName     string         `json:"zoneName"`                 // immutable (CEL self==oldSelf), FQDN with trailing dot
    Type         ZoneType       `json:"type"`                     // primary | forward | secondary(v0.2) | stub(v0.2) | external(v0.2) | catalog(v1.0) | secondaryCatalog(v1.0)
    SOA          *SOASpec       `json:"soa,omitempty"`            // mname, rname, refresh, retry, expire, minimum; serial lives in status
    Nameservers  []string       `json:"nameservers,omitempty"`
    DefaultTTL   uint32         `json:"defaultTTL,omitempty"`     // 300
    Policy       ZonePolicy     `json:"policy"`                   // Technitium's six-way vocabulary [technitium §4.2]
    Forward      *ForwardSpec   `json:"forward,omitempty"`        // upstreamGroup, dnssecValidation
    Secondary    *SecondarySpec `json:"secondary,omitempty"`      // primaries[], tsigKeyRef, transferProtocol tcp|tls (v0.2)
    Stub         *StubSpec      `json:"stub,omitempty"`           // nameservers[] (v0.2)
    External     *ExternalSpec  `json:"external,omitempty"`       // ingress/gateway/loadbalancer sources (v0.2)
    DNSSEC       *DNSSECSpec    `json:"dnssec,omitempty"`         // enabled, algorithm, denial nsec|nsec3, keySecretRef, kskRollover, zskRolloverDays (v1.0; schema reserved)
    RecordSetSelector *RecordSetSelector `json:"recordSetSelector,omitempty"` // namespaces[]/labelSelector allowed to add DNSRecordSets
    Publish      []PublishRule  `json:"publish,omitempty"`        // split-horizon: integrationRef + selector + types + policy + defaults (v0.2)
}
type ZonePolicy struct {
    QueryAccess  AccessMode `json:"queryAccess"`     // deny|allow|allowOnlyPrivate|allowOnlyZoneNameServers|useNetworkACL|zoneNameServersAndACL
    QueryACL     []string   `json:"queryACL,omitempty"`      // "10.0.0.0/8", "!10.0.0.2": ordered, first match wins, default deny
    ZoneTransfer AccessMode `json:"zoneTransfer"`
    TransferACL  []string   `json:"transferACL,omitempty"`
    TransferTSIGKeyRefs []string `json:"transferTSIGKeyRefs,omitempty"`
    Notify       NotifyMode `json:"notify"`          // none|zoneNameServers|specified|both
    NotifyTargets []string  `json:"notifyTargets,omitempty"`
    Update       AccessMode `json:"update"`          // RFC 2136 (v0.2)
    UpdatePolicies []UpdatePolicy `json:"updatePolicies,omitempty"` // {tsigKeyRef, name, types[]}
}
type DNSZoneStatus struct {
    ObservedGeneration  int64              `json:"observedGeneration"`
    Serial              uint32             `json:"serial"`             // stamped by the leader = max(prev+1, unix_now); replicas serve this value
    ContentHash         string             `json:"contentHash"`        // sha256 of canonical RRsets; drives the serial-divergence panel
    RecordCount         int                `json:"recordCount"`
    NameserverAddresses []string           `json:"nameserverAddresses,omitempty"` // auto-detected LB IPs (k8s_gateway idea)
    DNSSEC              *DNSSECStatus      `json:"dnssec,omitempty"`   // ds[], keys[]{id,type,state}, nextResign (v1.0)
    Published           []PublicationStatus `json:"published,omitempty"` // per integration: managed, drift, conflicts, lastSync (v0.2)
    Conditions          []metav1.Condition `json:"conditions"`        // Ready, Transferred, Signed, Published, Degraded
}

// api/v1alpha1/dnsrecordset_types.go (namespaced; the "clay copies" app teams own)
type DNSRecordSetSpec struct {
    ZoneRef   ObjectRef  `json:"zoneRef"`                 // immutable
    Endpoints []Endpoint `json:"endpoints"`               // ≤ 1000 per object (CRD size ceiling), many objects per zone
}
type Endpoint struct {                                    // superset of sigs.k8s.io/external-dns/endpoint.Endpoint; ToExternalDNS() drops Kudurru
    DNSName          string             `json:"dnsName"`
    RecordType       string             `json:"recordType"`   // A AAAA CNAME DNAME MX NAPTR NS PTR SRV TLSA TXT CAA HTTPS SVCB SSHFP + TYPEnnn (RFC 3597)
    Targets          []string           `json:"targets"`      // presentation-format rdata
    RecordTTL        int64              `json:"recordTTL,omitempty"`
    SetIdentifier    string             `json:"setIdentifier,omitempty"`
    Labels           map[string]string  `json:"labels,omitempty"`        // e.g. kudurru.io/publish: cloudflare
    ProviderSpecific []ProviderSpecific `json:"providerSpecific,omitempty"` // cloudflare/proxied, cloudflare/targets …
    Kudurru          *EndpointExt       `json:"kudurru,omitempty"`       // comment, disabled, expiresAt, view (public | <ClientGroup>) (view v0.2)
}

Other kinds (abbreviated; full schemas are generated into charts/kudurru-crds):

Kind (scope)Spec essentialsStatusCRD
BlockList (ns)sources[]{url|inline|configMapRef, format auto|hosts|domains|adblock|regex, subdomains, enabled}, mode block|allow, refreshInterval, response nxdomain|zeroIP|refused|customIP{v4,v6}Loaded{entries,ruleCount,invalid,etag,lastFetched}, sources[].digest, Readyv0.1
ClientGroup (ns)clients[]{ip|cidr|mac(v0.2)|clientID(v0.2)|hostname|nameGlob, name, comment}, sources{integrationRef} (v0.2)Ready, membersv0.1
Policy (ns)clientGroupRefs[], blockListRefs[], allowListRefs[], allowOnly, schedule{weekdays,start,end,tz}, upstreamGroup, blockMode, blockTTL, queryLog, priority, rewrites[] (v0.2), safeSearch (v0.2)Readyv0.1
Kudurru (cluster singleton default)cluster-wide settings: upstream groups, cache, blocking defaults, querylog defaults, auth providers, edge/vip defaults, feature flagsReady, Listening, UpstreamsHealthy, blocking{disabledUntil}YAML/ConfigMap v0.1 (spec); the writable runtime status field lives in the kudurru-runtime ConfigMap in v0.1 k8s (see §7.1 step 7); CRD v0.2
Integration (ns)type, credentials{…secretRef|file}, one-of cloudflare{}/unifi{}/webhook{}, resync, suspend, deletionPolicyReady, Authenticated, Synced, Degraded, countersYAML v0.1 (framework), CRD v0.2
IdentityProvider (cluster)issuer, clientID, clientSecretRef, cliClientID, scopes, audiences, claimValidationRules, claimMappings, provisioning, allowedDomainsDiscoveredYAML v0.1; CRD v0.2
RoleBinding (ns)role, scope{kind cluster|zone|zonegroup|clientgroup, name}, subjects[]—YAML v0.1; CRD v0.2
ServiceAccount (ns)description, token{secretName, expiresAfter}tokenID, lastUsedYAML v0.1; CRD v0.2
internal kinds (never CRDs; Secrets in k8s, tables in SQLite)LocalUser (Argon2id hash, TOTP secret), APIToken (sha256), TSIGKey, DNSSECKey, IntegrationLedger; all secret material in these kinds (TSIG secrets, DNSSEC private keys, TOTP seeds, encrypted refresh tokens in v0.2) is AES-256-GCM-encrypted with the cluster encryption key before it reaches state.db/local.db; in k8s they are Secrets and etcd encryption-at-rest is the cluster's concern——

Validation is CRD OpenAPI + CEL first (bounded lists, immutability, has(self.cloudflare) == (self.type == 'cloudflare')); the admission webhook is optional (webhook.enabled=false) so a basic install needs no cert-manager [k8s §7.3]. Cross-object checks (record under its zone, duplicates across namespaces) are reconciler conditions (OutsideZone, Duplicated), PowerDNS-Operator style.

Example (both modes; kudurru apply -f accepts this verbatim):

apiVersion: kudurru.io/v1alpha1
kind: DNSZone
metadata: { name: home-example-com }
spec:
  zoneName: home.example.com.
  type: primary
  soa: { mname: ns1.home.example.com., rname: hostmaster.example.com., refresh: 3600, retry: 900, expire: 1209600, minimum: 300 }
  nameservers: [ns1.home.example.com., ns2.home.example.com.]
  policy: { queryAccess: allow, zoneTransfer: useNetworkACL, transferACL: ["10.0.0.0/8"], notify: zoneNameServers, update: deny }
  recordSetSelector: { namespaces: [dns, apps] }
  publish:                                           # v0.2 split-horizon
  - integrationRef: { name: cf-public, namespace: dns }
    selector: { matchLabels: { kudurru.io/publish: cloudflare } }
    types: [A, AAAA, CNAME, TXT, MX, SRV, CAA]
    policy: sync
status:
  serial: 1791234567
  recordCount: 42
  nameserverAddresses: [10.0.0.53]
  conditions:
  - { type: Ready, status: "True", reason: Serving, observedGeneration: 3 }
---
apiVersion: kudurru.io/v1alpha1
kind: DNSRecordSet
metadata: { name: nas, namespace: apps, labels: { kudurru.io/publish: cloudflare } }
spec:
  zoneRef: { name: home-example-com }
  endpoints:
  - { dnsName: nas.home.example.com, recordType: A, targets: ["10.0.0.20"], recordTTL: 300, kudurru: { comment: "Synology" } }
  - { dnsName: nas.home.example.com, recordType: HTTPS, targets: ["1 . alpn=h2,h3"] }
  - dnsName: nas.home.example.com                     # v0.2: published to Cloudflare, never served internally
    recordType: CNAME
    targets: [tunnel-7f3a.cfargotunnel.com]
    providerSpecific: [{ name: cloudflare/proxied, value: "true" }]
    kudurru: { view: public }
---
apiVersion: kudurru.io/v1alpha1
kind: BlockList
metadata: { name: default-ads, namespace: dns }
spec: { mode: block, sources: [{ url: https://big.oisd.nl/, format: auto }], refreshInterval: 24h }
---
apiVersion: kudurru.io/v1alpha1
kind: ClientGroup
metadata: { name: kids, namespace: dns }
spec: { clients: [{ cidr: 10.0.20.0/24, name: "Kids VLAN" }, { hostname: "ps5*" }] }
---
apiVersion: kudurru.io/v1alpha1
kind: Policy
metadata: { name: kids, namespace: dns }
spec: { clientGroupRefs: [kids], blockListRefs: [default-ads, adult], blockMode: zeroIP, blockTTL: 10m, schedule: { weekdays: [mon,tue,wed,thu,fri], start: "08:00", end: "20:00", tz: Europe/Berlin }, priority: 10 }

4.2 Record semantics the comparables got wrong

  • Zones have types and SOA/serial management from v0.1 (Gravity hard-codes serial 1337 [gravity §18.6]). Serial rule everywhere: status.serial = max(prev+1, unix_now), stamped by the leader whenever the zone's contentHash changes (kube driver: leader stamps on informer events; sqlite driver: in the same transaction). Replicas serve status.serial; the window where a replica has newer content than the stamped serial is bounded by lease renewal (≤ 15 s) and only matters to external secondaries, which the leader NOTIFYs after stamping [k8s §8].
  • Typed RRsets in presentation format; all common types incl. SVCB/HTTPS (browsers bypass overrides without it [gravity #1335]), CAA, TLSA, SSHFP, NAPTR; unknown types as RFC 3597.
  • CNAME exclusivity and duplicate detection across namespaces (Duplicated on the later object); mergeStrategy: union opt-in.
  • Records never carry secrets. Labels, ProviderSpecific and kudurru{comment,disabled,expiresAt,view} are the only extension points; split-horizon overrides live in providerSpecific (cloudflare/targets, cloudflare/proxied) and in kudurru.view [integrations §2.5].
  • Per-record disabled and expiresAt (Technitium's expiry TTL) are honoured by zonecache, not by the store.

4.3 The Store interface (the seam everything depends on)

// internal/store/store.go
type Key struct{ Kind Kind; Namespace, Name string }
type Store interface {
    Get(ctx context.Context, key Key, into Object) error
    List(ctx context.Context, kind Kind, opts ListOptions) (ObjectList, error)   // keyset pagination, label selectors
    Apply(ctx context.Context, obj Object, opts ApplyOptions) (Object, error)    // whole-object apply; see contract below
    Delete(ctx context.Context, key Key, opts DeleteOptions) error              // honours finalizers
    UpdateStatus(ctx context.Context, obj Object) error                          // leader-only callers
    Watch(ctx context.Context, kind Kind, opts WatchOptions) (Watcher, error)    // list+watch, resync, compaction-safe; Added|Modified|Deleted|Bookmark
    Elector(name string) Elector                                                 // leader election primitive (named: controller, integration-<ns>-<name>)
    Snapshot(ctx context.Context, w io.Writer, opts SnapshotOptions) error       // `kind: List` YAML of all kinds (+ internal kinds unless --safe)
    Restore(ctx context.Context, r io.Reader) error                              // = apply
    Info() Info                                                                  // driver, SchemaVersion, ClusterID, Epoch, Leader
}
type ApplyOptions struct {
    Manager                 string   // "api" | "file:<path>" | "integration:<name>" | "bootstrap" | "system"  → metadata.managedBy
    ExpectedResourceVersion string   // optimistic concurrency; mismatch → ErrConflict (409 conflict)
    Force                   bool     // override managedBy (admin only, audited)
}
type Elector interface { Run(ctx context.Context, onStarted, onStopped func()) error; IsLeader() bool; Leader() string }

Apply contract (bounds the "SSA emulation" to something a small team can keep correct):

  1. Whole-object apply; no partial KV puts. The API layer computes merges for PATCH and records convenience routes and then calls Apply.
  2. metadata.managedBy must equal opts.Manager or be empty (first write); otherwise ErrManagedBy{Owner} → HTTP 409 managed_by with the owner in hint (UI shows "managed by file objects/zones.yaml" / "managed by Kubernetes"). In Kubernetes the kube driver uses server-side apply with FieldOwner(manager) and ForceOwnership only when Force; managedBy is mirrored in the annotation kudurru.io/managed-by. Field-manager tracking below the whole object is not emulated in the sqlite driver — by design.
  3. The caller (API/controller) stamps uid, creationTimestamp, timestamps and serial candidates; drivers store them verbatim (the apiserver necessarily overrides uid/resourceVersion/creationTimestamp in k8s). This keeps the future raft FSM deterministic without touching callers.
  4. Validation (OpenAPI + CEL, bounded lists, immutability) runs in the driver-independent store.Validator before any driver; both modes reject the same inputs. Entry points (D70): Validate for Apply, ValidateStatus for UpdateStatus, ValidateUnstructured for API and file input (strict unknown fields, complete defaulting, then conversion to the Go type). The validator writes schema defaults back into the object. Every list whose items carry CEL rules needs maxItems or the CRD will not install (the apiserver's static cost estimate; TestEmbeddedCRDsInstall enforces it); optional struct-typed fields with defaults use omitzero.
  5. Status and generation (D70): Apply writes the main resource and ignores status; UpdateStatus writes status and ignores spec; generation increments only when the spec changes and an identical re-apply is a no-op (resourceVersion may stay). Force is a whole-object takeover (field ownership reassigned first).
  6. Audit: the audit row for a mutation is written in the same SQLite transaction as the mutation in the sqlite driver (table audit in state.db); in the kube driver the apiserver audit log covers the write and kudurru's audit (in local.db) adds actor/why.
DriverBackingUsed inWatchElectorStatus
kubecontroller-runtime client + informer cache over CRDs/Secrets; local.db last-good snapshot on diskKubernetes (any replica count)informerscoordination.k8s.io/Lease 15 s/10 s/2 sv0.1
sqlitestate.db (modernc): objects as JSON rows + generation/resourceVersion columns + change-feed table; audit tabledocker run; k8s "standalone" profilein-process bus + resyncalways leaderv0.1
followersqlite mirror + periodic pull of the primary's Snapshot (ETag/ifModifiedSince) + push NOTIFY from the primary; writes forwarded to the primary; fenced promotecompose HA before raftas sqlitenever leader (primary is)v0.2
rafthashicorp/raft log (raft-boltdb), SQLite FSM, single-voter bootstrap, learner join via API token, snapshot before membership changecompose/bare-metal HA, multi-primaryFSM apply hookraft leadershipv1.0

Schema versioning (every driver, from v0.1): store.SchemaVersion is compiled into the binary; state.db/local.db carry meta.schema_version. On open: a newer on-disk version than the binary → refuse to start loudly (doctor explains); older → migrate inside one transaction. Follower (v0.2): the primary serves snapshots with X-Kudurru-Schema-Version; a follower at a lower version halts replication with a diagnosis message instead of guessing. Raft (v1.0): every Command carries MinVersion; cluster version = min(node versions); the leader applies Migrate only when every member is at or above the target.

Rules that keep the drivers interchangeable: serving never reads the store on the query path; runtime data (query log, cache, sessions) never goes through Store; backup format = Snapshot output = the YAML kudurru apply -f accepts = kubectl get kudurru.io -A -o yaml (kind: List); the conformance suite (internal/store/conformance) runs against sqlite and kube (envtest) in CI and later against follower and raft.

4.4 Exact HA decision per deployment shape

ShapeConfig truthReplicasLeaderHA of DNS answersHA of config writesVersion
Single Dockerstate.db on a volume1selfnone; optional dnsdist for transports/cachen/av0.1
Kubernetes multi-replicaCRDs (apiserver/etcd)Deployment ≥ 2, PDB, topology spreadLeaseevery pod serves from informer snapshots; keeps serving from memory when the apiserver is away; a restarted/rescheduled pod serves from local.db while waiting for the apiserver (below)apiserver HA; UI/API writes go through the apiserverv0.1
Kubernetes standalonestate.db on a PVCStatefulSet 1selfnonen/a ("Pi-hole on k3s", no CRDs)v0.1
Compose HA, static twins (ha-static)identical file-managed kudurru.yaml + objects.d/ on every node (replicated by the operator's own tooling: git, ansible)N nodes; every node's dnsdist lists all nodes; keepalived VIPself on each nodeall nodes answerfile-managed only; cluster.role: static puts the API/UI in read-only mode for all kudurru.io objects (not just file-managed ones), so twins cannot diverge through node-local managedBy: api writes; runtime actions (blocking toggle, cache flush) are node-local and badged as suchv0.1
Compose HA, follower modeprimary's state.db1 primary + N followers; dnsdist + keepalived as aboveprimaryall nodes answer; followers serve a ≤ pull seconds old config and current AXFR/IXFR copiesprimary only; writes on a follower are forwarded; fenced kudurru cluster promotev0.2
Compose HA, raftreplicated state.db3 or 5 voters (+ nonvoters)raftall nodesany node (forwarded to leader)v1.0

Cold start without the apiserver . zonecache writes the complete materialised object set (all kudurru.io kinds as JSON + the resourceVersion high-water mark) to local.db after every successful sync and then at most every 30 s when changed. On start, if informers have not synced within store.kube.startupTimeout (15 s), the pod loads local.db, sets Degraded{reason: ServingFromSnapshot}, exposes kudurru_store_snapshot_serving 1 and kudurru_store_snapshot_age_seconds, and reports ready (so it takes traffic: cached and forwarded answers and primary zones from the snapshot). When informers sync, the snapshot is swapped out. If the snapshot is older than store.kube.snapshot.maxAge (24 h), primary zones answer SERVFAIL (never NXDOMAIN) while forwarding continues. local.db lives on emptyDir by default (survives container restarts and node reboots while the pod stays assigned) and on a PVC with persistence.local.enabled for clusters that need DNS before their control plane. With priorityClassName: system-cluster-critical and dnsPolicy: Default this closes the "DNS needed to bring the cluster up" trap.

Fenced promotion (follower mode, v0.2). state.db.meta carries cluster_epoch and primary_id. kudurru cluster promote on a follower increments the epoch and records itself as primary; every node advertises {epoch, primary_id} on /internal/status; a former primary that reconnects and sees a higher epoch demotes itself to follower of the new primary, refuses writes (503 fenced), and raises kudurru_cluster_fenced 1 + an alert. The UI only offers "Promote" when the old primary has been unreachable for ≥ 2 pull intervals and requires typing the node name; the residual split-brain window exists only while partitioned and promoted — documented, and the reason raft replaces this in v1.0.

Why not embedded etcd or raft in v0.1: the k8s path already has a replicated store; the single-Docker path needs none; writing membership/join/snapshot/mixed-version logic is where Gravity accumulated its worst bugs (#837, #894, #1093, #1389, #927, #1949) [gravity §16]; we never have two editable sources of truth [k8s §16.1]. The raft driver adopts the single-binary-first proposal's safety rules up front: a node with existing raft state ignores bootstrap settings; a failed join retries forever with backoff and never bootstraps a new cluster; doctor warns on 2-node quorum; hostnames are raft addresses; preStop runs kudurru cluster leave.

4.5 Runtime data placement

DataDockerKubernetesReplicated?
Query log, rollups/var/lib/kudurru/querylog.dbemptyDir (default) or persistence.querylog PVC per pod; never RWX/NFS (WAL)no (scatter-gather)
Auditaudit table in state.db (atomic with mutations)local.db per pod + stdout JSON + Eventsno (apiserver audit covers CRD writes)
Last-good config snapshotn/a (state.db is local)local.dbno
Response cache, compiled listsRAM; list disk cache under lists/RAM; emptyDirno
Sessionssealed cookie (v0.1); db store in state.db (v0.2)sealed cookien/a
Node status / heartbeatsnowhere — scatter-gather of /internal/statusnowhere — EndpointSlice + scatter-gatherno
API token hashes, local users, TSIG, DNSSEC keys, ledgerstables in state.dbSecrets labelled kudurru.io/kind=<internal> via the kube driveryes (by the store)
Cookie/encryption keyKUDURRU_ENCRYPTION_KEY[_FILE]; rotation via KUDURRU_ENCRYPTION_KEY_PREVIOUS[_FILE] (decrypt with either, re-encrypt stored secrets on start, sessions sealed under the old key stay valid until lifetime)chart-generated Secret (helm.sh/resource-policy: keep); same _PREVIOUS rotationshared
Internal listener token + node TLS cert/CAHKDF from the encryption key (override: listeners.internal.tokenFile/tls)same derivation; no extra Secretderived, not stored

4.6 Leader-only work and what happens on failover

Leader loops are idempotent and level-triggered; timers they need to resume (nextResign, secondary refresh, list refresh, resync) are stored in object status, so any new leader resumes without redoing work. Specific rules:

  • Blocklists: leader fetches, publishes status.sources[].digest; replicas pull bodies from the leader's :8082/internal/lists/<digest> (sha256-verified) and compile locally; after 10 min without a reachable leader a replica falls back to its disk cache, then to fetching itself (§7.3).
  • Secondary zones (v0.2): every replica transfers independently from the configured external primaries and persists the zone in local.db (no leader in the transfer path, so leader flaps cannot re-transfer); NOTIFY-in arrives at whichever pod the LB picks and is fanned out over :8082/internal/events; the leader only reports Transferred.
  • Signed zones (v1.0): the leader signs; signed RRsets and the journal are zone data (never CRDs) held in the leader's local.db and served to replicas by IXFR over the headless Service with a chart-generated TSIG key; replicas persist them. A new leader does not re-sign on takeover: it loads the current signed state by IXFR from any replica (or its own copy), reads keys from the Secret and continues the schedule in status.dnssec.nextResign; it signs only RRsets whose content changed or whose RRSIGs are within the resign window. ECDSA non-determinism is therefore irrelevant (one signer at a time, sequential) [go-dns-stack §3.2, §17.4].
  • Integrations (v0.2): one Lease per instance so instances spread across replicas and fail over independently.
  • NOTIFY/AXFR towards external secondaries from Kubernetes: NOTIFY leaves from the leader pod and is SNAT'ed to a node/egress IP on most CNIs and clouds, and the secondary's allow-notify/primaries list must contain that address, not the Service VIP; kudurru doctor reports the observed egress address (via a self-NOTIFY round trip to the health.kudurru.internal. listener) and the chart documents an egress gateway / hostNetwork DaemonSet as the fix when a stable source is required. AXFR/IXFR to kudurru (secondaries, v0.2) and from kudurru (TCP via the LoadBalancer) are unaffected.

5. Kubernetes model

"Kubernetes-native" means three separable things [k8s §1]; kudurru does all three, staged:

  1. Good-citizen dataplane (v0.1): non-root on :53 under the Restricted PSS, /healthz/* split, lameduck, Deployment + PDB + topology spread, Helm OCI + kustomize, dnsPolicy: Default.
  2. Kubernetes API as configuration surface (v0.1 for DNS/blocking kinds, v0.2 for IAM/Integration/Kudurru): CRDs reconciled by a Lease-elected controller inside the same binary; status.conditions per api-conventions; Events; never phase.
  3. Kubernetes as a DNS data source (v0.2): DNSZone.type: external synthesises records from Ingress / Gateway API routes / LoadBalancer Services via stripped, indexed informers (k8s_gateway / CoreDNS technique), honouring external-dns.kubernetes.io/{hostname,target,ttl} (+ legacy alpha prefix). Replacing CoreDNS for cluster.local is not in scope before 1.x (§15).

5.1 Objects shipped

  • CRD chart kudurru-crds (Helm never upgrades crds/; separate chart + helm.sh/resource-policy: keep; docs show kubectl apply --server-side -f crds.yaml) [k8s §9.5].
  • v0.1 CRDs: DNSZone (cluster), DNSRecordSet, BlockList, ClientGroup, Policy (namespaced); compatibility watch of externaldns.k8s.io/v1alpha1 DNSEndpoint (zone inferred by suffix, read-only, never owned).
  • v0.2 CRDs: Kudurru, Integration, IdentityProvider, RoleBinding, ServiceAccount. Typed Secrets: kudurru.io/tsig, kudurru.io/dnssec-key, kudurru.io/session-key.
  • Aggregated ClusterRoles kudurru-viewer|editor|admin labelled rbac.authorization.k8s.io/aggregate-to-view|edit|admin (PowerDNS-Operator pattern) plus kudurru-auditor, which aggregates nowhere because it reads the query log; make rbac-manifests generates them from internal/rbac/permissions.yaml into charts/kudurru/templates/rbac-aggregated.yaml and a test fails on drift [k8s §10].
  • Print columns and short names: kubectl get zones → ZONE TYPE SERIAL RECORDS READY AGE; rrsets → ZONE NAMES READY PUBLISHED AGE; blocklists → MODE ENTRIES LAST-FETCH READY; shortNames zone, rrset, blocklist, cg, pol, integ, idp.
  • Events on CRs: SerialBumped, NotifyFailed, ListFetchFailed, Duplicated, OutsideZone, PublishConflict, Transferred, Signed.
  • API versions v1alpha1 → v1beta1 (v1.0 release) → v1; storage-version progression with no-op conversion until a breaking change forces a webhook.

5.2 Workload, Helm values outline, probes, ports

Chart charts/kudurru (values modelled on the CoreDNS chart):

mode: kubernetes                    # kubernetes = kube driver + Deployment | standalone = sqlite driver + StatefulSet(1) + PVC, no CRDs
replicaCount: 2
image: { repository: ghcr.io/robertherbaugh/kudurru, tag: "", digest: "", pullPolicy: IfNotPresent }
service:
  dns:  { type: LoadBalancer, port: 53, splitProtocols: false, loadBalancerIP: "", loadBalancerClass: "",
          externalTrafficPolicy: Local, ipFamilyPolicy: PreferDualStack, trafficDistribution: "", annotations: {} }
        # one mixed-protocol Service by default (GA since 1.26); splitProtocols=true emits dns-udp + dns-tcp with metallb.io/allow-shared-ip
  dot:  { enabled: false, port: 853 }
  doh:  { enabled: false, port: 443 }
  doq:  { enabled: false, port: 853 }             # udp
  doh3: { enabled: false, port: 443 }             # udp
  api:  { type: ClusterIP, port: 8080 }           # expose via Ingress/HTTPRoute; OIDC redirect URL derived from ingress.host
  headless: { name: kudurru-dns, publishNotReadyAddresses: true }   # edge backends, IXFR between replicas, NOTIFY source
ingress: { enabled: false, className: "", host: "", tls: [] }
listen: { port: 53 }                # 1053 escape hatch for clusters that forbid NET_BIND_SERVICE
edge:                               # dnsdist tier (v0.1 behind a flag; default off)
  enabled: false
  mode: deployment                  # deployment | sidecar (single-node k8s: shares the pod netns, kudurru on 5353) | daemonset (with vip.provider=keepalived)
  replicas: 2
  image: { repository: ghcr.io/robertherbaugh/kudurru-dnsdist, digest: "" }
  authoritativeCache: false
  dynblocks: { enabled: false }
  ebpf: { enabled: false }          # opt-in "performance" profile; needs retained caps
  proxyProtocolSecret: ""           # generated once if empty; mounted in edge AND kudurru pods; PROXYv2 TLV 0xE0 (§3.3) — pod-CIDR trust alone is spoofable
vip:
  provider: none                    # none | kube-vip | metallb | keepalived
  address: ""                       # emits kube-vip.io/loadbalancerIPs or metallb.io/loadBalancerIPs (+ allow-shared-ip when split)
  keepalived: { nodeSelector: { kudurru.io/vip: "true" }, interface: "", vrid: 53, hmacSecret: "" }  # opt-in DaemonSet + hostNetwork edge
hostNetwork: { enabled: false }     # switches to DaemonSet, NET_BIND_SERVICE, dnsPolicy ClusterFirstWithHostNet
securityContext: { runAsNonRoot: true, runAsUser: 65532, readOnlyRootFilesystem: true, allowPrivilegeEscalation: false,
                   seccompProfile: { type: RuntimeDefault }, capabilities: { drop: [ALL], add: [NET_BIND_SERVICE] } }
resources: { requests: { cpu: 100m, memory: 256Mi }, limits: { memory: 1Gi } }
podDisruptionBudget: { enabled: true, minAvailable: 1 }
topologySpreadConstraints: [ { topologyKey: kubernetes.io/hostname, whenUnsatisfiable: ScheduleAnyway } ]
priorityClassName: ""               # system-cluster-critical when kudurru is the cluster resolver
dnsPolicy: Default                  # never ClusterFirst for the DNS server itself
persistence:
  querylog: { enabled: false, storageClass: "", size: 10Gi, accessModes: [ReadWriteOnce] }
  local:    { enabled: false, size: 1Gi }         # local.db on a PVC for "DNS before control plane" clusters
  standalone: { size: 5Gi }                       # mode=standalone only
config:                             # rendered into TWO ConfigMaps: kudurru-node (hostOnly/restartRequired keys → checksum annotation rolls pods)
  node: {}                          #                             kudurru-settings (hot-reloaded on change, no restart)
  settings: {}                      # the Kudurru singleton (v0.1 ConfigMap; v0.2 CRD)
objects: []                         # optional inline kudurru.io objects applied by a post-install Job (seed only; then kubectl owns)
auth:
  encryptionKeySecret: ""           # generated once if empty
  bootstrapAdminSecret: ""          # generated once if empty; printed by `helm status` notes
  oidc: { issuer: "", clientID: "", clientSecretRef: {}, groupsClaim: groups, groupsPrefix: "corp:" }
  kubernetes: { mode: sar }         # sar (default) | impersonate (v0.2, needs the impersonate ClusterRole and auth.kubernetes.usernamePrefix/groupsPrefix)
rbac: { create: true, aggregateToDefaultRoles: true }
webhook: { enabled: false }         # validating webhook optional; CEL does the rest
serviceMonitor: { enabled: false }  prometheusRule: { enabled: false }  grafanaDashboard: { enabled: false }
networkPolicy: { enabled: false }
externalDNS: { webhook: { enabled: false, listen: ":8888" } }   # kudurru as an external-dns webhook provider (in-process) or sidecar values

The shipped chart differs from this outline where milestone M3 decided so (D105): node and settings files at /etc/kudurru/node/ and /etc/kudurru/settings/, preStop: sleep until a server-side POST /lameduck exists (the client is kudurru lameduck 5s), the ip_unprivileged_port_start sysctl instead of a file capability, a StatefulSet for per-pod persistence, keepalived as a second container of the edge DaemonSet, and auth.oidc.providers (a list rendered into auth.identityProviders) instead of the single-issuer auth.oidc; docs/deploy/helm.md lists every value.

Pod template facts: image ghcr.io/robertherbaugh/kudurru:<ver>@sha256 (distroless static, uid 65532, file capability cap_net_bind_service=+p raised only when a port < 1024 is configured, blocky-style) [k8s §9.2]; checksum/config annotation covers only the kudurru-node ConfigMap (CRD and settings changes never restart pods) [k8s §16.6]; terminationGracePeriodSeconds: 30 with preStop: kudurru lameduck 5s; args serve --store=kube --node-name=$(POD_NAME) --proxy-protocol-from=<edge pod CIDR> (a CIDR is accepted only together with the TLV secret, D72).

Probes on the ops listener :8081 (no auth) [observability §4.1]:

ProbeEndpointSemantics
startupGET /healthz/startupconfig parsed, store opened (informers synced or local.db loaded), listeners bound; failureThreshold sized for list compile (60 × 5 s)
readinessGET /healthz/readyall zone watches synced (or serving from a snapshot younger than maxAge), blocklists loaded or blocking.allowDegraded, loopback self-query health.kudurru.internal. A ≤ 2 s; upstream reachability is never a readiness condition; 503 during lameduck
livenessGET /healthz/liveself-check loop completed within 30 s; never load-based
humanGET /healthzJSON roll-up {status, components, version, node, leader, snapshotAge}; kudurru health and Docker HEALTHCHECK

Ports: 53/udp+tcp DNS (5353 PROXYv2-only when the edge is enabled), 853/tcp DoT, 853/udp DoQ, 443/tcp DoH, 443/udp DoH3 (8443 if not root-capable), 8080 API/UI, 8081 ops, 9153 metrics, 8082 internal (node-to-node), 8084 edge supervisor readiness, 8888 external-dns webhook (optional), 7000/7001 raft + peer API (v1.0).

5.3 Leader election, leases, RBAC for the kudurru ServiceAccount

Lease kudurru-controller in the release namespace, 15 s/10 s/2 s, identity = pod name; runnables implement NeedLeaderElection() per §3.1; per-integration Leases integration-<ns>-<name> (v0.2); kudurru_leader{role} gauge; no per-node Lease and no NodeStatus objects — the Nodes page scatter-gathers /internal/status over the headless EndpointSlice [observability §5].

Election lifecycle is decoupled from process lifetime (D44). The election is driven by internal/cluster with client-go's leaderelection.LeaderElector directly, not by the controller-runtime Manager's process-level election: OnStartedLeading starts the leader-only runnable group under a child context, OnStoppedLeading cancels only that group and re-enters Run after RetryPeriod; the process never exits on leadership loss. Stock controller-runtime stops the whole Manager when the lease is lost and kubebuilder scaffolds exit the binary (LeaderElectionReleaseOnCancel "requires the binary to immediately end when the Manager is stopped" [k8s §7.4]) — that would kill the DNS dataplane on every pod during exactly the apiserver outage §4.4 is designed to survive (lease renewal fails after RenewDeadline 10 s). ReleaseOnCancel semantics are applied only on SIGTERM (kudurru lameduck). A leader that loses the lease while a leader-only loop is mid-operation relies on the idempotent, level-triggered rules of §4.6; the dataplane's informers keep serving the last snapshot throughout.

Least-privilege ClusterRole for the kudurru SA:

get,list,watch,create,patch,delete   kudurru.io/* (server-side apply needs create+patch; file objects and finalizers need delete);
get,list,watch            externaldns.k8s.io/dnsendpoints; "" services,namespaces; discovery.k8s.io/endpointslices;
                          networking.k8s.io/ingresses; gateway.networking.k8s.io/httproutes,tlsroutes,grpcroutes (v0.2, only when external zones are enabled)
update,patch              kudurru.io/*/status
create,patch              "" events
get,create,update         coordination.k8s.io/leases   (Elector(name) → Lease `kudurru-<name>`; integrations use `kudurru-integration-<ns>-<name>`, D78)
get,list,watch,create,update,patch,delete   "" secrets   (Role in the release namespace only: internal kinds, TSIG, DNSSEC keys, session key; revocation and user/RoleBinding deletion delete Secrets)
create                    authentication.k8s.io/tokenreviews; authorization.k8s.io/subjectaccessreviews   (system:auth-delegator; v0.1)
impersonate               "" users,groups,serviceaccounts; authentication.k8s.io/userextras/*   (ONLY when auth.kubernetes.mode=impersonate, v0.2)

5.4 Edge and VIP in Kubernetes

  • Default: no dnsdist; the LoadBalancer Service points at kudurru pods; externalTrafficPolicy: Local preserves client IPs (per-client stats and client groups are the product) [k8s §16.12].
  • edge.enabled=true (v0.1): Deployment kudurru-edge from the supervisor image with anti-affinity and a PDB; backends from the headless EndpointSlice (--backends-from endpointslice:<ns>/kudurru-dns), stable UUID per pod name, chashed for the resolver pool, leastOutstanding for authoritative, use_proxy_protocol: true; readiness probe GET :8084/healthz/ready on the supervisor; the Service then targets the edge pods and kudurru listens on 5353 with proxyProtocolFrom = the edge pods' CIDR plus the edge.proxyProtocolSecret TLV check (§3.3) — the pod CIDR is shared with every other workload, so CIDR trust alone would let any pod forge client addresses; the chart's networkPolicy template additionally restricts :5353 ingress to pods labelled app.kubernetes.io/component=edge. edge.mode: sidecar for single-node k8s shares the pod netns (loopback, no TLV needed) [dnsdist §1.15].
  • VIP: vip.provider kube-vip/metallb only add annotations (both Apache-2.0 and Service-aware) [dnsdist §2.5]. keepalived deploys a DaemonSet (hostNetwork, NET_ADMIN/NET_RAW/NET_BROADCAST, nodeSelector kudurru.io/vip=true) running kudurru vip run with unicast_peer rendered from the selected Nodes and switches the edge to a hostNetwork DaemonSet on the same nodes so the VIP owner actually listens on :53 [dnsdist §3.3]; re-render + SIGHUP on Node-list change (shared PID namespace not required: the supervisor is PID 1).

5.5 Kubernetes identities and authorization (v0.1: SAR; v0.2: impersonation opt-in)

  • Bearer tokens that are projected ServiceAccount tokens (audience: kudurru) are verified via the cluster's OIDC discovery/JWKS (issuer read from /.well-known/openid-configuration with the pod's own token), falling back to TokenReview with an audience check, cached ≤ 2 min by token hash; principal system:serviceaccount:<ns>:<name> with its groups [auth §4.6].
  • SubjectAccessReview on kudurru.io resources — real CRDs and virtual resources (querylogs, stats, caches, blocking, audits, clusters, settings) — makes k8s RBAC the policy for Kubernetes identities without any extra CRD; RBAC rules are strings. The kudurru SA needs only system:auth-delegator.
  • OIDC humans use kudurru RoleBindings (§8.5). Precedence rule: a request is allowed if either path allows it; the audit record names the path that granted (authz: sar|binding).
  • auth.kubernetes.mode: impersonate (v0.2, opt-in): UI/API writes are sent with Impersonate-User/Group headers so apiserver RBAC and audit see the real user. Not the default because the impersonate grant makes an internet-facing DNS/HTTP server cluster-admin-equivalent if compromised, and the research never vetted it [k8s §10].

5.6 external-dns compatibility

kudurru natively serves the external-dns webhook provider contract (GET /, GET/POST /records, POST /adjustendpoints, application/external.dns.webhook+json;version=1, 204 on apply, 5xx-only retries, 32 MiB cap) on :8888 in-process on every replica (reads come from the zone snapshot; applied Changes go through Store.Apply, which any replica can perform in kube mode because the apiserver is the store — a leader-only listener behind a Service would 503 on non-leader pods) or as a kudurru externaldns-webhook sidecar in the external-dns pod; applied Changes become DNSRecordSets with managedBy: integration:external-dns in a configured namespace; external-dns TXT ownership records are stored as ordinary TXT. DNSEndpoint CRs are additionally read directly [k8s §3.4, §3.5].

5.7 Packaging

charts/kudurru + charts/kudurru-crds (OCI), config/ kubebuilder kustomize tree with overlays metallb, kube-vip, cloud-lb-mixed, hostnetwork-daemonset, standalone, with-edge; release artifacts crds.yaml + install.yaml; ServiceMonitor/PodMonitor, Grafana dashboard ConfigMap and PrometheusRule (§11.6) behind values. Minimum Kubernetes 1.30. OLM bundle only after v1 CRDs.


6. Single-instance Docker model

6.1 Image

ghcr.io/robertherbaugh/kudurru:<ver> built by goreleaser kos: + ko from gcr.io/distroless/static:nonroot (digest pinned), linux/amd64, linux/arm64 only (D68), cosign keyless signature + SBOM + SLSA provenance. EXPOSE 53/udp 53/tcp 853/tcp 853/udp 443/tcp 443/udp 8080 8081 9153. HEALTHCHECK CMD ["kudurru","health","--ready"]. uid 65532; :53 binds through the net.ipv4.ip_unprivileged_port_start=0 sysctl the chart and Docker provide (D105). Host networking cannot use that sysctl: <ver>-hostnetwork (and latest-hostnetwork) is the same binary with the file capability cap_net_bind_service=+ep, built by publish.yml from the ko image (ko cannot set file capabilities; images/hostnetwork/Dockerfile), and the chart's hostNetwork.enabled runs it with NET_BIND_SERVICE added and allowPrivilegeEscalation: false. Startup contract copied from the dnsdist image: env → rendered config fragment, KUDURRU_DEBUG_CONFIG=yes prints the effective config with secrets masked. A -debug variant is built on distroless debug-nonroot, which adds busybox (a shell and nslookup; distroless has no dig, D105). Both bases are pinned by digest.

6.2 Config file (/etc/kudurru/kudurru.yaml) — metadata-driven, two scopes

One metadata table (internal/config/schema.go) describes every key: type, default, allowed values, help, flags restartRequired, envLocked, writeOnly, hostOnly. It drives the YAML loader (JSON Schema published for # yaml-language-server), KUDURRU_<SECTION>__<KEY> env overrides and *_FILE variants (env-set values become read-only in the UI, Pi-hole's single-source rule), kudurru config get|set|schema, PATCH /api/v1/node/config, the generated "All settings" page and the docs page; the generator is tested so docs cannot drift [pihole §16.8]. Code-bearing or privileged keys are hostOnly from v0.1 (listener addresses, TLS paths, data dir, proxyProtocolFrom, edge/vip paths and VRIDs, cluster peers) and can never be set through the API — the Pi-hole 2026 RCE class made structural [pihole §17.7].

Two scopes in one file: node settings (node, listeners, data, log, edge, vip, cluster) are host-local; cluster settings (upstreams, cache, blocking, querylog, auth, rbac) are the Kudurru singleton and are replicated in multi-node shapes. Objects (zones, lists, groups, policies, bindings, integrations) may be embedded under objects: or dropped into objects.d/*.yaml (file-managed, managedBy: file:<path>, re-applied on fsnotify change; deleting a document deletes the object), or live in the store and be edited through UI/API/CLI (managedBy: api). kudurru apply -f accepts CRD YAML verbatim.

# /etc/kudurru/kudurru.yaml  (v0.1)
node: { name: "${HOSTNAME}", dataDir: /var/lib/kudurru }                 # hostOnly
listeners:                                                               # hostOnly
  dns:  [{ address: "0.0.0.0:53", protocols: [udp, tcp] }]
  dot:  [{ address: "0.0.0.0:853", tls: { certFile: /certs/tls.crt, keyFile: /certs/tls.key } }]
  doh:  [{ address: "0.0.0.0:443", path: /dns-query, tls: { certFile: /certs/tls.crt, keyFile: /certs/tls.key } }]
  doq:  [{ address: "0.0.0.0:853", tls: { certFile: /certs/tls.crt, keyFile: /certs/tls.key } }]                     # udp, ALPN doq
  doh3: [{ address: "0.0.0.0:443", path: /dns-query, tls: { certFile: /certs/tls.crt, keyFile: /certs/tls.key } }]   # udp, ALPN h3
  proxyProtocolFrom: []             # ["127.0.0.1/32","::1/128"] when dnsdist shares the netns; exact /32s, never a LAN /24 (§3.3)
  proxyProtocolSecretFile: ""       # PROXYv2 TLV 0xE0 shared with the dnsdist supervisor; required when proxyProtocolFrom is non-loopback
  api:  { address: "0.0.0.0:8080", tls: { auto: selfsigned }, pathPrefix: "" }
  ops:  { address: "0.0.0.0:8081" }
  metrics: { address: "0.0.0.0:9153" }
  internal: { address: "0.0.0.0:8082", tokenFile: "",                 # token + node cert derived from the encryption key unless set; always TLS
              tls: { certFile: "", keyFile: "", caFile: "", serverName: "" } }   # all empty: the CA and certificate derived from the encryption key
  sockets: 0                        # SO_REUSEPORT sockets per udp/tcp DNS listener; 0 = GOMAXPROCS, 1 = a single socket
upstreams:
  groups:
    default: { servers: ["tls://1.1.1.1", "tls://9.9.9.9"], strategy: parallel_best, timeout: 2s }
    internal-ad: { servers: ["10.0.0.5:53"], strategy: strict }
  bootstrap: ["1.1.1.1:53"]
  conditional: { "corp.example.": internal-ad, "10.in-addr.arpa.": internal-ad }
  ecs: { mode: strip, ipv4Prefix: 24, ipv6Prefix: 56 }   # strip (privacy default) | forward | add
cache: { maxEntries: 200000, minTTL: 5s, maxTTL: 24h, negativeTTL: 5m, prefetch: { threshold: 5, window: 2h },
         serveStale: { ttl: 72h, answerTTL: 30s, maxWait: 1800ms } }
blocking:
  enabled: true
  defaultResponse: zeroIP           # zeroIP | nxdomain | customIP | refused (discouraged)
  blockTTL: 10m
  initStrategy: fast                # fast | blocking | failOnError (disk-seeded)
  refreshInterval: 24h
  allowDegraded: true
  listsDir: ""                      # hostOnly; list body cache, default <dataDir>/lists
ratelimit:                          # per-prefix QPM on UDP at ingress (§7.1 step 2); TCP/DoT/DoH/DoQ/DoH3 and loopback never limited
  enabled: true
  ipv4: { "32": 600, "24": 6000 }
  ipv6: { "128": 600, "64": 6000, "56": 12000 }
  slip: 50                          # % of over-limit queries answered TC=1, the rest dropped
  maxBuckets: 65536
  exempt: []
querylog: { enabled: true, privacy: full, retention: { raw: 720h, maxDBBytes: 10GiB }, exclude: { domains: [], clients: [], sudn: true } }
auth:
  localLogin: enabled               # enabled | admins-only | disabled
  bootstrap: { adminPasswordFile: /run/secrets/admin_password }
  session: { idleTimeout: 30m, lifetime: 12h, secure: auto }   # auto = Secure when TLS terminates here or a trusted proxy sends X-Forwarded-Proto: https; plain-HTTP labs still get SameSite=Lax + CSRF header
  identityProviders:
  - id: corp-sso
    issuer: https://auth.example.com/application/o/kudurru/
    clientID: kudurru
    clientSecretFile: /run/secrets/oidc_client_secret
    cliClientID: kudurru-cli
    scopes: [openid, profile, email, groups]
    claimMappings: { username: { claim: preferred_username }, groups: { claim: groups, prefix: "corp:" } }
    provisioning: { autoProvision: mapped-only, syncGroups: true }
  kubernetes: { mode: sar }         # hostOnly; sar (v0.1) | impersonate (v0.2)
  adminSocket: { path: /run/kudurru/kudurru.sock, group: -1 }   # hostOnly; `kudurru admin` (D101); empty path disables
rbac:
  bindings:
  - { role: admin, scope: { kind: cluster }, subjects: [{ kind: Group, name: "corp:/dns-admins" }] }
edge: { enabled: false }            # hostOnly; see compose profiles
vip:  { enabled: false }            # hostOnly
objects:                            # optional inline CRD-shaped objects (managedBy: file:kudurru.yaml)
- apiVersion: kudurru.io/v1alpha1
  kind: BlockList
  metadata: { name: default-ads }
  spec: { mode: block, sources: [{ url: https://big.oisd.nl/ }], refreshInterval: 24h }
- apiVersion: kudurru.io/v1alpha1
  kind: Policy
  metadata: { name: default }
  spec: { clientGroupRefs: [default], blockListRefs: [default-ads] }

6.3 Persistence under /var/lib/kudurru

state.db        # sqlite store driver: all api/v1alpha1 objects + internal kinds (JSON rows), change feed, zone journals, audit table; meta.schema_version
querylog.db     # per-node fact table + rollups (WAL, synchronous=NORMAL, incremental_vacuum)
local.db        # (kube driver only) last-good snapshot + audit; (all drivers) response-cache dump on shutdown
lists/          # sha256(url) disk cache of blocklist bodies
keys/           # cookie/encryption key if not provided via env; TSIG/DNSSEC material (encrypted, v0.2+)
edge/           # rendered dnsdist.yml / keepalived.conf (compose profiles mount read-only; supervisors render their own)
backups/        # `kudurru backup` output when --out is omitted

Backup: kudurru backup --out kudurru-backup.tar.zst = Store.Snapshot (kind: List YAML) + optional query log; --safe strips secrets, sessions, tokens; kudurru restore applies it; kudurru export --safe -o cluster.yaml emits applyable YAML; the same YAML seeds followers (v0.2) and first boot (KUDURRU_IMPORT=/path.yaml). kudurru import bind|pihole|adguard|blocky|technitium converts foreign formats into the same YAML.

6.4 Compose: single instance (+ edge profile)

# deploy/compose/single/docker-compose.yml
services:
  kudurru:
    image: ghcr.io/robertherbaugh/kudurru:0.1.0
    restart: unless-stopped
    ports: ["53:53/udp", "53:53/tcp", "8080:8080"]            # dropped when profile edge owns :53
    environment:
      KUDURRU_ADMIN_PASSWORD_FILE: /run/secrets/admin_password
      KUDURRU_ENCRYPTION_KEY_FILE: /run/secrets/encryption_key
      TZ: Europe/Berlin
    volumes: ["./kudurru.yaml:/etc/kudurru/kudurru.yaml:ro", "./objects.d:/etc/kudurru/objects.d:ro", "kudurru-data:/var/lib/kudurru"]
    secrets: [admin_password, encryption_key]
    healthcheck: { test: ["CMD", "kudurru", "health", "--ready"], interval: 10s, timeout: 3s, retries: 5, start_period: 60s }
  dnsdist:                                                    # profile: edge (optional)
    profiles: [edge]
    image: ghcr.io/robertherbaugh/kudurru-dnsdist:2.1.2-k0.1.0@sha256:<digest>
    command: ["kudurru", "edge", "run", "--backends", "127.0.0.1:5353", "--tls-dir", "/certs"]
    ports: ["53:53/udp", "53:53/tcp", "853:853/tcp", "853:853/udp", "443:443/tcp", "443:443/udp"]
    volumes: ["certs:/certs:ro"]
    depends_on: [kudurru]
  # with profile edge: kudurru uses network_mode: "service:dnsdist", listeners.dns address 127.0.0.1:5353, proxyProtocolFrom ["127.0.0.1/32"]
volumes: { kudurru-data: {}, certs: {} }
secrets:
  admin_password: { file: ./secrets/admin_password }
  encryption_key: { file: ./secrets/encryption_key }

First boot prints the generated admin password once (if no secret), creates the root zone . as a forwarder to upstreams.groups.default, ClientGroup/default + Policy/default, and loads the build-time-embedded starter blocklist from the embedded FS so blocking works offline (Gravity's loopback-list idea without the loopback server) [gravity §17.11]. The embedded list is built at release time from permissively licensed sources only (candidate: StevenBlack/hosts, MIT — list licences are not covered by the research and must be confirmed in the release checklist) and carries its own NOTICE entry; copyleft-licensed lists (e.g. hagezi's GPL-3.0 lists, AdGuard's filters) may be referenced by URL at runtime like any other data but are never redistributed inside the image, and licenses.yml checks the embedded list's provenance file.

6.5 Compose HA with dnsdist + keepalived

One directory per node (deploy/compose/ha/node-{a,b,c}/), identical except NODE_NAME, NODE_IP, priority index. v0.1 ha-static: every node runs the same file-managed configuration (kudurru.yaml + objects.d/ distributed by git/ansible/rsync — cluster.role: static makes the API/UI read-only for every kudurru.io object, shows "managed by file", and a diagnosis message explains that writes must go through the distributed files); v0.2: the same compose with KUDURRU_CLUSTER_ROLE=follower on nodes b/c and KUDURRU_CLUSTER_PRIMARY=https://10.0.0.11:8082 makes the UI writable on the primary and replicated; v1.0: KUDURRU_CLUSTER_PEERS + bootstrapExpect turns the same three nodes into raft voters. The dnsdist and keepalived services do not change across the three versions.

# deploy/compose/ha/node-a/docker-compose.yml   (node-a: 10.0.0.11, prio 200; node-b: 10.0.0.12, prio 190; node-c: 10.0.0.13, prio 180)
services:
  kudurru:
    image: ghcr.io/robertherbaugh/kudurru:0.1.0
    network_mode: host                                   # binds :5353 for dnsdist, :8080 API, :8082 internal; the VIP is held by keepalived
    restart: unless-stopped
    environment:
      KUDURRU_NODE__NAME: node-a
      KUDURRU_LISTENERS__DNS: "0.0.0.0:5353"
      KUDURRU_LISTENERS__PROXYPROTOCOLFROM: "127.0.0.1/32,10.0.0.11/32,10.0.0.12/32,10.0.0.13/32"   # exact peers, never the LAN /24
      KUDURRU_LISTENERS__PROXYPROTOCOLSECRETFILE: /run/secrets/proxy_secret
      KUDURRU_ADMIN_PASSWORD_FILE: /run/secrets/admin_password
      KUDURRU_ENCRYPTION_KEY_FILE: /run/secrets/encryption_key
      KUDURRU_LISTENERS__INTERNAL__TOKENFILE: /run/secrets/internal_token         # optional: defaults to a value derived from the encryption key
      # v0.2: KUDURRU_CLUSTER__ROLE=follower  KUDURRU_CLUSTER__PRIMARY=https://10.0.0.11:8082   (nodes b, c)
      # v1.0: KUDURRU_CLUSTER__BOOTSTRAPEXPECT=3  KUDURRU_CLUSTER__PEERS=10.0.0.11:7000,10.0.0.12:7000,10.0.0.13:7000
    volumes: ["./kudurru.yaml:/etc/kudurru/kudurru.yaml:ro", "./objects.d:/etc/kudurru/objects.d:ro", "kudurru-data:/var/lib/kudurru", "kudurru-run:/run/kudurru"]
    secrets: [admin_password, encryption_key, internal_token, proxy_secret]
    healthcheck: { test: ["CMD", "kudurru", "health", "--ready"], interval: 10s, timeout: 3s, retries: 5, start_period: 60s }
  dnsdist:
    image: ghcr.io/robertherbaugh/kudurru-dnsdist:2.1.2-k0.1.0@sha256:<digest>
    network_mode: host                                   # recommended so dnsdist binds the VIP as it appears (0.0.0.0:53)
    restart: unless-stopped
    command: ["kudurru", "edge", "run",
              "--backends", "10.0.0.11:5353,10.0.0.12:5353,10.0.0.13:5353",   # EVERY node lists ALL kudurru nodes
              "--pool-resolver", "chashed", "--pool-authoritative", "leastOutstanding",
              "--tls-dir", "/certs", "--api", "127.0.0.1:8083", "--console", "127.0.0.1:5199",
              "--api-key-out", "/run/kudurru/dnsdist-api.key", "--proxy-protocol-secret-file", "/run/secrets/proxy_secret"]
    secrets: [proxy_secret]
    volumes: ["certs:/certs:ro", "kudurru-run:/run/kudurru"]
    depends_on: [kudurru]
  keepalived:
    image: ghcr.io/robertherbaugh/kudurru-keepalived:2.4.3-k0.1.0@sha256:<digest>
    network_mode: host
    cap_add: [NET_ADMIN, NET_RAW, NET_BROADCAST]
    restart: unless-stopped
    stop_grace_period: 20s                               # keepalived must release the VIP before the container goes
    command: ["kudurru", "vip", "run",
              "--vip", "dns=10.0.0.53/24@eth0#53",        # name=addr/len@iface#vrid  (VRID is hostOnly, never changes on re-render)
              "--vip", "dns2=10.0.0.54/24@eth0#54",       # optional second VIP, reversed priorities → active/active
              "--peers", "10.0.0.12,10.0.0.13", "--priority-index", "0",
              "--hmac-key-file", "/run/secrets/vrrp_hmac",
              "--track-dnsdist-api", "http://127.0.0.1:8083", "--dnsdist-api-key-file", "/run/kudurru/dnsdist-api.key", "--require-backends", "1"]
    volumes: ["kudurru-run:/run/kudurru"]
    secrets: [vrrp_hmac]
    depends_on: [dnsdist]
volumes: { kudurru-data: {}, kudurru-run: {}, certs: {} }
secrets:
  admin_password: { file: ./secrets/admin_password }
  encryption_key: { file: ./secrets/encryption_key }
  internal_token: { file: ./secrets/internal_token }
  proxy_secret:   { file: ./secrets/proxy_secret }
  vrrp_hmac:      { file: ./secrets/vrrp_hmac }

Behaviour: keepalived decides VIP ownership only (VRRPv3 unicast, state BACKUP, nopreempt, auth_hmac); track_script probes the local dnsdist on :53 udp+tcp and requires ≥ 1 healthy backend; track_file /run/kudurru/weight-dns is written by the local kudurru (0/60/300); the FIFO consumer emits kudurru_vip_master{vip="dns"}. Because every dnsdist lists all nodes, a node whose kudurru is unhealthy but whose dnsdist is healthy still serves, and query logs stay correct on every node through PROXYv2 [dnsdist §3.2]. External secondaries doing AXFR from ha-static twins should transfer from one named node (serials are max(prev+1, unix_now) on each twin and therefore close but not identical); follower mode (v0.2) removes that caveat.


7. Query path and the blocking engine

7.1 Step by step (one A query from a LAN client, dnsdist in front)

  1. dnsdist (optional) accepts on :53/:853/:443, ACL, DynBlocks/eBPF drop abuse, packet cache (resolver pool only), picks a backend (chashed on qname for cache locality), prepends PROXYv2 with the real client address, forwards to kudurru:5353.
  2. ingress: parse PROXYv2 only from proxyProtocolFrom (else drop), parse the message (FORMERR on garbage), EDNS version check, global ACL, per-prefix QPM limiter (/32: 600 udp, /24: 6000, IPv6 /128,/64,/56; over-limit UDP → TC=1 for a percentage, then drop) [technitium §20.9]; NSID/CHAOS id.server/version.bind/hostname.bind and health.kudurru.internal. (TXT <node-id> <serial-epoch>, never logged) answered directly.
  3. identify: client → ClientGroups: exact IP → longest-prefix CIDR → (v0.2) MAC via inventory → hostname/rDNS → DoT/DoH client ID (<id>.dot.<domain> SNI, /dns-query/<id>) → default. Equal-length CIDR ties raise a diagnosis message. The union of matching groups selects Policy objects (highest priority wins per setting) and the decision travels as Request.Policy [pihole §16.5, blocking §2.8].
  4. control: Policy/global rules (allowedNetworks, drop malformed, refuse ANY, block qtypes) → Allow | DropSilently | Refuse; a drop returns pipeline.ErrDrop, after which the transport should send nothing (UDP/DoQ) or close the exchange (v0.1 transports still answer SERVFAIL; docs/followups.md).
  5. local: custom host records / synthesised inventory names (UniFi, v0.2), SUDN (.local, .onion, RFC 6303 reverse without a zone → NXDOMAIN). Status: local_record; never cached.
  6. authoritative: longest-suffix zone match over the in-memory trees (root . is a forwarder by default). Enforce queryAccess/queryACL; answer with CNAME chase (loop cap 20; into other local zones, else into the rest of the pipeline when RD), wildcards by label replacement, NODATA vs NXDOMAIN on name existence (never NXDOMAIN while unsynced: SERVFAIL [k8s §2.2]); AXFR/IXFR here (transferACL, TSIG, dns.Transfer.Out); NOTIFY/UPDATE opcodes routed to handlers (NOTIMP in v0.1). Authoritative answers are not subject to blocking (Technitium rule: your own zone cannot be blocked by a list) [technitium §3].
  7. blocking (recursive path only): allow-wins-across-groups → allow-only mode → in-list @@ exception → deny ($important beats @@) → after next returns, response-side CNAME/A/AAAA/HTTPS-hint inspection against deny sets [blocking §2.7, §6.1]. A hit synthesises the policy's response (zeroIP default, nxdomain with SOA, customIP, refused discouraged), blockTTL (10 m default), EDE 15/17, Status: blocked_*, List/Rule for the UI. Temporary disable (POST /dns/blocking {blocking:false, timer:300}) is cluster-wide via Kudurru.status.blocking.disabledUntil; in v0.1 Kubernetes, where Kudurru is still a ConfigMap without a status subresource, the kube driver keeps this one runtime field in the kudurru-runtime ConfigMap (annotation kudurru.io/blocking-disabled-until, server-side applied by whichever replica served the request, watched by all), and in Docker in state.db; ha-static twins toggle node-locally (badged).
  8. rewrite (v0.2): $dnsrewrite/UI rewrites, client-group views (kudurru.view), DNS64.
  9. cache: key (qname,qtype,qclass,DO,ECS-scope), positive/negative split, TTL countdown, prefetch when hot and near expiry, serve-stale when all upstreams fail, in-flight coalescing; dumped to local.db on shutdown and loaded at start. Status: cached|cached_stale.
  10. dnssec-validate (v0.2): validate upstream answers (blocky's resolver/dnssec as reference), AD/CD, SERVFAIL + EDE on bogus, NTA list; only validated data enters the cache.
  11. upstream: Request.UpstreamGroup (forward zones, a Policy's group) → else conditional map → group; strategy; dnsproxy exchange with per-group timeout; ECS per upstreams.ecs.mode (strip default, forward, add with ipv4Prefix/ipv6Prefix); Status: forwarded, Upstream: <group/server name>; errors → servfail_upstream|timeout_upstream.
  12. postprocess (v0.2): rebinding protection, AAAA filtering, NXDOMAIN override, TRACEPARENT strip.
  13. log: build the QueryEvent (§11.2), non-blocking enqueue to the per-worker ring, atomic counters/histograms, dnstap CLIENT_RESPONSE (v0.2), OTel attributes (v0.2). dnsserver writes the reply; dnsdist caches it (resolver pool) and answers the client.

The decisions that once overwrote steps 3, 4, 5, 9, 11 and 13 of this list are D54, D64, D60, D62, D65 and D67 in §16.

Hard rules: the query path never touches Store, never blocks on logging, never waits on a leader, and keeps answering from the last snapshot when the store is unreachable [gravity §4.4].

7.2 Policy model

Source     {url|inline|configMapRef, format auto|hosts|domains|adblock|regex, refresh, etag, digest, ruleCount, invalid, enabled}
BlockList  {name, mode block|allow, sources[], response override}            # blocky "group list"
ClientGroup{name, clients[ip|cidr|mac|clientID|hostname|nameGlob]{name,comment}, sources{integrationRef}}
Policy     {name, clientGroupRefs[], blockListRefs[], allowListRefs[], allowOnly, schedule, upstreamGroup, blockMode, blockTTL, queryLog, priority, rewrites[], safeSearch, safeSearchMode, blockedServices[], blockedCategories[], allowedServices[]}

Defaults: ClientGroup/default (matches everything), Policy/default. Pi-hole users map 1:1: Groups → ClientGroup + Policy, Lists → BlockList, Domains (exact/regex allow/deny) → an inline BlockList source, Clients → ClientGroup.clients. Blocked-services and safe-search data come from a kudurru-authored, Apache-2.0 catalogue embedded in the binary with per-entry provenance (AdGuard's tables are GPL) [blocking §6.6]; blocking.catalogue.url can replace it at runtime. It shipped in the v0.1 fix round instead of M2 (D60, D120, contract in docs/deploy/blocked-services.md).

7.3 Blocking engine design (performance is a named risk, §14)

Per BlockList, compile sources into a chain of three matchers, cheapest first; the exact-set layout, the cheapest-first chain and matched-rule reporting are borrowed from blocky (Apache-2.0, NOTICE) [blocking §2.5]:

  1. Exact set: lower-cased entries bucketed by length, each bucket one sorted concatenated string, binary search, zero allocation.
  2. Suffix set for ||domain^, *.domain and hosts-style entries with subdomain semantics: TLD-bucketed sorted sets plus a maphash bit filter and a fence index, walking parent labels broadest-first and returning the matched rule. This is kudurru's own design (blocky v0.35 uses a trie, D73); one process-wide maphash seed serves every list's filter. It fixes blocky's reduction of ||domain^ to exact match [blocking §8.2].
  3. Regex scan indexed by a 4-byte piece of each expression's required literal (1,000 indexed regexes cost ~140 ns per miss); expressions with no such literal are scanned linearly (~25-75 ns each); warning above 50 unindexed regexes, hard cap 1,000 per list (CompileOptions.RegexHardCap, D73).
  4. (v0.2) Modifier slow path: rules with $important, $badfilter, $dnstype, $dnsrewrite, $client, $ctag, $denyallow, mid-pattern wildcards: 5-byte shortcut index → candidate verify (urlfilter's idea re-implemented, never imported). Unknown modifier → rule ignored and counted in status.invalid, so browser lists are safe. v0.1 parses the whole grammar, compiles pure ||x^/@@||x^/hosts/domains/regex into matchers 1-3 and counts the rest as "unsupported in this version".

Compiled sets live in an atomic.Pointer[Snapshot] swapped copy-on-write per refresh; refresh is skipped when the CRC-32 of normalised rule lines is unchanged (AdGuard Home) [blocking §3.3]. The list pipeline is blocky's: ETag/Last-Modified conditional GET, sha256(url) disk cache with stale-on-unreachable, tee-to-temp + atomic rename, retry/cooldown, maxErrorsPerSource, keep-old-group-on-failure, bounded concurrency, init strategies with disk seeding, HTML/binary detection, ! Title: extraction, maxHTTPSize [blocking §2.6, §6.3].

Fetch once, serve everywhere (v0.1): the leader's listfetcher downloads, writes lists/<sha256(url)>, and publishes BlockList.status.sources[].digest; replicas pull GET :8082/internal/lists/<digest> from the leader (found via the Lease holder → EndpointSlice), verify the hash and compile locally; fall back to the disk cache, then to independent fetching, after 10 min without a reachable leader [k8s §16.11]. Compilation is node-local (CPU is cheap; a binary snapshot format would be another thing to version).

Match reporting: Contains(name) → map[list]rule so the query log explains "blocked by oisd: ||ads.example^", while Prometheus labels carry only list names.

Performance gate in CI (make bench): 1 M-entry suffix set, p99 lookup < 2 µs single-threaded, < 150 MB RSS for 3 M entries (steady state after GC in a fresh process; peak during a copy-on-write rebuild is tracked separately and needs roughly old + new + one list in flight), compile < 5 s; a +20 % regression against a committed, runner-class-specific baseline fails the build, each sample in its own process. Measured in the spike (docs/spikes/blocking.md): p99 1.18 µs hit / 0.71 µs miss at 1 M, 90 MB steady RSS (138 MB peak) at 3 M, 0.84 s compile, zero allocations per lookup. List semantics pinned there: @@ exceptions apply to their own list by default (a selection scope gives the merged behaviour); *.x also matches x; ||x is read as ||x^; hosts lines pointing at addresses other than 0.0.0.0/::/127/8/::1 are rewrites and counted unsupported; localhost boilerplate is ignored; bare IPs are matched against answer addresses. No published numbers exist for blocky/AGH [blocking §5.1], so kudurru publishes its own; a conformance corpus (OISD, Hagezi, StevenBlack samples) with expected block/allow decisions is cross-checked black-box against an AdGuard Home container (no code copied).


8. Auth: OIDC flows, RBAC, tokens, bootstrap

8.1 Principals and identities

Principal = User | ServiceAccount | KubernetesServiceAccount | LocalRoot (unix socket) | Node (internal token / mTLS peer); Node principals hold implicit viewer + auditor. The stored identities are internal kinds (LocalUser, APIToken, ServiceAccount, RoleBinding, IdentityProvider) in api/internalkinds/v1alpha1 (Go's internal-package rule forbids api/internal, D100): Secrets in the release namespace in Kubernetes, JSON rows in state.db in Docker, never CRDs. Identity key for OIDC users is (provider, iss, sub) — never email; email is trusted only when email_verified [auth §2.7, §10.4]. A User has zero or more identities (local password, OIDC) and local or IdP-managed group memberships.

8.2 Web login (BFF, authorization code + PKCE) and sessions

GET  /api/v1/auth/oidc/{idp}/start?next=/zones   → pre-auth cookie {state, nonce, verifier, next} (sealed, 10 min) → 302 IdP (S256, nonce)
GET  /api/v1/auth/oidc/{idp}/callback?code&state → exchange (confidential client) → verify id_token (go-oidc, JWKS cache) → nonce
                                                 → claimValidationRules → claimMappings → provisioning policy → bindings
                                                 → Set-Cookie kudurru_session (sealed, HttpOnly, Secure, SameSite=Lax) → 302 next (same-origin only)
GET  /api/v1/auth/me                              → principal, effective bindings, permission matrix, csrf token
POST /api/v1/auth/password                        → {currentPassword,newPassword} (local users); revokes every earlier session, sets a fresh cookie
GET  /api/v1/auth/status                          → {localLogin, localLoginMode, providers, hasDefaultCredentials, session}
POST /api/v1/auth/logout                          → clear cookie; RP-initiated logout via end_session_endpoint when present (v0.2)

Session = sealed cookie (AES-256-GCM, cluster-shared KUDURRU_ENCRYPTION_KEY / kudurru.io/session-key Secret) with payload {sid, principal, roles_snapshot, idp, iat, exp, idle_exp, csrf} (roles_snapshot = the resolved bindings, never the raw IdP group list, so a user with hundreds of groups cannot hit the 4 KiB guard and be unable to log in) re-sealed on each request to slide the idle timeout (30 m) under an absolute lifetime (12 h); 4 KiB guard with a hard error; no refresh token in the cookie. This is why any replica can serve any session in a Kubernetes Deployment with no shared DB. Revocation: User.status.sessionsValidAfter (set on password change, admin "kill sessions", disable) is checked per request from the store snapshot. Behind a SessionStore interface: sealed (v0.1 both modes) and db (v0.2 Docker; k8s only with an external store, v1.0) which adds session listing/killing and hourly IdP group re-evaluation via an encrypted refresh token — the auth research's preferred model [auth §4.1], accepted as the v0.1 trade-off because the kube driver has no relational store.

Implementation notes from the auth spike (D71): keys are derived per purpose as HKDF-SHA256(KUDURRU_ENCRYPTION_KEY, info=kudurru/<purpose>/v1) with purposes session and oidc-preauth, and every envelope carries a 4-byte key id so the _PREVIOUS key (D57) opens old cookies; roles_snapshot holds grants as role or role@kind:name, resolved at login and frozen until the next login, so deleting or narrowing a RoleBinding must bump sessionsValidAfter for its subjects (M3); the session cookie takes the __Host- prefix when Secure; the pre-auth cookie is single-use and path-scoped; discovery is lazy and a failing IdP yields idp_unavailable; logout clears the browser copy only. A user with 200 IdP groups seals to 546 bytes (raw groups would be 9,210 bytes), 68 zone-scoped grants fit under the 4 KiB guard. Entra-style group-overage claims are refused with an explanation instead of being read as "no groups"; IdP usernames are prefixed <idp>: by default so they can never match User bindings meant for local accounts; defaultRole may never be admin. go-oidc verifies neither nonce, azp nor at_hash and does not fetch distributed claims, so kudurru does the nonce and audience checks itself.

CSRF: Go 1.25 http.CrossOriginProtection on mutating routes + X-Kudurru-CSRF header required for cookie-authenticated writes + SameSite=Lax; bearer requests exempt [auth §4.1]. Security headers: CSP default-src 'self', X-Frame-Options: DENY, nosniff, HSTS when TLS terminates in kudurru. Multiple providers in auth.identityProviders[] / IdentityProvider objects; login page lists them; ?local=1 is break-glass.

// internal/auth/session.go
type SessionStore interface {
    Issue(ctx context.Context, s Session) (cookieValue string, err error)      // sealed: encrypt; db: insert row, return random id
    Load(ctx context.Context, cookieValue string) (Session, error)              // verifies seal/row, idle+absolute expiry, sessionsValidAfter
    Touch(ctx context.Context, s *Session) (cookieValue string, changed bool, err error)   // slides idle_exp; sealed re-seals at most once per minute
    Revoke(ctx context.Context, sid string) error                               // db only; sealed returns ErrUnsupported (use sessionsValidAfter)
    List(ctx context.Context, principal string) ([]SessionInfo, error)         // db only
}

8.3 CLI login

  • v0.1: kudurru login --server https://dns.example --token kdr_… or --username/--password (local admin); credentials in the OS keyring (zalando/go-keyring) with a 0600 file fallback; --context picks a kubeconfig for CRD verbs.
  • v0.2: kudurru login opens the browser for loopback + PKCE (ports 8000/18000, public client kudurru-cli), --device for RFC 8628 (prints user code and client name per RFC 10027); the CLI sends the IdP ID token as bearer; the server routes by iss, verifies against JWKS, requires aud to contain cliClientID (or clientID) and, when aud has several values, azp to equal one of them, rejects access tokens and tokens minted for other relying parties (otherwise any ID token the IdP issued for an unrelated app could be replayed against kudurru), caches the verdict by sha256(token) until min(exp, 15 m) (dex issues 24 h ID tokens by default), re-runs claim mapping per call [auth §4.2].

8.4 Claims → identity → bindings

Three Kubernetes-shaped layers [auth §4.3]: claimValidationRules ({claim, requiredValue}; v0.2 CEL expression), claimMappings (username{claim,prefix} with fallback preferred_username → upn → email → sub, groups{claim,prefix} tolerant of string-or-array and also reading roles, displayName, email), then authorization via bindings on the prefixed remote group (corp:/dns-admins) or a groupMap. provisioning: { autoProvision: always|mapped-only|never, syncGroups: bool, defaultRole: "" } — strict by default: a login yielding zero bindings is refused with a clear message (Grafana role_attribute_strict); never silently admin (Gravity's mistake) [auth §10.1].

8.5 RBAC model and permission matrix

Hand-rolled (subject, role, scope) bindings behind an Authorizer interface (Casbin/SAR pluggable) [auth §5]:

// internal/rbac/authorizer.go
type Attributes struct{ Resource, Verb string; Scope Scope /* {Kind cluster|zone|zonegroup|clientgroup, Name} */; Namespace, Name string }
type Decision struct{ Allowed bool; Path string /* binding | sar | local | none */; Missing *Attributes; Reason string }
type Authorizer interface {
    Authorize(ctx context.Context, p Principal, a Attributes) (Decision, error)   // pure for the binding authorizer; SAR authorizer caches ≤ 2 min by (token hash, attributes)
}
type Chain []Authorizer   // first Allowed wins (§5.5 precedence); Decision.Path is what the audit row records

Every route declares {resource, verb, scopeFromPath}; a lint fails the build if a route has no declaration. internal/rbac/permissions.yaml is the single source that generates the built-in roles, the aggregated ClusterRoles in the chart, the docs table below and the lint fixtures.

Resources × verbs (read|write|delete + extras): zones{transfer,sign,share}, records, blocklists{refresh}, policies, clientgroups, clients, querylog{export,purge}, stats, cache{flush}, blocking{toggle}, settings, node{config}, users, groups, serviceaccounts, tokens, identityproviders, rolebindings, integrations{sync,check,secrets,act}, cluster{join,promote,drain}, edge, audit{export}.

Permissionviewerauditoroperatorzone-admin (scoped)admin
zones/records/stats/cache read✓✓✓✓ scoped✓
records write/delete✓ (scoped or global)✓ scoped✓
zones write (options) / delete / transfer / sign / share✓ scoped (create needs cluster zones.write)✓
blocklists/policies/clientgroups/clients read✓✓✓✓
blocklists/policies/clientgroups/clients write, cache flush, blocking toggle✓✓
querylog read (with client IPs)✓✓ (settings.operatorSeesQueryLog, default true)✓
querylog export / purgeexport✓
settings read (redacted)✓✓✓✓
settings write, node.config, integrations., cluster., edge.*✓
users/groups/serviceaccounts/rolebindings/identityproviders✓
own tokens write✓✓✓✓✓
audit read✓✓

Scopes: cluster, zone:<name>, zonegroup:<label> (v0.2), clientgroup:<name> (v1.0, so a group owner sees only their clients' queries). The creator of a zone receives zone-admin on it (Technitium's initial grant) [auth §9.1]. Evaluation is a pure function with table tests; zone listing filters by scope. 403 responses carry missing:{resource,verb,scope} so the UI can explain. Kubernetes mapping: viewer ≡ kudurru-viewer, operator ≡ kudurru-editor, admin ≡ kudurru-admin, auditor ≡ kudurru-auditor; zone-admin ≡ a namespaced RoleBinding plus resourceNames on dnszones.

apiVersion: kudurru.io/v1alpha1
kind: RoleBinding
metadata: { name: platform-zone-admins, namespace: dns }
spec:
  role: zone-admin
  scope: { kind: zone, name: home-example-com }
  subjects:
  - { kind: Group, name: "corp:/dns-admins" }
  - { kind: ServiceAccount, name: external-dns, namespace: kube-system }   # k8s SA via projected token

8.6 API tokens and service accounts

Grafana-style service accounts owning N tokens; wire format kdr_<12-char id>_<43-char secret>; store sha256(secret) only; constant-time compare on the full hash, never prefix lookups (Technitium v15.6 oracle); unknown id and wrong secret return the same 401; optional expiry (auth.tokens.maxLifetime), lastUsedAt/IP, revocation, audit [auth §4.5]. Personal tokens inherit the owner's bindings ∩ optional scopes and die with the user. Integration credentials (Cloudflare token, UniFi key, dnsdist API key) are outbound secrets, not principals: secretRef/file/env only; fingerprints in status.

8.7 Local accounts, bootstrap, recovery

  • First start with no users: admin from KUDURRU_ADMIN_PASSWORD[_FILE] else generated and printed once; mustChangePassword=true; hasDefaultCredentials in GET /auth/status so the UI nags [auth §4.4].
  • KUDURRU_BOOTSTRAP_TOKEN[_FILE] creates a bootstrap service-account token (Helm post-install jobs, Terraform) — hash only at rest.
  • Argon2id (m=19 MiB, t=2, p=1, PHC string), 1 KiB password cap, global hashing semaphore; per-IP (5 failures → 5 min) and per-user (10 → 15 min) limiters with 429 Retry-After, dummy hash for unknown users, sanitised usernames in logs [auth §7].
  • localLogin: enabled|admins-only|disabled; /login?local=1 break-glass; kudurru admin reset-password|create-token|list-users over auth.adminSocket.path (default /run/kudurru/kudurru.sock) (SO_PEERCRED uid 0, or the uid the server itself runs as — 65532 in the distroless image, which is what docker exec/kubectl exec get without --user — or a configured gid) for docker exec/kubectl exec.
  • TOTP (v0.2) with one attempt per second and last-counter replay protection.

8.8 Middleware order

Recover → RequestID → RealIP(trustedProxies) → SecurityHeaders → AccessLog → CrossOriginProtection → RateLimit(auth routes) → Authenticate(bearer kdr_ | bearer JWT by iss | k8s SA token | cookie+CSRF | unix peer | internal token) → LoadPrincipal(disabled? mustChange? sessionsValidAfter?) → Authorize(resource, verb, scope; SAR for k8s identities) → Handler → Audit(outcome, before/after, authz path).


9. Integration model

9.1 Tiers

TierWhoWhereContractVersion
A. In-tree Go integrations (externaldns, webhook; v0.2 cloudflare, unifi; later libdns, tailscale, proxmox, mqtt)kudurru repoin-process, leader-scoped per instance (Lease integration-<ns>-<name>), never on the query pathGo interfaces below, registered at init() (Caddy lifecycle: New → strict unmarshal → Provision → Validate → Start → Stop)framework v0.1
B. Out-of-process HTTP integrationsthird partiessidecar / Deployment / anywherethe external-dns webhook routes verbatim + kudurru extensions (/clients, /networks, /events, /actions) negotiated by application/kudurru.integration+json;version=1; 5xx-only retries, 4xx permanent, 32 MiB cap, bearer or mTLSserver side v0.1 (kudurru as an external-dns target); client side + kudurru integrations webhook conformance v0.2
C. go-plugin subprocesses for query-path hooksthird partiessubprocess, gRPC, AutoMTLS.proto of the pipeline stage hooksnot before 1.x, only on demand [integrations §5.1]

Tier B wire shape (v0.2 client side; the server side of the four external-dns routes is v0.1, §5.6): GET / returns {filters:[...], capabilities:[records|clients|hooks|actions], manifest:{...}} when the kudurru media type is accepted; GET /clients → [Client], GET /networks → [Network] (the ClientSource shapes of §9.2); POST /events ← Event with X-Kudurru-Signature: sha256=<HMAC of body with the integration's secret> and X-Kudurru-Delivery id (2xx acknowledges, 5xx retries with backoff + dead-letter after 24 h, 4xx drops and raises Degraded); GET /actions → [ActionSpec{name, params JSON Schema, rbac verb}], POST /actions/{name} ← params [integrations §5.5].

No runtime-loaded in-process code, ever (Technitium's privilege-escalation path) [integrations §7.1, technitium §21.4].

9.2 Interfaces (internal/integration)

type Manifest struct {
    Type, DisplayName, Version string
    Capabilities []Capability      // RecordSink | ClientSource | Hook | Actions
    IOClass      string            // cloud_polling | local_polling | local_push
    ConfigSchema json.RawMessage   // JSON Schema; also becomes the Integration CRD one-of block and the UI form
    SecretKeys   []SecretKey       // which spec fields are secret refs + how to verify them
    Docs         string
}
type Integration interface {
    Manifest() Manifest
    Provision(ctx context.Context, host Host, spec json.RawMessage, secrets SecretResolver) error // strict unmarshal
    Validate(ctx context.Context) error              // offline
    Check(ctx context.Context) (Status, error)       // network: auth + reachability (token verify, /v1/info)
    Start(ctx context.Context) error                 // runs until cancelled; leader-scoped
    Stop(ctx context.Context) error
}
type RecordSink interface {                          // outbound; Endpoint/Changes are external-dns wire shapes
    DomainFilter() DomainFilter
    Records(ctx context.Context, zone string) ([]Endpoint, error)
    AdjustEndpoints(ctx context.Context, eps []Endpoint) ([]Endpoint, error)
    ApplyChanges(ctx context.Context, zone string, ch Changes) (ApplyResult, error)   // returns provider IDs for the ledger
}
type ClientSource interface {                        // inbound inventory
    ListClients(ctx context.Context) ([]Client, error)     // {ID, MACs, IPs, Name, Hostname, Network{Name,VLAN,CIDR,Domain}, FixedIP, Tags, LastSeen, Attributes}
    ListNetworks(ctx context.Context) ([]Network, error)
}
type Hook interface { Topics() []string; Handle(ctx context.Context, ev Event) error }   // at-least-once; host retries + dead-letter
type ActionProvider interface { Actions() []ActionSpec; Invoke(ctx context.Context, name string, params json.RawMessage) (json.RawMessage, error) }
type Host interface {                                // what the host lends an integration
    Zones() ZoneReader; Inventory() InventoryWriter; Ledger(name string) Ledger; Events() EventBus
    Logger() *slog.Logger; Metrics() prometheus.Registerer; Status() StatusWriter; Clock() clock.Clock
    Resolve(ctx context.Context, q dns.Question) (*dns.Msg, error)   // re-enter the pipeline (Technitium DirectQuery)
}
func Register(m Manifest, ctor func() Integration)   // panics on duplicate type

Host-owned, not per-integration: polling cadence, jitter, backoff, soft/hard error classification (429/5xx/EOF → requeue honouring Retry-After), rate budgets, dry-run preview, status conditions Ready/Authenticated/Synced/Degraded, secret-rotation re-provisioning (file watch / Secret informer), the plan engine (per-(dnsName,setIdentifier) table grouped by type, CNAME exclusivity, owner mismatch → skip + Conflict, policies sync|upsert-only|create-only; ~400 lines re-implemented with a conformance test against external-dns plan_test.go cases) and the ledger ((integration, zone, name, type) → {providerIDs[], contentHash, generation, lastApplied} as an internal kind) [integrations §5.2, §5.4].

Lifecycle events (the hook Technitium lacks): zone.changed{zone, serial, delta}, record.changed, client.seen|left, query.blocked (sampled), blocking.toggled, integration.error, cluster.leader_changed, vip.master_changed. Debounced 2-5 s; every sink also has a periodic full resync (default 10 m) and a manual "sync now" [integrations §7.9].

9.3 Declaring integrations: one schema, two carriers

# Docker: kudurru.yaml → objects[] or objects.d/           # Kubernetes: kind Integration (v0.2)
apiVersion: kudurru.io/v1alpha1
kind: Integration
metadata: { name: cf-public, namespace: dns }
spec:
  type: cloudflare
  credentials: { apiToken: { secretRef: { name: cloudflare-token, key: token } } }   # Docker: { file: /run/secrets/cf_token }; never inline
  cloudflare:
    zones:
    - zone: example.com
      zoneID: 023e105f4ecef8ad9ca31a8372d0c353          # optional; resolved via Zone:Read
      select: { labels: { kudurru.io/publish: cloudflare }, types: [A, AAAA, CNAME, TXT, MX, SRV, CAA], exclude: ["*.internal.example.com"] }
      defaults: { proxied: false, ttl: 1, comment: "kudurru:owner=home" }
      policy: sync                                      # sync | upsert-only | create-only
      adoptFrom: { externalDNSOwner: k8s-home }         # read heritage=external-dns TXT records and adopt
      adoptUnowned: false
    deletionPolicy: Retain
  resync: 10m
  suspend: false
status:
  conditions:
  - { type: Authenticated, status: "True", reason: TokenVerified }
  - { type: Synced, status: "False", reason: RateLimited, message: "429 retry-after 37s" }
  - { type: Ready, status: "True", reason: Running }
  lastSyncTime: "2026-10-10T21:03:11Z"
  records: { managed: 42, drift: 1, conflicts: 0 }
  provider: { rateLimitRemaining: 1150, sdk: cloudflare-go v7.12.0 }

CEL: has(self.cloudflare) == (self.type == 'cloudflare'). Spec never contains secret values; status shows fingerprints only. RBAC verbs: integrations.read|write|secrets|act.

9.4 Worked example 1 — Cloudflare split-horizon (v0.2)

Problem: example.com is authoritative inside (RFC 1918 answers) and public at Cloudflare; a subset (public hosts, ACME TXT, MX/SPF) must exist publicly, some proxied, without a second hand-maintained zone [integrations §2.5].

  1. One zone, one truth. Records opt in with label kudurru.io/publish: cloudflare and/or DNSZone.spec.publish[] rules (selector + name glob + types).
  2. Per-sink overrides and views. providerSpecific: [{cloudflare/targets: "203.0.113.10"}, {cloudflare/proxied: "true"}] publishes a different value than is served internally; an endpoint with kudurru.view: public is only published, never served (e.g. the tunnel CNAME in §4.1). Sink-level translate: {"203.0.113.0/24": "10.0.0.0/24"} offers Technitium-style 1:1 NAT for bulk cases. Proxied records force ttl: 1; only A/AAAA/CNAME are proxiable; proxied public answers are never mirrored inward.
  3. Ownership. Internal ledger + Cloudflare comment: kudurru:owner=<clusterID> (works on Free) + tag kudurru-owner:<id> when the plan allows; external-dns heritage= TXT records are read for adoptFrom migrations, never written. Unowned records are never modified unless adoptUnowned: true; foreign-owned → Conflict + skip.
  4. Reconcile (leader, level-triggered). desired = select(zone) ▷ overrides/views ▷ AdjustEndpoints; current = GET /zones/{id}/dns_records?per_page=5000[&comment.startswith=kudurru:] cached per resync; plan; apply via POST /zones/{id}/dns_records/batch in 200-op chunks (deletes → patches → puts → posts), 1 s between chunks, per-record fallback on chunk failure; a shared token bucket budgeted at 1,000 of the 1,200 requests / 5 min per user limit (cumulative across dashboard, keys and tokens of that user; exceeding it blocks all calls for 5 min [integrations §2.1]) across every Cloudflare sink whose token belongs to the same user; 429/5xx → requeue with Retry-After; status Synced, records{managed,drift,conflicts}, rateLimitRemaining.
  5. Token. GET /user/tokens/verify + GET /zones?name= at Check(); documented scope Zone:Read + DNS:Edit restricted to the zone; cloudflare-go/v7 pinned exactly and wrapped thinly.
  6. Status. DNSZone.status.published[] and DNSRecordSet.status.publications[]{integration, state Accepted|Programmed|Failed} (external-dns DNSRecord idiom) so kubectl get rrset shows whether Cloudflare is in sync; kudurru integrations preview cf-public prints the dry-run plan.

9.5 Worked example 2 — UniFi Network (v0.2)

Inbound ClientSource (polling, official Integration API X-API-Key at /proxy/network/integration/v1: sites, clients, networks, DNS policies with offset/limit paging) via unpoller/unifi/v6 (read-only library [integrations §3.4]); legacy /rest/user, /stat/sta, /v2/api/site/<site>/active-leases only for alias/hostname/fixed-IP/VLAN/leases, behind version detection (GET /v1/info) and per-endpoint 404 tolerance [integrations §3]. The optional push paths below need writes that unpoller does not provide: a thin in-house client (≈ 200 lines, X-API-Key) over the official PUT /v1/sites/{site}/networks/{id} and POST|PUT /v1/sites/{site}/dns/policies endpoints, and the legacy cmd/stamgr for block/unblock behind the same legacy flag; filipowm/go-unifi (MPL-2.0) is acceptable as an unmodified dependency if the in-house client grows.

apiVersion: kudurru.io/v1alpha1
kind: Integration
metadata: { name: unifi-home, namespace: dns }
spec:
  type: unifi
  credentials: { apiKey: { secretRef: { name: unifi-key, key: apiKey } } }      # Docker: { file: /run/secrets/unifi_key }
  unifi:
    endpoint: https://192.168.1.1
    tlsFingerprint: "sha256:…"                        # instead of insecureSkipVerify
    site: default
    poll: 60s
    inventory: { zoneRef: { name: lan-example-com }, reverseZones: auto, nameTemplate: "{{ .Alias | default .Hostname | slug }}", ttl: 120, includeNetworks: [LAN, IoT, Kids] }
    groups: { byNetwork: { IoT: iot, Kids: kids, Guest: guest }, byNote: "kudurru:group=" }
    push: { dhcpDNSServers: false, mirrorRecords: { enabled: false, types: [A, CNAME] } }

What kudurru does with it: networks → Network inventory → auto-created reverse zones and ClientGroup membership by VLAN; clients → synthesised A/AAAA + PTR DNSRecordSets (managedBy: integration:unifi-home, read-only "synthesised" badge, deterministic slug dedupe -2, stable per MAC, TTL 120, expire after 3 missed polls); identify (§7.1 step 3) matches by MAC through the same inventory; client.seen/left events for Hooks. Optional push (integrations.act): write the VIP into DHCP DNS servers, mirror selected records into UniFi DNS policies so the gateway answers if kudurru is down, block-client/unblock-client dashboard buttons via stamgr.

9.6 external-dns (v0.1, inbound)

kudurru serve --externaldns-webhook :8888 (or the externalDNS.webhook chart values) serves the four routes over the zone store; external-dns --provider=webhook --webhook-provider-url=http://localhost:8888 makes Ingress/Service/Gateway hostnames flow into kudurru zones with zero kudurru-side Kubernetes code. In v0.1 because it is a thin HTTP façade over the record model and proves the Endpoint superset early.


10. Dashboard / UI plan and API surface

"Pi-hole-grade" means the twelve principles extracted in [pihole §2], re-derived (EUPL code/markup never copied), plus multi-node from day one:

  1. Four stat tiles — Total queries · Blocked · % blocked · Domains on lists (+ active/total clients in the footer) — each a link into the pre-filtered query log; one GET /stats/summary feeds tiles and the sidebar (QPS, status dot, blocking countdown, per-node health dots).
  2. 24 h stacked bars (total/blocked/cached/forwarded/authoritative/other from rollup_1m at step=auto), per-client stacked chart with "other clients"; click a bucket → log ±5 min. uPlot for the live series, ECharts for pies/top lists.
  3. Everything drills down: top domains/clients/upstreams/lists rows → queries?domain=…&status_class=blocked, etc.
  4. One colour code via semantic CSS classes q-permitted / q-cached / q-blocked / q-error; charts read colours from CSS; themes auto/light/dark/high-contrast(+dark)/one fun theme, delivered as Astryx theme packages (D69).
  5. Query log: keyset pagination (cursor on id, stable under live inserts), records_total/filtered, earliest_ts_memory|disk, /queries/suggestions, wildcard search, source=auto|memory|disk, live tail over SSE, per-row Allow/Deny (POST /queries/{id}/actions) with toast, expandable "why" row (status, matched list + rule, CNAME chain, upstream, cache/stale, DNSSEC, EDE, reply type/time, node, trace link).
  6. Lists / Groups / Policies / Clients pages with Default group auto-assignment, comment and enabled on everything, multi-add arrays returning processed{success,errors}, :batchDelete, list health (OK/unchanged/unavailable-cached/invalid, counts, last update), "managed by file/Kubernetes/integration" badges with the owner.
  7. Disable blocking with countdown (10 s / 30 s / 5 m / custom / indefinite) — cluster-wide.
  8. Diagnosis messages badge (regex errors, CIDR tie, list unreachable, cert expiry, disk/WAL size, serial divergence between nodes, dnsdist backend down, serving-from-snapshot, fenced/follower read-only, 2-node quorum warning).
  9. Node selector + "all nodes" on every page; Nodes page (version, roles, ready, qps, leader, VIP master, snapshot age) from scatter-gather.
  10. Settings: Basic/Expert toggle; "All settings" page generated from the config metadata with data-configkey binding; hostOnly/env-locked keys shown read-only with the reason.
  11. Zones: list (type, serial, records, Ready, managed-by), typed per-RR record editor (A/AAAA/CNAME/MX/SRV/TXT/CAA/HTTPS/SVCB/NS/PTR/TLSA/SSHFP/NAPTR/raw), BIND import/export, transfer/notify status, "publish to" column and DNSSEC panel (v0.2/v1.0).
  12. Long-term views (the most-requested missing Pi-hole v6 feature [pihole §17.3]): date-range history and top lists from rollups with approximate: true badges when top-N merging is lossy — in v0.1.
  13. Cluster page (nodes, leader, edge/dnsdist backends from /edge, VIP owners, join wizard emitting compose/Helm snippets in v0.2), Integrations page (manifest-driven form, conditions, preview plan, inventory view), IAM, Audit, Tools (dig, import, backup/export, doctor).

Stack (D69): Bun toolchain, React 19.2 + TypeScript, Astryx components and theming (prebuilt CSS, themes as CSS custom properties, swizzle for deep customisation), TanStack Query (+ Table/Virtual for the query log), uPlot for live series and ECharts for pies/top lists until @astryxdesign/charts is stable; generated TypeScript client from the huma OpenAPI; SSE via EventSource; strict CSP; no CDN; go:embedded; path-prefix aware. Breaking API changes are versioned (/api/v1) and deprecated routes survive one major [pihole §17.6].

API surface (/api/v1; every body carries took_ms; errors {error:{key,message,hint}}; 201+Location; 204 on delete; If-Match on resourceVersion; scope=node|cluster&node=<name> on stats/log calls):

auth:      GET /auth/status · POST /auth/login · GET /auth/oidc/{idp}/start|callback · POST /auth/logout · GET /auth/me · POST /auth/password
           POST|GET|DELETE /auth/tokens[/{id}] · GET|DELETE /auth/sessions[/{id}] (v0.2, db store) · POST /auth/backchannel-logout (v1.0)
objects:   GET|PUT|PATCH|DELETE /objects/{kind}[/{ns}]/{name} · GET /objects/{kind} · POST /apply (multi-doc YAML, whole-object apply)   # what `kudurru get|apply` uses
zones:     GET|POST /zones · GET|PUT|PATCH|DELETE /zones/{zone} · GET|POST /zones/{zone}/records · PUT|DELETE /zones/{zone}/records/{name}/{type}
           POST /zones/{zone}/import (bind) · GET /zones/{zone}/export · POST /zones/{zone}/notify · POST /zones/{zone}/sign (v1.0) · GET /zones/{zone}/bindings
lists:     CRUD /blocklists · POST /blocklists/{name}/refresh · GET /blocklists/{name}/search?domain=
policy:    CRUD /clientgroups · CRUD /policies · GET /clients (inventory) · GET /clients/_suggestions
           POST /domains/{allow|deny}/{exact|regex} (arrays; processed{success,errors}) · POST /{kind}:batchDelete
blocking:  GET|POST /dns/blocking {blocking,timer,groups?} · POST /cache/flush · POST /dns/query (built-in dig)
stats:     GET /stats/summary · /stats/timeseries · /stats/top?dim= · /stats/clients/timeseries · /stats/upstreams · /stats/recent_blocked · GET /padd
log:       GET /queries · /queries/suggestions · /queries/export · SSE /queries/stream · POST /queries/{id}/actions · DELETE /querylog · GET /querylog/status
nodes:     GET /nodes · GET /nodes/{name}/health · GET /cluster · POST /cluster/promote (v0.2) · POST /cluster/join-token (v1.0) · GET /edge (dnsdist backends, VIP owners)
settings:  GET|PATCH /settings (cluster) · GET|PATCH /node/config (hostOnly keys rejected) · GET /settings/_schema
iam:       CRUD /iam/users|groups|serviceaccounts|rolebindings|identityproviders   (v0.1 via YAML; API v0.2)
integr.:   CRUD /integrations · POST /integrations/{name}/sync|check|preview · POST /integrations/{name}/actions/{a}   (v0.2)
audit:     GET /audit · GET /audit/export
misc:      GET /endpoints · GET /openapi.json · GET /docs · GET /info/messages (diagnosis) · GET /version
external:  /externaldns/* (external-dns webhook contract, :8888)
internal:  :8082 /internal/status · /internal/zones (serial map) · /internal/lists/{digest} · /internal/events (SSE) · /internal/snapshot (follower pull, v0.2)   # node/internal-token auth only
compat:    optional Pi-hole v6 façade under /api/pihole/* (v1.0 module)

11. Observability

11.1 Metrics (Prometheus, :9153/metrics, OpenMetrics with exemplars when tracing is on)

Naming: single prefix kudurru_, base units, _total, _info; node comes from the scrape, never from kudurru. Bounded labels only: proto, family, qtype (fixed list + other), rcode, status (≤ 32), status_class, zone (configured zones, else _other), upstream/list/group (configured names, ≤ 64 else _other), sink, role, vip, integration. client and domain are never labels (metrics.clientLabels: topN opt-in only). Budget < 10 k series per node [observability §3.2].

kudurru_build_info{version,revision,goversion}   kudurru_up_since_seconds   kudurru_config_reloads_total{result}
kudurru_role_active{role}   kudurru_leader{role}   kudurru_blocking_enabled{group}
kudurru_dns_requests_total{proto,family,qtype,zone}   kudurru_dns_responses_total{proto,rcode,status,zone}
kudurru_dns_request_duration_seconds{proto,status_class}   (buckets .0005 … 1 + native)   kudurru_dns_request_size_bytes{proto}   kudurru_dns_response_size_bytes{proto}
kudurru_dns_inflight_requests{proto}   kudurru_dns_dropped_total{reason}  (proxy_unauthenticated, proxy_untrusted_peer, proxy_malformed, ratelimit)   kudurru_ratelimit_buckets   kudurru_dns_do_requests_total   kudurru_dns_edns_total{feature}
kudurru_dns_tcp_connections{proto}   kudurru_dns_tls_handshakes_total{proto,result}   kudurru_dns_doh_http_responses_total{status}
kudurru_block_decisions_total{action,source,group}   kudurru_block_list_hits_total{list}
kudurru_blocklist_entries{list}   kudurru_blocklist_last_update_timestamp_seconds{list}   kudurru_blocklist_update_failures_total{list}   kudurru_blocklist_stale{list}
kudurru_cache_entries{kind}   kudurru_cache_requests_total   kudurru_cache_hits_total{kind,stale}   kudurru_cache_evictions_total{reason}   kudurru_cache_prefetches_total
kudurru_upstream_requests_total{upstream,proto}   kudurru_upstream_responses_total{upstream,rcode}   kudurru_upstream_request_duration_seconds{upstream}
kudurru_upstream_errors_total{upstream,error}   kudurru_upstream_healthy{upstream}   kudurru_upstream_inflight{upstream}
kudurru_zone_records{zone,kind}   kudurru_zone_serial{zone}   kudurru_zone_transfers_total{zone,direction,result}   kudurru_dnssec_key_expiry_timestamp_seconds{zone,key}
kudurru_querylog_events_total   kudurru_querylog_dropped_total{stage,sink}   kudurru_querylog_batch_rows   kudurru_querylog_write_duration_seconds
kudurru_querylog_db_bytes   kudurru_querylog_wal_bytes   kudurru_querylog_rows   kudurru_querylog_oldest_timestamp_seconds
kudurru_sink_sent_total{sink}   kudurru_sink_errors_total{sink}   kudurru_sink_queue_length{sink}   kudurru_sink_last_success_timestamp_seconds{sink}
kudurru_store_operations_total{op,result}   kudurru_store_operation_duration_seconds{op}   kudurru_store_watch_resyncs_total{kind} (zonecache re-lists)
kudurru_store_watch_catchups_total{kind} (driver feed catch-ups)   kudurru_store_watchers
kudurru_store_snapshot_serving   kudurru_store_snapshot_age_seconds   kudurru_store_schema_version
kudurru_cluster_members   kudurru_cluster_leader_changes_total   kudurru_cluster_fenced   kudurru_cluster_epoch   kudurru_raft_{term,commit_index,last_contact_seconds} (v1.0)
kudurru_api_requests_total{method,route,code}   kudurru_api_request_duration_seconds{route}   kudurru_auth_events_total{kind,method}   kudurru_audit_events_total{result}
kudurru_health_check_duration_seconds   kudurru_health_check_failures_total
kudurru_vip_master{vip}   kudurru_vip_weight{vip}   kudurru_edge_backend_up{backend} (supervisor, from the dnsdist API)
kudurru_integration_sync_duration_seconds{integration}   kudurru_integration_changes_total{integration,op}   kudurru_integration_errors_total{integration,class}   kudurru_integration_rate_limit_remaining{integration}

dnsdist's own dnsdist_* metrics (webserver /metrics, prometheus_add_instance) are scraped alongside.

11.2 Query log pipeline and SQLite schema (per node)

listener workers ─(per-worker SPSC ring 4096, non-blocking; overflow = drop + metric)─▶ collector
  collector: assign id, apply privacy transform ONCE, dictionary ids via LRU, update minute accumulators, append to hot ring (65 536)
  ├─▶ batch writer: 1 000 rows or 1 s → one BEGIN IMMEDIATE…COMMIT (prepared statements); 30 s completion grace for in-progress queries
  ├─▶ rollup flush at minute boundary → rollup_1m/_client_1m/_qtype_1m/_upstream_1m; hourly/daily jobs derive _1h/_1d and domain/list/zone hourly tables
  └─▶ sinks (bounded queues, drop counters, power-of-ten warnings): dnstap (v0.2), clickhouse, loki, webhook, otlp_logs (v0.2+)
-- /var/lib/kudurru/querylog.db   [observability §2.3]
PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA auto_vacuum=INCREMENTAL;
PRAGMA wal_autocheckpoint=2000; PRAGMA mmap_size=268435456; PRAGMA busy_timeout=1000; PRAGMA temp_store=MEMORY;

CREATE TABLE meta (key TEXT PRIMARY KEY, value) WITHOUT ROWID;      -- schema_version (2; older files are upgraded in place on open), node_id, first_ts, privacy_mode_at_write

CREATE TABLE dict_domain   (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, etld1 TEXT, first_seen INTEGER, last_seen INTEGER);
CREATE TABLE dict_client   (id INTEGER PRIMARY KEY, ip TEXT NOT NULL, name TEXT, grp TEXT, first_seen INTEGER, last_seen INTEGER, UNIQUE(ip,name));
CREATE TABLE dict_upstream (id INTEGER PRIMARY KEY, addr TEXT NOT NULL UNIQUE);     -- "tls://1.1.1.1:853", "cache", "zone:example.com"
CREATE TABLE dict_zone     (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);
CREATE TABLE dict_list     (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);     -- blocklist / rule source
CREATE TABLE dict_node     (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);
CREATE TABLE dict_policy   (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);     -- "namespace/name" of the deciding policy (schema 2)

CREATE TABLE query (
  id          INTEGER PRIMARY KEY,          -- rowid, monotonic per node (time-ordered); keyset cursor
  ts          INTEGER NOT NULL,             -- unix µs
  latency_us  INTEGER,                      -- NULL if unanswered
  node        INTEGER NOT NULL REFERENCES dict_node,
  proto       INTEGER NOT NULL,             -- 0 udp 1 tcp 2 dot 3 doh 4 doq 5 dnscrypt
  client      INTEGER NOT NULL REFERENCES dict_client,
  domain      INTEGER NOT NULL REFERENCES dict_domain,
  qtype       INTEGER NOT NULL, qclass INTEGER NOT NULL DEFAULT 1,
  status      INTEGER NOT NULL,             -- taxonomy below; >= 16 = not answered normally
  rcode       INTEGER NOT NULL,
  reply_type  INTEGER NOT NULL,             -- ip, cname, nodata, nxdomain, … blob
  upstream    INTEGER REFERENCES dict_upstream,
  zone        INTEGER REFERENCES dict_zone,
  list        INTEGER REFERENCES dict_list,
  rule        TEXT,                         -- matched rule text (bounded 256 B), NULL unless blocked
  cname       INTEGER REFERENCES dict_domain,
  dnssec      INTEGER NOT NULL DEFAULT 0,   -- unknown, secure, insecure, bogus, indeterminate
  ede         INTEGER,
  flags       INTEGER NOT NULL DEFAULT 0,   -- RD, DO, TC, AD, CD, cached, stale, prefetch, ecs, traceparent
  ecs         TEXT,
  trace_id    BLOB,                         -- 16 bytes, NULL unless traced
  answer      TEXT,                         -- short summary (first ~3 RRs), NULL in restricted privacy modes
  policy      INTEGER REFERENCES dict_policy -- deciding policy (schema 2; NULL on older rows and when no policy applied)
);
-- Deliberately four indexes only (each costs ~10-15 B/row and write amplification):
CREATE INDEX query_ts        ON query(ts);
CREATE INDEX query_client_ts ON query(client, ts);
CREATE INDEX query_domain_ts ON query(domain, ts);
CREATE INDEX query_status_ts ON query(status, ts) WHERE status >= 16;   -- partial: blocked/error rows (recent-blocked list)

CREATE TABLE rollup_1m (
  ts INTEGER NOT NULL, node INTEGER NOT NULL,
  total INTEGER NOT NULL, blocked INTEGER NOT NULL, cached INTEGER NOT NULL, forwarded INTEGER NOT NULL, authoritative INTEGER NOT NULL, dropped INTEGER NOT NULL,
  rc_noerror INTEGER, rc_nxdomain INTEGER, rc_servfail INTEGER, rc_refused INTEGER, rc_other INTEGER,
  udp INTEGER, tcp INTEGER, dot INTEGER, doh INTEGER, doq INTEGER,
  lat_sum_us INTEGER, lat_cnt INTEGER,
  lat_b0 INTEGER, lat_b1 INTEGER, lat_b2 INTEGER, lat_b3 INTEGER, lat_b4 INTEGER, lat_b5 INTEGER,
  lat_b6 INTEGER, lat_b7 INTEGER, lat_b8 INTEGER, lat_b9 INTEGER, lat_b10 INTEGER, lat_b11 INTEGER,
  -- bucket upper bounds (ms): 0.5,1,2,5,10,25,50,100,250,500,1000,+Inf  (identical to the Prometheus histogram so both agree)
  PRIMARY KEY (ts, node)) WITHOUT ROWID;
CREATE TABLE rollup_client_1m   (ts INTEGER, node INTEGER, client INTEGER, total INTEGER, blocked INTEGER, PRIMARY KEY (ts,node,client)) WITHOUT ROWID;
CREATE TABLE rollup_qtype_1m    (ts INTEGER, node INTEGER, qtype INTEGER, total INTEGER, PRIMARY KEY (ts,node,qtype)) WITHOUT ROWID;
CREATE TABLE rollup_upstream_1m (ts INTEGER, node INTEGER, upstream INTEGER, total INTEGER, errors INTEGER, timeouts INTEGER, lat_sum_us INTEGER, lat_cnt INTEGER,
                                 lat_b0 INTEGER, lat_b1 INTEGER, lat_b2 INTEGER, lat_b3 INTEGER, lat_b4 INTEGER, lat_b5 INTEGER,
                                 lat_b6 INTEGER, lat_b7 INTEGER, lat_b8 INTEGER, lat_b9 INTEGER, lat_b10 INTEGER, lat_b11 INTEGER,
                                 PRIMARY KEY (ts,node,upstream)) WITHOUT ROWID;
CREATE TABLE rollup_domain_1h   (ts INTEGER, node INTEGER, domain INTEGER, total INTEGER, blocked INTEGER, PRIMARY KEY (ts,node,domain)) WITHOUT ROWID;
CREATE TABLE rollup_list_1h     (ts INTEGER, node INTEGER, list INTEGER, hits INTEGER, PRIMARY KEY (ts,node,list)) WITHOUT ROWID;
CREATE TABLE rollup_zone_1h     (ts INTEGER, node INTEGER, zone INTEGER, total INTEGER, PRIMARY KEY (ts,node,zone)) WITHOUT ROWID;
-- rollup_1h, rollup_client_1h, rollup_qtype_1h, rollup_upstream_1h, rollup_1d, rollup_client_1d, rollup_domain_1d have identical shapes.
CREATE INDEX rollup_domain_1h_ts ON rollup_domain_1h(ts);

Status taxonomy (status column; API returns symbolic names; UI colours by class): 0 unknown, 1 forwarded, 2 cached, 3 cached_stale, 4 authoritative, 5 local_record, 6 prefetch_refresh, 7 special_domain, 8 retried, 9 in_progress, 16 blocked_list, 17 blocked_regex, 18 blocked_exact, 19 blocked_cname, 20 blocked_upstream, 21 blocked_rpz (RPZ feed hits, live from v0.2 per D61), 22 blocked_safesearch, 23 blocked_rate_limit, 24 refused_acl, 25 dropped, 26 servfail_upstream, 27 timeout_upstream, 28 dnssec_bogus.

Retention: daily at a random 03:10-03:50 minute and whenever maxDBBytes is exceeded, deleting in 10 000-row batches with incremental_vacuum; dictionaries pruned by last_seen; rollups _1m 35 d, _1h 400 d, _1d forever; wal_checkpoint(TRUNCATE) every 5 min only when no reader is active; weekly ANALYZE; PRAGMA optimize on close. Sizing: ~100 B/row → ~0.9 GB/day at 100 qps; defaults raw 30 d capped at 10 GiB; at 1 000 qps keep 2 days raw and point users at the ClickHouse sink [observability §2.4, §2.5].

Privacy at ingest, irreversible: full | anonymize_client (/24,/48 or daily-HMAC pseudonym) | hide_domains | hide_domains_clients | nothing; exclude.{clients,domains,sudn}; per-policy queryLog: false. Raw dnstap export is a separate, audited switch [observability §2.6].

Multi-node: per-node truth; scope=cluster = scatter-gather with 2 s per-node timeout, partial: true and nodes[] on failure; exact sums for additive rollups (UTC-aligned buckets); top-N merged from n*4 per node and flagged approximate with cutoff_rank; uniques by union (≤ 10 k) or HLL; cluster cursors encode per-node cursors; live tail multiplexes per-node SSE streams; exact long-range cluster search uses the optional ClickHouse sink (source=central). Never Gravity's in-etcd tsdb [observability §5].

// internal/querylog/sink.go — every external sink; the SQLite writer is the one built-in consumer that may apply back-pressure to nothing (it drops + counts)
type Sink interface {
    Name() string
    Open(ctx context.Context, cfg json.RawMessage, m prometheus.Registerer) error
    Write(batch []*QueryEvent) error      // called from a dedicated goroutine with a bounded queue (default 10 000 events); never from the query path
    Flush(ctx context.Context) error
    Close() error
}
// host owns: queue sizing, drop counters (`kudurru_querylog_dropped_total{stage="sink",sink}`), power-of-ten warnings, retry/backoff, privacy transform applied before enqueue, and whether wire buffers are retained (only while a raw sink such as dnstap is registered)

11.3 dnstap (v0.2)

Native emission of CLIENT_QUERY/RESPONSE, FORWARDER_QUERY/RESPONSE (AUTH_* for local zones) via golang-dnstap SocketWriter, queue 10 000, identity=node, version=kudurru/<ver>, extra="status=… list=… trace=…"; dnstap listen mode ingests frames from dnsdist/Unbound sidecars into the same log; per-type switches and a sample rate; raw: true is an audited admin switch independent of the privacy mode. DNS-collector is the documented path to ClickHouse/Loki/Kafka.

11.4 Audit (v0.1)

One immutable AuditEvent per mutating API/CLI/reconcile/integration/VIP/auth action: {id ULID, ts, node, actor{kind,id,name,groups,auth}, action "<resource>.<verb>", resource{kind,id,sub}, result success|denied|error, reason, authz sar|binding|local, diff{before,after} (secrets redacted, 4 KiB cap), request{id,ip,ua,method,path,trace_id}, prev_hash, hash}; stored in the node's SQLite (state.db audit table in the same transaction as the mutation for the sqlite driver; local.db for the kube driver), hash-chained, 400 d retention; also emitted as a JSON line on stdout ("log":"audit") so Kubernetes log pipelines capture it; optional Kubernetes Event on the owning CR; GET /audit with RBAC audit.read [observability §4.2]. Denied attempts are audited from the authz layer. In Kubernetes the per-pod local.db rows live on emptyDir and disappear with the pod unless persistence.local is enabled; the durable paths there are the stdout JSON line (log pipeline) and Events, the apiserver audit log covers CRD writes, and GET /audit?scope=cluster scatter-gathers per-pod rows (partial: true when a pod is unreachable) rather than pretending the table is replicated.

11.5 Health, logging, tracing

Probes as in §5.2; CHAOS id.server/version.bind/hostname.bind + NSID; health.kudurru.internal. TXT for dnsdist and keepalived; kudurru lameduck 5s pre-stop. Logs: log/slog JSON with per-role levels (KUDURRU_LOG__LEVEL=info,dns=debug,raft=warn), request-scoped attributes, trace/span ids; the query log is not the app log. Tracing (v0.2): OTel SDK, OTLP exporter, parentbased_traceidratio 0.001 + tail-keep on error/slow (> 250 ms); accept EDNS TRACEPARENT (option 65500, configurable) only from configured proxies (dnsdist), strip before forwarding to public upstreams, re-add only to upstreams flagged tracePropagation: true; spans kudurru.dns.query → acl / blocking.match / cache.lookup / zone.lookup / dnssec.validate / upstream.exchange with OTel DNS semconv attributes; trace_id stored in the query row, as a histogram exemplar, and linked from the UI [observability §4.3].

11.6 Shipped dashboards and alert rules (v0.1)

deploy/grafana/kudurru-overview.json (rows: tiles, traffic, latency heatmap, blocking, cache, upstreams, zones + serial-divergence panel (max by (zone)(kudurru_zone_serial) - min by (zone)(kudurru_zone_serial)), pipeline, cluster/API, a dnsdist row from dnsdist_*, VIP row) and deploy/prometheus/rules.yaml as a PrometheusRule:

AlertExpression (sketch)For
KudurruNoLeadermax(kudurru_leader{role="controller"}) == 02 m
KudurruServfailRatiosum(rate(kudurru_dns_responses_total{rcode="SERVFAIL"}[5m])) / sum(rate(kudurru_dns_responses_total[5m])) > 0.055 m
KudurruUpstreamUnhealthykudurru_upstream_healthy == 02 m
KudurruQuerylogDropsincrease(kudurru_querylog_dropped_total[10m]) > 00 m
KudurruQuerylogNearCapkudurru_querylog_db_bytes / <max_db_bytes> > 0.930 m
KudurruSerialDivergencemax by (zone)(kudurru_zone_serial) != min by (zone)(kudurru_zone_serial)10 m
KudurruServingFromSnapshotkudurru_store_snapshot_serving == 115 m
KudurruSnapshotStalekudurru_store_snapshot_age_seconds > 432000 m
KudurruDNSSECKeyExpiringkudurru_dnssec_key_expiry_timestamp_seconds - time() < 7*864000 m
KudurruLatencyP95Highhistogram_quantile(0.95, …kudurru_dns_request_duration_seconds…) > 0.2510 m
KudurruEdgeBackendDownkudurru_edge_backend_up == 0 or dnsdist_server_*{state="down"}2 m
KudurruVIPNoMastermax by (vip)(kudurru_vip_master) == 01 m
KudurruClusterFencedkudurru_cluster_fenced == 10 m
KudurruBlocklistStaletime() - kudurru_blocklist_last_update_timestamp_seconds > 3*<refreshInterval>0 m

12. Repository layout, build, CI, release artifacts

Go module github.com/robertherbaugh/kudurru (confirmed by the git remote; ratify in §15 Q1). Single module, single binary, no cgo, kubebuilder-style api/ so CRD types, store types, REST types and docs come from one set of structs.

kudurru/
├── go.mod                            module github.com/robertherbaugh/kudurru  (go 1.27)
├── cmd/kudurru/                      cobra root: serve · login · get/apply/delete/describe · zones · records · lists · blocking · query · health · lameduck
│                                     · backup/restore/export · import · edge {render,run} · vip {render,run,fifo} · cluster {status,promote,join-token}
│                                     · integrations {list,check,sync,preview,webhook conformance} · admin {reset-password,create-token} · config {get,set,schema}
│                                     · doctor · api · version · completion
├── api/v1alpha1/                     Go types + kubebuilder markers for every kind (CRDs AND the sqlite driver's schema); deepcopy, register, CEL rules
├── internal/
│   ├── config/                       koanf loader, JSON Schema, env/_FILE, settings metadata table (schema.go: hostOnly/envLocked/restartRequired/writeOnly), docs generator
│   ├── dnsserver/                    listeners (udp/tcp/dot/doh[/doq/doh3]), proxyproto, EDNS/TC handling, chaos/health names, TLS reload
│   ├── pipeline/                     Stage contract, fixed chain, Request/Response, Status taxonomy
│   ├── stages/{ingress,identify,control,local,authoritative,blocking,rewrite,cache,dnssec,upstream,postprocess,log}/
│   ├── zone/                         RRset model (Endpoint superset), tree, lookup, serial/contentHash, journal, xfr (out; in v0.2), notify, bind import/export, catalog (v1.0)
│   ├── dnssec/                       (v0.2 validator; v1.0 signer, NSEC/NSEC3 chains, RFC 7583 key state machine, CDS/CDNSKEY)
│   ├── blocking/                     matchers (from blocky, NOTICE), adblock parser (clean-room), list fetcher, compile/snapshot, conformance corpus
│   ├── policy/                       client identification, ClientGroup/Policy index, schedules
│   ├── cache/                        sharded TTL message cache, prefetch, serve-stale, coalescing, persistence
│   ├── upstream/                     dnsproxy upstream groups, strategies, conditional map, bootstrap, ECS
│   ├── store/                        Store interface; validator (apiextensions + CEL); schema versions/migrations; conformance/
│   │   └── {sqlite,kube,follower,raft}/      drivers (follower v0.2, raft v1.0); kube/ includes local.db snapshot persistence
│   ├── controller/                   one reconciler per kind; status; Events; duplicate detection; serial stamping
│   ├── auth/                         authenticators (bearer kdr_, JWT by iss, k8s SA tokens, cookie, unix peer, internal token), sessions (SessionStore: sealed/db), argon2, oidc
│   ├── rbac/                         permissions.yaml (single source), roles, bindings, Authorizer (local, SAR), route-permission lint, generators (ClusterRoles, docs)
│   ├── api/                          huma routes, envelope, SSE, UI embed, openapi, external-dns webhook server; pihole/ compatibility façade module (v1.0)
│   ├── querylog/                     ring, collector, sqlite writer, rollups, retention, sinks/{dnstap,clickhouse,loki,webhook,otlp}
│   ├── audit/                        hash-chained audit store + stdout emitter + Events
│   ├── metrics/  health/             registry, ops listener, probes, self-query loop, lameduck
│   ├── integration/                  registry, manifest, lifecycle, runner (leader-scoped, per-instance Lease), plan engine, ledger, secrets
│   │   └── {externaldns,webhook,cloudflare,unifi,libdns}/   externaldns+webhook v0.1; cloudflare+unifi v0.2
│   ├── edge/                         topology.go; dnsdist/{render_yaml,render_lua,supervisor,api_client}; keepalived/{render,supervisor,fifo,trackfile}; vip provider interface
│   ├── cluster/                      node identity, membership sources (static|endpointslice|follower|raft), internal listener (:8082), scatter-gather, promote/fencing
│   ├── sources/k8s/                  (v0.2) stripped, indexed informers for Ingress / Gateway API / LoadBalancer Services feeding `DNSZone.type: external`
│   ├── acme/                         (v1.0) certmagic wrapper, DNS-01 solver against kudurru's own zones, leader-only runnable
│   ├── importers/                    bind, pihole (teleporter), adguard, blocky, technitium, corefile
│   └── testutil/                     CoreDNS-style test.Case helpers, fake upstreams, testcontainers helpers, envtest harness
├── web/                              React 19 + Astryx SPA built with Bun; src/api = generated client; dist embedded via web/embed.go (//go:embed all:dist)
├── charts/kudurru/  charts/kudurru-crds/
├── config/                           kubebuilder kustomize tree: crd/bases, rbac (generated from permissions.yaml), manager, samples/, overlays/{metallb,kube-vip,cloud-lb-mixed,hostnetwork-daemonset,standalone,with-edge}
├── deploy/compose/{single,single-edge,ha/node-a,ha/node-b,ha/node-c}/   deploy/grafana/  deploy/prometheus/  deploy/install.yaml (generated)
├── images/{dnsdist,keepalived}/Dockerfile   upstream GPL binaries (digest-pinned) + kudurru supervisor; GPL text + source offer in README
├── docs/                             Hugo/Docsy: per-role pages, config reference (generated), CRD reference (generated), migration guides, integration READMEs, runbooks
├── hack/                             dev compose (dex, BIND9/Knot for XFR interop, kind + MetalLB), bench fixtures, lint rules, 2-VM keepalived e2e
├── test/{e2e,interop,bench,conformance}/
├── .goreleaser.yaml  Makefile  .golangci.yml  go.mod  LICENSE (Apache-2.0)  NOTICE (blocky, dnsproxy, CoreDNS, external-dns attributions)  CONTRIBUTING.md
└── .github/workflows/                ci.yml (lint, unit, race, fuzz, conformance, e2e kind), edge.yml (edge-check + nightly 2-VM VRRP), dev-image.yml, promotion.yml, release-candidate.yml + release.yml → publish.yml (D139), codeql.yml, licenses.yml, cve-bump.yml

Build & CI (GitHub Actions):

  • make generate: controller-gen (CRDs, deepcopy, RBAC), huma OpenAPI dump, openapi-typescript client, Go client, config JSON Schema, config docs, CRD docs, RBAC docs/ClusterRoles from permissions.yaml.
  • make lint: golangci-lint, go vet, route-permission lint, config-metadata/docs drift test, go-licenses denying GPL/EUPL modules, CRD/CEL cost check.
  • make test: unit (-race), store driver conformance suite against sqlite and kube (envtest), pipeline golden tests (CoreDNS test.Case style), fuzz on wire parsers / PROXYv2 / adblock parser / zone file parser, RBAC table tests, plan-engine conformance against external-dns cases.
  • make bench: blocking matcher and cache benchmarks with the +20 % regression gate (results published to GitHub Pages).
  • make edge-check: render dnsdist.yml/dnsdist.lua/keepalived.conf fixtures for every topology and validate them inside the pinned images with dnsdist --check-config / keepalived -t; plus the console -e newServer smoke test (the week-1 spike, kept as a permanent canary).
  • make e2e: testcontainers (dex OIDC, BIND9 secondary doing AXFR/NOTIFY from kudurru, Knot catalog consumer in v1.0, AdGuard Home black-box blocking corpus), kind + Helm install (mixed-protocol Service with MetalLB, Restricted PSS admission, apiserver-outage test: kill the control plane, restart a pod, assert answers from local.db), nightly 2-VM keepalived failover with timing recorded.
  • Release: goreleaser (binaries linux/darwin amd64/arm64, deb/rpm/apk with a systemd unit, Homebrew tap), kos: images (distroless static nonroot, multi-arch, -debug variant), cosign keyless signatures + SBOM + SLSA provenance, Helm charts to oci://ghcr.io/robertherbaugh/charts/{kudurru,kudurru-crds}, crds.yaml + install.yaml, compose bundles, npm @kudurru/api TS client, docs site. Releases are cut by merging branches, not by pushing tags (D139): feature → main (a dev-<PR> image per pull request) → release-candidate (v<VERSION>-rc.<N>, a GitHub pre-release) → release (v<VERSION>, latest), with VERSION in the repository root.
  • Supply chain: dependabot (exact pin for cloudflare-go), govulncheck, CodeQL, a dnsdist/keepalived CVE tracking job that opens a PR bumping the pinned upstream digest and rebuilding the supervisor images.

13. Roadmap

13.1 v0.1 — "stone" (a credible daily driver; ~16 weeks, one release cycle)

Milestones (exit criteria in parentheses):

WeekMilestoneExit criteria
1-2Spikes (§14; not all run before the schedule went ahead: (c), (d), (e), (g) and (i) passed; (f) kind + MetalLB is a CI job since the v0.1 fix round; (a), (b) and (h) are open, docs/followups.md)dnsdist console-under-YAML verdict; PROXYv2 + chashed smoke; kube driver passes conformance on envtest; blocky matcher port with bench baseline; go-oidc BFF against dex with sealed sessions; mixed-protocol Service on kind + MetalLB (+ one cloud if available); apiextensions/CEL binary-size measurement
3-6M1 resolver + store (done 2026-10-11, docs/m1/; the carry-overs closed in M2 except multi-socket DoT/DoQ/DoH3, moved to v0.2)Do53/DoT/DoH/DoQ/DoH3 + PROXYv2 (D67); pipeline with golden tests; cache; upstream groups; Store + sqlite + kube drivers with conformance; zonecache + local.db snapshot; CRDs DNSZone/DNSRecordSet/BlockList/ClientGroup/Policy; Lease controller stamping serials/conditions/Events
7-9M2 authoritative + blocking (done 2026-10-11, docs/m2/, except the D60 catalogue, which M2 did not build; it was delivered in the v0.1 fix round as an embedded catalogue, D120, D124-D126. The fixed-runner bench job moved to v0.2)primary/forward zones, full RR set, wildcards, CNAME chase, AXFR out + TSIG + NOTIFY out, BIND import/export; blocking engine (exact/suffix/regex, allow-wins/allow-only, response-side checks), leader-fetched lists with digest pull, embedded starter list, the kudurru-owned blocked-services / safe-search catalogue as a fetched data source (D60), bench gate; QPM rate limiting; health.kudurru.internal.
10-12M3 API, auth, dashboard (done 2026-10-11, docs/m3/, but the dashboard was only tested against mock handlers until the v0.1 fix round, D123; settings hot reload (D106) and Scalar self-hosting landed in M5; cluster scatter-gather moved to v0.2; the IAM routes return empty lists until the fix round wires IAMReader)huma API + generated clients; OIDC BFF + local admin + kdr_ tokens + RBAC matrix + k8s SA tokens/SAR + audit; config metadata table with hostOnly; dashboard (tiles, 24 h chart, per-client chart, top lists, query log with SSE + allow/deny + explain + long-term views, lists/groups/policies/clients, zones editor, settings basic, nodes, diagnosis badge)
13-14M4 deploy + edge (reported in docs/m3/, D122; done 2026-10-11 except: the kudurru-keepalived image did not build and make edge-check had never passed until the v0.1 fix round; kind + MetalLB is a CI job that has not run yet; two-VM VRRP needs the owner's hardware; serve wiring of vip.NewKeepalived and vip.ServiceProvider moved to v0.2)distroless image; compose single, single-edge, ha-static (3 nodes + dnsdist + keepalived); supervisor images with edge run (restart convergence) and vip run (full contract); Helm kudurru + kudurru-crds (Deployment, mixed-protocol Service + split fallback, Restricted PSS, probes, PDB, edge.enabled, vip.provider, standalone mode); kustomize overlays; Grafana board + PrometheusRule; kudurru doctor
15-16M5 hardening (partly done 2026-10-11, docs/m5/: BIND secondary and dex e2e, fuzz corpus, security review. Not done: kind e2e (CI job since the fix round), apiserver-outage (v0.2), 2-VM VRRP (owner's hardware), the docs site (being built in the fix round, D121) and the v0.1.0 tag: no tag exists yet, v0.1.0-rc.1 included)e2e (BIND secondary, dex, kind, apiserver-outage, 2-VM VRRP), fuzz corpus, security review of auth + rendering, docs site, v0.1.0 tag

Explicitly not in v0.1 and why it is safe:

DeferredSeam present in v0.1
Secondary/stub zones, AXFR/IXFR in, NOTIFY in, RFC 2136ZoneType enum + SecondarySpec/StubSpec exist (rejected by CEL "not supported in this version"); zone.Journal records deltas per serial; Transfer() capability; NOTIFY/UPDATE opcodes routed to a handler returning NOTIMP
DNSSEC validation and signingdnssec stage slot (pass-through); DNSSECSpec reserved; zone tree separates user RRsets from derived RRsets (empty layer); IXFR-from-leader path exists for signed data; status.dnssec.nextResign reserved
Adblock modifiers ($dnstype, $dnsrewrite, $client, $important, $badfilter, $denyallow)the parser already tokenises modifiers into a counted "slow path" bucket; the rewrite stage exists (empty)
MAC / DoT-SNI / DoH-path client identificationClient struct carries MACs, ClientID; identify has ordered matcher slots; inventory API exists for UniFi to fill
Cloudflare sink, UniFi sourceintegration framework, Endpoint superset, Labels/ProviderSpecific/kudurru.view, ledger kind, DNSZone.spec.publish[] reserved, Integration schema designed; external-dns/webhook integrations exercise the framework
Console diff-apply for dnsdist, dynblock push, cert reload without restartedge.Supervisor already renders/validates/execs; Topology is shared by the YAML and Lua renderers; restart convergence stays the fallback
Follower mode / raftStore driver slots; cluster package membership-source abstraction; /internal/snapshot route reserved; meta.cluster_epoch/primary_id columns exist; ha-static compose is byte-compatible with the follower compose
IAM/Integration/Kudurru CRDsthe YAML config shape is the CRD spec; the kube driver maps kinds to CRDs generically, so adding a kind is registering its CRD
CEL claim mapping, CLI OIDC login, TOTP, impersonation modeClaimMapper, Authenticator chain, IdentityProvider fields reserved; bearer JWT routed by iss from day one; auth.kubernetes.mode enum exists with only sar accepted
IAM write API/UI (local users beyond admin, service accounts, role bindings, identity providers via API)v0.1 manages these as file/ConfigMap objects (rbac.bindings, auth.identityProviders, users:) plus POST /auth/tokens (own tokens) and kudurru admin create-token; the IAM page is read-only with "managed by file" badges; the generic /objects/{kind} route, managedBy and the internal LocalUser/ServiceAccount kinds already carry what v0.2 exposes
Session listing/killing, hourly IdP re-evaluationSessionStore interface with sealed; db implementation lands with the SQLite session table
dnstap, OTel, ClickHouse/Loki sinksquerylog.Sink interface with bounded queues; QueryEvent keeps wire buffers only while a raw sink is registered
external zone type (Ingress/Gateway/LB)ExternalSpec reserved; informer plumbing exists for EndpointSlices
Catalog zones, ACME, Pi-hole façade, per-clientgroup query-log scopev1.0; scope{kind: clientgroup} already parses and is rejected by validation

13.2 v0.2 — "clay copies" (~12 weeks)

Secondary + stub zones (AXFR/IXFR in, TSIG, NOTIFY in, XFR over TLS; every replica transfers independently), RFC 2136 UPDATE with key × owner × type policies, DNSSEC validation (+ NTA, EDE), adblock modifier slow path, rewrites/DNS64/AAAA filter/rebinding protection, MAC/ClientID identification, external zone type, Cloudflare RecordSink (split-horizon with views), UniFi ClientSource (+ optional push/actions), Tier-B webhook client + conformance CLI, dnsdist console diff-apply + cert reload + dynblock push (if the spike passed), compose HA follower mode with fenced promote and a cluster join wizard, Kudurru/Integration/IdentityProvider/RoleBinding/ServiceAccount CRDs, auth.kubernetes.mode: impersonate opt-in, CEL claim rules, CLI OIDC login (loopback PKCE + device flow), TOTP, db session store for Docker, RP-initiated logout, dnstap emit/listen, OTel tracing incl. dnsdist TRACEPARENT, ClickHouse/Loki sinks, "All settings" page, importers for blocky/CoreDNS Corefile/Technitium, RPZ feed import as a BlockList source format (D61), v1alpha1 → v1beta1 plan.

13.3 v1.0 — "temple" (~16-20 weeks)

DNSSEC signing on the leader (ECDSAP256SHA256 CSK default, Ed25519 opt-in, NSEC or NSEC3 with RFC 9276 params, CoreDNS sign timers/jitter, RFC 7583 key state machine, KSK activation gated on observed parent DS with manual override, ZSK rolloverDays, CDS/CDNSKEY, BIND-format key import, keys encrypted in the store, ldns-verify-zone/delv in CI, leader takeover without re-sign); catalog zones (RFC 9432) produced and consumed; RPZ publishing: a compiled BlockList served as a transferable RPZ zone with NOTIFY to subscribing resolvers (D61); raft Store driver (hashicorp/raft, SQLite FSM, single-voter bootstrap, learner join via single-permission token, snapshot before membership change, never fork on join failure, MinVersion migrations) replacing follower mode; native ACME DNS-01 via certmagic (leader); ClickHouse-backed exact cluster top-N; clientgroup: RBAC scope; back-channel logout; Pi-hole v6-compatible façade module; /api/v1 freeze + deprecation policy; CRD v1beta1; third-party security review; published dnsperf numbers per profile; OLM bundle after v1 CRDs.

Out of scope for 1.0 unless the user asks (§15): DHCP server, replacing CoreDNS for cluster.local, online DNSSEC signing (black lies) as default, go-plugin Tier C, embedded etcd, a QNAME-minimising iterative resolver (forwarder only).


14. Risks & mitigations

Ordered by impact × probability; each row names the spike or gate that retires the risk and the seam that keeps a wrong guess from forcing a rewrite.

#RiskWhy it is realMitigation / spikeSeam
1Replication/HA: owning consensus (membership, join, snapshots, mixed versions) is where Gravity accumulated its worst bugs[gravity §16/§18.3]v0.1 writes no consensus code; conformance suite per driver; follower mode (v0.2) has trivially understood semantics plus fencing; raft only in v1.0 after an rqlite-style single-voter prototype behind Store with a 100 k-record load benchmarkStore, whole-object Apply, serving reads only snapshots, backup = Snapshot YAML
2Cold start / apiserver outage on small clusters[k8s §8, SRE lens]local.db snapshot with age-gated readiness; leader election decoupled from process lifetime (§5.3, D44 — stock controller-runtime would exit every pod when the lease cannot be renewed); e2e kills the control plane for 5 min, asserts answers continue on every pod and that no pod restarted, then restarts a pod and asserts answers from local.db; persistence.local PVC opt-in; system-cluster-criticalzonecache snapshot writer, cluster.Elector
3dnsdist orchestration: no YAML reload; console directives under YAML UNVERIFIED; console wire protocol undocumented[dnsdist §1.11, §7]week-1 spike; restart convergence is the v0.1 baseline and the permanent fallback; supervisor execs the dnsdist binary (no console client to write); Lua renderer behind the same Topology; make edge-check; digest pinning + CVE bump jobedge.Renderer{YAML,Lua}, edge.Supervisor
4keepalived in containers: host networking + caps, VIP left behind, VRRP misconfig[dnsdist §2.4/§6]generator enforces state BACKUP, distinct priorities, nopreempt, auth_hmac, persisted VRID; keepalived 2.4.3 compiled from source (Alpine ships 2.3.4 without auth_hmac); explicit kudurru vip drain = FAULT (300) since nopreempt ignores priority drops, while lameduck never touches the VIP (D51, §3.6); keepalived -t before swap; stop_grace_period; nightly 2-VM e2e recording failover timing for node loss, FAULT drain and 60 no-op; never default in k8svip provider interface (Owner(), SetWeight(), events) shared by kube-vip/MetalLB/keepalived
5Sealed-cookie sessions diverge from the server-side recommendation[auth §4.1]small payload, 4 KiB guard, sessionsValidAfter, idle + absolute lifetimes, auditor review of the sealing format; db store in v0.2SessionStore
6DNSSEC signing correctness (NSEC3 chains, ENTs, wildcards, rollover timing)[go-dns-stack §3]leader-only signing, continuity on takeover, precomputed chains, ldns-verify-zone/dnssec-verify/delv + BIND validating resolver in e2e; staged to v1.0 after transfers are proven in v0.2derived-RRset layer, serial/journal, DNSSECSpec
7Blocking performance/memory with 1-3 M entries and regex-heavy lists; clean-room parser drift[blocking §5.1]blocky's structures; CI bench gate; regex cap; CRC-skip rebuilds; conformance corpus cross-checked against AdGuard Home black-boxblocking.Snapshot, slow-path bucket
8OIDC/RBAC footguns (auto-admin, token oracles, CSRF on plain-HTTP labs, IdP quirks)[auth §10]strict provisioning; constant-time full-hash compares; CSRF header always required for cookie writes; tolerant claim parsing; dex + Keycloak + Authentik in e2e; security review before v0.1; SAR default (no impersonate grant)Authenticator chain, Authorizer, ClaimMapper
9Two-driver semantics drift (sqlite vs kube)tech-lead lensbounded Apply contract (§4.3): whole-object, managedBy, optimistic concurrency, no sub-object field-manager emulation; conformance suite is the contractstore/conformance
10apiextensions/CEL dependency weight in the Docker binarytech-lead lensretired by the validator spike (D75): +12.9 MB on the scaffold binary, +6.7 MB once controller-runtime is linked, +9 MB RSS; the JSON-Schema fallback would save only 2.8 MB and lose defaulting, ratcheting and message parity; a CI binary-size budget step guards growthstore.Validator
11miekg/dns v1 end-of-life[go-dns-stack §2]dns.Msg/dns.RR confined to four packages; migrate when CoreDNS doesrecord model isolation
12Kubernetes LB realities (mixed-protocol unsupported on some clouds; Local policy imbalance; hostNetwork sysctl limits)[k8s §9.1]chart splitProtocols + shared-IP annotations, cloud matrix docs, e2e on kind + MetalLB, trafficDistribution valueHelm values only
13CRD scale (thousands of records per zone, 1.5 MiB cap, ~10 updates/s)[go-dns-stack §6.2, §17.3]DNSRecordSet ≤ 1000 endpoints; status summarises; large/imported/secondary/signed zones are node-local zone data, never CRDs; documented ceilings (~50 k records/zone, ~10 updates/s) with a doctor check; escape hatch raft driver (v1.0)zone data vs zone config split
14Static-twins HA serial skew confuses external secondaries§6.5documented; dnsdist order pins XFR to one node; follower mode removes itserial rule
15Per-node query logs in k8s (emptyDir loss)[observability §2.5]documented; persistence.querylog PVC opt-in; ClickHouse sink; never NFSquerylog.Sink
16Licence contamination (code and embedded data)[blocking §1.1, pihole §0]go-licenses deny list; NOTICE; CONTRIBUTING forbids porting from GPL/EUPL repos; supervisor images carry GPL text + source offer (written offer for the from-source keepalived build); the embedded starter blocklist and any future blocked-services/safe-search catalogue are built only from permissively licensed sources with a provenance file checked in CI (§6.4)—
17Scope creep / bus factor (Gravity's trajectory; v0.1 is already 16 weeks)[gravity §15]milestones with exit criteria; seams table; CONTRIBUTING architecture map; generated clients/docs; features gated on conformance tests—
18cgo-free constraint[go-dns-stack §12]modernc SQLite only; pure-Go ClickHouse client; CGO_ENABLED=0 enforced in release—

Spike list for weeks 1-2 (before committing the v0.1 schedule): (a) dnsdist 2.1.2 started from YAML + dnsdist -C cfg -e 'newServer({...})' and reloadAllCertificates(); (b) PROXYv2 + chashed + health validation_function smoke; (c) kube driver + envtest conformance of Store, including local.db snapshot restore with the apiserver stopped; (d) blocky matcher port + bench baseline; (e) go-oidc BFF against dex with sealed sessions + sessionsValidAfter; (f) mixed-protocol Service on kind + MetalLB and one cloud; (g) apiextensions + cel-go binary-size and build-time measurement; (h) keepalived 2.4.3 auth_hmac file: + track_file + FIFO on two VMs; (i) quic-go DoQ/DoH3 listeners against kdig +quic/+https and dnsproxy (D67).

Spike results (2026-10-11, docs/spikes/): (c) kube driver passes the conformance suite on envtest with the elector never exiting on lease loss; (d) blocking gates met with margin; (e) OIDC login works end to end against dex 2.46; (g) validator cost measured and accepted, and it found two CRDs a real apiserver refuses (now fixed); (i) all six transports answer through one registry and every PROXYv2 forgery case is dropped. (a), (b), (f) and (h) need a dnsdist binary, Docker/kind and two VMs and remain open.


15. Open questions for the user

  1. Project home Decided 2026-10-10 (D49): module github.com/robertherbaugh/kudurru, images under ghcr.io/robertherbaugh/, CRD group kudurru.io.
  2. Compose HA ladder Decided 2026-10-10 (D50): v0.1 static twins, v0.2 follower mode with fenced promote, v1.0 raft.
  3. Sessions: accept sealed cookies in v0.1 (no mid-session IdP group refresh, no session listing), with the SQLite db store in v0.2 for Docker and an optional external store (Valkey) for Kubernetes in v1.0?
  4. Kubernetes as data source: external zones (Ingress/Gateway/LB hostnames, v0.2) only, or also replace CoreDNS for cluster.local (not before 1.x proposed)?
  5. Blocked-services / safe-search data: maintain a kudurru-owned permissively licensed catalogue (AdGuard's tables are GPL), or drop those features?
  6. DHCP Decided 2026-10-10 (D52): out of scope for 1.0; a candidate for 2.0. UniFi/router owns it until then; inventory via UniFi.
  7. Default block response Decided 2026-10-10 (D58): Default block response. zeroIP with a 10 minute TTL and EDE 15.
  8. Query-log defaults Decided 2026-10-10 (D59): Query log defaults. Privacy full, raw rows 30 days up to 10 GiB, answer summary only in full, rollups 35 d / 400 d / forever.
  9. Cloudflare ownership marker: comment only (Free) or comment + tag; also honour/produce external-dns TXT heritage records for coexistence with a parallel external-dns?
  10. UniFi legacy API Decided 2026-10-10 (D63): UniFi legacy API. Official Integration API by default; legacy controller endpoints behind a flag with version detection and per-endpoint 404 tolerance.
  11. Edge default in Helm: dnsdist off by default (proposed) — flip to on once console diff-apply is proven?
  12. Pi-hole API façade Decided 2026-10-10 (D66): Pi-hole API façade. Optional module in v1.0.
  13. Transports: implement DoQ/DoH3 natively in v0.2 (proposed) or rely on dnsdist for QUIC and keep the core smaller?
  14. Minimum hardware/arch Decided 2026-10-10 (D68): Hardware floor. Raspberry Pi 4 class (arm64, 1 GB) supported; release targets are linux amd64 and arm64 only.
  15. Authorization in Kubernetes Decided 2026-10-10 (D55): Authorization in Kubernetes. SubjectAccessReview by default; apiserver impersonation stays a v0.2 opt-in.
  16. Frontend Decided 2026-10-10 (D69): Frontend stack. Bun + React 19 + TypeScript + Astryx (Meta's design system, MIT); uPlot/ECharts for charts until @astryxdesign/charts leaves canary.
  17. keepalived build Decided 2026-10-10 (D53): compile 2.4.3 from the upstream tarball; kudurru carries the GPL source offer (D46 stands).
  18. VIP drain policy Decided 2026-10-10 (D51): lameduck keeps the VIP and only drains the node from dnsdist; the VIP moves on real failure or an explicit kudurru vip drain.
  19. PROXYv2 authentication Decided 2026-10-10 (D56): PROXYv2 authentication. Exact peer allow-list plus the shared-secret TLV for every non-loopback peer; the loopback compose profile is exempt.
  20. RPZ Decided 2026-10-10 (D61): RPZ. Import RPZ feeds as a BlockList source in v0.2; publish kudurru's own blocklists as transferable RPZ zones in v1.0.
  21. Encryption-key rotation UX Decided 2026-10-10 (D57): Encryption key rotation. Dual-key window via KUDURRU_ENCRYPTION_KEY_PREVIOUS; stored secrets are re-encrypted on start and old sessions stay valid until their lifetime ends.

16. Decision log

#TopicDecisionWhyAlternatives rejected
D1Base proposalStart from the MVP-and-risk-first proposal; graft from the Kubernetes-first and single-binary-first proposalsHighest judge tally (24/30 vs 22 and 18); fewest ways to break on both targets; best seam disciplineKubernetes-first as base (largest v0.1, impersonation default, leader-held zones re-signed on flap); single-binary-first as base (owns consensus from day one, StatefulSet+PVC in k8s, OIDC/dnsdist deferred)
D2Source of truth in KubernetesCRDs via the apiserver are the only store; Deployment, no PVC for config[k8s §13 model 1, §16.1/§16.2]; zero consensus code; GitOps/RBAC/audit for freeInternal raft store with CRD bridge (two sources of truth, status lag; k8s §16.1); embedded etcd (Gravity's own advice). Note the research disagrees with itself: go-dns-stack §17.3 advises against CRDs as the primary datastore (1.5 MiB object cap, apiserver pressure, no home for runtime data); accepted here only for configuration objects with the documented ceilings and the zone-data/zone-config split of risk #13 — transferred, imported and signed zone data is never a CRD
D3Source of truth in DockerEmbedded SQLite (state.db) behind the same Store interfaceOne engine for state/log/audit; cgo-free; no fsync-heavy raft log on SD cards in v0.1bbolt object store (second engine); raft single-voter from day one (consensus surround is Gravity's bug cluster)
D4Consensus timingNone in v0.1; follower driver v0.2; raft driver v1.0Validate the product before owning membership/join/snapshots [gravity §16]raft in v0.1 (single-binary-first proposal); raft "post-1.0 unless demand" (Kubernetes-first proposal) — bare-metal multi-writer is a real user need
D5Compose HA before raftv0.1 ha-static (identical file-managed config, dnsdist on each node lists all, keepalived VIP); v0.2 follower with fenced promoteDelivers a working keepalived + dnsdist HA profile in v0.1 with zero replication code; follower adds a writable UI without consensusRemote driver + unfenced promote (split-brain by design); no HA until raft
D6Promotion fencingcluster_epoch + primary_id in state.db, advertised on /internal/status; stale primary demotes itself and returns 503 fencedCloses the SRE-identified split-brain in manual promoteNo fencing (the Kubernetes-first proposal)
D7Cold start without apiserverzonecache persists the materialised object set to local.db; serve from it with Degraded, age-gated readiness, maxAge 24 hFixes the "DNS needed to bring the cluster up" trap named by the SRE lensSERVFAIL until informers sync (CRD-only proposals); StatefulSet+PVC with raft (single-binary-first proposal)
D8Node heartbeatsNone stored; Nodes page = EndpointSlice + scatter-gather /internal/statusAvoids the Gravity tsdb anti-pattern in miniature [observability §5]NodeStatus in Secrets (MVP-first proposal); per-node Lease (extra writes for little value)
D9Apply semanticsWhole-object apply + managedBy single writer + optimistic resourceVersion; SSA with field owner in k8s; no sub-object field-manager emulation in SQLiteBounds the emulation scope a small team can keep correct; makes two sources of truth impossibleFull SSA emulation in ~2-3k LOC (k8s-first, unsubstantiated)
D10DeterminismProposer computes uid/timestamps/serial candidates; drivers store verbatimThe v1.0 raft FSM is deterministic without touching callersDrivers generating values in the apply path
D11Schema migrationsSchemaVersion in every driver from v0.1; newer-on-disk refuses to start; follower halts on version skew; raft MinVersion + cluster-min gatingBoth CRD proposals had no migration story for the embedded storeRely on apiserver storage versions only (does not exist in Docker)
D12Serial rulestatus.serial = max(prev+1, unix_now), leader-stamped on contentHash change, everywhereOne rule for all drivers; monotonic; near-equal on static twinsPer-commit counter in SQLite only (diverges across twins); hard-coded (Gravity)
D13Secondary zonesEvery replica transfers independently; persisted in local.db; no leader in the transfer pathNo re-transfer on leader flap (SRE critique of the Kubernetes-first proposal)Leader as IXFR primary for secondaries
D14DNSSEC signing (v1.0)Leader signs; signed RRsets are zone data replicated by IXFR to replicas; new leader continues from the last signed state, never re-signs on takeoverSequential signers make ECDSA non-determinism irrelevant; no NOTIFY storm on rolling upgradesRe-sign on every leader change (Kubernetes-first proposal); per-replica signing (go-dns-stack §17.4)
D15Blocklist distributionLeader fetches, publishes digest; replicas pull by digest from the leader, compile locally; fallback after 10 minAvoids N× egress and divergent list versions [k8s §16.11]; v0.1 not v0.2Every replica fetches in v0.1 (MVP-first proposal)
D16dnsdist/keepalived orchestrationkudurru supervisor is PID 1 inside kudurru-published images FROM the upstream digest; render → validate → exec → signal/consoleCleanest day-2 shape; versioned binary, not a shell loop; GPL boundary = mere aggregation [dnsdist §4]initContainer-copied binary + separate agent container (ambiguous exec path); shell snippet entrypoint from docs (the single-binary-first proposal)
D17dnsdist membership changesv0.1 regenerate YAML + restart child; v0.2 console diff-apply gated on the week-1 spike; Lua renderer fallbackConsole-under-YAML is UNVERIFIED [dnsdist §1.11]; restart is sub-second behind ≥ 2 edge replicasRely on console from day one; Lua maintenance() DNS-driven loop (unstable UUIDs)
D18Edge/VIP in v0.1edge run and vip run supervisors ship in v0.1dnsdist and keepalived are hard requirements; both are thin wrappers with the longest real-hardware feedback loopStatic render only in v0.1 (MVP-first proposal); no edge until v0.2 (single-binary-first proposal)
D19VIP in Kubernetesvip.provider: none|kube-vip|metallb|keepalived, keepalived opt-in only with hostNetwork edge DaemonSetkube-vip/MetalLB are Service-aware and Apache-2.0 [dnsdist §2.5, k8s §16.14]keepalived default in k8s
D20Every dnsdist lists all nodesYes, in every multi-node profileVIP follows dnsdist health, not kudurru health [dnsdist §3.2]Local-backend-only dnsdist
D21Kubernetes authzSAR on CRDs + virtual resources with projected SA tokens in v0.1; impersonation opt-in v0.2impersonate grant = cluster-admin-equivalent blast radius for an internet-facing server; research vetted SAR, not impersonation [k8s §10]Impersonation by default (Kubernetes-first proposal)
D22Dual authz precedenceAllowed if either k8s RBAC (SAR) or kudurru bindings allow; audit names the pathOIDC humans on home clusters rarely share the apiserver's issuerSingle policy system per mode (Kubernetes-first proposal) — leaves OIDC humans without bindings in k8s
D23SessionsSealed cookie in v0.1 (both modes) with sessionsValidAfter; SessionStore.db v0.2 Docker; external store option v1.0Deployment has no shared DB; revocation lever existsServer-side sessions via raft (single-binary; every session touch a consensus write); refresh token in the cookie (size)
D24Config metadata / hostOnlyMetadata table with hostOnly/envLocked/restartRequired/writeOnly in v0.1Fence against the Pi-hole 2026 RCE class; drives UI/docs from one table [pihole §16.8, §17.7]Defer to v0.2 (MVP-first proposal)
D25Settings in k8sTwo ConfigMaps: kudurru-node (checksum rolls pods) and kudurru-settings (hot reload); Kudurru CRD in v0.2Config changes must not restart pods [k8s §16.6]One ConfigMap with a checksum annotation
D26Object kindsDNSZone, DNSRecordSet (Endpoint superset), BlockList, ClientGroup, Policy, Kudurru, Integration, IdentityProvider, RoleBinding, ServiceAccount; User/APIToken/TSIGKey/DNSSECKey internalMirrors [k8s §12]; keeps hashes out of kubectl get; fewest concepts for Pi-hole usersRRset/BlockSource/ListGroup/Client/UpstreamGroup kinds (single-binary; 15 kinds); User/APIToken CRDs (Kubernetes-first proposal)
D27Upstream groupsKudurru.spec.upstreams.groups map referenced by nameOne fewer kind; groups rarely changeUpstreamGroup object
D28Record extensionsEndpoint.kudurru{comment,disabled,expiresAt,view}; ToExternalDNS() drops itPi-hole/Technitium users expect comments/disable; external-dns compatibility preservedEverything in providerSpecific (clunky); Endpoint isomorphism strictly (loses UX)
D29Split-horizonLabels + per-sink providerSpecific + kudurru.view: public endpoints + sink translateOne zone, one truth [integrations §2.5]Second hand-maintained public zone
D30Audit placementSame SQLite transaction as the mutation in the sqlite driver; local.db + stdout + Events in k8sAtomic audit where kudurru owns the storeSeparate audit.db only (not atomic)
D31Permissions sourceinternal/rbac/permissions.yaml generates roles, ClusterRoles, docs, lint fixturesPrevents drift between two authz systemsHand-maintained ClusterRoles
D32Backup formatkind: List YAML byte-identical to kubectl get kudurru.io -A -o yaml; restore = applyOne format across modesTarball of tables (Pi-hole teleporter style) as the primary format
D33Engine for zone data / snapshotsSQLite (local.db), not bboltOne engine, fewer dependenciesbbolt zones.db (Kubernetes-first proposal)
D34Store validationCRD OpenAPI + CEL executed locally in Docker via apiextensions packages; binary-size measured in the spikeSame rejects in both modes [k8s §13]Two validators (JSON Schema in-process, CEL in CRDs; single-binary)
D35TransportsDoT/DoH in v0.1; DoQ/DoH3 natively in v0.2 Superseded by D67 (all five transports native in v0.1)"Extreme featureset" without blocking v0.1 on QUICDo53-only v0.1 (single-binary-first proposal); all five in v0.1 (Kubernetes-first proposal)
D36Block response defaultzeroIP, TTL 10 m, EDE 15Responsive to one-click allow; AdGuard default [blocking §6.4]refused (stub resolvers fail over); 6 h TTL (blocky)
D37Long-term analyticsIn v0.1 from rollupsMost-requested missing Pi-hole v6 feature [pihole §17.3]v0.2 (Kubernetes-first proposal)
D38Pi-hole API façadeOptional module in v1.0Protect v0.2 scope; open question to pull forwardv0.2 (MVP-first proposal)
D39cluster.localForward to CoreDNS; external zone type v0.2; kubernetes type not before 1.xCoreDNS-grade conformance burden [k8s §17]Replace CoreDNS in v0.2 (Kubernetes-first proposal)
D40Module pathgithub.com/robertherbaugh/kudurruConfirmed by the git remote; flagged for ratificationgithub.com/kudurru/kudurru, github.com/kudurru-dns/kudurru (org names, unverified)
D41Minimum Kubernetes1.30CRD CEL validation rules GA (1.29 per the upstream changelog — not covered by the research, verify), ValidatingAdmissionPolicy GA (1.30 [k8s §0]; reserved for optional cross-object policies the chart may ship later), mixed-protocol LB GA since 1.26 [k8s §0]1.26
D42DHCPOut of scope for 1.0Gravity's and Technitium's largest bug clusters; UniFi/router owns itDHCP role in v1
D43Extension APITier A in-tree Go + Tier B external-dns-compatible HTTP; Tier C go-plugin deferredSimple integration model that third parties already know [integrations §5.1]; no runtime in-process codeTechnitium-style in-process app loading
D44Leader election lifecycleclient-go LeaderElector loop owned by internal/cluster; losing the lease stops only leader-only runnables; the process never exits on leadership lossStock controller-runtime/kubebuilder behaviour (stop Manager, exit) would take the dataplane down on every pod during an apiserver outage — the opposite of principle 4 [k8s §7.4]controller-runtime Manager election as-is; per-runnable Leases
D45PROXYv2 trustSource allow-list of exact peers and a shared-secret TLV (0xE0) injected by the dnsdist supervisor; NetworkPolicy template in the chartPod-CIDR / LAN-CIDR trust is forgeable by any neighbour and the forged address feeds ACLs, client groups and the query logCIDR-only trust (dnsdist/PowerDNS default); mTLS between dnsdist and kudurru (dnsdist→backend DoT exists, but client-certificate support for backends is unverified and it adds a certificate lifecycle to every edge pair; revisit if the TLV approach proves insufficient)
D46keepalived binary2.4.3 compiled from the upstream tarball in the kudurru image; 2.3.x distro package supported as a degraded, warned modeauth_hmac (2.4.0+) is the only authenticated VRRP option; Alpine still packages 2.3.4 [dnsdist §2.1]Alpine apk only (no auth_hmac); third-party images (unpinned, env-templated)
D47Internal listener securityAlways TLS; token and node certificate derived from the encryption key via HKDF unless overridden/internal/snapshot (v0.2) and /internal/lists carry internal kinds and list bodies; a plaintext token listener on the pod/LAN network is a credential and data leak; deriving from the existing shared key avoids a second secret to distributePlain HTTP + token (draft v1); separate chart-generated token Secret + cert-manager certs (more moving parts for small installs; still allowed via tokenFile/tls)
D48Session payloadSealed cookie carries resolved role bindings (roles_snapshot), not raw IdP groupsBounded size under the 4 KiB guard regardless of IdP group count; bindings are what authorization needsRaw groups claim in the cookie (draft v1)

| D49 | CRD group and project home (ratified) | kudurru.io is the CRD group; module github.com/robertherbaugh/kudurru; images ghcr.io/robertherbaugh/*; charts oci://ghcr.io/robertherbaugh/charts | Confirmed by the project owner on 2026-10-10 (§15 Q1); a group rename after v0.1 would force a migration on every user | kudurru.dev, an org-scoped group | | D50 | Compose HA ladder (ratified) | v0.1 ha-static, v0.2 follower mode with fenced promote, v1.0 raft | Confirmed by the project owner on 2026-10-10 (§15 Q2); keeps Cloudflare/UniFi in v0.2 | raft pulled forward into v0.2 | | D51 | VIP drain policy | kudurru lameduck never FAULTs the VIP: it drains the node from dnsdist's backend pools and the VIP stays while the local dnsdist is healthy with ≥ 1 backend; the VIP moves only on real failure or an explicit kudurru vip drain / maintenance (300) | Project owner on 2026-10-10 (§15 Q18): routine rolling restarts must not move the VIP, which under nopreempt would never come back; "every dnsdist lists all nodes" (D20) already makes VIP ownership follow dnsdist health | lameduck = FAULT (draft v1, the VIP moved on every restart of the MASTER) | | D52 | DHCP (ratified) | Out of scope for 1.0; candidate for 2.0 | Project owner on 2026-10-10 (§15 Q6); D42 stands, with a named horizon | DHCP role in 1.x | | D53 | keepalived build (ratified) | Compile 2.4.3 from the upstream tarball in the kudurru image; GPL source offer shipped | Project owner on 2026-10-10 (§15 Q17); D46 stands | Alpine 2.3.4 package without auth_hmac |

| D54 | Sessions (ratified) | Sealed AES-256-GCM cookies in v0.1 (both modes) with sessionsValidAfter revocation; SessionStore.db on SQLite in v0.2 for Docker; optional external store (Valkey) for Kubernetes in v1.0 | Project owner on 2026-10-10 (§15 Q3); D23 stands; a Deployment has no shared database in v0.1 and the revocation lever exists | db session store required in v0.1; db store for Docker only in v0.1 | | D55 | Kubernetes authorization (ratified) | SubjectAccessReview on real and virtual kudurru.io resources by default; auth.kubernetes.mode: impersonate remains an opt-in in v0.2 | Project owner on 2026-10-10 (§15 Q15); D21 stands; the impersonate grant makes a compromised kudurru cluster-admin-equivalent | impersonation by default; dropping impersonation entirely | | D56 | PROXYv2 authentication (ratified) | Exact /32 peer allow-list and the shared-secret TLV 0xE0 injected by the dnsdist supervisor are required for every non-loopback peer; loopback (127.0.0.1/32, ::1/128) is exempt when the secret is unset, so the compose single-edge profile needs no secret | Project owner on 2026-10-10 (§15 Q19); D45 stands; CIDR trust alone is forgeable by any neighbour, while loopback inside one network namespace is not | TLV required on loopback too; peer list only with NetworkPolicy and host firewalls | | D57 | Encryption key rotation (ratified) | KUDURRU_ENCRYPTION_KEY_PREVIOUS[_FILE] dual-key window: decrypt with either key, re-encrypt stored secrets on start, sessions sealed under the old key remain valid until lifetime; the operator removes the previous key after one session lifetime; doctor warns while both keys are set for longer than lifetime | Project owner on 2026-10-10 (§15 Q21); zero-downtime rotation on rolling clusters without a second command to learn | kudurru admin rotate-key rewriting the store in place with a brief outage; both mechanisms | | D58 | Default block response (ratified) | zeroIP (0.0.0.0 / ::), blockTTL 10 m, EDE 15 as the default block response; nxdomain, customIP and refused remain per-policy options | Project owner on 2026-10-10 (§15 Q7); D36 stands; short TTL keeps one-click allow responsive | NXDOMAIN with SOA by default; zeroIP with a 1 h TTL | | D59 | Query log defaults (ratified) | privacy full; raw retention 30 d capped at 10 GiB; answer summary stored only in full; rollups _1m 35 d, _1h 400 d, _1d forever; operators dial privacy down per node or per policy | Project owner on 2026-10-10 (§15 Q8); Pi-hole parity out of the box is the product promise | 7 d / 2 GiB default; anonymised clients by default | | D60 | Blocked-services and safe-search catalogue (revised by D120) | Maintain a kudurru-owned catalogue (blocked services, safe-search rewrites) in a separate permissively licensed data repository, fetched at runtime like any list and seeded at build time with provenance; it ships in v0.1 milestone M2, not v0.2 | Project owner on 2026-10-10 (§15 Q5); one-click "block TikTok" is part of the Pi-hole/AdGuard-grade promise and AdGuard's tables are GPL | ship in v0.2 (the draft default); drop the features | | D61 | Response Policy Zones | RPZ feed import (format: rpz on a BlockList source, AXFR/IXFR-fed) in v0.2; RPZ publishing (serve a compiled BlockList as a transferable zone with NOTIFY to subscribers) in v1.0; the blocked_rpz status becomes live in v0.2 | Project owner on 2026-10-10 (§15 Q20); lets kudurru consume BIND/Knot feeds and act as the blocklist source for other resolvers | leave reserved past v1.0; import only | | D62 | Cloudflare ownership marker (ratified) | Internal ledger + Cloudflare record comment kudurru:owner=<clusterID> (Free plan) + tag kudurru-owner:<id> when the plan supports tags; external-dns heritage= TXT records are read for adoptFrom migrations and never written; unowned records are untouched unless adoptUnowned: true | Project owner on 2026-10-10 (§15 Q9); §9.4 step 3 stands; works on every plan and coexists with a parallel external-dns | comment only; also writing heritage TXT records | | D63 | UniFi API surface (ratified) | Official Integration API (X-API-Key, /proxy/network/integration/v1) by default; legacy /rest/user, /stat/sta, active-leases and stamgr only behind unifi.legacy: true with GET /v1/info version detection and per-endpoint 404 tolerance | Project owner on 2026-10-10 (§15 Q10); §9.5 stands; aliases, fixed IPs and VLAN detail exist only on the legacy API, and the flag confines upgrade breakage | official API only; legacy on by default | | D64 | Kubernetes as a DNS data source (ratified) | DNSZone.type: external in v0.2 synthesising records from Ingress / Gateway API routes / LoadBalancer Services; cluster.local is forwarded to CoreDNS and a kubernetes zone type is not on the roadmap before 1.x | Project owner on 2026-10-10 (§15 Q4); D39 stands; replacing CoreDNS means CoreDNS-grade conformance | commit a kubernetes zone type to 1.x; external-dns webhook only | | D65 | Edge tier default in Helm (ratified) | edge.enabled: false by default in v0.1; the default is reconsidered in v0.2 after the dnsdist console diff-apply spike lands | Project owner on 2026-10-10 (§15 Q11); first install is one Deployment; the edge is a one-line opt-in | on by default from v0.1; off permanently | | D66 | Pi-hole API façade (ratified) | Pi-hole v6 compatible API façade (/api/pihole/*) ships as an optional module in v1.0 | Project owner on 2026-10-10 (§15 Q12); D38 stands; protects the v0.2 scope | pull into v0.2; never | | D67 | Transports (supersedes D35) | DoQ and DoH3 listeners (quic-go, patterned on CoreDNS server_quic.go / server_https3.go, ALPN doq/h3, RFC 9250 error codes) ship natively in v0.1 milestone M1 alongside Do53, DoT and DoH; the listener registry is keyed by protocol and all TLS listeners share one cert-reload path | Project owner on 2026-10-10 (§15 Q13); the "extreme feature set" promise applies to transports too; dnsdist stays optional rather than required for QUIC | native DoQ/DoH3 in v0.2 (D35); QUIC via dnsdist only | | D68 | Hardware floor and release architectures (ratified) | Supported floor is Raspberry Pi 4 class (arm64, 1 GB RAM); release binaries and images for linux/amd64 and linux/arm64 (plus darwin amd64/arm64 for the CLI); no armv7 and no riscv64 targets | Project owner on 2026-10-10 (§15 Q14); keeps the build and e2e matrix to two Linux architectures | armv7; riscv64 best effort | | D69 | Frontend stack (supersedes the §2/§10 draft stack) | Bun is the web toolchain (package manager, test runner, bundler via bun build; Vite under Bun is the fallback if the Bun bundler lacks a feature); React 19 + TypeScript; Astryx (@astryxdesign/core + a @astryxdesign/theme-* package, MIT, consumed as prebuilt CSS so no StyleX/Babel build plugin) replaces Tailwind + shadcn/ui as the component and theming layer; TanStack Query for data fetching; Astryx tables where adequate, TanStack Table + Virtual for the query log; uPlot for the live time series and ECharts for pies/top lists until @astryxdesign/charts is published as stable; generated TypeScript client from the huma OpenAPI document; SPA embedded with go:embed | Project owner on 2026-10-10 (§15 Q16); project owner's choice; Astryx ships 150+ accessible components, brand-level theming and dark mode, an MCP server and a machine-readable component manifest that keeps agent-written UI code honest. Risk: Astryx is in Beta, so the version is pinned exactly and customisations go through swizzle rather than forks | React + Tailwind + shadcn/ui (draft default); Lit + PatternFly; Svelte 5 | | D70 | Validator and store contract | store.Validator entry points Validate/ValidateStatus/ValidateUnstructured; the validator writes defaults back; Apply never changes status, UpdateStatus never changes spec; generation moves only on spec change; an identical re-apply is a no-op; the API validates unstructured input before converting | Measured against a real apiserver (envtest) in the validator and kube spikes; the memory and kube drivers pass the suite with these cases and no skips | Typed-only validation (cannot tell absent from zero); the draft memory driver that kept status on Apply | | D71 | Session key schedule and grants | HKDF-SHA256 per purpose with a 4-byte key id; roles_snapshot grants role / role@kind:name frozen at login; __Host- cookie when Secure; single-use pre-auth cookie; group-overage claims refused; IdP usernames prefixed <idp>: | Auth spike against dex 2.46: 546-byte cookie for a 200-group user; go-oidc leaves nonce/audience checks to the caller | Raw groups in the cookie (9,210 bytes); one key for every purpose | | D72 | PROXYv2 peers in Kubernetes | proxyProtocolFrom accepts CIDRs only when proxyProtocolSecretFile is set; exact /32s otherwise; loopback exempt from the secret but must be listed | Pod IPs are dynamic, so the exact-peer rule of D56 cannot express the edge Deployment; the TLV authenticates and the CIDR narrows exposure | Exact /32s only (D56 as written); CIDR trust without a secret | | D73 | Blocking engine specifics | Suffix set is kudurru's own (TLD buckets, bit filter, fence index, shared maphash seed); regexes indexed by required-literal 4-grams, warn above 50 unindexed, hard cap 1,000; RSS gate is steady state after GC in a fresh process; list semantics as recorded in §7.3 | Spike measurements and a 144-expectation corpus; blocky v0.35 has no such suffix structure | "Copied from blocky" wording; linear regex scan; 500-regex warning | | D74 | cel-go pin | github.com/google/cel-go v0.29.2, the version k8s.io/apiextensions-apiserver v0.37.1 imports; bumped only with the Kubernetes minor; v0.2 claim mappings use the same module | One CEL copy in the binary | v0.31.x from the draft §2 | | D75 | Risk #10 retired | The apiextensions + CEL validator cost is accepted: +12.9 MB on the scaffold binary, +6.7 MB with controller-runtime linked, +9 MB RSS; add a CI binary-size budget | Measured in the validator spike | JSON-Schema + bare cel-go fallback (saves 2.8 MB, loses defaulting/ratcheting/parity) | | D76 | Transport limits | MaxTCPQueries unlimited with 10 s idle; UDP read buffer 4096 B (+2 KiB with PROXYv2); DoH body cap 65,535 B, headers 16 KiB; DoQ grants no unidirectional streams; QUIC connection cap configurable | miekg's defaults closed TCP/DoT after 128 queries and dropped EDNS queries over 512 B | 2 MiB DoH cap in the spike brief | | D77 | Kubernetes RBAC for the kudurru ServiceAccount | create, patch and delete on kudurru.io/* (server-side apply needs create+patch), patch on */status, get on namespaces (kube-system uid as the cluster id), leases get/create/update | Least-privilege run in the kube spike | get/list/watch only (draft §5.3) | | D78 | Elector contract | Elector(name) maps to Lease kudurru-<name>; integration leases are kudurru-integration-<ns>-<name>; Run passes the term context to onStarted; the kube driver's elector owns the client-go loop and internal/cluster runs leader-only work through Store.Elector | Kube spike: lease loss never returned from Run, at most one concurrent term, 12 s/18 s failover at production timings | Separate lease naming in the draft §5.3; controller-runtime's process-level election (D44 stands) |

| D79 | local.db ownership | zonecache owns local.db: it persists its own materialised object set for any driver after the first sync, then at most every 30 s when changed, refreshes the timestamp every 5 min while live and unchanged, and writes only fully synced live data; kudurru_store_snapshot_age_seconds is 0 while live. The code lives in internal/store/localdb | One source for the snapshot and the views; a quiet cluster must not age past maxAge | The kube driver's own PersistSnapshots (store-kube spike); write-on-change only | | D80 | Serving while unsynced (v0.1) | Until zonecache has built its first live or snapshot view every query is answered SERVFAIL, because a zone that has not loaded yet may cover the name. A stale local.db (older than maxAge) is treated the same way in v0.1; the §4.4 refinement (SERVFAIL only for primary zones while forwarding continues) needs a primary-only gate in the authoritative stage and is an M2 follow-up | Never NXDOMAIN or an upstream leak for a name a local zone owns | Forward everything while unsynced | | D81 | Restore and finalizers | Restore keeps each object's recorded managedBy and uses bootstrap only when none is recorded; finalizers are cleared only by Apply (status writes never change metadata), and the Apply that clears the last finalizer removes the object | Matches the kube driver and the apiserver's status subresource | bootstrap as the manager of every restored object | | D82 | Cacheability of hinted routes | A forward-zone hint to the default group (the bootstrap root zone) is ordinary forwarding and cacheable; hints to any other group and conditional suffixes are never cached | Otherwise the bootstrap root zone disabled the cache for every recursive query | "hinted routes are never cached" as first implemented | | D83 | Serial stamping cadence | Zone reconciles triggered by record-set changes are coalesced over a 250 ms window, one serial per burst; the serial still moves only when contentHash changes | A bulk import stamped one serial per record set (284 for 300 objects) | Immediate per-event stamping | | D84 | File-managed object ownership | File declarations take over bootstrap-managed objects and objects of files that dropped them, never api, system or integration:* objects (ManagedByOther diagnosis); first boot means no object of the seeded kinds exists, and a default the operator deleted is not recreated | Files are the operator's intent in Docker mode; API edits must never be silently overwritten | Files always win; always recreate defaults | | D85 | M1 settings keys | store.driver (sqlite|kube), store.kube.startupTimeout, store.kube.snapshot.maxAge, cache.* (maxEntries, minTTL, maxTTL, negativeTTL, prefetch., serveStale.), upstreams.ecs.* (mode strip|forward|add, prefixes), objects, objectsDir | Added by the M1 packages; all in the metadata table with the drift test | — |

| D86 | Client identification tiers | exact IP > MAC (v0.2) > longest CIDR > hostname > nameGlob > clientID > catch-all /0 > default group; equal-length CIDR ties join every tied group and add a Tie diagnosis | The bootstrap default group is two /0 CIDRs; under a literal "CIDR before hostname" order every hostname matcher would be dead | Literal §7.1 order | | D87 | Policy merge | Lists are united across active policies (allow lists first); each setting comes from the highest-priority policy that sets it; allowOnly from the top policy; equal priorities ordered by namespace/name; the bootstrap Policy/default is the fallback; a policy upstreamGroup replaces only the default-group hint and its answers are not cached (D82) | Deterministic merge for Pi-hole-style overlapping groups | Last-writer-wins | | D88 | Block response precedence and shape | BlockList response > Policy blockMode > blocking.defaultResponse; nxdomain carries no SOA; EDE 15 for list blocks and 17 for allow-only with the list name as extra text; statuses blocked_exact/blocked_list/blocked_regex/blocked_cname by matched rule kind; pipeline.ErrDrop and a nil-Msg Response with status dropped/blocked_rate_limit mean "send nothing" on UDP and SERVFAIL elsewhere | Measured 0.4-0.7 µs stage overhead; drops must not be answered | NXDOMAIN with SOA (draft §7.1) | | D89 | Transfers and NOTIFY | zoneTransfer is the address mode and, when set, transferTSIGKeyRefs; empty means deny; AXFR over TCP/DoT only; IXFR answered with AXFR (single SOA when current) until the journal exists; NOTIFY signed with the first transfer key, 5 attempts with backoff, targets = notifyTargets and/or apex NS except MNAME; invalid transfer/notify policy degrades the zone, invalid queryACL still stops it | RFC 5936/1995/1996/8945 behaviour verified end to end with miekg clients; failing open would leak a zone | UDP AXFR; re-transfer on every leader flap | | D90 | contentHash and endpoint identity | status.contentHash = zone.ContentHash of zone.Build(zone.ServedRecordSets(...)), shared by the controller and the zone cache; identity is (owner, type, setIdentifier); CNAME conflicts are Duplicated across objects and Invalid within one; data below a DNAME is Occluded | Hash parity between what replicas serve and what the serial tracks | Tuple hash in the controller (M1) | | D91 | D80 as implemented | Unsynced: every query SERVFAIL except positive data from loaded zones; stale local.db: SERVFAIL for names under primary zones, forward zones and other names forwarded; new status 29 servfail_unsynced, Upstream zonecache:unsynced or zonecache:stale | Closes the M1 carry-over | Forward everything while unsynced | | D92 | Internal listener credentials (D47 detail) | HKDF-SHA256 info strings kudurru/internal/v1 (token) and kudurru/internal-ca/v1 (CA seed); fixed verification name internal.kudurru.invalid; TLS 1.3 only; during rotation nodes issue from the previous CA while _PREVIOUS is set and clients retry the previous token on 401; listeners.internal.tls is four optional file keys | Two in-process nodes verified; no extra Secret in the chart | auto-derived certs with no rotation story | | D93 | List pipeline specifics | A body is refused only when invalid lines exceed maxErrorsPerSource and outnumber its rules (browser filter lists have many cosmetic rules); replicas keep serving an older cached body while a newer digest is pending; the 10-minute self-fetch fallback also starts when no digest was ever published; the embedded starter list is Steven Black's own MIT list (2,852 names), never the unified file | Measured: 1 M-entry pull 90-237 ms, leader compile 0.7 s | blocky's literal error threshold | | D94 | Rate limiting placement | The per-prefix QPM limiter runs first in ingress, before the FORMERR/NOTIMP/BADVERS checks, so floods of malformed queries are charged too; TCP is never limited; loopback and the health name are exempt; over-limit UDP gets TC=1 for slip percent then drops | 127 ns per check, 0 allocs | Limiter after validation | | D95 | local.db layout version | local.db carries meta.layout_version (2: blobs table for the cache dump) separate from meta.schema_version (the object schema); zonecache is the only writer of the object snapshot, cache/persist writes the blob | Different change cadence from the object schema | One version for both | | D96 | Query log sizing | Measured 141 B/row with privacy full (116 B without the answer summary), 28-31 k rows/s write capacity, so the 10 GiB default holds about 8 days at 100 qps of raw rows; rollup ts is unix seconds of the UTC bucket; proto 6 is doh3; dictionary keys prune only after 400 days unseen; pseudonym mode rotates a random key at UTC midnight | Replaces the ~100 B/row estimate in §11.2 | — | | D97 | M2 settings keys | blocking.{enabled,defaultResponse,blockTTL,allowDegraded,customIP.v4,customIP.v6,refreshInterval,initStrategy,listsDir}, querylog.{enabled,sink,privacy,anonymize,retention.raw,retention.maxDBBytes,exclude.*}, listeners.internal.{address,tokenFile,tls.*}, listeners.sockets, ratelimit.*, tsigKeys[], notify.source | All in the metadata table with the drift test | — |

| D98 | API server specifics | huma v2 over the Go mux; Scalar docs at /api/v1/docs; api/openapi.json (3.1) and api/openapi-3.0.json committed with a drift test; the generated Go client lives in internal/apiclient/openapi (the CLI's hand-written transport in internal/apiclient wraps it later); own-token routes are authenticated-only (a zone-scoped binding cannot satisfy a cluster-scope tokens.own); POST /domains attaches the ui-allow/ui-deny lists to Policy <ns>/default when that policy is API- or bootstrap-managed; /dns/query needs stats.read, /info/messages and GET /node/config settings.read, PATCH /node/config node.config, /nodes and /cluster cluster.read, /edge edge.read | Measured 3.5-9 ms for GET /zones with 1,000 zones through the full chain | /api/docs (draft §3.4); route-level tokens.own | | D99 | Authorization principal | The API's Authorize step uses auth.RBACPrincipal: builtin-admin flag, session or token grants, implicit grants and token scopes reach the engine; Decision.Path is local, sar, binding or none, with local covering the first-boot admin, the bootstrap and break-glass service accounts, the unix-socket peer and a peer node's implicit viewer+auditor grants | Integration found the first-boot admin forbidden everything when the API mapped only name and groups | Name-and-groups mapping (draft brief) | | D100 | Internal kinds | LocalUser, APIToken, ServiceAccount, RoleBinding, IdentityProvider live in api/internalkinds/v1alpha1 (Go's internal-package rule forbids api/internal), are never CRDs, and in Kubernetes are Secrets named kudurru-<kind>.<ns>.<name> labelled kudurru.io/kind with the object JSON in data.object; the release-namespace Role therefore needs delete on secrets | Both drivers serve them through the same Store interface; envtest-verified | CRDs for IAM kinds (Kubernetes-first proposal) | | D101 | Auth behaviours pinned by the stack | Admin socket is HTTP/1.1 JSON on auth.adminSocket.{path,group} (/admin/v1/reset-password, create-token, list-users; peers by SO_PEERCRED uid 0, the server's uid or the group); a CSRF failure on a cookie request drops the session (403 on protected routes, public routes run anonymous); bearer OIDC ID tokens are accepted in v0.1 and access tokens refused; a token may not create a token with wider scopes or a later expiry than its own; POST /auth/password; localLogin: admins-only admits builtin admins and cluster-admin role holders; sessions issued after a password change carry iat = sessionsValidAfter + 1 s; auth.kubernetes.mode accepts only sar | Measured 2-6 µs per authenticated request; dex e2e green | NDJSON admin protocol (draft brief) | | D102 | Dashboard conventions | comment and enabled on every object are the annotations kudurru.io/comment and kudurru.io/enabled="false" (servers do not honour enabled yet, see followups); React 19.3 + Astryx 0.6.x with web.Handler setting its own CSP; a placeholder web/dist/index.html is committed so go build needs no Bun and make web-build produces the real bundle (235 KiB gzip initial, 600 KiB budget enforced) | Keeps the Go build self-contained and the bundle bounded | Spec fields for comment/enabled (needs a v1alpha1 change) | | D103 | CLI conventions | Own config at ~/.config/kudurru/config.yaml with named contexts and tokenStore keyring|file; persistent -n/--namespace and -o/--output; kudurru login mints a personal token through the session; kudurru admin speaks the HTTP admin socket; --context always names a kudurru API endpoint in v0.1 (kubeconfig passthrough is a follow-up) | Matches kubectl muscle memory without pretending to be kubectl | kubeconfig contexts in v0.1 | | D104 | Supervisor images | tini is PID 1 and the kudurru supervisor its only child (CMD, so compose command: overrides work); the VRRP check is kudurru health --vrrp … as §3.6 spells it (rendered by default since the M5 burndown, e8b36b4; kudurru vip probe is the same probe with identical flags); vip fifo publishes state to /run/kudurru/vip-state.json; kudurru vip drain <vip> [--undo] is the explicit way to move a VIP and lameduck can never write 300; the PROXYv2 secret must be printable ASCII of at least 32 bytes because dnsdist YAML cannot carry bytes; candidate files are <base>.candidate.<ext>; keepalived_script is uid/gid 953 so the probe can read the dnsdist API key | Renderers golden-tested; real dnsdist --check-config and keepalived -t need make edge-check on a Docker runner | Supervisor as PID 1 (§3.6 wording; the GPL boundary is unchanged) | | D105 | Deployment shapes | Node file at /etc/kudurru/node/kudurru.yaml, settings file at /etc/kudurru/settings/settings.yaml (merged into the node file until a settings reader with hot reload exists); preStop: sleep instead of kudurru lameduck; the net.ipv4.ip_unprivileged_port_start=0 sysctl instead of a file capability for :53 as non-root (hostNetwork pods still need the capability); mode=kubernetes with persistence.querylog or persistence.local becomes a StatefulSet with per-pod claims; seed objects are post-install hook resources; with vip.provider=keepalived keepalived is a second container in the hostNetwork edge DaemonSet; CRD chart copies carry helm.sh/resource-policy: keep; kustomize bases are rendered from the chart and drift-tested; the -debug image is distroless debug-nonroot (no dig) | 33 chart permutations and 7 kustomize builds validated with kubeconform against Kubernetes 1.30 | Separate keepalived DaemonSet; kudurru apply seed Job |

| D106 | Settings file layer (D25 implemented) | Layer order is defaults, node file, settings file (settingsFile / KUDURRU_SETTINGS_FILE, cluster-scope keys only), environment; the file is watched with fsnotify plus a 10 s poll; PATCH /settings persists into it in Docker and answers "managed by Kubernetes" with the kube driver; hot keys: upstream groups and conditional map (set swap + cache flush), cache limits, blocking defaults, ratelimit, querylog privacy/retention, log level; restartRequired in the metadata table is now truthful and pinned by a test | The chart mounts the settings ConfigMap and sets the variable; settings changes no longer roll pods | Merging settings into the node file (the M4 chart) | | D107 | Lameduck (D51 drain path) | POST/DELETE /lameduck on the ops listener, loopback peers only; while lameduck the health name answers SERVFAIL so dnsdist's must_resolve check drains the node, readiness is 503, queries are still answered; kudurru lameduck [duration] [--cancel] is the client and the chart's preStop | Verified live: health SERVFAIL while other names answer, ready 503, --cancel restores NOERROR | admin-socket verb; preStop sleep | | D108 | Silent drops on every transport | A control-stage or rate-limit drop sends nothing on UDP and DoQ, closes TCP and DoT, aborts DoH and DoH3; the log observer labels such responses rcode="none" | Answering SERVFAIL to a flood costs more than the query | SERVFAIL over TCP (M2) | | D109 | kudurru.io/enabled | kudurru.io/enabled: "false" is the v0.1 switch for BlockList, ClientGroup and Policy, honoured by the policy index, the list resolver/fetcher and the controller (Disabled condition); spec.enabled fields were rejected to keep the external-dns Endpoint superset untouched | Dashboard "enabled" toggles now take effect | spec fields | | D110 | Credentials never outlive their account | Sessions and API tokens are refused when issued before the local user or service account they name was created (auth.Principal.CredentialIssuedAt); the kube driver restores API tokens after their owners; per-IP login lockouts key IPv6 clients by /64 and count the real client behind trusted proxies | Security review S2, S3, S10 | Name-keyed credentials | | D111 | List URL guard | BlockList URLs may not resolve to loopback, link-local, unspecified, multicast or known cloud metadata addresses unless blocking.allowLocalListURLs is set; private LAN ranges stay allowed; the guard checks the resolved address at dial time | Security review S1 (SSRF from blocklists.write) | Unrestricted URLs | | D112 | Webhook and stream limits | The external-dns webhook listens on 127.0.0.1:8888 by default and warns when exposed; at most 64 concurrent live query streams per node with a 30 s write deadline; forwarded auth bodies are capped at 64 KiB; the built-in dig (POST /dns/query) marks its requests synthetic and they never reach the query log; hide_domains also hides the matched zone | Security review S4-S8 | Unbounded defaults | | D113 | Renderer input validation | dnsdist topology strings are validated before rendering (hosts are IP addresses or DNS names; cert, key and DoH paths are printable ASCII without quotes or spaces; no newlines anywhere); keepalived paths allow braces only as the literal {vrid} | Security review S9, e2e finding E2E-6 | Quoting only | | D114 | Leader address in Kubernetes | cluster.ElectorMembership resolves the Lease holder's pod through the headless EndpointSlice (30 s TTL) and falls back to the DNS name | dnsPolicy: Default pods cannot resolve <pod>.<headless> | DNS only (M2 wiring) | | D115 | Internal zones route and serial metrics | GET /internal/zones returns {node, zones:[{name, serial, type}]} from the zone cache; kudurru_zone_serial{zone} and kudurru_zone_records{zone,kind} exist, so the serial-divergence panel and alert can ship | The edge supervisor polls it to flush the authoritative packet cache | — | | D116 | Aggregated ClusterRoles are generated | make rbac-manifests generates charts/kudurru/templates/rbac-aggregated.yaml from internal/rbac/permissions.yaml (D31 implemented) with a drift test; kudurru-auditor is no longer aggregated into the built-in view role because that would expose query logs to every viewer | Hand-written roles had drifted (custom verbs flush/toggle/refresh) | Hand-maintained ClusterRoles | | D117 | Release identity | A build without git metadata reports RELEASE_VERSION (0.1.0-rc.1); charts are at 0.1.0-rc.1; the binary-size budget is re-baselined to 60,362,000 bytes after the dashboard, Scalar bundle (self-hosted under the strict CSP, +1 MB) and supervisors; docs/release-checklist.md lists the owner-hardware steps (edge-check, kind + MetalLB, two-VM VRRP) | Measured 54.9 MB stripped | 51.2 MB budget from M1 | | D118 | E2E and fuzzing are opt-in but real | make e2e (KUDURRU_E2E=1) drives real kudurru serve binaries: BIND 9.18 as a secondary (AXFR, NOTIFY, TSIG refusal) and as a conditional upstream, a two-node internal-listener test, SIGTERM lameduck, restart continuity, config and port failures; make fuzz runs every Fuzz target with a committed seed corpus; CI runs both (bind9 via apt). Recorded bugs are skipped with KnownBug and listed in followups.md; E2E-1, E2E-3, E2E-4 and E2E-5 were fixed right after M5 (node.lameduck, default-group hints falling through to conditional forwarding, readiness gated on the first live snapshot, truthful Generated) | Single Linux host coverage without Docker | Waiting for Docker-based e2e | | D119 | Post-M5 hardening before the v0.1.0 tag | The five e2e bugs and E2E-6 are fixed (node.lameduck drain window default 2s, default-group hints fall through to conditional forwarding, readiness waits for the first live snapshot, Generated only for a created key, braces in keepalived paths only as {vrid}); the local edge resolver cache is flushed on list snapshots and toggle changes (Toggle.SetOnChange); the chart no longer merges config.settings into the node file and the compose files set KUDURRU_SETTINGS_FILE on the data volume; a busy admin socket is a warning; the Tools page reads GET /doctor; the release workflow builds and signs the supervisor images | Every item had a failing test or a documented gap in followups.md | Carrying them as known limitations into v0.1.0 | | D120 | Blocked-services and safe-search catalogue (D60 outcome) | Shipped in v0.1 as an embedded, Apache-2.0, kudurru-authored catalogue (internal/listdata/catalogue) with per-entry provenance checked in CI. Services compile as block lists service:<id>. Safe search answers the vendor's documented CNAME target (or address) with status blocked_safesearch. blocking.catalogue.url replaces the catalogue at runtime with a schema-version-1 document; the leader fetches it and replicas pull it over /internal/catalogue. The separate public data repository is deferred until the project owner creates it (publishing is the owner's call). It landed in the v0.1 fix round, not in M2. Contract: docs/deploy/blocked-services.md | M2 shipped without the catalogue and no decision recorded the slip (only docs/m2/policy.md said "v0.2"); an embedded copy works offline and the fetch URL keeps the D60 update path without a release | Ship it in v0.2; wait for the data repository before shipping anything | | D121 | Docs site (M5 exit criterion) | A Hugo site under site/ is built from the markdown in docs/ (front matter only where needed) with a CI job; it is implemented in the v0.1 fix round. M5 closed without it | The §13.1 M5 criterion and §12 list a docs site; the markdown is the single source, the site only renders it | A separate docs tree; dropping the criterion | | D122 | M4 report location | M4 (deploy + edge) was implemented together with M3 and reported in docs/m3/ (docs/m3/README.md "Milestones M3 and M4", deploy.md, edge.md); there is no docs/m4/ | The serve wiring of both milestones landed in one change; a split report would have duplicated it | An empty docs/m4/ pointing back | | D123 | Dashboard tested against the real server | The dashboard is tested end to end with Playwright against the real kudurru serve binary (web/e2e, make web-e2e, CI job web-e2e); the vitest + MSW unit tests remain for component coverage; the generated OpenAPI types (bun run gen:api) are the static half of the guard | Until the v0.1 fix round the SPA was only tested against mock handlers and drifted from the API in ways no test saw | Mocks only; a Go + headless Chrome harness | | D124 | Policy.spec.blockedServices | A pattern-checked string set, not a CRD enum; unknown IDs are skipped and reported as Degraded{UnknownService}, and re-checked whenever the catalogue changes | The catalogue can change at runtime (blocking.catalogue.url) without a CRD upgrade | An enum of service IDs in the CRD schema | | D125 | Safe search is part of filtering | blocking.enabled=false and the temporary blocking disable also turn safe search off, and an allow-list match skips it | One switch and one allow path for everything the filter rewrites | Safe search independent of blocking | | D126 | safeSearchMode | strict (default) or moderate on the Policy; the second level exists only because YouTube documents two Restricted Mode levels | Follows the vendors' documented targets instead of inventing levels | A per-vendor mode map | | D127 | Forwarded auth routes are documented by checked mirrors | The auth stack serves the read-only /iam/* lists like /auth/* (api.Options.IAM/IAMReader removed, one implementation). The OpenAPI schemas of every forwarded route are mirror types in internal/api/authtypes.go; TestForwardedShapes (cmd/kudurru) fails when a mirror differs from the stack type, when the real handler rejects a body built from the documented type, or when a real answer has an undocumented field. API permission routes answer 403 password_change_required like the stack's; /auth/me reports live grants (local sessions carry no roles snapshot, D48) and a scoped token reports its scopes | The OpenAPI document described token and status bodies the handlers rejected, so a generated client could not work | Wiring a second IAM reader in the API package; leaving the forwarded routes undocumented | | D128 | Dashboard wire types are generated | The SPA aliases the generated OpenAPI schemas (web/src/api/generated.ts, bun run gen:api) and the MSW mocks serve the real shapes (settings schema regenerated from a live server). Upstreams are edited on a dedicated page through PATCH /settings merge patches; v0.1 has no separate upstream API | Hand-written types drifted from every second route (D123) | A separate /upstreams API; hand-written types with a contract test | | D129 | Stream write deadline | Each message written on a TCP or DoT connection, zone-transfer streams included, gets a deadline of Limits.StreamWriteTimeout (default 30 s); a failed transfer write closes the connection | The kernel wakes a blocked writer only after two thirds of the send buffer drained, so the 2 s WriteTimeout would cut off a slow WAN secondary | One deadline for the whole transfer; reusing WriteTimeout | | D130 | Object size cap in every driver | store.MaxObjectBytes (1.5 MiB, the etcd request cap) applies in the sqlite and kube drivers; both report store.ErrTooLarge, which the API maps to 413 too_large | An object accepted by sqlite must also fit the kube driver, so a backup restores in either mode | Enforcing the cap only in the API | | D131 | Dashboard theme and dialogs | The default theme "Kudurru" is Astryx Neutral with the docs/brand palette as CSS custom-property overrides (Paper/Ink surfaces, Clay accent, Clay as text adjusted for AA, Stone dividers); query status colours are kudurru's own light/dark pairs, the same in every theme and separate from the brand; hover and selection are translucent overlays. Every modal uses components/FormDialog (header with a close button, scrolling content, footer that stays visible); the close button and Escape always close, the backdrop only until the form was changed, and the forced password change cannot be dismissed | The placeholder look ignored the brand, solid hover tints made rows unreadable, and long dialogs hid their buttons | Astryx Stone as the base (cool greys and washed-out status colours clash with the brand, loads lazily); per-page dialog markup | | D132 | Pages, wizards and dialogs | Every object a user configures or inspects has a dedicated page with a current-state header and sections edited in place; creating anything with several concepts is a wizard on its own route (/…/new, components/Wizard on the Astryx Stepper); FormDialog only for short single-purpose actions. Closed sets, durations, bounded numbers and object references are typed controls, never free text (docs/m3/ui.md "Pages, wizards and dialogs") | Long scrolling modal forms hid their buttons and context, and free text invited invalid values | Modals for everything; generic object forms | | D133 | Typed settings metadata | config.Key carries label, group, enum (value/label/help), min/max, presets, format, ref and nested shapes; Config.Validate enforces it before the cross-key checks, so the file, the environment, the API and the UI share one definition of a valid value; 400 invalid answers carry error.fields and the allowed values. Closed sets owned elsewhere (rbac roles) are pinned by drift tests | The UI needs the closed sets to offer dropdowns, and duplicated hand-written checks drifted | Enums only in the SPA; JSON Schema per key | | D134 | Catalogue logos | Logos for catalogue services, safe-search engines and upstream providers are SVGs from homarr-labs/dashboard-icons (Apache-2.0, pinned commit, PROVENANCE) embedded in the binary, sanitised and scanned in tests, served publicly at /api/v1/catalogue/icons/{slug}.svg with an ETag; entries without a logo get a monogram | A self-hosted DNS server should not make the browser fetch from a CDN; static logos need no session | A CDN; Simple Icons | | D135 | Block page | The blockPage response answers A/AAAA with blockPage.ipv4/ipv6 (default: the first specific listen address; refused when none) and other types NODATA with EDE 15, falling back to zeroIP when the page is off. internal/blockpage serves the page on the node with a strict CSP, rendered by hand (executing html/template disables the linker's dead-method elimination: +12.6 MB; TestNoTemplateExecution guards it). The reason comes from the query-log tail, then querylog.db, within the privacy mode, then the client's current policy; the admin preview shows an example reason so settings.read reveals no query-log data. HTTPS is optional and always warns; HSTS sites cannot show it | Users of a filtering resolver need to see why a site is blocked, like Technitium's blocking page | Proxying the site; a per-client cache outside the privacy mode | | D136 | Unblock requests | Requests are UnblockRequest objects (namespaced; a CRD in Kubernetes, rows in state.db) indexed per node from a store watch, so the unauthenticated block page never reads the store per view; its writes are bounded per client, per node and by a pending cap, with a same-origin check. Approvals reuse the UI allow lists (ui-allow for everyone, attached to the default and requester policies; ui-allow-<group> for a group) and record the entry in status.allow, so the leader removes exactly what a time-limited approval added. No per-client scope because policies bind to ClientGroups. Clients are stored as querylog.privacy allows | One place for admins to fix what blocks users, visible on every node and auditable with kubectl | Node-local storage; a per-client ClientGroup per approval | | D137 | Per-source list switch and policy attribution | BlockList.spec.sources[].enabled (omitted = true): a disabled source is not fetched or compiled, keeps its cache and last status; an all-disabled list is Ready with 0 entries; the whole-list switch stays the kudurru.io/enabled annotation (D109). querylog.db schema 2 records the deciding policy per row (additive, upgraded in place) for GET /policies/{ns}/{name}/state and GET /queries?policy= | Pi-hole-style per-adlist switches; a policy page needs its own activity | Deleting sources to disable them; per-entry switches (the inline format stores none) | | D138 | Binary-size ceiling | make size fails above 100,000,000 bytes (stripped, CGO_ENABLED=0, linux/amd64); the v0.1 UX round measures 58,523,808. Growth is still explained in the change that causes it, and TestNoTemplateExecution keeps template execution (which disables dead-method elimination, +12.6 MB) out of the binary | The project owner's call: features over the old 10 % headroom, under a hard 100 MB ceiling | Re-baselining at +10 % per release | | D139 | Release branches | Releases are promoted through branches: pull requests into main push ghcr.io/robertherbaugh/kudurru:dev-<PR> (overwritten by later pushes); each push to release-candidate (merges from main only) tags v<VERSION>-rc.<N> and publishes a GitHub pre-release with <VERSION>-rc.<N> images; each push to release (merges from release-candidate only) tags v<VERSION> and publishes the GitHub release with <VERSION> and latest. VERSION in the repository root is the only hand-maintained version; hack/release-tag.sh derives the tags, refuses a VERSION that is already released and a release whose tree differs from its last candidate. The workflows push the tags themselves and call publish.yml (goreleaser, charts, supervisor images) | A tag pushed by hand could ship an untested commit; a release now ships exactly what its last candidate shipped, and the version lives in one reviewed file | Tags pushed by hand (the previous v* trigger); retagging the rc image as the release (the binary would report the rc version); semantic-release from commit messages | | D140 | Dashboard themes are Astryx themes (revises D131) | web/src/themes holds Kudurru (defineTheme extending Neutral; colours are stops of a palette generated from docs/brand by astryx theme palette generate, brand colours as exact anchors) and Neutral/Stone/Y2K as local themes extending the package themes, all built with astryx theme build and wired through the generated src/astryx-themes.ts and <Theme>; @astryxdesign/core/reset.css is imported. Query status colours are theme tokens (--q-*, the same in every theme); the high-contrast toggle re-points them to --q-*-strong. Themes name only system fonts. astryx doctor reports no warnings | Astryx's rule against :root palette overrides; the packages' Google Fonts imports are blocked by the CSP | Package themes imported as-is; :root overrides (D131) | | D141 | Form fields: label, control, help, status | Every control is an Astryx component behind web/src/components/fields/ (SelectField, TextField, TextAreaField, NumberField, SwitchField, CheckboxField, EnumSelect, DurationField, ObjectRefPicker, …). Astryx description sits between label and control with no placement option, so the wrappers render the help as supporting Text after the control, linked by aria-describedby (re-applied because most Astryx inputs overwrite it), and errors as a detached FieldStatus; Switch and CheckboxInput keep their own description. Raw form elements and description on Astryx inputs are lint errors outside components/fields/. NumberField checks min/max itself so out-of-range input gets an inline error instead of NumberInput's silent clamp. The dashboard follows web/AGENTS.md (astryx init --features agents) | Rows with and without help text did not align, native selects did not match the design system | Astryx Field description above the control; native controls | | D142 | Creation wizards are modals (revises D132) | Creating anything with several concepts is a wizard in a modal over the page the user is on (WizardDialog, composed like Astryx's form-wizard-dialog template: title, ✕ and a compact Stepper pinned above the step, Cancel/Back/Next/Create pinned below). ✕, Escape and Cancel ask "Discard the new …?" (AlertDialog) once anything was entered; the backdrop never closes a dirty wizard; Create closes it and opens the new object's page. List pages open it with ?new=1; /…/new redirects there. A wizard opens no other dialog besides the discard question. Objects keep dedicated pages | A dedicated page for a creation flow took the user away from the list they were adding to | Wizard on its own route (UX round 2); nested dialogs | | D143 | Allowlist/Denylist storage and per-entry switches | Per-entry state lives on the lists. The ui source of ui-allow / ui-deny (everyone) and ui-allow-<group> / ui-deny-<group> (one ClientGroup, in its namespace) holds spec.sources[].entries: [{value, type: exact\|wildcard\|regex, enabled, comment, created, createdBy, modified, origin, request}]; everyone lists are referenced from every API- or bootstrap-managed policy of the namespace, group lists from the writable policies naming the group, re-checked by the leader every 30 s and on every write; file- or integration-managed policies are reported as unattached, never edited. Single entries of any source (URL, ConfigMap, inline) are switched off with spec.sources[].exclude, kept across refreshes, with staleExcludes in status. Old inline ui lines are migrated losslessly once by the leader. /allowlist, /denylist and /domains serve the entries; the query log, unblock approvals and the compatibility /domains/{allow\|deny}/{exact\|regex} routes write them | The project owner's choice to keep the state on the lists (with a link back to each list); Pi-hole/Technitium-style allow and deny pages with switches, comments and group scope | A DomainRule kind (new CRD, rows, controller, RBAC; one object per entry in etcd); query-time scope in the policy engine | | D144 | List exceptions on the Allowlist and Denylist; switches on the right | Switched-off entries of non-ui lists (spec.sources[].exclude) are shown as exception rows on the Allowlist (block lists) or Denylist (allow lists), and on Domains; they have no storage of their own: the row's switch, delete and batch edit the exclusion, and POST {values, list, source?} creates them ("Only in list …"). Per-row switches sit on the right, before the actions, in every table; selection checkboxes stay leftmost | One central place for everything that lets a name through, without opening each list; the project owner's layout preference | Exceptions only in each list's entries browser | | D145 | Catalogue categories and category blocking (revises D120, D124) | Catalogue schema 2 adds categories (stable id, name, description, Lucide icon, order) and per-service requires/related/note; version 1 documents are still read with the embedded category names. Policy.spec.blockedCategories blocks every service of those categories in the catalogue in service, including ones a later catalogue adds; spec.allowedServices are exceptions inside blocked categories only (never cancelling blockedServices; allow lists still win). Unknown categories are Degraded{UnknownCategory}. The embedded catalogue has 150 kudurru-authored services in 14 categories plus other, with per-entry provenance and a CI check that no service blocks an upstream resolver or a shared sign-in host | Owners block a whole kind of service (AI) without picking each one, and keep up as services appear | A CRD enum of categories; copying AdGuard's or HaGeZi's service lists | | D146 | Content categories from the Block List Project | Its topic lists (gambling, adult, piracy, phishing, malware, …) are offered as well-known lists and as policy "content categories": each is a BlockList blp-<id> with the upstream URL, created on demand in the policy's namespace and refreshed on the normal schedule; nothing is embedded. Recorded licence: repository Unlicense, list headers MIT, the gambling and porn lists merge HaGeZi (GPL-3.0); the UI says so and warns about the size of the malware list | Long-tail categories cannot be hand-written per brand | Embedding the lists | | D147 | Opt-in runtime import of AdGuard's HostlistsRegistry services | blocking.catalogue.adguardServicesURL (default off, restart) makes the instance download AdGuard's GPL-3.0 services file; kudurru ships, vendors and tests no AdGuard data (the format is implemented from the registry's public README; tests use a hand-written fixture). The leader fetches it on the catalogue schedule, replicas pull /internal/catalogue/adguard, the last good copy is kept. Imported services carry origin: adguard, license: GPL-3.0 and are marked in the UI; on an id both have, kudurru's entry wins (the import is counted as shadowed), so turning the opt-in on never changes what an existing policy blocks. AdGuard groups map to kudurru categories (messenger→messaging, social_network→social, streaming→video, unknown→other) | The project owner's choice to offer AdGuard's broader list without distributing GPL data | Side-by-side prefixed ids (duplicate tiles; an id's meaning would depend on the opt-in); bundling the data | | D148 | Deleting a zone | DELETE /zones/{zone} deletes the DNSZone and every DNSRecordSet whose zoneRef names it, in any namespace: one transaction on the sqlite driver (store.MultiDeleter; one audit row per object), record sets first and the zone last on the kube driver, so a failure keeps the zone (the error says how many record sets went) and a retry finishes. It needs zones.delete on the zone, plus records.delete there when record sets exist; it refuses with 409 managed_by (all offenders in error.objects) when the zone or a record set is owned by a file, an integration, Kubernetes or the system, and with 409 default_zone for the root zone ., which routes every query outside the other zones (its upstream group and query access apply to them): it is edited, not deleted. ?dryRun=true runs every check and returns the same summary (record sets, record set and endpoint counts) without deleting. The API adds one zones.delete audit event and flushes this node's cached answers at or below the apex. The generic DELETE /objects/dnszones/{name} and dnszones:batchDelete never cascade: a zone that record sets reference is 409 zone_not_empty (listed in error.objects), the root zone 409 default_zone. GET /zones reports deleteRefusal per zone | Record sets reference zones by name without ownerReferences, so deleting only the DNSZone orphaned them (ZoneNotFound); one cascading route keeps the generic delete Kubernetes-like and the two paths from disagreeing | Cascading on the generic route (a surprising side effect of kudurru delete/batchDelete); ownerReferences on record sets (only the Kubernetes garbage collector honours them, and only after the fact; the sqlite driver has none, and every writer, integrations and kubectl users included, would have to set them); allowing the root zone to be deleted (it silently changes routing and drops its query ACL) | | D149 | Attach for policies the dashboard may not edit | The Allowlist and Denylist show, for each file-, Kubernetes- or integration-managed policy that lacks a ui list, the exact spec.allowListRefs/spec.blockListRefs YAML merged with the policy's current refs, and say where the definition lives from kudurru.io/managed-by (objects-directory file: watched, no restart; objects: in the config file: restart; Kubernetes: manifest or a kubectl patch merge patch; integration: change it at its source). ?add=<domain> on /allowlist, /denylist and /domains opens the Add dialog prefilled (the query log links there) | One writer per object (D9) stays intact while the dashboard still tells the admin exactly what to change | The API editing file-managed objects; a /allowlist/new route | | D150 | Own-token routes at any held scope | /auth/tokens* are guarded by tokens.own at cluster scope, then at the scope of each effective grant and of each scope of a scoped API token, each checked by the RBAC engine so token scopes still restrict; ?all=true and other owners' tokens still need tokens.read/tokens.delete at cluster scope. Token lastUsedIP comes from the API's client IP (trusted proxies) | Zone-admins could not mint tokens; the proxy address was recorded instead of the client | A cluster-scope tokens.own grant for every role | | D151 | Host-network images and edge privileges | <version>-hostnetwork is the ko binary with cap_net_bind_service=+ep (ko cannot set file capabilities), built and signed by publish.yml from the verified ko digest; the chart's hostNetwork.enabled runs it. The main image stays without the capability (a +ep binary refuses to start where the bounding set lacks it). For eBPF and host-network dnsdist the container starts as root with only NET_BIND_SERVICE, SETUID, SETGID, KILL (+ BPF, SYS_ADMIN for eBPF) and dnsdist switches to 953 (kudurru edge run --dnsdist-user), because Kubernetes makes added capabilities effective only for root and file capabilities do not pass through tini under no_new_privs. Supervisor images copy /ko-app/kudurru (ko's path) | Port 53 on the host network and eBPF maps could not work as uid 953/65532; the first release would have shipped broken supervisor images | File capability on the main image; running dnsdist as root throughout | | D152 | Precompressed dashboard | The dashboard is embedded gzip-only (web/scripts/compress.ts, files ≥ 1 KiB except index.html) and served with Content-Encoding, Vary and an ETag per encoding; clients without gzip get it decompressed once; brotli is opt-in (KUDURRU_WEB_BROTLI=1, ~0.7 MB more). −1.84 MB binary | D75 headroom; nearly every client accepts gzip | Both encodings embedded | | D153 | v0.1 security round decisions | Unblock request names are ur- + 16 hex of HMAC-SHA256 over client and name with an HKDF key from the encryption key (a viewer cannot recover the requester's address); the AdGuard import keeps only single-site \|\|name^ rules below a public suffix, at most 1,000 services; the guarded list/catalogue client checks every request URL, redirects included, for local addresses (also behind a proxy); each node runs at most 4 concurrent cached-body parses (then 503 with Retry-After); releases are published only from the release/release-candidate branches, check out refs/tags/<tag> without persisted credentials, build supervisor and host-network images from verified digests, and every cosign verify pins publish.yml@refs/heads/release(-candidate) (docs/m5/security-v0.1-round.md) | Findings V1–V17 | Accepting the risks | | D154 | Writable directories in every image | The ko release images build on ghcr.io/robertherbaugh/kudurru-base (images/base: the pinned distroless image plus /var/lib/kudurru 0755 and /run/kudurru 1777, both owned by 65532), which publish.yml builds, signs and passes to goreleaser by digest (KUDURRU_BASE_IMAGE, KUDURRU_DEBUG_BASE_IMAGE); the Dockerfile and images/hostnetwork create the same directories; make volume-check and publish.yml start each image on fresh named volumes | A fresh Docker named volume takes the owner of the image's directory; distroless has none, so -v kudurru-data:/var/lib/kudurru (docs/deploy/docker.md, every compose file) came up root-owned and kudurru exited with open state.db: permission denied. ko cannot add directories or owners | Building the images from the Dockerfile instead of ko (larger pipeline change before the first tag); documenting a chown init container (every user hits it first) |


17. Glossary

TermMeaning in this document
Stone / clay copyThe boundary-stone metaphor: the store that is authoritative for configuration (apiserver CRDs or state.db) vs. copies distributed by AXFR/IXFR/NOTIFY/catalog zones or by store replication (follower/raft). DNSRecordSet objects owned by app teams are also "clay copies" of authority delegated from a DNSZone.
Store / driverinternal/store.Store, the single persistence interface; drivers kube (CRDs), sqlite (state.db), follower (v0.2), raft (v1.0).
managedBymetadata.managedBy: the single writer of an object (api, file:<path>, integration:<name>, bootstrap, system); in Kubernetes the apiserver field manager plus the kudurru.io/managed-by annotation.
zonecacheThe always-on runnable that materialises store objects into in-memory zone trees, policy indexes and compiled lists, and persists the last-good set to local.db.
local.dbPer-node SQLite file in Kubernetes mode: last-good config snapshot, audit, response-cache dump, (v0.2+) transferred/signed zone data.
state.dbThe sqlite driver's object store in Docker mode (objects, internal kinds, change feed, journals, audit, meta.schema_version, cluster_epoch).
Leader-only runnableA manager runnable with NeedLeaderElection()=true: controller, listfetcher, notifier, signer, integrations, acme. Elected by Lease (k8s), self (sqlite), primary (follower), raft (v1.0).
Static twins (ha-static)v0.1 compose HA profile: N nodes with identical file-managed configuration, each with its own dnsdist listing all nodes, and keepalived for the VIP.
Follower modev0.2 compose HA: one writable primary, N read-only followers pulling snapshots; kudurru cluster promote with epoch fencing.
Fencing / epochcluster_epoch incremented on promote; a primary that sees a higher epoch demotes itself and refuses writes.
Pipeline / stageThe fixed 13-stage query path (ingress … log); stages are packages implementing pipeline.Stage.
Status taxonomyThe ≤ 32-value Response.Status enum (forwarded, cached, blocked_list, …) shared by the query log, metrics and UI colours.
EndpointThe record shape: external-dns Endpoint fields plus a kudurru{comment,disabled,expiresAt,view} block.
ViewEndpoint.kudurru.view: public (published to sinks, never served) or a ClientGroup name (served only to that group). v0.2.
Publish rule / sinkDNSZone.spec.publish[] selecting records for an outbound RecordSink integration (Cloudflare); per-sink overrides via providerSpecific.
LedgerInternal kind recording what an integration wrote to a provider (providerIDs, contentHash, generation).
Supervisorkudurru edge run / kudurru vip run: PID 1 in the dnsdist/keepalived images; renders, validates, execs and signals the GPL binary.
TopologyThe Go struct shared by the dnsdist YAML and Lua renderers (binds, pools, backends, TLS, cache, console, webserver, rules).
track_file / FIFOkeepalived channels: kudurru writes a weight (0/60/300) to a file; keepalived writes state transitions to a FIFO that kudurru consumes.
PROXYv2PROXY protocol v2 header prepended by dnsdist so kudurru sees the real client address; accepted only from proxyProtocolFrom.
health.kudurru.internal.Locally answered health name (TXT <node-id> <serial-epoch>) used by dnsdist health checks, keepalived track_script and the liveness self-query.
Sealed cookieAES-256-GCM-encrypted session cookie carrying the principal and a groups snapshot; any replica can serve it without shared state.
sessionsValidAfterPer-user timestamp; sessions issued before it are rejected (password change, admin kill, disable).
SAR / TokenReviewKubernetes SubjectAccessReview / TokenReview; used to authorise and authenticate Kubernetes identities against kudurru.io real and virtual resources.
Virtual resourceA kudurru.io resource name used only in RBAC rules (querylogs, stats, caches, blocking, audits, clusters, settings) with no CRD behind it.
kdr_ tokenkudurru API token: kdr_<12-char id>_<43-char secret>; only sha256(secret) is stored.
hostOnlyConfig-metadata flag: the key can be set only via file/env on the host, never through the API or UI.
Conformance suiteinternal/store/conformance: the test suite every Store driver must pass (and the external-dns plan-engine cases, the blocking corpus).
RollupPre-aggregated query-log tables (rollup_1m/_1h/_1d and per-client/qtype/upstream/domain/list/zone variants) that power dashboards without scanning facts.
Scatter-gatherCluster-scope API calls fan out to every node's /internal/* with a 2 s timeout and merge results (partial: true on failure).
SeamAn interface or schema field that exists in v0.1 so a deferred feature can be added without a rewrite (§13.1).

Source: docs/architecture.md