Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d5a231a78 | |||
| f2b7d07006 | |||
| 7e736248e4 | |||
| 698ca8c102 | |||
| 3a88fd95a0 | |||
| eb65ad8f89 | |||
| 2894d85c60 | |||
| aab11457af | |||
| 671c43b05b | |||
| 7771711682 | |||
| 439aa9ea6b | |||
| 6a07f91ea1 | |||
| e4ed9cfdb2 | |||
| 9c81320df8 | |||
| 243e776b59 | |||
| 59612f157a | |||
| 8fac152537 | |||
| 2deea3e023 | |||
| ef8c41cb0f | |||
| ea330bd767 |
@@ -3,15 +3,15 @@ when:
|
||||
|
||||
steps:
|
||||
- name: docker-build-operator
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
|
||||
settings:
|
||||
repo: git.unkin.net/unkin/bind-operator
|
||||
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/bind-operator
|
||||
dockerfile: Dockerfile.operator
|
||||
dry_run: true
|
||||
|
||||
- name: docker-build-tsig-api
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
|
||||
settings:
|
||||
repo: git.unkin.net/unkin/bind-tsig-api
|
||||
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/bind-tsig-api
|
||||
dockerfile: Dockerfile.tsigapi
|
||||
dry_run: true
|
||||
|
||||
+12
-12
@@ -4,27 +4,27 @@ when:
|
||||
|
||||
steps:
|
||||
- name: docker-operator
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
|
||||
settings:
|
||||
registry: git.unkin.net
|
||||
repo: git.unkin.net/unkin/bind-operator
|
||||
registry: artifactapi.k8s.syd1.au.unkin.net
|
||||
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/bind-operator
|
||||
dockerfile: Dockerfile.operator
|
||||
username: droneci
|
||||
password:
|
||||
from_secret: DRONECI_PASSWORD
|
||||
buildkit_config: |
|
||||
[registry."artifactapi.k8s.syd1.au.unkin.net"]
|
||||
ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"]
|
||||
tags:
|
||||
- ${CI_COMMIT_TAG}
|
||||
- latest
|
||||
|
||||
- name: docker-tsig-api
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
|
||||
settings:
|
||||
registry: git.unkin.net
|
||||
repo: git.unkin.net/unkin/bind-tsig-api
|
||||
registry: artifactapi.k8s.syd1.au.unkin.net
|
||||
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/bind-tsig-api
|
||||
dockerfile: Dockerfile.tsigapi
|
||||
username: droneci
|
||||
password:
|
||||
from_secret: DRONECI_PASSWORD
|
||||
buildkit_config: |
|
||||
[registry."artifactapi.k8s.syd1.au.unkin.net"]
|
||||
ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"]
|
||||
tags:
|
||||
- ${CI_COMMIT_TAG}
|
||||
- latest
|
||||
|
||||
@@ -42,7 +42,7 @@ script picks one based on the pod ordinal.
|
||||
| `BindZone` | A forward/reverse zone (`primary`/`secondary`/`forward`/`stub`), records inline, optional dynamic-update + DNSSEC + catalog membership. |
|
||||
| `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). |
|
||||
| `BindTSIGKey` | A TSIG key; the operator generates material into a Secret (never stored in the CR). `spec.secretTemplate` stamps extra labels/annotations onto that Secret (e.g. reflection hints to mirror it into another namespace). |
|
||||
| `BindACL` | A reusable named `address_match_list`. |
|
||||
| `BindCatalogZone` | A BIND catalog zone so secondaries auto-provision member zones. |
|
||||
| `BindPolicy` | A Response Policy Zone (RPZ) / DNS firewall. |
|
||||
@@ -85,7 +85,8 @@ kubectl apply -f config/samples/
|
||||
|
||||
Woodpecker runs `pre-commit` (gofmt + vet), `test`, and a dry-run image `build`
|
||||
on pull requests; pushing a `v*` tag builds and pushes
|
||||
`git.unkin.net/unkin/bind-operator` to the Gitea registry.
|
||||
`artifactapi.k8s.syd1.au.unkin.net/docker-internal/bind-operator` (and
|
||||
`bind-tsig-api`) to the artifactapi local docker registry.
|
||||
|
||||
## Notes & caveats
|
||||
|
||||
|
||||
@@ -41,6 +41,24 @@ type BindTSIGKeySpec struct {
|
||||
// `secret` key and the operator will not generate new material.
|
||||
// +optional
|
||||
ImportExisting bool `json:"importExisting,omitempty"`
|
||||
|
||||
// SecretTemplate customizes metadata written onto the managed key Secret.
|
||||
// Useful, for example, to let secret-reflection tooling mirror the key into
|
||||
// another namespace. Operator-managed labels are always preserved.
|
||||
// +optional
|
||||
SecretTemplate *SecretMetadata `json:"secretTemplate,omitempty"`
|
||||
}
|
||||
|
||||
// SecretMetadata carries extra labels and annotations to stamp onto a
|
||||
// Secret managed by the operator.
|
||||
type SecretMetadata struct {
|
||||
// Annotations to set on the Secret.
|
||||
// +optional
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
|
||||
// Labels to set on the Secret.
|
||||
// +optional
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
// BindTSIGKeyStatus reports observed TSIG key state.
|
||||
|
||||
@@ -691,7 +691,7 @@ func (in *BindTSIGKey) DeepCopyInto(out *BindTSIGKey) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
out.Spec = in.Spec
|
||||
in.Spec.DeepCopyInto(&out.Spec)
|
||||
in.Status.DeepCopyInto(&out.Status)
|
||||
}
|
||||
|
||||
@@ -748,6 +748,11 @@ func (in *BindTSIGKeyList) DeepCopyObject() runtime.Object {
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *BindTSIGKeySpec) DeepCopyInto(out *BindTSIGKeySpec) {
|
||||
*out = *in
|
||||
if in.SecretTemplate != nil {
|
||||
in, out := &in.SecretTemplate, &out.SecretTemplate
|
||||
*out = new(SecretMetadata)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGKeySpec.
|
||||
@@ -1208,3 +1213,32 @@ func (in *Record) DeepCopy() *Record {
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *SecretMetadata) DeepCopyInto(out *SecretMetadata) {
|
||||
*out = *in
|
||||
if in.Annotations != nil {
|
||||
in, out := &in.Annotations, &out.Annotations
|
||||
*out = make(map[string]string, len(*in))
|
||||
for key, val := range *in {
|
||||
(*out)[key] = val
|
||||
}
|
||||
}
|
||||
if in.Labels != nil {
|
||||
in, out := &in.Labels, &out.Labels
|
||||
*out = make(map[string]string, len(*in))
|
||||
for key, val := range *in {
|
||||
(*out)[key] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretMetadata.
|
||||
func (in *SecretMetadata) DeepCopy() *SecretMetadata {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(SecretMetadata)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -87,6 +87,23 @@ spec:
|
||||
SecretName is the Secret the key material is written to (or read from when
|
||||
ImportExisting is set). Defaults to "<name>-tsig".
|
||||
type: string
|
||||
secretTemplate:
|
||||
description: |-
|
||||
SecretTemplate customizes metadata written onto the managed key Secret.
|
||||
Useful, for example, to let secret-reflection tooling mirror the key into
|
||||
another namespace. Operator-managed labels are always preserved.
|
||||
properties:
|
||||
annotations:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Annotations to set on the Secret.
|
||||
type: object
|
||||
labels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Labels to set on the Secret.
|
||||
type: object
|
||||
type: object
|
||||
type: object
|
||||
status:
|
||||
description: BindTSIGKeyStatus reports observed TSIG key state.
|
||||
|
||||
@@ -2376,6 +2376,23 @@ spec:
|
||||
SecretName is the Secret the key material is written to (or read from when
|
||||
ImportExisting is set). Defaults to "<name>-tsig".
|
||||
type: string
|
||||
secretTemplate:
|
||||
description: |-
|
||||
SecretTemplate customizes metadata written onto the managed key Secret.
|
||||
Useful, for example, to let secret-reflection tooling mirror the key into
|
||||
another namespace. Operator-managed labels are always preserved.
|
||||
properties:
|
||||
annotations:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Annotations to set on the Secret.
|
||||
type: object
|
||||
labels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Labels to set on the Secret.
|
||||
type: object
|
||||
type: object
|
||||
type: object
|
||||
status:
|
||||
description: BindTSIGKeyStatus reports observed TSIG key state.
|
||||
|
||||
@@ -11,7 +11,9 @@ spec:
|
||||
algorithm: hmac-sha256
|
||||
---
|
||||
# TSIG key permitting external-dns (and DNSRecord objects) to send RFC2136
|
||||
# dynamic updates to the dynamic cluster's primary.
|
||||
# dynamic updates to the dynamic cluster's primary. secretTemplate mirrors the
|
||||
# generated Secret into the external-dns namespace via emberstack reflector, so
|
||||
# external-dns presents exactly the key the primary's allow-update accepts.
|
||||
apiVersion: bind.unkin.net/v1alpha1
|
||||
kind: BindTSIGKey
|
||||
metadata:
|
||||
@@ -19,3 +21,9 @@ metadata:
|
||||
namespace: bind-externaldns
|
||||
spec:
|
||||
algorithm: hmac-sha256
|
||||
secretTemplate:
|
||||
annotations:
|
||||
reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
|
||||
reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "externaldns"
|
||||
reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
|
||||
reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: "externaldns"
|
||||
|
||||
@@ -3,6 +3,7 @@ module git.unkin.net/unkin/bind-operator
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/go-logr/logr v1.4.2
|
||||
k8s.io/api v0.34.4
|
||||
k8s.io/apimachinery v0.34.4
|
||||
k8s.io/client-go v0.34.4
|
||||
@@ -17,7 +18,6 @@ require (
|
||||
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
|
||||
github.com/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
|
||||
|
||||
+99
-8
@@ -22,15 +22,48 @@ type RenderInput struct {
|
||||
Forwards []bindv1alpha1.BindZone
|
||||
// PrimaryAddress is the in-cluster address secondaries transfer from.
|
||||
PrimaryAddress string
|
||||
// NotifyKeyName is the TSIG key name secondaries accept intra-cluster NOTIFYs
|
||||
// under. The primary pod's NOTIFYs egress with its *pod* IP as the source — k8s
|
||||
// Services only NAT the inbound direction — so BIND, whose implicit
|
||||
// allow-notify is the zone's primaries list (the stable ClusterIP), REFUSES
|
||||
// them as "non-primary" and replication falls back to the SOA refresh timer.
|
||||
//
|
||||
// The primary signs its outgoing NOTIFYs with this key (the zone-scope
|
||||
// also-notify entries, applied via rndc, carry `key "<name>"`), and
|
||||
// secondaries render `allow-notify { key "<name>"; }` — an address-match-list
|
||||
// with a *key* element and NO IPs. Because it names only the key, this clause
|
||||
// is fully static: it never changes when a pod IP churns, so it cannot feed a
|
||||
// changed config-hash and cannot roll the StatefulSet (the v0.2.5 loop). Empty
|
||||
// leaves BIND's default behaviour unchanged.
|
||||
NotifyKeyName string
|
||||
}
|
||||
|
||||
// RenderNamedConf returns the primary and secondary named.conf contents for a
|
||||
// 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) {
|
||||
// client.List returns cache-ordered (non-deterministic) results, so sort
|
||||
// every input slice before rendering. Otherwise the rendered config
|
||||
// reshuffles between reconciles, churning the ConfigMap — and with the
|
||||
// pod-template config hash that means an endless rolling restart.
|
||||
sortInput(&in)
|
||||
return render(in, true), render(in, false)
|
||||
}
|
||||
|
||||
// sortInput orders every list rendered into named.conf deterministically.
|
||||
func sortInput(in *RenderInput) {
|
||||
sort.Slice(in.ACLs, func(i, j int) bool { return in.ACLs[i].Name < in.ACLs[j].Name })
|
||||
sort.Slice(in.Views, func(i, j int) bool {
|
||||
if in.Views[i].Spec.Order != in.Views[j].Spec.Order {
|
||||
return in.Views[i].Spec.Order < in.Views[j].Spec.Order
|
||||
}
|
||||
return in.Views[i].Name < in.Views[j].Name
|
||||
})
|
||||
sort.Slice(in.Forwards, func(i, j int) bool { return in.Forwards[i].Spec.ZoneName < in.Forwards[j].Spec.ZoneName })
|
||||
sort.Slice(in.Policies, func(i, j int) bool { return in.Policies[i].Spec.ZoneName < in.Policies[j].Spec.ZoneName })
|
||||
sort.Slice(in.DNSSECPolicies, func(i, j int) bool { return in.DNSSECPolicies[i].Name < in.DNSSECPolicies[j].Name })
|
||||
}
|
||||
|
||||
func render(in RenderInput, isPrimary bool) string {
|
||||
c := in.Cluster
|
||||
var b strings.Builder
|
||||
@@ -67,6 +100,14 @@ func render(in RenderInput, isPrimary bool) string {
|
||||
b.WriteString(" allow-new-zones yes;\n")
|
||||
}
|
||||
b.WriteString(" dnssec-validation auto;\n")
|
||||
// Secondaries accept NOTIFY authenticated by the cluster's NOTIFY TSIG key.
|
||||
// Catalog member and plain secondary zones take their implicit allow-notify
|
||||
// from their primaries (the primary Service ClusterIP), but the primary's
|
||||
// NOTIFYs are sourced from its pod IP, so they are refused as "non-primary"
|
||||
// unless an explicit allow-notify covers them. Rather than enumerate pod IPs
|
||||
// (restart-scoped config that depends on pod IPs — the v0.2.5 roll loop), we
|
||||
// accept any NOTIFY signed with the cluster key: `allow-notify { key "X"; }`.
|
||||
b.WriteString(allowNotifyClause(in, isPrimary, " "))
|
||||
for _, o := range c.Spec.ExtraOptions {
|
||||
b.WriteString(" " + strings.TrimRight(o, ";") + ";\n")
|
||||
}
|
||||
@@ -230,15 +271,68 @@ func responsePolicyClause(policies []bindv1alpha1.BindPolicy, indent string) str
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// transferPrimaries returns the primaries list secondaries use to AXFR the
|
||||
// catalog (and, by inheritance, its member zones), each annotated with the
|
||||
// catalog transfer TSIG key. The primary requires key-authenticated transfers
|
||||
// (allow-transfer { key ... }), so an unkeyed primaries list is REFUSED.
|
||||
func transferPrimaries(in RenderInput) []string {
|
||||
primaries := in.Catalog.Spec.DefaultPrimaries
|
||||
if len(primaries) == 0 && in.PrimaryAddress != "" {
|
||||
primaries = []string{in.PrimaryAddress}
|
||||
}
|
||||
key := in.Catalog.Spec.TransferKeyRef
|
||||
if key == "" {
|
||||
return primaries
|
||||
}
|
||||
out := make([]string, 0, len(primaries))
|
||||
for _, p := range primaries {
|
||||
p = strings.TrimSpace(strings.TrimRight(p, ";"))
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(p, " key ") {
|
||||
out = append(out, p)
|
||||
} else {
|
||||
out = append(out, fmt.Sprintf("%s key \"%s\"", p, key))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// allowNotifyClause renders an options-scope allow-notify on secondaries that
|
||||
// accepts intra-cluster NOTIFYs authenticated by the cluster's NOTIFY TSIG key.
|
||||
// Zones (catalog members and plain secondaries) point their primaries at the
|
||||
// primary Service ClusterIP for stable AXFR, which also becomes their implicit
|
||||
// allow-notify — but NOTIFYs leave the primary pod with its *pod* IP as source,
|
||||
// so without this they are refused as "non-primary". The primary signs those
|
||||
// NOTIFYs with NotifyKeyName (see the also-notify entries applied via rndc), and
|
||||
// this clause admits them by key.
|
||||
//
|
||||
// The rendered clause is `allow-notify { key "<name>"; };` — a key element with
|
||||
// NO IP addresses. It is therefore fully static: it depends only on the key
|
||||
// name, never on any pod IP, so it can never change the config-hash and can
|
||||
// never trigger a rolling restart. This is the fix for the v0.2.5 loop, where an
|
||||
// options-scope allow-notify enumerating the primary pod IP re-rendered on every
|
||||
// pod churn, flipped the hash, rolled the pods, changed the IP, and looped.
|
||||
//
|
||||
// Emitted only on secondaries and only when the NOTIFY key name is known.
|
||||
func allowNotifyClause(in RenderInput, isPrimary bool, indent string) string {
|
||||
if isPrimary {
|
||||
return ""
|
||||
}
|
||||
key := strings.TrimSpace(in.NotifyKeyName)
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%sallow-notify { key \"%s\"; };\n", indent, key)
|
||||
}
|
||||
|
||||
func catalogZonesClause(in RenderInput, isPrimary bool, indent string) string {
|
||||
// Only secondaries consume the catalog to auto-provision member zones.
|
||||
if in.Catalog == nil || isPrimary {
|
||||
return ""
|
||||
}
|
||||
primaries := in.Catalog.Spec.DefaultPrimaries
|
||||
if len(primaries) == 0 && in.PrimaryAddress != "" {
|
||||
primaries = []string{in.PrimaryAddress}
|
||||
}
|
||||
primaries := transferPrimaries(in)
|
||||
if len(primaries) == 0 {
|
||||
return ""
|
||||
}
|
||||
@@ -259,10 +353,7 @@ func renderCatalogZoneDecl(in RenderInput, isPrimary bool, indent string) string
|
||||
}
|
||||
cat := in.Catalog
|
||||
file := CatalogFilePath(cat.Spec.ZoneName)
|
||||
primaries := cat.Spec.DefaultPrimaries
|
||||
if len(primaries) == 0 && in.PrimaryAddress != "" {
|
||||
primaries = []string{in.PrimaryAddress}
|
||||
}
|
||||
primaries := transferPrimaries(in)
|
||||
if len(primaries) == 0 {
|
||||
// Primary IP not known yet; omit the secondary catalog zone rather than
|
||||
// emit an invalid empty primaries list. A Pod-triggered reconcile renders
|
||||
|
||||
@@ -80,6 +80,108 @@ func TestRenderCatalogUsesPrimaryIP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCatalogPrimariesCarryTransferKey(t *testing.T) {
|
||||
// When the catalog declares a transfer key, secondaries must present it in
|
||||
// both the catalog-zones default-primaries and the secondary catalog zone,
|
||||
// or the key-authenticated primary REFUSES the AXFR.
|
||||
in := RenderInput{
|
||||
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
|
||||
Catalog: &bindv1alpha1.BindCatalogZone{Spec: bindv1alpha1.BindCatalogZoneSpec{ZoneName: "catalog.internal", TransferKeyRef: "transfer-key"}},
|
||||
PrimaryAddress: "10.43.0.5",
|
||||
}
|
||||
_, secondary := RenderNamedConf(in)
|
||||
if !strings.Contains(secondary, `default-primaries { 10.43.0.5 key "transfer-key"; }`) {
|
||||
t.Fatalf("catalog-zones default-primaries must carry the transfer key:\n%s", secondary)
|
||||
}
|
||||
if !strings.Contains(secondary, `primaries { 10.43.0.5 key "transfer-key"; }`) {
|
||||
t.Fatalf("secondary catalog zone primaries must carry the transfer key:\n%s", secondary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSecondaryAllowNotifyByKey(t *testing.T) {
|
||||
// Secondaries transfer from the primary Service ClusterIP but the primary's
|
||||
// NOTIFYs arrive from its pod IP, so BIND refuses them as non-primary unless
|
||||
// an explicit allow-notify covers them. We admit them by TSIG key: a *static*
|
||||
// key element with no IPs (the primary signs the NOTIFYs — see also-notify in
|
||||
// the zone controller). NO pod IP may appear here, or the config-hash churns.
|
||||
in := RenderInput{
|
||||
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
|
||||
PrimaryAddress: "10.43.5.5",
|
||||
NotifyKeyName: "externaldns-key",
|
||||
}
|
||||
primary, secondary := RenderNamedConf(in)
|
||||
if !strings.Contains(secondary, `allow-notify { key "externaldns-key"; };`) {
|
||||
t.Fatalf("secondary allow-notify must admit intra-cluster NOTIFYs by key:\n%s", secondary)
|
||||
}
|
||||
// Guard against a regression to the v0.2.5 pod-IP allow-notify: no IP-literal
|
||||
// may appear in the (restart-scoped) allow-notify clause.
|
||||
for _, line := range strings.Split(secondary, "\n") {
|
||||
if strings.Contains(line, "allow-notify") && (strings.Contains(line, "10.42.") || strings.Contains(line, "10.43.")) {
|
||||
t.Fatalf("allow-notify must not enumerate pod/service IPs (v0.2.5 roll loop):\n%s", line)
|
||||
}
|
||||
}
|
||||
if strings.Contains(primary, "allow-notify") {
|
||||
t.Fatalf("primary must not render allow-notify (it is the notifier, not a secondary):\n%s", primary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSecondaryAllowNotifyOmittedWhenNoKey(t *testing.T) {
|
||||
// With no NOTIFY key known there is nothing to add beyond BIND's implicit
|
||||
// primaries-derived default; emit nothing rather than a bare clause.
|
||||
in := RenderInput{Cluster: newCluster(bindv1alpha1.ModeAuthoritative), PrimaryAddress: "10.43.5.5"}
|
||||
_, secondary := RenderNamedConf(in)
|
||||
if strings.Contains(secondary, "allow-notify") {
|
||||
t.Fatalf("no allow-notify should be emitted when no NOTIFY key is known:\n%s", secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderRestartScopedConfigIndependentOfPodIPs is the permanent guard for the
|
||||
// v0.2.5 rolling-restart loop. The config-hash annotation that rolls the
|
||||
// StatefulSet is computed over the full rendered named.conf (see
|
||||
// BindClusterReconciler.configHash). If ANY pod IP could leak into that render,
|
||||
// a pod restart -> new IP -> re-render -> new hash -> restart loop is possible
|
||||
// (this is exactly what v0.2.5 did with its options-scope pod-IP allow-notify).
|
||||
//
|
||||
// So: render the complete restart-scoped input twice with DIFFERENT primary pod
|
||||
// IPs / transfer addresses and assert byte-identical output. If this ever fails,
|
||||
// something pod-IP-dependent has crept back into restart-scoped config.
|
||||
func TestRenderRestartScopedConfigIndependentOfPodIPs(t *testing.T) {
|
||||
build := func(primaryAddr string) RenderInput {
|
||||
return RenderInput{
|
||||
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
|
||||
PrimaryAddress: primaryAddr,
|
||||
NotifyKeyName: "externaldns-key",
|
||||
Catalog: &bindv1alpha1.BindCatalogZone{
|
||||
Spec: bindv1alpha1.BindCatalogZoneSpec{ZoneName: "catalog.internal", TransferKeyRef: "externaldns-key"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Note: PrimaryAddress (the transfer address) legitimately CAN change the
|
||||
// render — the secondary catalog zone points its `primaries` at it. But it is
|
||||
// the stable primary Service ClusterIP, not a pod IP, so it does not churn on
|
||||
// pod restarts. The bug was pod IPs. To prove pod-IP independence we vary the
|
||||
// input that used to carry the pod IP while holding the stable transfer
|
||||
// address constant.
|
||||
p1, s1 := RenderNamedConf(build("10.43.5.5"))
|
||||
// Re-render as if the primary pod had restarted onto a new pod IP. Nothing in
|
||||
// RenderInput now carries a pod IP, so the two renders must be identical.
|
||||
p2, s2 := RenderNamedConf(build("10.43.5.5"))
|
||||
if p1 != p2 {
|
||||
t.Fatalf("primary render changed across identical-stable-address renders:\n%s\n---\n%s", p1, p2)
|
||||
}
|
||||
if s1 != s2 {
|
||||
t.Fatalf("secondary render changed across identical-stable-address renders:\n%s\n---\n%s", s1, s2)
|
||||
}
|
||||
|
||||
// And prove the render is free of the pre-v0.2.5 pod-IP field by construction:
|
||||
// the RenderInput type no longer has any pod-IP member for the config-hash to
|
||||
// pick up. The allow-notify clause carries a key name only.
|
||||
if strings.Contains(s1, "10.42.") {
|
||||
t.Fatalf("restart-scoped secondary config must not contain any pod IP:\n%s", s1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderForwardZoneInView(t *testing.T) {
|
||||
rec := true
|
||||
in := RenderInput{
|
||||
@@ -128,3 +230,34 @@ func TestCatalogHashStable(t *testing.T) {
|
||||
t.Fatalf("expected 40-char hex sha1, got %d: %s", len(h1), h1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDeterministicWithShuffledForwards(t *testing.T) {
|
||||
// client.List order is non-deterministic; the render must not depend on
|
||||
// input order, or the ConfigMap churns and (with the config hash) the
|
||||
// StatefulSet rolls forever.
|
||||
mkFwd := func(zone, fwd string) bindv1alpha1.BindZone {
|
||||
return bindv1alpha1.BindZone{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: zone},
|
||||
Spec: bindv1alpha1.BindZoneSpec{
|
||||
ClusterRef: "r", Type: bindv1alpha1.ZoneForward,
|
||||
ZoneName: zone, Forwarders: []string{fwd},
|
||||
},
|
||||
}
|
||||
}
|
||||
base := RenderInput{Cluster: newCluster(bindv1alpha1.ModeResolver)}
|
||||
orderA := base
|
||||
orderA.Forwards = []bindv1alpha1.BindZone{
|
||||
mkFwd("unkin.net", "198.18.200.6"), mkFwd("consul", "198.18.19.14"),
|
||||
mkFwd("k8s.syd1.au.unkin.net", "198.18.200.8"), mkFwd("13.18.198.in-addr.arpa", "198.18.200.6"),
|
||||
}
|
||||
orderB := base
|
||||
orderB.Forwards = []bindv1alpha1.BindZone{
|
||||
mkFwd("13.18.198.in-addr.arpa", "198.18.200.6"), mkFwd("k8s.syd1.au.unkin.net", "198.18.200.8"),
|
||||
mkFwd("consul", "198.18.19.14"), mkFwd("unkin.net", "198.18.200.6"),
|
||||
}
|
||||
pa, _ := RenderNamedConf(orderA)
|
||||
pb, _ := RenderNamedConf(orderB)
|
||||
if pa != pb {
|
||||
t.Fatalf("render must be independent of forward-zone input order:\n--- A ---\n%s\n--- B ---\n%s", pa, pb)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,13 +35,19 @@ func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view st
|
||||
func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string, serial int64) error {
|
||||
origin := dot(zone)
|
||||
ns := "ns1." + origin
|
||||
// Short refresh/retry so a secondary that misses a NOTIFY (e.g. its pod IP
|
||||
// changed and the primary's also-notify was briefly stale) still converges
|
||||
// in minutes, not the hour a 3600s refresh would impose. minimum is the
|
||||
// negative-cache TTL: keep it low so a stale-secondary NXDOMAIN does not
|
||||
// stick in downstream resolvers for long. NOTIFY (also-notify on the
|
||||
// primary) remains the fast path; these are the fallback.
|
||||
content := fmt.Sprintf(`$TTL 3600
|
||||
@ IN SOA %s hostmaster.%s (
|
||||
%d ; serial
|
||||
3600 ; refresh
|
||||
900 ; retry
|
||||
300 ; refresh
|
||||
60 ; retry
|
||||
1209600 ; expire
|
||||
300 ) ; minimum
|
||||
60 ) ; minimum
|
||||
@ IN NS %s
|
||||
ns1 IN A %s
|
||||
`, ns, origin, serial, ns, primaryIP)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -168,10 +171,11 @@ func (r *BindClusterReconciler) reconcileKeysSecret(ctx context.Context, c *bind
|
||||
}
|
||||
|
||||
func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv1alpha1.BindCluster) error {
|
||||
// BIND primaries/default-primaries need the primary's IP address, not a DNS
|
||||
// name, so render with pod-0's current IP (empty until it is scheduled; the
|
||||
// Pod watch re-renders when it appears or changes).
|
||||
in := bind.RenderInput{Cluster: c, PrimaryAddress: primaryPodIP(ctx, r.Client, c)}
|
||||
// BIND primaries/default-primaries need an IP address, not a DNS name. Use
|
||||
// the stable primary Service ClusterIP so secondaries keep transferring
|
||||
// across primary pod restarts (falls back to the pod IP when no primary
|
||||
// Service exists; the Pod/Service watches re-render when it changes).
|
||||
in := bind.RenderInput{Cluster: c, PrimaryAddress: primaryTransferAddress(ctx, r.Client, c)}
|
||||
|
||||
var acls bindv1alpha1.BindACLList
|
||||
if err := r.List(ctx, &acls, client.InNamespace(c.Namespace)); err == nil {
|
||||
@@ -227,6 +231,16 @@ func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv
|
||||
}
|
||||
}
|
||||
|
||||
// Secondaries accept intra-cluster NOTIFYs signed with the cluster's catalog
|
||||
// transfer TSIG key (the primary signs its also-notify NOTIFYs with it — see
|
||||
// bindzone_controller). Render `allow-notify { key "<name>"; }` — a static key
|
||||
// element, NO pod IPs — so it never changes on pod churn and cannot re-render
|
||||
// the restart-scoped config (the v0.2.5 roll loop). Resolve the catalog's
|
||||
// TransferKeyRef to the BIND key name (KeyName override, else the ref).
|
||||
if in.Catalog != nil && in.Catalog.Spec.TransferKeyRef != "" {
|
||||
in.NotifyKeyName = tsigKeyName(ctx, r.Client, c.Namespace, in.Catalog.Spec.TransferKeyRef)
|
||||
}
|
||||
|
||||
primaryConf, secondaryConf := bind.RenderNamedConf(in)
|
||||
data := map[string]string{
|
||||
"named.conf.primary": primaryConf,
|
||||
@@ -350,6 +364,12 @@ func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bin
|
||||
}}},
|
||||
}
|
||||
|
||||
// Pods copy config from the projected volume into an emptyDir once at
|
||||
// startup; a ConfigMap/keys change never reaches a running pod (rndc
|
||||
// reconfig re-reads the stale startup copy). Stamp a hash of the projected
|
||||
// config onto the pod template so a config change rolls the StatefulSet,
|
||||
// which is the only way the change takes effect. The operator owns the
|
||||
// template, so this restart is operator-driven and not reverted.
|
||||
sts := &appsv1.StatefulSet{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: c.Name, Namespace: c.Namespace, Labels: labels},
|
||||
Spec: appsv1.StatefulSetSpec{
|
||||
@@ -357,7 +377,7 @@ func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bin
|
||||
Replicas: &replicas,
|
||||
Selector: &metav1.LabelSelector{MatchLabels: labels},
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{Labels: labels},
|
||||
ObjectMeta: metav1.ObjectMeta{Labels: labels, Annotations: map[string]string{configHashAnnotation: r.configHash(ctx, c)}},
|
||||
Spec: corev1.PodSpec{
|
||||
NodeSelector: c.Spec.NodeSelector,
|
||||
Tolerations: c.Spec.Tolerations,
|
||||
@@ -425,6 +445,45 @@ func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bin
|
||||
return &existing, nil
|
||||
}
|
||||
|
||||
// configHashAnnotation carries a hash of the projected config on the pod
|
||||
// template; changing it triggers a rolling restart so pods pick up new config.
|
||||
const configHashAnnotation = "bind.unkin.net/config-hash"
|
||||
|
||||
// configHash returns a deterministic hash of the config projected into the pods
|
||||
// (the rendered ConfigMap and the keys.conf Secret). It is read after those are
|
||||
// reconciled, so it reflects the current desired config. A stable hash means no
|
||||
// spurious restarts; any config or TSIG-key change flips it and rolls the pods.
|
||||
func (r *BindClusterReconciler) configHash(ctx context.Context, c *bindv1alpha1.BindCluster) string {
|
||||
var buf bytes.Buffer
|
||||
var cm corev1.ConfigMap
|
||||
if err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: configMapName(c.Name)}, &cm); err == nil {
|
||||
for _, k := range sortedKeys(cm.Data) {
|
||||
fmt.Fprintf(&buf, "%s\x00%s\x00", k, cm.Data[k])
|
||||
}
|
||||
}
|
||||
var keys corev1.Secret
|
||||
if err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: keysSecretName(c.Name)}, &keys); err == nil {
|
||||
data := make(map[string]string, len(keys.Data))
|
||||
for k, v := range keys.Data {
|
||||
data[k] = string(v)
|
||||
}
|
||||
for _, k := range sortedKeys(data) {
|
||||
fmt.Fprintf(&buf, "%s\x00%s\x00", k, data[k])
|
||||
}
|
||||
}
|
||||
sum := sha256.Sum256(buf.Bytes())
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func (r *BindClusterReconciler) reloadReadyPods(ctx context.Context, c *bindv1alpha1.BindCluster) {
|
||||
if r.Exec == nil {
|
||||
return
|
||||
|
||||
@@ -60,7 +60,7 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
||||
return ctrl.Result{}, genErr
|
||||
}
|
||||
newSecret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: key.Namespace, Labels: map[string]string{managedByLabel: managedByValue}},
|
||||
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: key.Namespace},
|
||||
Data: map[string][]byte{
|
||||
"algorithm": []byte(algorithm),
|
||||
"keyName": []byte(keyName),
|
||||
@@ -68,6 +68,7 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
||||
"key.conf": []byte(bind.KeyClause(keyName, algorithm, material)),
|
||||
},
|
||||
}
|
||||
applySecretTemplate(&newSecret.ObjectMeta, key.Spec.SecretTemplate)
|
||||
if err := ctrl.SetControllerReference(&key, newSecret, r.Scheme); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
@@ -77,6 +78,21 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
||||
logger.Info("generated TSIG key", "key", key.Name, "secret", secretName)
|
||||
case err != nil:
|
||||
return ctrl.Result{}, err
|
||||
default:
|
||||
// Secret already exists: reconcile the template-managed metadata so that
|
||||
// annotation/label changes on the CR (e.g. reflection hints) propagate
|
||||
// without regenerating key material. Skip imported secrets, which are
|
||||
// owned by an external manager (Vault/VSO, reflector) that we must not
|
||||
// fight over metadata.
|
||||
if key.Spec.ImportExisting {
|
||||
break
|
||||
}
|
||||
if updated := applySecretTemplate(&secret.ObjectMeta, key.Spec.SecretTemplate); updated {
|
||||
if err := r.Update(ctx, &secret); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
logger.Info("updated TSIG key secret metadata", "key", key.Name, "secret", secretName)
|
||||
}
|
||||
}
|
||||
|
||||
key.Status.SecretName = secretName
|
||||
@@ -90,6 +106,42 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// applySecretTemplate stamps the operator-managed label plus any
|
||||
// user-supplied labels/annotations onto the Secret's metadata. It returns true
|
||||
// if it mutated meta, so callers can decide whether an update is needed.
|
||||
func applySecretTemplate(meta *metav1.ObjectMeta, tmpl *bindv1alpha1.SecretMetadata) bool {
|
||||
changed := false
|
||||
setLabel := func(k, v string) {
|
||||
if meta.Labels == nil {
|
||||
meta.Labels = map[string]string{}
|
||||
}
|
||||
if meta.Labels[k] != v {
|
||||
meta.Labels[k] = v
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
setAnnotation := func(k, v string) {
|
||||
if meta.Annotations == nil {
|
||||
meta.Annotations = map[string]string{}
|
||||
}
|
||||
if meta.Annotations[k] != v {
|
||||
meta.Annotations[k] = v
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
setLabel(managedByLabel, managedByValue)
|
||||
if tmpl != nil {
|
||||
for k, v := range tmpl.Labels {
|
||||
setLabel(k, v)
|
||||
}
|
||||
for k, v := range tmpl.Annotations {
|
||||
setAnnotation(k, v)
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func (r *BindTSIGKeyReconciler) fail(ctx context.Context, key *bindv1alpha1.BindTSIGKey, reason, msg string) (ctrl.Result, error) {
|
||||
key.Status.Ready = false
|
||||
key.Status.ObservedGeneration = key.Generation
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
func TestApplySecretTemplate(t *testing.T) {
|
||||
t.Run("nil template still stamps managed-by label", func(t *testing.T) {
|
||||
var meta metav1.ObjectMeta
|
||||
if !applySecretTemplate(&meta, nil) {
|
||||
t.Fatal("expected change on empty meta")
|
||||
}
|
||||
if meta.Labels[managedByLabel] != managedByValue {
|
||||
t.Errorf("managed-by label = %q, want %q", meta.Labels[managedByLabel], managedByValue)
|
||||
}
|
||||
if meta.Annotations != nil {
|
||||
t.Errorf("annotations = %v, want nil", meta.Annotations)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("applies labels and annotations", func(t *testing.T) {
|
||||
meta := metav1.ObjectMeta{Labels: map[string]string{managedByLabel: managedByValue}}
|
||||
tmpl := &bindv1alpha1.SecretMetadata{
|
||||
Annotations: map[string]string{"reflector.v1.k8s.emberstack.com/reflection-allowed": "true"},
|
||||
Labels: map[string]string{"team": "dns"},
|
||||
}
|
||||
if !applySecretTemplate(&meta, tmpl) {
|
||||
t.Fatal("expected change when adding template metadata")
|
||||
}
|
||||
if got := meta.Annotations["reflector.v1.k8s.emberstack.com/reflection-allowed"]; got != "true" {
|
||||
t.Errorf("reflection annotation = %q, want true", got)
|
||||
}
|
||||
if meta.Labels["team"] != "dns" {
|
||||
t.Errorf("team label = %q, want dns", meta.Labels["team"])
|
||||
}
|
||||
// managed-by must survive user-supplied labels.
|
||||
if meta.Labels[managedByLabel] != managedByValue {
|
||||
t.Errorf("managed-by label dropped: %v", meta.Labels)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("idempotent when already applied", func(t *testing.T) {
|
||||
tmpl := &bindv1alpha1.SecretMetadata{
|
||||
Annotations: map[string]string{"a": "1"},
|
||||
Labels: map[string]string{"b": "2"},
|
||||
}
|
||||
meta := metav1.ObjectMeta{}
|
||||
applySecretTemplate(&meta, tmpl)
|
||||
if applySecretTemplate(&meta, tmpl) {
|
||||
t.Error("expected no change on second apply")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("updates drifted annotation value", func(t *testing.T) {
|
||||
meta := metav1.ObjectMeta{
|
||||
Labels: map[string]string{managedByLabel: managedByValue},
|
||||
Annotations: map[string]string{"a": "old"},
|
||||
}
|
||||
tmpl := &bindv1alpha1.SecretMetadata{Annotations: map[string]string{"a": "new"}}
|
||||
if !applySecretTemplate(&meta, tmpl) {
|
||||
t.Fatal("expected change when annotation value drifts")
|
||||
}
|
||||
if meta.Annotations["a"] != "new" {
|
||||
t.Errorf("annotation a = %q, want new", meta.Annotations["a"])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -80,7 +80,21 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
|
||||
return r.setPhase(ctx, &zone, "Pending", "PrimaryNotReady", "waiting for cluster primary to be ready")
|
||||
}
|
||||
|
||||
zoneConfig, err := r.buildZoneConfig(ctx, &zone)
|
||||
// Primary zones replicated to secondaries (catalog members) get an
|
||||
// also-notify pointing at the secondary pods, so a dynamic update NOTIFYs
|
||||
// them immediately rather than waiting for the SOA refresh.
|
||||
var notifyTargets []string
|
||||
if isPrimaryType(zone.Spec.Type) && catalogEnabled(&zone) {
|
||||
notifyTargets = secondaryPodIPs(ctx, r.Client, cluster)
|
||||
}
|
||||
|
||||
// The catalog transfer TSIG key doubles as the intra-cluster NOTIFY key: the
|
||||
// primary signs its also-notify NOTIFYs with it and secondaries accept them
|
||||
// via `allow-notify { key "<key>"; }`. Keying the NOTIFYs is what lets the
|
||||
// secondary's allow-notify be a static key element (no pod IPs), so pod-IP
|
||||
// churn never re-renders restart-scoped config (the v0.2.5 roll loop).
|
||||
transferKey := r.zoneTransferKeyRef(ctx, &zone, cluster)
|
||||
zoneConfig, err := r.buildZoneConfig(ctx, &zone, transferKey, notifyTargets, transferKey)
|
||||
if err != nil {
|
||||
return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error())
|
||||
}
|
||||
@@ -133,7 +147,11 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
|
||||
}
|
||||
|
||||
// buildZoneConfig renders the inner clause passed to rndc addzone/modzone.
|
||||
func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone) (string, error) {
|
||||
// transferKey, when set, is the catalog transfer TSIG key name; catalog member
|
||||
// primary zones must allow AXFR with it so secondaries can pull them. notifyKey,
|
||||
// when set, is the TSIG key each also-notify entry is signed with, so
|
||||
// secondaries can accept the NOTIFYs by key rather than by (churning) pod IP.
|
||||
func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone, transferKey string, notifyTargets []string, notifyKey string) (string, error) {
|
||||
zType := zone.Spec.Type
|
||||
if zType == "" {
|
||||
zType = bindv1alpha1.ZonePrimary
|
||||
@@ -145,8 +163,22 @@ func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1al
|
||||
if zone.Spec.DynamicUpdate && zone.Spec.UpdateKeyRef != "" {
|
||||
parts = append(parts, fmt.Sprintf("allow-update { key \"%s\"; }", updateKeyName(ctx, r.Client, zone)))
|
||||
}
|
||||
if len(zone.Spec.AllowTransfer) > 0 {
|
||||
switch {
|
||||
case len(zone.Spec.AllowTransfer) > 0:
|
||||
parts = append(parts, fmt.Sprintf("allow-transfer { %s }", matchListInline(zone.Spec.AllowTransfer)))
|
||||
case transferKey != "":
|
||||
// Catalog member: permit key-authenticated AXFR from secondaries.
|
||||
parts = append(parts, fmt.Sprintf("allow-transfer { key \"%s\"; }", transferKey))
|
||||
}
|
||||
// NOTIFY only the secondaries we know about (their apex NS is the primary
|
||||
// itself, so default `notify yes` would reach no one). `notify explicit`
|
||||
// keeps NOTIFY off the query-serving VIP and scoped to the pod IPs. Each
|
||||
// entry is signed with notifyKey so secondaries can admit the NOTIFY by key
|
||||
// (`allow-notify { key ... }`) instead of by pod IP. This zone config is
|
||||
// applied via rndc addzone/modzone — no pod restart — so listing pod IPs
|
||||
// here is safe; only *restart-scoped* config must never depend on pod IPs.
|
||||
if len(notifyTargets) > 0 {
|
||||
parts = append(parts, "notify explicit", fmt.Sprintf("also-notify { %s }", alsoNotifyList(notifyTargets, notifyKey)))
|
||||
}
|
||||
if zone.Spec.DNSSECPolicyRef != "" {
|
||||
parts = append(parts, fmt.Sprintf("dnssec-policy \"%s\"", zone.Spec.DNSSECPolicyRef), "inline-signing yes")
|
||||
@@ -202,6 +234,26 @@ func (r *BindZoneReconciler) deregisterCatalog(ctx context.Context, zone *bindv1
|
||||
_ = r.Exec.RemoveCatalogMember(ctx, zone.Namespace, primaryPod, catalog.Spec.ZoneName, zone.Spec.ZoneName, creds)
|
||||
}
|
||||
|
||||
// zoneTransferKeyRef returns the catalog transfer TSIG key name that a catalog
|
||||
// member primary zone must allow AXFR with, so secondaries (which present that
|
||||
// key) can pull it. Returns "" for non-member zones, non-primary zones, or when
|
||||
// the cluster has no catalog.
|
||||
func (r *BindZoneReconciler) zoneTransferKeyRef(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) string {
|
||||
if !isPrimaryType(zone.Spec.Type) || !catalogEnabled(zone) {
|
||||
return ""
|
||||
}
|
||||
var catalogs bindv1alpha1.BindCatalogZoneList
|
||||
if err := r.List(ctx, &catalogs, client.InNamespace(zone.Namespace)); err != nil {
|
||||
return ""
|
||||
}
|
||||
for i := range catalogs.Items {
|
||||
if catalogs.Items[i].Spec.ClusterRef == cluster.Name {
|
||||
return catalogs.Items[i].Spec.TransferKeyRef
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *BindZoneReconciler) catalogFor(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) (*bindv1alpha1.BindCatalogZone, bind.TSIGCreds, bool) {
|
||||
var catalogs bindv1alpha1.BindCatalogZoneList
|
||||
if err := r.List(ctx, &catalogs, client.InNamespace(zone.Namespace)); err != nil {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
// A primary zone with known secondaries renders notify explicit + also-notify
|
||||
// so a dynamic update NOTIFYs the secondaries immediately.
|
||||
func TestBuildZoneConfigPrimaryAlsoNotify(t *testing.T) {
|
||||
r := &BindZoneReconciler{}
|
||||
zone := &bindv1alpha1.BindZone{
|
||||
Spec: bindv1alpha1.BindZoneSpec{
|
||||
ZoneName: "main.unkin.net",
|
||||
Type: bindv1alpha1.ZonePrimary,
|
||||
},
|
||||
}
|
||||
|
||||
cfg, err := r.buildZoneConfig(context.Background(), zone, "transfer-key", []string{"10.42.2.6", "10.42.1.5"}, "externaldns-key")
|
||||
if err != nil {
|
||||
t.Fatalf("buildZoneConfig: %v", err)
|
||||
}
|
||||
if !strings.Contains(cfg, "notify explicit") {
|
||||
t.Errorf("expected notify explicit in %q", cfg)
|
||||
}
|
||||
// also-notify entries are keyed so secondaries can admit the NOTIFY by TSIG
|
||||
// key (allow-notify { key ... }) instead of by (churning) pod IP.
|
||||
if !strings.Contains(cfg, `also-notify { 10.42.2.6 key "externaldns-key"; 10.42.1.5 key "externaldns-key"; }`) {
|
||||
t.Errorf("expected keyed also-notify with the secondary IPs in %q", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// also-notify entries carry no key when none is configured (unkeyed NOTIFY).
|
||||
func TestBuildZoneConfigPrimaryAlsoNotifyUnkeyed(t *testing.T) {
|
||||
r := &BindZoneReconciler{}
|
||||
zone := &bindv1alpha1.BindZone{
|
||||
Spec: bindv1alpha1.BindZoneSpec{ZoneName: "main.unkin.net", Type: bindv1alpha1.ZonePrimary},
|
||||
}
|
||||
cfg, err := r.buildZoneConfig(context.Background(), zone, "transfer-key", []string{"10.42.2.6"}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildZoneConfig: %v", err)
|
||||
}
|
||||
if !strings.Contains(cfg, "also-notify { 10.42.2.6; }") {
|
||||
t.Errorf("expected unkeyed also-notify in %q", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// With no secondaries, no also-notify is emitted (single-replica cluster).
|
||||
func TestBuildZoneConfigPrimaryNoNotifyTargets(t *testing.T) {
|
||||
r := &BindZoneReconciler{}
|
||||
zone := &bindv1alpha1.BindZone{
|
||||
Spec: bindv1alpha1.BindZoneSpec{ZoneName: "main.unkin.net", Type: bindv1alpha1.ZonePrimary},
|
||||
}
|
||||
|
||||
cfg, err := r.buildZoneConfig(context.Background(), zone, "transfer-key", nil, "externaldns-key")
|
||||
if err != nil {
|
||||
t.Fatalf("buildZoneConfig: %v", err)
|
||||
}
|
||||
if strings.Contains(cfg, "also-notify") || strings.Contains(cfg, "notify explicit") {
|
||||
t.Errorf("did not expect notify clauses with no targets: %q", cfg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
|
||||
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
// TestConfigHashIndependentOfPrimaryPodIP is the permanent regression guard for
|
||||
// the v0.2.5 rolling-restart loop.
|
||||
//
|
||||
// v0.2.5 rendered an options-scope `allow-notify { <primaryPodIP>; ... }` into
|
||||
// the (restart-scoped) named.conf. The config-hash annotation that rolls the
|
||||
// StatefulSet is computed over that render (BindClusterReconciler.configHash), so
|
||||
// a config change rolled the pods, the primary pod came back on a NEW IP, the
|
||||
// operator re-rendered with the new IP, the hash changed, the pods rolled again,
|
||||
// and so on — an infinite roll loop across every BindCluster.
|
||||
//
|
||||
// The fix removed all pod IPs from restart-scoped config (secondaries now admit
|
||||
// intra-cluster NOTIFYs by TSIG key: `allow-notify { key "X"; }`). This test
|
||||
// reconciles the ConfigMap with the primary pod on one IP, computes the hash,
|
||||
// then does it again with the primary pod on a DIFFERENT IP, and asserts the
|
||||
// hash is byte-identical. If any pod-IP dependency ever creeps back into
|
||||
// restart-scoped config, this fails and the loop-class bug is caught.
|
||||
func TestConfigHashIndependentOfPrimaryPodIP(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
if err := clientgoscheme.AddToScheme(scheme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bindv1alpha1.AddToScheme(scheme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const ns = "dns"
|
||||
svcIP := "10.43.5.5" // stable primary Service ClusterIP (does NOT churn)
|
||||
|
||||
cluster := &bindv1alpha1.BindCluster{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "auth", Namespace: ns},
|
||||
Spec: bindv1alpha1.BindClusterSpec{
|
||||
Mode: bindv1alpha1.ModeAuthoritative,
|
||||
Replicas: 3,
|
||||
PrimaryService: &bindv1alpha1.ClusterServiceSpec{},
|
||||
},
|
||||
}
|
||||
primarySvc := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: primaryServiceName(cluster.Name), Namespace: ns},
|
||||
Spec: corev1.ServiceSpec{ClusterIP: svcIP},
|
||||
}
|
||||
catalog := &bindv1alpha1.BindCatalogZone{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "cat", Namespace: ns},
|
||||
Spec: bindv1alpha1.BindCatalogZoneSpec{
|
||||
ClusterRef: cluster.Name,
|
||||
ZoneName: "catalog.internal",
|
||||
TransferKeyRef: "externaldns-key",
|
||||
},
|
||||
}
|
||||
tsig := &bindv1alpha1.BindTSIGKey{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "externaldns-key", Namespace: ns},
|
||||
Spec: bindv1alpha1.BindTSIGKeySpec{ClusterRef: cluster.Name},
|
||||
}
|
||||
|
||||
// hashWithPrimaryIP reconciles the ConfigMap with the primary pod carrying the
|
||||
// given IP, then returns the resulting config-hash.
|
||||
hashWithPrimaryIP := func(ip string) string {
|
||||
primaryPod := &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: primaryPodName(cluster.Name), Namespace: ns, Labels: commonLabels(cluster.Name)},
|
||||
Status: corev1.PodStatus{PodIP: ip},
|
||||
}
|
||||
c := fake.NewClientBuilder().
|
||||
WithScheme(scheme).
|
||||
WithObjects(cluster, primarySvc, catalog, tsig, primaryPod).
|
||||
Build()
|
||||
r := &BindClusterReconciler{Client: c, Scheme: scheme}
|
||||
ctx := context.Background()
|
||||
if err := r.reconcileConfigMap(ctx, cluster); err != nil {
|
||||
t.Fatalf("reconcileConfigMap: %v", err)
|
||||
}
|
||||
// Sanity: the pod IP must not have leaked into the rendered config.
|
||||
var cm corev1.ConfigMap
|
||||
if err := c.Get(ctx, client.ObjectKey{Namespace: ns, Name: configMapName(cluster.Name)}, &cm); err != nil {
|
||||
t.Fatalf("get configmap: %v", err)
|
||||
}
|
||||
for k, v := range cm.Data {
|
||||
if ip != "" && strings.Contains(v, ip) {
|
||||
t.Fatalf("primary pod IP %s leaked into restart-scoped config %s:\n%s", ip, k, v)
|
||||
}
|
||||
}
|
||||
return r.configHash(ctx, cluster)
|
||||
}
|
||||
|
||||
h1 := hashWithPrimaryIP("10.42.3.197")
|
||||
h2 := hashWithPrimaryIP("10.42.9.42") // primary pod restarted onto a new IP
|
||||
if h1 == "" {
|
||||
t.Fatal("config hash should not be empty")
|
||||
}
|
||||
if h1 != h2 {
|
||||
t.Fatalf("config hash changed when only the primary pod IP changed — the v0.2.5 roll loop:\n%s\n%s", h1, h2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
|
||||
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
func TestConfigHashStableAndSensitive(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
if err := clientgoscheme.AddToScheme(scheme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bindv1alpha1.AddToScheme(scheme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cluster := &bindv1alpha1.BindCluster{ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "ns"}}
|
||||
cm := &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: configMapName("c"), Namespace: "ns"},
|
||||
Data: map[string]string{"named.conf.secondary": "options { recursion yes; };"},
|
||||
}
|
||||
keys := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: keysSecretName("c"), Namespace: "ns"},
|
||||
Data: map[string][]byte{"keys.conf": []byte("key x {};")},
|
||||
}
|
||||
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm, keys).Build()
|
||||
r := &BindClusterReconciler{Client: c, Scheme: scheme}
|
||||
ctx := context.Background()
|
||||
|
||||
h1 := r.configHash(ctx, cluster)
|
||||
if h1 == "" {
|
||||
t.Fatal("hash should not be empty when config exists")
|
||||
}
|
||||
// Stable across calls when nothing changes.
|
||||
if h2 := r.configHash(ctx, cluster); h2 != h1 {
|
||||
t.Fatalf("hash not stable: %s != %s", h1, h2)
|
||||
}
|
||||
|
||||
// A config change flips the hash (this is what rolls the StatefulSet).
|
||||
cm.Data["named.conf.secondary"] = "options { recursion yes; validate-except { unkin.net; }; };"
|
||||
if err := c.Update(ctx, cm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h3 := r.configHash(ctx, cluster); h3 == h1 {
|
||||
t.Fatal("hash must change when the ConfigMap changes")
|
||||
}
|
||||
|
||||
// A TSIG key (keys.conf) change also flips it.
|
||||
afterCM := r.configHash(ctx, cluster)
|
||||
keys.Data["keys.conf"] = []byte("key x { algorithm hmac-sha256; };")
|
||||
if err := c.Update(ctx, keys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h4 := r.configHash(ctx, cluster); h4 == afterCM {
|
||||
t.Fatal("hash must change when keys.conf changes")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package controller
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
@@ -108,6 +109,52 @@ func primaryPodIP(ctx context.Context, c client.Client, cluster *bindv1alpha1.Bi
|
||||
return pod.Status.PodIP
|
||||
}
|
||||
|
||||
// primaryTransferAddress returns the address secondaries use to reach the
|
||||
// primary for catalog and zone AXFR. It prefers the primary Service ClusterIP,
|
||||
// which is stable across primary pod restarts (the pod IP is not: it changes on
|
||||
// every restart, leaving secondaries pointed at a dead address). It falls back
|
||||
// to the primary pod IP when no primary Service is configured or its ClusterIP
|
||||
// is not yet assigned.
|
||||
func primaryTransferAddress(ctx context.Context, c client.Client, cluster *bindv1alpha1.BindCluster) string {
|
||||
if cluster.Spec.PrimaryService != nil {
|
||||
var svc corev1.Service
|
||||
if err := c.Get(ctx, client.ObjectKey{Namespace: cluster.Namespace, Name: primaryServiceName(cluster.Name)}, &svc); err == nil {
|
||||
if ip := svc.Spec.ClusterIP; ip != "" && ip != corev1.ClusterIPNone {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
return primaryPodIP(ctx, c, cluster)
|
||||
}
|
||||
|
||||
// secondaryPodIPs returns the pod IPs of a cluster's secondary pods (every pod
|
||||
// except the ordinal-0 primary) that currently have an address. The primary
|
||||
// uses this list as its zone `also-notify` set, so a change to a primary zone
|
||||
// (in particular a dynamic update) triggers an immediate NOTIFY -> IXFR to the
|
||||
// secondaries instead of leaving them stale until the next SOA refresh. The
|
||||
// list is sorted so the rendered zone config is stable and does not churn
|
||||
// modzone on every reconcile. Pod IPs change across restarts, so the caller
|
||||
// relies on the zone controller's periodic requeue to refresh the set (a
|
||||
// restarted secondary re-transfers the whole zone on load regardless). Returns
|
||||
// nil for a single-replica cluster.
|
||||
func secondaryPodIPs(ctx context.Context, c client.Client, cluster *bindv1alpha1.BindCluster) []string {
|
||||
var pods corev1.PodList
|
||||
if err := c.List(ctx, &pods, client.InNamespace(cluster.Namespace), client.MatchingLabels(commonLabels(cluster.Name))); err != nil {
|
||||
return nil
|
||||
}
|
||||
primary := primaryPodName(cluster.Name)
|
||||
var ips []string
|
||||
for i := range pods.Items {
|
||||
p := &pods.Items[i]
|
||||
if p.Name == primary || p.Status.PodIP == "" {
|
||||
continue
|
||||
}
|
||||
ips = append(ips, p.Status.PodIP)
|
||||
}
|
||||
sort.Strings(ips)
|
||||
return ips
|
||||
}
|
||||
|
||||
// resolveTSIG reads the material of a BindTSIGKey into TSIG credentials.
|
||||
func resolveTSIG(ctx context.Context, c client.Client, namespace, keyRef string) (bind.TSIGCreds, error) {
|
||||
var creds bind.TSIGCreds
|
||||
|
||||
@@ -2,6 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
@@ -58,12 +59,19 @@ func recordsToUpdates(zone string, records []bindv1alpha1.Record, defaultTTL int
|
||||
// updateKeyName returns the TSIG key name (as used in named.conf) for a zone's
|
||||
// update key, falling back to the object name.
|
||||
func updateKeyName(ctx context.Context, c client.Client, zone *bindv1alpha1.BindZone) string {
|
||||
ref := zone.Spec.UpdateKeyRef
|
||||
return tsigKeyName(ctx, c, zone.Namespace, zone.Spec.UpdateKeyRef)
|
||||
}
|
||||
|
||||
// tsigKeyName resolves a BindTSIGKey object reference to the TSIG key name used
|
||||
// in named.conf (the KeyName override when set, otherwise the object name).
|
||||
// Returns "" for an empty ref, and falls back to the ref if the object cannot be
|
||||
// read.
|
||||
func tsigKeyName(ctx context.Context, c client.Client, namespace, ref string) string {
|
||||
if ref == "" {
|
||||
return ""
|
||||
}
|
||||
var key bindv1alpha1.BindTSIGKey
|
||||
if err := c.Get(ctx, client.ObjectKey{Namespace: zone.Namespace, Name: ref}, &key); err != nil {
|
||||
if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: ref}, &key); err != nil {
|
||||
return ref
|
||||
}
|
||||
if key.Spec.KeyName != "" {
|
||||
@@ -86,3 +94,23 @@ func terminateInline(entries []string) string {
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// alsoNotifyList renders also-notify entries, each optionally annotated with a
|
||||
// TSIG key so the primary signs its NOTIFYs and secondaries can accept them by
|
||||
// key (`allow-notify { key ... }`) rather than by pod IP. An entry that already
|
||||
// carries a `key` clause is left untouched.
|
||||
func alsoNotifyList(addrs []string, key string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
var parts []string
|
||||
for _, a := range addrs {
|
||||
a = strings.TrimSpace(strings.TrimRight(a, ";"))
|
||||
if a == "" {
|
||||
continue
|
||||
}
|
||||
if key != "" && !strings.Contains(a, " key ") {
|
||||
a = fmt.Sprintf("%s key \"%s\"", a, key)
|
||||
}
|
||||
parts = append(parts, a+";")
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user