Initial bind-operator: 9 CRDs + controllers

Implements a Kubernetes operator that manages fleets of BIND9 servers
declaratively, using controller-runtime (matching forgebot conventions).

- add BindCluster reconciler: StatefulSet (pod-0 primary, secondaries),
  headless + client Services, rendered named.conf ConfigMap, TSIG keys
  Secret and rndc control Secret; watches dependent CRs to re-render
- add BindTSIGKey reconciler that generates key material into a Secret
- add BindZone/DNSRecord reconcilers using fully-dynamic delivery
  (rndc addzone + TSIG nsupdate against the primary pod)
- add BindCatalogZone reconciler so secondaries auto-provision zones
- add BindPolicy (RPZ), BindDNSSECPolicy, BindView, BindACL reconcilers
- render primary/secondary named.conf variants selected by pod ordinal
- generate CRDs, deepcopy and RBAC; add samples mapping the three Puppet
  roles (authoritative/resolver/external-dns) to three BindClusters
- add Makefile, Dockerfile.operator, Woodpecker CI and kind manifests
This commit is contained in:
2026-07-03 15:48:13 +10:00
parent b3a5b4d0b7
commit fe5fbdaf6d
63 changed files with 8240 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
/bin/
*.out
*.test
.env
+10
View File
@@ -0,0 +1,10 @@
when:
- event: pull_request
steps:
- name: docker-build-operator
image: woodpeckerci/plugin-docker-buildx
settings:
repo: git.unkin.net/unkin/bind-operator
dockerfile: Dockerfile.operator
dry_run: true
+17
View File
@@ -0,0 +1,17 @@
when:
- event: tag
ref: refs/tags/v*
steps:
- name: docker-operator
image: woodpeckerci/plugin-docker-buildx
settings:
registry: git.unkin.net
repo: git.unkin.net/unkin/bind-operator
dockerfile: Dockerfile.operator
username: droneci
password:
from_secret: DRONECI_PASSWORD
tags:
- ${CI_COMMIT_TAG}
- latest
+9
View File
@@ -0,0 +1,9 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: golang:1.25
commands:
- test -z "$(gofmt -l .)"
- go vet ./...
+8
View File
@@ -0,0 +1,8 @@
when:
- event: pull_request
steps:
- name: test
image: golang:1.25
commands:
- go test -race -count=1 ./api/... ./internal/...
+18
View File
@@ -0,0 +1,18 @@
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 bind-operator ./cmd/operator
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /build/bind-operator /usr/local/bin/bind-operator
ENTRYPOINT ["bind-operator"]
+51
View File
@@ -0,0 +1,51 @@
.PHONY: build test lint fmt generate manifests docker-operator clean tidy patch minor major
BINARY_OP := bin/bind-operator
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev")
build: tidy
go build -ldflags="-s -w" -o $(BINARY_OP) ./cmd/operator
test:
go test -race -count=1 ./api/... ./internal/...
lint:
go vet ./...
fmt:
gofmt -w .
## generate: regenerate deepcopy, CRDs and RBAC from kubebuilder markers
generate:
controller-gen object paths="./api/..."
controller-gen crd paths="./api/..." output:crd:artifacts:config=config/crd/bases
controller-gen rbac:roleName=bind-operator paths="./internal/controller/..." output:rbac:dir=config/rbac
manifests: generate
docker-operator:
docker build -t bind-operator:$(VERSION) -f Dockerfile.operator .
clean:
rm -rf bin/
tidy:
go mod tidy
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
patch:
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
minor:
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
major:
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
+96 -1
View File
@@ -1,3 +1,98 @@
# bind-operator # bind-operator
Kubernetes operator for managing BIND9 DNS clusters, zones, views, and TSIG keys A Kubernetes operator that manages fleets of BIND9 servers declaratively:
StatefulSet-backed clusters with primary/secondary replication, and zones,
views, TSIG keys, ACLs, catalog zones, RPZ policies and DNSSEC policies as
custom resources.
## Architecture
Each `BindCluster` is a StatefulSet plus a headless Service (stable per-pod DNS)
and a client Service. **Ordinal-0 is the primary**; the remaining pods are
secondaries that replicate via AXFR/IXFR + NOTIFY. A per-pod PVC holds zone
databases and journals.
Zone content is delivered **dynamically**: the operator execs `rndc addzone` and
TSIG `nsupdate` against the primary pod (the same write path external-dns uses).
Cluster-wide config — `options`, `controls`, ACLs, views, `dnssec-policy` blocks
and `response-policy` clauses — is rendered into a ConfigMap-backed `named.conf`
and reloaded with `rndc reconfig`. New zones land on the secondaries
automatically through a **catalog zone**, so secondaries never need
per-zone reconfiguration.
```
BindCluster ──> StatefulSet (pod-0 = primary, pod-N = secondaries)
├─ headless Service (pod-0.<cluster>-headless.<ns>.svc…)
├─ client Service (ClusterIP / LoadBalancer)
├─ ConfigMap (named.conf.primary / .secondary + entrypoint)
├─ Secret <cluster>-keys (TSIG key clauses, included by named.conf)
└─ Secret <cluster>-rndc (rndc control key)
BindZone / DNSRecord ──rndc addzone + nsupdate──> primary ──catalog + AXFR──> secondaries
```
The named.conf is rendered in two variants (primary/secondary); an entrypoint
script picks one based on the pod ordinal.
## Custom Resources
| Kind | Purpose |
|------|---------|
| `BindCluster` | A set of BIND9 servers. `spec.mode`: `authoritative`, `resolver`, or `dynamic`. |
| `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. |
| `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). |
| `BindACL` | A reusable named `address_match_list`. |
| `BindCatalogZone` | A BIND catalog zone so secondaries auto-provision member zones. |
| `BindPolicy` | A Response Policy Zone (RPZ) / DNS firewall. |
| `BindDNSSECPolicy` | A `dnssec-policy` for automated signing. |
See `config/samples/` for worked examples.
## Migration mapping
The three Puppet-managed BIND roles map onto three `BindCluster`s:
| Puppet role | `BindCluster` | Mode |
|-------------|---------------|------|
| 3× authoritative masters | `auth` | `authoritative` (pod-0 primary, 2 secondaries) |
| 3× only-resolvers | `resolver` | `resolver` (3 identical recursive servers) |
| 3× external-dns | `externaldns` | `dynamic` (RFC2136 TSIG updates on primary) |
## Development
```sh
make generate # regenerate deepcopy, CRDs and RBAC from kubebuilder markers
make build # build the operator binary
make test # go test -race
make lint fmt # go vet / gofmt
```
### Local (kind)
```sh
kind create cluster --name bind
docker build -t bind-operator:dev -f Dockerfile.operator .
kind load docker-image bind-operator:dev --name bind
kubectl apply -f config/crd/bases/
kubectl apply -f hack/kind/manifests/
kubectl apply -f config/samples/
```
## CI
Woodpecker runs `pre-commit` (gofmt + vet), `test`, and a dry-run image `build`
on pull requests; pushing a `v*` tag builds and pushes
`git.unkin.net/unkin/bind-operator` to the Gitea registry.
## Notes & caveats
- The BIND container image (`spec.image`, default
`git.unkin.net/unkin/bind9:latest`) must ship `named`, `rndc` and `nsupdate`,
read `/run/named/named.conf`, and honour the operator's `/etc/bind` layout.
- Dynamic updates authenticate with `nsupdate -y`; the TSIG secret is passed on
the argv of an exec'd process inside the pod.
- RPZ IP-trigger encodings (`ip`, `client-ip`, `nsip`) are emitted verbatim;
QNAME and NSDNAME triggers are fully supported.
+61
View File
@@ -0,0 +1,61 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// BindACLSpec defines a reusable named address_match_list.
type BindACLSpec struct {
// ClusterRef names the BindCluster whose named.conf this ACL is rendered
// into. When empty the ACL is available to every cluster in the namespace.
// +optional
ClusterRef string `json:"clusterRef,omitempty"`
// Entries are raw BIND address-match-list elements, e.g. "10.0.0.0/8",
// "!192.168.1.5", "key transfer-key", "localhost", "any", or the name of
// another ACL.
// +kubebuilder:validation:MinItems=1
Entries []string `json:"entries"`
}
// BindACLStatus reports observed ACL state.
type BindACLStatus struct {
// +optional
Ready bool `json:"ready,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=bacl
// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterRef`
// +kubebuilder:printcolumn:name="Entries",type=integer,JSONPath=`.spec.entries[*]`
// +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=`.status.ready`
// BindACL is a named address-match-list referenced by views, zones and
// policies for match-clients / allow-query / allow-transfer / allow-update.
type BindACL struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec BindACLSpec `json:"spec,omitempty"`
Status BindACLStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// BindACLList contains a list of BindACL.
type BindACLList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []BindACL `json:"items"`
}
func init() {
SchemeBuilder.Register(&BindACL{}, &BindACLList{})
}
+71
View File
@@ -0,0 +1,71 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// BindCatalogZoneSpec defines a BIND9 catalog zone. The primary publishes it
// and secondaries consume it, so member zones are provisioned onto every
// secondary automatically without per-zone reconfiguration.
type BindCatalogZoneSpec struct {
// ClusterRef names the owning BindCluster.
ClusterRef string `json:"clusterRef"`
// ZoneName is the catalog zone's own origin, e.g. "catalog.internal".
ZoneName string `json:"zoneName"`
// DefaultPrimaries are the addresses member zones point at on secondaries.
// Defaults to the cluster primary Service.
// +optional
DefaultPrimaries []string `json:"defaultPrimaries,omitempty"`
// TransferKeyRef names the BindTSIGKey authenticating catalog + member zone
// transfers to secondaries.
// +optional
TransferKeyRef string `json:"transferKeyRef,omitempty"`
}
// BindCatalogZoneStatus reports observed catalog state.
type BindCatalogZoneStatus struct {
// +optional
Ready bool `json:"ready,omitempty"`
// MemberCount is the number of member zones registered in the catalog.
// +optional
MemberCount int32 `json:"memberCount,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=bcz
// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterRef`
// +kubebuilder:printcolumn:name="Zone",type=string,JSONPath=`.spec.zoneName`
// +kubebuilder:printcolumn:name="Members",type=integer,JSONPath=`.status.memberCount`
// +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=`.status.ready`
// BindCatalogZone auto-provisions member zones onto cluster secondaries.
type BindCatalogZone struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec BindCatalogZoneSpec `json:"spec,omitempty"`
Status BindCatalogZoneStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// BindCatalogZoneList contains a list of BindCatalogZone.
type BindCatalogZoneList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []BindCatalogZone `json:"items"`
}
func init() {
SchemeBuilder.Register(&BindCatalogZone{}, &BindCatalogZoneList{})
}
+181
View File
@@ -0,0 +1,181 @@
package v1alpha1
import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// BindMode selects the behaviour of a BindCluster and maps onto a classic
// BIND deployment role.
// - authoritative: serves signed/unsigned authoritative zones. Ordinal-0 is
// the primary that holds zone data; the remaining pods are secondaries that
// replicate via AXFR/IXFR + NOTIFY (optionally driven by a catalog zone).
// - resolver: N identical recursive resolvers, no zone replication.
// - dynamic: like authoritative, but the primary accepts RFC2136 TSIG updates
// (the external-dns pattern); secondaries replicate the result.
//
// +kubebuilder:validation:Enum=authoritative;resolver;dynamic
type BindMode string
const (
ModeAuthoritative BindMode = "authoritative"
ModeResolver BindMode = "resolver"
ModeDynamic BindMode = "dynamic"
)
// ClusterServiceSpec controls how the cluster is exposed to clients.
type ClusterServiceSpec struct {
// Type of the client-facing Service. Defaults to ClusterIP.
// +kubebuilder:validation:Enum=ClusterIP;LoadBalancer;NodePort
// +optional
Type corev1.ServiceType `json:"type,omitempty"`
// LoadBalancerIP requests a specific address when Type is LoadBalancer.
// +optional
LoadBalancerIP string `json:"loadBalancerIP,omitempty"`
// Annotations added to the client-facing Service (e.g. PureLB/MetalLB hints).
// +optional
Annotations map[string]string `json:"annotations,omitempty"`
}
// BindClusterSpec defines the desired state of a BIND cluster.
type BindClusterSpec struct {
// Mode selects the cluster role.
// +kubebuilder:default=authoritative
Mode BindMode `json:"mode"`
// Replicas is the number of BIND pods. Ordinal-0 is the primary for the
// authoritative and dynamic modes.
// +kubebuilder:default=3
// +kubebuilder:validation:Minimum=1
// +optional
Replicas int32 `json:"replicas,omitempty"`
// Image is the BIND9 container image.
// +kubebuilder:default="git.unkin.net/unkin/bind9:latest"
// +optional
Image string `json:"image,omitempty"`
// ImagePullPolicy for the BIND container.
// +optional
ImagePullPolicy corev1.PullPolicy `json:"imagePullPolicy,omitempty"`
// Recursion overrides the default per-mode recursion setting. When nil,
// resolver mode enables recursion and the other modes disable it.
// +optional
Recursion *bool `json:"recursion,omitempty"`
// Forwarders is a list of upstream resolvers used by resolver mode (and any
// forward zones that do not specify their own).
// +optional
Forwarders []string `json:"forwarders,omitempty"`
// AllowNewZones enables the rndc addzone/delzone control path required for
// dynamic zone provisioning. Defaults to true.
// +kubebuilder:default=true
// +optional
AllowNewZones *bool `json:"allowNewZones,omitempty"`
// CatalogZoneRef names a BindCatalogZone that secondaries consume so member
// zones are auto-provisioned without per-zone reconfiguration.
// +optional
CatalogZoneRef string `json:"catalogZoneRef,omitempty"`
// ExtraOptions are raw named.conf `options { ... }` lines appended verbatim.
// +optional
ExtraOptions []string `json:"extraOptions,omitempty"`
// StorageClassName for the per-pod PVC that holds zone data and journals.
// +optional
StorageClassName *string `json:"storageClassName,omitempty"`
// StorageSize for the per-pod PVC. Defaults to 1Gi.
// +kubebuilder:default="1Gi"
// +optional
StorageSize string `json:"storageSize,omitempty"`
// Resources for the BIND container.
// +optional
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
// Service controls how the cluster is exposed.
// +optional
Service ClusterServiceSpec `json:"service,omitempty"`
// NodeSelector for the BIND pods.
// +optional
NodeSelector map[string]string `json:"nodeSelector,omitempty"`
// Tolerations for the BIND pods.
// +optional
Tolerations []corev1.Toleration `json:"tolerations,omitempty"`
// Affinity for the BIND pods.
// +optional
Affinity *corev1.Affinity `json:"affinity,omitempty"`
}
// BindClusterStatus reports observed cluster state.
type BindClusterStatus struct {
// Phase is a coarse lifecycle summary.
// +optional
Phase string `json:"phase,omitempty"`
// Replicas is the number of BIND pods requested.
// +optional
Replicas int32 `json:"replicas,omitempty"`
// ReadyReplicas is the number of BIND pods currently ready.
// +optional
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
// PrimaryPod is the pod that holds authoritative zone data (ordinal-0).
// +optional
PrimaryPod string `json:"primaryPod,omitempty"`
// PrimaryService is the in-cluster DNS name secondaries transfer from.
// +optional
PrimaryService string `json:"primaryService,omitempty"`
// ObservedGeneration is the last reconciled generation.
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// Conditions represent the latest available observations.
// +optional
// +listType=map
// +listMapKey=type
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:shortName=bc
// +kubebuilder:printcolumn:name="Mode",type=string,JSONPath=`.spec.mode`
// +kubebuilder:printcolumn:name="Desired",type=integer,JSONPath=`.spec.replicas`
// +kubebuilder:printcolumn:name="Ready",type=integer,JSONPath=`.status.readyReplicas`
// +kubebuilder:printcolumn:name="Primary",type=string,JSONPath=`.status.primaryPod`
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// BindCluster is a managed set of BIND9 servers.
type BindCluster struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec BindClusterSpec `json:"spec,omitempty"`
Status BindClusterStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// BindClusterList contains a list of BindCluster.
type BindClusterList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []BindCluster `json:"items"`
}
func init() {
SchemeBuilder.Register(&BindCluster{}, &BindClusterList{})
}
+111
View File
@@ -0,0 +1,111 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// DNSSECKey describes a key in a signing policy.
type DNSSECKey struct {
// Lifetime is how long the key is used before rollover, e.g. "P30D" or
// "unlimited". Empty means unlimited.
// +optional
Lifetime string `json:"lifetime,omitempty"`
// Algorithm overrides the policy algorithm for this key.
// +optional
Algorithm string `json:"algorithm,omitempty"`
// KeySize in bits for RSA algorithms (ignored for ECDSA/EdDSA).
// +optional
KeySize int32 `json:"keySize,omitempty"`
}
// BindDNSSECPolicySpec mirrors a BIND9 dnssec-policy. Zones referencing it are
// signed with inline-signing and automated key management.
type BindDNSSECPolicySpec struct {
// ClusterRef names the owning BindCluster.
ClusterRef string `json:"clusterRef"`
// PolicyName is the dnssec-policy name in named.conf. Defaults to the object
// name.
// +optional
PolicyName string `json:"policyName,omitempty"`
// Algorithm for signing. Defaults to ecdsap256sha256.
// +kubebuilder:default="ecdsap256sha256"
// +optional
Algorithm string `json:"algorithm,omitempty"`
// CSK, when set, uses a Combined Signing Key instead of split KSK/ZSK.
// +optional
CSK *DNSSECKey `json:"csk,omitempty"`
// KSK is the Key Signing Key configuration (ignored when CSK is set).
// +optional
KSK *DNSSECKey `json:"ksk,omitempty"`
// ZSK is the Zone Signing Key configuration (ignored when CSK is set).
// +optional
ZSK *DNSSECKey `json:"zsk,omitempty"`
// NSEC3 enables NSEC3 hashing instead of NSEC.
// +optional
NSEC3 bool `json:"nsec3,omitempty"`
// MaxZoneTTL, e.g. "P1D".
// +optional
MaxZoneTTL string `json:"maxZoneTTL,omitempty"`
// SignaturesValidity, e.g. "P14D".
// +optional
SignaturesValidity string `json:"signaturesValidity,omitempty"`
// ExtraOptions are raw named.conf lines appended inside the policy block.
// +optional
ExtraOptions []string `json:"extraOptions,omitempty"`
}
// BindDNSSECPolicyStatus reports observed policy state.
type BindDNSSECPolicyStatus struct {
// +optional
Ready bool `json:"ready,omitempty"`
// ZoneCount is the number of zones signed with this policy.
// +optional
ZoneCount int32 `json:"zoneCount,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=bdp
// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterRef`
// +kubebuilder:printcolumn:name="Algorithm",type=string,JSONPath=`.spec.algorithm`
// +kubebuilder:printcolumn:name="Zones",type=integer,JSONPath=`.status.zoneCount`
// +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=`.status.ready`
// BindDNSSECPolicy is a reusable DNSSEC signing policy.
type BindDNSSECPolicy struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec BindDNSSECPolicySpec `json:"spec,omitempty"`
Status BindDNSSECPolicyStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// BindDNSSECPolicyList contains a list of BindDNSSECPolicy.
type BindDNSSECPolicyList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []BindDNSSECPolicy `json:"items"`
}
func init() {
SchemeBuilder.Register(&BindDNSSECPolicy{}, &BindDNSSECPolicyList{})
}
+110
View File
@@ -0,0 +1,110 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// RPZTrigger is the match domain of a response-policy rule.
// +kubebuilder:validation:Enum=qname;client-ip;ip;nsdname;nsip
type RPZTrigger string
// RPZAction is the policy action taken on a match.
// +kubebuilder:validation:Enum=nxdomain;nodata;passthru;drop;tcp-only;cname
type RPZAction string
// RPZRule is a single response-policy rule.
type RPZRule struct {
// Trigger selects what the Match is compared against.
// +kubebuilder:default=qname
// +optional
Trigger RPZTrigger `json:"trigger,omitempty"`
// Match is the trigger value, e.g. a domain "bad.example." or CIDR.
Match string `json:"match"`
// Action taken when the rule matches.
// +kubebuilder:default=nxdomain
// +optional
Action RPZAction `json:"action,omitempty"`
// Target is the rewrite target when Action is cname.
// +optional
Target string `json:"target,omitempty"`
}
// BindPolicySpec defines a Response Policy Zone (RPZ) — a DNS firewall applied
// to a resolver cluster.
type BindPolicySpec struct {
// ClusterRef names the owning BindCluster (typically a resolver).
ClusterRef string `json:"clusterRef"`
// ViewRef optionally scopes the policy to a single view.
// +optional
ViewRef string `json:"viewRef,omitempty"`
// ZoneName is the RPZ zone origin, e.g. "rpz.internal".
ZoneName string `json:"zoneName"`
// Order controls this policy's position in the response-policy clause.
// +kubebuilder:default=100
// +optional
Order int32 `json:"order,omitempty"`
// Rules are the inline policy triggers.
// +optional
Rules []RPZRule `json:"rules,omitempty"`
// Primaries lets the RPZ zone be transferred from an external feed instead
// of being locally populated.
// +optional
Primaries []string `json:"primaries,omitempty"`
// TransferKeyRef names the BindTSIGKey used to pull from Primaries.
// +optional
TransferKeyRef string `json:"transferKeyRef,omitempty"`
}
// BindPolicyStatus reports observed policy state.
type BindPolicyStatus struct {
// +optional
Ready bool `json:"ready,omitempty"`
// RuleCount is the number of active rules.
// +optional
RuleCount int32 `json:"ruleCount,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=bp
// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterRef`
// +kubebuilder:printcolumn:name="Zone",type=string,JSONPath=`.spec.zoneName`
// +kubebuilder:printcolumn:name="Rules",type=integer,JSONPath=`.status.ruleCount`
// +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=`.status.ready`
// BindPolicy is a Response Policy Zone (RPZ) applied to a cluster.
type BindPolicy struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec BindPolicySpec `json:"spec,omitempty"`
Status BindPolicyStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// BindPolicyList contains a list of BindPolicy.
type BindPolicyList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []BindPolicy `json:"items"`
}
func init() {
SchemeBuilder.Register(&BindPolicy{}, &BindPolicyList{})
}
+92
View File
@@ -0,0 +1,92 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// TSIGAlgorithm is a supported TSIG HMAC algorithm.
// +kubebuilder:validation:Enum=hmac-sha256;hmac-sha512;hmac-sha384;hmac-sha224;hmac-sha1;hmac-md5
type TSIGAlgorithm string
const (
TSIGHMACSHA256 TSIGAlgorithm = "hmac-sha256"
TSIGHMACSHA512 TSIGAlgorithm = "hmac-sha512"
)
// 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 {
// Algorithm is the HMAC algorithm. Defaults to hmac-sha256.
// +kubebuilder:default="hmac-sha256"
// +optional
Algorithm TSIGAlgorithm `json:"algorithm,omitempty"`
// KeyName is the TSIG key name emitted into named.conf. Defaults to the
// object name.
// +optional
KeyName string `json:"keyName,omitempty"`
// SecretName is the Secret the key material is written to (or read from when
// ImportExisting is set). Defaults to "<name>-tsig".
// +optional
SecretName string `json:"secretName,omitempty"`
// ImportExisting, when true, means the referenced Secret already contains a
// `secret` key and the operator will not generate new material.
// +optional
ImportExisting bool `json:"importExisting,omitempty"`
}
// BindTSIGKeyStatus reports observed TSIG key state.
type BindTSIGKeyStatus struct {
// SecretName holds the generated/managed key material.
// +optional
SecretName string `json:"secretName,omitempty"`
// KeyName as used in named.conf.
// +optional
KeyName string `json:"keyName,omitempty"`
// Ready is true once the key Secret exists.
// +optional
Ready bool `json:"ready,omitempty"`
// ObservedGeneration is the last reconciled generation.
// +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=btk
// +kubebuilder:printcolumn:name="Algorithm",type=string,JSONPath=`.spec.algorithm`
// +kubebuilder:printcolumn:name="Secret",type=string,JSONPath=`.status.secretName`
// +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=`.status.ready`
// BindTSIGKey is a TSIG key backing zone transfers, dynamic updates and view
// matching. The key material lives in a Kubernetes Secret, never in the CR.
type BindTSIGKey struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec BindTSIGKeySpec `json:"spec,omitempty"`
Status BindTSIGKeyStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// BindTSIGKeyList contains a list of BindTSIGKey.
type BindTSIGKeyList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []BindTSIGKey `json:"items"`
}
func init() {
SchemeBuilder.Register(&BindTSIGKey{}, &BindTSIGKeyList{})
}
+84
View File
@@ -0,0 +1,84 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// BindViewSpec defines a split-horizon view. View ordering is significant in
// BIND; use Order to control the sequence in named.conf.
type BindViewSpec struct {
// ClusterRef names the owning BindCluster.
ClusterRef string `json:"clusterRef"`
// Order controls the position of this view in named.conf (ascending). The
// first view whose match-clients matches a query wins.
// +kubebuilder:default=100
// +optional
Order int32 `json:"order,omitempty"`
// MatchClients is an address-match-list (inline entries and/or ACL names)
// selecting which clients this view answers. Defaults to "any".
// +optional
MatchClients []string `json:"matchClients,omitempty"`
// MatchDestinations is an optional destination address-match-list.
// +optional
MatchDestinations []string `json:"matchDestinations,omitempty"`
// Recursion overrides the cluster recursion setting for this view.
// +optional
Recursion *bool `json:"recursion,omitempty"`
// AllowQuery is an address-match-list restricting queries into this view.
// +optional
AllowQuery []string `json:"allowQuery,omitempty"`
// ExtraOptions are raw named.conf lines appended inside the view block.
// +optional
ExtraOptions []string `json:"extraOptions,omitempty"`
}
// BindViewStatus reports observed view state.
type BindViewStatus struct {
// +optional
Ready bool `json:"ready,omitempty"`
// ZoneCount is the number of zones currently bound to this view.
// +optional
ZoneCount int32 `json:"zoneCount,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=bv
// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterRef`
// +kubebuilder:printcolumn:name="Order",type=integer,JSONPath=`.spec.order`
// +kubebuilder:printcolumn:name="Zones",type=integer,JSONPath=`.status.zoneCount`
// +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=`.status.ready`
// BindView is a split-horizon view on a BindCluster.
type BindView struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec BindViewSpec `json:"spec,omitempty"`
Status BindViewStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// BindViewList contains a list of BindView.
type BindViewList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []BindView `json:"items"`
}
func init() {
SchemeBuilder.Register(&BindView{}, &BindViewList{})
}
+156
View File
@@ -0,0 +1,156 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// ZoneType is the BIND zone type.
// +kubebuilder:validation:Enum=primary;secondary;forward;stub
type ZoneType string
const (
ZonePrimary ZoneType = "primary"
ZoneSecondary ZoneType = "secondary"
ZoneForward ZoneType = "forward"
ZoneStub ZoneType = "stub"
)
// Record is a single resource record set seeded into a primary zone via
// dynamic update (nsupdate). Ongoing changes may also arrive from DNSRecord
// objects or external RFC2136 clients.
type Record struct {
// Name is the owner name, relative to the zone apex or fully qualified.
// Use "@" for the apex.
// +kubebuilder:default="@"
// +optional
Name string `json:"name,omitempty"`
// Type is the RR type, e.g. A, AAAA, CNAME, MX, TXT, SRV, NS, PTR, CAA.
Type string `json:"type"`
// TTL for the record set in seconds. Falls back to the zone default TTL.
// +optional
TTL *int32 `json:"ttl,omitempty"`
// Values are the RDATA entries, e.g. ["10 mail.example.com."] for an MX or
// ["192.0.2.1","192.0.2.2"] for an A round-robin.
// +kubebuilder:validation:MinItems=1
Values []string `json:"values"`
}
// BindZoneSpec defines a DNS zone managed on a BindCluster's primary.
type BindZoneSpec struct {
// ClusterRef names the owning BindCluster.
ClusterRef string `json:"clusterRef"`
// ViewRef optionally binds this zone to a BindView.
// +optional
ViewRef string `json:"viewRef,omitempty"`
// ZoneName is the DNS origin, e.g. "example.com" or "2.0.192.in-addr.arpa".
ZoneName string `json:"zoneName"`
// Type is the zone type. Defaults to primary.
// +kubebuilder:default=primary
// +optional
Type ZoneType `json:"type,omitempty"`
// DefaultTTL for records that do not set their own TTL. Defaults to 3600.
// +kubebuilder:default=3600
// +optional
DefaultTTL int32 `json:"defaultTTL,omitempty"`
// Records are static record sets seeded into a primary zone.
// +optional
Records []Record `json:"records,omitempty"`
// DynamicUpdate enables RFC2136 updates for this zone (external-dns style).
// When true, UpdateKeyRef must reference a BindTSIGKey.
// +optional
DynamicUpdate bool `json:"dynamicUpdate,omitempty"`
// UpdateKeyRef names the BindTSIGKey permitted to send dynamic updates.
// +optional
UpdateKeyRef string `json:"updateKeyRef,omitempty"`
// AllowTransfer is an address-match-list (inline entries and/or ACL/key
// names) permitted to AXFR/IXFR this zone.
// +optional
AllowTransfer []string `json:"allowTransfer,omitempty"`
// Catalog, when true, registers this zone as a member of the cluster's
// catalog zone so secondaries auto-provision it.
// +kubebuilder:default=true
// +optional
Catalog *bool `json:"catalog,omitempty"`
// DNSSECPolicyRef names a BindDNSSECPolicy to sign this zone with.
// +optional
DNSSECPolicyRef string `json:"dnssecPolicyRef,omitempty"`
// Forwarders lists upstreams for a forward-type zone.
// +optional
Forwarders []string `json:"forwarders,omitempty"`
// Primaries lists source servers for a secondary/stub-type zone.
// +optional
Primaries []string `json:"primaries,omitempty"`
// TransferKeyRef names the BindTSIGKey used to authenticate transfers from
// Primaries for a secondary zone.
// +optional
TransferKeyRef string `json:"transferKeyRef,omitempty"`
}
// BindZoneStatus reports observed zone state.
type BindZoneStatus struct {
// Phase is a coarse lifecycle summary (Pending/Ready/Error).
// +optional
Phase string `json:"phase,omitempty"`
// Serial is the last observed SOA serial on the primary.
// +optional
Serial int64 `json:"serial,omitempty"`
// RecordCount is the number of managed record sets applied.
// +optional
RecordCount int32 `json:"recordCount,omitempty"`
// Signed reports whether DNSSEC signing is active.
// +optional
Signed bool `json:"signed,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=bz
// +kubebuilder:printcolumn:name="Zone",type=string,JSONPath=`.spec.zoneName`
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterRef`
// +kubebuilder:printcolumn:name="Serial",type=integer,JSONPath=`.status.serial`
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// BindZone is a forward or reverse DNS zone.
type BindZone struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec BindZoneSpec `json:"spec,omitempty"`
Status BindZoneStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// BindZoneList contains a list of BindZone.
type BindZoneList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []BindZone `json:"items"`
}
func init() {
SchemeBuilder.Register(&BindZone{}, &BindZoneList{})
}
+75
View File
@@ -0,0 +1,75 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// DNSRecordSpec defines a single record set applied to a zone via TSIG dynamic
// update (nsupdate) — the external-dns write path expressed as a CRD.
type DNSRecordSpec struct {
// ZoneRef names the BindZone this record belongs to. The cluster, view and
// update key are derived from the referenced zone.
ZoneRef string `json:"zoneRef"`
// Name is the owner name, relative to the zone apex or fully qualified.
// +kubebuilder:default="@"
// +optional
Name string `json:"name,omitempty"`
// Type is the RR type, e.g. A, AAAA, CNAME, TXT, SRV, MX.
Type string `json:"type"`
// TTL for the record set in seconds. Falls back to the zone default TTL.
// +optional
TTL *int32 `json:"ttl,omitempty"`
// Values are the RDATA entries.
// +kubebuilder:validation:MinItems=1
Values []string `json:"values"`
}
// DNSRecordStatus reports observed record state.
type DNSRecordStatus struct {
// Phase is a coarse lifecycle summary (Pending/Applied/Error).
// +optional
Phase string `json:"phase,omitempty"`
// FQDN is the fully-qualified owner name that was applied.
// +optional
FQDN string `json:"fqdn,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=dnsr
// +kubebuilder:printcolumn:name="Zone",type=string,JSONPath=`.spec.zoneRef`
// +kubebuilder:printcolumn:name="Name",type=string,JSONPath=`.spec.name`
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// DNSRecord is an individually-managed record set applied to a BindZone.
type DNSRecord struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec DNSRecordSpec `json:"spec,omitempty"`
Status DNSRecordStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// DNSRecordList contains a list of DNSRecord.
type DNSRecordList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []DNSRecord `json:"items"`
}
func init() {
SchemeBuilder.Register(&DNSRecord{}, &DNSRecordList{})
}
+3
View File
@@ -0,0 +1,3 @@
// +kubebuilder:object:generate=true
// +groupName=bind.unkin.net
package v1alpha1
+17
View File
@@ -0,0 +1,17 @@
package v1alpha1
import (
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/scheme"
)
var (
// GroupVersion is the group/version used to register these objects.
GroupVersion = schema.GroupVersion{Group: "bind.unkin.net", Version: "v1alpha1"}
// SchemeBuilder registers the API types with a runtime scheme.
SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
// AddToScheme adds the types in this group-version to the given scheme.
AddToScheme = SchemeBuilder.AddToScheme
)
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"flag"
"os"
"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/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/bind"
"git.unkin.net/unkin/bind-operator/internal/controller"
)
var scheme = runtime.NewScheme()
func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(bindv1alpha1.AddToScheme(scheme))
}
func main() {
var metricsAddr, probeAddr string
var leaderElect bool
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "metrics endpoint address")
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "health probe address")
flag.BoolVar(&leaderElect, "leader-elect", false, "enable leader election")
flag.Parse()
ctrl.SetLogger(zap.New(zap.UseDevMode(false)))
logger := ctrl.Log.WithName("setup")
restConfig := ctrl.GetConfigOrDie()
mgr, err := ctrl.NewManager(restConfig, ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{BindAddress: metricsAddr},
HealthProbeBindAddress: probeAddr,
LeaderElection: leaderElect,
LeaderElectionID: "bind-operator",
})
if err != nil {
logger.Error(err, "unable to create manager")
os.Exit(1)
}
executor, err := bind.NewExecutor(restConfig)
if err != nil {
logger.Error(err, "unable to create pod executor")
os.Exit(1)
}
if err := controller.SetupAll(mgr, executor); err != nil {
logger.Error(err, "unable to set up controllers")
os.Exit(1)
}
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
logger.Error(err, "unable to set up health check")
os.Exit(1)
}
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
logger.Error(err, "unable to set up ready check")
os.Exit(1)
}
logger.Info("starting bind-operator")
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
logger.Error(err, "manager exited with error")
os.Exit(1)
}
}
@@ -0,0 +1,145 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.3
name: bindacls.bind.unkin.net
spec:
group: bind.unkin.net
names:
kind: BindACL
listKind: BindACLList
plural: bindacls
shortNames:
- bacl
singular: bindacl
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.clusterRef
name: Cluster
type: string
- jsonPath: .spec.entries[*]
name: Entries
type: integer
- jsonPath: .status.ready
name: Ready
type: boolean
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
BindACL is a named address-match-list referenced by views, zones and
policies for match-clients / allow-query / allow-transfer / allow-update.
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: BindACLSpec defines a reusable named address_match_list.
properties:
clusterRef:
description: |-
ClusterRef names the BindCluster whose named.conf this ACL is rendered
into. When empty the ACL is available to every cluster in the namespace.
type: string
entries:
description: |-
Entries are raw BIND address-match-list elements, e.g. "10.0.0.0/8",
"!192.168.1.5", "key transfer-key", "localhost", "any", or the name of
another ACL.
items:
type: string
minItems: 1
type: array
required:
- entries
type: object
status:
description: BindACLStatus reports observed ACL 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
observedGeneration:
format: int64
type: integer
ready:
type: boolean
type: object
type: object
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,159 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.3
name: bindcatalogzones.bind.unkin.net
spec:
group: bind.unkin.net
names:
kind: BindCatalogZone
listKind: BindCatalogZoneList
plural: bindcatalogzones
shortNames:
- bcz
singular: bindcatalogzone
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.clusterRef
name: Cluster
type: string
- jsonPath: .spec.zoneName
name: Zone
type: string
- jsonPath: .status.memberCount
name: Members
type: integer
- jsonPath: .status.ready
name: Ready
type: boolean
name: v1alpha1
schema:
openAPIV3Schema:
description: BindCatalogZone auto-provisions member zones onto cluster secondaries.
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: |-
BindCatalogZoneSpec defines a BIND9 catalog zone. The primary publishes it
and secondaries consume it, so member zones are provisioned onto every
secondary automatically without per-zone reconfiguration.
properties:
clusterRef:
description: ClusterRef names the owning BindCluster.
type: string
defaultPrimaries:
description: |-
DefaultPrimaries are the addresses member zones point at on secondaries.
Defaults to the cluster primary Service.
items:
type: string
type: array
transferKeyRef:
description: |-
TransferKeyRef names the BindTSIGKey authenticating catalog + member zone
transfers to secondaries.
type: string
zoneName:
description: ZoneName is the catalog zone's own origin, e.g. "catalog.internal".
type: string
required:
- clusterRef
- zoneName
type: object
status:
description: BindCatalogZoneStatus reports observed catalog 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
memberCount:
description: MemberCount is the number of member zones registered
in the catalog.
format: int32
type: integer
observedGeneration:
format: int64
type: integer
ready:
type: boolean
type: object
type: object
served: true
storage: true
subresources:
status: {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,219 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.3
name: binddnssecpolicies.bind.unkin.net
spec:
group: bind.unkin.net
names:
kind: BindDNSSECPolicy
listKind: BindDNSSECPolicyList
plural: binddnssecpolicies
shortNames:
- bdp
singular: binddnssecpolicy
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.clusterRef
name: Cluster
type: string
- jsonPath: .spec.algorithm
name: Algorithm
type: string
- jsonPath: .status.zoneCount
name: Zones
type: integer
- jsonPath: .status.ready
name: Ready
type: boolean
name: v1alpha1
schema:
openAPIV3Schema:
description: BindDNSSECPolicy is a reusable DNSSEC signing policy.
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: |-
BindDNSSECPolicySpec mirrors a BIND9 dnssec-policy. Zones referencing it are
signed with inline-signing and automated key management.
properties:
algorithm:
default: ecdsap256sha256
description: Algorithm for signing. Defaults to ecdsap256sha256.
type: string
clusterRef:
description: ClusterRef names the owning BindCluster.
type: string
csk:
description: CSK, when set, uses a Combined Signing Key instead of
split KSK/ZSK.
properties:
algorithm:
description: Algorithm overrides the policy algorithm for this
key.
type: string
keySize:
description: KeySize in bits for RSA algorithms (ignored for ECDSA/EdDSA).
format: int32
type: integer
lifetime:
description: |-
Lifetime is how long the key is used before rollover, e.g. "P30D" or
"unlimited". Empty means unlimited.
type: string
type: object
extraOptions:
description: ExtraOptions are raw named.conf lines appended inside
the policy block.
items:
type: string
type: array
ksk:
description: KSK is the Key Signing Key configuration (ignored when
CSK is set).
properties:
algorithm:
description: Algorithm overrides the policy algorithm for this
key.
type: string
keySize:
description: KeySize in bits for RSA algorithms (ignored for ECDSA/EdDSA).
format: int32
type: integer
lifetime:
description: |-
Lifetime is how long the key is used before rollover, e.g. "P30D" or
"unlimited". Empty means unlimited.
type: string
type: object
maxZoneTTL:
description: MaxZoneTTL, e.g. "P1D".
type: string
nsec3:
description: NSEC3 enables NSEC3 hashing instead of NSEC.
type: boolean
policyName:
description: |-
PolicyName is the dnssec-policy name in named.conf. Defaults to the object
name.
type: string
signaturesValidity:
description: SignaturesValidity, e.g. "P14D".
type: string
zsk:
description: ZSK is the Zone Signing Key configuration (ignored when
CSK is set).
properties:
algorithm:
description: Algorithm overrides the policy algorithm for this
key.
type: string
keySize:
description: KeySize in bits for RSA algorithms (ignored for ECDSA/EdDSA).
format: int32
type: integer
lifetime:
description: |-
Lifetime is how long the key is used before rollover, e.g. "P30D" or
"unlimited". Empty means unlimited.
type: string
type: object
required:
- clusterRef
type: object
status:
description: BindDNSSECPolicyStatus reports observed policy 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
observedGeneration:
format: int64
type: integer
ready:
type: boolean
zoneCount:
description: ZoneCount is the number of zones signed with this policy.
format: int32
type: integer
type: object
type: object
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,203 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.3
name: bindpolicies.bind.unkin.net
spec:
group: bind.unkin.net
names:
kind: BindPolicy
listKind: BindPolicyList
plural: bindpolicies
shortNames:
- bp
singular: bindpolicy
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.clusterRef
name: Cluster
type: string
- jsonPath: .spec.zoneName
name: Zone
type: string
- jsonPath: .status.ruleCount
name: Rules
type: integer
- jsonPath: .status.ready
name: Ready
type: boolean
name: v1alpha1
schema:
openAPIV3Schema:
description: BindPolicy is a Response Policy Zone (RPZ) applied to a cluster.
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: |-
BindPolicySpec defines a Response Policy Zone (RPZ) — a DNS firewall applied
to a resolver cluster.
properties:
clusterRef:
description: ClusterRef names the owning BindCluster (typically a
resolver).
type: string
order:
default: 100
description: Order controls this policy's position in the response-policy
clause.
format: int32
type: integer
primaries:
description: |-
Primaries lets the RPZ zone be transferred from an external feed instead
of being locally populated.
items:
type: string
type: array
rules:
description: Rules are the inline policy triggers.
items:
description: RPZRule is a single response-policy rule.
properties:
action:
default: nxdomain
description: Action taken when the rule matches.
enum:
- nxdomain
- nodata
- passthru
- drop
- tcp-only
- cname
type: string
match:
description: Match is the trigger value, e.g. a domain "bad.example."
or CIDR.
type: string
target:
description: Target is the rewrite target when Action is cname.
type: string
trigger:
default: qname
description: Trigger selects what the Match is compared against.
enum:
- qname
- client-ip
- ip
- nsdname
- nsip
type: string
required:
- match
type: object
type: array
transferKeyRef:
description: TransferKeyRef names the BindTSIGKey used to pull from
Primaries.
type: string
viewRef:
description: ViewRef optionally scopes the policy to a single view.
type: string
zoneName:
description: ZoneName is the RPZ zone origin, e.g. "rpz.internal".
type: string
required:
- clusterRef
- zoneName
type: object
status:
description: BindPolicyStatus reports observed policy 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
observedGeneration:
format: int64
type: integer
ready:
type: boolean
ruleCount:
description: RuleCount is the number of active rules.
format: int32
type: integer
type: object
type: object
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,165 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.3
name: bindtsigkeys.bind.unkin.net
spec:
group: bind.unkin.net
names:
kind: BindTSIGKey
listKind: BindTSIGKeyList
plural: bindtsigkeys
shortNames:
- btk
singular: bindtsigkey
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.algorithm
name: Algorithm
type: string
- jsonPath: .status.secretName
name: Secret
type: string
- jsonPath: .status.ready
name: Ready
type: boolean
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
BindTSIGKey is a TSIG key backing zone transfers, dynamic updates and view
matching. The key material lives in a Kubernetes Secret, never in the CR.
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: |-
BindTSIGKeySpec defines a TSIG key. If no existing key material is imported,
the operator generates a random key and stores it in a Secret.
properties:
algorithm:
default: hmac-sha256
description: Algorithm is the HMAC algorithm. Defaults to hmac-sha256.
enum:
- hmac-sha256
- hmac-sha512
- hmac-sha384
- hmac-sha224
- hmac-sha1
- hmac-md5
type: string
importExisting:
description: |-
ImportExisting, when true, means the referenced Secret already contains a
`secret` key and the operator will not generate new material.
type: boolean
keyName:
description: |-
KeyName is the TSIG key name emitted into named.conf. Defaults to the
object name.
type: string
secretName:
description: |-
SecretName is the Secret the key material is written to (or read from when
ImportExisting is set). Defaults to "<name>-tsig".
type: string
type: object
status:
description: BindTSIGKeyStatus reports observed TSIG key 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
keyName:
description: KeyName as used in named.conf.
type: string
observedGeneration:
description: ObservedGeneration is the last reconciled generation.
format: int64
type: integer
ready:
description: Ready is true once the key Secret exists.
type: boolean
secretName:
description: SecretName holds the generated/managed key material.
type: string
type: object
type: object
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,177 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.3
name: bindviews.bind.unkin.net
spec:
group: bind.unkin.net
names:
kind: BindView
listKind: BindViewList
plural: bindviews
shortNames:
- bv
singular: bindview
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.clusterRef
name: Cluster
type: string
- jsonPath: .spec.order
name: Order
type: integer
- jsonPath: .status.zoneCount
name: Zones
type: integer
- jsonPath: .status.ready
name: Ready
type: boolean
name: v1alpha1
schema:
openAPIV3Schema:
description: BindView is a split-horizon view on a BindCluster.
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: |-
BindViewSpec defines a split-horizon view. View ordering is significant in
BIND; use Order to control the sequence in named.conf.
properties:
allowQuery:
description: AllowQuery is an address-match-list restricting queries
into this view.
items:
type: string
type: array
clusterRef:
description: ClusterRef names the owning BindCluster.
type: string
extraOptions:
description: ExtraOptions are raw named.conf lines appended inside
the view block.
items:
type: string
type: array
matchClients:
description: |-
MatchClients is an address-match-list (inline entries and/or ACL names)
selecting which clients this view answers. Defaults to "any".
items:
type: string
type: array
matchDestinations:
description: MatchDestinations is an optional destination address-match-list.
items:
type: string
type: array
order:
default: 100
description: |-
Order controls the position of this view in named.conf (ascending). The
first view whose match-clients matches a query wins.
format: int32
type: integer
recursion:
description: Recursion overrides the cluster recursion setting for
this view.
type: boolean
required:
- clusterRef
type: object
status:
description: BindViewStatus reports observed view 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
observedGeneration:
format: int64
type: integer
ready:
type: boolean
zoneCount:
description: ZoneCount is the number of zones currently bound to this
view.
format: int32
type: integer
type: object
type: object
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,252 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.3
name: bindzones.bind.unkin.net
spec:
group: bind.unkin.net
names:
kind: BindZone
listKind: BindZoneList
plural: bindzones
shortNames:
- bz
singular: bindzone
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.zoneName
name: Zone
type: string
- jsonPath: .spec.type
name: Type
type: string
- jsonPath: .spec.clusterRef
name: Cluster
type: string
- jsonPath: .status.serial
name: Serial
type: integer
- jsonPath: .status.phase
name: Phase
type: string
name: v1alpha1
schema:
openAPIV3Schema:
description: BindZone is a forward or reverse DNS zone.
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: BindZoneSpec defines a DNS zone managed on a BindCluster's
primary.
properties:
allowTransfer:
description: |-
AllowTransfer is an address-match-list (inline entries and/or ACL/key
names) permitted to AXFR/IXFR this zone.
items:
type: string
type: array
catalog:
default: true
description: |-
Catalog, when true, registers this zone as a member of the cluster's
catalog zone so secondaries auto-provision it.
type: boolean
clusterRef:
description: ClusterRef names the owning BindCluster.
type: string
defaultTTL:
default: 3600
description: DefaultTTL for records that do not set their own TTL.
Defaults to 3600.
format: int32
type: integer
dnssecPolicyRef:
description: DNSSECPolicyRef names a BindDNSSECPolicy to sign this
zone with.
type: string
dynamicUpdate:
description: |-
DynamicUpdate enables RFC2136 updates for this zone (external-dns style).
When true, UpdateKeyRef must reference a BindTSIGKey.
type: boolean
forwarders:
description: Forwarders lists upstreams for a forward-type zone.
items:
type: string
type: array
primaries:
description: Primaries lists source servers for a secondary/stub-type
zone.
items:
type: string
type: array
records:
description: Records are static record sets seeded into a primary
zone.
items:
description: |-
Record is a single resource record set seeded into a primary zone via
dynamic update (nsupdate). Ongoing changes may also arrive from DNSRecord
objects or external RFC2136 clients.
properties:
name:
default: '@'
description: |-
Name is the owner name, relative to the zone apex or fully qualified.
Use "@" for the apex.
type: string
ttl:
description: TTL for the record set in seconds. Falls back to
the zone default TTL.
format: int32
type: integer
type:
description: Type is the RR type, e.g. A, AAAA, CNAME, MX, TXT,
SRV, NS, PTR, CAA.
type: string
values:
description: |-
Values are the RDATA entries, e.g. ["10 mail.example.com."] for an MX or
["192.0.2.1","192.0.2.2"] for an A round-robin.
items:
type: string
minItems: 1
type: array
required:
- type
- values
type: object
type: array
transferKeyRef:
description: |-
TransferKeyRef names the BindTSIGKey used to authenticate transfers from
Primaries for a secondary zone.
type: string
type:
default: primary
description: Type is the zone type. Defaults to primary.
enum:
- primary
- secondary
- forward
- stub
type: string
updateKeyRef:
description: UpdateKeyRef names the BindTSIGKey permitted to send
dynamic updates.
type: string
viewRef:
description: ViewRef optionally binds this zone to a BindView.
type: string
zoneName:
description: ZoneName is the DNS origin, e.g. "example.com" or "2.0.192.in-addr.arpa".
type: string
required:
- clusterRef
- zoneName
type: object
status:
description: BindZoneStatus reports observed zone 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
observedGeneration:
format: int64
type: integer
phase:
description: Phase is a coarse lifecycle summary (Pending/Ready/Error).
type: string
recordCount:
description: RecordCount is the number of managed record sets applied.
format: int32
type: integer
serial:
description: Serial is the last observed SOA serial on the primary.
format: int64
type: integer
signed:
description: Signed reports whether DNSSEC signing is active.
type: boolean
type: object
type: object
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,165 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.3
name: dnsrecords.bind.unkin.net
spec:
group: bind.unkin.net
names:
kind: DNSRecord
listKind: DNSRecordList
plural: dnsrecords
shortNames:
- dnsr
singular: dnsrecord
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.zoneRef
name: Zone
type: string
- jsonPath: .spec.name
name: Name
type: string
- jsonPath: .spec.type
name: Type
type: string
- jsonPath: .status.phase
name: Phase
type: string
name: v1alpha1
schema:
openAPIV3Schema:
description: DNSRecord is an individually-managed record set applied to a
BindZone.
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: |-
DNSRecordSpec defines a single record set applied to a zone via TSIG dynamic
update (nsupdate) — the external-dns write path expressed as a CRD.
properties:
name:
default: '@'
description: Name is the owner name, relative to the zone apex or
fully qualified.
type: string
ttl:
description: TTL for the record set in seconds. Falls back to the
zone default TTL.
format: int32
type: integer
type:
description: Type is the RR type, e.g. A, AAAA, CNAME, TXT, SRV, MX.
type: string
values:
description: Values are the RDATA entries.
items:
type: string
minItems: 1
type: array
zoneRef:
description: |-
ZoneRef names the BindZone this record belongs to. The cluster, view and
update key are derived from the referenced zone.
type: string
required:
- type
- values
- zoneRef
type: object
status:
description: DNSRecordStatus reports observed record 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
fqdn:
description: FQDN is the fully-qualified owner name that was applied.
type: string
observedGeneration:
format: int64
type: integer
phase:
description: Phase is a coarse lifecycle summary (Pending/Applied/Error).
type: string
type: object
type: object
served: true
storage: true
subresources:
status: {}
+83
View File
@@ -0,0 +1,83 @@
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: bind-operator
rules:
- apiGroups:
- ""
resources:
- configmaps
- secrets
- services
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- ""
resources:
- pods
verbs:
- get
- list
- watch
- apiGroups:
- ""
resources:
- pods/exec
verbs:
- create
- get
- apiGroups:
- apps
resources:
- statefulsets
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- bind.unkin.net
resources:
- bindacls
- bindcatalogzones
- bindclusters
- binddnssecpolicies
- bindpolicies
- bindtsigkeys
- bindviews
- bindzones
- dnsrecords
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- bind.unkin.net
resources:
- bindacls/status
- bindcatalogzones/status
- bindclusters/status
- binddnssecpolicies/status
- bindpolicies/status
- bindtsigkeys/status
- bindviews/status
- bindzones/status
- dnsrecords/status
verbs:
- get
- patch
- update
+21
View File
@@ -0,0 +1,21 @@
---
# TSIG key used to authenticate zone transfers between primary and secondaries
# (and catalog zone transfers). The operator generates the material into a
# Secret named <name>-tsig; the key never appears in the CR.
apiVersion: bind.unkin.net/v1alpha1
kind: BindTSIGKey
metadata:
name: transfer-key
namespace: bind-auth
spec:
algorithm: hmac-sha256
---
# TSIG key permitting external-dns (and DNSRecord objects) to send RFC2136
# dynamic updates to the dynamic cluster's primary.
apiVersion: bind.unkin.net/v1alpha1
kind: BindTSIGKey
metadata:
name: externaldns-key
namespace: bind-externaldns
spec:
algorithm: hmac-sha256
+102
View File
@@ -0,0 +1,102 @@
---
# Authoritative masters role (replaces 3x Puppet authoritative servers).
# Ordinal-0 is the primary holding zone data; the other two replicate via the
# catalog zone + AXFR/IXFR.
apiVersion: bind.unkin.net/v1alpha1
kind: BindCluster
metadata:
name: auth
namespace: bind-auth
spec:
mode: authoritative
replicas: 3
storageSize: 2Gi
service:
type: LoadBalancer
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
---
apiVersion: bind.unkin.net/v1alpha1
kind: BindACL
metadata:
name: internal-nets
namespace: bind-auth
spec:
clusterRef: auth
entries:
- 10.0.0.0/8
- 192.168.0.0/16
---
# Catalog zone: new BindZones are auto-provisioned onto the secondaries.
apiVersion: bind.unkin.net/v1alpha1
kind: BindCatalogZone
metadata:
name: auth-catalog
namespace: bind-auth
spec:
clusterRef: auth
zoneName: catalog.internal
transferKeyRef: transfer-key
---
apiVersion: bind.unkin.net/v1alpha1
kind: BindDNSSECPolicy
metadata:
name: standard
namespace: bind-auth
spec:
clusterRef: auth
algorithm: ecdsap256sha256
nsec3: true
csk:
lifetime: unlimited
---
# Forward zone (signed) with a couple of seeded records.
apiVersion: bind.unkin.net/v1alpha1
kind: BindZone
metadata:
name: example-internal
namespace: bind-auth
spec:
clusterRef: auth
zoneName: internal.example.com
type: primary
defaultTTL: 3600
dnssecPolicyRef: standard
allowTransfer:
- key transfer-key
updateKeyRef: transfer-key
dynamicUpdate: true
records:
- name: "@"
type: NS
values: ["ns1.internal.example.com."]
- name: ns1
type: A
values: ["10.0.0.53"]
- name: www
type: A
values: ["10.0.1.10", "10.0.1.11"]
---
# Reverse zone for 10.0.0.0/16.
apiVersion: bind.unkin.net/v1alpha1
kind: BindZone
metadata:
name: reverse-10-0
namespace: bind-auth
spec:
clusterRef: auth
zoneName: 0.10.in-addr.arpa
type: primary
updateKeyRef: transfer-key
dynamicUpdate: true
allowTransfer:
- key transfer-key
records:
- name: "53.0"
type: PTR
values: ["ns1.internal.example.com."]
+50
View File
@@ -0,0 +1,50 @@
---
# Recursive resolvers role (replaces 3x Puppet only-resolver servers).
# All three pods are identical recursive servers; no zone replication.
apiVersion: bind.unkin.net/v1alpha1
kind: BindCluster
metadata:
name: resolver
namespace: bind-resolver
spec:
mode: resolver
replicas: 3
service:
type: LoadBalancer
forwarders:
- 1.1.1.1
- 9.9.9.9
---
# Conditional forwarding of an internal zone to the authoritative cluster.
apiVersion: bind.unkin.net/v1alpha1
kind: BindZone
metadata:
name: forward-internal
namespace: bind-resolver
spec:
clusterRef: resolver
zoneName: internal.example.com
type: forward
catalog: false
forwarders:
- 10.0.0.53
---
# DNS firewall (RPZ) blocklist applied to the resolvers.
apiVersion: bind.unkin.net/v1alpha1
kind: BindPolicy
metadata:
name: blocklist
namespace: bind-resolver
spec:
clusterRef: resolver
zoneName: rpz.internal
order: 10
transferKeyRef: transfer-key
rules:
- trigger: qname
match: malware.example.
action: nxdomain
- trigger: qname
match: tracker.example.
action: cname
target: blocked.internal.example.com
+43
View File
@@ -0,0 +1,43 @@
---
# external-dns role (replaces 3x Puppet external-dns servers). The primary
# accepts RFC2136 TSIG updates from external-dns; secondaries replicate.
apiVersion: bind.unkin.net/v1alpha1
kind: BindCluster
metadata:
name: externaldns
namespace: bind-externaldns
spec:
mode: dynamic
replicas: 3
service:
type: LoadBalancer
---
# Public zone that external-dns writes into via nsupdate/TSIG.
apiVersion: bind.unkin.net/v1alpha1
kind: BindZone
metadata:
name: example-com
namespace: bind-externaldns
spec:
clusterRef: externaldns
zoneName: example.com
type: primary
dynamicUpdate: true
updateKeyRef: externaldns-key
allowTransfer:
- key externaldns-key
---
# A record managed as a CRD (external-dns-style) instead of via the RFC2136
# controller — same write path (TSIG nsupdate to the primary).
apiVersion: bind.unkin.net/v1alpha1
kind: DNSRecord
metadata:
name: www-example-com
namespace: bind-externaldns
spec:
zoneRef: example-com
name: www
type: A
ttl: 300
values:
- 203.0.113.10
+26
View File
@@ -0,0 +1,26 @@
---
# Split-horizon example: an internal view answering RFC1918 clients and a
# default external view. Bind a zone to a view via BindZone.spec.viewRef.
apiVersion: bind.unkin.net/v1alpha1
kind: BindView
metadata:
name: internal
namespace: bind-auth
spec:
clusterRef: auth
order: 10
matchClients:
- internal-nets
recursion: false
---
apiVersion: bind.unkin.net/v1alpha1
kind: BindView
metadata:
name: external
namespace: bind-auth
spec:
clusterRef: auth
order: 100
matchClients:
- any
recursion: false
+71
View File
@@ -0,0 +1,71 @@
module git.unkin.net/unkin/bind-operator
go 1.25
require (
k8s.io/api v0.34.4
k8s.io/apimachinery v0.34.4
k8s.io/client-go v0.34.4
sigs.k8s.io/controller-runtime v0.22.4
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/fsnotify/fsnotify v1.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-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/moby/spdystream v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/term v0.30.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/time v0.9.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/apiextensions-apiserver v0.34.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
+206
View File
@@ -0,0 +1,206 @@
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg=
github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw=
github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.34.4 h1:Z5hsoQcZ2yBjelb9j5JKzCVo9qv9XLkVm5llnqS4h+0=
k8s.io/api v0.34.4/go.mod h1:6SaGYuGPkMqqCgg8rPG/OQoCrhgSEV+wWn9v21fDP3o=
k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI=
k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc=
k8s.io/apimachinery v0.34.4 h1:C5SiSzLEMyWIk53sSbnk0WlOOyqv/MFnWvuc/d6M+xc=
k8s.io/apimachinery v0.34.4/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/client-go v0.34.4 h1:IXhvzFdm0e897kXtLbeyMpAGzontcShJ/gi/XCCsOLc=
k8s.io/client-go v0.34.4/go.mod h1:tXIVJTQabT5QRGlFdxZQFxrIhcGUPpKL5DAc4gSWTE8=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A=
sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+5
View File
@@ -0,0 +1,5 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: bind-operator-system
@@ -0,0 +1,51 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: bind-operator
namespace: bind-operator-system
labels:
app: bind-operator
spec:
replicas: 1
selector:
matchLabels:
app: bind-operator
template:
metadata:
labels:
app: bind-operator
spec:
serviceAccountName: bind-operator
containers:
- name: operator
image: bind-operator:dev
imagePullPolicy: Never
args:
- --metrics-bind-address=:8080
- --health-probe-bind-address=:8081
- --leader-elect
ports:
- containerPort: 8080
name: metrics
- containerPort: 8081
name: health
readinessProbe:
httpGet:
path: /readyz
port: 8081
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 8081
initialDelaySeconds: 15
periodSeconds: 20
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
+46
View File
@@ -0,0 +1,46 @@
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: bind-operator
namespace: bind-operator-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: bind-operator
rules:
- apiGroups: ["bind.unkin.net"]
resources: ["*"]
verbs: ["*"]
- apiGroups: [""]
resources: ["services", "configmaps", "secrets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/exec"]
verbs: ["create", "get"]
- apiGroups: ["apps"]
resources: ["statefulsets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: bind-operator
subjects:
- kind: ServiceAccount
name: bind-operator
namespace: bind-operator-system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: bind-operator
+66
View File
@@ -0,0 +1,66 @@
// Package bind contains helpers for driving BIND9 pods: executing rndc and
// nsupdate over the Kubernetes exec subresource, and rendering named.conf.
package bind
import (
"bytes"
"context"
"fmt"
corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/remotecommand"
)
// ContainerName is the BIND container name within each pod.
const ContainerName = "bind"
// Executor runs commands inside BIND pods via the exec subresource.
type Executor struct {
config *rest.Config
clientset kubernetes.Interface
}
// NewExecutor builds an Executor from a controller-runtime rest config.
func NewExecutor(cfg *rest.Config) (*Executor, error) {
cs, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, fmt.Errorf("build clientset: %w", err)
}
return &Executor{config: cfg, clientset: cs}, nil
}
// Exec runs command in the BIND container of pod, optionally feeding stdin, and
// returns stdout. A non-zero exit or transport error yields an error that
// includes stderr.
func (e *Executor) Exec(ctx context.Context, namespace, pod string, command []string, stdin string) (string, error) {
req := e.clientset.CoreV1().RESTClient().Post().
Resource("pods").
Name(pod).
Namespace(namespace).
SubResource("exec").
VersionedParams(&corev1.PodExecOptions{
Container: ContainerName,
Command: command,
Stdin: stdin != "",
Stdout: true,
Stderr: true,
}, scheme.ParameterCodec)
exec, err := remotecommand.NewSPDYExecutor(e.config, "POST", req.URL())
if err != nil {
return "", fmt.Errorf("spdy executor: %w", err)
}
var stdout, stderr bytes.Buffer
opts := remotecommand.StreamOptions{Stdout: &stdout, Stderr: &stderr}
if stdin != "" {
opts.Stdin = bytes.NewBufferString(stdin)
}
if err := exec.StreamWithContext(ctx, opts); err != nil {
return stdout.String(), fmt.Errorf("exec %v: %w (stderr: %s)", command, err, stderr.String())
}
return stdout.String(), nil
}
+29
View File
@@ -0,0 +1,29 @@
package bind
import (
"crypto/sha1"
"encoding/hex"
"strings"
)
// catalogHash returns the unique member label for a catalog zone entry: the
// hex-encoded SHA-1 digest of the member zone name in DNS wire format, per the
// BIND catalog-zone schema (RFC 9432).
func catalogHash(zone string) string {
sum := sha1.Sum(wireName(zone))
return hex.EncodeToString(sum[:])
}
// wireName encodes a domain name into uncompressed DNS wire format: each label
// length-prefixed, terminated by a zero-length root label. Names are lowercased.
func wireName(name string) []byte {
name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
var out []byte
if name != "" {
for _, label := range strings.Split(name, ".") {
out = append(out, byte(len(label)))
out = append(out, []byte(label)...)
}
}
return append(out, 0)
}
+34
View File
@@ -0,0 +1,34 @@
package bind
import (
"crypto/rand"
"encoding/base64"
"fmt"
)
// GenerateSecret returns a base64-encoded cryptographically-random key of n
// bytes, suitable for a TSIG or rndc HMAC secret.
func GenerateSecret(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("read random: %w", err)
}
return base64.StdEncoding.EncodeToString(buf), nil
}
// KeyClause renders a named.conf `key` block for inclusion.
func KeyClause(name, algorithm, secret string) string {
return fmt.Sprintf("key \"%s\" {\n algorithm %s;\n secret \"%s\";\n};\n", name, algorithm, secret)
}
// SecretBytesForAlgorithm returns a reasonable key length for a TSIG algorithm.
func SecretBytesForAlgorithm(algorithm string) int {
switch algorithm {
case "hmac-sha512", "hmac-sha384":
return 64
case "hmac-sha256":
return 32
default:
return 32
}
}
+60
View File
@@ -0,0 +1,60 @@
package bind
import (
"context"
"fmt"
"strings"
)
// TSIGCreds carries the material needed to authenticate a dynamic update.
type TSIGCreds struct {
Name string // TSIG key name
Algorithm string // e.g. hmac-sha256
Secret string // base64-encoded key
}
// RecordUpdate describes a desired record set to apply to a zone.
type RecordUpdate struct {
FQDN string // fully-qualified owner name, trailing dot recommended
Type string // RR type
TTL int32 // record TTL
Values []string // RDATA entries
Delete bool // when true, delete the RRset instead of replacing it
}
// NSUpdate applies a set of record changes to zone by executing nsupdate on the
// primary pod, targeting the local server and authenticating with creds. All
// changes are sent in a single atomic transaction.
func (e *Executor) NSUpdate(ctx context.Context, namespace, pod, zone string, creds TSIGCreds, updates []RecordUpdate) error {
var b strings.Builder
b.WriteString("server 127.0.0.1\n")
b.WriteString(fmt.Sprintf("zone %s\n", dot(zone)))
for _, u := range updates {
// Replace semantics: clear the RRset first, then add the desired values.
b.WriteString(fmt.Sprintf("update delete %s %s\n", dot(u.FQDN), u.Type))
if u.Delete {
continue
}
for _, v := range u.Values {
b.WriteString(fmt.Sprintf("update add %s %d %s %s\n", dot(u.FQDN), u.TTL, u.Type, v))
}
}
b.WriteString("send\n")
cmd := []string{"nsupdate", "-y", fmt.Sprintf("%s:%s:%s", creds.Algorithm, creds.Name, creds.Secret)}
if out, err := e.Exec(ctx, namespace, pod, cmd, b.String()); err != nil {
return fmt.Errorf("nsupdate zone %s: %w (out: %s)", zone, err, out)
}
return nil
}
// dot ensures a name is fully qualified with a trailing dot.
func dot(name string) string {
if name == "" || name == "@" {
return "@"
}
if strings.HasSuffix(name, ".") {
return name
}
return name + "."
}
+289
View File
@@ -0,0 +1,289 @@
package bind
import (
"fmt"
"sort"
"strings"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
// RenderInput aggregates everything needed to render a cluster's named.conf.
type RenderInput struct {
Cluster *bindv1alpha1.BindCluster
ACLs []bindv1alpha1.BindACL
Views []bindv1alpha1.BindView
Policies []bindv1alpha1.BindPolicy
DNSSECPolicies []bindv1alpha1.BindDNSSECPolicy
Catalog *bindv1alpha1.BindCatalogZone
// PrimaryAddress is the in-cluster address secondaries transfer from.
PrimaryAddress string
}
// DataDir is where BIND keeps zone databases and journals (backed by the PVC).
const DataDir = "/var/lib/named"
// RenderNamedConf returns the primary and secondary named.conf contents for a
// cluster. Both variants are shipped in the ConfigMap; the entrypoint selects
// one based on the pod ordinal.
func RenderNamedConf(in RenderInput) (primary string, secondary string) {
return render(in, true), render(in, false)
}
func render(in RenderInput, isPrimary bool) string {
c := in.Cluster
var b strings.Builder
b.WriteString("// Managed by bind-operator. Do not edit.\n")
b.WriteString(`include "/etc/bind/keys/keys.conf";` + "\n\n")
// Named ACLs (global scope).
acls := append([]bindv1alpha1.BindACL(nil), in.ACLs...)
sort.Slice(acls, func(i, j int) bool { return acls[i].Name < acls[j].Name })
for _, a := range acls {
b.WriteString(fmt.Sprintf("acl \"%s\" { %s };\n", a.Name, matchList(a.Spec.Entries)))
}
if len(acls) > 0 {
b.WriteString("\n")
}
// DNSSEC policies (must precede zones that reference them).
for _, p := range in.DNSSECPolicies {
b.WriteString(renderDNSSECPolicy(p))
}
// options.
b.WriteString("options {\n")
b.WriteString(fmt.Sprintf(" directory \"%s\";\n", DataDir))
b.WriteString(" listen-on port 53 { any; };\n")
b.WriteString(" listen-on-v6 port 53 { any; };\n")
b.WriteString(fmt.Sprintf(" recursion %s;\n", yesno(recursionFor(c))))
if len(c.Spec.Forwarders) > 0 {
b.WriteString(fmt.Sprintf(" forwarders { %s };\n", terminate(c.Spec.Forwarders)))
}
if allowNewZones(c) {
b.WriteString(" allow-new-zones yes;\n")
}
b.WriteString(" dnssec-validation auto;\n")
for _, o := range c.Spec.ExtraOptions {
b.WriteString(" " + strings.TrimRight(o, ";") + ";\n")
}
// When there are no views, response-policy and catalog-zones live in options.
if len(in.Views) == 0 {
b.WriteString(responsePolicyClause(in.Policies, " "))
b.WriteString(catalogZonesClause(in, isPrimary, " "))
}
b.WriteString("};\n\n")
// controls (rndc).
b.WriteString("controls {\n")
b.WriteString(" inet 127.0.0.1 port 953 allow { 127.0.0.1; } keys { \"rndc-key\"; };\n")
b.WriteString("};\n\n")
// Views, if any.
views := append([]bindv1alpha1.BindView(nil), in.Views...)
sort.Slice(views, func(i, j int) bool { return views[i].Spec.Order < views[j].Spec.Order })
for _, v := range views {
b.WriteString(renderView(v, in, isPrimary))
}
// Catalog zone declaration lives at top level when there are no views.
if in.Catalog != nil && len(in.Views) == 0 {
b.WriteString(renderCatalogZoneDecl(in, isPrimary, ""))
}
return b.String()
}
func renderView(v bindv1alpha1.BindView, in RenderInput, isPrimary bool) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("view \"%s\" {\n", v.Name))
mc := v.Spec.MatchClients
if len(mc) == 0 {
mc = []string{"any"}
}
b.WriteString(fmt.Sprintf(" match-clients { %s };\n", matchList(mc)))
if len(v.Spec.MatchDestinations) > 0 {
b.WriteString(fmt.Sprintf(" match-destinations { %s };\n", matchList(v.Spec.MatchDestinations)))
}
rec := recursionFor(in.Cluster)
if v.Spec.Recursion != nil {
rec = *v.Spec.Recursion
}
b.WriteString(fmt.Sprintf(" recursion %s;\n", yesno(rec)))
if len(v.Spec.AllowQuery) > 0 {
b.WriteString(fmt.Sprintf(" allow-query { %s };\n", matchList(v.Spec.AllowQuery)))
}
for _, o := range v.Spec.ExtraOptions {
b.WriteString(" " + strings.TrimRight(o, ";") + ";\n")
}
// Policies and catalog scoped to this view.
viewPolicies := filterPoliciesForView(in.Policies, v.Name)
b.WriteString(responsePolicyClause(viewPolicies, " "))
b.WriteString(catalogZonesClause(in, isPrimary, " "))
if in.Catalog != nil {
b.WriteString(renderCatalogZoneDecl(in, isPrimary, " "))
}
b.WriteString("};\n\n")
return b.String()
}
func renderDNSSECPolicy(p bindv1alpha1.BindDNSSECPolicy) string {
name := p.Spec.PolicyName
if name == "" {
name = p.Name
}
var b strings.Builder
b.WriteString(fmt.Sprintf("dnssec-policy \"%s\" {\n", name))
if p.Spec.NSEC3 {
b.WriteString(" nsec3param;\n")
}
if p.Spec.MaxZoneTTL != "" {
b.WriteString(fmt.Sprintf(" max-zone-ttl %s;\n", p.Spec.MaxZoneTTL))
}
if p.Spec.SignaturesValidity != "" {
b.WriteString(fmt.Sprintf(" signatures-validity %s;\n", p.Spec.SignaturesValidity))
}
alg := p.Spec.Algorithm
if alg == "" {
alg = "ecdsap256sha256"
}
if p.Spec.CSK != nil {
b.WriteString(" keys {\n")
b.WriteString(" csk " + keyLine(p.Spec.CSK, alg) + ";\n")
b.WriteString(" };\n")
} else {
b.WriteString(" keys {\n")
if p.Spec.KSK != nil {
b.WriteString(" ksk " + keyLine(p.Spec.KSK, alg) + ";\n")
}
if p.Spec.ZSK != nil {
b.WriteString(" zsk " + keyLine(p.Spec.ZSK, alg) + ";\n")
}
b.WriteString(" };\n")
}
for _, o := range p.Spec.ExtraOptions {
b.WriteString(" " + strings.TrimRight(o, ";") + ";\n")
}
b.WriteString("};\n\n")
return b.String()
}
func keyLine(k *bindv1alpha1.DNSSECKey, defaultAlg string) string {
lifetime := k.Lifetime
if lifetime == "" {
lifetime = "unlimited"
}
alg := k.Algorithm
if alg == "" {
alg = defaultAlg
}
if k.KeySize > 0 {
return fmt.Sprintf("lifetime %s algorithm %s %d", lifetime, alg, k.KeySize)
}
return fmt.Sprintf("lifetime %s algorithm %s", lifetime, alg)
}
func responsePolicyClause(policies []bindv1alpha1.BindPolicy, indent string) string {
if len(policies) == 0 {
return ""
}
sorted := append([]bindv1alpha1.BindPolicy(nil), policies...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Spec.Order < sorted[j].Spec.Order })
var b strings.Builder
b.WriteString(indent + "response-policy {\n")
for _, p := range sorted {
b.WriteString(fmt.Sprintf("%s zone \"%s\";\n", indent, p.Spec.ZoneName))
}
b.WriteString(indent + "};\n")
return b.String()
}
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}
}
var b strings.Builder
b.WriteString(indent + "catalog-zones {\n")
b.WriteString(fmt.Sprintf("%s zone \"%s\" default-primaries { %s };\n", indent, in.Catalog.Spec.ZoneName, terminate(primaries)))
b.WriteString(indent + "};\n")
return b.String()
}
// renderCatalogZoneDecl declares the catalog zone as a secondary on consumer
// pods. The primary hosts the catalog zone dynamically (created by the
// BindCatalogZone controller via rndc addzone), so nothing is emitted here for
// the primary.
func renderCatalogZoneDecl(in RenderInput, isPrimary bool, indent string) string {
if isPrimary {
return ""
}
cat := in.Catalog
file := CatalogFilePath(cat.Spec.ZoneName)
primaries := cat.Spec.DefaultPrimaries
if len(primaries) == 0 && in.PrimaryAddress != "" {
primaries = []string{in.PrimaryAddress}
}
var b strings.Builder
b.WriteString(fmt.Sprintf("%szone \"%s\" {\n", indent, cat.Spec.ZoneName))
b.WriteString(indent + " type secondary;\n")
b.WriteString(fmt.Sprintf("%s file \"%s\";\n", indent, file))
b.WriteString(fmt.Sprintf("%s primaries { %s };\n", indent, terminate(primaries)))
b.WriteString(indent + "};\n\n")
return b.String()
}
func filterPoliciesForView(policies []bindv1alpha1.BindPolicy, view string) []bindv1alpha1.BindPolicy {
var out []bindv1alpha1.BindPolicy
for _, p := range policies {
if p.Spec.ViewRef == view || p.Spec.ViewRef == "" {
out = append(out, p)
}
}
return out
}
// matchList renders address-match-list elements, each terminated with a
// semicolon: `10.0.0.0/8; key foo;`.
func matchList(entries []string) string {
return terminate(entries)
}
// terminate joins elements each followed by "; ".
func terminate(entries []string) string {
var parts []string
for _, e := range entries {
e = strings.TrimSpace(strings.TrimRight(e, ";"))
if e == "" {
continue
}
parts = append(parts, e+";")
}
return strings.Join(parts, " ")
}
func yesno(b bool) string {
if b {
return "yes"
}
return "no"
}
func recursionFor(c *bindv1alpha1.BindCluster) bool {
if c.Spec.Recursion != nil {
return *c.Spec.Recursion
}
return c.Spec.Mode == bindv1alpha1.ModeResolver
}
func allowNewZones(c *bindv1alpha1.BindCluster) bool {
if c.Spec.AllowNewZones != nil {
return *c.Spec.AllowNewZones
}
return true
}
+80
View File
@@ -0,0 +1,80 @@
package bind
import (
"strings"
"testing"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func newCluster(mode bindv1alpha1.BindMode) *bindv1alpha1.BindCluster {
return &bindv1alpha1.BindCluster{
ObjectMeta: metav1.ObjectMeta{Name: "auth", Namespace: "dns"},
Spec: bindv1alpha1.BindClusterSpec{Mode: mode, Replicas: 3},
}
}
func TestRenderResolverEnablesRecursion(t *testing.T) {
primary, secondary := RenderNamedConf(RenderInput{Cluster: newCluster(bindv1alpha1.ModeResolver)})
if !strings.Contains(primary, "recursion yes;") {
t.Fatalf("resolver primary should enable recursion:\n%s", primary)
}
if !strings.Contains(secondary, "recursion yes;") {
t.Fatalf("resolver secondary should enable recursion")
}
}
func TestRenderAuthoritativeDisablesRecursion(t *testing.T) {
primary, _ := RenderNamedConf(RenderInput{Cluster: newCluster(bindv1alpha1.ModeAuthoritative)})
if !strings.Contains(primary, "recursion no;") {
t.Fatalf("authoritative should disable recursion:\n%s", primary)
}
if !strings.Contains(primary, "allow-new-zones yes;") {
t.Fatalf("authoritative should allow new zones for dynamic provisioning")
}
}
func TestRenderCatalogOnSecondaryOnly(t *testing.T) {
in := RenderInput{
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
Catalog: &bindv1alpha1.BindCatalogZone{Spec: bindv1alpha1.BindCatalogZoneSpec{ZoneName: "catalog.internal", DefaultPrimaries: []string{"10.0.0.1"}}},
PrimaryAddress: "auth-0.auth-headless.dns.svc.cluster.local",
}
primary, secondary := RenderNamedConf(in)
if strings.Contains(primary, "catalog-zones") {
t.Fatalf("primary must not consume the catalog it publishes:\n%s", primary)
}
if !strings.Contains(secondary, "catalog-zones") {
t.Fatalf("secondary must consume the catalog zone:\n%s", secondary)
}
if !strings.Contains(secondary, "type secondary;") {
t.Fatalf("secondary must declare the catalog zone as a secondary")
}
}
func TestRenderACL(t *testing.T) {
in := RenderInput{
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
ACLs: []bindv1alpha1.BindACL{{
ObjectMeta: metav1.ObjectMeta{Name: "internal"},
Spec: bindv1alpha1.BindACLSpec{Entries: []string{"10.0.0.0/8", "192.168.0.0/16"}},
}},
}
primary, _ := RenderNamedConf(in)
if !strings.Contains(primary, `acl "internal" { 10.0.0.0/8; 192.168.0.0/16; };`) {
t.Fatalf("ACL not rendered correctly:\n%s", primary)
}
}
func TestCatalogHashStable(t *testing.T) {
// SHA-1 of the wire format of "example.com" is well-defined and stable.
h1 := catalogHash("example.com")
h2 := catalogHash("example.com.")
if h1 != h2 {
t.Fatalf("trailing dot should not change hash: %s vs %s", h1, h2)
}
if len(h1) != 40 {
t.Fatalf("expected 40-char hex sha1, got %d: %s", len(h1), h1)
}
}
+89
View File
@@ -0,0 +1,89 @@
package bind
import (
"context"
"fmt"
"strings"
)
// RndcConfPath is the operator-managed rndc client config mounted in each pod.
const RndcConfPath = "/etc/bind/rndc.conf"
// Rndc runs `rndc <args...>` on a pod and returns its output.
func (e *Executor) Rndc(ctx context.Context, namespace, pod string, args ...string) (string, error) {
base := []string{"rndc", "-c", RndcConfPath}
return e.Exec(ctx, namespace, pod, append(base, args...), "")
}
// Reconfig reloads named.conf and any newly added/removed zones without a full
// restart.
func (e *Executor) Reconfig(ctx context.Context, namespace, pod string) error {
_, err := e.Rndc(ctx, namespace, pod, "reconfig")
return err
}
// AddZone provisions a zone at runtime via `rndc addzone`. config is the inner
// zone clause, e.g. `{ type primary; file "db.example"; allow-update { key k; }; };`.
func (e *Executor) AddZone(ctx context.Context, namespace, pod, zone, view, config string) error {
args := []string{"addzone", zone}
if view != "" {
args = append(args, "in", view)
}
args = append(args, config)
out, err := e.Rndc(ctx, namespace, pod, args...)
if err != nil {
// addzone fails if the zone already exists; fall back to modzone so the
// operation is idempotent.
if strings.Contains(err.Error(), "already exists") || strings.Contains(out, "already exists") {
return e.ModZone(ctx, namespace, pod, zone, view, config)
}
return err
}
return nil
}
// ModZone updates an existing runtime-added zone's configuration.
func (e *Executor) ModZone(ctx context.Context, namespace, pod, zone, view, config string) error {
args := []string{"modzone", zone}
if view != "" {
args = append(args, "in", view)
}
args = append(args, config)
_, err := e.Rndc(ctx, namespace, pod, args...)
return err
}
// DelZone removes a runtime-added zone. A missing zone is treated as success.
func (e *Executor) DelZone(ctx context.Context, namespace, pod, zone, view string) error {
args := []string{"delzone", zone}
if view != "" {
args = append(args, "in", view)
}
out, err := e.Rndc(ctx, namespace, pod, args...)
if err != nil && (strings.Contains(err.Error(), "not found") || strings.Contains(out, "not found")) {
return nil
}
return err
}
// ZoneSerial returns the current SOA serial for a zone via `rndc zonestatus`.
func (e *Executor) ZoneSerial(ctx context.Context, namespace, pod, zone, view string) (int64, error) {
args := []string{"zonestatus", zone}
if view != "" {
args = append(args, "in", view)
}
out, err := e.Rndc(ctx, namespace, pod, args...)
if err != nil {
return 0, err
}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "serial:") {
var serial int64
if _, err := fmt.Sscanf(line, "serial: %d", &serial); err == nil {
return serial, nil
}
}
}
return 0, nil
}
+74
View File
@@ -0,0 +1,74 @@
package bind
import (
"context"
"fmt"
"strings"
)
// ZoneFilePath returns the on-pod path of a zone database file.
func ZoneFilePath(zone string) string {
return fmt.Sprintf("%s/zones/db.%s", DataDir, strings.TrimSuffix(zone, "."))
}
// CatalogFilePath returns the on-pod path of a catalog zone database file.
func CatalogFilePath(zone string) string {
return fmt.Sprintf("%s/catalog/db.%s", DataDir, strings.TrimSuffix(zone, "."))
}
// ZoneExists reports whether a zone is currently loaded on the pod.
func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view string) bool {
args := []string{"zonestatus", zone}
if view != "" {
args = append(args, "in", view)
}
_, err := e.Rndc(ctx, namespace, pod, args...)
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 {
origin := dot(zone)
if primaryNS == "" {
primaryNS = "ns1." + origin
}
content := fmt.Sprintf(`$TTL 3600
@ IN SOA %s hostmaster.%s (
%d ; serial
3600 ; refresh
900 ; retry
1209600 ; expire
300 ) ; minimum
@ IN NS %s
`, dot(primaryNS), origin, serial, dot(primaryNS))
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 {
return fmt.Errorf("seed zone %s: %w (out: %s)", zone, err, out)
}
return nil
}
// AddCatalogMember registers a member zone in a catalog zone by adding the
// catalog PTR record, so secondaries auto-provision it.
func (e *Executor) AddCatalogMember(ctx context.Context, namespace, pod, catalogZone, memberZone string, creds TSIGCreds) error {
hash := catalogHash(memberZone)
owner := fmt.Sprintf("%s.zones.%s", hash, dot(catalogZone))
updates := []RecordUpdate{{
FQDN: owner,
Type: "PTR",
TTL: 3600,
Values: []string{dot(memberZone)},
}}
return e.NSUpdate(ctx, namespace, pod, catalogZone, creds, updates)
}
// RemoveCatalogMember deregisters a member zone from a catalog zone.
func (e *Executor) RemoveCatalogMember(ctx context.Context, namespace, pod, catalogZone, memberZone string, creds TSIGCreds) error {
hash := catalogHash(memberZone)
owner := fmt.Sprintf("%s.zones.%s", hash, dot(catalogZone))
updates := []RecordUpdate{{FQDN: owner, Type: "PTR", Delete: true}}
return e.NSUpdate(ctx, namespace, pod, catalogZone, creds, updates)
}
+41
View File
@@ -0,0 +1,41 @@
package controller
import (
"context"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
// BindACLReconciler validates a BindACL and reports readiness. The rendered ACL
// is emitted into named.conf by the BindCluster controller, which watches ACLs.
type BindACLReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindacls,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindacls/status,verbs=get;update;patch
func (r *BindACLReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var acl bindv1alpha1.BindACL
if err := r.Get(ctx, req.NamespacedName, &acl); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
acl.Status.Ready = len(acl.Spec.Entries) > 0
acl.Status.ObservedGeneration = acl.Generation
setReady(&acl.Status.Conditions, acl.Generation, acl.Status.Ready, "Validated", "ACL rendered into named.conf")
if err := r.Status().Update(ctx, &acl); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func (r *BindACLReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.BindACL{}).
Complete(r)
}
@@ -0,0 +1,111 @@
package controller
import (
"context"
"fmt"
"k8s.io/apimachinery/pkg/runtime"
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"
)
// BindCatalogZoneReconciler creates and maintains the catalog zone on a cluster
// primary so secondaries auto-provision member zones.
type BindCatalogZoneReconciler struct {
client.Client
Scheme *runtime.Scheme
Exec *bind.Executor
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindcatalogzones,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindcatalogzones/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones;bindtsigkeys,verbs=get;list;watch
func (r *BindCatalogZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var catalog bindv1alpha1.BindCatalogZone
if err := r.Get(ctx, req.NamespacedName, &catalog); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
cluster, err := getCluster(ctx, r.Client, catalog.Namespace, catalog.Spec.ClusterRef)
if err != nil {
return r.fail(ctx, &catalog, "ClusterMissing", err.Error())
}
primaryPod := primaryPodName(cluster.Name)
if !primaryReady(ctx, r.Client, cluster) || r.Exec == nil {
return r.fail(ctx, &catalog, "PrimaryNotReady", "waiting for cluster primary")
}
creds, err := resolveTSIG(ctx, r.Client, catalog.Namespace, catalog.Spec.TransferKeyRef)
if err != nil {
return r.fail(ctx, &catalog, "NoTransferKey", err.Error())
}
// 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 {
return r.fail(ctx, &catalog, "SeedFailed", err.Error())
}
}
zoneConfig := fmt.Sprintf("{ type primary; file \"%s\"; allow-transfer { key \"%s\"; }; allow-update { key \"%s\"; }; };",
bind.CatalogFilePath(catalog.Spec.ZoneName), catalog.Spec.TransferKeyRef, catalog.Spec.TransferKeyRef)
if err := r.Exec.AddZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, "", zoneConfig); err != nil {
return r.fail(ctx, &catalog, "AddZoneFailed", err.Error())
}
// Catalog zones must advertise their schema version (RFC 9432: "2").
versionUpdate := bind.RecordUpdate{
FQDN: "version." + catalog.Spec.ZoneName + ".",
Type: "TXT",
TTL: 3600,
Values: []string{"\"2\""},
}
if err := r.Exec.NSUpdate(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, creds, []bind.RecordUpdate{versionUpdate}); err != nil {
return r.fail(ctx, &catalog, "VersionUpdateFailed", err.Error())
}
// Count member zones for status.
var zones bindv1alpha1.BindZoneList
members := int32(0)
if err := r.List(ctx, &zones, client.InNamespace(catalog.Namespace)); err == nil {
for i := range zones.Items {
z := &zones.Items[i]
if z.Spec.ClusterRef == cluster.Name && catalogEnabled(z) {
members++
}
}
}
catalog.Status.Ready = true
catalog.Status.MemberCount = members
catalog.Status.ObservedGeneration = catalog.Generation
setReady(&catalog.Status.Conditions, catalog.Generation, true, "Ready", "catalog zone provisioned")
if err := r.Status().Update(ctx, &catalog); err != nil {
return ctrl.Result{}, err
}
logger.Info("catalog zone reconciled", "zone", catalog.Spec.ZoneName, "members", members)
return ctrl.Result{RequeueAfter: requeueLong}, nil
}
func (r *BindCatalogZoneReconciler) fail(ctx context.Context, catalog *bindv1alpha1.BindCatalogZone, reason, msg string) (ctrl.Result, error) {
catalog.Status.Ready = false
catalog.Status.ObservedGeneration = catalog.Generation
setReady(&catalog.Status.Conditions, catalog.Generation, false, reason, msg)
if err := r.Status().Update(ctx, catalog); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: requeueShort}, nil
}
func (r *BindCatalogZoneReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.BindCatalogZone{}).
Complete(r)
}
@@ -0,0 +1,418 @@
package controller
import (
"context"
"fmt"
"sort"
"strings"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
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/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
// BindClusterReconciler manages the StatefulSet, Services, ConfigMap and
// Secrets backing a BindCluster, and re-renders named.conf when dependent
// objects (ACLs, views, policies, keys, catalog) change.
type BindClusterReconciler struct {
client.Client
Scheme *runtime.Scheme
Exec *bind.Executor
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindclusters,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindclusters/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindacls;bindviews;bindpolicies;binddnssecpolicies;bindcatalogzones;bindtsigkeys,verbs=get;list;watch
// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=services;configmaps;secrets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch
// +kubebuilder:rbac:groups="",resources=pods/exec,verbs=create;get
func (r *BindClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var cluster bindv1alpha1.BindCluster
if err := r.Get(ctx, req.NamespacedName, &cluster); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if err := r.reconcileRNDCSecret(ctx, &cluster); err != nil {
return ctrl.Result{}, fmt.Errorf("rndc secret: %w", err)
}
if err := r.reconcileKeysSecret(ctx, &cluster); err != nil {
return ctrl.Result{}, fmt.Errorf("keys secret: %w", err)
}
if err := r.reconcileConfigMap(ctx, &cluster); err != nil {
return ctrl.Result{}, fmt.Errorf("configmap: %w", err)
}
if err := r.reconcileServices(ctx, &cluster); err != nil {
return ctrl.Result{}, fmt.Errorf("services: %w", err)
}
sts, err := r.reconcileStatefulSet(ctx, &cluster)
if err != nil {
return ctrl.Result{}, fmt.Errorf("statefulset: %w", err)
}
// Best-effort: reload configuration on ready pods so ConfigMap changes take
// effect without a rollout.
r.reloadReadyPods(ctx, &cluster)
// Status.
cluster.Status.ObservedGeneration = cluster.Generation
cluster.Status.Replicas = cluster.Spec.Replicas
cluster.Status.ReadyReplicas = sts.Status.ReadyReplicas
cluster.Status.PrimaryPod = primaryPodName(cluster.Name)
cluster.Status.PrimaryService = primaryAddress(cluster.Name, cluster.Namespace)
ready := sts.Status.ReadyReplicas == cluster.Spec.Replicas && cluster.Spec.Replicas > 0
if ready {
cluster.Status.Phase = "Ready"
} else {
cluster.Status.Phase = "Progressing"
}
setReady(&cluster.Status.Conditions, cluster.Generation, ready, "Reconciled",
fmt.Sprintf("%d/%d replicas ready", sts.Status.ReadyReplicas, cluster.Spec.Replicas))
if err := r.Status().Update(ctx, &cluster); err != nil {
return ctrl.Result{}, err
}
if !ready {
return ctrl.Result{RequeueAfter: requeueShort}, nil
}
logger.V(1).Info("cluster reconciled", "cluster", cluster.Name, "ready", sts.Status.ReadyReplicas)
return ctrl.Result{}, nil
}
func (r *BindClusterReconciler) reconcileRNDCSecret(ctx context.Context, c *bindv1alpha1.BindCluster) error {
name := rndcSecretName(c.Name)
var existing corev1.Secret
err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: name}, &existing)
if err == nil {
return nil // rndc key is generated once and preserved
}
if !apierrors.IsNotFound(err) {
return err
}
secret, genErr := bind.GenerateSecret(32)
if genErr != nil {
return genErr
}
keyClause := bind.KeyClause("rndc-key", "hmac-sha256", secret)
rndcConf := fmt.Sprintf("include \"/etc/bind/rndc.key\";\noptions {\n default-key \"rndc-key\";\n default-server 127.0.0.1;\n default-port 953;\n};\n")
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: c.Namespace, Labels: commonLabels(c.Name)},
Data: map[string][]byte{
"rndc.key": []byte(keyClause),
"rndc.conf": []byte(rndcConf),
},
}
if err := ctrl.SetControllerReference(c, s, r.Scheme); err != nil {
return err
}
return r.Create(ctx, s)
}
func (r *BindClusterReconciler) reconcileKeysSecret(ctx context.Context, c *bindv1alpha1.BindCluster) error {
var keys bindv1alpha1.BindTSIGKeyList
if err := r.List(ctx, &keys, client.InNamespace(c.Namespace)); err != nil {
return err
}
items := append([]bindv1alpha1.BindTSIGKey(nil), keys.Items...)
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
var b strings.Builder
b.WriteString("// Managed by bind-operator.\n")
for _, k := range items {
secretName := k.Status.SecretName
if secretName == "" {
secretName = k.Spec.SecretName
}
if secretName == "" {
secretName = k.Name + "-tsig"
}
var secret corev1.Secret
if err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: secretName}, &secret); err != nil {
continue // key not yet materialised; skip until its controller runs
}
keyName := k.Spec.KeyName
if keyName == "" {
keyName = k.Name
}
alg := string(secret.Data["algorithm"])
if alg == "" {
alg = string(bindv1alpha1.TSIGHMACSHA256)
}
b.WriteString(bind.KeyClause(keyName, alg, string(secret.Data["secret"])))
}
return r.upsertSecret(ctx, c, keysSecretName(c.Name), map[string][]byte{"keys.conf": []byte(b.String())})
}
func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv1alpha1.BindCluster) error {
in := bind.RenderInput{Cluster: c, PrimaryAddress: primaryAddress(c.Name, c.Namespace)}
var acls bindv1alpha1.BindACLList
if err := r.List(ctx, &acls, client.InNamespace(c.Namespace)); err == nil {
for _, a := range acls.Items {
if a.Spec.ClusterRef == "" || a.Spec.ClusterRef == c.Name {
in.ACLs = append(in.ACLs, a)
}
}
}
var views bindv1alpha1.BindViewList
if err := r.List(ctx, &views, client.InNamespace(c.Namespace)); err == nil {
for _, v := range views.Items {
if v.Spec.ClusterRef == c.Name {
in.Views = append(in.Views, v)
}
}
}
var policies bindv1alpha1.BindPolicyList
if err := r.List(ctx, &policies, client.InNamespace(c.Namespace)); err == nil {
for _, p := range policies.Items {
if p.Spec.ClusterRef == c.Name {
in.Policies = append(in.Policies, p)
}
}
}
var dnssec bindv1alpha1.BindDNSSECPolicyList
if err := r.List(ctx, &dnssec, client.InNamespace(c.Namespace)); err == nil {
for _, d := range dnssec.Items {
if d.Spec.ClusterRef == c.Name {
in.DNSSECPolicies = append(in.DNSSECPolicies, d)
}
}
}
var catalogs bindv1alpha1.BindCatalogZoneList
if err := r.List(ctx, &catalogs, client.InNamespace(c.Namespace)); err == nil {
for i := range catalogs.Items {
if catalogs.Items[i].Spec.ClusterRef == c.Name {
in.Catalog = &catalogs.Items[i]
break
}
}
}
primaryConf, secondaryConf := bind.RenderNamedConf(in)
data := map[string]string{
"named.conf.primary": primaryConf,
"named.conf.secondary": secondaryConf,
"entrypoint.sh": entrypointScript(),
}
return r.upsertConfigMap(ctx, c, configMapName(c.Name), data)
}
func (r *BindClusterReconciler) reconcileServices(ctx context.Context, c *bindv1alpha1.BindCluster) error {
dnsPorts := []corev1.ServicePort{
{Name: "dns-udp", Port: 53, Protocol: corev1.ProtocolUDP, TargetPort: intstrFromInt(53)},
{Name: "dns-tcp", Port: 53, Protocol: corev1.ProtocolTCP, TargetPort: intstrFromInt(53)},
}
headless := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: headlessServiceName(c.Name), Namespace: c.Namespace, Labels: commonLabels(c.Name)},
Spec: corev1.ServiceSpec{
ClusterIP: corev1.ClusterIPNone,
PublishNotReadyAddresses: true,
Selector: commonLabels(c.Name),
Ports: dnsPorts,
},
}
if err := r.upsertService(ctx, c, headless); err != nil {
return err
}
svcType := c.Spec.Service.Type
if svcType == "" {
svcType = corev1.ServiceTypeClusterIP
}
client := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: clientServiceName(c.Name),
Namespace: c.Namespace,
Labels: commonLabels(c.Name),
Annotations: c.Spec.Service.Annotations,
},
Spec: corev1.ServiceSpec{
Type: svcType,
Selector: commonLabels(c.Name),
Ports: dnsPorts,
LoadBalancerIP: c.Spec.Service.LoadBalancerIP,
},
}
return r.upsertService(ctx, c, client)
}
func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bindv1alpha1.BindCluster) (*appsv1.StatefulSet, error) {
labels := commonLabels(c.Name)
replicas := c.Spec.Replicas
image := c.Spec.Image
if image == "" {
image = "git.unkin.net/unkin/bind9:latest"
}
storageSize := c.Spec.StorageSize
if storageSize == "" {
storageSize = "1Gi"
}
qty, err := resource.ParseQuantity(storageSize)
if err != nil {
return nil, fmt.Errorf("parse storageSize: %w", err)
}
projected := corev1.Volume{
Name: "bind-etc",
VolumeSource: corev1.VolumeSource{Projected: &corev1.ProjectedVolumeSource{Sources: []corev1.VolumeProjection{
{ConfigMap: &corev1.ConfigMapProjection{LocalObjectReference: corev1.LocalObjectReference{Name: configMapName(c.Name)}}},
{Secret: &corev1.SecretProjection{LocalObjectReference: corev1.LocalObjectReference{Name: keysSecretName(c.Name)}}},
{Secret: &corev1.SecretProjection{LocalObjectReference: corev1.LocalObjectReference{Name: rndcSecretName(c.Name)}}},
}}},
}
sts := &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{Name: c.Name, Namespace: c.Namespace, Labels: labels},
Spec: appsv1.StatefulSetSpec{
ServiceName: headlessServiceName(c.Name),
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: labels},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels},
Spec: corev1.PodSpec{
NodeSelector: c.Spec.NodeSelector,
Tolerations: c.Spec.Tolerations,
Affinity: c.Spec.Affinity,
Containers: []corev1.Container{{
Name: bind.ContainerName,
Image: image,
ImagePullPolicy: c.Spec.ImagePullPolicy,
Command: []string{"/bin/sh", "/etc/bind/entrypoint.sh"},
Ports: []corev1.ContainerPort{
{Name: "dns-udp", ContainerPort: 53, Protocol: corev1.ProtocolUDP},
{Name: "dns-tcp", ContainerPort: 53, Protocol: corev1.ProtocolTCP},
},
Resources: c.Spec.Resources,
VolumeMounts: []corev1.VolumeMount{
{Name: "bind-etc", MountPath: "/etc/bind", ReadOnly: true},
{Name: "run", MountPath: "/run/named"},
{Name: "data", MountPath: bind.DataDir},
},
ReadinessProbe: &corev1.Probe{
ProbeHandler: corev1.ProbeHandler{TCPSocket: &corev1.TCPSocketAction{Port: intstrFromInt(53)}},
InitialDelaySeconds: 5,
PeriodSeconds: 10,
},
LivenessProbe: &corev1.Probe{
ProbeHandler: corev1.ProbeHandler{TCPSocket: &corev1.TCPSocketAction{Port: intstrFromInt(53)}},
InitialDelaySeconds: 15,
PeriodSeconds: 20,
},
}},
Volumes: []corev1.Volume{
projected,
{Name: "run", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}},
},
},
},
VolumeClaimTemplates: []corev1.PersistentVolumeClaim{{
ObjectMeta: metav1.ObjectMeta{Name: "data"},
Spec: corev1.PersistentVolumeClaimSpec{
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
StorageClassName: c.Spec.StorageClassName,
Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceStorage: qty}},
},
}},
},
}
if err := ctrl.SetControllerReference(c, sts, r.Scheme); err != nil {
return nil, err
}
var existing appsv1.StatefulSet
err = r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: c.Name}, &existing)
if apierrors.IsNotFound(err) {
return sts, r.Create(ctx, sts)
}
if err != nil {
return nil, err
}
// VolumeClaimTemplates are immutable; only mutate the mutable fields.
existing.Spec.Replicas = sts.Spec.Replicas
existing.Spec.Template = sts.Spec.Template
if err := r.Update(ctx, &existing); err != nil {
return nil, err
}
return &existing, nil
}
func (r *BindClusterReconciler) reloadReadyPods(ctx context.Context, c *bindv1alpha1.BindCluster) {
if r.Exec == nil {
return
}
logger := log.FromContext(ctx)
var pods corev1.PodList
if err := r.List(ctx, &pods, client.InNamespace(c.Namespace), client.MatchingLabels(commonLabels(c.Name))); err != nil {
return
}
for i := range pods.Items {
pod := &pods.Items[i]
if !podReady(pod) {
continue
}
if err := r.Exec.Reconfig(ctx, c.Namespace, pod.Name); err != nil {
logger.V(1).Info("rndc reconfig failed", "pod", pod.Name, "err", err.Error())
}
}
}
func (r *BindClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
mapToCluster := func(clusterRef, namespace string) []reconcile.Request {
if clusterRef == "" {
return nil
}
return []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: namespace, Name: clusterRef}}}
}
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.BindCluster{}).
Owns(&appsv1.StatefulSet{}).
Owns(&corev1.Service{}).
Owns(&corev1.ConfigMap{}).
Owns(&corev1.Secret{}).
Watches(&bindv1alpha1.BindACL{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
return mapToCluster(o.(*bindv1alpha1.BindACL).Spec.ClusterRef, o.GetNamespace())
})).
Watches(&bindv1alpha1.BindView{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
return mapToCluster(o.(*bindv1alpha1.BindView).Spec.ClusterRef, o.GetNamespace())
})).
Watches(&bindv1alpha1.BindPolicy{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
return mapToCluster(o.(*bindv1alpha1.BindPolicy).Spec.ClusterRef, o.GetNamespace())
})).
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.BindCatalogZone{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
return mapToCluster(o.(*bindv1alpha1.BindCatalogZone).Spec.ClusterRef, o.GetNamespace())
})).
Watches(&bindv1alpha1.BindTSIGKey{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
// TSIG keys are namespace-wide; re-render every cluster in the namespace.
var clusters bindv1alpha1.BindClusterList
if err := r.List(ctx, &clusters, client.InNamespace(o.GetNamespace())); err != nil {
return nil
}
var reqs []reconcile.Request
for _, cl := range clusters.Items {
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: cl.Namespace, Name: cl.Name}})
}
return reqs
})).
Complete(r)
}
@@ -0,0 +1,55 @@
package controller
import (
"context"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
// BindDNSSECPolicyReconciler validates a signing policy and reports how many
// zones reference it. The dnssec-policy block is rendered into named.conf by
// the BindCluster controller, which watches these policies.
type BindDNSSECPolicyReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=binddnssecpolicies,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=binddnssecpolicies/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones,verbs=get;list;watch
func (r *BindDNSSECPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var policy bindv1alpha1.BindDNSSECPolicy
if err := r.Get(ctx, req.NamespacedName, &policy); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
var zones bindv1alpha1.BindZoneList
count := int32(0)
if err := r.List(ctx, &zones, client.InNamespace(policy.Namespace)); err == nil {
for _, z := range zones.Items {
if z.Spec.ClusterRef == policy.Spec.ClusterRef && z.Spec.DNSSECPolicyRef == policy.Name {
count++
}
}
}
policy.Status.ZoneCount = count
policy.Status.Ready = policy.Spec.ClusterRef != ""
policy.Status.ObservedGeneration = policy.Generation
setReady(&policy.Status.Conditions, policy.Generation, policy.Status.Ready, "Validated", "dnssec-policy rendered into named.conf")
if err := r.Status().Update(ctx, &policy); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func (r *BindDNSSECPolicyReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.BindDNSSECPolicy{}).
Complete(r)
}
@@ -0,0 +1,163 @@
package controller
import (
"context"
"fmt"
"strings"
"k8s.io/apimachinery/pkg/runtime"
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"
)
// BindPolicyReconciler provisions a Response Policy Zone (RPZ) on a cluster
// primary and seeds its rules. The cluster controller renders the matching
// response-policy clause into named.conf.
type BindPolicyReconciler struct {
client.Client
Scheme *runtime.Scheme
Exec *bind.Executor
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindpolicies,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindpolicies/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindtsigkeys,verbs=get;list;watch
func (r *BindPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var policy bindv1alpha1.BindPolicy
if err := r.Get(ctx, req.NamespacedName, &policy); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
cluster, err := getCluster(ctx, r.Client, policy.Namespace, policy.Spec.ClusterRef)
if err != nil {
return r.fail(ctx, &policy, "ClusterMissing", err.Error())
}
primaryPod := primaryPodName(cluster.Name)
if !primaryReady(ctx, r.Client, cluster) || r.Exec == nil {
return r.fail(ctx, &policy, "PrimaryNotReady", "waiting for cluster primary")
}
// Externally-fed RPZ: configure as a secondary of the feed. Otherwise host a
// locally-populated primary RPZ zone.
if len(policy.Spec.Primaries) > 0 {
creds, _ := resolveTSIG(ctx, r.Client, policy.Namespace, policy.Spec.TransferKeyRef)
_ = creds
cfg := fmt.Sprintf("{ type secondary; file \"%s\"; primaries { %s }; };",
bind.ZoneFilePath(policy.Spec.ZoneName), terminateInline(policy.Spec.Primaries))
if err := r.Exec.AddZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, policy.Spec.ViewRef, cfg); err != nil {
return r.fail(ctx, &policy, "AddZoneFailed", err.Error())
}
return r.ready(ctx, &policy, int32(0))
}
creds, err := resolveTSIG(ctx, r.Client, policy.Namespace, policy.Spec.TransferKeyRef)
if err != nil {
return r.fail(ctx, &policy, "NoUpdateKey", "spec.transferKeyRef required to seed RPZ rules")
}
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 {
return r.fail(ctx, &policy, "SeedFailed", err.Error())
}
}
cfg := fmt.Sprintf("{ type primary; file \"%s\"; allow-update { key \"%s\"; }; };",
bind.ZoneFilePath(policy.Spec.ZoneName), policy.Spec.TransferKeyRef)
if err := r.Exec.AddZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, policy.Spec.ViewRef, cfg); err != nil {
return r.fail(ctx, &policy, "AddZoneFailed", err.Error())
}
updates := rpzRulesToUpdates(policy.Spec.ZoneName, policy.Spec.Rules)
if len(updates) > 0 {
if err := r.Exec.NSUpdate(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, creds, updates); err != nil {
return r.fail(ctx, &policy, "RuleUpdateFailed", err.Error())
}
}
logger.Info("policy reconciled", "zone", policy.Spec.ZoneName, "rules", len(updates))
return r.ready(ctx, &policy, int32(len(updates)))
}
// rpzRulesToUpdates maps RPZ rules to the CNAME records that encode them.
func rpzRulesToUpdates(rpzZone string, rules []bindv1alpha1.RPZRule) []bind.RecordUpdate {
var updates []bind.RecordUpdate
origin := strings.TrimSuffix(rpzZone, ".") + "."
for _, rule := range rules {
trigger := rule.Trigger
if trigger == "" {
trigger = "qname"
}
match := strings.TrimSuffix(strings.TrimSpace(rule.Match), ".")
var owner string
switch trigger {
case "qname":
owner = match + "." + origin
case "client-ip":
owner = match + ".rpz-client-ip." + origin
case "ip":
owner = match + ".rpz-ip." + origin
case "nsdname":
owner = match + ".rpz-nsdname." + origin
case "nsip":
owner = match + ".rpz-nsip." + origin
default:
owner = match + "." + origin
}
action := rule.Action
if action == "" {
action = "nxdomain"
}
var rdata string
switch action {
case "nxdomain":
rdata = "."
case "nodata":
rdata = "*."
case "passthru":
rdata = "rpz-passthru."
case "drop":
rdata = "rpz-drop."
case "tcp-only":
rdata = "rpz-tcp-only."
case "cname":
rdata = strings.TrimSuffix(rule.Target, ".") + "."
default:
rdata = "."
}
updates = append(updates, bind.RecordUpdate{FQDN: owner, Type: "CNAME", TTL: 3600, Values: []string{rdata}})
}
return updates
}
func (r *BindPolicyReconciler) ready(ctx context.Context, policy *bindv1alpha1.BindPolicy, rules int32) (ctrl.Result, error) {
policy.Status.Ready = true
policy.Status.RuleCount = rules
policy.Status.ObservedGeneration = policy.Generation
setReady(&policy.Status.Conditions, policy.Generation, true, "Ready", "RPZ provisioned")
if err := r.Status().Update(ctx, policy); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: requeueLong}, nil
}
func (r *BindPolicyReconciler) fail(ctx context.Context, policy *bindv1alpha1.BindPolicy, reason, msg string) (ctrl.Result, error) {
policy.Status.Ready = false
policy.Status.ObservedGeneration = policy.Generation
setReady(&policy.Status.Conditions, policy.Generation, false, reason, msg)
if err := r.Status().Update(ctx, policy); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: requeueShort}, nil
}
func (r *BindPolicyReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.BindPolicy{}).
Complete(r)
}
@@ -0,0 +1,108 @@
package controller
import (
"context"
"fmt"
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/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"
)
// BindTSIGKeyReconciler generates TSIG key material into a Secret.
type BindTSIGKeyReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindtsigkeys,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindtsigkeys/status,verbs=get;update;patch
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete
func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var key bindv1alpha1.BindTSIGKey
if err := r.Get(ctx, req.NamespacedName, &key); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
algorithm := string(key.Spec.Algorithm)
if algorithm == "" {
algorithm = string(bindv1alpha1.TSIGHMACSHA256)
}
keyName := key.Spec.KeyName
if keyName == "" {
keyName = key.Name
}
secretName := key.Spec.SecretName
if secretName == "" {
secretName = key.Name + "-tsig"
}
var secret corev1.Secret
err := r.Get(ctx, types.NamespacedName{Namespace: key.Namespace, Name: secretName}, &secret)
switch {
case apierrors.IsNotFound(err):
if key.Spec.ImportExisting {
return r.fail(ctx, &key, "SecretMissing", fmt.Sprintf("import secret %s not found", secretName))
}
material, genErr := bind.GenerateSecret(bind.SecretBytesForAlgorithm(algorithm))
if genErr != nil {
return ctrl.Result{}, genErr
}
newSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: key.Namespace, Labels: map[string]string{managedByLabel: managedByValue}},
Data: map[string][]byte{
"algorithm": []byte(algorithm),
"keyName": []byte(keyName),
"secret": []byte(material),
"key.conf": []byte(bind.KeyClause(keyName, algorithm, material)),
},
}
if err := ctrl.SetControllerReference(&key, newSecret, r.Scheme); err != nil {
return ctrl.Result{}, err
}
if err := r.Create(ctx, newSecret); err != nil {
return ctrl.Result{}, err
}
logger.Info("generated TSIG key", "key", key.Name, "secret", secretName)
case err != nil:
return ctrl.Result{}, err
}
key.Status.SecretName = secretName
key.Status.KeyName = keyName
key.Status.Ready = true
key.Status.ObservedGeneration = key.Generation
setReady(&key.Status.Conditions, key.Generation, true, "KeyReady", "TSIG key material present")
if err := r.Status().Update(ctx, &key); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func (r *BindTSIGKeyReconciler) fail(ctx context.Context, key *bindv1alpha1.BindTSIGKey, reason, msg string) (ctrl.Result, error) {
key.Status.Ready = false
key.Status.ObservedGeneration = key.Generation
setReady(&key.Status.Conditions, key.Generation, false, reason, msg)
if err := r.Status().Update(ctx, key); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: requeueShort}, nil
}
func (r *BindTSIGKeyReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.BindTSIGKey{}).
Owns(&corev1.Secret{}).
Complete(r)
}
@@ -0,0 +1,55 @@
package controller
import (
"context"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
// BindViewReconciler validates a BindView and reports the number of zones bound
// to it. The view block is rendered into named.conf by the BindCluster
// controller, which watches views.
type BindViewReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindviews,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindviews/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones,verbs=get;list;watch
func (r *BindViewReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var view bindv1alpha1.BindView
if err := r.Get(ctx, req.NamespacedName, &view); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
var zones bindv1alpha1.BindZoneList
count := int32(0)
if err := r.List(ctx, &zones, client.InNamespace(view.Namespace)); err == nil {
for _, z := range zones.Items {
if z.Spec.ClusterRef == view.Spec.ClusterRef && z.Spec.ViewRef == view.Name {
count++
}
}
}
view.Status.ZoneCount = count
view.Status.Ready = view.Spec.ClusterRef != ""
view.Status.ObservedGeneration = view.Generation
setReady(&view.Status.Conditions, view.Generation, view.Status.Ready, "Validated", "view rendered into named.conf")
if err := r.Status().Update(ctx, &view); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func (r *BindViewReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.BindView{}).
Complete(r)
}
+223
View File
@@ -0,0 +1,223 @@
package controller
import (
"context"
"fmt"
"strings"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
// BindZoneReconciler provisions zones on a cluster primary via rndc addzone and
// seeds records via dynamic update.
type BindZoneReconciler struct {
client.Client
Scheme *runtime.Scheme
Exec *bind.Executor
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindcatalogzones;bindtsigkeys,verbs=get;list;watch
func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var zone bindv1alpha1.BindZone
if err := r.Get(ctx, req.NamespacedName, &zone); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
cluster, err := getCluster(ctx, r.Client, zone.Namespace, zone.Spec.ClusterRef)
if err != nil {
return r.setPhase(ctx, &zone, "Error", "ClusterMissing", err.Error())
}
primaryPod := primaryPodName(cluster.Name)
// Handle deletion via finalizer: remove the zone from the primary and catalog.
if !zone.DeletionTimestamp.IsZero() {
if controllerutil.ContainsFinalizer(&zone, finalizer) {
if primaryReady(ctx, r.Client, cluster) && r.Exec != nil {
_ = r.Exec.DelZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef)
r.deregisterCatalog(ctx, &zone, cluster, primaryPod)
}
controllerutil.RemoveFinalizer(&zone, finalizer)
if err := r.Update(ctx, &zone); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
if !controllerutil.ContainsFinalizer(&zone, finalizer) {
controllerutil.AddFinalizer(&zone, finalizer)
if err := r.Update(ctx, &zone); err != nil {
return ctrl.Result{}, err
}
}
if !primaryReady(ctx, r.Client, cluster) || r.Exec == nil {
return r.setPhase(ctx, &zone, "Pending", "PrimaryNotReady", "waiting for cluster primary to be ready")
}
zoneConfig, err := r.buildZoneConfig(ctx, &zone)
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 {
return r.setPhase(ctx, &zone, "Error", "SeedFailed", err.Error())
}
}
if err := r.Exec.AddZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef, zoneConfig); err != nil {
return r.setPhase(ctx, &zone, "Error", "AddZoneFailed", err.Error())
}
// Seed static records (primary zones only).
recordCount := 0
if isPrimaryType(zone.Spec.Type) && len(zone.Spec.Records) > 0 {
creds, err := r.zoneUpdateCreds(ctx, &zone)
if err != nil {
return r.setPhase(ctx, &zone, "Error", "NoUpdateKey", err.Error())
}
updates := recordsToUpdates(zone.Spec.ZoneName, zone.Spec.Records, zone.Spec.DefaultTTL)
if err := r.Exec.NSUpdate(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds, updates); err != nil {
return r.setPhase(ctx, &zone, "Error", "RecordUpdateFailed", err.Error())
}
recordCount = len(updates)
}
// Register in the catalog so secondaries auto-provision.
if catalogEnabled(&zone) {
r.registerCatalog(ctx, &zone, cluster, primaryPod)
}
serial, _ := r.Exec.ZoneSerial(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef)
zone.Status.Phase = "Ready"
zone.Status.Serial = serial
zone.Status.RecordCount = int32(recordCount)
zone.Status.Signed = zone.Spec.DNSSECPolicyRef != ""
zone.Status.ObservedGeneration = zone.Generation
setReady(&zone.Status.Conditions, zone.Generation, true, "Provisioned", "zone provisioned on primary")
if err := r.Status().Update(ctx, &zone); err != nil {
return ctrl.Result{}, err
}
logger.Info("zone reconciled", "zone", zone.Spec.ZoneName, "serial", serial)
return ctrl.Result{RequeueAfter: requeueLong}, nil
}
// buildZoneConfig renders the inner clause passed to rndc addzone/modzone.
func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone) (string, error) {
zType := zone.Spec.Type
if zType == "" {
zType = bindv1alpha1.ZonePrimary
}
var parts []string
switch zType {
case bindv1alpha1.ZonePrimary:
parts = append(parts, "type primary", fmt.Sprintf("file \"%s\"", bind.ZoneFilePath(zone.Spec.ZoneName)))
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 {
parts = append(parts, fmt.Sprintf("allow-transfer { %s }", matchListInline(zone.Spec.AllowTransfer)))
}
if zone.Spec.DNSSECPolicyRef != "" {
parts = append(parts, fmt.Sprintf("dnssec-policy \"%s\"", zone.Spec.DNSSECPolicyRef), "inline-signing yes")
}
case bindv1alpha1.ZoneSecondary:
parts = append(parts, "type secondary", fmt.Sprintf("file \"%s\"", bind.ZoneFilePath(zone.Spec.ZoneName)))
if len(zone.Spec.Primaries) > 0 {
parts = append(parts, fmt.Sprintf("primaries { %s }", terminateInline(zone.Spec.Primaries)))
}
case bindv1alpha1.ZoneForward:
parts = append(parts, "type forward", "forward only")
if len(zone.Spec.Forwarders) > 0 {
parts = append(parts, fmt.Sprintf("forwarders { %s }", terminateInline(zone.Spec.Forwarders)))
}
case bindv1alpha1.ZoneStub:
parts = append(parts, "type stub", fmt.Sprintf("file \"%s\"", bind.ZoneFilePath(zone.Spec.ZoneName)))
if len(zone.Spec.Primaries) > 0 {
parts = append(parts, fmt.Sprintf("primaries { %s }", terminateInline(zone.Spec.Primaries)))
}
}
return "{ " + strings.Join(parts, "; ") + "; };", nil
}
func (r *BindZoneReconciler) zoneUpdateCreds(ctx context.Context, zone *bindv1alpha1.BindZone) (bind.TSIGCreds, error) {
keyRef := zone.Spec.UpdateKeyRef
if keyRef == "" {
keyRef = zone.Spec.TransferKeyRef
}
if keyRef == "" {
// Fall back to local (non-TSIG) update when the zone allows it; most
// seeded primaries permit localhost updates.
return bind.TSIGCreds{}, fmt.Errorf("records require spec.updateKeyRef")
}
return resolveTSIG(ctx, r.Client, zone.Namespace, keyRef)
}
func (r *BindZoneReconciler) registerCatalog(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster, primaryPod string) {
logger := log.FromContext(ctx)
catalog, creds, ok := r.catalogFor(ctx, zone, cluster)
if !ok {
return
}
if err := r.Exec.AddCatalogMember(ctx, zone.Namespace, primaryPod, catalog.Spec.ZoneName, zone.Spec.ZoneName, creds); err != nil {
logger.V(1).Info("catalog register failed", "zone", zone.Spec.ZoneName, "err", err.Error())
}
}
func (r *BindZoneReconciler) deregisterCatalog(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster, primaryPod string) {
catalog, creds, ok := r.catalogFor(ctx, zone, cluster)
if !ok {
return
}
_ = r.Exec.RemoveCatalogMember(ctx, zone.Namespace, primaryPod, catalog.Spec.ZoneName, zone.Spec.ZoneName, creds)
}
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 {
return nil, bind.TSIGCreds{}, false
}
for i := range catalogs.Items {
if catalogs.Items[i].Spec.ClusterRef == cluster.Name {
cat := &catalogs.Items[i]
creds, err := resolveTSIG(ctx, r.Client, zone.Namespace, cat.Spec.TransferKeyRef)
if err != nil {
return nil, bind.TSIGCreds{}, false
}
return cat, creds, true
}
}
return nil, bind.TSIGCreds{}, false
}
func (r *BindZoneReconciler) setPhase(ctx context.Context, zone *bindv1alpha1.BindZone, phase, reason, msg string) (ctrl.Result, error) {
zone.Status.Phase = phase
zone.Status.ObservedGeneration = zone.Generation
setReady(&zone.Status.Conditions, zone.Generation, phase == "Ready", reason, msg)
if err := r.Status().Update(ctx, zone); err != nil {
return ctrl.Result{}, err
}
if phase == "Error" || phase == "Pending" {
return ctrl.Result{RequeueAfter: requeueShort}, nil
}
return ctrl.Result{}, nil
}
func (r *BindZoneReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.BindZone{}).
Complete(r)
}
+116
View File
@@ -0,0 +1,116 @@
package controller
import (
"context"
"fmt"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
// DNSRecordReconciler applies individual record sets to a zone via TSIG dynamic
// update — the external-dns write path as a CRD.
type DNSRecordReconciler struct {
client.Client
Scheme *runtime.Scheme
Exec *bind.Executor
}
// +kubebuilder:rbac:groups=bind.unkin.net,resources=dnsrecords,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=bind.unkin.net,resources=dnsrecords/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones;bindtsigkeys,verbs=get;list;watch
func (r *DNSRecordReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var record bindv1alpha1.DNSRecord
if err := r.Get(ctx, req.NamespacedName, &record); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
var zone bindv1alpha1.BindZone
if err := r.Get(ctx, client.ObjectKey{Namespace: record.Namespace, Name: record.Spec.ZoneRef}, &zone); err != nil {
return r.setPhase(ctx, &record, "Error", "ZoneMissing", err.Error())
}
cluster, err := getCluster(ctx, r.Client, record.Namespace, zone.Spec.ClusterRef)
if err != nil {
return r.setPhase(ctx, &record, "Error", "ClusterMissing", err.Error())
}
primaryPod := primaryPodName(cluster.Name)
name := fqdn(record.Spec.Name, zone.Spec.ZoneName)
creds, err := resolveTSIG(ctx, r.Client, record.Namespace, zone.Spec.UpdateKeyRef)
if err != nil {
return r.setPhase(ctx, &record, "Error", "NoUpdateKey", fmt.Sprintf("zone %s: %v", zone.Name, err))
}
// Deletion via finalizer: remove the RRset.
if !record.DeletionTimestamp.IsZero() {
if controllerutil.ContainsFinalizer(&record, finalizer) {
if primaryReady(ctx, r.Client, cluster) && r.Exec != nil {
_ = r.Exec.NSUpdate(ctx, record.Namespace, primaryPod, zone.Spec.ZoneName, creds,
[]bind.RecordUpdate{{FQDN: name, Type: record.Spec.Type, Delete: true}})
}
controllerutil.RemoveFinalizer(&record, finalizer)
if err := r.Update(ctx, &record); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
if !controllerutil.ContainsFinalizer(&record, finalizer) {
controllerutil.AddFinalizer(&record, finalizer)
if err := r.Update(ctx, &record); err != nil {
return ctrl.Result{}, err
}
}
if !primaryReady(ctx, r.Client, cluster) || r.Exec == nil {
return r.setPhase(ctx, &record, "Pending", "PrimaryNotReady", "waiting for cluster primary")
}
ttl := zone.Spec.DefaultTTL
if record.Spec.TTL != nil {
ttl = *record.Spec.TTL
}
update := bind.RecordUpdate{FQDN: name, Type: record.Spec.Type, TTL: ttl, Values: record.Spec.Values}
if err := r.Exec.NSUpdate(ctx, record.Namespace, primaryPod, zone.Spec.ZoneName, creds, []bind.RecordUpdate{update}); err != nil {
return r.setPhase(ctx, &record, "Error", "UpdateFailed", err.Error())
}
record.Status.FQDN = name
record.Status.Phase = "Applied"
record.Status.ObservedGeneration = record.Generation
setReady(&record.Status.Conditions, record.Generation, true, "Applied", "record applied via dynamic update")
if err := r.Status().Update(ctx, &record); err != nil {
return ctrl.Result{}, err
}
logger.Info("record applied", "record", name, "type", record.Spec.Type)
return ctrl.Result{}, nil
}
func (r *DNSRecordReconciler) setPhase(ctx context.Context, record *bindv1alpha1.DNSRecord, phase, reason, msg string) (ctrl.Result, error) {
record.Status.Phase = phase
record.Status.ObservedGeneration = record.Generation
setReady(&record.Status.Conditions, record.Generation, phase == "Applied", reason, msg)
if err := r.Status().Update(ctx, record); err != nil {
return ctrl.Result{}, err
}
if phase == "Error" || phase == "Pending" {
return ctrl.Result{RequeueAfter: requeueShort}, nil
}
return ctrl.Result{}, nil
}
func (r *DNSRecordReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&bindv1alpha1.DNSRecord{}).
Complete(r)
}
+121
View File
@@ -0,0 +1,121 @@
package controller
import (
"context"
"fmt"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
const (
requeueShort = 15 * time.Second
requeueLong = 2 * time.Minute
managedByLabel = "app.kubernetes.io/managed-by"
managedByValue = "bind-operator"
clusterLabel = "bind.unkin.net/cluster"
finalizer = "bind.unkin.net/finalizer"
)
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" }
// primaryAddress is the in-cluster DNS name of the primary pod (ordinal 0).
func primaryAddress(cluster, namespace string) string {
return fmt.Sprintf("%s-0.%s.%s.svc.cluster.local", cluster, headlessServiceName(cluster), namespace)
}
// setReady sets the standard Ready condition on a status conditions slice.
func setReady(conds *[]metav1.Condition, gen int64, ok bool, reason, msg string) {
status := metav1.ConditionFalse
if ok {
status = metav1.ConditionTrue
}
meta.SetStatusCondition(conds, metav1.Condition{
Type: "Ready",
Status: status,
Reason: reason,
Message: msg,
ObservedGeneration: gen,
})
}
// commonLabels are applied to every object the operator creates for a cluster.
func commonLabels(cluster string) map[string]string {
return map[string]string{
managedByLabel: managedByValue,
clusterLabel: cluster,
}
}
// getCluster fetches the BindCluster referenced by clusterRef in namespace.
func getCluster(ctx context.Context, c client.Client, namespace, clusterRef string) (*bindv1alpha1.BindCluster, error) {
var cluster bindv1alpha1.BindCluster
if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: clusterRef}, &cluster); err != nil {
return nil, err
}
return &cluster, nil
}
// primaryReady reports whether the primary pod of a cluster is Ready.
func primaryReady(ctx context.Context, c client.Client, cluster *bindv1alpha1.BindCluster) bool {
var pod corev1.Pod
key := client.ObjectKey{Namespace: cluster.Namespace, Name: primaryPodName(cluster.Name)}
if err := c.Get(ctx, key, &pod); err != nil {
return false
}
for _, cond := range pod.Status.Conditions {
if cond.Type == corev1.PodReady {
return cond.Status == corev1.ConditionTrue
}
}
return false
}
// 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
if keyRef == "" {
return creds, fmt.Errorf("no TSIG key referenced")
}
var key bindv1alpha1.BindTSIGKey
if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: keyRef}, &key); err != nil {
return creds, fmt.Errorf("get tsig key %s: %w", keyRef, err)
}
secretName := key.Status.SecretName
if secretName == "" {
secretName = key.Spec.SecretName
}
if secretName == "" {
secretName = keyRef + "-tsig"
}
var secret corev1.Secret
if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: secretName}, &secret); err != nil {
return creds, fmt.Errorf("get tsig secret %s: %w", secretName, err)
}
keyName := key.Spec.KeyName
if keyName == "" {
keyName = keyRef
}
creds = bind.TSIGCreds{
Name: keyName,
Algorithm: string(secret.Data["algorithm"]),
Secret: string(secret.Data["secret"]),
}
if creds.Algorithm == "" {
creds.Algorithm = string(bindv1alpha1.TSIGHMACSHA256)
}
return creds, nil
}
+39
View File
@@ -0,0 +1,39 @@
package controller
import (
ctrl "sigs.k8s.io/controller-runtime"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
// SetupAll registers every controller with the manager.
func SetupAll(mgr ctrl.Manager, exec *bind.Executor) error {
if err := (&BindClusterReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&BindTSIGKeyReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&BindACLReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&BindViewReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&BindDNSSECPolicyReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&BindCatalogZoneReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&BindZoneReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&BindPolicyReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&DNSRecordReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil {
return err
}
return nil
}
+111
View File
@@ -0,0 +1,111 @@
package controller
import (
"context"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"
ctrl "sigs.k8s.io/controller-runtime"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
func intstrFromInt(i int) intstr.IntOrString { return intstr.FromInt(i) }
func podReady(pod *corev1.Pod) bool {
for _, c := range pod.Status.Conditions {
if c.Type == corev1.PodReady {
return c.Status == corev1.ConditionTrue
}
}
return false
}
// entrypointScript selects the primary or secondary named.conf based on the
// pod's StatefulSet ordinal and launches named in the foreground.
func entrypointScript() string {
return `#!/bin/sh
set -eu
ORD="${HOSTNAME##*-}"
if [ "$ORD" = "0" ]; then
cp /etc/bind/named.conf.primary /run/named/named.conf
else
cp /etc/bind/named.conf.secondary /run/named/named.conf
fi
mkdir -p /var/lib/named/zones /var/lib/named/catalog
exec named -g -c /run/named/named.conf
`
}
func (r *BindClusterReconciler) upsertService(ctx context.Context, c *bindv1alpha1.BindCluster, desired *corev1.Service) error {
if err := ctrl.SetControllerReference(c, desired, r.Scheme); err != nil {
return err
}
var existing corev1.Service
err := r.Get(ctx, types.NamespacedName{Namespace: desired.Namespace, Name: desired.Name}, &existing)
if apierrors.IsNotFound(err) {
return r.Create(ctx, desired)
}
if err != nil {
return err
}
existing.Spec.Ports = desired.Spec.Ports
existing.Spec.Selector = desired.Spec.Selector
existing.Spec.Type = desired.Spec.Type
existing.Spec.LoadBalancerIP = desired.Spec.LoadBalancerIP
if desired.Annotations != nil {
if existing.Annotations == nil {
existing.Annotations = map[string]string{}
}
for k, v := range desired.Annotations {
existing.Annotations[k] = v
}
}
return r.Update(ctx, &existing)
}
func (r *BindClusterReconciler) upsertConfigMap(ctx context.Context, c *bindv1alpha1.BindCluster, name string, data map[string]string) error {
desired := &corev1.ConfigMap{}
desired.Name = name
desired.Namespace = c.Namespace
desired.Labels = commonLabels(c.Name)
desired.Data = data
if err := ctrl.SetControllerReference(c, desired, r.Scheme); err != nil {
return err
}
var existing corev1.ConfigMap
err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: name}, &existing)
if apierrors.IsNotFound(err) {
return r.Create(ctx, desired)
}
if err != nil {
return err
}
existing.Data = data
existing.Labels = commonLabels(c.Name)
return r.Update(ctx, &existing)
}
func (r *BindClusterReconciler) upsertSecret(ctx context.Context, c *bindv1alpha1.BindCluster, name string, data map[string][]byte) error {
desired := &corev1.Secret{}
desired.Name = name
desired.Namespace = c.Namespace
desired.Labels = commonLabels(c.Name)
desired.Data = data
if err := ctrl.SetControllerReference(c, desired, r.Scheme); err != nil {
return err
}
var existing corev1.Secret
err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: name}, &existing)
if apierrors.IsNotFound(err) {
return r.Create(ctx, desired)
}
if err != nil {
return err
}
existing.Data = data
existing.Labels = commonLabels(c.Name)
return r.Update(ctx, &existing)
}
+88
View File
@@ -0,0 +1,88 @@
package controller
import (
"context"
"strings"
"sigs.k8s.io/controller-runtime/pkg/client"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
"git.unkin.net/unkin/bind-operator/internal/bind"
)
func isPrimaryType(t bindv1alpha1.ZoneType) bool {
return t == bindv1alpha1.ZonePrimary || t == ""
}
// catalogEnabled reports whether a primary zone should be registered in the
// cluster catalog zone.
func catalogEnabled(zone *bindv1alpha1.BindZone) bool {
if !isPrimaryType(zone.Spec.Type) {
return false
}
if zone.Spec.Catalog == nil {
return true
}
return *zone.Spec.Catalog
}
// fqdn resolves a record owner name relative to a zone origin.
func fqdn(name, zone string) string {
zone = strings.TrimSuffix(zone, ".") + "."
if name == "" || name == "@" {
return zone
}
if strings.HasSuffix(name, ".") {
return name
}
return name + "." + zone
}
func recordsToUpdates(zone string, records []bindv1alpha1.Record, defaultTTL int32) []bind.RecordUpdate {
updates := make([]bind.RecordUpdate, 0, len(records))
for _, rec := range records {
ttl := defaultTTL
if rec.TTL != nil {
ttl = *rec.TTL
}
updates = append(updates, bind.RecordUpdate{
FQDN: fqdn(rec.Name, zone),
Type: rec.Type,
TTL: ttl,
Values: rec.Values,
})
}
return updates
}
// updateKeyName returns the TSIG key name (as used in named.conf) for a zone's
// update key, falling back to the object name.
func updateKeyName(ctx context.Context, c client.Client, zone *bindv1alpha1.BindZone) string {
ref := zone.Spec.UpdateKeyRef
if ref == "" {
return ""
}
var key bindv1alpha1.BindTSIGKey
if err := c.Get(ctx, client.ObjectKey{Namespace: zone.Namespace, Name: ref}, &key); err != nil {
return ref
}
if key.Spec.KeyName != "" {
return key.Spec.KeyName
}
return ref
}
// matchListInline renders address-match-list entries on one line.
func matchListInline(entries []string) string { return terminateInline(entries) }
func terminateInline(entries []string) string {
var parts []string
for _, e := range entries {
e = strings.TrimSpace(strings.TrimRight(e, ";"))
if e == "" {
continue
}
parts = append(parts, e+";")
}
return strings.Join(parts, " ")
}
+79
View File
@@ -0,0 +1,79 @@
package controller
import (
"testing"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
func TestFQDN(t *testing.T) {
cases := []struct{ name, zone, want string }{
{"@", "example.com", "example.com."},
{"", "example.com", "example.com."},
{"www", "example.com", "www.example.com."},
{"www.example.com.", "example.com", "www.example.com."},
{"host", "10.in-addr.arpa", "host.10.in-addr.arpa."},
}
for _, c := range cases {
if got := fqdn(c.name, c.zone); got != c.want {
t.Errorf("fqdn(%q,%q)=%q want %q", c.name, c.zone, got, c.want)
}
}
}
func TestRecordsToUpdatesTTLFallback(t *testing.T) {
custom := int32(60)
records := []bindv1alpha1.Record{
{Name: "@", Type: "A", Values: []string{"192.0.2.1"}},
{Name: "low", Type: "A", TTL: &custom, Values: []string{"192.0.2.2"}},
}
updates := recordsToUpdates("example.com", records, 3600)
if len(updates) != 2 {
t.Fatalf("expected 2 updates, got %d", len(updates))
}
if updates[0].TTL != 3600 {
t.Errorf("expected default TTL 3600, got %d", updates[0].TTL)
}
if updates[1].TTL != 60 {
t.Errorf("expected record TTL 60, got %d", updates[1].TTL)
}
if updates[0].FQDN != "example.com." {
t.Errorf("apex FQDN wrong: %s", updates[0].FQDN)
}
}
func TestRPZRulesToUpdates(t *testing.T) {
rules := []bindv1alpha1.RPZRule{
{Trigger: "qname", Match: "bad.example.com", Action: "nxdomain"},
{Trigger: "qname", Match: "walled.example.com", Action: "cname", Target: "block.internal"},
}
updates := rpzRulesToUpdates("rpz.internal", rules)
if len(updates) != 2 {
t.Fatalf("expected 2 updates, got %d", len(updates))
}
if updates[0].FQDN != "bad.example.com.rpz.internal." {
t.Errorf("qname owner wrong: %s", updates[0].FQDN)
}
if updates[0].Values[0] != "." {
t.Errorf("nxdomain rdata should be '.', got %q", updates[0].Values[0])
}
if updates[1].Values[0] != "block.internal." {
t.Errorf("cname rdata wrong: %q", updates[1].Values[0])
}
}
func TestCatalogEnabledDefault(t *testing.T) {
on := &bindv1alpha1.BindZone{Spec: bindv1alpha1.BindZoneSpec{Type: bindv1alpha1.ZonePrimary}}
if !catalogEnabled(on) {
t.Error("primary zone should default to catalog enabled")
}
no := false
off := &bindv1alpha1.BindZone{Spec: bindv1alpha1.BindZoneSpec{Type: bindv1alpha1.ZonePrimary, Catalog: &no}}
if catalogEnabled(off) {
t.Error("catalog=false should disable membership")
}
sec := &bindv1alpha1.BindZone{Spec: bindv1alpha1.BindZoneSpec{Type: bindv1alpha1.ZoneSecondary}}
if catalogEnabled(sec) {
t.Error("secondary zone should never be a catalog member")
}
}