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
+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")
}
}