kudurru on Kubernetes with Helm
Two charts (docs/architecture.md §5.1, §5.2, §5.7). Minimum Kubernetes 1.30.
kudurru-crds: thekudurru.ioCRDs, separate sohelm uninstallnever deletes them (generated bymake manifestsfromapi/v1alpha1).kudurru: the server. Every value maps onto a settings key of the binary (kudurru config schema), aKUDURRU_*variable or a Kubernetes field;deploy/chart_test.gorenders every values permutation, loads each renderedkudurru.yamlthrough the binary's own config loader and validates the manifests with kubeconform (make helm-check).
Install
kubectl create namespace kudurru-system
kubectl label namespace kudurru-system pod-security.kubernetes.io/enforce=restricted
helm install kudurru-crds oci://ghcr.io/robertherbaugh/charts/kudurru-crds --version 0.1.0
helm install kudurru oci://ghcr.io/robertherbaugh/charts/kudurru --version 0.1.0 \
--namespace kudurru-system
The default is mode: kubernetes: two replicas of a Deployment storing
objects as CRDs (kube driver), a Lease-elected controller, one
mixed-protocol LoadBalancer Service on :53 with
externalTrafficPolicy: Local (client addresses are the product), a PDB,
topology spread, dnsPolicy: Default and the Restricted Pod Security
Standard (uid 65532, read-only root, all capabilities dropped except
NET_BIND_SERVICE, net.ipv4.ip_unprivileged_port_start=0 so the
non-root process binds :53).
First login
The chart generates three Secrets once and keeps them on uninstall
(helm.sh/resource-policy: keep): <release>-encryption-key,
<release>-admin and, with edge.enabled, <release>-proxy-protocol. The
release notes print the commands; in short:
kubectl -n kudurru-system get secret kudurru-admin -o jsonpath='{.data.password}' | base64 -d; echo
kubectl -n kudurru-system port-forward svc/kudurru-api 8080:8080
# open https://localhost:8080/ and sign in as admin
The admin Secret is read only while no user exists; the UI asks for a new
password at the first login. Point auth.bootstrapAdminSecret and
auth.encryptionKeySecret at your own Secrets (for example from
external-secrets) to keep secret material out of Helm entirely.
Shapes
| Goal | Values |
|---|---|
| Default: replicas behind a cloud or MetalLB LoadBalancer | (none) |
| Pin the address with MetalLB | vip.provider=metallb, vip.address=192.0.2.53 (vip.metallbPool) |
| Pin the address with kube-vip | vip.provider=kube-vip, vip.address=192.0.2.53 |
| LoadBalancer without mixed-protocol support | service.dns.splitProtocols=true (dns-udp + dns-tcp sharing the IP) |
| One pod per node on the host network | hostNetwork.enabled=true (DaemonSet, the -hostnetwork image) |
| Single node, no CRDs ("Pi-hole on k3s") | mode=standalone (sqlite on a PVC, StatefulSet with one replica) |
| dnsdist edge in front (DoT/DoH/DoQ, packet cache) | edge.enabled=true (edge.mode=deployment) |
| Single-node cluster with dnsdist | edge.enabled=true, edge.mode=sidecar |
| Bare-metal VIP with keepalived | edge.enabled=true, vip.provider=keepalived, vip.address=10.0.0.53/24, vip.keepalived.interface=eth0 |
| DNS before the control plane | priorityClassName=system-cluster-critical, persistence.local.enabled=true |
mode=standalone
store.driver: sqlite, a StatefulSet with one replica and a
volumeClaimTemplate for /var/lib/kudurru (persistence.standalone). No
ClusterRole, no ServiceAccount token, no CRDs: objects come from
objects: in the values (written into the node file as file-managed
objects) and from the UI.
Persistence in mode=kubernetes
querylog.db and local.db live in /var/lib/kudurru. By default that is
an emptyDir (it survives container restarts and node reboots while the
pod stays). persistence.querylog.enabled or persistence.local.enabled
switch the Deployment to a StatefulSet so every replica gets its own claim
(SQLite WAL files must never be shared or put on RWX/NFS). A hostNetwork
DaemonSet cannot have per-pod claims; use extraVolumes with a hostPath
there.
hostNetwork
hostNetwork.enabled runs a DaemonSet in the host network namespace, where
the namespaced net.ipv4.ip_unprivileged_port_start sysctl is not available
(Kubernetes rejects net.* sysctls on hostNetwork pods) and Kubernetes gives
a non-root process no ambient capabilities. The chart therefore runs
ghcr.io/robertherbaugh/kudurru:<version>-hostnetwork
(hostNetwork.imageTagSuffix): the same binary with the file capability
cap_net_bind_service=+ep (images/hostnetwork/Dockerfile). The container
keeps the Restricted security context: uid 65532, all capabilities dropped
except NET_BIND_SERVICE, allowPrivilegeEscalation: false.
NET_BIND_SERVICE must stay in securityContext.capabilities.add: the
kernel refuses to start a binary whose file capability is outside the
bounding set, so the chart fails the render without it. Host networking is
outside the Baseline level; label the namespace
pod-security.kubernetes.io/enforce=privileged. With your own image set
hostNetwork.imageTagSuffix: "" and either give the binary the capability
or set net.ipv4.ip_unprivileged_port_start=53 on the hosts.
Edge (dnsdist)
With edge.enabled the dns Service selects the edge pods; kudurru listens
on edge.backendPort (5353) and accepts PROXYv2 only from
edge.proxyProtocolFrom with the TLV 0xE0 secret from
<release>-proxy-protocol (D72). The CIDR defaults to the RFC 1918 and ULA
ranges; narrow it to your pod CIDR. The supervisor reads the headless
Service's EndpointSlices (--backends-from endpointslice:<ns>/<release>-headless) and exposes readiness and
/metrics on :8084.
The dnsdist container runs as uid 953 under the Restricted standard, and
binds :53 through the pod's ip_unprivileged_port_start sysctl. Two cases
need more than Kubernetes gives a non-root process, because added
capabilities are only effective for root: edge.ebpf.enabled (creating
the BPF maps needs CAP_BPF/CAP_SYS_ADMIN) and dnsdist on the host
network (edge.mode=daemonset, vip.provider=keepalived, or the sidecar of
a hostNetwork.enabled pod), where the sysctl is not available. There the
container starts as root with group 953, allowPrivilegeEscalation: false,
a read-only root and every capability dropped except NET_BIND_SERVICE,
SETUID, SETGID and KILL (plus BPF and SYS_ADMIN for eBPF); the
supervisor passes --dnsdist-user=953:953, so dnsdist binds its sockets
and creates the maps as root, then switches to 953 and keeps only
CAP_SYS_ADMIN (eBPF) or nothing. make edge-check runs both cases in the
image. Such a namespace needs the privileged level (SYS_ADMIN and host
networking are outside Baseline).
vip.provider=keepalived runs dnsdist and keepalived in one hostNetwork
DaemonSet on the nodes labelled kudurru.io/vip=true. keepalived needs
NET_ADMIN, NET_RAW and NET_BROADCAST as root: label the namespace
pod-security.kubernetes.io/enforce=privileged.
Encrypted transports
service.dot|doh|doq|doh3.enabled add the ports to the dns Service. Without
the edge kudurru terminates TLS with the kubernetes.io/tls Secret named by
tls.secretName; with the edge dnsdist terminates it with
edge.tlsSecretName.
Configuration
config.node (node keys: listeners, store, log, tsigKeys, notify) and
config.settings (cluster keys: upstreams, cache, blocking, querylog,
ratelimit) are YAML in the format of kudurru.yaml
(docs/architecture.md §6.2):
config:
node:
log: { level: info, format: json }
settings:
upstreams:
groups:
default: { servers: ["tls://1.1.1.1", "tls://9.9.9.9"], strategy: parallel_best, timeout: 2s }
conditional: { "corp.example.": internal }
blocking: { defaultResponse: nxdomain }
They render into two ConfigMaps (D25): <release>-node is the file
serve --config reads and its checksum annotation rolls the pods;
<release>-settings holds the cluster keys and is mounted at
/etc/kudurru/settings for hot reload. Today the binary reads only the
node file, so the chart also merges the settings into it and a settings
change rolls the pods; once the settings file is read and watched
(docs/followups.md) the merge goes away and settings changes apply without a
restart.
Seed objects with objects: (post-install hook resources in
mode=kubernetes: created once, then owned by kubectl and the UI).
Monitoring
| Value | Renders |
|---|---|
serviceMonitor.enabled | ServiceMonitor on the API Service's metrics port (adds a pod label) |
podMonitor.enabled | PodMonitor on every kudurru pod |
either, with edge.enabled | PodMonitor <release>-edge on the supervisors: kudurru edge run :8084 (edge-ready) and, with vip.provider=keepalived, kudurru vip run :8085 (vip-health); labels and interval from podMonitor when enabled, else serviceMonitor |
prometheusRule.enabled | PrometheusRule from deploy/prometheus/rules.yaml |
grafanaDashboard.enabled | ConfigMap with deploy/grafana/kudurru-overview.json, label grafana_dashboard: "1" for the Grafana sidecar |
The dashboard has the §11.6 rows (tiles, traffic, latency heatmap,
blocking, cache, upstreams, zones, pipeline and query log, cluster and
store, dnsdist, VIP); every query uses a metric the binary registers today
(the deploy tests enforce it), including the serial-divergence panel and
KudurruSerialDivergence. The edge and VIP panels and the
KudurruEdgeBackendDown and KudurruVIPNoMaster alerts read the
supervisors' metrics listeners (:8084, :8085), which the <release>-edge
PodMonitor scrapes (a node label names the node of hostNetwork pods).
Network policy
networkPolicy.enabled restricts ingress to the kudurru pods: :5353 only
from edge pods (plus edge.proxyProtocolFrom as ipBlocks in the
hostNetwork daemonset mode), the internal listener :8082 only from kudurru
and edge pods, the API from networkPolicy.apiFrom (default: everyone),
metrics from networkPolicy.metricsFrom, probes on :8081, and with
blockPage.enabled the block page ports from everyone. Egress is not
restricted.
Block page
blockPage.enabled with blockPage.ipv4 (or ipv6) serves the block page
on the kudurru pods behind a <release>-blockpage Service (LoadBalancer by
default, ports 80 and optionally 443) and sets blockPage.enabled/ipv4/ipv6
in the settings ConfigMap; pick the blockPage response with
config.settings.blocking.defaultResponse or per Policy. Setup, the HTTPS
caveats and the edge and caching notes: blocking-page.md.
RBAC
The kudurru ServiceAccount gets (D77, §5.3): create/patch/delete/get/list/
watch on the five kudurru.io kinds, update/patch on their status,
read-only dnsendpoints, services, namespaces and endpointslices,
create on events, tokenreviews and subjectaccessreviews; in the release
namespace only, leases (get/create/update), secrets
(get/list/watch/create/update/patch/delete: token revocation and user or
RoleBinding deletion delete Secrets) and configmaps
(get/list/watch/create/update). rbac.aggregateToDefaultRoles adds
<release>-viewer|editor|admin aggregated into view|edit|admin and
<release>-auditor (aggregated nowhere: it reads the query log). They are
generated from internal/rbac/permissions.yaml by make rbac-manifests
into templates/rbac-aggregated.yaml.
Values
See charts/kudurru/values.yaml; every value carries a comment. Values that
the chart added beyond §5.2: tls.secretName, edge.backendPort,
edge.proxyProtocolFrom, edge.tlsSecretName, edge.existingSecret,
edge.dnsdistVersion, vip.metallbPool, vip.keepalived.existingSecret,
vip.keepalived.keepalivedVersion, auth.encryptionKeyPreviousSecret,
auth.bootstrapToken, podMonitor, probes, lameduckSeconds,
clusterDomain (default cluster.local), persistence.standalone.enabled,
extraEnv, extraVolumes, extraVolumeMounts, blockPage. auth.oidc.providers is
a list of OpenID Providers rendered into the settings key
auth.identityProviders (the §5.2 outline's single-issuer auth.oidc
became a list). webhook from the §5.2 outline is not in the chart: the
validating webhook does not exist in the binary.
OIDC usernames: without claimMappings.username.claim a provider's
username is the first of preferred_username, upn, email (only when
email_verified is true) and sub. Some identity providers let users edit
preferred_username, so a role binding with a User subject could be
claimed by another account. Bind groups instead, or set
claimMappings.username.claim: sub (with a prefix) on such providers.