14 Commits

Author SHA1 Message Date
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
benvin 49df29a072 Merge pull request 'Add a primary (write) Service routing to pod-0' (#7) from benvin/primary-write-service into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #7
2026-07-04 22:37:40 +10:00
benvin ea3d71fa93 Merge pull request 'Support externalTrafficPolicy on the client Service' (#6) from benvin/svc-external-traffic-policy into main
Reviewed-on: #6
2026-07-04 22:37:03 +10:00
unkinben 28ae6538cb Add a primary (write) Service routing to pod-0
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Secondaries reject RFC2136/nsupdate writes, but the read Service round-
robins across all pods. Add an optional per-cluster write endpoint that
targets only the primary pod (ordinal 0) via the StatefulSet pod-name
label. Reads keep using the all-pods Service.

- api: BindCluster.spec.primaryService (*ClusterServiceSpec) — ClusterIP
  for in-cluster writers (external-dns) or LoadBalancer for external
- reconcilePrimaryService creates <cluster>-primary selecting pod-0 when
  set, deletes it when unset
- regenerate CRDs + install.yaml
2026-07-04 22:29:55 +10:00
unkinben e0bd3973ed Support externalTrafficPolicy on the client Service
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Adds BindCluster.spec.service.externalTrafficPolicy so DNS LoadBalancers
can preserve client source IPs (Local), which the source-IP ACLs on the
authoritative/resolver need to actually restrict external clients (Cluster
SNATs everything to node IPs).

- api: ClusterServiceSpec.externalTrafficPolicy (enum Cluster;Local)
- set it on the client Service for LoadBalancer/NodePort types
- regenerate CRDs + install.yaml
2026-07-04 22:15:22 +10:00
benvin 547d168c12 Merge pull request 'Render forward zones into named.conf on every pod' (#5) from benvin/forward-zones-config into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #5
2026-07-04 21:16:03 +10:00
unkinben 9bc4436c79 Render forward zones into named.conf on every pod
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
type:forward zones are pure configuration (conditional forwarding), not
replicated data, so a resolver needs them on all pods. They were being
rndc-addzone'd on the primary only, so queries hitting a secondary pod
missed the forwarding. Render them into named.conf instead.

- render forward zones inside their view (or top-level when no views)
- BindCluster lists type:forward zones and watches BindZone to re-render
- BindZone controller skips forward zones (config-managed, no addzone)
- unit test for forward-zone-in-view rendering
2026-07-04 11:55:03 +10:00
benvin f0e851c0bc Merge pull request 'Add clusterRef to BindTSIGKey' (#4) from benvin/tsigkey-clusterref into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #4
2026-07-04 00:03:42 +10:00
unkinben 55e80b467c Add clusterRef to BindTSIGKey
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
TSIG keys were included in every cluster's keys.conf namespace-wide. When
multiple clusters share a namespace, that leaks keys across clusters. Add
spec.clusterRef so a key can target a specific BindCluster; empty keeps
the shared (all-clusters-in-namespace) behaviour.

- api: BindTSIGKey.spec.clusterRef (optional)
- BindCluster keys.conf now includes only keys with matching or empty
  clusterRef
- regenerate CRDs + install.yaml bundle
2026-07-03 23:44:35 +10:00
benvin cd25c94efc Merge pull request 'Fix zone provisioning: seed glue + IP primaries' (#3) from benvin/fix-zone-seed into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #3
2026-07-03 21:39:45 +10:00
unkinben fb103a9e95 Fix zone provisioning: seed glue + IP primaries
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Two bugs made every provisioned zone fail to load:

1. The seed zone's apex NS (ns1.<zone>) is in-zone but had no address
   record, so BIND check-integrity refused to load it and rndc addzone
   reverted. Add a glue A record pointing at the primary pod IP.
2. Secondaries rendered primaries/default-primaries with the primary's
   DNS name, but BIND only accepts IP addresses there (it read the name
   as a remote-servers list and failed config load, crash-looping the
   secondary). Render the primary pod IP instead, and watch Pods so the
   config re-renders when that IP appears or changes.

- bind.WriteSeedZone writes 'ns1 IN A <primaryIP>' glue
- controllers resolve primaryPodIP and pass it to the seed (requeue if
  the primary has no IP yet)
- BindCluster renders PrimaryAddress from pod-0's IP and watches Pods
- render omits catalog primaries when the IP is unknown (no empty list)
2026-07-03 21:33:31 +10:00
27 changed files with 2129 additions and 32 deletions
+7
View File
@@ -8,3 +8,10 @@ steps:
repo: git.unkin.net/unkin/bind-operator
dockerfile: Dockerfile.operator
dry_run: true
- name: docker-build-tsig-api
image: woodpeckerci/plugin-docker-buildx
settings:
repo: git.unkin.net/unkin/bind-tsig-api
dockerfile: Dockerfile.tsigapi
dry_run: true
+13
View File
@@ -15,3 +15,16 @@ steps:
tags:
- ${CI_COMMIT_TAG}
- latest
- name: docker-tsig-api
image: woodpeckerci/plugin-docker-buildx
settings:
registry: git.unkin.net
repo: git.unkin.net/unkin/bind-tsig-api
dockerfile: Dockerfile.tsigapi
username: droneci
password:
from_secret: DRONECI_PASSWORD
tags:
- ${CI_COMMIT_TAG}
- 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"]
+16 -1
View File
@@ -33,6 +33,13 @@ type ClusterServiceSpec struct {
// +optional
LoadBalancerIP string `json:"loadBalancerIP,omitempty"`
// 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.
// +kubebuilder:validation:Enum=Cluster;Local
// +optional
ExternalTrafficPolicy corev1.ServiceExternalTrafficPolicy `json:"externalTrafficPolicy,omitempty"`
// Annotations added to the client-facing Service (e.g. PureLB/MetalLB hints).
// +optional
Annotations map[string]string `json:"annotations,omitempty"`
@@ -98,10 +105,18 @@ type BindClusterSpec struct {
// +optional
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
// Service controls how the cluster is exposed.
// Service controls how the cluster is exposed for reads (all pods).
// +optional
Service ClusterServiceSpec `json:"service,omitempty"`
// PrimaryService, when set, creates an additional Service routing only to the
// primary pod (ordinal 0) — the write endpoint for RFC2136/nsupdate, since
// secondaries reject updates. Reads still use Service (all pods). Use
// ClusterIP for in-cluster writers (e.g. external-dns) or LoadBalancer for
// external writers.
// +optional
PrimaryService *ClusterServiceSpec `json:"primaryService,omitempty"`
// NodeSelector for the BIND pods.
// +optional
NodeSelector map[string]string `json:"nodeSelector,omitempty"`
+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{})
}
+6
View File
@@ -16,6 +16,12 @@ const (
// BindTSIGKeySpec defines a TSIG key. If no existing key material is imported,
// the operator generates a random key and stores it in a Secret.
type BindTSIGKeySpec struct {
// ClusterRef names the BindCluster this key is included in. When empty the
// key is shared with every cluster in the namespace (useful when multiple
// clusters share one namespace).
// +optional
ClusterRef string `json:"clusterRef,omitempty"`
// Algorithm is the HMAC algorithm. Defaults to hmac-sha256.
// +kubebuilder:default="hmac-sha256"
// +optional
+110
View File
@@ -301,6 +301,11 @@ func (in *BindClusterSpec) DeepCopyInto(out *BindClusterSpec) {
}
in.Resources.DeepCopyInto(&out.Resources)
in.Service.DeepCopyInto(&out.Service)
if in.PrimaryService != nil {
in, out := &in.PrimaryService, &out.PrimaryService
*out = new(ClusterServiceSpec)
(*in).DeepCopyInto(*out)
}
if in.NodeSelector != nil {
in, out := &in.NodeSelector, &out.NodeSelector
*out = make(map[string]string, len(*in))
@@ -576,6 +581,111 @@ func (in *BindPolicyStatus) DeepCopy() *BindPolicyStatus {
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.
func (in *BindTSIGKey) DeepCopyInto(out *BindTSIGKey) {
*out = *in
+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"
}
@@ -1013,6 +1013,41 @@ spec:
type: string
description: NodeSelector for the BIND pods.
type: object
primaryService:
description: |-
PrimaryService, when set, creates an additional Service routing only to the
primary pod (ordinal 0) — the write endpoint for RFC2136/nsupdate, since
secondaries reject updates. Reads still use Service (all pods). Use
ClusterIP for in-cluster writers (e.g. external-dns) or LoadBalancer for
external writers.
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
recursion:
description: |-
Recursion overrides the default per-mode recursion setting. When nil,
@@ -1086,7 +1121,8 @@ spec:
type: object
type: object
service:
description: Service controls how the cluster is exposed.
description: Service controls how the cluster is exposed for reads
(all pods).
properties:
annotations:
additionalProperties:
@@ -1094,6 +1130,15 @@ spec:
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.
@@ -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: {}
@@ -66,6 +66,12 @@ spec:
- hmac-sha1
- hmac-md5
type: string
clusterRef:
description: |-
ClusterRef names the BindCluster this key is included in. When empty the
key is shared with every cluster in the namespace (useful when multiple
clusters share one namespace).
type: string
importExisting:
description: |-
ImportExisting, when true, means the referenced Secret already contains a
+319 -1
View File
@@ -1318,6 +1318,41 @@ spec:
type: string
description: NodeSelector for the BIND pods.
type: object
primaryService:
description: |-
PrimaryService, when set, creates an additional Service routing only to the
primary pod (ordinal 0) — the write endpoint for RFC2136/nsupdate, since
secondaries reject updates. Reads still use Service (all pods). Use
ClusterIP for in-cluster writers (e.g. external-dns) or LoadBalancer for
external writers.
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
recursion:
description: |-
Recursion overrides the default per-mode recursion setting. When nil,
@@ -1391,7 +1426,8 @@ spec:
type: object
type: object
service:
description: Service controls how the cluster is exposed.
description: Service controls how the cluster is exposed for reads
(all pods).
properties:
annotations:
additionalProperties:
@@ -1399,6 +1435,15 @@ spec:
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.
@@ -1978,6 +2023,273 @@ spec:
---
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: {}
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.3
@@ -2043,6 +2355,12 @@ spec:
- hmac-sha1
- hmac-md5
type: string
clusterRef:
description: |-
ClusterRef names the BindCluster this key is included in. When empty the
key is shared with every cluster in the namespace (useful when multiple
clusters share one namespace).
type: string
importExisting:
description: |-
ImportExisting, when true, means the referenced Secret already contains a
+17
View File
@@ -9,6 +9,7 @@ rules:
resources:
- configmaps
- secrets
- serviceaccounts
- services
verbs:
- create
@@ -36,6 +37,7 @@ rules:
- apiGroups:
- apps
resources:
- deployments
- statefulsets
verbs:
- create
@@ -53,6 +55,7 @@ rules:
- bindclusters
- binddnssecpolicies
- bindpolicies
- bindtsigapis
- bindtsigkeys
- bindviews
- bindzones
@@ -73,6 +76,7 @@ rules:
- bindclusters/status
- binddnssecpolicies/status
- bindpolicies/status
- bindtsigapis/status
- bindtsigkeys/status
- bindviews/status
- bindzones/status
@@ -81,3 +85,16 @@ rules:
- get
- patch
- update
- apiGroups:
- rbac.authorization.k8s.io
resources:
- rolebindings
- roles
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
+70 -6
View File
@@ -16,6 +16,10 @@ type RenderInput struct {
Policies []bindv1alpha1.BindPolicy
DNSSECPolicies []bindv1alpha1.BindDNSSECPolicy
Catalog *bindv1alpha1.BindCatalogZone
// Forwards are type:forward BindZones. They are pure configuration (no
// replicated data), so they are rendered into named.conf on every pod
// rather than added dynamically to the primary.
Forwards []bindv1alpha1.BindZone
// PrimaryAddress is the in-cluster address secondaries transfer from.
PrimaryAddress string
}
@@ -90,6 +94,29 @@ func render(in RenderInput, isPrimary bool) string {
b.WriteString(renderCatalogZoneDecl(in, isPrimary, ""))
}
// Top-level forward zones (BIND only allows top-level zones when no views
// are defined; in-view forward zones are rendered inside renderView).
if len(in.Views) == 0 {
for _, z := range in.Forwards {
if z.Spec.ViewRef == "" {
b.WriteString(renderForwardZone(z, ""))
}
}
}
return b.String()
}
// renderForwardZone renders a type:forward zone clause.
func renderForwardZone(z bindv1alpha1.BindZone, indent string) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("%szone \"%s\" {\n", indent, z.Spec.ZoneName))
b.WriteString(indent + " type forward;\n")
b.WriteString(indent + " forward only;\n")
if len(z.Spec.Forwarders) > 0 {
b.WriteString(fmt.Sprintf("%s forwarders { %s };\n", indent, terminate(z.Spec.Forwarders)))
}
b.WriteString(indent + "};\n")
return b.String()
}
@@ -122,6 +149,12 @@ func renderView(v bindv1alpha1.BindView, in RenderInput, isPrimary bool) string
if in.Catalog != nil {
b.WriteString(renderCatalogZoneDecl(in, isPrimary, " "))
}
// Forward zones bound to this view.
for _, z := range in.Forwards {
if z.Spec.ViewRef == v.Name {
b.WriteString(renderForwardZone(z, " "))
}
}
b.WriteString("};\n\n")
return b.String()
}
@@ -197,14 +230,42 @@ func responsePolicyClause(policies []bindv1alpha1.BindPolicy, indent string) str
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
}
func catalogZonesClause(in RenderInput, isPrimary bool, indent string) string {
// Only secondaries consume the catalog to auto-provision member zones.
if in.Catalog == nil || isPrimary {
return ""
}
primaries := in.Catalog.Spec.DefaultPrimaries
if len(primaries) == 0 && in.PrimaryAddress != "" {
primaries = []string{in.PrimaryAddress}
primaries := transferPrimaries(in)
if len(primaries) == 0 {
return ""
}
var b strings.Builder
b.WriteString(indent + "catalog-zones {\n")
@@ -223,9 +284,12 @@ func renderCatalogZoneDecl(in RenderInput, isPrimary bool, indent string) string
}
cat := in.Catalog
file := CatalogFilePath(cat.Spec.ZoneName)
primaries := cat.Spec.DefaultPrimaries
if len(primaries) == 0 && in.PrimaryAddress != "" {
primaries = []string{in.PrimaryAddress}
primaries := transferPrimaries(in)
if len(primaries) == 0 {
// Primary IP not known yet; omit the secondary catalog zone rather than
// emit an invalid empty primaries list. A Pod-triggered reconcile renders
// it once the primary pod has an IP.
return ""
}
var b strings.Builder
b.WriteString(fmt.Sprintf("%szone \"%s\" {\n", indent, cat.Spec.ZoneName))
+68
View File
@@ -53,6 +53,74 @@ func TestRenderCatalogOnSecondaryOnly(t *testing.T) {
}
}
func TestRenderCatalogOmittedWhenPrimaryIPUnknown(t *testing.T) {
// Primary IP not known yet and no explicit default-primaries: the secondary
// must not emit a catalog-zones / secondary catalog zone with an empty
// primaries list (which BIND rejects at config load).
in := RenderInput{
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
Catalog: &bindv1alpha1.BindCatalogZone{Spec: bindv1alpha1.BindCatalogZoneSpec{ZoneName: "catalog.internal"}},
PrimaryAddress: "",
}
_, secondary := RenderNamedConf(in)
if strings.Contains(secondary, "catalog-zones") || strings.Contains(secondary, "primaries {") {
t.Fatalf("secondary must omit catalog primaries when the primary IP is unknown:\n%s", secondary)
}
}
func TestRenderCatalogUsesPrimaryIP(t *testing.T) {
in := RenderInput{
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
Catalog: &bindv1alpha1.BindCatalogZone{Spec: bindv1alpha1.BindCatalogZoneSpec{ZoneName: "catalog.internal"}},
PrimaryAddress: "10.42.0.7",
}
_, secondary := RenderNamedConf(in)
if !strings.Contains(secondary, "primaries { 10.42.0.7; }") {
t.Fatalf("secondary should point primaries at the primary pod IP:\n%s", secondary)
}
}
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 TestRenderForwardZoneInView(t *testing.T) {
rec := true
in := RenderInput{
Cluster: newCluster(bindv1alpha1.ModeResolver),
Views: []bindv1alpha1.BindView{{
ObjectMeta: metav1.ObjectMeta{Name: "openforwarder"},
Spec: bindv1alpha1.BindViewSpec{ClusterRef: "auth", MatchClients: []string{"acl-main"}, Recursion: &rec},
}},
Forwards: []bindv1alpha1.BindZone{{
Spec: bindv1alpha1.BindZoneSpec{ClusterRef: "auth", ZoneName: "unkin.net", Type: bindv1alpha1.ZoneForward, ViewRef: "openforwarder", Forwarders: []string{"198.18.19.15"}},
}},
}
primary, secondary := RenderNamedConf(in)
for _, out := range []string{primary, secondary} {
if !strings.Contains(out, `view "openforwarder"`) {
t.Fatalf("view missing:\n%s", out)
}
if !strings.Contains(out, `zone "unkin.net" {`) || !strings.Contains(out, "type forward;") || !strings.Contains(out, "forwarders { 198.18.19.15; }") {
t.Fatalf("forward zone not rendered inside view (must be on all pods):\n%s", out)
}
}
}
func TestRenderACL(t *testing.T) {
in := RenderInput{
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
+10 -8
View File
@@ -26,14 +26,15 @@ func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view st
return err == nil
}
// WriteSeedZone writes a minimal loadable zone file (SOA + apex NS) to path,
// creating parent directories. It is only safe to call when creating a zone, as
// it overwrites any existing file.
func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path, primaryNS string, serial int64) error {
// WriteSeedZone writes a minimal loadable zone file (SOA + apex NS + glue) to
// path, creating parent directories. The apex NS is the in-zone name ns1, and a
// glue A record pointing at primaryIP is included so BIND's check-integrity
// accepts the zone (an in-zone NS without an address record is a load error).
// It is only safe to call when creating a zone, as it overwrites any existing
// 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)
if primaryNS == "" {
primaryNS = "ns1." + origin
}
ns := "ns1." + origin
content := fmt.Sprintf(`$TTL 3600
@ IN SOA %s hostmaster.%s (
%d ; serial
@@ -42,7 +43,8 @@ func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path
1209600 ; expire
300 ) ; minimum
@ IN NS %s
`, dot(primaryNS), origin, serial, dot(primaryNS))
ns1 IN A %s
`, ns, origin, serial, ns, primaryIP)
cmd := []string{"sh", "-c", fmt.Sprintf("mkdir -p \"$(dirname '%s')\" && cat > '%s'", path, path)}
if out, err := e.Exec(ctx, namespace, pod, cmd, content); err != nil {
@@ -50,7 +50,11 @@ func (r *BindCatalogZoneReconciler) Reconcile(ctx context.Context, req ctrl.Requ
// Ensure the catalog zone exists on the primary.
if !r.Exec.ZoneExists(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, "") {
if err := r.Exec.WriteSeedZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, bind.CatalogFilePath(catalog.Spec.ZoneName), "", 1); err != nil {
primaryIP := primaryPodIP(ctx, r.Client, cluster)
if primaryIP == "" {
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 {
return r.fail(ctx, &catalog, "SeedFailed", err.Error())
}
}
+90 -3
View File
@@ -128,7 +128,15 @@ func (r *BindClusterReconciler) reconcileKeysSecret(ctx context.Context, c *bind
if err := r.List(ctx, &keys, client.InNamespace(c.Namespace)); err != nil {
return err
}
items := append([]bindv1alpha1.BindTSIGKey(nil), keys.Items...)
// Include keys scoped to this cluster (spec.clusterRef == name) and shared
// keys (empty clusterRef). This keeps keys from leaking across clusters that
// share a namespace.
var items []bindv1alpha1.BindTSIGKey
for _, k := range keys.Items {
if k.Spec.ClusterRef == "" || k.Spec.ClusterRef == c.Name {
items = append(items, k)
}
}
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
var b strings.Builder
@@ -160,7 +168,11 @@ func (r *BindClusterReconciler) reconcileKeysSecret(ctx context.Context, c *bind
}
func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv1alpha1.BindCluster) error {
in := bind.RenderInput{Cluster: c, PrimaryAddress: primaryAddress(c.Name, c.Namespace)}
// BIND primaries/default-primaries need an IP address, not a DNS name. Use
// the stable primary Service ClusterIP so secondaries keep transferring
// across primary pod restarts (falls back to the pod IP when no primary
// 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
if err := r.List(ctx, &acls, client.InNamespace(c.Namespace)); err == nil {
@@ -195,6 +207,17 @@ func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv
}
}
// Forward zones are configuration (no data), so they are rendered into
// named.conf on every pod rather than added dynamically to the primary.
var zones bindv1alpha1.BindZoneList
if err := r.List(ctx, &zones, client.InNamespace(c.Namespace)); err == nil {
for _, z := range zones.Items {
if z.Spec.ClusterRef == c.Name && z.Spec.Type == bindv1alpha1.ZoneForward {
in.Forwards = append(in.Forwards, z)
}
}
}
var catalogs bindv1alpha1.BindCatalogZoneList
if err := r.List(ctx, &catalogs, client.InNamespace(c.Namespace)); err == nil {
for i := range catalogs.Items {
@@ -251,7 +274,56 @@ func (r *BindClusterReconciler) reconcileServices(ctx context.Context, c *bindv1
LoadBalancerIP: c.Spec.Service.LoadBalancerIP,
},
}
return r.upsertService(ctx, c, client)
// externalTrafficPolicy is only valid for LoadBalancer/NodePort Services.
if svcType == corev1.ServiceTypeLoadBalancer || svcType == corev1.ServiceTypeNodePort {
client.Spec.ExternalTrafficPolicy = c.Spec.Service.ExternalTrafficPolicy
}
if err := r.upsertService(ctx, c, client); err != nil {
return err
}
// Primary (write) Service: routes only to pod-0. Created when configured,
// deleted when removed.
return r.reconcilePrimaryService(ctx, c, dnsPorts)
}
func (r *BindClusterReconciler) reconcilePrimaryService(ctx context.Context, c *bindv1alpha1.BindCluster, dnsPorts []corev1.ServicePort) error {
name := primaryServiceName(c.Name)
if c.Spec.PrimaryService == nil {
var existing corev1.Service
err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: name}, &existing)
if apierrors.IsNotFound(err) {
return nil
}
if err != nil {
return err
}
return client.IgnoreNotFound(r.Delete(ctx, &existing))
}
ps := c.Spec.PrimaryService
psType := ps.Type
if psType == "" {
psType = corev1.ServiceTypeClusterIP
}
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: c.Namespace,
Labels: commonLabels(c.Name),
Annotations: ps.Annotations,
},
Spec: corev1.ServiceSpec{
Type: psType,
Selector: primaryPodSelector(c.Name),
Ports: dnsPorts,
LoadBalancerIP: ps.LoadBalancerIP,
},
}
if psType == corev1.ServiceTypeLoadBalancer || psType == corev1.ServiceTypeNodePort {
svc.Spec.ExternalTrafficPolicy = ps.ExternalTrafficPolicy
}
return r.upsertService(ctx, c, svc)
}
func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bindv1alpha1.BindCluster) (*appsv1.StatefulSet, error) {
@@ -387,6 +459,12 @@ func (r *BindClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
Owns(&corev1.Service{}).
Owns(&corev1.ConfigMap{}).
Owns(&corev1.Secret{}).
Watches(&corev1.Pod{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
// Re-render named.conf when a cluster pod's IP appears or changes, so
// secondaries always point primaries/default-primaries at the current
// primary pod IP.
return mapToCluster(o.GetLabels()[clusterLabel], o.GetNamespace())
})).
Watches(&bindv1alpha1.BindACL{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
return mapToCluster(o.(*bindv1alpha1.BindACL).Spec.ClusterRef, o.GetNamespace())
})).
@@ -399,6 +477,15 @@ func (r *BindClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
Watches(&bindv1alpha1.BindDNSSECPolicy{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
return mapToCluster(o.(*bindv1alpha1.BindDNSSECPolicy).Spec.ClusterRef, o.GetNamespace())
})).
Watches(&bindv1alpha1.BindZone{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
// Only forward zones affect named.conf; primary/secondary zones are
// managed dynamically by the BindZone controller.
z := o.(*bindv1alpha1.BindZone)
if z.Spec.Type != bindv1alpha1.ZoneForward {
return nil
}
return mapToCluster(z.Spec.ClusterRef, o.GetNamespace())
})).
Watches(&bindv1alpha1.BindCatalogZone{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
return mapToCluster(o.(*bindv1alpha1.BindCatalogZone).Spec.ClusterRef, o.GetNamespace())
})).
+5 -1
View File
@@ -63,7 +63,11 @@ func (r *BindPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
}
if !r.Exec.ZoneExists(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, policy.Spec.ViewRef) {
if err := r.Exec.WriteSeedZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, bind.ZoneFilePath(policy.Spec.ZoneName), "", 1); err != nil {
primaryIP := primaryPodIP(ctx, r.Client, cluster)
if primaryIP == "" {
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 {
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 }
+48 -5
View File
@@ -35,6 +35,19 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// Forward zones are pure configuration rendered into named.conf by the
// BindCluster controller (on every pod), not added dynamically to the
// primary. Nothing to do here beyond reporting readiness.
if zone.Spec.Type == bindv1alpha1.ZoneForward {
zone.Status.Phase = "Ready"
zone.Status.ObservedGeneration = zone.Generation
setReady(&zone.Status.Conditions, zone.Generation, true, "Configured", "forward zone rendered into named.conf")
if err := r.Status().Update(ctx, &zone); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
cluster, err := getCluster(ctx, r.Client, zone.Namespace, zone.Spec.ClusterRef)
if err != nil {
return r.setPhase(ctx, &zone, "Error", "ClusterMissing", err.Error())
@@ -67,14 +80,18 @@ 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")
}
zoneConfig, err := r.buildZoneConfig(ctx, &zone)
zoneConfig, err := r.buildZoneConfig(ctx, &zone, r.zoneTransferKeyRef(ctx, &zone, cluster))
if err != nil {
return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error())
}
created := !r.Exec.ZoneExists(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef)
if created && zone.Spec.Type == bindv1alpha1.ZonePrimary || (created && zone.Spec.Type == "") {
if err := r.Exec.WriteSeedZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, bind.ZoneFilePath(zone.Spec.ZoneName), "", 1); err != nil {
if created && (zone.Spec.Type == bindv1alpha1.ZonePrimary || zone.Spec.Type == "") {
primaryIP := primaryPodIP(ctx, r.Client, cluster)
if primaryIP == "" {
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 {
return r.setPhase(ctx, &zone, "Error", "SeedFailed", err.Error())
}
}
@@ -116,7 +133,9 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
}
// 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.
func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone, transferKey string) (string, error) {
zType := zone.Spec.Type
if zType == "" {
zType = bindv1alpha1.ZonePrimary
@@ -128,8 +147,12 @@ func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1al
if zone.Spec.DynamicUpdate && zone.Spec.UpdateKeyRef != "" {
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)))
case transferKey != "":
// Catalog member: permit key-authenticated AXFR from secondaries.
parts = append(parts, fmt.Sprintf("allow-transfer { key \"%s\"; }", transferKey))
}
if zone.Spec.DNSSECPolicyRef != "" {
parts = append(parts, fmt.Sprintf("dnssec-policy \"%s\"", zone.Spec.DNSSECPolicyRef), "inline-signing yes")
@@ -185,6 +208,26 @@ func (r *BindZoneReconciler) deregisterCatalog(ctx context.Context, zone *bindv1
_ = 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) {
var catalogs bindv1alpha1.BindCatalogZoneList
if err := r.List(ctx, &catalogs, client.InNamespace(zone.Namespace)); err != nil {
+44 -5
View File
@@ -29,11 +29,20 @@ const (
)
func headlessServiceName(cluster string) string { return cluster + "-headless" }
func clientServiceName(cluster string) string { return cluster }
func primaryPodName(cluster string) string { return cluster + "-0" }
func configMapName(cluster string) string { return cluster + "-config" }
func keysSecretName(cluster string) string { return cluster + "-keys" }
func rndcSecretName(cluster string) string { return cluster + "-rndc" }
func primaryServiceName(cluster string) string { return cluster + "-primary" }
// primaryPodSelector selects only the primary pod (ordinal 0) via the stable
// StatefulSet pod-name label, for the write Service.
func primaryPodSelector(cluster string) map[string]string {
s := commonLabels(cluster)
s["statefulset.kubernetes.io/pod-name"] = primaryPodName(cluster)
return s
}
func clientServiceName(cluster string) string { return cluster }
func primaryPodName(cluster string) string { return cluster + "-0" }
func configMapName(cluster string) string { return cluster + "-config" }
func keysSecretName(cluster string) string { return cluster + "-keys" }
func rndcSecretName(cluster string) string { return cluster + "-rndc" }
// primaryAddress is the in-cluster DNS name of the primary pod (ordinal 0).
func primaryAddress(cluster, namespace string) string {
@@ -87,6 +96,36 @@ func primaryReady(ctx context.Context, c client.Client, cluster *bindv1alpha1.Bi
return false
}
// primaryPodIP returns the pod IP of a cluster's primary pod (ordinal 0), or an
// empty string if the pod has no IP yet. BIND's primaries/default-primaries
// only accept IP addresses (not hostnames), and zone seeding needs the address
// for glue, so the operator resolves the pod IP rather than using a DNS name.
func primaryPodIP(ctx context.Context, c client.Client, cluster *bindv1alpha1.BindCluster) string {
var pod corev1.Pod
if err := c.Get(ctx, client.ObjectKey{Namespace: cluster.Namespace, Name: primaryPodName(cluster.Name)}, &pod); err != nil {
return ""
}
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)
}
// resolveTSIG reads the material of a BindTSIGKey into TSIG credentials.
func resolveTSIG(ctx context.Context, c client.Client, namespace, keyRef string) (bind.TSIGCreds, error) {
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 {
return err
}
if err := (&BindTSIGAPIReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil {
return err
}
return nil
}
+1
View File
@@ -57,6 +57,7 @@ func (r *BindClusterReconciler) upsertService(ctx context.Context, c *bindv1alph
existing.Spec.Selector = desired.Spec.Selector
existing.Spec.Type = desired.Spec.Type
existing.Spec.LoadBalancerIP = desired.Spec.LoadBalancerIP
existing.Spec.ExternalTrafficPolicy = desired.Spec.ExternalTrafficPolicy
if desired.Annotations != nil {
if existing.Annotations == nil {
existing.Annotations = map[string]string{}
+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
}