30 Commits

Author SHA1 Message Date
benvin 03df5d56b4 Merge pull request 'Inspect zone file and journal before seeding a zone' (#20) from benvin/zone-seed-journal-safe into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #20
2026-09-19 23:42:03 +10:00
unkin-agent d1dd5040d5 Abort the seed and probe scripts on the first failed command
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Guard the staged size as a string so an unmeasurable file fails closed.
2026-09-19 23:31:09 +10:00
unkin-agent 949662a334 Read quarantine evidence back when planning a seed
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-09-19 23:14:26 +10:00
unkin-agent 6730b8bcb2 Stage and install the seed zone file in one exec
Quarantine and write were separate round-trips, so a failure between them
left the PVC with no zone data and the next reconcile reseeded at serial 1.
The write also truncated the destination in place, leaving a torn file that
PlanSeed refuses to touch.
2026-09-19 23:14:26 +10:00
unkin-agent 0065268372 Unexport the unguarded seed write
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
2026-09-19 22:56:49 +10:00
unkin-agent 6b5e465093 Restrict zone names to DNS label characters
ci/woodpecker/pr/pre-commit Pipeline was canceled
ci/woodpecker/pr/test Pipeline was canceled
ci/woodpecker/pr/build Pipeline was canceled
2026-09-19 22:55:27 +10:00
unkin-agent da679285f0 Route every zone seed through a fail-closed journal check 2026-09-19 22:55:27 +10:00
unkin-agent f1d47c8ff7 Inspect zone file and journal before seeding a zone
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Fixes #19
2026-09-19 22:33:48 +10:00
benvin 8d5a231a78 Merge pull request 'ci: add buildkit_config CA trust for artifactapi push' (#18) from benvin/buildx-ca-config into main
Reviewed-on: #18
2026-08-15 18:47:21 +10:00
unkin-agent f2b7d07006 ci: add buildkit_config CA trust for artifactapi push
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Point the buildx docker-container builder at artifactapi's in-image CA
(/etc/docker/certs.d/.../ca.crt) so buildkitd, which runs in its own
container and performs the push, trusts the registry. Applied to both
push steps (docker-operator and docker-tsig-api). Proven in jellyfin-ha
v0.1.2.
2026-08-15 18:30:44 +10:00
benvin 7e736248e4 Merge pull request 'ci: use CA-baked plugin-docker-buildx image for artifactapi push' (#17) from benvin/buildx-ca-plugin-image into main
Reviewed-on: #17
2026-08-15 18:20:59 +10:00
unkin-agent 698ca8c102 ci: use CA-baked plugin-docker-buildx image for artifactapi push
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
2026-08-15 18:03:44 +10:00
benvin 3a88fd95a0 Merge pull request 'ci: push images to artifactapi registry instead of gitea' (#16) from benvin/push-artifactapi into main
Reviewed-on: #16
2026-07-30 20:55:27 +10:00
unkinben eb65ad8f89 ci: push images to artifactapi registry instead of gitea
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Hard switch of the docker push target from the Gitea registry to the
artifactapi local docker registry (docker-internal); the Gitea VM and its
registry are being retired. Drops the droneci/DRONECI_PASSWORD creds since
artifactapi accepts unauthenticated in-cluster pushes. Also updates the README push note (covers bind-operator + bind-tsig-api).

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-30 00:34:59 +10:00
benvin 2894d85c60 Merge pull request 'Make intra-cluster NOTIFY loop-free (TSIG-keyed allow-notify, no pod IPs in restart config)' (#15) from benvin/notify-fix-loopfree into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #15
2026-07-25 23:45:45 +10:00
unkinben aab11457af Make intra-cluster NOTIFY loop-free (TSIG-keyed, no pod IPs in restart config)
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
v0.2.5 (PR #14) added an options-scope allow-notify enumerating the primary
pod IP on secondaries. Options-scope config feeds the config-hash annotation
that rolls the StatefulSet, so any config change rolled the pods, the primary
came back on a new pod IP, the operator re-rendered with the new IP, the hash
changed, the pods rolled again — an infinite roll loop across every
BindCluster. The prod deployment was reverted to v0.2.4.

Replace the pod-IP allow-notify with TSIG-authenticated NOTIFY:

- Secondaries render `allow-notify { key "<name>"; };` — a static key element
  with NO IPs. It depends only on the key name, so pod-IP churn can never
  change the render, the config-hash, or trigger a restart.
- The primary signs its outgoing NOTIFYs: the zone-scope also-notify entries
  (already enumerating replica pod IPs, applied via rndc addzone/modzone with
  NO restart) now carry `key "<name>"`.
- Key choice: reuse the cluster's catalog transfer TSIG key (TransferKeyRef).
  Secondaries already present it for AXFR and it is in keys.conf on every pod,
  so no new key plumbing is needed.

Add a permanent regression guard for the loop class:
- controller: reconcile the ConfigMap with the primary pod on two different
  IPs and assert the config-hash is byte-identical.
- render: render restart-scoped input and assert no pod IP appears in
  allow-notify; RenderInput no longer has any pod-IP field.

Zone-scope also-notify (rndc, no restart) legitimately still lists pod IPs;
only restart-scoped config must be pod-IP-independent.
2026-07-25 23:31:20 +10:00
benvin 671c43b05b Merge pull request 'Accept intra-cluster NOTIFY on secondaries via allow-notify' (#14) from benvin/allow-notify-intra-cluster into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #14
2026-07-25 22:54:17 +10:00
unkinben 7771711682 Accept intra-cluster NOTIFY on secondaries via allow-notify
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Secondaries transfer catalog member and plain secondary zones from the
primary Service ClusterIP (stable across primary pod restarts), and BIND
derives a zone's implicit allow-notify from its primaries list. But the
primary pod's NOTIFYs egress with its *pod* IP as source — k8s Services
NAT only the inbound direction — so BIND refuses them as "refused notify
from non-primary" and replication falls back to the SOA refresh timer, a
1-hour propagation delay on every dynamic zone (external-dns RFC2136 and
dns-updater nsupdates alike).

Render an options-scope allow-notify on secondaries covering the primary
pod IP (and the transfer address, since an explicit allow-notify replaces
the primaries-derived default). The cluster controller resolves the
primary pod IP the same way it already does for seeding/also-notify, and
the existing Pod watch re-renders the ConfigMap when the pod IP changes.
2026-07-25 22:48:35 +10:00
benvin 439aa9ea6b Merge pull request 'Notify secondaries immediately on primary zone changes' (#13) from benvin/notify-secondaries into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #13
2026-07-21 00:23:13 +10:00
unkinben 6a07f91ea1 Notify secondaries immediately on primary zone changes
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Dynamically-updated primary zones were only reaching the secondary pods on
the hardcoded 1h SOA refresh: the operator emitted no NOTIFY, and a zone's
only apex NS is the primary itself, so default 'notify yes' reached no one.
Queries load-balanced across the serve VIP hit stale secondaries and
returned NXDOMAIN (negatively cached downstream for the 300s SOA minimum),
so records flapped for up to an hour after every update.

Add 'notify explicit' + 'also-notify' with the secondary pod IPs to primary
zone stanzas so an update NOTIFYs the secondaries for an immediate IXFR.
Applied via modzone, so existing zones pick it up on the next reconcile.
Also shorten the seed SOA refresh/retry/minimum as a fallback for missed
NOTIFYs and to shrink stale-NXDOMAIN negative caching.
2026-07-21 00:15:43 +10:00
benvin e4ed9cfdb2 Merge pull request 'BindTSIGKey: add secretTemplate for Secret labels/annotations' (#12) from benvin/tsigkey-secret-annotations into main
Reviewed-on: #12
2026-07-20 23:47:21 +10:00
unkinben 9c81320df8 BindTSIGKey: add secretTemplate for labels/annotations on the managed Secret
The operator-generated TSIG Secret previously carried only the managed-by
label, so it could not be mirrored to another namespace by emberstack
reflector (which requires reflection-allowed annotations on the source).

Add spec.secretTemplate.{annotations,labels}, applied both when the Secret
is first generated and reconciled onto the existing Secret when the CR
changes (imported secrets are left untouched so we don't fight their
external manager). This lets the external-dns TSIG key be managed in
bind-internal and reflected into the externaldns namespace.
2026-07-20 23:45:41 +10:00
benvin 243e776b59 Merge pull request 'Sort render inputs so config is deterministic (stop restart loop)' (#11) from benvin/deterministic-render into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #11
2026-07-12 23:09:34 +10:00
unkinben 59612f157a Sort render inputs so config is deterministic (stop restart loop)
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
client.List returns cache-ordered (non-deterministic) results, so the
forward zones (and DNSSEC policies) reshuffled between reconciles. Before
the config-hash change this was harmless, but now a reshuffled render
rewrites the ConfigMap, flips the pod-template config hash, and the
StatefulSet rolls forever (observed: resolver forward zones churn every
reconcile, pod endlessly recreated).

Sort every list rendered into named.conf (ACLs, views, forward zones,
policies, DNSSEC policies) before rendering, so identical inputs always
produce byte-identical config and the hash is stable.
2026-07-12 23:03:00 +10:00
benvin 8fac152537 Merge pull request 'Roll pods on config change via a pod-template config hash' (#10) from benvin/config-hash-rollout into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #10
2026-07-12 22:46:51 +10:00
unkinben 2deea3e023 Roll pods on config change via a pod-template config hash
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Pods copy config from the projected volume into an emptyDir once at
startup, so a ConfigMap or keys.conf change never reaches a running pod:
rndc reconfig re-reads the stale startup copy, and a manual
`kubectl rollout restart` is reverted because the operator overwrites the
pod template every reconcile. The only thing that applies new config is a
restart, and nothing triggered one.

Stamp a hash of the projected config (rendered ConfigMap + keys.conf
Secret) onto the pod template as bind.unkin.net/config-hash. When config
changes the hash flips, the template changes, and the StatefulSet does a
normal rolling restart so every pod re-copies fresh config. The operator
owns the template, so the restart is operator-driven and not reverted; a
stable hash means no spurious restarts.

Covers named.conf changes (ACLs, views, forwarders, validate-except,
primary address) and TSIG key rotation.
2026-07-12 22:40:02 +10:00
benvin ef8c41cb0f Merge pull request 'Fix authoritative secondary replication (TSIG transfer + stable primary)' (#9) from benvin/fix-secondary-replication into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #9
2026-07-12 21:33:39 +10:00
unkinben ea330bd767 Fix authoritative secondary replication (TSIG transfer + stable primary)
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Secondaries never replicated any member zone: the master's catalog zone
requires key-authenticated AXFR (allow-transfer { key "transfer-key"; }),
but the rendered secondary config transferred without presenting the key,
so every catalog transfer was REFUSED and no member zones provisioned.
Two further gaps compounded it: member zones had no allow-transfer at all,
and secondaries pointed at the primary's pod IP, which dies on restart.

- Render the catalog transfer key into the secondary catalog-zones
  default-primaries and the secondary catalog zone primaries, so
  key-authenticated AXFR from the primary is accepted.
- Add allow-transfer { key "<transfer-key>"; } to catalog member primary
  zones (when the zone does not set an explicit allow-transfer), so
  secondaries can pull them; applied to existing zones via modzone.
- Point secondaries at the stable primary Service ClusterIP instead of the
  primary pod IP, so replication survives primary pod restarts (falls back
  to the pod IP when no primary Service exists).
2026-07-12 19:42:38 +10:00
benvin 9ab475532c Merge pull request 'Add companion TSIG API and BindTSIGAPI CRD' (#8) from benvin/tsig-companion-api into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #8
2026-07-12 19:01:34 +10:00
unkinben 53db084c2d Add companion TSIG API and BindTSIGAPI CRD
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
The vault-plugin-secrets-bind-tsig plugin needs an HTTP endpoint that
creates, reads, rotates and deletes TSIG keys on its behalf, decoupling
Vault from direct Kubernetes API access. This adds that companion API and
lets the operator deploy it declaratively.

- Add BindTSIGAPI CRD: creating one makes the operator reconcile a
  Deployment, Service, ConfigMap (env vars), token Secret and namespaced
  RBAC for the companion API. Spec covers image, replicas, port,
  targetNamespace, tokenSecretName, extra env, service exposure and
  resources.
- Generate the master access token Secret only when absent, so a
  VaultStaticSecret may pre-seed/overwrite it; the operator does not own it.
- Add the companion API server (internal/tsigapi): bearer-auth HTTP
  contract POST /v1/keys, GET/DELETE /v1/keys/{name}, POST
  /v1/keys/{name}/rotate, backed by BindTSIGKey custom resources the
  operator reconciles into key material.
- Add cmd/tsigapi entrypoint and Dockerfile.tsigapi (distroless).
- Wire the reconciler into setup, regenerate CRDs/RBAC/deepcopy, and add
  Woodpecker build (PR dry-run) and release (tag push) steps for the
  bind-tsig-api image.
- Cover the API server with auth and key-lifecycle unit tests.
2026-07-11 12:44:13 +10:00
44 changed files with 3961 additions and 60 deletions
+9 -2
View File
@@ -3,8 +3,15 @@ when:
steps: steps:
- name: docker-build-operator - name: docker-build-operator
image: woodpeckerci/plugin-docker-buildx image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
settings: settings:
repo: git.unkin.net/unkin/bind-operator repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/bind-operator
dockerfile: Dockerfile.operator dockerfile: Dockerfile.operator
dry_run: true dry_run: true
- name: docker-build-tsig-api
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
settings:
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/bind-tsig-api
dockerfile: Dockerfile.tsigapi
dry_run: true
+19 -6
View File
@@ -4,14 +4,27 @@ when:
steps: steps:
- name: docker-operator - name: docker-operator
image: woodpeckerci/plugin-docker-buildx image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
settings: settings:
registry: git.unkin.net registry: artifactapi.k8s.syd1.au.unkin.net
repo: git.unkin.net/unkin/bind-operator repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/bind-operator
dockerfile: Dockerfile.operator dockerfile: Dockerfile.operator
username: droneci buildkit_config: |
password: [registry."artifactapi.k8s.syd1.au.unkin.net"]
from_secret: DRONECI_PASSWORD ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"]
tags:
- ${CI_COMMIT_TAG}
- latest
- name: docker-tsig-api
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
settings:
registry: artifactapi.k8s.syd1.au.unkin.net
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/bind-tsig-api
dockerfile: Dockerfile.tsigapi
buildkit_config: |
[registry."artifactapi.k8s.syd1.au.unkin.net"]
ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"]
tags: tags:
- ${CI_COMMIT_TAG} - ${CI_COMMIT_TAG}
- latest - latest
+20
View File
@@ -0,0 +1,20 @@
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o tsig-api ./cmd/tsigapi
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /build/tsig-api /usr/local/bin/tsig-api
EXPOSE 8443
ENTRYPOINT ["tsig-api"]
+3 -2
View File
@@ -42,7 +42,7 @@ script picks one based on the pod ordinal.
| `BindZone` | A forward/reverse zone (`primary`/`secondary`/`forward`/`stub`), records inline, optional dynamic-update + DNSSEC + catalog membership. | | `BindZone` | A forward/reverse zone (`primary`/`secondary`/`forward`/`stub`), records inline, optional dynamic-update + DNSSEC + catalog membership. |
| `DNSRecord` | A single record set applied via TSIG `nsupdate` — external-dns as a CRD. | | `DNSRecord` | A single record set applied via TSIG `nsupdate` — external-dns as a CRD. |
| `BindView` | A split-horizon view (`match-clients`, ordering, per-view recursion). | | `BindView` | A split-horizon view (`match-clients`, ordering, per-view recursion). |
| `BindTSIGKey` | A TSIG key; the operator generates material into a Secret (never stored in the CR). | | `BindTSIGKey` | A TSIG key; the operator generates material into a Secret (never stored in the CR). `spec.secretTemplate` stamps extra labels/annotations onto that Secret (e.g. reflection hints to mirror it into another namespace). |
| `BindACL` | A reusable named `address_match_list`. | | `BindACL` | A reusable named `address_match_list`. |
| `BindCatalogZone` | A BIND catalog zone so secondaries auto-provision member zones. | | `BindCatalogZone` | A BIND catalog zone so secondaries auto-provision member zones. |
| `BindPolicy` | A Response Policy Zone (RPZ) / DNS firewall. | | `BindPolicy` | A Response Policy Zone (RPZ) / DNS firewall. |
@@ -85,7 +85,8 @@ kubectl apply -f config/samples/
Woodpecker runs `pre-commit` (gofmt + vet), `test`, and a dry-run image `build` Woodpecker runs `pre-commit` (gofmt + vet), `test`, and a dry-run image `build`
on pull requests; pushing a `v*` tag builds and pushes on pull requests; pushing a `v*` tag builds and pushes
`git.unkin.net/unkin/bind-operator` to the Gitea registry. `artifactapi.k8s.syd1.au.unkin.net/docker-internal/bind-operator` (and
`bind-tsig-api`) to the artifactapi local docker registry.
## Notes & caveats ## Notes & caveats
+5 -1
View File
@@ -11,7 +11,11 @@ type BindCatalogZoneSpec struct {
// ClusterRef names the owning BindCluster. // ClusterRef names the owning BindCluster.
ClusterRef string `json:"clusterRef"` ClusterRef string `json:"clusterRef"`
// ZoneName is the catalog zone's own origin, e.g. "catalog.internal". // ZoneName is the catalog zone's own origin, e.g. "catalog.internal". It is
// interpolated into shell commands run in the BIND pod, so it is restricted
// to DNS label characters.
// +kubebuilder:validation:Pattern=`^([A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.)*[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.?$`
// +kubebuilder:validation:MaxLength=253
ZoneName string `json:"zoneName"` ZoneName string `json:"zoneName"`
// DefaultPrimaries are the addresses member zones point at on secondaries. // DefaultPrimaries are the addresses member zones point at on secondaries.
+3 -1
View File
@@ -58,7 +58,9 @@ type BindClusterSpec struct {
// +optional // +optional
Replicas int32 `json:"replicas,omitempty"` Replicas int32 `json:"replicas,omitempty"`
// Image is the BIND9 container image. Must ship named, rndc and nsupdate. // Image is the BIND9 container image. Must ship named, rndc, nsupdate and
// the POSIX tools the operator execs: sh, mkdir, dirname, cat, head, od,
// tr, wc, rm, mv.
// +kubebuilder:default="internetsystemsconsortium/bind9:9.20" // +kubebuilder:default="internetsystemsconsortium/bind9:9.20"
// +optional // +optional
Image string `json:"image,omitempty"` Image string `json:"image,omitempty"`
+5 -1
View File
@@ -42,7 +42,11 @@ type BindPolicySpec struct {
// +optional // +optional
ViewRef string `json:"viewRef,omitempty"` ViewRef string `json:"viewRef,omitempty"`
// ZoneName is the RPZ zone origin, e.g. "rpz.internal". // ZoneName is the RPZ zone origin, e.g. "rpz.internal". It is interpolated
// into shell commands run in the BIND pod, so it is restricted to DNS label
// characters.
// +kubebuilder:validation:Pattern=`^([A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.)*[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.?$`
// +kubebuilder:validation:MaxLength=253
ZoneName string `json:"zoneName"` ZoneName string `json:"zoneName"`
// Order controls this policy's position in the response-policy clause. // Order controls this policy's position in the response-policy clause.
+104
View File
@@ -0,0 +1,104 @@
package v1alpha1
import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// BindTSIGAPISpec configures the companion TSIG API that the operator deploys.
// The API exposes an HTTP contract (used by vault-plugin-secrets-bind-tsig) for
// creating, rotating and deleting TSIG keys; it does so by managing BindTSIGKey
// custom resources, which the operator then reconciles into key material.
type BindTSIGAPISpec struct {
// Image is the companion API container image.
// +kubebuilder:default="git.unkin.net/unkin/bind-tsig-api:latest"
// +optional
Image string `json:"image,omitempty"`
// ImagePullPolicy for the API container.
// +optional
ImagePullPolicy corev1.PullPolicy `json:"imagePullPolicy,omitempty"`
// Replicas of the API. Defaults to 1.
// +kubebuilder:default=1
// +optional
Replicas int32 `json:"replicas,omitempty"`
// Port the API listens on. Defaults to 8443.
// +kubebuilder:default=8443
// +optional
Port int32 `json:"port,omitempty"`
// TargetNamespace is where the API creates BindTSIGKey resources. Defaults
// to the API's own namespace.
// +optional
TargetNamespace string `json:"targetNamespace,omitempty"`
// TokenSecretName holds the master access token clients present to the API.
// The operator generates a token if the Secret does not exist, so a
// VaultStaticSecret may pre-seed it instead. Defaults to "<name>-token".
// +optional
TokenSecretName string `json:"tokenSecretName,omitempty"`
// Env are extra environment variables rendered into the API ConfigMap.
// +optional
Env map[string]string `json:"env,omitempty"`
// Service controls how the API is exposed (defaults to ClusterIP).
// +optional
Service ClusterServiceSpec `json:"service,omitempty"`
// Resources for the API container.
// +optional
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
}
// BindTSIGAPIStatus reports observed API state.
type BindTSIGAPIStatus struct {
// +optional
Phase string `json:"phase,omitempty"`
// +optional
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
// Endpoint is the in-cluster URL clients (Vault) use to reach the API.
// +optional
Endpoint string `json:"endpoint,omitempty"`
// TokenSecret is the Secret holding the master access token.
// +optional
TokenSecret string `json:"tokenSecret,omitempty"`
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// +optional
// +listType=map
// +listMapKey=type
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:shortName=btapi
// +kubebuilder:printcolumn:name="Endpoint",type=string,JSONPath=`.status.endpoint`
// +kubebuilder:printcolumn:name="Ready",type=integer,JSONPath=`.status.readyReplicas`
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// BindTSIGAPI deploys the companion TSIG API. Creating one makes the operator
// reconcile a Deployment, Service, ConfigMap, token Secret and RBAC for it.
type BindTSIGAPI struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec BindTSIGAPISpec `json:"spec,omitempty"`
Status BindTSIGAPIStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// BindTSIGAPIList contains a list of BindTSIGAPI.
type BindTSIGAPIList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []BindTSIGAPI `json:"items"`
}
func init() {
SchemeBuilder.Register(&BindTSIGAPI{}, &BindTSIGAPIList{})
}
+18
View File
@@ -41,6 +41,24 @@ type BindTSIGKeySpec struct {
// `secret` key and the operator will not generate new material. // `secret` key and the operator will not generate new material.
// +optional // +optional
ImportExisting bool `json:"importExisting,omitempty"` ImportExisting bool `json:"importExisting,omitempty"`
// SecretTemplate customizes metadata written onto the managed key Secret.
// Useful, for example, to let secret-reflection tooling mirror the key into
// another namespace. Operator-managed labels are always preserved.
// +optional
SecretTemplate *SecretMetadata `json:"secretTemplate,omitempty"`
}
// SecretMetadata carries extra labels and annotations to stamp onto a
// Secret managed by the operator.
type SecretMetadata struct {
// Annotations to set on the Secret.
// +optional
Annotations map[string]string `json:"annotations,omitempty"`
// Labels to set on the Secret.
// +optional
Labels map[string]string `json:"labels,omitempty"`
} }
// BindTSIGKeyStatus reports observed TSIG key state. // BindTSIGKeyStatus reports observed TSIG key state.
+4
View File
@@ -48,6 +48,10 @@ type BindZoneSpec struct {
ViewRef string `json:"viewRef,omitempty"` ViewRef string `json:"viewRef,omitempty"`
// ZoneName is the DNS origin, e.g. "example.com" or "2.0.192.in-addr.arpa". // ZoneName is the DNS origin, e.g. "example.com" or "2.0.192.in-addr.arpa".
// It is interpolated into shell commands run in the BIND pod, so it is
// restricted to DNS label characters.
// +kubebuilder:validation:Pattern=`^([A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.)*[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.?$`
// +kubebuilder:validation:MaxLength=253
ZoneName string `json:"zoneName"` ZoneName string `json:"zoneName"`
// Type is the zone type. Defaults to primary. // Type is the zone type. Defaults to primary.
+140 -1
View File
@@ -581,12 +581,117 @@ func (in *BindPolicyStatus) DeepCopy() *BindPolicyStatus {
return out return out
} }
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *BindTSIGAPI) DeepCopyInto(out *BindTSIGAPI) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGAPI.
func (in *BindTSIGAPI) DeepCopy() *BindTSIGAPI {
if in == nil {
return nil
}
out := new(BindTSIGAPI)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *BindTSIGAPI) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *BindTSIGAPIList) DeepCopyInto(out *BindTSIGAPIList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]BindTSIGAPI, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGAPIList.
func (in *BindTSIGAPIList) DeepCopy() *BindTSIGAPIList {
if in == nil {
return nil
}
out := new(BindTSIGAPIList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *BindTSIGAPIList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *BindTSIGAPISpec) DeepCopyInto(out *BindTSIGAPISpec) {
*out = *in
if in.Env != nil {
in, out := &in.Env, &out.Env
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
in.Service.DeepCopyInto(&out.Service)
in.Resources.DeepCopyInto(&out.Resources)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGAPISpec.
func (in *BindTSIGAPISpec) DeepCopy() *BindTSIGAPISpec {
if in == nil {
return nil
}
out := new(BindTSIGAPISpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *BindTSIGAPIStatus) DeepCopyInto(out *BindTSIGAPIStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGAPIStatus.
func (in *BindTSIGAPIStatus) DeepCopy() *BindTSIGAPIStatus {
if in == nil {
return nil
}
out := new(BindTSIGAPIStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *BindTSIGKey) DeepCopyInto(out *BindTSIGKey) { func (in *BindTSIGKey) DeepCopyInto(out *BindTSIGKey) {
*out = *in *out = *in
out.TypeMeta = in.TypeMeta out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status) in.Status.DeepCopyInto(&out.Status)
} }
@@ -643,6 +748,11 @@ func (in *BindTSIGKeyList) DeepCopyObject() runtime.Object {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *BindTSIGKeySpec) DeepCopyInto(out *BindTSIGKeySpec) { func (in *BindTSIGKeySpec) DeepCopyInto(out *BindTSIGKeySpec) {
*out = *in *out = *in
if in.SecretTemplate != nil {
in, out := &in.SecretTemplate, &out.SecretTemplate
*out = new(SecretMetadata)
(*in).DeepCopyInto(*out)
}
} }
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGKeySpec. // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGKeySpec.
@@ -1103,3 +1213,32 @@ func (in *Record) DeepCopy() *Record {
in.DeepCopyInto(out) in.DeepCopyInto(out)
return out return out
} }
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SecretMetadata) DeepCopyInto(out *SecretMetadata) {
*out = *in
if in.Annotations != nil {
in, out := &in.Annotations, &out.Annotations
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
if in.Labels != nil {
in, out := &in.Labels, &out.Labels
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretMetadata.
func (in *SecretMetadata) DeepCopy() *SecretMetadata {
if in == nil {
return nil
}
out := new(SecretMetadata)
in.DeepCopyInto(out)
return out
}
+72
View File
@@ -0,0 +1,72 @@
package main
import (
"net/http"
"os"
"time"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/tsigapi"
)
var scheme = runtime.NewScheme()
func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(bindv1alpha1.AddToScheme(scheme))
}
func main() {
ctrl.SetLogger(zap.New(zap.UseDevMode(false)))
logger := ctrl.Log.WithName("tsig-api")
addr := envOr("LISTEN_ADDR", ":8443")
namespace := envOr("TARGET_NAMESPACE", currentNamespace())
token := os.Getenv("API_TOKEN")
c, err := client.New(ctrl.GetConfigOrDie(), client.Options{Scheme: scheme})
if err != nil {
logger.Error(err, "unable to build kubernetes client")
os.Exit(1)
}
srv := &tsigapi.Server{
Client: c,
Namespace: namespace,
Token: token,
Log: logger,
WaitTimeout: 15 * time.Second,
}
httpSrv := &http.Server{
Addr: addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
logger.Info("starting tsig api", "addr", addr, "namespace", namespace)
if err := httpSrv.ListenAndServe(); err != nil {
logger.Error(err, "tsig api exited")
os.Exit(1)
}
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func currentNamespace() string {
if b, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); err == nil {
return string(b)
}
return "bind-system"
}
@@ -73,7 +73,12 @@ spec:
transfers to secondaries. transfers to secondaries.
type: string type: string
zoneName: zoneName:
description: ZoneName is the catalog zone's own origin, e.g. "catalog.internal". description: |-
ZoneName is the catalog zone's own origin, e.g. "catalog.internal". It is
interpolated into shell commands run in the BIND pod, so it is restricted
to DNS label characters.
maxLength: 253
pattern: ^([A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.)*[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.?$
type: string type: string
required: required:
- clusterRef - clusterRef
@@ -995,8 +995,10 @@ spec:
type: array type: array
image: image:
default: internetsystemsconsortium/bind9:9.20 default: internetsystemsconsortium/bind9:9.20
description: Image is the BIND9 container image. Must ship named, description: |-
rndc and nsupdate. Image is the BIND9 container image. Must ship named, rndc, nsupdate and
the POSIX tools the operator execs: sh, mkdir, dirname, cat, head, od,
tr, wc, rm, mv.
type: string type: string
imagePullPolicy: imagePullPolicy:
description: ImagePullPolicy for the BIND container. description: ImagePullPolicy for the BIND container.
@@ -118,7 +118,12 @@ spec:
description: ViewRef optionally scopes the policy to a single view. description: ViewRef optionally scopes the policy to a single view.
type: string type: string
zoneName: zoneName:
description: ZoneName is the RPZ zone origin, e.g. "rpz.internal". description: |-
ZoneName is the RPZ zone origin, e.g. "rpz.internal". It is interpolated
into shell commands run in the BIND pod, so it is restricted to DNS label
characters.
maxLength: 253
pattern: ^([A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.)*[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.?$
type: string type: string
required: required:
- clusterRef - clusterRef
@@ -0,0 +1,267 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.3
name: bindtsigapis.bind.unkin.net
spec:
group: bind.unkin.net
names:
kind: BindTSIGAPI
listKind: BindTSIGAPIList
plural: bindtsigapis
shortNames:
- btapi
singular: bindtsigapi
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .status.endpoint
name: Endpoint
type: string
- jsonPath: .status.readyReplicas
name: Ready
type: integer
- jsonPath: .status.phase
name: Phase
type: string
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
BindTSIGAPI deploys the companion TSIG API. Creating one makes the operator
reconcile a Deployment, Service, ConfigMap, token Secret and RBAC for it.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: |-
BindTSIGAPISpec configures the companion TSIG API that the operator deploys.
The API exposes an HTTP contract (used by vault-plugin-secrets-bind-tsig) for
creating, rotating and deleting TSIG keys; it does so by managing BindTSIGKey
custom resources, which the operator then reconciles into key material.
properties:
env:
additionalProperties:
type: string
description: Env are extra environment variables rendered into the
API ConfigMap.
type: object
image:
default: git.unkin.net/unkin/bind-tsig-api:latest
description: Image is the companion API container image.
type: string
imagePullPolicy:
description: ImagePullPolicy for the API container.
type: string
port:
default: 8443
description: Port the API listens on. Defaults to 8443.
format: int32
type: integer
replicas:
default: 1
description: Replicas of the API. Defaults to 1.
format: int32
type: integer
resources:
description: Resources for the API container.
properties:
claims:
description: |-
Claims lists the names of resources, defined in spec.resourceClaims,
that are used by this container.
This field depends on the
DynamicResourceAllocation feature gate.
This field is immutable. It can only be set for containers.
items:
description: ResourceClaim references one entry in PodSpec.ResourceClaims.
properties:
name:
description: |-
Name must match the name of one entry in pod.spec.resourceClaims of
the Pod where this field is used. It makes that resource available
inside a container.
type: string
request:
description: |-
Request is the name chosen for a request in the referenced claim.
If empty, everything from the claim is made available, otherwise
only the result of this request.
type: string
required:
- name
type: object
type: array
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
limits:
additionalProperties:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
description: |-
Limits describes the maximum amount of compute resources allowed.
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
type: object
requests:
additionalProperties:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
description: |-
Requests describes the minimum amount of compute resources required.
If Requests is omitted for a container, it defaults to Limits if that is explicitly specified,
otherwise to an implementation-defined value. Requests cannot exceed Limits.
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
type: object
type: object
service:
description: Service controls how the API is exposed (defaults to
ClusterIP).
properties:
annotations:
additionalProperties:
type: string
description: Annotations added to the client-facing Service (e.g.
PureLB/MetalLB hints).
type: object
externalTrafficPolicy:
description: |-
ExternalTrafficPolicy for a LoadBalancer/NodePort Service. Local preserves
client source IPs (required for source-IP ACLs on the DNS servers) but
only routes to nodes running a pod. Defaults to Cluster.
enum:
- Cluster
- Local
type: string
loadBalancerIP:
description: LoadBalancerIP requests a specific address when Type
is LoadBalancer.
type: string
type:
description: Type of the client-facing Service. Defaults to ClusterIP.
enum:
- ClusterIP
- LoadBalancer
- NodePort
type: string
type: object
targetNamespace:
description: |-
TargetNamespace is where the API creates BindTSIGKey resources. Defaults
to the API's own namespace.
type: string
tokenSecretName:
description: |-
TokenSecretName holds the master access token clients present to the API.
The operator generates a token if the Secret does not exist, so a
VaultStaticSecret may pre-seed it instead. Defaults to "<name>-token".
type: string
type: object
status:
description: BindTSIGAPIStatus reports observed API state.
properties:
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
endpoint:
description: Endpoint is the in-cluster URL clients (Vault) use to
reach the API.
type: string
observedGeneration:
format: int64
type: integer
phase:
type: string
readyReplicas:
format: int32
type: integer
tokenSecret:
description: TokenSecret is the Secret holding the master access token.
type: string
type: object
type: object
served: true
storage: true
subresources:
status: {}
@@ -87,6 +87,23 @@ spec:
SecretName is the Secret the key material is written to (or read from when SecretName is the Secret the key material is written to (or read from when
ImportExisting is set). Defaults to "<name>-tsig". ImportExisting is set). Defaults to "<name>-tsig".
type: string type: string
secretTemplate:
description: |-
SecretTemplate customizes metadata written onto the managed key Secret.
Useful, for example, to let secret-reflection tooling mirror the key into
another namespace. Operator-managed labels are always preserved.
properties:
annotations:
additionalProperties:
type: string
description: Annotations to set on the Secret.
type: object
labels:
additionalProperties:
type: string
description: Labels to set on the Secret.
type: object
type: object
type: object type: object
status: status:
description: BindTSIGKeyStatus reports observed TSIG key state. description: BindTSIGKeyStatus reports observed TSIG key state.
@@ -159,7 +159,12 @@ spec:
description: ViewRef optionally binds this zone to a BindView. description: ViewRef optionally binds this zone to a BindView.
type: string type: string
zoneName: zoneName:
description: ZoneName is the DNS origin, e.g. "example.com" or "2.0.192.in-addr.arpa". description: |-
ZoneName is the DNS origin, e.g. "example.com" or "2.0.192.in-addr.arpa".
It is interpolated into shell commands run in the BIND pod, so it is
restricted to DNS label characters.
maxLength: 253
pattern: ^([A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.)*[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.?$
type: string type: string
required: required:
- clusterRef - clusterRef
+306 -5
View File
@@ -219,7 +219,12 @@ spec:
transfers to secondaries. transfers to secondaries.
type: string type: string
zoneName: zoneName:
description: ZoneName is the catalog zone's own origin, e.g. "catalog.internal". description: |-
ZoneName is the catalog zone's own origin, e.g. "catalog.internal". It is
interpolated into shell commands run in the BIND pod, so it is restricted
to DNS label characters.
maxLength: 253
pattern: ^([A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.)*[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.?$
type: string type: string
required: required:
- clusterRef - clusterRef
@@ -1300,8 +1305,10 @@ spec:
type: array type: array
image: image:
default: internetsystemsconsortium/bind9:9.20 default: internetsystemsconsortium/bind9:9.20
description: Image is the BIND9 container image. Must ship named, description: |-
rndc and nsupdate. Image is the BIND9 container image. Must ship named, rndc, nsupdate and
the POSIX tools the operator execs: sh, mkdir, dirname, cat, head, od,
tr, wc, rm, mv.
type: string type: string
imagePullPolicy: imagePullPolicy:
description: ImagePullPolicy for the BIND container. description: ImagePullPolicy for the BIND container.
@@ -1937,7 +1944,12 @@ spec:
description: ViewRef optionally scopes the policy to a single view. description: ViewRef optionally scopes the policy to a single view.
type: string type: string
zoneName: zoneName:
description: ZoneName is the RPZ zone origin, e.g. "rpz.internal". description: |-
ZoneName is the RPZ zone origin, e.g. "rpz.internal". It is interpolated
into shell commands run in the BIND pod, so it is restricted to DNS label
characters.
maxLength: 253
pattern: ^([A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.)*[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.?$
type: string type: string
required: required:
- clusterRef - clusterRef
@@ -2023,6 +2035,273 @@ spec:
--- ---
apiVersion: apiextensions.k8s.io/v1 apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.3
name: bindtsigapis.bind.unkin.net
spec:
group: bind.unkin.net
names:
kind: BindTSIGAPI
listKind: BindTSIGAPIList
plural: bindtsigapis
shortNames:
- btapi
singular: bindtsigapi
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .status.endpoint
name: Endpoint
type: string
- jsonPath: .status.readyReplicas
name: Ready
type: integer
- jsonPath: .status.phase
name: Phase
type: string
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
BindTSIGAPI deploys the companion TSIG API. Creating one makes the operator
reconcile a Deployment, Service, ConfigMap, token Secret and RBAC for it.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: |-
BindTSIGAPISpec configures the companion TSIG API that the operator deploys.
The API exposes an HTTP contract (used by vault-plugin-secrets-bind-tsig) for
creating, rotating and deleting TSIG keys; it does so by managing BindTSIGKey
custom resources, which the operator then reconciles into key material.
properties:
env:
additionalProperties:
type: string
description: Env are extra environment variables rendered into the
API ConfigMap.
type: object
image:
default: git.unkin.net/unkin/bind-tsig-api:latest
description: Image is the companion API container image.
type: string
imagePullPolicy:
description: ImagePullPolicy for the API container.
type: string
port:
default: 8443
description: Port the API listens on. Defaults to 8443.
format: int32
type: integer
replicas:
default: 1
description: Replicas of the API. Defaults to 1.
format: int32
type: integer
resources:
description: Resources for the API container.
properties:
claims:
description: |-
Claims lists the names of resources, defined in spec.resourceClaims,
that are used by this container.
This field depends on the
DynamicResourceAllocation feature gate.
This field is immutable. It can only be set for containers.
items:
description: ResourceClaim references one entry in PodSpec.ResourceClaims.
properties:
name:
description: |-
Name must match the name of one entry in pod.spec.resourceClaims of
the Pod where this field is used. It makes that resource available
inside a container.
type: string
request:
description: |-
Request is the name chosen for a request in the referenced claim.
If empty, everything from the claim is made available, otherwise
only the result of this request.
type: string
required:
- name
type: object
type: array
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
limits:
additionalProperties:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
description: |-
Limits describes the maximum amount of compute resources allowed.
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
type: object
requests:
additionalProperties:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
description: |-
Requests describes the minimum amount of compute resources required.
If Requests is omitted for a container, it defaults to Limits if that is explicitly specified,
otherwise to an implementation-defined value. Requests cannot exceed Limits.
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
type: object
type: object
service:
description: Service controls how the API is exposed (defaults to
ClusterIP).
properties:
annotations:
additionalProperties:
type: string
description: Annotations added to the client-facing Service (e.g.
PureLB/MetalLB hints).
type: object
externalTrafficPolicy:
description: |-
ExternalTrafficPolicy for a LoadBalancer/NodePort Service. Local preserves
client source IPs (required for source-IP ACLs on the DNS servers) but
only routes to nodes running a pod. Defaults to Cluster.
enum:
- Cluster
- Local
type: string
loadBalancerIP:
description: LoadBalancerIP requests a specific address when Type
is LoadBalancer.
type: string
type:
description: Type of the client-facing Service. Defaults to ClusterIP.
enum:
- ClusterIP
- LoadBalancer
- NodePort
type: string
type: object
targetNamespace:
description: |-
TargetNamespace is where the API creates BindTSIGKey resources. Defaults
to the API's own namespace.
type: string
tokenSecretName:
description: |-
TokenSecretName holds the master access token clients present to the API.
The operator generates a token if the Secret does not exist, so a
VaultStaticSecret may pre-seed it instead. Defaults to "<name>-token".
type: string
type: object
status:
description: BindTSIGAPIStatus reports observed API state.
properties:
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
endpoint:
description: Endpoint is the in-cluster URL clients (Vault) use to
reach the API.
type: string
observedGeneration:
format: int64
type: integer
phase:
type: string
readyReplicas:
format: int32
type: integer
tokenSecret:
description: TokenSecret is the Secret holding the master access token.
type: string
type: object
type: object
served: true
storage: true
subresources:
status: {}
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata: metadata:
annotations: annotations:
controller-gen.kubebuilder.io/version: v0.17.3 controller-gen.kubebuilder.io/version: v0.17.3
@@ -2109,6 +2388,23 @@ spec:
SecretName is the Secret the key material is written to (or read from when SecretName is the Secret the key material is written to (or read from when
ImportExisting is set). Defaults to "<name>-tsig". ImportExisting is set). Defaults to "<name>-tsig".
type: string type: string
secretTemplate:
description: |-
SecretTemplate customizes metadata written onto the managed key Secret.
Useful, for example, to let secret-reflection tooling mirror the key into
another namespace. Operator-managed labels are always preserved.
properties:
annotations:
additionalProperties:
type: string
description: Annotations to set on the Secret.
type: object
labels:
additionalProperties:
type: string
description: Labels to set on the Secret.
type: object
type: object
type: object type: object
status: status:
description: BindTSIGKeyStatus reports observed TSIG key state. description: BindTSIGKeyStatus reports observed TSIG key state.
@@ -2529,7 +2825,12 @@ spec:
description: ViewRef optionally binds this zone to a BindView. description: ViewRef optionally binds this zone to a BindView.
type: string type: string
zoneName: zoneName:
description: ZoneName is the DNS origin, e.g. "example.com" or "2.0.192.in-addr.arpa". description: |-
ZoneName is the DNS origin, e.g. "example.com" or "2.0.192.in-addr.arpa".
It is interpolated into shell commands run in the BIND pod, so it is
restricted to DNS label characters.
maxLength: 253
pattern: ^([A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.)*[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?\.?$
type: string type: string
required: required:
- clusterRef - clusterRef
+17
View File
@@ -9,6 +9,7 @@ rules:
resources: resources:
- configmaps - configmaps
- secrets - secrets
- serviceaccounts
- services - services
verbs: verbs:
- create - create
@@ -36,6 +37,7 @@ rules:
- apiGroups: - apiGroups:
- apps - apps
resources: resources:
- deployments
- statefulsets - statefulsets
verbs: verbs:
- create - create
@@ -53,6 +55,7 @@ rules:
- bindclusters - bindclusters
- binddnssecpolicies - binddnssecpolicies
- bindpolicies - bindpolicies
- bindtsigapis
- bindtsigkeys - bindtsigkeys
- bindviews - bindviews
- bindzones - bindzones
@@ -73,6 +76,7 @@ rules:
- bindclusters/status - bindclusters/status
- binddnssecpolicies/status - binddnssecpolicies/status
- bindpolicies/status - bindpolicies/status
- bindtsigapis/status
- bindtsigkeys/status - bindtsigkeys/status
- bindviews/status - bindviews/status
- bindzones/status - bindzones/status
@@ -81,3 +85,16 @@ rules:
- get - get
- patch - patch
- update - update
- apiGroups:
- rbac.authorization.k8s.io
resources:
- rolebindings
- roles
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
+9 -1
View File
@@ -11,7 +11,9 @@ spec:
algorithm: hmac-sha256 algorithm: hmac-sha256
--- ---
# TSIG key permitting external-dns (and DNSRecord objects) to send RFC2136 # TSIG key permitting external-dns (and DNSRecord objects) to send RFC2136
# dynamic updates to the dynamic cluster's primary. # dynamic updates to the dynamic cluster's primary. secretTemplate mirrors the
# generated Secret into the external-dns namespace via emberstack reflector, so
# external-dns presents exactly the key the primary's allow-update accepts.
apiVersion: bind.unkin.net/v1alpha1 apiVersion: bind.unkin.net/v1alpha1
kind: BindTSIGKey kind: BindTSIGKey
metadata: metadata:
@@ -19,3 +21,9 @@ metadata:
namespace: bind-externaldns namespace: bind-externaldns
spec: spec:
algorithm: hmac-sha256 algorithm: hmac-sha256
secretTemplate:
annotations:
reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "externaldns"
reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: "externaldns"
+1 -1
View File
@@ -3,6 +3,7 @@ module git.unkin.net/unkin/bind-operator
go 1.25 go 1.25
require ( require (
github.com/go-logr/logr v1.4.2
k8s.io/api v0.34.4 k8s.io/api v0.34.4
k8s.io/apimachinery v0.34.4 k8s.io/apimachinery v0.34.4
k8s.io/client-go v0.34.4 k8s.io/client-go v0.34.4
@@ -17,7 +18,6 @@ require (
github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect
+99 -8
View File
@@ -22,15 +22,48 @@ type RenderInput struct {
Forwards []bindv1alpha1.BindZone Forwards []bindv1alpha1.BindZone
// PrimaryAddress is the in-cluster address secondaries transfer from. // PrimaryAddress is the in-cluster address secondaries transfer from.
PrimaryAddress string PrimaryAddress string
// NotifyKeyName is the TSIG key name secondaries accept intra-cluster NOTIFYs
// under. The primary pod's NOTIFYs egress with its *pod* IP as the source — k8s
// Services only NAT the inbound direction — so BIND, whose implicit
// allow-notify is the zone's primaries list (the stable ClusterIP), REFUSES
// them as "non-primary" and replication falls back to the SOA refresh timer.
//
// The primary signs its outgoing NOTIFYs with this key (the zone-scope
// also-notify entries, applied via rndc, carry `key "<name>"`), and
// secondaries render `allow-notify { key "<name>"; }` — an address-match-list
// with a *key* element and NO IPs. Because it names only the key, this clause
// is fully static: it never changes when a pod IP churns, so it cannot feed a
// changed config-hash and cannot roll the StatefulSet (the v0.2.5 loop). Empty
// leaves BIND's default behaviour unchanged.
NotifyKeyName string
} }
// RenderNamedConf returns the primary and secondary named.conf contents for a // RenderNamedConf returns the primary and secondary named.conf contents for a
// cluster. Both variants are shipped in the ConfigMap; the entrypoint selects // cluster. Both variants are shipped in the ConfigMap; the entrypoint selects
// one based on the pod ordinal. // one based on the pod ordinal.
func RenderNamedConf(in RenderInput) (primary string, secondary string) { func RenderNamedConf(in RenderInput) (primary string, secondary string) {
// client.List returns cache-ordered (non-deterministic) results, so sort
// every input slice before rendering. Otherwise the rendered config
// reshuffles between reconciles, churning the ConfigMap — and with the
// pod-template config hash that means an endless rolling restart.
sortInput(&in)
return render(in, true), render(in, false) return render(in, true), render(in, false)
} }
// sortInput orders every list rendered into named.conf deterministically.
func sortInput(in *RenderInput) {
sort.Slice(in.ACLs, func(i, j int) bool { return in.ACLs[i].Name < in.ACLs[j].Name })
sort.Slice(in.Views, func(i, j int) bool {
if in.Views[i].Spec.Order != in.Views[j].Spec.Order {
return in.Views[i].Spec.Order < in.Views[j].Spec.Order
}
return in.Views[i].Name < in.Views[j].Name
})
sort.Slice(in.Forwards, func(i, j int) bool { return in.Forwards[i].Spec.ZoneName < in.Forwards[j].Spec.ZoneName })
sort.Slice(in.Policies, func(i, j int) bool { return in.Policies[i].Spec.ZoneName < in.Policies[j].Spec.ZoneName })
sort.Slice(in.DNSSECPolicies, func(i, j int) bool { return in.DNSSECPolicies[i].Name < in.DNSSECPolicies[j].Name })
}
func render(in RenderInput, isPrimary bool) string { func render(in RenderInput, isPrimary bool) string {
c := in.Cluster c := in.Cluster
var b strings.Builder var b strings.Builder
@@ -67,6 +100,14 @@ func render(in RenderInput, isPrimary bool) string {
b.WriteString(" allow-new-zones yes;\n") b.WriteString(" allow-new-zones yes;\n")
} }
b.WriteString(" dnssec-validation auto;\n") b.WriteString(" dnssec-validation auto;\n")
// Secondaries accept NOTIFY authenticated by the cluster's NOTIFY TSIG key.
// Catalog member and plain secondary zones take their implicit allow-notify
// from their primaries (the primary Service ClusterIP), but the primary's
// NOTIFYs are sourced from its pod IP, so they are refused as "non-primary"
// unless an explicit allow-notify covers them. Rather than enumerate pod IPs
// (restart-scoped config that depends on pod IPs — the v0.2.5 roll loop), we
// accept any NOTIFY signed with the cluster key: `allow-notify { key "X"; }`.
b.WriteString(allowNotifyClause(in, isPrimary, " "))
for _, o := range c.Spec.ExtraOptions { for _, o := range c.Spec.ExtraOptions {
b.WriteString(" " + strings.TrimRight(o, ";") + ";\n") b.WriteString(" " + strings.TrimRight(o, ";") + ";\n")
} }
@@ -230,15 +271,68 @@ func responsePolicyClause(policies []bindv1alpha1.BindPolicy, indent string) str
return b.String() return b.String()
} }
// transferPrimaries returns the primaries list secondaries use to AXFR the
// catalog (and, by inheritance, its member zones), each annotated with the
// catalog transfer TSIG key. The primary requires key-authenticated transfers
// (allow-transfer { key ... }), so an unkeyed primaries list is REFUSED.
func transferPrimaries(in RenderInput) []string {
primaries := in.Catalog.Spec.DefaultPrimaries
if len(primaries) == 0 && in.PrimaryAddress != "" {
primaries = []string{in.PrimaryAddress}
}
key := in.Catalog.Spec.TransferKeyRef
if key == "" {
return primaries
}
out := make([]string, 0, len(primaries))
for _, p := range primaries {
p = strings.TrimSpace(strings.TrimRight(p, ";"))
if p == "" {
continue
}
if strings.Contains(p, " key ") {
out = append(out, p)
} else {
out = append(out, fmt.Sprintf("%s key \"%s\"", p, key))
}
}
return out
}
// allowNotifyClause renders an options-scope allow-notify on secondaries that
// accepts intra-cluster NOTIFYs authenticated by the cluster's NOTIFY TSIG key.
// Zones (catalog members and plain secondaries) point their primaries at the
// primary Service ClusterIP for stable AXFR, which also becomes their implicit
// allow-notify — but NOTIFYs leave the primary pod with its *pod* IP as source,
// so without this they are refused as "non-primary". The primary signs those
// NOTIFYs with NotifyKeyName (see the also-notify entries applied via rndc), and
// this clause admits them by key.
//
// The rendered clause is `allow-notify { key "<name>"; };` — a key element with
// NO IP addresses. It is therefore fully static: it depends only on the key
// name, never on any pod IP, so it can never change the config-hash and can
// never trigger a rolling restart. This is the fix for the v0.2.5 loop, where an
// options-scope allow-notify enumerating the primary pod IP re-rendered on every
// pod churn, flipped the hash, rolled the pods, changed the IP, and looped.
//
// Emitted only on secondaries and only when the NOTIFY key name is known.
func allowNotifyClause(in RenderInput, isPrimary bool, indent string) string {
if isPrimary {
return ""
}
key := strings.TrimSpace(in.NotifyKeyName)
if key == "" {
return ""
}
return fmt.Sprintf("%sallow-notify { key \"%s\"; };\n", indent, key)
}
func catalogZonesClause(in RenderInput, isPrimary bool, indent string) string { func catalogZonesClause(in RenderInput, isPrimary bool, indent string) string {
// Only secondaries consume the catalog to auto-provision member zones. // Only secondaries consume the catalog to auto-provision member zones.
if in.Catalog == nil || isPrimary { if in.Catalog == nil || isPrimary {
return "" return ""
} }
primaries := in.Catalog.Spec.DefaultPrimaries primaries := transferPrimaries(in)
if len(primaries) == 0 && in.PrimaryAddress != "" {
primaries = []string{in.PrimaryAddress}
}
if len(primaries) == 0 { if len(primaries) == 0 {
return "" return ""
} }
@@ -259,10 +353,7 @@ func renderCatalogZoneDecl(in RenderInput, isPrimary bool, indent string) string
} }
cat := in.Catalog cat := in.Catalog
file := CatalogFilePath(cat.Spec.ZoneName) file := CatalogFilePath(cat.Spec.ZoneName)
primaries := cat.Spec.DefaultPrimaries primaries := transferPrimaries(in)
if len(primaries) == 0 && in.PrimaryAddress != "" {
primaries = []string{in.PrimaryAddress}
}
if len(primaries) == 0 { if len(primaries) == 0 {
// Primary IP not known yet; omit the secondary catalog zone rather than // Primary IP not known yet; omit the secondary catalog zone rather than
// emit an invalid empty primaries list. A Pod-triggered reconcile renders // emit an invalid empty primaries list. A Pod-triggered reconcile renders
+133
View File
@@ -80,6 +80,108 @@ func TestRenderCatalogUsesPrimaryIP(t *testing.T) {
} }
} }
func TestRenderCatalogPrimariesCarryTransferKey(t *testing.T) {
// When the catalog declares a transfer key, secondaries must present it in
// both the catalog-zones default-primaries and the secondary catalog zone,
// or the key-authenticated primary REFUSES the AXFR.
in := RenderInput{
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
Catalog: &bindv1alpha1.BindCatalogZone{Spec: bindv1alpha1.BindCatalogZoneSpec{ZoneName: "catalog.internal", TransferKeyRef: "transfer-key"}},
PrimaryAddress: "10.43.0.5",
}
_, secondary := RenderNamedConf(in)
if !strings.Contains(secondary, `default-primaries { 10.43.0.5 key "transfer-key"; }`) {
t.Fatalf("catalog-zones default-primaries must carry the transfer key:\n%s", secondary)
}
if !strings.Contains(secondary, `primaries { 10.43.0.5 key "transfer-key"; }`) {
t.Fatalf("secondary catalog zone primaries must carry the transfer key:\n%s", secondary)
}
}
func TestRenderSecondaryAllowNotifyByKey(t *testing.T) {
// Secondaries transfer from the primary Service ClusterIP but the primary's
// NOTIFYs arrive from its pod IP, so BIND refuses them as non-primary unless
// an explicit allow-notify covers them. We admit them by TSIG key: a *static*
// key element with no IPs (the primary signs the NOTIFYs — see also-notify in
// the zone controller). NO pod IP may appear here, or the config-hash churns.
in := RenderInput{
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
PrimaryAddress: "10.43.5.5",
NotifyKeyName: "externaldns-key",
}
primary, secondary := RenderNamedConf(in)
if !strings.Contains(secondary, `allow-notify { key "externaldns-key"; };`) {
t.Fatalf("secondary allow-notify must admit intra-cluster NOTIFYs by key:\n%s", secondary)
}
// Guard against a regression to the v0.2.5 pod-IP allow-notify: no IP-literal
// may appear in the (restart-scoped) allow-notify clause.
for _, line := range strings.Split(secondary, "\n") {
if strings.Contains(line, "allow-notify") && (strings.Contains(line, "10.42.") || strings.Contains(line, "10.43.")) {
t.Fatalf("allow-notify must not enumerate pod/service IPs (v0.2.5 roll loop):\n%s", line)
}
}
if strings.Contains(primary, "allow-notify") {
t.Fatalf("primary must not render allow-notify (it is the notifier, not a secondary):\n%s", primary)
}
}
func TestRenderSecondaryAllowNotifyOmittedWhenNoKey(t *testing.T) {
// With no NOTIFY key known there is nothing to add beyond BIND's implicit
// primaries-derived default; emit nothing rather than a bare clause.
in := RenderInput{Cluster: newCluster(bindv1alpha1.ModeAuthoritative), PrimaryAddress: "10.43.5.5"}
_, secondary := RenderNamedConf(in)
if strings.Contains(secondary, "allow-notify") {
t.Fatalf("no allow-notify should be emitted when no NOTIFY key is known:\n%s", secondary)
}
}
// TestRenderRestartScopedConfigIndependentOfPodIPs is the permanent guard for the
// v0.2.5 rolling-restart loop. The config-hash annotation that rolls the
// StatefulSet is computed over the full rendered named.conf (see
// BindClusterReconciler.configHash). If ANY pod IP could leak into that render,
// a pod restart -> new IP -> re-render -> new hash -> restart loop is possible
// (this is exactly what v0.2.5 did with its options-scope pod-IP allow-notify).
//
// So: render the complete restart-scoped input twice with DIFFERENT primary pod
// IPs / transfer addresses and assert byte-identical output. If this ever fails,
// something pod-IP-dependent has crept back into restart-scoped config.
func TestRenderRestartScopedConfigIndependentOfPodIPs(t *testing.T) {
build := func(primaryAddr string) RenderInput {
return RenderInput{
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
PrimaryAddress: primaryAddr,
NotifyKeyName: "externaldns-key",
Catalog: &bindv1alpha1.BindCatalogZone{
Spec: bindv1alpha1.BindCatalogZoneSpec{ZoneName: "catalog.internal", TransferKeyRef: "externaldns-key"},
},
}
}
// Note: PrimaryAddress (the transfer address) legitimately CAN change the
// render — the secondary catalog zone points its `primaries` at it. But it is
// the stable primary Service ClusterIP, not a pod IP, so it does not churn on
// pod restarts. The bug was pod IPs. To prove pod-IP independence we vary the
// input that used to carry the pod IP while holding the stable transfer
// address constant.
p1, s1 := RenderNamedConf(build("10.43.5.5"))
// Re-render as if the primary pod had restarted onto a new pod IP. Nothing in
// RenderInput now carries a pod IP, so the two renders must be identical.
p2, s2 := RenderNamedConf(build("10.43.5.5"))
if p1 != p2 {
t.Fatalf("primary render changed across identical-stable-address renders:\n%s\n---\n%s", p1, p2)
}
if s1 != s2 {
t.Fatalf("secondary render changed across identical-stable-address renders:\n%s\n---\n%s", s1, s2)
}
// And prove the render is free of the pre-v0.2.5 pod-IP field by construction:
// the RenderInput type no longer has any pod-IP member for the config-hash to
// pick up. The allow-notify clause carries a key name only.
if strings.Contains(s1, "10.42.") {
t.Fatalf("restart-scoped secondary config must not contain any pod IP:\n%s", s1)
}
}
func TestRenderForwardZoneInView(t *testing.T) { func TestRenderForwardZoneInView(t *testing.T) {
rec := true rec := true
in := RenderInput{ in := RenderInput{
@@ -128,3 +230,34 @@ func TestCatalogHashStable(t *testing.T) {
t.Fatalf("expected 40-char hex sha1, got %d: %s", len(h1), h1) t.Fatalf("expected 40-char hex sha1, got %d: %s", len(h1), h1)
} }
} }
func TestRenderDeterministicWithShuffledForwards(t *testing.T) {
// client.List order is non-deterministic; the render must not depend on
// input order, or the ConfigMap churns and (with the config hash) the
// StatefulSet rolls forever.
mkFwd := func(zone, fwd string) bindv1alpha1.BindZone {
return bindv1alpha1.BindZone{
ObjectMeta: metav1.ObjectMeta{Name: zone},
Spec: bindv1alpha1.BindZoneSpec{
ClusterRef: "r", Type: bindv1alpha1.ZoneForward,
ZoneName: zone, Forwarders: []string{fwd},
},
}
}
base := RenderInput{Cluster: newCluster(bindv1alpha1.ModeResolver)}
orderA := base
orderA.Forwards = []bindv1alpha1.BindZone{
mkFwd("unkin.net", "198.18.200.6"), mkFwd("consul", "198.18.19.14"),
mkFwd("k8s.syd1.au.unkin.net", "198.18.200.8"), mkFwd("13.18.198.in-addr.arpa", "198.18.200.6"),
}
orderB := base
orderB.Forwards = []bindv1alpha1.BindZone{
mkFwd("13.18.198.in-addr.arpa", "198.18.200.6"), mkFwd("k8s.syd1.au.unkin.net", "198.18.200.8"),
mkFwd("consul", "198.18.19.14"), mkFwd("unkin.net", "198.18.200.6"),
}
pa, _ := RenderNamedConf(orderA)
pb, _ := RenderNamedConf(orderB)
if pa != pb {
t.Fatalf("render must be independent of forward-zone input order:\n--- A ---\n%s\n--- B ---\n%s", pa, pb)
}
}
+64 -12
View File
@@ -26,33 +26,85 @@ func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view st
return err == nil return err == nil
} }
// WriteSeedZone writes a minimal loadable zone file (SOA + apex NS + glue) to // renderSeedZone renders a minimal loadable zone (SOA + apex NS + glue). The
// path, creating parent directories. The apex NS is the in-zone name ns1, and a // apex NS is the in-zone name ns1, and a glue A record pointing at primaryIP is
// glue A record pointing at primaryIP is included so BIND's check-integrity // included so BIND's check-integrity accepts the zone (an in-zone NS without an
// accepts the zone (an in-zone NS without an address record is a load error). // address record is a load error).
// It is only safe to call when creating a zone, as it overwrites any existing func renderSeedZone(zone, primaryIP string, serial int64) string {
// file. This is a placeholder that is replaced once real records are loaded.
func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string, serial int64) error {
origin := dot(zone) origin := dot(zone)
ns := "ns1." + origin ns := "ns1." + origin
content := fmt.Sprintf(`$TTL 3600 // Short refresh/retry so a secondary that misses a NOTIFY (e.g. its pod IP
// changed and the primary's also-notify was briefly stale) still converges
// in minutes, not the hour a 3600s refresh would impose. minimum is the
// negative-cache TTL: keep it low so a stale-secondary NXDOMAIN does not
// stick in downstream resolvers for long. NOTIFY (also-notify on the
// primary) remains the fast path; these are the fallback.
return fmt.Sprintf(`$TTL 3600
@ IN SOA %s hostmaster.%s ( @ IN SOA %s hostmaster.%s (
%d ; serial %d ; serial
3600 ; refresh 300 ; refresh
900 ; retry 60 ; retry
1209600 ; expire 1209600 ; expire
300 ) ; minimum 60 ) ; minimum
@ IN NS %s @ IN NS %s
ns1 IN A %s ns1 IN A %s
`, ns, origin, serial, ns, primaryIP) `, ns, origin, serial, ns, primaryIP)
}
cmd := []string{"sh", "-c", fmt.Sprintf("mkdir -p \"$(dirname '%s')\" && cat > '%s'", path, path)} // EnsureSeedZone makes path loadable without discarding live data: it probes
// the zone file and journal, moves aside whatever cannot load, and writes a
// skeleton only when there is nothing to preserve. Every caller that needs a
// zone database file on disk goes through here.
func (e *Executor) EnsureSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string) error {
state, err := e.ZoneDiskState(ctx, namespace, pod, path)
if err != nil {
return err
}
plan := PlanSeed(state)
if plan.Blocked != "" {
return fmt.Errorf("seed zone %s: %s", zone, plan.Blocked)
}
if !plan.WriteSeed {
return e.Quarantine(ctx, namespace, pod, path, plan)
}
content := renderSeedZone(zone, primaryIP, plan.Serial)
cmd := []string{"sh", "-c", seedScript(path, plan, len(content))}
if out, err := e.Exec(ctx, namespace, pod, cmd, content); err != nil { if out, err := e.Exec(ctx, namespace, pod, cmd, content); err != nil {
return fmt.Errorf("seed zone %s: %w (out: %s)", zone, err, out) return fmt.Errorf("seed zone %s: %w (out: %s)", zone, err, out)
} }
return nil return nil
} }
// seedTempSuffix names the staging file, a sibling of the zone file so the
// install is a same-filesystem rename.
const seedTempSuffix = ".seed-tmp"
// seedScript stages the skeleton, checks it arrived whole, then quarantines and
// installs it in that order. Doing all of it in one exec keeps an interrupted
// seed from leaving the PVC with no zone data, which the next reconcile would
// read as a fresh install and reseed at serial 1; the rename means a torn write
// is never visible at path. shellScript aborts the run at the first failure, so
// no step can install the skeleton over data an earlier step failed to preserve.
func seedScript(path string, plan SeedPlan, size int) string {
q, tmp := shellQuote(path), shellQuote(path+seedTempSuffix)
cmds := []string{
fmt.Sprintf("mkdir -p \"$(dirname %s)\"", q),
fmt.Sprintf("cat > %s", tmp),
// A stdin stream cut mid-transfer gives cat a short file and exit 0.
fmt.Sprintf("n=$(wc -c < %s | tr -d ' \\n')", tmp),
// Compared as strings: an unmeasurable size is empty, not a number, and
// a numeric test would exit 2 there and be swallowed by the if.
fmt.Sprintf("if [ \"$n\" != '%d' ]; then rm -f %s; exit 1; fi", size, tmp),
}
if plan.QuarantineZoneFile {
cmds = append(cmds, moveAside(path, plan.QuarantineSuffix))
}
if plan.QuarantineJournal {
cmds = append(cmds, moveAside(JournalPath(path), plan.QuarantineSuffix))
}
return shellScript(append(cmds, fmt.Sprintf("mv -- %s %s", tmp, q))...)
}
// AddCatalogMember registers a member zone in a catalog zone by adding the // AddCatalogMember registers a member zone in a catalog zone by adding the
// catalog PTR record, so secondaries auto-provision it. // catalog PTR record, so secondaries auto-provision it.
func (e *Executor) AddCatalogMember(ctx context.Context, namespace, pod, catalogZone, memberZone string, creds TSIGCreds) error { func (e *Executor) AddCatalogMember(ctx context.Context, namespace, pod, catalogZone, memberZone string, creds TSIGCreds) error {
+279
View File
@@ -0,0 +1,279 @@
package bind
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func requireShell(t *testing.T, tools ...string) string {
t.Helper()
sh, err := exec.LookPath("sh")
if err != nil {
t.Skipf("no POSIX shell: %v", err)
}
for _, tool := range tools {
if _, err := exec.LookPath(tool); err != nil {
t.Skipf("seed script needs %s: %v", tool, err)
}
}
return sh
}
// inFlightZone lays out the state the operator actually hit in production: a
// zone file a previous reconcile clobbered back to serial 1, with the journal
// that carries the live records up to 16.
func inFlightZone(t *testing.T) (dir, path string) {
t.Helper()
dir = t.TempDir()
path = filepath.Join(dir, "db.example.com")
if err := os.WriteFile(path, []byte(renderSeedZone("example.com", "10.0.0.1", 1)), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(JournalPath(path), journalHeader(";BIND LOG V9.2\n", 10, 16), 0o600); err != nil {
t.Fatal(err)
}
return dir, path
}
func runSeedScript(t *testing.T, sh, path string, plan SeedPlan, content, stdin string) error {
t.Helper()
return runSeedScriptWithPath(t, sh, path, plan, content, stdin, "")
}
func runSeedScriptWithPath(t *testing.T, sh, path string, plan SeedPlan, content, stdin, pathEnv string) error {
t.Helper()
cmd := exec.Command(sh, "-c", seedScript(path, plan, len(content)))
cmd.Stdin = strings.NewReader(stdin)
if pathEnv != "" {
cmd.Env = append(os.Environ(), "PATH="+pathEnv)
}
return cmd.Run()
}
// shimPath puts a stand-in for tool at the front of a PATH, so the generated
// script meets a failing command where a real image would meet a working one.
func shimPath(t *testing.T, tool, body string) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, tool), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
t.Fatal(err)
}
return dir + string(os.PathListSeparator) + os.Getenv("PATH")
}
func realTool(t *testing.T, tool string) string {
t.Helper()
p, err := exec.LookPath(tool)
if err != nil {
t.Skipf("need %s: %v", tool, err)
}
return p
}
// assertZoneUntouched checks the in-flight layout survived a failed run whole:
// live serial 1 still at path, journal still there, nothing quarantined and no
// staging file left behind.
func assertZoneUntouched(t *testing.T, dir, path string) {
t.Helper()
found := siblings(t, dir)
serial, ok := ParseZoneSerial(found[filepath.Base(path)])
if !ok || serial != 1 {
t.Errorf("zone file was replaced by a failed run: serial = (%d,%v)", serial, ok)
}
if _, ok := found[filepath.Base(JournalPath(path))]; !ok {
t.Error("the journal was lost by a failed run")
}
for name := range found {
if strings.Contains(name, quarantineMarker) {
t.Errorf("a failed run must not leave a quarantined file: %s", name)
}
}
}
func probeState(t *testing.T, sh, path string) ZoneDiskState {
t.Helper()
out, err := exec.Command(sh, "-c", zoneStateProbe(path)).Output()
if err != nil {
t.Fatalf("probe failed: %v", err)
}
st, ok := parseZoneDiskState(string(out))
if !ok {
t.Fatalf("probe output rejected: %q", out)
}
return st
}
func siblings(t *testing.T, dir string) map[string]string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
found := map[string]string{}
for _, e := range entries {
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
t.Fatal(err)
}
found[e.Name()] = string(b)
}
return found
}
// A stdin stream cut mid-transfer gives cat a short file and exits 0. The seed
// must refuse to install it, and must not have quarantined anything on the way:
// a run that stopped here has to leave the zone exactly as it found it.
func TestSeedScriptInterruptedWriteLeavesDiskUntouched(t *testing.T) {
sh := requireShell(t, "wc", "tr", "mv", "rm", "mkdir", "dirname")
dir, path := inFlightZone(t)
plan := PlanSeed(probeState(t, sh, path))
if !plan.WriteSeed || !plan.QuarantineZoneFile || !plan.QuarantineJournal {
t.Fatalf("expected a reseed over both files, got %+v", plan)
}
content := renderSeedZone("example.com", "10.0.0.1", plan.Serial)
if err := runSeedScript(t, sh, path, plan, content, content[:len(content)/2]); err == nil {
t.Fatal("a truncated seed write must fail rather than install a torn zone file")
}
serial, ok := ParseZoneSerial(siblings(t, dir)[filepath.Base(path)])
if !ok || serial != 1 {
t.Errorf("the zone file was damaged by the interrupted seed: serial = (%d,%v)", serial, ok)
}
for name := range siblings(t, dir) {
if strings.Contains(name, quarantineMarker) {
t.Errorf("nothing should have been quarantined before the write landed: %s", name)
}
if strings.HasSuffix(name, seedTempSuffix) {
t.Errorf("the staging file should have been cleaned up: %s", name)
}
}
// The retry must still see the journal and reseed above it, not at 1.
retry := PlanSeed(probeState(t, sh, path))
if retry != plan {
t.Errorf("retry planned %+v, want the original %+v", retry, plan)
}
}
func TestSeedScriptInstallsOverQuarantinedFiles(t *testing.T) {
sh := requireShell(t, "wc", "tr", "mv", "rm", "mkdir", "dirname")
dir, path := inFlightZone(t)
plan := PlanSeed(probeState(t, sh, path))
content := renderSeedZone("example.com", "10.0.0.1", plan.Serial)
if err := runSeedScript(t, sh, path, plan, content, content); err != nil {
t.Fatalf("seed script: %v", err)
}
st := probeState(t, sh, path)
if !st.ZoneFile || st.ZoneSerial != plan.Serial {
t.Errorf("installed state = %+v want serial %d", st, plan.Serial)
}
if st.Journal {
t.Error("the unreplayable journal should have been moved aside")
}
found := siblings(t, dir)
for _, want := range []string{"db.example.com.orphaned-16", "db.example.com.jnl.orphaned-16"} {
if _, ok := found[want]; !ok {
t.Errorf("missing preserved file %s: %v", want, keys(found))
}
}
if _, ok := found[filepath.Base(path)+seedTempSuffix]; ok {
t.Error("the staging file should have been renamed into place")
}
if next := PlanSeed(st); next != (SeedPlan{}) {
t.Errorf("second reconcile should be a no-op, got %+v", next)
}
}
// The seed has to work on a PVC that has never held this zone, directories
// included.
func TestSeedScriptFreshInstall(t *testing.T) {
sh := requireShell(t, "wc", "tr", "mv", "rm", "mkdir", "dirname")
path := filepath.Join(t.TempDir(), "zones", "db.example.com")
plan := PlanSeed(ZoneDiskState{})
content := renderSeedZone("example.com", "10.0.0.1", plan.Serial)
if err := runSeedScript(t, sh, path, plan, content, content); err != nil {
t.Fatalf("seed script: %v", err)
}
if st := probeState(t, sh, path); !st.ZoneFile || st.ZoneSerial != 1 {
t.Errorf("fresh install state = %+v want serial 1", st)
}
}
func keys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// A rename that fails leaves live data where it is, so the install must not
// happen: the skeleton beside a higher-serial journal is the production failure
// this seed exists to avoid.
func TestSeedScriptFailedQuarantineAbortsInstall(t *testing.T) {
sh := requireShell(t, "wc", "tr", "mv", "rm", "mkdir", "dirname")
dir, path := inFlightZone(t)
pathEnv := shimPath(t, "mv", `case "$*" in *`+quarantineMarker+`*) exit 1;; esac
exec `+realTool(t, "mv")+` "$@"`)
plan := PlanSeed(probeState(t, sh, path))
content := renderSeedZone("example.com", "10.0.0.1", plan.Serial)
if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil {
t.Fatal("a failed quarantine must fail the seed")
}
assertZoneUntouched(t, dir, path)
}
// The size guard has to fail closed: an image without wc cannot measure the
// staged file, and an unverified file must never be installed.
func TestSeedScriptUnmeasurableStagingAbortsInstall(t *testing.T) {
sh := requireShell(t, "wc", "tr", "mv", "rm", "mkdir", "dirname")
dir, path := inFlightZone(t)
pathEnv := shimPath(t, "wc", "exit 127")
plan := PlanSeed(probeState(t, sh, path))
content := renderSeedZone("example.com", "10.0.0.1", plan.Serial)
if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil {
t.Fatal("an unmeasurable staging file must fail the seed")
}
assertZoneUntouched(t, dir, path)
if _, ok := siblings(t, dir)[filepath.Base(path)+seedTempSuffix]; ok {
t.Error("the staging file should have been cleaned up")
}
}
func TestSeedScriptFailedMkdirAbortsInstall(t *testing.T) {
sh := requireShell(t, "wc", "tr", "mv", "rm", "mkdir", "dirname")
dir, path := inFlightZone(t)
pathEnv := shimPath(t, "mkdir", "exit 1")
plan := PlanSeed(probeState(t, sh, path))
content := renderSeedZone("example.com", "10.0.0.1", plan.Serial)
if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil {
t.Fatal("a failed mkdir must fail the seed")
}
assertZoneUntouched(t, dir, path)
}
// The probe decides whether there is anything to preserve, so a tool it cannot
// run must be an error rather than a state that reads as "nothing readable".
func TestZoneStateProbeFailedToolIsAnError(t *testing.T) {
sh := requireShell(t, "head", "od", "tr")
_, path := inFlightZone(t)
pathEnv := shimPath(t, "tr", "exit 127")
cmd := exec.Command(sh, "-c", zoneStateProbe(path))
cmd.Env = append(os.Environ(), "PATH="+pathEnv)
out, err := cmd.Output()
if err == nil {
t.Fatalf("probe reported success without reading the journal header: %q", out)
}
}
+402
View File
@@ -0,0 +1,402 @@
package bind
import (
"context"
"encoding/hex"
"fmt"
"strconv"
"strings"
)
// JournalPath returns the BIND journal that accompanies a zone database file.
func JournalPath(zonePath string) string { return zonePath + ".jnl" }
// ZoneDiskState is what the primary pod's filesystem holds for one zone.
// A journal is only replayable onto a zone file whose SOA serial lies within
// [JournalBegin, JournalEnd]; outside that range BIND fails the load with
// "out of range" and the zone never comes up.
type ZoneDiskState struct {
ZoneFile bool
ZoneSerial int64
ZoneSerialOK bool
Journal bool
JournalBegin int64
JournalEnd int64
JournalOK bool
// OrphanSerial is the furthest serial recorded in a quarantine sibling on
// disk. It is the only record of how far the zone had advanced once a
// transition is interrupted between the renames and the new file landing.
OrphanSerial int64
OrphanSerialOK bool
}
// SeedPlan is the decision taken before writing a skeleton zone file.
type SeedPlan struct {
WriteSeed bool
Serial int64
QuarantineZoneFile bool
QuarantineJournal bool
QuarantineSuffix string
// Blocked names the reason the disk state could not be judged safely. The
// caller must touch nothing and surface it.
Blocked string
}
// PlanSeed decides how to make a zone loadable without discarding live data.
// Nothing is ever deleted: unusable files are renamed aside so they stay
// recoverable on the PVC.
func PlanSeed(st ZoneDiskState) SeedPlan {
suffix := quarantineSuffix(st)
if !st.ZoneFile {
// The journal's serial range is the only evidence of how far the zone
// had advanced; without it a seed could regress below live data.
if st.Journal && !st.JournalOK {
return SeedPlan{Blocked: "journal present but its header could not be read"}
}
return SeedPlan{
WriteSeed: true,
Serial: nextSerial(st),
QuarantineJournal: st.Journal,
QuarantineSuffix: suffix,
}
}
if !st.Journal || !st.JournalOK || !st.ZoneSerialOK {
return SeedPlan{}
}
switch {
case serialLT(st.ZoneSerial, st.JournalBegin):
// The file has regressed behind the journal: it is the skeleton a
// previous reconcile clobbered it with. Keep both aside and reseed
// above the journal so secondaries still see a serial increase.
return SeedPlan{
WriteSeed: true,
Serial: nextSerial(st),
QuarantineZoneFile: true,
QuarantineJournal: true,
QuarantineSuffix: suffix,
}
case serialLT(st.JournalEnd, st.ZoneSerial):
return SeedPlan{QuarantineJournal: true, QuarantineSuffix: suffix}
default:
return SeedPlan{}
}
}
// highestSerial reports the furthest serial on disk. The second result is false
// when no serial could be read at all: 0 is a legitimate serial, so it cannot
// double as "nothing found" in RFC 1982 sequence space. RFC 1982 ordering is
// not total, so the fold is order-dependent once the inputs span more than
// 2^31; a zone cannot advance that far between reconciles.
func highestSerial(st ZoneDiskState) (int64, bool) {
var h int64
var known bool
if st.ZoneFile && st.ZoneSerialOK {
h, known = st.ZoneSerial, true
}
if st.Journal && st.JournalOK && (!known || serialLT(h, st.JournalEnd)) {
h, known = st.JournalEnd, true
}
if st.OrphanSerialOK && (!known || serialLT(h, st.OrphanSerial)) {
h, known = st.OrphanSerial, true
}
return h, known
}
func quarantineSuffix(st ZoneDiskState) string {
h, _ := highestSerial(st)
return quarantineMarker + strconv.FormatInt(h, 10)
}
// parseOrphanSerial reads the serial back out of a name moveAside produced,
// with or without the counter it appends when the destination is taken.
func parseOrphanSerial(name string) (int64, bool) {
i := strings.LastIndex(name, quarantineMarker)
if i < 0 {
return 0, false
}
digits := name[i+len(quarantineMarker):]
n := 0
for n < len(digits) && digits[n] >= '0' && digits[n] <= '9' {
n++
}
if n == 0 {
return 0, false
}
serial, err := strconv.ParseUint(digits[:n], 10, 32)
if err != nil {
return 0, false
}
return int64(serial), true
}
func orphanSerial(names []string) (int64, bool) {
var h int64
var known bool
for _, name := range names {
s, ok := parseOrphanSerial(name)
if !ok {
continue
}
if !known || serialLT(h, s) {
h, known = s, true
}
}
return h, known
}
func nextSerial(st ZoneDiskState) int64 {
h, known := highestSerial(st)
if !known {
return 1
}
next := int64(uint32(h) + 1)
if next == 0 {
return 1
}
return next
}
// serialLT compares DNS serials in RFC 1982 sequence space.
func serialLT(a, b int64) bool {
if a == b {
return false
}
return uint32(b)-uint32(a) < 1<<31
}
// ParseZoneSerial extracts the SOA serial from the head of a zone file, in
// both the operator's seed layout and BIND's own multi-line dump layout.
func ParseZoneSerial(content string) (int64, bool) {
var tokens []string
for _, line := range strings.Split(content, "\n") {
if i := strings.IndexByte(line, ';'); i >= 0 {
line = line[:i]
}
line = strings.ReplaceAll(line, "(", " ( ")
line = strings.ReplaceAll(line, ")", " ) ")
tokens = append(tokens, strings.Fields(line)...)
}
for i, tok := range tokens {
if !strings.EqualFold(tok, "SOA") {
continue
}
rest := tokens[i+1:]
if len(rest) < 3 {
return 0, false
}
rest = rest[2:] // MNAME, RNAME
if rest[0] == "(" {
rest = rest[1:]
}
if len(rest) == 0 {
return 0, false
}
serial, err := strconv.ParseUint(rest[0], 10, 32)
if err != nil {
return 0, false
}
return int64(serial), true
}
return 0, false
}
// journalFormats are the zero-padded 16-byte format fields BIND writes and
// compares whole (lib/dns/journal.c).
var journalFormats = [][16]byte{
journalFormat(";BIND LOG V9\n"),
journalFormat(";BIND LOG V9.2\n"),
}
func journalFormat(magic string) [16]byte {
var f [16]byte
copy(f[:], magic)
return f
}
// parseJournalHeader reads the begin and end serials from a BIND journal
// header: a 16-byte format magic followed by two {serial,offset} big-endian
// pairs.
func parseJournalHeader(b []byte) (begin, end int64, ok bool) {
if len(b) < 28 {
return 0, 0, false
}
var format [16]byte
copy(format[:], b[:16])
known := false
for _, f := range journalFormats {
if format == f {
known = true
break
}
}
if !known {
return 0, 0, false
}
return int64(beUint32(b[16:20])), int64(beUint32(b[24:28])), true
}
func beUint32(b []byte) uint32 {
return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
}
// Probe framing. Every field is declared exactly once between the sentinels, so
// stdout that was truncated, empty or partially written is rejected rather than
// read as "fresh install".
const (
probeBegin = "zonestate-begin-v1"
probeEnd = "zonestate-end-v1"
probeHeadOpen = "head<<"
probeHeadShut = ">>head"
probeOrphanOpen = "orphans<<"
probeOrphanShut = ">>orphans"
// quarantineMarker joins a preserved file to the serial floor a reseed must
// stay above, which highestSerial may take from a sibling rather than from
// the renamed file itself.
quarantineMarker = ".orphaned-"
)
// zoneStateProbe reads only the head of the zone file: the SOA is the first
// record in both layouts the operator has to read.
func zoneStateProbe(path string) string {
zone, jnl := shellQuote(path), shellQuote(JournalPath(path))
return shellScript(
fmt.Sprintf("printf '%s\\n'", probeBegin),
fmt.Sprintf("if [ -f %s ]; then printf 'zonefile=1\\n%s\\n'; head -c 4096 %s; printf '\\n%s\\n'; else printf 'zonefile=0\\n'; fi",
zone, probeHeadOpen, zone, probeHeadShut),
// The hex is folded in its own assignment so a failing tr aborts the
// probe instead of reporting an unreadable journal header.
fmt.Sprintf("if [ -f %s ]; then printf 'journal=1\\n'; hdr=$(od -An -v -tx1 -N32 %s); hdr=$(printf '%%s' \"$hdr\" | tr -d ' \\n'); printf 'jnl=%%s\\n' \"$hdr\"; else printf 'journal=0\\n'; fi",
jnl, jnl),
// The quarantine siblings outlive the files they replaced, so they are
// the floor a reseed must stay above after an interrupted transition.
fmt.Sprintf("printf '%s\\n'\nfor f in %s* %s*; do if [ -e \"$f\" ]; then printf '%%s\\n' \"$f\"; fi; done\nprintf '%s\\n'",
probeOrphanOpen, shellQuote(path+quarantineMarker), shellQuote(JournalPath(path)+quarantineMarker), probeOrphanShut),
fmt.Sprintf("printf '%s\\n'", probeEnd),
)
}
// parseZoneDiskState returns false unless the probe output is complete: a
// half-written or empty probe must never be mistaken for an empty filesystem.
func parseZoneDiskState(out string) (ZoneDiskState, bool) {
var st ZoneDiskState
var head, orphans []string
var sawBegin, sawEnd, inHead, headShut, inOrphans, orphansShut bool
var zoneDecls, journalDecls, headerDecls, orphanDecls int
for _, line := range strings.Split(out, "\n") {
switch {
case inHead && line == probeHeadShut:
inHead, headShut = false, true
case inHead:
head = append(head, line)
case inOrphans && line == probeOrphanShut:
inOrphans, orphansShut = false, true
case inOrphans:
if line != "" {
orphans = append(orphans, line)
}
case line == probeBegin:
sawBegin = true
case line == probeEnd:
sawEnd = true
case line == probeHeadOpen:
inHead = true
case line == probeOrphanOpen:
inOrphans, orphanDecls = true, orphanDecls+1
case line == "zonefile=1":
st.ZoneFile = true
zoneDecls++
case line == "zonefile=0":
zoneDecls++
case line == "journal=1":
st.Journal = true
journalDecls++
case line == "journal=0":
journalDecls++
case strings.HasPrefix(line, "jnl="):
headerDecls++
if raw, err := hex.DecodeString(strings.TrimPrefix(line, "jnl=")); err == nil {
st.JournalBegin, st.JournalEnd, st.JournalOK = parseJournalHeader(raw)
}
}
}
switch {
case !sawBegin || !sawEnd || inHead || inOrphans:
return ZoneDiskState{}, false
case orphanDecls != 1 || !orphansShut:
return ZoneDiskState{}, false
case zoneDecls != 1 || journalDecls != 1:
return ZoneDiskState{}, false
case st.ZoneFile && !headShut:
return ZoneDiskState{}, false
case st.Journal && headerDecls != 1:
return ZoneDiskState{}, false
case !st.Journal && headerDecls != 0:
return ZoneDiskState{}, false
}
if st.ZoneFile {
st.ZoneSerial, st.ZoneSerialOK = ParseZoneSerial(strings.Join(head, "\n"))
}
st.OrphanSerial, st.OrphanSerialOK = orphanSerial(orphans)
return st, true
}
// ZoneDiskState inspects the zone database file and journal on the pod.
func (e *Executor) ZoneDiskState(ctx context.Context, namespace, pod, path string) (ZoneDiskState, error) {
out, err := e.Exec(ctx, namespace, pod, []string{"sh", "-c", zoneStateProbe(path)}, "")
if err != nil {
return ZoneDiskState{}, fmt.Errorf("inspect zone files %s: %w (out: %s)", path, err, out)
}
st, ok := parseZoneDiskState(out)
if !ok {
return ZoneDiskState{}, fmt.Errorf("inspect zone files %s: incomplete probe output %q", path, out)
}
return st, nil
}
// Quarantine renames the files the plan marks unusable, leaving them on the
// PVC under a suffixed name.
func (e *Executor) Quarantine(ctx context.Context, namespace, pod, path string, plan SeedPlan) error {
var cmds []string
if plan.QuarantineZoneFile {
cmds = append(cmds, moveAside(path, plan.QuarantineSuffix))
}
if plan.QuarantineJournal {
cmds = append(cmds, moveAside(JournalPath(path), plan.QuarantineSuffix))
}
if len(cmds) == 0 {
return nil
}
cmd := []string{"sh", "-c", shellScript(cmds...)}
if out, err := e.Exec(ctx, namespace, pod, cmd, ""); err != nil {
return fmt.Errorf("quarantine zone files %s: %w (out: %s)", path, err, out)
}
return nil
}
// moveAside renames path out of the way. A repeat incident can compute the same
// suffix, so the destination is numbered until it is free: a preserved copy is
// never overwritten.
func moveAside(path, suffix string) string {
src, dest := shellQuote(path), shellQuote(path+suffix)
return fmt.Sprintf("if [ -f %s ]; then d=%s; n=0; while [ -e \"$d\" ]; do n=$((n+1)); d=%s.$n; done; mv -- %s \"$d\"; fi",
src, dest, dest, src)
}
// shellQuote renders s as a single POSIX shell word.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// shellScript joins commands into a script that stops at the first failure and
// exits non-zero. A plain script runs every line regardless of the previous
// one's status, which would let an install proceed over a failed quarantine and
// still report success to the caller.
func shellScript(cmds ...string) string {
return strings.Join(append([]string{"set -e"}, cmds...), "\n")
}
+595
View File
@@ -0,0 +1,595 @@
package bind
import (
"encoding/hex"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// probeOut frames body the way zoneStateProbe does, so the parser is exercised
// on realistic input.
func probeOut(body ...string) string {
body = append(body, probeOrphanOpen, probeOrphanShut)
return strings.Join(append(append([]string{probeBegin}, body...), probeEnd, ""), "\n")
}
func journalHeader(magic string, begin, end uint32) []byte {
b := make([]byte, 32)
copy(b, magic)
put := func(off int, v uint32) {
b[off] = byte(v >> 24)
b[off+1] = byte(v >> 16)
b[off+2] = byte(v >> 8)
b[off+3] = byte(v)
}
put(16, begin)
put(24, end)
return b
}
func TestParseZoneSerial(t *testing.T) {
bindDump := `$ORIGIN .
$TTL 3600 ; 1 hour
k8s.syd1.au.unkin.net IN SOA ns1.k8s.syd1.au.unkin.net. hostmaster.k8s.syd1.au.unkin.net. (
16 ; serial
300 ; refresh (5 minutes)
60 ; retry (1 minute)
1209600 ; expire (2 weeks)
60 ; minimum (1 minute)
)
`
cases := []struct {
name string
content string
want int64
ok bool
}{
{"bind dump", bindDump, 16, true},
{"seed", renderSeedZone("example.com", "10.0.0.1", 42), 42, true},
{"single line", "@ IN SOA ns1.example.com. hostmaster.example.com. 7 300 60 1209600 60\n", 7, true},
{"glued paren", "@ IN SOA ns. host. (9 300 60 1209600 60)\n", 9, true},
{"no soa", "$TTL 3600\nwww IN A 192.0.2.1\n", 0, false},
{"truncated", "@ IN SOA ns.\n", 0, false},
{"non numeric serial", "@ IN SOA ns. host. ( abc 300 )\n", 0, false},
}
for _, c := range cases {
got, ok := ParseZoneSerial(c.content)
if got != c.want || ok != c.ok {
t.Errorf("%s: ParseZoneSerial = (%d,%v) want (%d,%v)", c.name, got, ok, c.want, c.ok)
}
}
}
func TestParseJournalHeader(t *testing.T) {
begin, end, ok := parseJournalHeader(journalHeader(";BIND LOG V9.2\n", 10, 16))
if !ok || begin != 10 || end != 16 {
t.Errorf("V9.2 header = (%d,%d,%v) want (10,16,true)", begin, end, ok)
}
if _, _, ok := parseJournalHeader(journalHeader("not a journal\n", 10, 16)); ok {
t.Error("bad magic should not parse")
}
if _, _, ok := parseJournalHeader([]byte(";BIND LOG V9.2\n")); ok {
t.Error("truncated header should not parse")
}
}
func TestParseZoneDiskState(t *testing.T) {
out := probeOut(
"zonefile=1",
probeHeadOpen,
"@ IN SOA ns. host. ( 5 300 60 1209600 60 )",
probeHeadShut,
"journal=1",
"jnl="+hex.EncodeToString(journalHeader(";BIND LOG V9.2\n", 3, 8)),
)
st, ok := parseZoneDiskState(out)
if !ok {
t.Fatalf("well-formed probe rejected: %q", out)
}
if !st.ZoneFile || !st.ZoneSerialOK || st.ZoneSerial != 5 {
t.Errorf("zone file state wrong: %+v", st)
}
if !st.Journal || !st.JournalOK || st.JournalBegin != 3 || st.JournalEnd != 8 {
t.Errorf("journal state wrong: %+v", st)
}
empty, ok := parseZoneDiskState(probeOut("zonefile=0", "journal=0"))
if !ok || empty.ZoneFile || empty.Journal {
t.Errorf("empty state wrong: %+v (ok=%v)", empty, ok)
}
}
// A probe that returns nothing useful must not be read as "fresh install": the
// shell exits 0 after its last printf and stderr is dropped on success, so a
// missing tool or a truncated stream is otherwise invisible.
func TestParseZoneDiskStateRejectsDegradedProbe(t *testing.T) {
live := probeHeadOpen + "\n@ IN SOA ns. host. ( 5 300 60 1209600 60 )\n" + probeHeadShut
cases := map[string]string{
"empty output": "",
"whitespace only": "\n\n",
"no framing": "zonefile=0\njournal=0\n",
"no terminator": probeBegin + "\nzonefile=0\njournal=0\n",
"cut before zone file": probeBegin + "\n",
"cut mid head": probeBegin + "\nzonefile=1\n" + probeHeadOpen + "\n@ IN SOA ns. host. ( 5",
"cut after head": probeBegin + "\nzonefile=1\n" + live + "\n",
"no zone declaration": probeOut("journal=0"),
"no journal branch": probeOut("zonefile=1", live),
"journal without hex": probeOut("zonefile=0", "journal=1"),
"duplicate zone decl": probeOut("zonefile=0", "zonefile=1", "journal=0"),
"header without file": probeOut("zonefile=0", "journal=0", "jnl=00"),
"no orphan block": strings.Join(
[]string{probeBegin, "zonefile=0", "journal=0", probeEnd, ""}, "\n"),
"orphan block unterminated": strings.Join(
[]string{probeBegin, "zonefile=0", "journal=0", probeOrphanOpen, probeEnd, ""}, "\n"),
"duplicate orphan block": strings.Join(
[]string{probeBegin, "zonefile=0", "journal=0", probeOrphanOpen, probeOrphanShut,
probeOrphanOpen, probeOrphanShut, probeEnd, ""}, "\n"),
}
for name, out := range cases {
// The zero state is a legitimate fresh install, so rejection has to
// happen here: ZoneDiskState turns it into an error and nothing plans.
if st, ok := parseZoneDiskState(out); ok {
t.Errorf("%s: degraded probe accepted as %+v", name, st)
}
}
}
// applyPlan models what the pod filesystem looks like after the plan runs, so
// a second PlanSeed can be checked for idempotence.
func applyPlan(st ZoneDiskState, p SeedPlan) ZoneDiskState {
if p.QuarantineJournal {
st.Journal, st.JournalBegin, st.JournalEnd, st.JournalOK = false, 0, 0, false
}
if p.QuarantineZoneFile {
st.ZoneFile, st.ZoneSerial, st.ZoneSerialOK = false, 0, false
}
if p.WriteSeed {
st.ZoneFile, st.ZoneSerial, st.ZoneSerialOK = true, p.Serial, true
}
return st
}
func TestPlanSeedFreshInstall(t *testing.T) {
p := PlanSeed(ZoneDiskState{})
if !p.WriteSeed || p.Serial != 1 {
t.Fatalf("fresh install should seed at serial 1, got %+v", p)
}
if p.QuarantineZoneFile || p.QuarantineJournal {
t.Errorf("fresh install should quarantine nothing, got %+v", p)
}
}
func TestPlanSeedOrphanJournal(t *testing.T) {
st := ZoneDiskState{Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true}
p := PlanSeed(st)
if !p.WriteSeed {
t.Fatalf("orphan journal should still seed, got %+v", p)
}
if !p.QuarantineJournal || p.QuarantineZoneFile {
t.Errorf("only the journal should be quarantined, got %+v", p)
}
if p.Serial <= 16 {
t.Errorf("seed serial %d must exceed the journal end serial 16", p.Serial)
}
if p.QuarantineSuffix != ".orphaned-16" {
t.Errorf("quarantine suffix should be deterministic, got %q", p.QuarantineSuffix)
}
}
func TestPlanSeedFileRegressedBehindJournal(t *testing.T) {
// The production failure: a skeleton at serial 1 left next to a journal at
// serial 16, which BIND refuses to replay ("out of range").
st := ZoneDiskState{
ZoneFile: true, ZoneSerial: 1, ZoneSerialOK: true,
Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true,
}
p := PlanSeed(st)
if !p.WriteSeed || p.Serial <= 16 {
t.Fatalf("reseed must land above the journal end serial, got %+v", p)
}
if !p.QuarantineJournal || !p.QuarantineZoneFile {
t.Errorf("the unloadable pair should both be moved aside, got %+v", p)
}
after := applyPlan(st, p)
if after.Journal {
t.Error("journal should be gone after quarantine")
}
if next := PlanSeed(after); next != (SeedPlan{}) {
t.Errorf("second reconcile should be a no-op, got %+v", next)
}
if after.ZoneSerial != p.Serial {
t.Errorf("second reconcile changed the serial: %d want %d", after.ZoneSerial, p.Serial)
}
}
func TestPlanSeedLeavesHealthyZoneAlone(t *testing.T) {
cases := []struct {
name string
st ZoneDiskState
}{
{"file and covering journal", ZoneDiskState{
ZoneFile: true, ZoneSerial: 16, ZoneSerialOK: true,
Journal: true, JournalBegin: 10, JournalEnd: 20, JournalOK: true,
}},
{"file at journal end", ZoneDiskState{
ZoneFile: true, ZoneSerial: 20, ZoneSerialOK: true,
Journal: true, JournalBegin: 10, JournalEnd: 20, JournalOK: true,
}},
{"file without journal", ZoneDiskState{ZoneFile: true, ZoneSerial: 16, ZoneSerialOK: true}},
{"unreadable journal header", ZoneDiskState{
ZoneFile: true, ZoneSerial: 16, ZoneSerialOK: true, Journal: true,
}},
{"unparsable zone file", ZoneDiskState{ZoneFile: true}},
}
for _, c := range cases {
if p := PlanSeed(c.st); p != (SeedPlan{}) {
t.Errorf("%s: live data must not be touched, got %+v", c.name, p)
}
}
}
func TestPlanSeedStaleJournalBehindFile(t *testing.T) {
st := ZoneDiskState{
ZoneFile: true, ZoneSerial: 30, ZoneSerialOK: true,
Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true,
}
p := PlanSeed(st)
if p.WriteSeed || p.QuarantineZoneFile {
t.Fatalf("a file ahead of its journal is live data, got %+v", p)
}
if !p.QuarantineJournal {
t.Errorf("the unreplayable journal should be moved aside, got %+v", p)
}
if next := PlanSeed(applyPlan(st, p)); next != (SeedPlan{}) {
t.Errorf("second reconcile should be a no-op, got %+v", next)
}
}
func TestPlanSeedFreshInstallIdempotent(t *testing.T) {
st := ZoneDiskState{}
p := PlanSeed(st)
after := applyPlan(st, p)
if next := PlanSeed(after); next != (SeedPlan{}) {
t.Fatalf("second reconcile of a fresh zone should be a no-op, got %+v", next)
}
if after.ZoneSerial != 1 {
t.Errorf("serial reset on second reconcile: %d", after.ZoneSerial)
}
}
func TestPlanSeedSerialWrap(t *testing.T) {
st := ZoneDiskState{Journal: true, JournalBegin: 1 << 31, JournalEnd: 1<<32 - 1, JournalOK: true}
if h, known := highestSerial(st); !known || h != 1<<32-1 {
t.Fatalf("highestSerial = (%d,%v) want (%d,true): the wrap branch is not being reached", h, known, int64(1)<<32-1)
}
p := PlanSeed(st)
if p.Serial != 1 {
t.Fatalf("serial after wrap = %d want 1", p.Serial)
}
if !serialLT(st.JournalEnd, p.Serial) {
t.Errorf("wrapped serial %d must still sort after journal end %d", p.Serial, st.JournalEnd)
}
}
// Serials above 2^31 must not be flattened to 1: RFC 1982 comparison against a
// zero placeholder reads them as older, and secondaries reject the regression.
func TestPlanSeedHighSerialJournal(t *testing.T) {
st := ZoneDiskState{Journal: true, JournalBegin: 1<<31 - 10, JournalEnd: 1 << 31, JournalOK: true}
p := PlanSeed(st)
if !p.WriteSeed {
t.Fatalf("orphan journal should still seed, got %+v", p)
}
if p.Serial != 1<<31+1 {
t.Errorf("seed serial = %d want %d", p.Serial, int64(1)<<31+1)
}
if !serialLT(st.JournalEnd, p.Serial) {
t.Errorf("seed serial %d must sort after journal end %d", p.Serial, st.JournalEnd)
}
if p.QuarantineSuffix != ".orphaned-2147483648" {
t.Errorf("quarantine suffix = %q", p.QuarantineSuffix)
}
}
// An orphan journal whose header will not parse (no od, EACCES, short read)
// hides how far the zone had advanced, so quarantining it and reseeding at 1
// would regress live data.
func TestPlanSeedBlocksOnUnreadableOrphanJournal(t *testing.T) {
p := PlanSeed(ZoneDiskState{Journal: true})
if p.Blocked == "" {
t.Fatalf("an unreadable orphan journal must block, got %+v", p)
}
if p.WriteSeed || p.QuarantineJournal || p.QuarantineZoneFile {
t.Errorf("a blocked plan must touch nothing, got %+v", p)
}
}
func TestSeedZoneRoundTripsThroughParser(t *testing.T) {
content := renderSeedZone("200.18.198.in-addr.arpa", "198.18.200.8", 17)
got, ok := ParseZoneSerial(content)
if !ok || got != 17 {
t.Fatalf("seed zone serial = (%d,%v) want (17,true)", got, ok)
}
}
func TestQuarantinePathsAreSuffixed(t *testing.T) {
path := ZoneFilePath("example.com")
if JournalPath(path) != path+".jnl" {
t.Fatalf("journal path = %q", JournalPath(path))
}
cmd := moveAside(JournalPath(path), ".orphaned-16")
if !strings.Contains(cmd, "'"+path+".jnl.orphaned-16'") {
t.Errorf("quarantine command should rename, not delete: %s", cmd)
}
if strings.Contains(cmd, "rm ") {
t.Errorf("quarantine must never delete: %s", cmd)
}
}
// A repeat incident computes the same suffix, so the rename must not overwrite
// the copy preserved by the previous one.
func TestMoveAsidePreservesEarlierQuarantine(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skipf("no POSIX shell: %v", err)
}
dir := t.TempDir()
path := filepath.Join(dir, "db.example.com")
for _, content := range []string{"first", "second"} {
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
out, err := exec.Command(sh, "-c", moveAside(path, ".orphaned-16")).CombinedOutput()
if err != nil {
t.Fatalf("moveAside(%s): %v (%s)", content, err, out)
}
}
if _, err := os.Stat(path); err == nil {
t.Error("the quarantined file should have been renamed away")
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
found := map[string]bool{}
for _, e := range entries {
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
t.Fatal(err)
}
found[string(b)] = true
}
for _, want := range []string{"first", "second"} {
if !found[want] {
t.Errorf("quarantine destroyed %q: %v", want, found)
}
}
}
func TestParseJournalHeaderRejectsPaddingGarbage(t *testing.T) {
h := journalHeader(";BIND LOG V9\n", 10, 16)
h[15] = 'x'
if _, _, ok := parseJournalHeader(h); ok {
t.Error("format field must match all 16 bytes")
}
}
func TestShellQuoteEscapesQuotes(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skipf("no POSIX shell: %v", err)
}
evil := `a'; touch pwned; echo '`
out, err := exec.Command(sh, "-c", "printf %s "+shellQuote(evil)).Output()
if err != nil {
t.Fatal(err)
}
if string(out) != evil {
t.Errorf("shellQuote round trip = %q want %q", out, evil)
}
}
// The probe is shell, so run it and check the parser agrees with what is
// actually on disk; a syntax slip or a missing field would otherwise only
// surface as a seed over live data.
func TestZoneStateProbeRoundTrip(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skipf("no POSIX shell: %v", err)
}
for _, tool := range []string{"head", "od", "tr"} {
if _, err := exec.LookPath(tool); err != nil {
t.Skipf("probe needs %s: %v", tool, err)
}
}
cases := []struct {
name string
zone string
jnl []byte
orphans []string
want ZoneDiskState
}{
{name: "fresh install"},
{
name: "zone file only",
zone: renderSeedZone("example.com", "10.0.0.1", 42),
want: ZoneDiskState{ZoneFile: true, ZoneSerial: 42, ZoneSerialOK: true},
},
{
name: "zone file and journal",
zone: renderSeedZone("example.com", "10.0.0.1", 12),
jnl: journalHeader(";BIND LOG V9.2\n", 10, 16),
want: ZoneDiskState{
ZoneFile: true, ZoneSerial: 12, ZoneSerialOK: true,
Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true,
},
},
{
name: "orphan journal",
jnl: journalHeader(";BIND LOG V9.2\n", 10, 16),
want: ZoneDiskState{Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true},
},
{
name: "journal with unreadable header",
jnl: []byte("garbage"),
want: ZoneDiskState{Journal: true},
},
{
name: "quarantine evidence only",
orphans: []string{".orphaned-16", ".jnl.orphaned-16"},
want: ZoneDiskState{OrphanSerial: 16, OrphanSerialOK: true},
},
{
name: "repeat quarantine keeps the highest serial",
orphans: []string{".orphaned-16", ".orphaned-30", ".orphaned-30.1"},
want: ZoneDiskState{OrphanSerial: 30, OrphanSerialOK: true},
},
{
name: "live zone beside old quarantine evidence",
zone: renderSeedZone("example.com", "10.0.0.1", 42),
orphans: []string{".orphaned-16"},
want: ZoneDiskState{
ZoneFile: true, ZoneSerial: 42, ZoneSerialOK: true,
OrphanSerial: 16, OrphanSerialOK: true,
},
},
}
for _, c := range cases {
path := filepath.Join(t.TempDir(), "db.example.com")
if c.zone != "" {
if err := os.WriteFile(path, []byte(c.zone), 0o600); err != nil {
t.Fatal(err)
}
}
if c.jnl != nil {
if err := os.WriteFile(JournalPath(path), c.jnl, 0o600); err != nil {
t.Fatal(err)
}
}
for _, suffix := range c.orphans {
if err := os.WriteFile(path+suffix, []byte("preserved"), 0o600); err != nil {
t.Fatal(err)
}
}
out, err := exec.Command(sh, "-c", zoneStateProbe(path)).Output()
if err != nil {
t.Fatalf("%s: probe failed: %v", c.name, err)
}
got, ok := parseZoneDiskState(string(out))
if !ok {
t.Errorf("%s: probe output rejected: %q", c.name, out)
continue
}
if got != c.want {
t.Errorf("%s: state = %+v want %+v (out %q)", c.name, got, c.want, out)
}
}
}
// applyPlanInterrupted models the plan being cut off between the quarantine
// renames and the new zone file landing: the PVC holds no zone data at all, and
// the .orphaned-<serial> siblings are the only record of how far it had got.
func applyPlanInterrupted(st ZoneDiskState, p SeedPlan) ZoneDiskState {
h, known := highestSerial(st)
if p.QuarantineJournal {
st.Journal, st.JournalBegin, st.JournalEnd, st.JournalOK = false, 0, 0, false
}
if p.QuarantineZoneFile {
st.ZoneFile, st.ZoneSerial, st.ZoneSerialOK = false, 0, false
}
if known && (p.QuarantineJournal || p.QuarantineZoneFile) {
st.OrphanSerial, st.OrphanSerialOK = h, true
}
return st
}
// A reconcile that quarantined and then failed to write leaves a directory that
// looks fresh. Seeding it at 1 loads cleanly but every secondary holding the
// old serial refuses the transfer, so the zone goes permanently stale.
func TestPlanSeedInterruptedTransitionDoesNotRegressSerial(t *testing.T) {
st := ZoneDiskState{
ZoneFile: true, ZoneSerial: 1, ZoneSerialOK: true,
Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true,
}
first := PlanSeed(st)
if !first.WriteSeed {
t.Fatalf("a file behind its journal should be reseeded, got %+v", first)
}
after := applyPlanInterrupted(st, first)
if after.ZoneFile || after.Journal {
t.Fatalf("the interrupted state should hold no zone data, got %+v", after)
}
retry := PlanSeed(after)
if !retry.WriteSeed {
t.Fatalf("a zone with nothing on disk must still be seeded, got %+v", retry)
}
if !serialLT(16, retry.Serial) {
t.Errorf("reseed at %d regressed below the quarantined serial 16", retry.Serial)
}
if retry.Serial != first.Serial {
t.Errorf("retry seeded at %d, the interrupted attempt planned %d", retry.Serial, first.Serial)
}
if retry.QuarantineZoneFile || retry.QuarantineJournal {
t.Errorf("there is nothing left to quarantine, got %+v", retry)
}
if next := PlanSeed(applyPlan(after, retry)); next != (SeedPlan{}) {
t.Errorf("third reconcile should be a no-op, got %+v", next)
}
}
// Quarantine evidence is a floor, never a trigger: it must not disturb a zone
// that is healthy now, and it must not unblock an unjudgeable journal.
func TestPlanSeedOrphanEvidenceDoesNotDisturbLiveData(t *testing.T) {
healthy := ZoneDiskState{
ZoneFile: true, ZoneSerial: 20, ZoneSerialOK: true,
Journal: true, JournalBegin: 10, JournalEnd: 20, JournalOK: true,
OrphanSerial: 99, OrphanSerialOK: true,
}
if p := PlanSeed(healthy); p != (SeedPlan{}) {
t.Errorf("a healthy zone must not be touched, got %+v", p)
}
blocked := ZoneDiskState{Journal: true, OrphanSerial: 99, OrphanSerialOK: true}
if p := PlanSeed(blocked); p.Blocked == "" {
t.Errorf("an unreadable orphan journal must still block, got %+v", p)
}
}
func TestParseOrphanSerial(t *testing.T) {
base := ZoneFilePath("example.com")
cases := []struct {
name string
want int64
ok bool
}{
{base + ".orphaned-16", 16, true},
{JournalPath(base) + ".orphaned-16", 16, true},
{base + ".orphaned-16.3", 16, true},
{base + ".orphaned-4294967295", 4294967295, true},
{base + ".orphaned-", 0, false},
{base + ".orphaned-abc", 0, false},
{base + ".orphaned-4294967296", 0, false},
{base, 0, false},
{base + ".jnl", 0, false},
}
for _, c := range cases {
got, ok := parseOrphanSerial(c.name)
if got != c.want || ok != c.ok {
t.Errorf("parseOrphanSerial(%q) = (%d,%v) want (%d,%v)", c.name, got, ok, c.want, c.ok)
}
}
if _, ok := orphanSerial(nil); ok {
t.Error("no siblings means no recorded serial, not serial 0")
}
// Serial 0 is legitimate and must not read as "nothing found".
if h, ok := orphanSerial([]string{base + ".orphaned-0"}); !ok || h != 0 {
t.Errorf("orphanSerial = (%d,%v) want (0,true)", h, ok)
}
}
@@ -54,7 +54,7 @@ func (r *BindCatalogZoneReconciler) Reconcile(ctx context.Context, req ctrl.Requ
if primaryIP == "" { if primaryIP == "" {
return r.fail(ctx, &catalog, "PrimaryNoIP", "waiting for primary pod IP") return r.fail(ctx, &catalog, "PrimaryNoIP", "waiting for primary pod IP")
} }
if err := r.Exec.WriteSeedZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, bind.CatalogFilePath(catalog.Spec.ZoneName), primaryIP, 1); err != nil { if err := r.Exec.EnsureSeedZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, bind.CatalogFilePath(catalog.Spec.ZoneName), primaryIP); err != nil {
return r.fail(ctx, &catalog, "SeedFailed", err.Error()) return r.fail(ctx, &catalog, "SeedFailed", err.Error())
} }
} }
+64 -5
View File
@@ -1,7 +1,10 @@
package controller package controller
import ( import (
"bytes"
"context" "context"
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
@@ -168,10 +171,11 @@ func (r *BindClusterReconciler) reconcileKeysSecret(ctx context.Context, c *bind
} }
func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv1alpha1.BindCluster) error { func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv1alpha1.BindCluster) error {
// BIND primaries/default-primaries need the primary's IP address, not a DNS // BIND primaries/default-primaries need an IP address, not a DNS name. Use
// name, so render with pod-0's current IP (empty until it is scheduled; the // the stable primary Service ClusterIP so secondaries keep transferring
// Pod watch re-renders when it appears or changes). // across primary pod restarts (falls back to the pod IP when no primary
in := bind.RenderInput{Cluster: c, PrimaryAddress: primaryPodIP(ctx, r.Client, c)} // Service exists; the Pod/Service watches re-render when it changes).
in := bind.RenderInput{Cluster: c, PrimaryAddress: primaryTransferAddress(ctx, r.Client, c)}
var acls bindv1alpha1.BindACLList var acls bindv1alpha1.BindACLList
if err := r.List(ctx, &acls, client.InNamespace(c.Namespace)); err == nil { if err := r.List(ctx, &acls, client.InNamespace(c.Namespace)); err == nil {
@@ -227,6 +231,16 @@ func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv
} }
} }
// Secondaries accept intra-cluster NOTIFYs signed with the cluster's catalog
// transfer TSIG key (the primary signs its also-notify NOTIFYs with it — see
// bindzone_controller). Render `allow-notify { key "<name>"; }` — a static key
// element, NO pod IPs — so it never changes on pod churn and cannot re-render
// the restart-scoped config (the v0.2.5 roll loop). Resolve the catalog's
// TransferKeyRef to the BIND key name (KeyName override, else the ref).
if in.Catalog != nil && in.Catalog.Spec.TransferKeyRef != "" {
in.NotifyKeyName = tsigKeyName(ctx, r.Client, c.Namespace, in.Catalog.Spec.TransferKeyRef)
}
primaryConf, secondaryConf := bind.RenderNamedConf(in) primaryConf, secondaryConf := bind.RenderNamedConf(in)
data := map[string]string{ data := map[string]string{
"named.conf.primary": primaryConf, "named.conf.primary": primaryConf,
@@ -350,6 +364,12 @@ func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bin
}}}, }}},
} }
// Pods copy config from the projected volume into an emptyDir once at
// startup; a ConfigMap/keys change never reaches a running pod (rndc
// reconfig re-reads the stale startup copy). Stamp a hash of the projected
// config onto the pod template so a config change rolls the StatefulSet,
// which is the only way the change takes effect. The operator owns the
// template, so this restart is operator-driven and not reverted.
sts := &appsv1.StatefulSet{ sts := &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{Name: c.Name, Namespace: c.Namespace, Labels: labels}, ObjectMeta: metav1.ObjectMeta{Name: c.Name, Namespace: c.Namespace, Labels: labels},
Spec: appsv1.StatefulSetSpec{ Spec: appsv1.StatefulSetSpec{
@@ -357,7 +377,7 @@ func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bin
Replicas: &replicas, Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: labels}, Selector: &metav1.LabelSelector{MatchLabels: labels},
Template: corev1.PodTemplateSpec{ Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels}, ObjectMeta: metav1.ObjectMeta{Labels: labels, Annotations: map[string]string{configHashAnnotation: r.configHash(ctx, c)}},
Spec: corev1.PodSpec{ Spec: corev1.PodSpec{
NodeSelector: c.Spec.NodeSelector, NodeSelector: c.Spec.NodeSelector,
Tolerations: c.Spec.Tolerations, Tolerations: c.Spec.Tolerations,
@@ -425,6 +445,45 @@ func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bin
return &existing, nil return &existing, nil
} }
// configHashAnnotation carries a hash of the projected config on the pod
// template; changing it triggers a rolling restart so pods pick up new config.
const configHashAnnotation = "bind.unkin.net/config-hash"
// configHash returns a deterministic hash of the config projected into the pods
// (the rendered ConfigMap and the keys.conf Secret). It is read after those are
// reconciled, so it reflects the current desired config. A stable hash means no
// spurious restarts; any config or TSIG-key change flips it and rolls the pods.
func (r *BindClusterReconciler) configHash(ctx context.Context, c *bindv1alpha1.BindCluster) string {
var buf bytes.Buffer
var cm corev1.ConfigMap
if err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: configMapName(c.Name)}, &cm); err == nil {
for _, k := range sortedKeys(cm.Data) {
fmt.Fprintf(&buf, "%s\x00%s\x00", k, cm.Data[k])
}
}
var keys corev1.Secret
if err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: keysSecretName(c.Name)}, &keys); err == nil {
data := make(map[string]string, len(keys.Data))
for k, v := range keys.Data {
data[k] = string(v)
}
for _, k := range sortedKeys(data) {
fmt.Fprintf(&buf, "%s\x00%s\x00", k, data[k])
}
}
sum := sha256.Sum256(buf.Bytes())
return hex.EncodeToString(sum[:])
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func (r *BindClusterReconciler) reloadReadyPods(ctx context.Context, c *bindv1alpha1.BindCluster) { func (r *BindClusterReconciler) reloadReadyPods(ctx context.Context, c *bindv1alpha1.BindCluster) {
if r.Exec == nil { if r.Exec == nil {
return return
+1 -1
View File
@@ -67,7 +67,7 @@ func (r *BindPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
if primaryIP == "" { if primaryIP == "" {
return r.fail(ctx, &policy, "PrimaryNoIP", "waiting for primary pod IP") return r.fail(ctx, &policy, "PrimaryNoIP", "waiting for primary pod IP")
} }
if err := r.Exec.WriteSeedZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, bind.ZoneFilePath(policy.Spec.ZoneName), primaryIP, 1); err != nil { if err := r.Exec.EnsureSeedZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, bind.ZoneFilePath(policy.Spec.ZoneName), primaryIP); err != nil {
return r.fail(ctx, &policy, "SeedFailed", err.Error()) return r.fail(ctx, &policy, "SeedFailed", err.Error())
} }
} }
@@ -0,0 +1,274 @@
package controller
import (
"context"
"fmt"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
// BindTSIGAPIReconciler deploys the companion TSIG API (Deployment, Service,
// ConfigMap, token Secret and RBAC) when a BindTSIGAPI resource exists.
type BindTSIGAPIReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindtsigapis,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindtsigapis/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings,verbs=get;list;watch;create;update;patch;delete
func (r *BindTSIGAPIReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var api bindv1alpha1.BindTSIGAPI
if err := r.Get(ctx, req.NamespacedName, &api); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
tokenSecret := api.Spec.TokenSecretName
if tokenSecret == "" {
tokenSecret = api.Name + "-token"
}
targetNS := api.Spec.TargetNamespace
if targetNS == "" {
targetNS = api.Namespace
}
for _, step := range []func(context.Context, *bindv1alpha1.BindTSIGAPI, string, string) error{
r.reconcileServiceAccount,
r.reconcileRBAC,
r.reconcileTokenSecret,
r.reconcileConfigMap,
r.reconcileDeployment,
r.reconcileService,
} {
if err := step(ctx, &api, tokenSecret, targetNS); err != nil {
return ctrl.Result{}, err
}
}
// Status from the Deployment.
var dep appsv1.Deployment
_ = r.Get(ctx, types.NamespacedName{Namespace: api.Namespace, Name: api.Name}, &dep)
port := api.Spec.Port
if port == 0 {
port = 8443
}
api.Status.ReadyReplicas = dep.Status.ReadyReplicas
api.Status.Endpoint = fmt.Sprintf("http://%s.%s.svc:%d", api.Name, api.Namespace, port)
api.Status.TokenSecret = tokenSecret
api.Status.ObservedGeneration = api.Generation
ready := dep.Status.ReadyReplicas > 0
if ready {
api.Status.Phase = "Ready"
} else {
api.Status.Phase = "Progressing"
}
setReady(&api.Status.Conditions, api.Generation, ready, "Reconciled", fmt.Sprintf("%d ready", dep.Status.ReadyReplicas))
if err := r.Status().Update(ctx, &api); err != nil {
return ctrl.Result{}, err
}
if !ready {
return ctrl.Result{RequeueAfter: requeueShort}, nil
}
logger.V(1).Info("tsig api reconciled", "api", api.Name)
return ctrl.Result{}, nil
}
func tsigAPILabels(name string) map[string]string {
return map[string]string{
managedByLabel: managedByValue,
"app.kubernetes.io/name": "bind-tsig-api",
"app.kubernetes.io/instance": name,
"app.kubernetes.io/component": "tsig-api",
}
}
func (r *BindTSIGAPIReconciler) reconcileServiceAccount(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, _, _ string) error {
sa := &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace, Labels: tsigAPILabels(api.Name)}}
return r.apply(ctx, api, sa, func() {})
}
func (r *BindTSIGAPIReconciler) reconcileRBAC(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, _, targetNS string) error {
role := &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: targetNS, Labels: tsigAPILabels(api.Name)}}
if err := r.applyIn(ctx, api, role, targetNS, func() {
role.Rules = []rbacv1.PolicyRule{
{APIGroups: []string{"bind.unkin.net"}, Resources: []string{"bindtsigkeys"}, Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}},
{APIGroups: []string{"bind.unkin.net"}, Resources: []string{"bindtsigkeys/status"}, Verbs: []string{"get"}},
{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get", "list", "watch", "delete"}},
}
}); err != nil {
return err
}
rb := &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: targetNS, Labels: tsigAPILabels(api.Name)}}
return r.applyIn(ctx, api, rb, targetNS, func() {
rb.RoleRef = rbacv1.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: api.Name}
rb.Subjects = []rbacv1.Subject{{Kind: "ServiceAccount", Name: api.Name, Namespace: api.Namespace}}
})
}
// reconcileTokenSecret creates the master-access-token Secret only when it does
// not already exist, so a VaultStaticSecret may pre-seed it. Not owned by the
// BindTSIGAPI, so it survives and stays overwritable.
func (r *BindTSIGAPIReconciler) reconcileTokenSecret(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, tokenSecret, _ string) error {
var existing corev1.Secret
err := r.Get(ctx, types.NamespacedName{Namespace: api.Namespace, Name: tokenSecret}, &existing)
if err == nil {
return nil
}
if !apierrors.IsNotFound(err) {
return err
}
token, genErr := bind.GenerateSecret(32)
if genErr != nil {
return genErr
}
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: tokenSecret, Namespace: api.Namespace, Labels: tsigAPILabels(api.Name)},
Data: map[string][]byte{"token": []byte(token)},
}
return r.Create(ctx, s)
}
func (r *BindTSIGAPIReconciler) reconcileConfigMap(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, _, targetNS string) error {
cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: api.Name + "-config", Namespace: api.Namespace, Labels: tsigAPILabels(api.Name)}}
return r.apply(ctx, api, cm, func() {
port := api.Spec.Port
if port == 0 {
port = 8443
}
data := map[string]string{
"LISTEN_ADDR": fmt.Sprintf(":%d", port),
"TARGET_NAMESPACE": targetNS,
}
for k, v := range api.Spec.Env {
data[k] = v
}
cm.Data = data
})
}
func (r *BindTSIGAPIReconciler) reconcileDeployment(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, tokenSecret, _ string) error {
dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace, Labels: tsigAPILabels(api.Name)}}
return r.apply(ctx, api, dep, func() {
replicas := api.Spec.Replicas
if replicas == 0 {
replicas = 1
}
image := api.Spec.Image
if image == "" {
image = "git.unkin.net/unkin/bind-tsig-api:latest"
}
port := api.Spec.Port
if port == 0 {
port = 8443
}
labels := tsigAPILabels(api.Name)
dep.Spec = appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app.kubernetes.io/instance": api.Name}},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels},
Spec: corev1.PodSpec{
ServiceAccountName: api.Name,
SecurityContext: &corev1.PodSecurityContext{RunAsNonRoot: ptr(true)},
Containers: []corev1.Container{{
Name: "tsig-api",
Image: image,
ImagePullPolicy: api.Spec.ImagePullPolicy,
Command: []string{"tsig-api"},
Ports: []corev1.ContainerPort{{Name: "https", ContainerPort: port}},
EnvFrom: []corev1.EnvFromSource{{ConfigMapRef: &corev1.ConfigMapEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: api.Name + "-config"}}}},
Env: []corev1.EnvVar{{
Name: "API_TOKEN",
ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: tokenSecret}, Key: "token"}},
}},
Resources: api.Spec.Resources,
ReadinessProbe: &corev1.Probe{
ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{Path: "/healthz", Port: intstrFromInt(int(port))}},
InitialDelaySeconds: 5, PeriodSeconds: 10,
},
SecurityContext: &corev1.SecurityContext{
AllowPrivilegeEscalation: ptr(false),
ReadOnlyRootFilesystem: ptr(true),
Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}},
},
}},
},
},
}
})
}
func (r *BindTSIGAPIReconciler) reconcileService(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, _, _ string) error {
svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace, Labels: tsigAPILabels(api.Name), Annotations: api.Spec.Service.Annotations}}
return r.apply(ctx, api, svc, func() {
port := api.Spec.Port
if port == 0 {
port = 8443
}
svcType := api.Spec.Service.Type
if svcType == "" {
svcType = corev1.ServiceTypeClusterIP
}
svc.Spec.Type = svcType
svc.Spec.Selector = map[string]string{"app.kubernetes.io/instance": api.Name}
svc.Spec.Ports = []corev1.ServicePort{{Name: "https", Port: port, TargetPort: intstrFromInt(int(port))}}
if api.Spec.Service.LoadBalancerIP != "" {
svc.Spec.LoadBalancerIP = api.Spec.Service.LoadBalancerIP
}
})
}
// apply creates or updates an owned object (in the BindTSIGAPI's namespace).
func (r *BindTSIGAPIReconciler) apply(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, obj client.Object, mutate func()) error {
return r.applyIn(ctx, api, obj, api.Namespace, mutate)
}
// applyIn creates or updates an object; ownership is set only for objects in
// the BindTSIGAPI's own namespace (cross-namespace owner refs are not allowed).
func (r *BindTSIGAPIReconciler) applyIn(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, obj client.Object, namespace string, mutate func()) error {
key := client.ObjectKeyFromObject(obj)
err := r.Get(ctx, key, obj)
if apierrors.IsNotFound(err) {
mutate()
if namespace == api.Namespace {
if serr := ctrl.SetControllerReference(api, obj, r.Scheme); serr != nil {
return serr
}
}
return r.Create(ctx, obj)
}
if err != nil {
return err
}
mutate()
return r.Update(ctx, obj)
}
func (r *BindTSIGAPIReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.BindTSIGAPI{}).
Owns(&appsv1.Deployment{}).
Owns(&corev1.Service{}).
Owns(&corev1.ConfigMap{}).
Owns(&corev1.ServiceAccount{}).
Complete(r)
}
func ptr[T any](v T) *T { return &v }
+53 -1
View File
@@ -60,7 +60,7 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
return ctrl.Result{}, genErr return ctrl.Result{}, genErr
} }
newSecret := &corev1.Secret{ newSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: key.Namespace, Labels: map[string]string{managedByLabel: managedByValue}}, ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: key.Namespace},
Data: map[string][]byte{ Data: map[string][]byte{
"algorithm": []byte(algorithm), "algorithm": []byte(algorithm),
"keyName": []byte(keyName), "keyName": []byte(keyName),
@@ -68,6 +68,7 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
"key.conf": []byte(bind.KeyClause(keyName, algorithm, material)), "key.conf": []byte(bind.KeyClause(keyName, algorithm, material)),
}, },
} }
applySecretTemplate(&newSecret.ObjectMeta, key.Spec.SecretTemplate)
if err := ctrl.SetControllerReference(&key, newSecret, r.Scheme); err != nil { if err := ctrl.SetControllerReference(&key, newSecret, r.Scheme); err != nil {
return ctrl.Result{}, err return ctrl.Result{}, err
} }
@@ -77,6 +78,21 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
logger.Info("generated TSIG key", "key", key.Name, "secret", secretName) logger.Info("generated TSIG key", "key", key.Name, "secret", secretName)
case err != nil: case err != nil:
return ctrl.Result{}, err return ctrl.Result{}, err
default:
// Secret already exists: reconcile the template-managed metadata so that
// annotation/label changes on the CR (e.g. reflection hints) propagate
// without regenerating key material. Skip imported secrets, which are
// owned by an external manager (Vault/VSO, reflector) that we must not
// fight over metadata.
if key.Spec.ImportExisting {
break
}
if updated := applySecretTemplate(&secret.ObjectMeta, key.Spec.SecretTemplate); updated {
if err := r.Update(ctx, &secret); err != nil {
return ctrl.Result{}, err
}
logger.Info("updated TSIG key secret metadata", "key", key.Name, "secret", secretName)
}
} }
key.Status.SecretName = secretName key.Status.SecretName = secretName
@@ -90,6 +106,42 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
return ctrl.Result{}, nil return ctrl.Result{}, nil
} }
// applySecretTemplate stamps the operator-managed label plus any
// user-supplied labels/annotations onto the Secret's metadata. It returns true
// if it mutated meta, so callers can decide whether an update is needed.
func applySecretTemplate(meta *metav1.ObjectMeta, tmpl *bindv1alpha1.SecretMetadata) bool {
changed := false
setLabel := func(k, v string) {
if meta.Labels == nil {
meta.Labels = map[string]string{}
}
if meta.Labels[k] != v {
meta.Labels[k] = v
changed = true
}
}
setAnnotation := func(k, v string) {
if meta.Annotations == nil {
meta.Annotations = map[string]string{}
}
if meta.Annotations[k] != v {
meta.Annotations[k] = v
changed = true
}
}
setLabel(managedByLabel, managedByValue)
if tmpl != nil {
for k, v := range tmpl.Labels {
setLabel(k, v)
}
for k, v := range tmpl.Annotations {
setAnnotation(k, v)
}
}
return changed
}
func (r *BindTSIGKeyReconciler) fail(ctx context.Context, key *bindv1alpha1.BindTSIGKey, reason, msg string) (ctrl.Result, error) { func (r *BindTSIGKeyReconciler) fail(ctx context.Context, key *bindv1alpha1.BindTSIGKey, reason, msg string) (ctrl.Result, error) {
key.Status.Ready = false key.Status.Ready = false
key.Status.ObservedGeneration = key.Generation key.Status.ObservedGeneration = key.Generation
+71
View File
@@ -0,0 +1,71 @@
package controller
import (
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
func TestApplySecretTemplate(t *testing.T) {
t.Run("nil template still stamps managed-by label", func(t *testing.T) {
var meta metav1.ObjectMeta
if !applySecretTemplate(&meta, nil) {
t.Fatal("expected change on empty meta")
}
if meta.Labels[managedByLabel] != managedByValue {
t.Errorf("managed-by label = %q, want %q", meta.Labels[managedByLabel], managedByValue)
}
if meta.Annotations != nil {
t.Errorf("annotations = %v, want nil", meta.Annotations)
}
})
t.Run("applies labels and annotations", func(t *testing.T) {
meta := metav1.ObjectMeta{Labels: map[string]string{managedByLabel: managedByValue}}
tmpl := &bindv1alpha1.SecretMetadata{
Annotations: map[string]string{"reflector.v1.k8s.emberstack.com/reflection-allowed": "true"},
Labels: map[string]string{"team": "dns"},
}
if !applySecretTemplate(&meta, tmpl) {
t.Fatal("expected change when adding template metadata")
}
if got := meta.Annotations["reflector.v1.k8s.emberstack.com/reflection-allowed"]; got != "true" {
t.Errorf("reflection annotation = %q, want true", got)
}
if meta.Labels["team"] != "dns" {
t.Errorf("team label = %q, want dns", meta.Labels["team"])
}
// managed-by must survive user-supplied labels.
if meta.Labels[managedByLabel] != managedByValue {
t.Errorf("managed-by label dropped: %v", meta.Labels)
}
})
t.Run("idempotent when already applied", func(t *testing.T) {
tmpl := &bindv1alpha1.SecretMetadata{
Annotations: map[string]string{"a": "1"},
Labels: map[string]string{"b": "2"},
}
meta := metav1.ObjectMeta{}
applySecretTemplate(&meta, tmpl)
if applySecretTemplate(&meta, tmpl) {
t.Error("expected no change on second apply")
}
})
t.Run("updates drifted annotation value", func(t *testing.T) {
meta := metav1.ObjectMeta{
Labels: map[string]string{managedByLabel: managedByValue},
Annotations: map[string]string{"a": "old"},
}
tmpl := &bindv1alpha1.SecretMetadata{Annotations: map[string]string{"a": "new"}}
if !applySecretTemplate(&meta, tmpl) {
t.Fatal("expected change when annotation value drifts")
}
if meta.Annotations["a"] != "new" {
t.Errorf("annotation a = %q, want new", meta.Annotations["a"])
}
})
}
+59 -4
View File
@@ -80,7 +80,21 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
return r.setPhase(ctx, &zone, "Pending", "PrimaryNotReady", "waiting for cluster primary to be ready") return r.setPhase(ctx, &zone, "Pending", "PrimaryNotReady", "waiting for cluster primary to be ready")
} }
zoneConfig, err := r.buildZoneConfig(ctx, &zone) // Primary zones replicated to secondaries (catalog members) get an
// also-notify pointing at the secondary pods, so a dynamic update NOTIFYs
// them immediately rather than waiting for the SOA refresh.
var notifyTargets []string
if isPrimaryType(zone.Spec.Type) && catalogEnabled(&zone) {
notifyTargets = secondaryPodIPs(ctx, r.Client, cluster)
}
// The catalog transfer TSIG key doubles as the intra-cluster NOTIFY key: the
// primary signs its also-notify NOTIFYs with it and secondaries accept them
// via `allow-notify { key "<key>"; }`. Keying the NOTIFYs is what lets the
// secondary's allow-notify be a static key element (no pod IPs), so pod-IP
// churn never re-renders restart-scoped config (the v0.2.5 roll loop).
transferKey := r.zoneTransferKeyRef(ctx, &zone, cluster)
zoneConfig, err := r.buildZoneConfig(ctx, &zone, transferKey, notifyTargets, transferKey)
if err != nil { if err != nil {
return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error()) return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error())
} }
@@ -91,7 +105,10 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
if primaryIP == "" { if primaryIP == "" {
return r.setPhase(ctx, &zone, "Pending", "PrimaryNoIP", "waiting for primary pod IP") return r.setPhase(ctx, &zone, "Pending", "PrimaryNoIP", "waiting for primary pod IP")
} }
if err := r.Exec.WriteSeedZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, bind.ZoneFilePath(zone.Spec.ZoneName), primaryIP, 1); err != nil { // The zone is absent from named's memory, but its database file and
// journal may still be on the PVC from a previous incarnation.
path := bind.ZoneFilePath(zone.Spec.ZoneName)
if err := r.Exec.EnsureSeedZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, path, primaryIP); err != nil {
return r.setPhase(ctx, &zone, "Error", "SeedFailed", err.Error()) return r.setPhase(ctx, &zone, "Error", "SeedFailed", err.Error())
} }
} }
@@ -133,7 +150,11 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
} }
// buildZoneConfig renders the inner clause passed to rndc addzone/modzone. // buildZoneConfig renders the inner clause passed to rndc addzone/modzone.
func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone) (string, error) { // transferKey, when set, is the catalog transfer TSIG key name; catalog member
// primary zones must allow AXFR with it so secondaries can pull them. notifyKey,
// when set, is the TSIG key each also-notify entry is signed with, so
// secondaries can accept the NOTIFYs by key rather than by (churning) pod IP.
func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone, transferKey string, notifyTargets []string, notifyKey string) (string, error) {
zType := zone.Spec.Type zType := zone.Spec.Type
if zType == "" { if zType == "" {
zType = bindv1alpha1.ZonePrimary zType = bindv1alpha1.ZonePrimary
@@ -145,8 +166,22 @@ func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1al
if zone.Spec.DynamicUpdate && zone.Spec.UpdateKeyRef != "" { if zone.Spec.DynamicUpdate && zone.Spec.UpdateKeyRef != "" {
parts = append(parts, fmt.Sprintf("allow-update { key \"%s\"; }", updateKeyName(ctx, r.Client, zone))) parts = append(parts, fmt.Sprintf("allow-update { key \"%s\"; }", updateKeyName(ctx, r.Client, zone)))
} }
if len(zone.Spec.AllowTransfer) > 0 { switch {
case len(zone.Spec.AllowTransfer) > 0:
parts = append(parts, fmt.Sprintf("allow-transfer { %s }", matchListInline(zone.Spec.AllowTransfer))) parts = append(parts, fmt.Sprintf("allow-transfer { %s }", matchListInline(zone.Spec.AllowTransfer)))
case transferKey != "":
// Catalog member: permit key-authenticated AXFR from secondaries.
parts = append(parts, fmt.Sprintf("allow-transfer { key \"%s\"; }", transferKey))
}
// NOTIFY only the secondaries we know about (their apex NS is the primary
// itself, so default `notify yes` would reach no one). `notify explicit`
// keeps NOTIFY off the query-serving VIP and scoped to the pod IPs. Each
// entry is signed with notifyKey so secondaries can admit the NOTIFY by key
// (`allow-notify { key ... }`) instead of by pod IP. This zone config is
// applied via rndc addzone/modzone — no pod restart — so listing pod IPs
// here is safe; only *restart-scoped* config must never depend on pod IPs.
if len(notifyTargets) > 0 {
parts = append(parts, "notify explicit", fmt.Sprintf("also-notify { %s }", alsoNotifyList(notifyTargets, notifyKey)))
} }
if zone.Spec.DNSSECPolicyRef != "" { if zone.Spec.DNSSECPolicyRef != "" {
parts = append(parts, fmt.Sprintf("dnssec-policy \"%s\"", zone.Spec.DNSSECPolicyRef), "inline-signing yes") parts = append(parts, fmt.Sprintf("dnssec-policy \"%s\"", zone.Spec.DNSSECPolicyRef), "inline-signing yes")
@@ -202,6 +237,26 @@ func (r *BindZoneReconciler) deregisterCatalog(ctx context.Context, zone *bindv1
_ = r.Exec.RemoveCatalogMember(ctx, zone.Namespace, primaryPod, catalog.Spec.ZoneName, zone.Spec.ZoneName, creds) _ = r.Exec.RemoveCatalogMember(ctx, zone.Namespace, primaryPod, catalog.Spec.ZoneName, zone.Spec.ZoneName, creds)
} }
// zoneTransferKeyRef returns the catalog transfer TSIG key name that a catalog
// member primary zone must allow AXFR with, so secondaries (which present that
// key) can pull it. Returns "" for non-member zones, non-primary zones, or when
// the cluster has no catalog.
func (r *BindZoneReconciler) zoneTransferKeyRef(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) string {
if !isPrimaryType(zone.Spec.Type) || !catalogEnabled(zone) {
return ""
}
var catalogs bindv1alpha1.BindCatalogZoneList
if err := r.List(ctx, &catalogs, client.InNamespace(zone.Namespace)); err != nil {
return ""
}
for i := range catalogs.Items {
if catalogs.Items[i].Spec.ClusterRef == cluster.Name {
return catalogs.Items[i].Spec.TransferKeyRef
}
}
return ""
}
func (r *BindZoneReconciler) catalogFor(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) (*bindv1alpha1.BindCatalogZone, bind.TSIGCreds, bool) { func (r *BindZoneReconciler) catalogFor(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) (*bindv1alpha1.BindCatalogZone, bind.TSIGCreds, bool) {
var catalogs bindv1alpha1.BindCatalogZoneList var catalogs bindv1alpha1.BindCatalogZoneList
if err := r.List(ctx, &catalogs, client.InNamespace(zone.Namespace)); err != nil { if err := r.List(ctx, &catalogs, client.InNamespace(zone.Namespace)); err != nil {
@@ -0,0 +1,65 @@
package controller
import (
"context"
"strings"
"testing"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
// A primary zone with known secondaries renders notify explicit + also-notify
// so a dynamic update NOTIFYs the secondaries immediately.
func TestBuildZoneConfigPrimaryAlsoNotify(t *testing.T) {
r := &BindZoneReconciler{}
zone := &bindv1alpha1.BindZone{
Spec: bindv1alpha1.BindZoneSpec{
ZoneName: "main.unkin.net",
Type: bindv1alpha1.ZonePrimary,
},
}
cfg, err := r.buildZoneConfig(context.Background(), zone, "transfer-key", []string{"10.42.2.6", "10.42.1.5"}, "externaldns-key")
if err != nil {
t.Fatalf("buildZoneConfig: %v", err)
}
if !strings.Contains(cfg, "notify explicit") {
t.Errorf("expected notify explicit in %q", cfg)
}
// also-notify entries are keyed so secondaries can admit the NOTIFY by TSIG
// key (allow-notify { key ... }) instead of by (churning) pod IP.
if !strings.Contains(cfg, `also-notify { 10.42.2.6 key "externaldns-key"; 10.42.1.5 key "externaldns-key"; }`) {
t.Errorf("expected keyed also-notify with the secondary IPs in %q", cfg)
}
}
// also-notify entries carry no key when none is configured (unkeyed NOTIFY).
func TestBuildZoneConfigPrimaryAlsoNotifyUnkeyed(t *testing.T) {
r := &BindZoneReconciler{}
zone := &bindv1alpha1.BindZone{
Spec: bindv1alpha1.BindZoneSpec{ZoneName: "main.unkin.net", Type: bindv1alpha1.ZonePrimary},
}
cfg, err := r.buildZoneConfig(context.Background(), zone, "transfer-key", []string{"10.42.2.6"}, "")
if err != nil {
t.Fatalf("buildZoneConfig: %v", err)
}
if !strings.Contains(cfg, "also-notify { 10.42.2.6; }") {
t.Errorf("expected unkeyed also-notify in %q", cfg)
}
}
// With no secondaries, no also-notify is emitted (single-replica cluster).
func TestBuildZoneConfigPrimaryNoNotifyTargets(t *testing.T) {
r := &BindZoneReconciler{}
zone := &bindv1alpha1.BindZone{
Spec: bindv1alpha1.BindZoneSpec{ZoneName: "main.unkin.net", Type: bindv1alpha1.ZonePrimary},
}
cfg, err := r.buildZoneConfig(context.Background(), zone, "transfer-key", nil, "externaldns-key")
if err != nil {
t.Fatalf("buildZoneConfig: %v", err)
}
if strings.Contains(cfg, "also-notify") || strings.Contains(cfg, "notify explicit") {
t.Errorf("did not expect notify clauses with no targets: %q", cfg)
}
}
@@ -0,0 +1,108 @@
package controller
import (
"context"
"strings"
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
// TestConfigHashIndependentOfPrimaryPodIP is the permanent regression guard for
// the v0.2.5 rolling-restart loop.
//
// v0.2.5 rendered an options-scope `allow-notify { <primaryPodIP>; ... }` into
// the (restart-scoped) named.conf. The config-hash annotation that rolls the
// StatefulSet is computed over that render (BindClusterReconciler.configHash), so
// a config change rolled the pods, the primary pod came back on a NEW IP, the
// operator re-rendered with the new IP, the hash changed, the pods rolled again,
// and so on — an infinite roll loop across every BindCluster.
//
// The fix removed all pod IPs from restart-scoped config (secondaries now admit
// intra-cluster NOTIFYs by TSIG key: `allow-notify { key "X"; }`). This test
// reconciles the ConfigMap with the primary pod on one IP, computes the hash,
// then does it again with the primary pod on a DIFFERENT IP, and asserts the
// hash is byte-identical. If any pod-IP dependency ever creeps back into
// restart-scoped config, this fails and the loop-class bug is caught.
func TestConfigHashIndependentOfPrimaryPodIP(t *testing.T) {
scheme := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := bindv1alpha1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
const ns = "dns"
svcIP := "10.43.5.5" // stable primary Service ClusterIP (does NOT churn)
cluster := &bindv1alpha1.BindCluster{
ObjectMeta: metav1.ObjectMeta{Name: "auth", Namespace: ns},
Spec: bindv1alpha1.BindClusterSpec{
Mode: bindv1alpha1.ModeAuthoritative,
Replicas: 3,
PrimaryService: &bindv1alpha1.ClusterServiceSpec{},
},
}
primarySvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: primaryServiceName(cluster.Name), Namespace: ns},
Spec: corev1.ServiceSpec{ClusterIP: svcIP},
}
catalog := &bindv1alpha1.BindCatalogZone{
ObjectMeta: metav1.ObjectMeta{Name: "cat", Namespace: ns},
Spec: bindv1alpha1.BindCatalogZoneSpec{
ClusterRef: cluster.Name,
ZoneName: "catalog.internal",
TransferKeyRef: "externaldns-key",
},
}
tsig := &bindv1alpha1.BindTSIGKey{
ObjectMeta: metav1.ObjectMeta{Name: "externaldns-key", Namespace: ns},
Spec: bindv1alpha1.BindTSIGKeySpec{ClusterRef: cluster.Name},
}
// hashWithPrimaryIP reconciles the ConfigMap with the primary pod carrying the
// given IP, then returns the resulting config-hash.
hashWithPrimaryIP := func(ip string) string {
primaryPod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: primaryPodName(cluster.Name), Namespace: ns, Labels: commonLabels(cluster.Name)},
Status: corev1.PodStatus{PodIP: ip},
}
c := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(cluster, primarySvc, catalog, tsig, primaryPod).
Build()
r := &BindClusterReconciler{Client: c, Scheme: scheme}
ctx := context.Background()
if err := r.reconcileConfigMap(ctx, cluster); err != nil {
t.Fatalf("reconcileConfigMap: %v", err)
}
// Sanity: the pod IP must not have leaked into the rendered config.
var cm corev1.ConfigMap
if err := c.Get(ctx, client.ObjectKey{Namespace: ns, Name: configMapName(cluster.Name)}, &cm); err != nil {
t.Fatalf("get configmap: %v", err)
}
for k, v := range cm.Data {
if ip != "" && strings.Contains(v, ip) {
t.Fatalf("primary pod IP %s leaked into restart-scoped config %s:\n%s", ip, k, v)
}
}
return r.configHash(ctx, cluster)
}
h1 := hashWithPrimaryIP("10.42.3.197")
h2 := hashWithPrimaryIP("10.42.9.42") // primary pod restarted onto a new IP
if h1 == "" {
t.Fatal("config hash should not be empty")
}
if h1 != h2 {
t.Fatalf("config hash changed when only the primary pod IP changed — the v0.2.5 roll loop:\n%s\n%s", h1, h2)
}
}
+64
View File
@@ -0,0 +1,64 @@
package controller
import (
"context"
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
func TestConfigHashStableAndSensitive(t *testing.T) {
scheme := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := bindv1alpha1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
cluster := &bindv1alpha1.BindCluster{ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "ns"}}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: configMapName("c"), Namespace: "ns"},
Data: map[string]string{"named.conf.secondary": "options { recursion yes; };"},
}
keys := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: keysSecretName("c"), Namespace: "ns"},
Data: map[string][]byte{"keys.conf": []byte("key x {};")},
}
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm, keys).Build()
r := &BindClusterReconciler{Client: c, Scheme: scheme}
ctx := context.Background()
h1 := r.configHash(ctx, cluster)
if h1 == "" {
t.Fatal("hash should not be empty when config exists")
}
// Stable across calls when nothing changes.
if h2 := r.configHash(ctx, cluster); h2 != h1 {
t.Fatalf("hash not stable: %s != %s", h1, h2)
}
// A config change flips the hash (this is what rolls the StatefulSet).
cm.Data["named.conf.secondary"] = "options { recursion yes; validate-except { unkin.net; }; };"
if err := c.Update(ctx, cm); err != nil {
t.Fatal(err)
}
if h3 := r.configHash(ctx, cluster); h3 == h1 {
t.Fatal("hash must change when the ConfigMap changes")
}
// A TSIG key (keys.conf) change also flips it.
afterCM := r.configHash(ctx, cluster)
keys.Data["keys.conf"] = []byte("key x { algorithm hmac-sha256; };")
if err := c.Update(ctx, keys); err != nil {
t.Fatal(err)
}
if h4 := r.configHash(ctx, cluster); h4 == afterCM {
t.Fatal("hash must change when keys.conf changes")
}
}
+47
View File
@@ -3,6 +3,7 @@ package controller
import ( import (
"context" "context"
"fmt" "fmt"
"sort"
"time" "time"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
@@ -108,6 +109,52 @@ func primaryPodIP(ctx context.Context, c client.Client, cluster *bindv1alpha1.Bi
return pod.Status.PodIP return pod.Status.PodIP
} }
// primaryTransferAddress returns the address secondaries use to reach the
// primary for catalog and zone AXFR. It prefers the primary Service ClusterIP,
// which is stable across primary pod restarts (the pod IP is not: it changes on
// every restart, leaving secondaries pointed at a dead address). It falls back
// to the primary pod IP when no primary Service is configured or its ClusterIP
// is not yet assigned.
func primaryTransferAddress(ctx context.Context, c client.Client, cluster *bindv1alpha1.BindCluster) string {
if cluster.Spec.PrimaryService != nil {
var svc corev1.Service
if err := c.Get(ctx, client.ObjectKey{Namespace: cluster.Namespace, Name: primaryServiceName(cluster.Name)}, &svc); err == nil {
if ip := svc.Spec.ClusterIP; ip != "" && ip != corev1.ClusterIPNone {
return ip
}
}
}
return primaryPodIP(ctx, c, cluster)
}
// secondaryPodIPs returns the pod IPs of a cluster's secondary pods (every pod
// except the ordinal-0 primary) that currently have an address. The primary
// uses this list as its zone `also-notify` set, so a change to a primary zone
// (in particular a dynamic update) triggers an immediate NOTIFY -> IXFR to the
// secondaries instead of leaving them stale until the next SOA refresh. The
// list is sorted so the rendered zone config is stable and does not churn
// modzone on every reconcile. Pod IPs change across restarts, so the caller
// relies on the zone controller's periodic requeue to refresh the set (a
// restarted secondary re-transfers the whole zone on load regardless). Returns
// nil for a single-replica cluster.
func secondaryPodIPs(ctx context.Context, c client.Client, cluster *bindv1alpha1.BindCluster) []string {
var pods corev1.PodList
if err := c.List(ctx, &pods, client.InNamespace(cluster.Namespace), client.MatchingLabels(commonLabels(cluster.Name))); err != nil {
return nil
}
primary := primaryPodName(cluster.Name)
var ips []string
for i := range pods.Items {
p := &pods.Items[i]
if p.Name == primary || p.Status.PodIP == "" {
continue
}
ips = append(ips, p.Status.PodIP)
}
sort.Strings(ips)
return ips
}
// resolveTSIG reads the material of a BindTSIGKey into TSIG credentials. // resolveTSIG reads the material of a BindTSIGKey into TSIG credentials.
func resolveTSIG(ctx context.Context, c client.Client, namespace, keyRef string) (bind.TSIGCreds, error) { func resolveTSIG(ctx context.Context, c client.Client, namespace, keyRef string) (bind.TSIGCreds, error) {
var creds bind.TSIGCreds var creds bind.TSIGCreds
+3
View File
@@ -35,5 +35,8 @@ func SetupAll(mgr ctrl.Manager, exec *bind.Executor) error {
if err := (&DNSRecordReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil { if err := (&DNSRecordReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil {
return err return err
} }
if err := (&BindTSIGAPIReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil {
return err
}
return nil return nil
} }
+30 -2
View File
@@ -2,6 +2,7 @@ package controller
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
@@ -58,12 +59,19 @@ func recordsToUpdates(zone string, records []bindv1alpha1.Record, defaultTTL int
// updateKeyName returns the TSIG key name (as used in named.conf) for a zone's // updateKeyName returns the TSIG key name (as used in named.conf) for a zone's
// update key, falling back to the object name. // update key, falling back to the object name.
func updateKeyName(ctx context.Context, c client.Client, zone *bindv1alpha1.BindZone) string { func updateKeyName(ctx context.Context, c client.Client, zone *bindv1alpha1.BindZone) string {
ref := zone.Spec.UpdateKeyRef return tsigKeyName(ctx, c, zone.Namespace, zone.Spec.UpdateKeyRef)
}
// tsigKeyName resolves a BindTSIGKey object reference to the TSIG key name used
// in named.conf (the KeyName override when set, otherwise the object name).
// Returns "" for an empty ref, and falls back to the ref if the object cannot be
// read.
func tsigKeyName(ctx context.Context, c client.Client, namespace, ref string) string {
if ref == "" { if ref == "" {
return "" return ""
} }
var key bindv1alpha1.BindTSIGKey var key bindv1alpha1.BindTSIGKey
if err := c.Get(ctx, client.ObjectKey{Namespace: zone.Namespace, Name: ref}, &key); err != nil { if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: ref}, &key); err != nil {
return ref return ref
} }
if key.Spec.KeyName != "" { if key.Spec.KeyName != "" {
@@ -86,3 +94,23 @@ func terminateInline(entries []string) string {
} }
return strings.Join(parts, " ") return strings.Join(parts, " ")
} }
// alsoNotifyList renders also-notify entries, each optionally annotated with a
// TSIG key so the primary signs its NOTIFYs and secondaries can accept them by
// key (`allow-notify { key ... }`) rather than by pod IP. An entry that already
// carries a `key` clause is left untouched.
func alsoNotifyList(addrs []string, key string) string {
key = strings.TrimSpace(key)
var parts []string
for _, a := range addrs {
a = strings.TrimSpace(strings.TrimRight(a, ";"))
if a == "" {
continue
}
if key != "" && !strings.Contains(a, " key ") {
a = fmt.Sprintf("%s key \"%s\"", a, key)
}
parts = append(parts, a+";")
}
return strings.Join(parts, " ")
}
+168
View File
@@ -0,0 +1,168 @@
package tsigapi
import (
"context"
"fmt"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
// createKey creates a BindTSIGKey and waits for the operator to materialise it.
func (s *Server) createKey(ctx context.Context, req createRequest) (*keyResponse, error) {
algorithm := req.Algorithm
if algorithm == "" {
algorithm = string(bindv1alpha1.TSIGHMACSHA256)
}
key := &bindv1alpha1.BindTSIGKey{
ObjectMeta: metav1.ObjectMeta{
Name: req.Name,
Namespace: s.Namespace,
Labels: map[string]string{"app.kubernetes.io/managed-by": "bind-tsig-api"},
},
Spec: bindv1alpha1.BindTSIGKeySpec{
Algorithm: bindv1alpha1.TSIGAlgorithm(algorithm),
ClusterRef: req.ClusterRef,
KeyName: req.Name,
},
}
if err := s.Client.Create(ctx, key); err != nil && !apierrors.IsAlreadyExists(err) {
return nil, fmt.Errorf("create bindtsigkey: %w", err)
}
return s.waitForMaterial(ctx, req.Name)
}
// readKey returns the current material for a key.
func (s *Server) readKey(ctx context.Context, name string) (*keyResponse, error) {
var key bindv1alpha1.BindTSIGKey
if err := s.Client.Get(ctx, s.key(name), &key); err != nil {
return nil, err
}
return s.material(ctx, &key)
}
// rotateKey replaces the key material: it deletes the key Secret (the operator
// regenerates it) and bumps a rotation annotation so the cluster controller
// re-renders keys.conf and reloads BIND.
func (s *Server) rotateKey(ctx context.Context, name string) (*keyResponse, error) {
var key bindv1alpha1.BindTSIGKey
if err := s.Client.Get(ctx, s.key(name), &key); err != nil {
return nil, err
}
secretName := secretNameFor(&key)
var secret corev1.Secret
if err := s.Client.Get(ctx, client.ObjectKey{Namespace: s.Namespace, Name: secretName}, &secret); err == nil {
if derr := s.Client.Delete(ctx, &secret); derr != nil && !apierrors.IsNotFound(derr) {
return nil, fmt.Errorf("delete key secret: %w", derr)
}
} else if !apierrors.IsNotFound(err) {
return nil, err
}
// Trigger re-generation + re-render by bumping the CR.
if key.Annotations == nil {
key.Annotations = map[string]string{}
}
key.Annotations[rotationAnnotation] = s.Now().UTC().Format(time.RFC3339Nano)
if err := s.Client.Update(ctx, &key); err != nil {
return nil, fmt.Errorf("bump rotation annotation: %w", err)
}
return s.waitForNewMaterial(ctx, name, secret.UID)
}
// deleteKey removes the BindTSIGKey (its Secret is garbage-collected).
func (s *Server) deleteKey(ctx context.Context, name string) error {
key := &bindv1alpha1.BindTSIGKey{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: s.Namespace}}
if err := s.Client.Delete(ctx, key); err != nil && !apierrors.IsNotFound(err) {
return err
}
return nil
}
// material reads the key material from the Secret referenced by a BindTSIGKey.
func (s *Server) material(ctx context.Context, key *bindv1alpha1.BindTSIGKey) (*keyResponse, error) {
var secret corev1.Secret
if err := s.Client.Get(ctx, client.ObjectKey{Namespace: s.Namespace, Name: secretNameFor(key)}, &secret); err != nil {
return nil, err
}
algorithm := string(secret.Data["algorithm"])
if algorithm == "" {
algorithm = string(key.Spec.Algorithm)
}
keyName := string(secret.Data["keyName"])
if keyName == "" {
keyName = key.Name
}
return &keyResponse{
Name: key.Name,
Algorithm: algorithm,
Secret: string(secret.Data["secret"]),
KeyName: keyName,
ClusterRef: key.Spec.ClusterRef,
}, nil
}
// waitForMaterial polls until the key's Secret exists (operator reconciled).
func (s *Server) waitForMaterial(ctx context.Context, name string) (*keyResponse, error) {
deadline := s.Now().Add(s.WaitTimeout)
for {
var key bindv1alpha1.BindTSIGKey
if err := s.Client.Get(ctx, s.key(name), &key); err != nil {
return nil, err
}
if resp, err := s.material(ctx, &key); err == nil && resp.Secret != "" {
return resp, nil
}
if s.Now().After(deadline) {
return nil, fmt.Errorf("timed out waiting for key %q material", name)
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(250 * time.Millisecond):
}
}
}
// waitForNewMaterial polls until the Secret has been recreated (different UID).
func (s *Server) waitForNewMaterial(ctx context.Context, name string, oldUID types.UID) (*keyResponse, error) {
deadline := s.Now().Add(s.WaitTimeout)
for {
var key bindv1alpha1.BindTSIGKey
if err := s.Client.Get(ctx, s.key(name), &key); err != nil {
return nil, err
}
var secret corev1.Secret
err := s.Client.Get(ctx, client.ObjectKey{Namespace: s.Namespace, Name: secretNameFor(&key)}, &secret)
if err == nil && secret.UID != oldUID && len(secret.Data["secret"]) > 0 {
return s.material(ctx, &key)
}
if s.Now().After(deadline) {
return nil, fmt.Errorf("timed out waiting for key %q to rotate", name)
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(250 * time.Millisecond):
}
}
}
// secretNameFor returns the Secret name holding a key's material.
func secretNameFor(key *bindv1alpha1.BindTSIGKey) string {
if key.Status.SecretName != "" {
return key.Status.SecretName
}
if key.Spec.SecretName != "" {
return key.Spec.SecretName
}
return key.Name + "-tsig"
}
+155
View File
@@ -0,0 +1,155 @@
// Package tsigapi implements the companion TSIG API deployed by the operator.
// It exposes the HTTP contract consumed by vault-plugin-secrets-bind-tsig and
// fulfils it by managing BindTSIGKey custom resources, which the operator
// reconciles into key material.
package tsigapi
import (
"crypto/subtle"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/go-logr/logr"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
)
const rotationAnnotation = "bind.unkin.net/rotated-at"
// Server serves the TSIG key API.
type Server struct {
Client client.Client
Namespace string
Token string
Log logr.Logger
// WaitTimeout bounds how long a create/rotate waits for the operator to
// reconcile the key material.
WaitTimeout time.Duration
// Now is injectable for tests; defaults to time.Now.
Now func() time.Time
}
type keyResponse struct {
Name string `json:"name"`
Algorithm string `json:"algorithm"`
Secret string `json:"secret"`
KeyName string `json:"key_name"`
ClusterRef string `json:"cluster_ref,omitempty"`
}
type createRequest struct {
Name string `json:"name"`
Algorithm string `json:"algorithm"`
ClusterRef string `json:"cluster_ref"`
Static bool `json:"static"`
}
// Handler returns the API's HTTP handler.
func (s *Server) Handler() http.Handler {
if s.Now == nil {
s.Now = time.Now
}
if s.WaitTimeout == 0 {
s.WaitTimeout = 15 * time.Second
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
mux.HandleFunc("/v1/keys", s.auth(s.handleKeys))
mux.HandleFunc("/v1/keys/", s.auth(s.handleKey))
return mux
}
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.Token != "" {
want := "Bearer " + s.Token
got := r.Header.Get("Authorization")
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
}
next(w, r)
}
}
// handleKeys serves POST /v1/keys (create).
func (s *Server) handleKeys(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req createRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if req.Name == "" {
http.Error(w, "name is required", http.StatusBadRequest)
return
}
key, err := s.createKey(r.Context(), req)
if err != nil {
s.fail(w, "create", req.Name, err)
return
}
writeJSON(w, http.StatusOK, key)
}
// handleKey serves GET/DELETE /v1/keys/{name} and POST /v1/keys/{name}/rotate.
func (s *Server) handleKey(w http.ResponseWriter, r *http.Request) {
name := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/keys/"), "/")
rotate := strings.HasSuffix(name, "/rotate")
name = strings.TrimSuffix(name, "/rotate")
if name == "" {
http.Error(w, "key name is required", http.StatusBadRequest)
return
}
switch {
case rotate && r.Method == http.MethodPost:
key, err := s.rotateKey(r.Context(), name)
if err != nil {
s.fail(w, "rotate", name, err)
return
}
writeJSON(w, http.StatusOK, key)
case r.Method == http.MethodGet:
key, err := s.readKey(r.Context(), name)
if err != nil {
s.fail(w, "read", name, err)
return
}
writeJSON(w, http.StatusOK, key)
case r.Method == http.MethodDelete:
if err := s.deleteKey(r.Context(), name); err != nil {
s.fail(w, "delete", name, err)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) fail(w http.ResponseWriter, op, name string, err error) {
if apierrors.IsNotFound(err) {
http.Error(w, "not found", http.StatusNotFound)
return
}
s.Log.Error(err, "tsig api request failed", "op", op, "name", name)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func (s *Server) key(name string) types.NamespacedName {
return types.NamespacedName{Namespace: s.Namespace, Name: name}
}
+185
View File
@@ -0,0 +1,185 @@
package tsigapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
const testNS = "bind-system"
// fakeOperator simulates the BindTSIGKey controller: whenever a key exists
// without its material Secret, it creates one. It stops when ctx is cancelled.
func fakeOperator(ctx context.Context, c client.Client) {
seq := 0
for {
select {
case <-ctx.Done():
return
case <-time.After(20 * time.Millisecond):
}
var keys bindv1alpha1.BindTSIGKeyList
if err := c.List(ctx, &keys); err != nil {
continue
}
for i := range keys.Items {
k := &keys.Items[i]
secretName := k.Name + "-tsig"
var existing corev1.Secret
if err := c.Get(ctx, client.ObjectKey{Namespace: k.Namespace, Name: secretName}, &existing); err == nil {
continue
}
seq++
material := "secret-material-" + itoa(seq)
_ = c.Create(ctx, &corev1.Secret{
// Fake client does not assign UIDs; set one so rotation
// (which detects a Secret with a new UID) is observable.
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: k.Namespace, UID: types.UID("uid-" + itoa(seq))},
Data: map[string][]byte{
"algorithm": []byte(k.Spec.Algorithm),
"keyName": []byte(k.Name),
"secret": []byte(material),
},
})
}
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
func newTestServer(t *testing.T) (*Server, context.CancelFunc) {
t.Helper()
scheme := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(scheme); err != nil {
t.Fatalf("clientgo scheme: %v", err)
}
if err := bindv1alpha1.AddToScheme(scheme); err != nil {
t.Fatalf("bind scheme: %v", err)
}
c := fake.NewClientBuilder().WithScheme(scheme).Build()
ctx, cancel := context.WithCancel(context.Background())
go fakeOperator(ctx, c)
return &Server{
Client: c,
Namespace: testNS,
Token: "s3cr3t",
Log: logr.Discard(),
WaitTimeout: 3 * time.Second,
}, cancel
}
func TestHealthzAndAuth(t *testing.T) {
srv, cancel := newTestServer(t)
defer cancel()
h := srv.Handler()
// healthz needs no auth.
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rr.Code != http.StatusOK {
t.Fatalf("healthz: want 200, got %d", rr.Code)
}
// missing token is rejected.
rr = httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/v1/keys", strings.NewReader(`{"name":"x"}`)))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("no-auth: want 401, got %d", rr.Code)
}
}
func TestKeyLifecycle(t *testing.T) {
srv, cancel := newTestServer(t)
defer cancel()
h := srv.Handler()
// create
created := do(t, h, http.MethodPost, "/v1/keys", `{"name":"host-a","cluster_ref":"bind-authoritative"}`, http.StatusOK)
if created.Secret == "" {
t.Fatalf("create returned empty secret")
}
if created.ClusterRef != "bind-authoritative" {
t.Fatalf("cluster_ref not propagated: %q", created.ClusterRef)
}
// read returns the same material
got := do(t, h, http.MethodGet, "/v1/keys/host-a", "", http.StatusOK)
if got.Secret != created.Secret {
t.Fatalf("read secret %q != created %q", got.Secret, created.Secret)
}
// rotate returns new material
rotated := do(t, h, http.MethodPost, "/v1/keys/host-a/rotate", "", http.StatusOK)
if rotated.Secret == created.Secret {
t.Fatalf("rotate did not change secret")
}
// delete
rr := httptest.NewRecorder()
req := authed(http.MethodDelete, "/v1/keys/host-a", "")
h.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("delete: want 204, got %d", rr.Code)
}
// read after delete → 404
rr = httptest.NewRecorder()
h.ServeHTTP(rr, authed(http.MethodGet, "/v1/keys/host-a", ""))
if rr.Code != http.StatusNotFound {
t.Fatalf("read-after-delete: want 404, got %d", rr.Code)
}
}
func authed(method, path, body string) *http.Request {
var r *http.Request
if body == "" {
r = httptest.NewRequest(method, path, nil)
} else {
r = httptest.NewRequest(method, path, strings.NewReader(body))
}
r.Header.Set("Authorization", "Bearer s3cr3t")
return r
}
func do(t *testing.T, h http.Handler, method, path, body string, wantCode int) keyResponse {
t.Helper()
rr := httptest.NewRecorder()
h.ServeHTTP(rr, authed(method, path, body))
if rr.Code != wantCode {
t.Fatalf("%s %s: want %d, got %d (%s)", method, path, wantCode, rr.Code, rr.Body.String())
}
var resp keyResponse
if rr.Body.Len() > 0 {
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
}
return resp
}