Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2894d85c60 | |||
| aab11457af | |||
| 671c43b05b | |||
| 7771711682 | |||
| 439aa9ea6b | |||
| 6a07f91ea1 | |||
| e4ed9cfdb2 | |||
| 9c81320df8 | |||
| 243e776b59 | |||
| 59612f157a | |||
| 8fac152537 | |||
| 2deea3e023 | |||
| ef8c41cb0f | |||
| ea330bd767 | |||
| 9ab475532c | |||
| 53db084c2d | |||
| 49df29a072 | |||
| ea3d71fa93 | |||
| 28ae6538cb | |||
| e0bd3973ed | |||
| 547d168c12 | |||
| 9bc4436c79 | |||
| f0e851c0bc | |||
| 55e80b467c |
@@ -8,3 +8,10 @@ steps:
|
|||||||
repo: git.unkin.net/unkin/bind-operator
|
repo: git.unkin.net/unkin/bind-operator
|
||||||
dockerfile: Dockerfile.operator
|
dockerfile: Dockerfile.operator
|
||||||
dry_run: true
|
dry_run: true
|
||||||
|
|
||||||
|
- name: docker-build-tsig-api
|
||||||
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
|
settings:
|
||||||
|
repo: git.unkin.net/unkin/bind-tsig-api
|
||||||
|
dockerfile: Dockerfile.tsigapi
|
||||||
|
dry_run: true
|
||||||
|
|||||||
@@ -15,3 +15,16 @@ steps:
|
|||||||
tags:
|
tags:
|
||||||
- ${CI_COMMIT_TAG}
|
- ${CI_COMMIT_TAG}
|
||||||
- latest
|
- latest
|
||||||
|
|
||||||
|
- name: docker-tsig-api
|
||||||
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
|
settings:
|
||||||
|
registry: git.unkin.net
|
||||||
|
repo: git.unkin.net/unkin/bind-tsig-api
|
||||||
|
dockerfile: Dockerfile.tsigapi
|
||||||
|
username: droneci
|
||||||
|
password:
|
||||||
|
from_secret: DRONECI_PASSWORD
|
||||||
|
tags:
|
||||||
|
- ${CI_COMMIT_TAG}
|
||||||
|
- latest
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o tsig-api ./cmd/tsigapi
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
|
||||||
|
COPY --from=builder /build/tsig-api /usr/local/bin/tsig-api
|
||||||
|
|
||||||
|
EXPOSE 8443
|
||||||
|
|
||||||
|
ENTRYPOINT ["tsig-api"]
|
||||||
@@ -42,7 +42,7 @@ script picks one based on the pod ordinal.
|
|||||||
| `BindZone` | A forward/reverse zone (`primary`/`secondary`/`forward`/`stub`), records inline, optional dynamic-update + DNSSEC + catalog membership. |
|
| `BindZone` | A forward/reverse zone (`primary`/`secondary`/`forward`/`stub`), records inline, optional dynamic-update + DNSSEC + catalog membership. |
|
||||||
| `DNSRecord` | A single record set applied via TSIG `nsupdate` — external-dns as a CRD. |
|
| `DNSRecord` | A single record set applied via TSIG `nsupdate` — external-dns as a CRD. |
|
||||||
| `BindView` | A split-horizon view (`match-clients`, ordering, per-view recursion). |
|
| `BindView` | A split-horizon view (`match-clients`, ordering, per-view recursion). |
|
||||||
| `BindTSIGKey` | A TSIG key; the operator generates material into a Secret (never stored in the CR). |
|
| `BindTSIGKey` | A TSIG key; the operator generates material into a Secret (never stored in the CR). `spec.secretTemplate` stamps extra labels/annotations onto that Secret (e.g. reflection hints to mirror it into another namespace). |
|
||||||
| `BindACL` | A reusable named `address_match_list`. |
|
| `BindACL` | A reusable named `address_match_list`. |
|
||||||
| `BindCatalogZone` | A BIND catalog zone so secondaries auto-provision member zones. |
|
| `BindCatalogZone` | A BIND catalog zone so secondaries auto-provision member zones. |
|
||||||
| `BindPolicy` | A Response Policy Zone (RPZ) / DNS firewall. |
|
| `BindPolicy` | A Response Policy Zone (RPZ) / DNS firewall. |
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ type ClusterServiceSpec struct {
|
|||||||
// +optional
|
// +optional
|
||||||
LoadBalancerIP string `json:"loadBalancerIP,omitempty"`
|
LoadBalancerIP string `json:"loadBalancerIP,omitempty"`
|
||||||
|
|
||||||
|
// ExternalTrafficPolicy for a LoadBalancer/NodePort Service. Local preserves
|
||||||
|
// client source IPs (required for source-IP ACLs on the DNS servers) but
|
||||||
|
// only routes to nodes running a pod. Defaults to Cluster.
|
||||||
|
// +kubebuilder:validation:Enum=Cluster;Local
|
||||||
|
// +optional
|
||||||
|
ExternalTrafficPolicy corev1.ServiceExternalTrafficPolicy `json:"externalTrafficPolicy,omitempty"`
|
||||||
|
|
||||||
// Annotations added to the client-facing Service (e.g. PureLB/MetalLB hints).
|
// Annotations added to the client-facing Service (e.g. PureLB/MetalLB hints).
|
||||||
// +optional
|
// +optional
|
||||||
Annotations map[string]string `json:"annotations,omitempty"`
|
Annotations map[string]string `json:"annotations,omitempty"`
|
||||||
@@ -98,10 +105,18 @@ type BindClusterSpec struct {
|
|||||||
// +optional
|
// +optional
|
||||||
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
|
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
|
||||||
|
|
||||||
// Service controls how the cluster is exposed.
|
// Service controls how the cluster is exposed for reads (all pods).
|
||||||
// +optional
|
// +optional
|
||||||
Service ClusterServiceSpec `json:"service,omitempty"`
|
Service ClusterServiceSpec `json:"service,omitempty"`
|
||||||
|
|
||||||
|
// PrimaryService, when set, creates an additional Service routing only to the
|
||||||
|
// primary pod (ordinal 0) — the write endpoint for RFC2136/nsupdate, since
|
||||||
|
// secondaries reject updates. Reads still use Service (all pods). Use
|
||||||
|
// ClusterIP for in-cluster writers (e.g. external-dns) or LoadBalancer for
|
||||||
|
// external writers.
|
||||||
|
// +optional
|
||||||
|
PrimaryService *ClusterServiceSpec `json:"primaryService,omitempty"`
|
||||||
|
|
||||||
// NodeSelector for the BIND pods.
|
// NodeSelector for the BIND pods.
|
||||||
// +optional
|
// +optional
|
||||||
NodeSelector map[string]string `json:"nodeSelector,omitempty"`
|
NodeSelector map[string]string `json:"nodeSelector,omitempty"`
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package v1alpha1
|
||||||
|
|
||||||
|
import (
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BindTSIGAPISpec configures the companion TSIG API that the operator deploys.
|
||||||
|
// The API exposes an HTTP contract (used by vault-plugin-secrets-bind-tsig) for
|
||||||
|
// creating, rotating and deleting TSIG keys; it does so by managing BindTSIGKey
|
||||||
|
// custom resources, which the operator then reconciles into key material.
|
||||||
|
type BindTSIGAPISpec struct {
|
||||||
|
// Image is the companion API container image.
|
||||||
|
// +kubebuilder:default="git.unkin.net/unkin/bind-tsig-api:latest"
|
||||||
|
// +optional
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
|
||||||
|
// ImagePullPolicy for the API container.
|
||||||
|
// +optional
|
||||||
|
ImagePullPolicy corev1.PullPolicy `json:"imagePullPolicy,omitempty"`
|
||||||
|
|
||||||
|
// Replicas of the API. Defaults to 1.
|
||||||
|
// +kubebuilder:default=1
|
||||||
|
// +optional
|
||||||
|
Replicas int32 `json:"replicas,omitempty"`
|
||||||
|
|
||||||
|
// Port the API listens on. Defaults to 8443.
|
||||||
|
// +kubebuilder:default=8443
|
||||||
|
// +optional
|
||||||
|
Port int32 `json:"port,omitempty"`
|
||||||
|
|
||||||
|
// TargetNamespace is where the API creates BindTSIGKey resources. Defaults
|
||||||
|
// to the API's own namespace.
|
||||||
|
// +optional
|
||||||
|
TargetNamespace string `json:"targetNamespace,omitempty"`
|
||||||
|
|
||||||
|
// TokenSecretName holds the master access token clients present to the API.
|
||||||
|
// The operator generates a token if the Secret does not exist, so a
|
||||||
|
// VaultStaticSecret may pre-seed it instead. Defaults to "<name>-token".
|
||||||
|
// +optional
|
||||||
|
TokenSecretName string `json:"tokenSecretName,omitempty"`
|
||||||
|
|
||||||
|
// Env are extra environment variables rendered into the API ConfigMap.
|
||||||
|
// +optional
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
|
||||||
|
// Service controls how the API is exposed (defaults to ClusterIP).
|
||||||
|
// +optional
|
||||||
|
Service ClusterServiceSpec `json:"service,omitempty"`
|
||||||
|
|
||||||
|
// Resources for the API container.
|
||||||
|
// +optional
|
||||||
|
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindTSIGAPIStatus reports observed API state.
|
||||||
|
type BindTSIGAPIStatus struct {
|
||||||
|
// +optional
|
||||||
|
Phase string `json:"phase,omitempty"`
|
||||||
|
// +optional
|
||||||
|
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
|
||||||
|
// Endpoint is the in-cluster URL clients (Vault) use to reach the API.
|
||||||
|
// +optional
|
||||||
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
|
// TokenSecret is the Secret holding the master access token.
|
||||||
|
// +optional
|
||||||
|
TokenSecret string `json:"tokenSecret,omitempty"`
|
||||||
|
// +optional
|
||||||
|
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||||
|
// +optional
|
||||||
|
// +listType=map
|
||||||
|
// +listMapKey=type
|
||||||
|
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// +kubebuilder:object:root=true
|
||||||
|
// +kubebuilder:subresource:status
|
||||||
|
// +kubebuilder:resource:shortName=btapi
|
||||||
|
// +kubebuilder:printcolumn:name="Endpoint",type=string,JSONPath=`.status.endpoint`
|
||||||
|
// +kubebuilder:printcolumn:name="Ready",type=integer,JSONPath=`.status.readyReplicas`
|
||||||
|
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
|
||||||
|
|
||||||
|
// BindTSIGAPI deploys the companion TSIG API. Creating one makes the operator
|
||||||
|
// reconcile a Deployment, Service, ConfigMap, token Secret and RBAC for it.
|
||||||
|
type BindTSIGAPI struct {
|
||||||
|
metav1.TypeMeta `json:",inline"`
|
||||||
|
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||||
|
|
||||||
|
Spec BindTSIGAPISpec `json:"spec,omitempty"`
|
||||||
|
Status BindTSIGAPIStatus `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// +kubebuilder:object:root=true
|
||||||
|
|
||||||
|
// BindTSIGAPIList contains a list of BindTSIGAPI.
|
||||||
|
type BindTSIGAPIList struct {
|
||||||
|
metav1.TypeMeta `json:",inline"`
|
||||||
|
metav1.ListMeta `json:"metadata,omitempty"`
|
||||||
|
Items []BindTSIGAPI `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
SchemeBuilder.Register(&BindTSIGAPI{}, &BindTSIGAPIList{})
|
||||||
|
}
|
||||||
@@ -16,6 +16,12 @@ const (
|
|||||||
// BindTSIGKeySpec defines a TSIG key. If no existing key material is imported,
|
// BindTSIGKeySpec defines a TSIG key. If no existing key material is imported,
|
||||||
// the operator generates a random key and stores it in a Secret.
|
// the operator generates a random key and stores it in a Secret.
|
||||||
type BindTSIGKeySpec struct {
|
type BindTSIGKeySpec struct {
|
||||||
|
// ClusterRef names the BindCluster this key is included in. When empty the
|
||||||
|
// key is shared with every cluster in the namespace (useful when multiple
|
||||||
|
// clusters share one namespace).
|
||||||
|
// +optional
|
||||||
|
ClusterRef string `json:"clusterRef,omitempty"`
|
||||||
|
|
||||||
// Algorithm is the HMAC algorithm. Defaults to hmac-sha256.
|
// Algorithm is the HMAC algorithm. Defaults to hmac-sha256.
|
||||||
// +kubebuilder:default="hmac-sha256"
|
// +kubebuilder:default="hmac-sha256"
|
||||||
// +optional
|
// +optional
|
||||||
@@ -35,6 +41,24 @@ type BindTSIGKeySpec struct {
|
|||||||
// `secret` key and the operator will not generate new material.
|
// `secret` key and the operator will not generate new material.
|
||||||
// +optional
|
// +optional
|
||||||
ImportExisting bool `json:"importExisting,omitempty"`
|
ImportExisting bool `json:"importExisting,omitempty"`
|
||||||
|
|
||||||
|
// SecretTemplate customizes metadata written onto the managed key Secret.
|
||||||
|
// Useful, for example, to let secret-reflection tooling mirror the key into
|
||||||
|
// another namespace. Operator-managed labels are always preserved.
|
||||||
|
// +optional
|
||||||
|
SecretTemplate *SecretMetadata `json:"secretTemplate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretMetadata carries extra labels and annotations to stamp onto a
|
||||||
|
// Secret managed by the operator.
|
||||||
|
type SecretMetadata struct {
|
||||||
|
// Annotations to set on the Secret.
|
||||||
|
// +optional
|
||||||
|
Annotations map[string]string `json:"annotations,omitempty"`
|
||||||
|
|
||||||
|
// Labels to set on the Secret.
|
||||||
|
// +optional
|
||||||
|
Labels map[string]string `json:"labels,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BindTSIGKeyStatus reports observed TSIG key state.
|
// BindTSIGKeyStatus reports observed TSIG key state.
|
||||||
|
|||||||
@@ -301,6 +301,11 @@ func (in *BindClusterSpec) DeepCopyInto(out *BindClusterSpec) {
|
|||||||
}
|
}
|
||||||
in.Resources.DeepCopyInto(&out.Resources)
|
in.Resources.DeepCopyInto(&out.Resources)
|
||||||
in.Service.DeepCopyInto(&out.Service)
|
in.Service.DeepCopyInto(&out.Service)
|
||||||
|
if in.PrimaryService != nil {
|
||||||
|
in, out := &in.PrimaryService, &out.PrimaryService
|
||||||
|
*out = new(ClusterServiceSpec)
|
||||||
|
(*in).DeepCopyInto(*out)
|
||||||
|
}
|
||||||
if in.NodeSelector != nil {
|
if in.NodeSelector != nil {
|
||||||
in, out := &in.NodeSelector, &out.NodeSelector
|
in, out := &in.NodeSelector, &out.NodeSelector
|
||||||
*out = make(map[string]string, len(*in))
|
*out = make(map[string]string, len(*in))
|
||||||
@@ -576,12 +581,117 @@ func (in *BindPolicyStatus) DeepCopy() *BindPolicyStatus {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *BindTSIGAPI) DeepCopyInto(out *BindTSIGAPI) {
|
||||||
|
*out = *in
|
||||||
|
out.TypeMeta = in.TypeMeta
|
||||||
|
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||||
|
in.Spec.DeepCopyInto(&out.Spec)
|
||||||
|
in.Status.DeepCopyInto(&out.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGAPI.
|
||||||
|
func (in *BindTSIGAPI) DeepCopy() *BindTSIGAPI {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(BindTSIGAPI)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||||
|
func (in *BindTSIGAPI) DeepCopyObject() runtime.Object {
|
||||||
|
if c := in.DeepCopy(); c != nil {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *BindTSIGAPIList) DeepCopyInto(out *BindTSIGAPIList) {
|
||||||
|
*out = *in
|
||||||
|
out.TypeMeta = in.TypeMeta
|
||||||
|
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||||
|
if in.Items != nil {
|
||||||
|
in, out := &in.Items, &out.Items
|
||||||
|
*out = make([]BindTSIGAPI, len(*in))
|
||||||
|
for i := range *in {
|
||||||
|
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGAPIList.
|
||||||
|
func (in *BindTSIGAPIList) DeepCopy() *BindTSIGAPIList {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(BindTSIGAPIList)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||||
|
func (in *BindTSIGAPIList) DeepCopyObject() runtime.Object {
|
||||||
|
if c := in.DeepCopy(); c != nil {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *BindTSIGAPISpec) DeepCopyInto(out *BindTSIGAPISpec) {
|
||||||
|
*out = *in
|
||||||
|
if in.Env != nil {
|
||||||
|
in, out := &in.Env, &out.Env
|
||||||
|
*out = make(map[string]string, len(*in))
|
||||||
|
for key, val := range *in {
|
||||||
|
(*out)[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in.Service.DeepCopyInto(&out.Service)
|
||||||
|
in.Resources.DeepCopyInto(&out.Resources)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGAPISpec.
|
||||||
|
func (in *BindTSIGAPISpec) DeepCopy() *BindTSIGAPISpec {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(BindTSIGAPISpec)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *BindTSIGAPIStatus) DeepCopyInto(out *BindTSIGAPIStatus) {
|
||||||
|
*out = *in
|
||||||
|
if in.Conditions != nil {
|
||||||
|
in, out := &in.Conditions, &out.Conditions
|
||||||
|
*out = make([]v1.Condition, len(*in))
|
||||||
|
for i := range *in {
|
||||||
|
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGAPIStatus.
|
||||||
|
func (in *BindTSIGAPIStatus) DeepCopy() *BindTSIGAPIStatus {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(BindTSIGAPIStatus)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *BindTSIGKey) DeepCopyInto(out *BindTSIGKey) {
|
func (in *BindTSIGKey) DeepCopyInto(out *BindTSIGKey) {
|
||||||
*out = *in
|
*out = *in
|
||||||
out.TypeMeta = in.TypeMeta
|
out.TypeMeta = in.TypeMeta
|
||||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||||
out.Spec = in.Spec
|
in.Spec.DeepCopyInto(&out.Spec)
|
||||||
in.Status.DeepCopyInto(&out.Status)
|
in.Status.DeepCopyInto(&out.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,6 +748,11 @@ func (in *BindTSIGKeyList) DeepCopyObject() runtime.Object {
|
|||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *BindTSIGKeySpec) DeepCopyInto(out *BindTSIGKeySpec) {
|
func (in *BindTSIGKeySpec) DeepCopyInto(out *BindTSIGKeySpec) {
|
||||||
*out = *in
|
*out = *in
|
||||||
|
if in.SecretTemplate != nil {
|
||||||
|
in, out := &in.SecretTemplate, &out.SecretTemplate
|
||||||
|
*out = new(SecretMetadata)
|
||||||
|
(*in).DeepCopyInto(*out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGKeySpec.
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGKeySpec.
|
||||||
@@ -1098,3 +1213,32 @@ func (in *Record) DeepCopy() *Record {
|
|||||||
in.DeepCopyInto(out)
|
in.DeepCopyInto(out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *SecretMetadata) DeepCopyInto(out *SecretMetadata) {
|
||||||
|
*out = *in
|
||||||
|
if in.Annotations != nil {
|
||||||
|
in, out := &in.Annotations, &out.Annotations
|
||||||
|
*out = make(map[string]string, len(*in))
|
||||||
|
for key, val := range *in {
|
||||||
|
(*out)[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Labels != nil {
|
||||||
|
in, out := &in.Labels, &out.Labels
|
||||||
|
*out = make(map[string]string, len(*in))
|
||||||
|
for key, val := range *in {
|
||||||
|
(*out)[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretMetadata.
|
||||||
|
func (in *SecretMetadata) DeepCopy() *SecretMetadata {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(SecretMetadata)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||||
|
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||||
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/log/zap"
|
||||||
|
|
||||||
|
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||||
|
"git.unkin.net/unkin/bind-operator/internal/tsigapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
var scheme = runtime.NewScheme()
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
|
||||||
|
utilruntime.Must(bindv1alpha1.AddToScheme(scheme))
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctrl.SetLogger(zap.New(zap.UseDevMode(false)))
|
||||||
|
logger := ctrl.Log.WithName("tsig-api")
|
||||||
|
|
||||||
|
addr := envOr("LISTEN_ADDR", ":8443")
|
||||||
|
namespace := envOr("TARGET_NAMESPACE", currentNamespace())
|
||||||
|
token := os.Getenv("API_TOKEN")
|
||||||
|
|
||||||
|
c, err := client.New(ctrl.GetConfigOrDie(), client.Options{Scheme: scheme})
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(err, "unable to build kubernetes client")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &tsigapi.Server{
|
||||||
|
Client: c,
|
||||||
|
Namespace: namespace,
|
||||||
|
Token: token,
|
||||||
|
Log: logger,
|
||||||
|
WaitTimeout: 15 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
httpSrv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: srv.Handler(),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
logger.Info("starting tsig api", "addr", addr, "namespace", namespace)
|
||||||
|
if err := httpSrv.ListenAndServe(); err != nil {
|
||||||
|
logger.Error(err, "tsig api exited")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentNamespace() string {
|
||||||
|
if b, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); err == nil {
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
return "bind-system"
|
||||||
|
}
|
||||||
@@ -1013,6 +1013,41 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
description: NodeSelector for the BIND pods.
|
description: NodeSelector for the BIND pods.
|
||||||
type: object
|
type: object
|
||||||
|
primaryService:
|
||||||
|
description: |-
|
||||||
|
PrimaryService, when set, creates an additional Service routing only to the
|
||||||
|
primary pod (ordinal 0) — the write endpoint for RFC2136/nsupdate, since
|
||||||
|
secondaries reject updates. Reads still use Service (all pods). Use
|
||||||
|
ClusterIP for in-cluster writers (e.g. external-dns) or LoadBalancer for
|
||||||
|
external writers.
|
||||||
|
properties:
|
||||||
|
annotations:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Annotations added to the client-facing Service (e.g.
|
||||||
|
PureLB/MetalLB hints).
|
||||||
|
type: object
|
||||||
|
externalTrafficPolicy:
|
||||||
|
description: |-
|
||||||
|
ExternalTrafficPolicy for a LoadBalancer/NodePort Service. Local preserves
|
||||||
|
client source IPs (required for source-IP ACLs on the DNS servers) but
|
||||||
|
only routes to nodes running a pod. Defaults to Cluster.
|
||||||
|
enum:
|
||||||
|
- Cluster
|
||||||
|
- Local
|
||||||
|
type: string
|
||||||
|
loadBalancerIP:
|
||||||
|
description: LoadBalancerIP requests a specific address when Type
|
||||||
|
is LoadBalancer.
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
description: Type of the client-facing Service. Defaults to ClusterIP.
|
||||||
|
enum:
|
||||||
|
- ClusterIP
|
||||||
|
- LoadBalancer
|
||||||
|
- NodePort
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
recursion:
|
recursion:
|
||||||
description: |-
|
description: |-
|
||||||
Recursion overrides the default per-mode recursion setting. When nil,
|
Recursion overrides the default per-mode recursion setting. When nil,
|
||||||
@@ -1086,7 +1121,8 @@ spec:
|
|||||||
type: object
|
type: object
|
||||||
type: object
|
type: object
|
||||||
service:
|
service:
|
||||||
description: Service controls how the cluster is exposed.
|
description: Service controls how the cluster is exposed for reads
|
||||||
|
(all pods).
|
||||||
properties:
|
properties:
|
||||||
annotations:
|
annotations:
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
@@ -1094,6 +1130,15 @@ spec:
|
|||||||
description: Annotations added to the client-facing Service (e.g.
|
description: Annotations added to the client-facing Service (e.g.
|
||||||
PureLB/MetalLB hints).
|
PureLB/MetalLB hints).
|
||||||
type: object
|
type: object
|
||||||
|
externalTrafficPolicy:
|
||||||
|
description: |-
|
||||||
|
ExternalTrafficPolicy for a LoadBalancer/NodePort Service. Local preserves
|
||||||
|
client source IPs (required for source-IP ACLs on the DNS servers) but
|
||||||
|
only routes to nodes running a pod. Defaults to Cluster.
|
||||||
|
enum:
|
||||||
|
- Cluster
|
||||||
|
- Local
|
||||||
|
type: string
|
||||||
loadBalancerIP:
|
loadBalancerIP:
|
||||||
description: LoadBalancerIP requests a specific address when Type
|
description: LoadBalancerIP requests a specific address when Type
|
||||||
is LoadBalancer.
|
is LoadBalancer.
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
---
|
||||||
|
apiVersion: apiextensions.k8s.io/v1
|
||||||
|
kind: CustomResourceDefinition
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
controller-gen.kubebuilder.io/version: v0.17.3
|
||||||
|
name: bindtsigapis.bind.unkin.net
|
||||||
|
spec:
|
||||||
|
group: bind.unkin.net
|
||||||
|
names:
|
||||||
|
kind: BindTSIGAPI
|
||||||
|
listKind: BindTSIGAPIList
|
||||||
|
plural: bindtsigapis
|
||||||
|
shortNames:
|
||||||
|
- btapi
|
||||||
|
singular: bindtsigapi
|
||||||
|
scope: Namespaced
|
||||||
|
versions:
|
||||||
|
- additionalPrinterColumns:
|
||||||
|
- jsonPath: .status.endpoint
|
||||||
|
name: Endpoint
|
||||||
|
type: string
|
||||||
|
- jsonPath: .status.readyReplicas
|
||||||
|
name: Ready
|
||||||
|
type: integer
|
||||||
|
- jsonPath: .status.phase
|
||||||
|
name: Phase
|
||||||
|
type: string
|
||||||
|
name: v1alpha1
|
||||||
|
schema:
|
||||||
|
openAPIV3Schema:
|
||||||
|
description: |-
|
||||||
|
BindTSIGAPI deploys the companion TSIG API. Creating one makes the operator
|
||||||
|
reconcile a Deployment, Service, ConfigMap, token Secret and RBAC for it.
|
||||||
|
properties:
|
||||||
|
apiVersion:
|
||||||
|
description: |-
|
||||||
|
APIVersion defines the versioned schema of this representation of an object.
|
||||||
|
Servers should convert recognized schemas to the latest internal value, and
|
||||||
|
may reject unrecognized values.
|
||||||
|
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
description: |-
|
||||||
|
Kind is a string value representing the REST resource this object represents.
|
||||||
|
Servers may infer this from the endpoint the client submits requests to.
|
||||||
|
Cannot be updated.
|
||||||
|
In CamelCase.
|
||||||
|
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||||
|
type: string
|
||||||
|
metadata:
|
||||||
|
type: object
|
||||||
|
spec:
|
||||||
|
description: |-
|
||||||
|
BindTSIGAPISpec configures the companion TSIG API that the operator deploys.
|
||||||
|
The API exposes an HTTP contract (used by vault-plugin-secrets-bind-tsig) for
|
||||||
|
creating, rotating and deleting TSIG keys; it does so by managing BindTSIGKey
|
||||||
|
custom resources, which the operator then reconciles into key material.
|
||||||
|
properties:
|
||||||
|
env:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Env are extra environment variables rendered into the
|
||||||
|
API ConfigMap.
|
||||||
|
type: object
|
||||||
|
image:
|
||||||
|
default: git.unkin.net/unkin/bind-tsig-api:latest
|
||||||
|
description: Image is the companion API container image.
|
||||||
|
type: string
|
||||||
|
imagePullPolicy:
|
||||||
|
description: ImagePullPolicy for the API container.
|
||||||
|
type: string
|
||||||
|
port:
|
||||||
|
default: 8443
|
||||||
|
description: Port the API listens on. Defaults to 8443.
|
||||||
|
format: int32
|
||||||
|
type: integer
|
||||||
|
replicas:
|
||||||
|
default: 1
|
||||||
|
description: Replicas of the API. Defaults to 1.
|
||||||
|
format: int32
|
||||||
|
type: integer
|
||||||
|
resources:
|
||||||
|
description: Resources for the API container.
|
||||||
|
properties:
|
||||||
|
claims:
|
||||||
|
description: |-
|
||||||
|
Claims lists the names of resources, defined in spec.resourceClaims,
|
||||||
|
that are used by this container.
|
||||||
|
|
||||||
|
This field depends on the
|
||||||
|
DynamicResourceAllocation feature gate.
|
||||||
|
|
||||||
|
This field is immutable. It can only be set for containers.
|
||||||
|
items:
|
||||||
|
description: ResourceClaim references one entry in PodSpec.ResourceClaims.
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
description: |-
|
||||||
|
Name must match the name of one entry in pod.spec.resourceClaims of
|
||||||
|
the Pod where this field is used. It makes that resource available
|
||||||
|
inside a container.
|
||||||
|
type: string
|
||||||
|
request:
|
||||||
|
description: |-
|
||||||
|
Request is the name chosen for a request in the referenced claim.
|
||||||
|
If empty, everything from the claim is made available, otherwise
|
||||||
|
only the result of this request.
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-map-keys:
|
||||||
|
- name
|
||||||
|
x-kubernetes-list-type: map
|
||||||
|
limits:
|
||||||
|
additionalProperties:
|
||||||
|
anyOf:
|
||||||
|
- type: integer
|
||||||
|
- type: string
|
||||||
|
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
|
||||||
|
x-kubernetes-int-or-string: true
|
||||||
|
description: |-
|
||||||
|
Limits describes the maximum amount of compute resources allowed.
|
||||||
|
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
|
||||||
|
type: object
|
||||||
|
requests:
|
||||||
|
additionalProperties:
|
||||||
|
anyOf:
|
||||||
|
- type: integer
|
||||||
|
- type: string
|
||||||
|
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
|
||||||
|
x-kubernetes-int-or-string: true
|
||||||
|
description: |-
|
||||||
|
Requests describes the minimum amount of compute resources required.
|
||||||
|
If Requests is omitted for a container, it defaults to Limits if that is explicitly specified,
|
||||||
|
otherwise to an implementation-defined value. Requests cannot exceed Limits.
|
||||||
|
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
service:
|
||||||
|
description: Service controls how the API is exposed (defaults to
|
||||||
|
ClusterIP).
|
||||||
|
properties:
|
||||||
|
annotations:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Annotations added to the client-facing Service (e.g.
|
||||||
|
PureLB/MetalLB hints).
|
||||||
|
type: object
|
||||||
|
externalTrafficPolicy:
|
||||||
|
description: |-
|
||||||
|
ExternalTrafficPolicy for a LoadBalancer/NodePort Service. Local preserves
|
||||||
|
client source IPs (required for source-IP ACLs on the DNS servers) but
|
||||||
|
only routes to nodes running a pod. Defaults to Cluster.
|
||||||
|
enum:
|
||||||
|
- Cluster
|
||||||
|
- Local
|
||||||
|
type: string
|
||||||
|
loadBalancerIP:
|
||||||
|
description: LoadBalancerIP requests a specific address when Type
|
||||||
|
is LoadBalancer.
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
description: Type of the client-facing Service. Defaults to ClusterIP.
|
||||||
|
enum:
|
||||||
|
- ClusterIP
|
||||||
|
- LoadBalancer
|
||||||
|
- NodePort
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
targetNamespace:
|
||||||
|
description: |-
|
||||||
|
TargetNamespace is where the API creates BindTSIGKey resources. Defaults
|
||||||
|
to the API's own namespace.
|
||||||
|
type: string
|
||||||
|
tokenSecretName:
|
||||||
|
description: |-
|
||||||
|
TokenSecretName holds the master access token clients present to the API.
|
||||||
|
The operator generates a token if the Secret does not exist, so a
|
||||||
|
VaultStaticSecret may pre-seed it instead. Defaults to "<name>-token".
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
status:
|
||||||
|
description: BindTSIGAPIStatus reports observed API state.
|
||||||
|
properties:
|
||||||
|
conditions:
|
||||||
|
items:
|
||||||
|
description: Condition contains details for one aspect of the current
|
||||||
|
state of this API Resource.
|
||||||
|
properties:
|
||||||
|
lastTransitionTime:
|
||||||
|
description: |-
|
||||||
|
lastTransitionTime is the last time the condition transitioned from one status to another.
|
||||||
|
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
description: |-
|
||||||
|
message is a human readable message indicating details about the transition.
|
||||||
|
This may be an empty string.
|
||||||
|
maxLength: 32768
|
||||||
|
type: string
|
||||||
|
observedGeneration:
|
||||||
|
description: |-
|
||||||
|
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
||||||
|
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
||||||
|
with respect to the current state of the instance.
|
||||||
|
format: int64
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
reason:
|
||||||
|
description: |-
|
||||||
|
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
||||||
|
Producers of specific condition types may define expected values and meanings for this field,
|
||||||
|
and whether the values are considered a guaranteed API.
|
||||||
|
The value should be a CamelCase string.
|
||||||
|
This field may not be empty.
|
||||||
|
maxLength: 1024
|
||||||
|
minLength: 1
|
||||||
|
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
description: status of the condition, one of True, False, Unknown.
|
||||||
|
enum:
|
||||||
|
- "True"
|
||||||
|
- "False"
|
||||||
|
- Unknown
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||||
|
maxLength: 316
|
||||||
|
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- lastTransitionTime
|
||||||
|
- message
|
||||||
|
- reason
|
||||||
|
- status
|
||||||
|
- type
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-map-keys:
|
||||||
|
- type
|
||||||
|
x-kubernetes-list-type: map
|
||||||
|
endpoint:
|
||||||
|
description: Endpoint is the in-cluster URL clients (Vault) use to
|
||||||
|
reach the API.
|
||||||
|
type: string
|
||||||
|
observedGeneration:
|
||||||
|
format: int64
|
||||||
|
type: integer
|
||||||
|
phase:
|
||||||
|
type: string
|
||||||
|
readyReplicas:
|
||||||
|
format: int32
|
||||||
|
type: integer
|
||||||
|
tokenSecret:
|
||||||
|
description: TokenSecret is the Secret holding the master access token.
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
served: true
|
||||||
|
storage: true
|
||||||
|
subresources:
|
||||||
|
status: {}
|
||||||
@@ -66,6 +66,12 @@ spec:
|
|||||||
- hmac-sha1
|
- hmac-sha1
|
||||||
- hmac-md5
|
- hmac-md5
|
||||||
type: string
|
type: string
|
||||||
|
clusterRef:
|
||||||
|
description: |-
|
||||||
|
ClusterRef names the BindCluster this key is included in. When empty the
|
||||||
|
key is shared with every cluster in the namespace (useful when multiple
|
||||||
|
clusters share one namespace).
|
||||||
|
type: string
|
||||||
importExisting:
|
importExisting:
|
||||||
description: |-
|
description: |-
|
||||||
ImportExisting, when true, means the referenced Secret already contains a
|
ImportExisting, when true, means the referenced Secret already contains a
|
||||||
@@ -81,6 +87,23 @@ spec:
|
|||||||
SecretName is the Secret the key material is written to (or read from when
|
SecretName is the Secret the key material is written to (or read from when
|
||||||
ImportExisting is set). Defaults to "<name>-tsig".
|
ImportExisting is set). Defaults to "<name>-tsig".
|
||||||
type: string
|
type: string
|
||||||
|
secretTemplate:
|
||||||
|
description: |-
|
||||||
|
SecretTemplate customizes metadata written onto the managed key Secret.
|
||||||
|
Useful, for example, to let secret-reflection tooling mirror the key into
|
||||||
|
another namespace. Operator-managed labels are always preserved.
|
||||||
|
properties:
|
||||||
|
annotations:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Annotations to set on the Secret.
|
||||||
|
type: object
|
||||||
|
labels:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Labels to set on the Secret.
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
type: object
|
type: object
|
||||||
status:
|
status:
|
||||||
description: BindTSIGKeyStatus reports observed TSIG key state.
|
description: BindTSIGKeyStatus reports observed TSIG key state.
|
||||||
|
|||||||
+336
-1
@@ -1318,6 +1318,41 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
description: NodeSelector for the BIND pods.
|
description: NodeSelector for the BIND pods.
|
||||||
type: object
|
type: object
|
||||||
|
primaryService:
|
||||||
|
description: |-
|
||||||
|
PrimaryService, when set, creates an additional Service routing only to the
|
||||||
|
primary pod (ordinal 0) — the write endpoint for RFC2136/nsupdate, since
|
||||||
|
secondaries reject updates. Reads still use Service (all pods). Use
|
||||||
|
ClusterIP for in-cluster writers (e.g. external-dns) or LoadBalancer for
|
||||||
|
external writers.
|
||||||
|
properties:
|
||||||
|
annotations:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Annotations added to the client-facing Service (e.g.
|
||||||
|
PureLB/MetalLB hints).
|
||||||
|
type: object
|
||||||
|
externalTrafficPolicy:
|
||||||
|
description: |-
|
||||||
|
ExternalTrafficPolicy for a LoadBalancer/NodePort Service. Local preserves
|
||||||
|
client source IPs (required for source-IP ACLs on the DNS servers) but
|
||||||
|
only routes to nodes running a pod. Defaults to Cluster.
|
||||||
|
enum:
|
||||||
|
- Cluster
|
||||||
|
- Local
|
||||||
|
type: string
|
||||||
|
loadBalancerIP:
|
||||||
|
description: LoadBalancerIP requests a specific address when Type
|
||||||
|
is LoadBalancer.
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
description: Type of the client-facing Service. Defaults to ClusterIP.
|
||||||
|
enum:
|
||||||
|
- ClusterIP
|
||||||
|
- LoadBalancer
|
||||||
|
- NodePort
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
recursion:
|
recursion:
|
||||||
description: |-
|
description: |-
|
||||||
Recursion overrides the default per-mode recursion setting. When nil,
|
Recursion overrides the default per-mode recursion setting. When nil,
|
||||||
@@ -1391,7 +1426,8 @@ spec:
|
|||||||
type: object
|
type: object
|
||||||
type: object
|
type: object
|
||||||
service:
|
service:
|
||||||
description: Service controls how the cluster is exposed.
|
description: Service controls how the cluster is exposed for reads
|
||||||
|
(all pods).
|
||||||
properties:
|
properties:
|
||||||
annotations:
|
annotations:
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
@@ -1399,6 +1435,15 @@ spec:
|
|||||||
description: Annotations added to the client-facing Service (e.g.
|
description: Annotations added to the client-facing Service (e.g.
|
||||||
PureLB/MetalLB hints).
|
PureLB/MetalLB hints).
|
||||||
type: object
|
type: object
|
||||||
|
externalTrafficPolicy:
|
||||||
|
description: |-
|
||||||
|
ExternalTrafficPolicy for a LoadBalancer/NodePort Service. Local preserves
|
||||||
|
client source IPs (required for source-IP ACLs on the DNS servers) but
|
||||||
|
only routes to nodes running a pod. Defaults to Cluster.
|
||||||
|
enum:
|
||||||
|
- Cluster
|
||||||
|
- Local
|
||||||
|
type: string
|
||||||
loadBalancerIP:
|
loadBalancerIP:
|
||||||
description: LoadBalancerIP requests a specific address when Type
|
description: LoadBalancerIP requests a specific address when Type
|
||||||
is LoadBalancer.
|
is LoadBalancer.
|
||||||
@@ -1978,6 +2023,273 @@ spec:
|
|||||||
---
|
---
|
||||||
apiVersion: apiextensions.k8s.io/v1
|
apiVersion: apiextensions.k8s.io/v1
|
||||||
kind: CustomResourceDefinition
|
kind: CustomResourceDefinition
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
controller-gen.kubebuilder.io/version: v0.17.3
|
||||||
|
name: bindtsigapis.bind.unkin.net
|
||||||
|
spec:
|
||||||
|
group: bind.unkin.net
|
||||||
|
names:
|
||||||
|
kind: BindTSIGAPI
|
||||||
|
listKind: BindTSIGAPIList
|
||||||
|
plural: bindtsigapis
|
||||||
|
shortNames:
|
||||||
|
- btapi
|
||||||
|
singular: bindtsigapi
|
||||||
|
scope: Namespaced
|
||||||
|
versions:
|
||||||
|
- additionalPrinterColumns:
|
||||||
|
- jsonPath: .status.endpoint
|
||||||
|
name: Endpoint
|
||||||
|
type: string
|
||||||
|
- jsonPath: .status.readyReplicas
|
||||||
|
name: Ready
|
||||||
|
type: integer
|
||||||
|
- jsonPath: .status.phase
|
||||||
|
name: Phase
|
||||||
|
type: string
|
||||||
|
name: v1alpha1
|
||||||
|
schema:
|
||||||
|
openAPIV3Schema:
|
||||||
|
description: |-
|
||||||
|
BindTSIGAPI deploys the companion TSIG API. Creating one makes the operator
|
||||||
|
reconcile a Deployment, Service, ConfigMap, token Secret and RBAC for it.
|
||||||
|
properties:
|
||||||
|
apiVersion:
|
||||||
|
description: |-
|
||||||
|
APIVersion defines the versioned schema of this representation of an object.
|
||||||
|
Servers should convert recognized schemas to the latest internal value, and
|
||||||
|
may reject unrecognized values.
|
||||||
|
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
description: |-
|
||||||
|
Kind is a string value representing the REST resource this object represents.
|
||||||
|
Servers may infer this from the endpoint the client submits requests to.
|
||||||
|
Cannot be updated.
|
||||||
|
In CamelCase.
|
||||||
|
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||||
|
type: string
|
||||||
|
metadata:
|
||||||
|
type: object
|
||||||
|
spec:
|
||||||
|
description: |-
|
||||||
|
BindTSIGAPISpec configures the companion TSIG API that the operator deploys.
|
||||||
|
The API exposes an HTTP contract (used by vault-plugin-secrets-bind-tsig) for
|
||||||
|
creating, rotating and deleting TSIG keys; it does so by managing BindTSIGKey
|
||||||
|
custom resources, which the operator then reconciles into key material.
|
||||||
|
properties:
|
||||||
|
env:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Env are extra environment variables rendered into the
|
||||||
|
API ConfigMap.
|
||||||
|
type: object
|
||||||
|
image:
|
||||||
|
default: git.unkin.net/unkin/bind-tsig-api:latest
|
||||||
|
description: Image is the companion API container image.
|
||||||
|
type: string
|
||||||
|
imagePullPolicy:
|
||||||
|
description: ImagePullPolicy for the API container.
|
||||||
|
type: string
|
||||||
|
port:
|
||||||
|
default: 8443
|
||||||
|
description: Port the API listens on. Defaults to 8443.
|
||||||
|
format: int32
|
||||||
|
type: integer
|
||||||
|
replicas:
|
||||||
|
default: 1
|
||||||
|
description: Replicas of the API. Defaults to 1.
|
||||||
|
format: int32
|
||||||
|
type: integer
|
||||||
|
resources:
|
||||||
|
description: Resources for the API container.
|
||||||
|
properties:
|
||||||
|
claims:
|
||||||
|
description: |-
|
||||||
|
Claims lists the names of resources, defined in spec.resourceClaims,
|
||||||
|
that are used by this container.
|
||||||
|
|
||||||
|
This field depends on the
|
||||||
|
DynamicResourceAllocation feature gate.
|
||||||
|
|
||||||
|
This field is immutable. It can only be set for containers.
|
||||||
|
items:
|
||||||
|
description: ResourceClaim references one entry in PodSpec.ResourceClaims.
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
description: |-
|
||||||
|
Name must match the name of one entry in pod.spec.resourceClaims of
|
||||||
|
the Pod where this field is used. It makes that resource available
|
||||||
|
inside a container.
|
||||||
|
type: string
|
||||||
|
request:
|
||||||
|
description: |-
|
||||||
|
Request is the name chosen for a request in the referenced claim.
|
||||||
|
If empty, everything from the claim is made available, otherwise
|
||||||
|
only the result of this request.
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-map-keys:
|
||||||
|
- name
|
||||||
|
x-kubernetes-list-type: map
|
||||||
|
limits:
|
||||||
|
additionalProperties:
|
||||||
|
anyOf:
|
||||||
|
- type: integer
|
||||||
|
- type: string
|
||||||
|
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
|
||||||
|
x-kubernetes-int-or-string: true
|
||||||
|
description: |-
|
||||||
|
Limits describes the maximum amount of compute resources allowed.
|
||||||
|
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
|
||||||
|
type: object
|
||||||
|
requests:
|
||||||
|
additionalProperties:
|
||||||
|
anyOf:
|
||||||
|
- type: integer
|
||||||
|
- type: string
|
||||||
|
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
|
||||||
|
x-kubernetes-int-or-string: true
|
||||||
|
description: |-
|
||||||
|
Requests describes the minimum amount of compute resources required.
|
||||||
|
If Requests is omitted for a container, it defaults to Limits if that is explicitly specified,
|
||||||
|
otherwise to an implementation-defined value. Requests cannot exceed Limits.
|
||||||
|
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
service:
|
||||||
|
description: Service controls how the API is exposed (defaults to
|
||||||
|
ClusterIP).
|
||||||
|
properties:
|
||||||
|
annotations:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Annotations added to the client-facing Service (e.g.
|
||||||
|
PureLB/MetalLB hints).
|
||||||
|
type: object
|
||||||
|
externalTrafficPolicy:
|
||||||
|
description: |-
|
||||||
|
ExternalTrafficPolicy for a LoadBalancer/NodePort Service. Local preserves
|
||||||
|
client source IPs (required for source-IP ACLs on the DNS servers) but
|
||||||
|
only routes to nodes running a pod. Defaults to Cluster.
|
||||||
|
enum:
|
||||||
|
- Cluster
|
||||||
|
- Local
|
||||||
|
type: string
|
||||||
|
loadBalancerIP:
|
||||||
|
description: LoadBalancerIP requests a specific address when Type
|
||||||
|
is LoadBalancer.
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
description: Type of the client-facing Service. Defaults to ClusterIP.
|
||||||
|
enum:
|
||||||
|
- ClusterIP
|
||||||
|
- LoadBalancer
|
||||||
|
- NodePort
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
targetNamespace:
|
||||||
|
description: |-
|
||||||
|
TargetNamespace is where the API creates BindTSIGKey resources. Defaults
|
||||||
|
to the API's own namespace.
|
||||||
|
type: string
|
||||||
|
tokenSecretName:
|
||||||
|
description: |-
|
||||||
|
TokenSecretName holds the master access token clients present to the API.
|
||||||
|
The operator generates a token if the Secret does not exist, so a
|
||||||
|
VaultStaticSecret may pre-seed it instead. Defaults to "<name>-token".
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
status:
|
||||||
|
description: BindTSIGAPIStatus reports observed API state.
|
||||||
|
properties:
|
||||||
|
conditions:
|
||||||
|
items:
|
||||||
|
description: Condition contains details for one aspect of the current
|
||||||
|
state of this API Resource.
|
||||||
|
properties:
|
||||||
|
lastTransitionTime:
|
||||||
|
description: |-
|
||||||
|
lastTransitionTime is the last time the condition transitioned from one status to another.
|
||||||
|
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
description: |-
|
||||||
|
message is a human readable message indicating details about the transition.
|
||||||
|
This may be an empty string.
|
||||||
|
maxLength: 32768
|
||||||
|
type: string
|
||||||
|
observedGeneration:
|
||||||
|
description: |-
|
||||||
|
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
||||||
|
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
||||||
|
with respect to the current state of the instance.
|
||||||
|
format: int64
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
reason:
|
||||||
|
description: |-
|
||||||
|
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
||||||
|
Producers of specific condition types may define expected values and meanings for this field,
|
||||||
|
and whether the values are considered a guaranteed API.
|
||||||
|
The value should be a CamelCase string.
|
||||||
|
This field may not be empty.
|
||||||
|
maxLength: 1024
|
||||||
|
minLength: 1
|
||||||
|
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
description: status of the condition, one of True, False, Unknown.
|
||||||
|
enum:
|
||||||
|
- "True"
|
||||||
|
- "False"
|
||||||
|
- Unknown
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||||
|
maxLength: 316
|
||||||
|
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- lastTransitionTime
|
||||||
|
- message
|
||||||
|
- reason
|
||||||
|
- status
|
||||||
|
- type
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-map-keys:
|
||||||
|
- type
|
||||||
|
x-kubernetes-list-type: map
|
||||||
|
endpoint:
|
||||||
|
description: Endpoint is the in-cluster URL clients (Vault) use to
|
||||||
|
reach the API.
|
||||||
|
type: string
|
||||||
|
observedGeneration:
|
||||||
|
format: int64
|
||||||
|
type: integer
|
||||||
|
phase:
|
||||||
|
type: string
|
||||||
|
readyReplicas:
|
||||||
|
format: int32
|
||||||
|
type: integer
|
||||||
|
tokenSecret:
|
||||||
|
description: TokenSecret is the Secret holding the master access token.
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
served: true
|
||||||
|
storage: true
|
||||||
|
subresources:
|
||||||
|
status: {}
|
||||||
|
---
|
||||||
|
apiVersion: apiextensions.k8s.io/v1
|
||||||
|
kind: CustomResourceDefinition
|
||||||
metadata:
|
metadata:
|
||||||
annotations:
|
annotations:
|
||||||
controller-gen.kubebuilder.io/version: v0.17.3
|
controller-gen.kubebuilder.io/version: v0.17.3
|
||||||
@@ -2043,6 +2355,12 @@ spec:
|
|||||||
- hmac-sha1
|
- hmac-sha1
|
||||||
- hmac-md5
|
- hmac-md5
|
||||||
type: string
|
type: string
|
||||||
|
clusterRef:
|
||||||
|
description: |-
|
||||||
|
ClusterRef names the BindCluster this key is included in. When empty the
|
||||||
|
key is shared with every cluster in the namespace (useful when multiple
|
||||||
|
clusters share one namespace).
|
||||||
|
type: string
|
||||||
importExisting:
|
importExisting:
|
||||||
description: |-
|
description: |-
|
||||||
ImportExisting, when true, means the referenced Secret already contains a
|
ImportExisting, when true, means the referenced Secret already contains a
|
||||||
@@ -2058,6 +2376,23 @@ spec:
|
|||||||
SecretName is the Secret the key material is written to (or read from when
|
SecretName is the Secret the key material is written to (or read from when
|
||||||
ImportExisting is set). Defaults to "<name>-tsig".
|
ImportExisting is set). Defaults to "<name>-tsig".
|
||||||
type: string
|
type: string
|
||||||
|
secretTemplate:
|
||||||
|
description: |-
|
||||||
|
SecretTemplate customizes metadata written onto the managed key Secret.
|
||||||
|
Useful, for example, to let secret-reflection tooling mirror the key into
|
||||||
|
another namespace. Operator-managed labels are always preserved.
|
||||||
|
properties:
|
||||||
|
annotations:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Annotations to set on the Secret.
|
||||||
|
type: object
|
||||||
|
labels:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Labels to set on the Secret.
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
type: object
|
type: object
|
||||||
status:
|
status:
|
||||||
description: BindTSIGKeyStatus reports observed TSIG key state.
|
description: BindTSIGKeyStatus reports observed TSIG key state.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ rules:
|
|||||||
resources:
|
resources:
|
||||||
- configmaps
|
- configmaps
|
||||||
- secrets
|
- secrets
|
||||||
|
- serviceaccounts
|
||||||
- services
|
- services
|
||||||
verbs:
|
verbs:
|
||||||
- create
|
- create
|
||||||
@@ -36,6 +37,7 @@ rules:
|
|||||||
- apiGroups:
|
- apiGroups:
|
||||||
- apps
|
- apps
|
||||||
resources:
|
resources:
|
||||||
|
- deployments
|
||||||
- statefulsets
|
- statefulsets
|
||||||
verbs:
|
verbs:
|
||||||
- create
|
- create
|
||||||
@@ -53,6 +55,7 @@ rules:
|
|||||||
- bindclusters
|
- bindclusters
|
||||||
- binddnssecpolicies
|
- binddnssecpolicies
|
||||||
- bindpolicies
|
- bindpolicies
|
||||||
|
- bindtsigapis
|
||||||
- bindtsigkeys
|
- bindtsigkeys
|
||||||
- bindviews
|
- bindviews
|
||||||
- bindzones
|
- bindzones
|
||||||
@@ -73,6 +76,7 @@ rules:
|
|||||||
- bindclusters/status
|
- bindclusters/status
|
||||||
- binddnssecpolicies/status
|
- binddnssecpolicies/status
|
||||||
- bindpolicies/status
|
- bindpolicies/status
|
||||||
|
- bindtsigapis/status
|
||||||
- bindtsigkeys/status
|
- bindtsigkeys/status
|
||||||
- bindviews/status
|
- bindviews/status
|
||||||
- bindzones/status
|
- bindzones/status
|
||||||
@@ -81,3 +85,16 @@ rules:
|
|||||||
- get
|
- get
|
||||||
- patch
|
- patch
|
||||||
- update
|
- update
|
||||||
|
- apiGroups:
|
||||||
|
- rbac.authorization.k8s.io
|
||||||
|
resources:
|
||||||
|
- rolebindings
|
||||||
|
- roles
|
||||||
|
verbs:
|
||||||
|
- create
|
||||||
|
- delete
|
||||||
|
- get
|
||||||
|
- list
|
||||||
|
- patch
|
||||||
|
- update
|
||||||
|
- watch
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ spec:
|
|||||||
algorithm: hmac-sha256
|
algorithm: hmac-sha256
|
||||||
---
|
---
|
||||||
# TSIG key permitting external-dns (and DNSRecord objects) to send RFC2136
|
# TSIG key permitting external-dns (and DNSRecord objects) to send RFC2136
|
||||||
# dynamic updates to the dynamic cluster's primary.
|
# dynamic updates to the dynamic cluster's primary. secretTemplate mirrors the
|
||||||
|
# generated Secret into the external-dns namespace via emberstack reflector, so
|
||||||
|
# external-dns presents exactly the key the primary's allow-update accepts.
|
||||||
apiVersion: bind.unkin.net/v1alpha1
|
apiVersion: bind.unkin.net/v1alpha1
|
||||||
kind: BindTSIGKey
|
kind: BindTSIGKey
|
||||||
metadata:
|
metadata:
|
||||||
@@ -19,3 +21,9 @@ metadata:
|
|||||||
namespace: bind-externaldns
|
namespace: bind-externaldns
|
||||||
spec:
|
spec:
|
||||||
algorithm: hmac-sha256
|
algorithm: hmac-sha256
|
||||||
|
secretTemplate:
|
||||||
|
annotations:
|
||||||
|
reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
|
||||||
|
reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "externaldns"
|
||||||
|
reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
|
||||||
|
reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: "externaldns"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module git.unkin.net/unkin/bind-operator
|
|||||||
go 1.25
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/go-logr/logr v1.4.2
|
||||||
k8s.io/api v0.34.4
|
k8s.io/api v0.34.4
|
||||||
k8s.io/apimachinery v0.34.4
|
k8s.io/apimachinery v0.34.4
|
||||||
k8s.io/client-go v0.34.4
|
k8s.io/client-go v0.34.4
|
||||||
@@ -17,7 +18,6 @@ require (
|
|||||||
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
|
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
|
||||||
github.com/go-logr/zapr v1.3.0 // indirect
|
github.com/go-logr/zapr v1.3.0 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||||
|
|||||||
+132
-8
@@ -16,17 +16,54 @@ type RenderInput struct {
|
|||||||
Policies []bindv1alpha1.BindPolicy
|
Policies []bindv1alpha1.BindPolicy
|
||||||
DNSSECPolicies []bindv1alpha1.BindDNSSECPolicy
|
DNSSECPolicies []bindv1alpha1.BindDNSSECPolicy
|
||||||
Catalog *bindv1alpha1.BindCatalogZone
|
Catalog *bindv1alpha1.BindCatalogZone
|
||||||
|
// Forwards are type:forward BindZones. They are pure configuration (no
|
||||||
|
// replicated data), so they are rendered into named.conf on every pod
|
||||||
|
// rather than added dynamically to the primary.
|
||||||
|
Forwards []bindv1alpha1.BindZone
|
||||||
// PrimaryAddress is the in-cluster address secondaries transfer from.
|
// PrimaryAddress is the in-cluster address secondaries transfer from.
|
||||||
PrimaryAddress string
|
PrimaryAddress string
|
||||||
|
// NotifyKeyName is the TSIG key name secondaries accept intra-cluster NOTIFYs
|
||||||
|
// under. The primary pod's NOTIFYs egress with its *pod* IP as the source — k8s
|
||||||
|
// Services only NAT the inbound direction — so BIND, whose implicit
|
||||||
|
// allow-notify is the zone's primaries list (the stable ClusterIP), REFUSES
|
||||||
|
// them as "non-primary" and replication falls back to the SOA refresh timer.
|
||||||
|
//
|
||||||
|
// The primary signs its outgoing NOTIFYs with this key (the zone-scope
|
||||||
|
// also-notify entries, applied via rndc, carry `key "<name>"`), and
|
||||||
|
// secondaries render `allow-notify { key "<name>"; }` — an address-match-list
|
||||||
|
// with a *key* element and NO IPs. Because it names only the key, this clause
|
||||||
|
// is fully static: it never changes when a pod IP churns, so it cannot feed a
|
||||||
|
// changed config-hash and cannot roll the StatefulSet (the v0.2.5 loop). Empty
|
||||||
|
// leaves BIND's default behaviour unchanged.
|
||||||
|
NotifyKeyName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderNamedConf returns the primary and secondary named.conf contents for a
|
// RenderNamedConf returns the primary and secondary named.conf contents for a
|
||||||
// cluster. Both variants are shipped in the ConfigMap; the entrypoint selects
|
// cluster. Both variants are shipped in the ConfigMap; the entrypoint selects
|
||||||
// one based on the pod ordinal.
|
// one based on the pod ordinal.
|
||||||
func RenderNamedConf(in RenderInput) (primary string, secondary string) {
|
func RenderNamedConf(in RenderInput) (primary string, secondary string) {
|
||||||
|
// client.List returns cache-ordered (non-deterministic) results, so sort
|
||||||
|
// every input slice before rendering. Otherwise the rendered config
|
||||||
|
// reshuffles between reconciles, churning the ConfigMap — and with the
|
||||||
|
// pod-template config hash that means an endless rolling restart.
|
||||||
|
sortInput(&in)
|
||||||
return render(in, true), render(in, false)
|
return render(in, true), render(in, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortInput orders every list rendered into named.conf deterministically.
|
||||||
|
func sortInput(in *RenderInput) {
|
||||||
|
sort.Slice(in.ACLs, func(i, j int) bool { return in.ACLs[i].Name < in.ACLs[j].Name })
|
||||||
|
sort.Slice(in.Views, func(i, j int) bool {
|
||||||
|
if in.Views[i].Spec.Order != in.Views[j].Spec.Order {
|
||||||
|
return in.Views[i].Spec.Order < in.Views[j].Spec.Order
|
||||||
|
}
|
||||||
|
return in.Views[i].Name < in.Views[j].Name
|
||||||
|
})
|
||||||
|
sort.Slice(in.Forwards, func(i, j int) bool { return in.Forwards[i].Spec.ZoneName < in.Forwards[j].Spec.ZoneName })
|
||||||
|
sort.Slice(in.Policies, func(i, j int) bool { return in.Policies[i].Spec.ZoneName < in.Policies[j].Spec.ZoneName })
|
||||||
|
sort.Slice(in.DNSSECPolicies, func(i, j int) bool { return in.DNSSECPolicies[i].Name < in.DNSSECPolicies[j].Name })
|
||||||
|
}
|
||||||
|
|
||||||
func render(in RenderInput, isPrimary bool) string {
|
func render(in RenderInput, isPrimary bool) string {
|
||||||
c := in.Cluster
|
c := in.Cluster
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -63,6 +100,14 @@ func render(in RenderInput, isPrimary bool) string {
|
|||||||
b.WriteString(" allow-new-zones yes;\n")
|
b.WriteString(" allow-new-zones yes;\n")
|
||||||
}
|
}
|
||||||
b.WriteString(" dnssec-validation auto;\n")
|
b.WriteString(" dnssec-validation auto;\n")
|
||||||
|
// Secondaries accept NOTIFY authenticated by the cluster's NOTIFY TSIG key.
|
||||||
|
// Catalog member and plain secondary zones take their implicit allow-notify
|
||||||
|
// from their primaries (the primary Service ClusterIP), but the primary's
|
||||||
|
// NOTIFYs are sourced from its pod IP, so they are refused as "non-primary"
|
||||||
|
// unless an explicit allow-notify covers them. Rather than enumerate pod IPs
|
||||||
|
// (restart-scoped config that depends on pod IPs — the v0.2.5 roll loop), we
|
||||||
|
// accept any NOTIFY signed with the cluster key: `allow-notify { key "X"; }`.
|
||||||
|
b.WriteString(allowNotifyClause(in, isPrimary, " "))
|
||||||
for _, o := range c.Spec.ExtraOptions {
|
for _, o := range c.Spec.ExtraOptions {
|
||||||
b.WriteString(" " + strings.TrimRight(o, ";") + ";\n")
|
b.WriteString(" " + strings.TrimRight(o, ";") + ";\n")
|
||||||
}
|
}
|
||||||
@@ -90,6 +135,29 @@ func render(in RenderInput, isPrimary bool) string {
|
|||||||
b.WriteString(renderCatalogZoneDecl(in, isPrimary, ""))
|
b.WriteString(renderCatalogZoneDecl(in, isPrimary, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Top-level forward zones (BIND only allows top-level zones when no views
|
||||||
|
// are defined; in-view forward zones are rendered inside renderView).
|
||||||
|
if len(in.Views) == 0 {
|
||||||
|
for _, z := range in.Forwards {
|
||||||
|
if z.Spec.ViewRef == "" {
|
||||||
|
b.WriteString(renderForwardZone(z, ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderForwardZone renders a type:forward zone clause.
|
||||||
|
func renderForwardZone(z bindv1alpha1.BindZone, indent string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf("%szone \"%s\" {\n", indent, z.Spec.ZoneName))
|
||||||
|
b.WriteString(indent + " type forward;\n")
|
||||||
|
b.WriteString(indent + " forward only;\n")
|
||||||
|
if len(z.Spec.Forwarders) > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf("%s forwarders { %s };\n", indent, terminate(z.Spec.Forwarders)))
|
||||||
|
}
|
||||||
|
b.WriteString(indent + "};\n")
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +190,12 @@ func renderView(v bindv1alpha1.BindView, in RenderInput, isPrimary bool) string
|
|||||||
if in.Catalog != nil {
|
if in.Catalog != nil {
|
||||||
b.WriteString(renderCatalogZoneDecl(in, isPrimary, " "))
|
b.WriteString(renderCatalogZoneDecl(in, isPrimary, " "))
|
||||||
}
|
}
|
||||||
|
// Forward zones bound to this view.
|
||||||
|
for _, z := range in.Forwards {
|
||||||
|
if z.Spec.ViewRef == v.Name {
|
||||||
|
b.WriteString(renderForwardZone(z, " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
b.WriteString("};\n\n")
|
b.WriteString("};\n\n")
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
@@ -197,15 +271,68 @@ func responsePolicyClause(policies []bindv1alpha1.BindPolicy, indent string) str
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// transferPrimaries returns the primaries list secondaries use to AXFR the
|
||||||
|
// catalog (and, by inheritance, its member zones), each annotated with the
|
||||||
|
// catalog transfer TSIG key. The primary requires key-authenticated transfers
|
||||||
|
// (allow-transfer { key ... }), so an unkeyed primaries list is REFUSED.
|
||||||
|
func transferPrimaries(in RenderInput) []string {
|
||||||
|
primaries := in.Catalog.Spec.DefaultPrimaries
|
||||||
|
if len(primaries) == 0 && in.PrimaryAddress != "" {
|
||||||
|
primaries = []string{in.PrimaryAddress}
|
||||||
|
}
|
||||||
|
key := in.Catalog.Spec.TransferKeyRef
|
||||||
|
if key == "" {
|
||||||
|
return primaries
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(primaries))
|
||||||
|
for _, p := range primaries {
|
||||||
|
p = strings.TrimSpace(strings.TrimRight(p, ";"))
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(p, " key ") {
|
||||||
|
out = append(out, p)
|
||||||
|
} else {
|
||||||
|
out = append(out, fmt.Sprintf("%s key \"%s\"", p, key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowNotifyClause renders an options-scope allow-notify on secondaries that
|
||||||
|
// accepts intra-cluster NOTIFYs authenticated by the cluster's NOTIFY TSIG key.
|
||||||
|
// Zones (catalog members and plain secondaries) point their primaries at the
|
||||||
|
// primary Service ClusterIP for stable AXFR, which also becomes their implicit
|
||||||
|
// allow-notify — but NOTIFYs leave the primary pod with its *pod* IP as source,
|
||||||
|
// so without this they are refused as "non-primary". The primary signs those
|
||||||
|
// NOTIFYs with NotifyKeyName (see the also-notify entries applied via rndc), and
|
||||||
|
// this clause admits them by key.
|
||||||
|
//
|
||||||
|
// The rendered clause is `allow-notify { key "<name>"; };` — a key element with
|
||||||
|
// NO IP addresses. It is therefore fully static: it depends only on the key
|
||||||
|
// name, never on any pod IP, so it can never change the config-hash and can
|
||||||
|
// never trigger a rolling restart. This is the fix for the v0.2.5 loop, where an
|
||||||
|
// options-scope allow-notify enumerating the primary pod IP re-rendered on every
|
||||||
|
// pod churn, flipped the hash, rolled the pods, changed the IP, and looped.
|
||||||
|
//
|
||||||
|
// Emitted only on secondaries and only when the NOTIFY key name is known.
|
||||||
|
func allowNotifyClause(in RenderInput, isPrimary bool, indent string) string {
|
||||||
|
if isPrimary {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(in.NotifyKeyName)
|
||||||
|
if key == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%sallow-notify { key \"%s\"; };\n", indent, key)
|
||||||
|
}
|
||||||
|
|
||||||
func catalogZonesClause(in RenderInput, isPrimary bool, indent string) string {
|
func catalogZonesClause(in RenderInput, isPrimary bool, indent string) string {
|
||||||
// Only secondaries consume the catalog to auto-provision member zones.
|
// Only secondaries consume the catalog to auto-provision member zones.
|
||||||
if in.Catalog == nil || isPrimary {
|
if in.Catalog == nil || isPrimary {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
primaries := in.Catalog.Spec.DefaultPrimaries
|
primaries := transferPrimaries(in)
|
||||||
if len(primaries) == 0 && in.PrimaryAddress != "" {
|
|
||||||
primaries = []string{in.PrimaryAddress}
|
|
||||||
}
|
|
||||||
if len(primaries) == 0 {
|
if len(primaries) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -226,10 +353,7 @@ func renderCatalogZoneDecl(in RenderInput, isPrimary bool, indent string) string
|
|||||||
}
|
}
|
||||||
cat := in.Catalog
|
cat := in.Catalog
|
||||||
file := CatalogFilePath(cat.Spec.ZoneName)
|
file := CatalogFilePath(cat.Spec.ZoneName)
|
||||||
primaries := cat.Spec.DefaultPrimaries
|
primaries := transferPrimaries(in)
|
||||||
if len(primaries) == 0 && in.PrimaryAddress != "" {
|
|
||||||
primaries = []string{in.PrimaryAddress}
|
|
||||||
}
|
|
||||||
if len(primaries) == 0 {
|
if len(primaries) == 0 {
|
||||||
// Primary IP not known yet; omit the secondary catalog zone rather than
|
// Primary IP not known yet; omit the secondary catalog zone rather than
|
||||||
// emit an invalid empty primaries list. A Pod-triggered reconcile renders
|
// emit an invalid empty primaries list. A Pod-triggered reconcile renders
|
||||||
|
|||||||
@@ -80,6 +80,131 @@ 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{
|
||||||
|
Cluster: newCluster(bindv1alpha1.ModeResolver),
|
||||||
|
Views: []bindv1alpha1.BindView{{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: "openforwarder"},
|
||||||
|
Spec: bindv1alpha1.BindViewSpec{ClusterRef: "auth", MatchClients: []string{"acl-main"}, Recursion: &rec},
|
||||||
|
}},
|
||||||
|
Forwards: []bindv1alpha1.BindZone{{
|
||||||
|
Spec: bindv1alpha1.BindZoneSpec{ClusterRef: "auth", ZoneName: "unkin.net", Type: bindv1alpha1.ZoneForward, ViewRef: "openforwarder", Forwarders: []string{"198.18.19.15"}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
primary, secondary := RenderNamedConf(in)
|
||||||
|
for _, out := range []string{primary, secondary} {
|
||||||
|
if !strings.Contains(out, `view "openforwarder"`) {
|
||||||
|
t.Fatalf("view missing:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `zone "unkin.net" {`) || !strings.Contains(out, "type forward;") || !strings.Contains(out, "forwarders { 198.18.19.15; }") {
|
||||||
|
t.Fatalf("forward zone not rendered inside view (must be on all pods):\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenderACL(t *testing.T) {
|
func TestRenderACL(t *testing.T) {
|
||||||
in := RenderInput{
|
in := RenderInput{
|
||||||
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
|
Cluster: newCluster(bindv1alpha1.ModeAuthoritative),
|
||||||
@@ -105,3 +230,34 @@ func TestCatalogHashStable(t *testing.T) {
|
|||||||
t.Fatalf("expected 40-char hex sha1, got %d: %s", len(h1), h1)
|
t.Fatalf("expected 40-char hex sha1, got %d: %s", len(h1), h1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderDeterministicWithShuffledForwards(t *testing.T) {
|
||||||
|
// client.List order is non-deterministic; the render must not depend on
|
||||||
|
// input order, or the ConfigMap churns and (with the config hash) the
|
||||||
|
// StatefulSet rolls forever.
|
||||||
|
mkFwd := func(zone, fwd string) bindv1alpha1.BindZone {
|
||||||
|
return bindv1alpha1.BindZone{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: zone},
|
||||||
|
Spec: bindv1alpha1.BindZoneSpec{
|
||||||
|
ClusterRef: "r", Type: bindv1alpha1.ZoneForward,
|
||||||
|
ZoneName: zone, Forwarders: []string{fwd},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base := RenderInput{Cluster: newCluster(bindv1alpha1.ModeResolver)}
|
||||||
|
orderA := base
|
||||||
|
orderA.Forwards = []bindv1alpha1.BindZone{
|
||||||
|
mkFwd("unkin.net", "198.18.200.6"), mkFwd("consul", "198.18.19.14"),
|
||||||
|
mkFwd("k8s.syd1.au.unkin.net", "198.18.200.8"), mkFwd("13.18.198.in-addr.arpa", "198.18.200.6"),
|
||||||
|
}
|
||||||
|
orderB := base
|
||||||
|
orderB.Forwards = []bindv1alpha1.BindZone{
|
||||||
|
mkFwd("13.18.198.in-addr.arpa", "198.18.200.6"), mkFwd("k8s.syd1.au.unkin.net", "198.18.200.8"),
|
||||||
|
mkFwd("consul", "198.18.19.14"), mkFwd("unkin.net", "198.18.200.6"),
|
||||||
|
}
|
||||||
|
pa, _ := RenderNamedConf(orderA)
|
||||||
|
pb, _ := RenderNamedConf(orderB)
|
||||||
|
if pa != pb {
|
||||||
|
t.Fatalf("render must be independent of forward-zone input order:\n--- A ---\n%s\n--- B ---\n%s", pa, pb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string, serial int64) error {
|
||||||
origin := dot(zone)
|
origin := dot(zone)
|
||||||
ns := "ns1." + origin
|
ns := "ns1." + origin
|
||||||
|
// 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
|
content := fmt.Sprintf(`$TTL 3600
|
||||||
@ IN SOA %s hostmaster.%s (
|
@ IN SOA %s hostmaster.%s (
|
||||||
%d ; serial
|
%d ; serial
|
||||||
3600 ; refresh
|
300 ; refresh
|
||||||
900 ; retry
|
60 ; retry
|
||||||
1209600 ; expire
|
1209600 ; expire
|
||||||
300 ) ; minimum
|
60 ) ; minimum
|
||||||
@ IN NS %s
|
@ IN NS %s
|
||||||
ns1 IN A %s
|
ns1 IN A %s
|
||||||
`, ns, origin, serial, ns, primaryIP)
|
`, ns, origin, serial, ns, primaryIP)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -128,7 +131,15 @@ func (r *BindClusterReconciler) reconcileKeysSecret(ctx context.Context, c *bind
|
|||||||
if err := r.List(ctx, &keys, client.InNamespace(c.Namespace)); err != nil {
|
if err := r.List(ctx, &keys, client.InNamespace(c.Namespace)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
items := append([]bindv1alpha1.BindTSIGKey(nil), keys.Items...)
|
// Include keys scoped to this cluster (spec.clusterRef == name) and shared
|
||||||
|
// keys (empty clusterRef). This keeps keys from leaking across clusters that
|
||||||
|
// share a namespace.
|
||||||
|
var items []bindv1alpha1.BindTSIGKey
|
||||||
|
for _, k := range keys.Items {
|
||||||
|
if k.Spec.ClusterRef == "" || k.Spec.ClusterRef == c.Name {
|
||||||
|
items = append(items, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
|
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -160,10 +171,11 @@ func (r *BindClusterReconciler) reconcileKeysSecret(ctx context.Context, c *bind
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv1alpha1.BindCluster) error {
|
func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv1alpha1.BindCluster) error {
|
||||||
// BIND primaries/default-primaries need the primary's IP address, not a DNS
|
// BIND primaries/default-primaries need an IP address, not a DNS name. Use
|
||||||
// name, so render with pod-0's current IP (empty until it is scheduled; the
|
// the stable primary Service ClusterIP so secondaries keep transferring
|
||||||
// Pod watch re-renders when it appears or changes).
|
// across primary pod restarts (falls back to the pod IP when no primary
|
||||||
in := bind.RenderInput{Cluster: c, PrimaryAddress: primaryPodIP(ctx, r.Client, c)}
|
// Service exists; the Pod/Service watches re-render when it changes).
|
||||||
|
in := bind.RenderInput{Cluster: c, PrimaryAddress: primaryTransferAddress(ctx, r.Client, c)}
|
||||||
|
|
||||||
var acls bindv1alpha1.BindACLList
|
var acls bindv1alpha1.BindACLList
|
||||||
if err := r.List(ctx, &acls, client.InNamespace(c.Namespace)); err == nil {
|
if err := r.List(ctx, &acls, client.InNamespace(c.Namespace)); err == nil {
|
||||||
@@ -198,6 +210,17 @@ func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forward zones are configuration (no data), so they are rendered into
|
||||||
|
// named.conf on every pod rather than added dynamically to the primary.
|
||||||
|
var zones bindv1alpha1.BindZoneList
|
||||||
|
if err := r.List(ctx, &zones, client.InNamespace(c.Namespace)); err == nil {
|
||||||
|
for _, z := range zones.Items {
|
||||||
|
if z.Spec.ClusterRef == c.Name && z.Spec.Type == bindv1alpha1.ZoneForward {
|
||||||
|
in.Forwards = append(in.Forwards, z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var catalogs bindv1alpha1.BindCatalogZoneList
|
var catalogs bindv1alpha1.BindCatalogZoneList
|
||||||
if err := r.List(ctx, &catalogs, client.InNamespace(c.Namespace)); err == nil {
|
if err := r.List(ctx, &catalogs, client.InNamespace(c.Namespace)); err == nil {
|
||||||
for i := range catalogs.Items {
|
for i := range catalogs.Items {
|
||||||
@@ -208,6 +231,16 @@ func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Secondaries accept intra-cluster NOTIFYs signed with the cluster's catalog
|
||||||
|
// transfer TSIG key (the primary signs its also-notify NOTIFYs with it — see
|
||||||
|
// bindzone_controller). Render `allow-notify { key "<name>"; }` — a static key
|
||||||
|
// element, NO pod IPs — so it never changes on pod churn and cannot re-render
|
||||||
|
// the restart-scoped config (the v0.2.5 roll loop). Resolve the catalog's
|
||||||
|
// TransferKeyRef to the BIND key name (KeyName override, else the ref).
|
||||||
|
if in.Catalog != nil && in.Catalog.Spec.TransferKeyRef != "" {
|
||||||
|
in.NotifyKeyName = tsigKeyName(ctx, r.Client, c.Namespace, in.Catalog.Spec.TransferKeyRef)
|
||||||
|
}
|
||||||
|
|
||||||
primaryConf, secondaryConf := bind.RenderNamedConf(in)
|
primaryConf, secondaryConf := bind.RenderNamedConf(in)
|
||||||
data := map[string]string{
|
data := map[string]string{
|
||||||
"named.conf.primary": primaryConf,
|
"named.conf.primary": primaryConf,
|
||||||
@@ -254,7 +287,56 @@ func (r *BindClusterReconciler) reconcileServices(ctx context.Context, c *bindv1
|
|||||||
LoadBalancerIP: c.Spec.Service.LoadBalancerIP,
|
LoadBalancerIP: c.Spec.Service.LoadBalancerIP,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return r.upsertService(ctx, c, client)
|
// externalTrafficPolicy is only valid for LoadBalancer/NodePort Services.
|
||||||
|
if svcType == corev1.ServiceTypeLoadBalancer || svcType == corev1.ServiceTypeNodePort {
|
||||||
|
client.Spec.ExternalTrafficPolicy = c.Spec.Service.ExternalTrafficPolicy
|
||||||
|
}
|
||||||
|
if err := r.upsertService(ctx, c, client); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary (write) Service: routes only to pod-0. Created when configured,
|
||||||
|
// deleted when removed.
|
||||||
|
return r.reconcilePrimaryService(ctx, c, dnsPorts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BindClusterReconciler) reconcilePrimaryService(ctx context.Context, c *bindv1alpha1.BindCluster, dnsPorts []corev1.ServicePort) error {
|
||||||
|
name := primaryServiceName(c.Name)
|
||||||
|
if c.Spec.PrimaryService == nil {
|
||||||
|
var existing corev1.Service
|
||||||
|
err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: name}, &existing)
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return client.IgnoreNotFound(r.Delete(ctx, &existing))
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := c.Spec.PrimaryService
|
||||||
|
psType := ps.Type
|
||||||
|
if psType == "" {
|
||||||
|
psType = corev1.ServiceTypeClusterIP
|
||||||
|
}
|
||||||
|
svc := &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: name,
|
||||||
|
Namespace: c.Namespace,
|
||||||
|
Labels: commonLabels(c.Name),
|
||||||
|
Annotations: ps.Annotations,
|
||||||
|
},
|
||||||
|
Spec: corev1.ServiceSpec{
|
||||||
|
Type: psType,
|
||||||
|
Selector: primaryPodSelector(c.Name),
|
||||||
|
Ports: dnsPorts,
|
||||||
|
LoadBalancerIP: ps.LoadBalancerIP,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if psType == corev1.ServiceTypeLoadBalancer || psType == corev1.ServiceTypeNodePort {
|
||||||
|
svc.Spec.ExternalTrafficPolicy = ps.ExternalTrafficPolicy
|
||||||
|
}
|
||||||
|
return r.upsertService(ctx, c, svc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bindv1alpha1.BindCluster) (*appsv1.StatefulSet, error) {
|
func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bindv1alpha1.BindCluster) (*appsv1.StatefulSet, error) {
|
||||||
@@ -282,6 +364,12 @@ func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bin
|
|||||||
}}},
|
}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pods copy config from the projected volume into an emptyDir once at
|
||||||
|
// startup; a ConfigMap/keys change never reaches a running pod (rndc
|
||||||
|
// reconfig re-reads the stale startup copy). Stamp a hash of the projected
|
||||||
|
// config onto the pod template so a config change rolls the StatefulSet,
|
||||||
|
// which is the only way the change takes effect. The operator owns the
|
||||||
|
// template, so this restart is operator-driven and not reverted.
|
||||||
sts := &appsv1.StatefulSet{
|
sts := &appsv1.StatefulSet{
|
||||||
ObjectMeta: metav1.ObjectMeta{Name: c.Name, Namespace: c.Namespace, Labels: labels},
|
ObjectMeta: metav1.ObjectMeta{Name: c.Name, Namespace: c.Namespace, Labels: labels},
|
||||||
Spec: appsv1.StatefulSetSpec{
|
Spec: appsv1.StatefulSetSpec{
|
||||||
@@ -289,7 +377,7 @@ func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bin
|
|||||||
Replicas: &replicas,
|
Replicas: &replicas,
|
||||||
Selector: &metav1.LabelSelector{MatchLabels: labels},
|
Selector: &metav1.LabelSelector{MatchLabels: labels},
|
||||||
Template: corev1.PodTemplateSpec{
|
Template: corev1.PodTemplateSpec{
|
||||||
ObjectMeta: metav1.ObjectMeta{Labels: labels},
|
ObjectMeta: metav1.ObjectMeta{Labels: labels, Annotations: map[string]string{configHashAnnotation: r.configHash(ctx, c)}},
|
||||||
Spec: corev1.PodSpec{
|
Spec: corev1.PodSpec{
|
||||||
NodeSelector: c.Spec.NodeSelector,
|
NodeSelector: c.Spec.NodeSelector,
|
||||||
Tolerations: c.Spec.Tolerations,
|
Tolerations: c.Spec.Tolerations,
|
||||||
@@ -357,6 +445,45 @@ func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bin
|
|||||||
return &existing, nil
|
return &existing, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configHashAnnotation carries a hash of the projected config on the pod
|
||||||
|
// template; changing it triggers a rolling restart so pods pick up new config.
|
||||||
|
const configHashAnnotation = "bind.unkin.net/config-hash"
|
||||||
|
|
||||||
|
// configHash returns a deterministic hash of the config projected into the pods
|
||||||
|
// (the rendered ConfigMap and the keys.conf Secret). It is read after those are
|
||||||
|
// reconciled, so it reflects the current desired config. A stable hash means no
|
||||||
|
// spurious restarts; any config or TSIG-key change flips it and rolls the pods.
|
||||||
|
func (r *BindClusterReconciler) configHash(ctx context.Context, c *bindv1alpha1.BindCluster) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
var cm corev1.ConfigMap
|
||||||
|
if err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: configMapName(c.Name)}, &cm); err == nil {
|
||||||
|
for _, k := range sortedKeys(cm.Data) {
|
||||||
|
fmt.Fprintf(&buf, "%s\x00%s\x00", k, cm.Data[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var keys corev1.Secret
|
||||||
|
if err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: keysSecretName(c.Name)}, &keys); err == nil {
|
||||||
|
data := make(map[string]string, len(keys.Data))
|
||||||
|
for k, v := range keys.Data {
|
||||||
|
data[k] = string(v)
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys(data) {
|
||||||
|
fmt.Fprintf(&buf, "%s\x00%s\x00", k, data[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(buf.Bytes())
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(m map[string]string) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
func (r *BindClusterReconciler) reloadReadyPods(ctx context.Context, c *bindv1alpha1.BindCluster) {
|
func (r *BindClusterReconciler) reloadReadyPods(ctx context.Context, c *bindv1alpha1.BindCluster) {
|
||||||
if r.Exec == nil {
|
if r.Exec == nil {
|
||||||
return
|
return
|
||||||
@@ -408,6 +535,15 @@ func (r *BindClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
|||||||
Watches(&bindv1alpha1.BindDNSSECPolicy{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
|
Watches(&bindv1alpha1.BindDNSSECPolicy{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
|
||||||
return mapToCluster(o.(*bindv1alpha1.BindDNSSECPolicy).Spec.ClusterRef, o.GetNamespace())
|
return mapToCluster(o.(*bindv1alpha1.BindDNSSECPolicy).Spec.ClusterRef, o.GetNamespace())
|
||||||
})).
|
})).
|
||||||
|
Watches(&bindv1alpha1.BindZone{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
|
||||||
|
// Only forward zones affect named.conf; primary/secondary zones are
|
||||||
|
// managed dynamically by the BindZone controller.
|
||||||
|
z := o.(*bindv1alpha1.BindZone)
|
||||||
|
if z.Spec.Type != bindv1alpha1.ZoneForward {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mapToCluster(z.Spec.ClusterRef, o.GetNamespace())
|
||||||
|
})).
|
||||||
Watches(&bindv1alpha1.BindCatalogZone{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
|
Watches(&bindv1alpha1.BindCatalogZone{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
|
||||||
return mapToCluster(o.(*bindv1alpha1.BindCatalogZone).Spec.ClusterRef, o.GetNamespace())
|
return mapToCluster(o.(*bindv1alpha1.BindCatalogZone).Spec.ClusterRef, o.GetNamespace())
|
||||||
})).
|
})).
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
rbacv1 "k8s.io/api/rbac/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/apimachinery/pkg/types"
|
||||||
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||||
|
|
||||||
|
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||||
|
"git.unkin.net/unkin/bind-operator/internal/bind"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BindTSIGAPIReconciler deploys the companion TSIG API (Deployment, Service,
|
||||||
|
// ConfigMap, token Secret and RBAC) when a BindTSIGAPI resource exists.
|
||||||
|
type BindTSIGAPIReconciler struct {
|
||||||
|
client.Client
|
||||||
|
Scheme *runtime.Scheme
|
||||||
|
}
|
||||||
|
|
||||||
|
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindtsigapis,verbs=get;list;watch;create;update;patch;delete
|
||||||
|
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindtsigapis/status,verbs=get;update;patch
|
||||||
|
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
|
||||||
|
// +kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch;create;update;patch;delete
|
||||||
|
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings,verbs=get;list;watch;create;update;patch;delete
|
||||||
|
|
||||||
|
func (r *BindTSIGAPIReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
|
||||||
|
var api bindv1alpha1.BindTSIGAPI
|
||||||
|
if err := r.Get(ctx, req.NamespacedName, &api); err != nil {
|
||||||
|
return ctrl.Result{}, client.IgnoreNotFound(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenSecret := api.Spec.TokenSecretName
|
||||||
|
if tokenSecret == "" {
|
||||||
|
tokenSecret = api.Name + "-token"
|
||||||
|
}
|
||||||
|
targetNS := api.Spec.TargetNamespace
|
||||||
|
if targetNS == "" {
|
||||||
|
targetNS = api.Namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, step := range []func(context.Context, *bindv1alpha1.BindTSIGAPI, string, string) error{
|
||||||
|
r.reconcileServiceAccount,
|
||||||
|
r.reconcileRBAC,
|
||||||
|
r.reconcileTokenSecret,
|
||||||
|
r.reconcileConfigMap,
|
||||||
|
r.reconcileDeployment,
|
||||||
|
r.reconcileService,
|
||||||
|
} {
|
||||||
|
if err := step(ctx, &api, tokenSecret, targetNS); err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status from the Deployment.
|
||||||
|
var dep appsv1.Deployment
|
||||||
|
_ = r.Get(ctx, types.NamespacedName{Namespace: api.Namespace, Name: api.Name}, &dep)
|
||||||
|
port := api.Spec.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 8443
|
||||||
|
}
|
||||||
|
api.Status.ReadyReplicas = dep.Status.ReadyReplicas
|
||||||
|
api.Status.Endpoint = fmt.Sprintf("http://%s.%s.svc:%d", api.Name, api.Namespace, port)
|
||||||
|
api.Status.TokenSecret = tokenSecret
|
||||||
|
api.Status.ObservedGeneration = api.Generation
|
||||||
|
ready := dep.Status.ReadyReplicas > 0
|
||||||
|
if ready {
|
||||||
|
api.Status.Phase = "Ready"
|
||||||
|
} else {
|
||||||
|
api.Status.Phase = "Progressing"
|
||||||
|
}
|
||||||
|
setReady(&api.Status.Conditions, api.Generation, ready, "Reconciled", fmt.Sprintf("%d ready", dep.Status.ReadyReplicas))
|
||||||
|
if err := r.Status().Update(ctx, &api); err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
if !ready {
|
||||||
|
return ctrl.Result{RequeueAfter: requeueShort}, nil
|
||||||
|
}
|
||||||
|
logger.V(1).Info("tsig api reconciled", "api", api.Name)
|
||||||
|
return ctrl.Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tsigAPILabels(name string) map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
managedByLabel: managedByValue,
|
||||||
|
"app.kubernetes.io/name": "bind-tsig-api",
|
||||||
|
"app.kubernetes.io/instance": name,
|
||||||
|
"app.kubernetes.io/component": "tsig-api",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BindTSIGAPIReconciler) reconcileServiceAccount(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, _, _ string) error {
|
||||||
|
sa := &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace, Labels: tsigAPILabels(api.Name)}}
|
||||||
|
return r.apply(ctx, api, sa, func() {})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BindTSIGAPIReconciler) reconcileRBAC(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, _, targetNS string) error {
|
||||||
|
role := &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: targetNS, Labels: tsigAPILabels(api.Name)}}
|
||||||
|
if err := r.applyIn(ctx, api, role, targetNS, func() {
|
||||||
|
role.Rules = []rbacv1.PolicyRule{
|
||||||
|
{APIGroups: []string{"bind.unkin.net"}, Resources: []string{"bindtsigkeys"}, Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}},
|
||||||
|
{APIGroups: []string{"bind.unkin.net"}, Resources: []string{"bindtsigkeys/status"}, Verbs: []string{"get"}},
|
||||||
|
{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get", "list", "watch", "delete"}},
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rb := &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: targetNS, Labels: tsigAPILabels(api.Name)}}
|
||||||
|
return r.applyIn(ctx, api, rb, targetNS, func() {
|
||||||
|
rb.RoleRef = rbacv1.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: api.Name}
|
||||||
|
rb.Subjects = []rbacv1.Subject{{Kind: "ServiceAccount", Name: api.Name, Namespace: api.Namespace}}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileTokenSecret creates the master-access-token Secret only when it does
|
||||||
|
// not already exist, so a VaultStaticSecret may pre-seed it. Not owned by the
|
||||||
|
// BindTSIGAPI, so it survives and stays overwritable.
|
||||||
|
func (r *BindTSIGAPIReconciler) reconcileTokenSecret(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, tokenSecret, _ string) error {
|
||||||
|
var existing corev1.Secret
|
||||||
|
err := r.Get(ctx, types.NamespacedName{Namespace: api.Namespace, Name: tokenSecret}, &existing)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !apierrors.IsNotFound(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
token, genErr := bind.GenerateSecret(32)
|
||||||
|
if genErr != nil {
|
||||||
|
return genErr
|
||||||
|
}
|
||||||
|
s := &corev1.Secret{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: tokenSecret, Namespace: api.Namespace, Labels: tsigAPILabels(api.Name)},
|
||||||
|
Data: map[string][]byte{"token": []byte(token)},
|
||||||
|
}
|
||||||
|
return r.Create(ctx, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BindTSIGAPIReconciler) reconcileConfigMap(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, _, targetNS string) error {
|
||||||
|
cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: api.Name + "-config", Namespace: api.Namespace, Labels: tsigAPILabels(api.Name)}}
|
||||||
|
return r.apply(ctx, api, cm, func() {
|
||||||
|
port := api.Spec.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 8443
|
||||||
|
}
|
||||||
|
data := map[string]string{
|
||||||
|
"LISTEN_ADDR": fmt.Sprintf(":%d", port),
|
||||||
|
"TARGET_NAMESPACE": targetNS,
|
||||||
|
}
|
||||||
|
for k, v := range api.Spec.Env {
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
cm.Data = data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BindTSIGAPIReconciler) reconcileDeployment(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, tokenSecret, _ string) error {
|
||||||
|
dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace, Labels: tsigAPILabels(api.Name)}}
|
||||||
|
return r.apply(ctx, api, dep, func() {
|
||||||
|
replicas := api.Spec.Replicas
|
||||||
|
if replicas == 0 {
|
||||||
|
replicas = 1
|
||||||
|
}
|
||||||
|
image := api.Spec.Image
|
||||||
|
if image == "" {
|
||||||
|
image = "git.unkin.net/unkin/bind-tsig-api:latest"
|
||||||
|
}
|
||||||
|
port := api.Spec.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 8443
|
||||||
|
}
|
||||||
|
labels := tsigAPILabels(api.Name)
|
||||||
|
dep.Spec = appsv1.DeploymentSpec{
|
||||||
|
Replicas: &replicas,
|
||||||
|
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app.kubernetes.io/instance": api.Name}},
|
||||||
|
Template: corev1.PodTemplateSpec{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Labels: labels},
|
||||||
|
Spec: corev1.PodSpec{
|
||||||
|
ServiceAccountName: api.Name,
|
||||||
|
SecurityContext: &corev1.PodSecurityContext{RunAsNonRoot: ptr(true)},
|
||||||
|
Containers: []corev1.Container{{
|
||||||
|
Name: "tsig-api",
|
||||||
|
Image: image,
|
||||||
|
ImagePullPolicy: api.Spec.ImagePullPolicy,
|
||||||
|
Command: []string{"tsig-api"},
|
||||||
|
Ports: []corev1.ContainerPort{{Name: "https", ContainerPort: port}},
|
||||||
|
EnvFrom: []corev1.EnvFromSource{{ConfigMapRef: &corev1.ConfigMapEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: api.Name + "-config"}}}},
|
||||||
|
Env: []corev1.EnvVar{{
|
||||||
|
Name: "API_TOKEN",
|
||||||
|
ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: tokenSecret}, Key: "token"}},
|
||||||
|
}},
|
||||||
|
Resources: api.Spec.Resources,
|
||||||
|
ReadinessProbe: &corev1.Probe{
|
||||||
|
ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{Path: "/healthz", Port: intstrFromInt(int(port))}},
|
||||||
|
InitialDelaySeconds: 5, PeriodSeconds: 10,
|
||||||
|
},
|
||||||
|
SecurityContext: &corev1.SecurityContext{
|
||||||
|
AllowPrivilegeEscalation: ptr(false),
|
||||||
|
ReadOnlyRootFilesystem: ptr(true),
|
||||||
|
Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BindTSIGAPIReconciler) reconcileService(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, _, _ string) error {
|
||||||
|
svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace, Labels: tsigAPILabels(api.Name), Annotations: api.Spec.Service.Annotations}}
|
||||||
|
return r.apply(ctx, api, svc, func() {
|
||||||
|
port := api.Spec.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 8443
|
||||||
|
}
|
||||||
|
svcType := api.Spec.Service.Type
|
||||||
|
if svcType == "" {
|
||||||
|
svcType = corev1.ServiceTypeClusterIP
|
||||||
|
}
|
||||||
|
svc.Spec.Type = svcType
|
||||||
|
svc.Spec.Selector = map[string]string{"app.kubernetes.io/instance": api.Name}
|
||||||
|
svc.Spec.Ports = []corev1.ServicePort{{Name: "https", Port: port, TargetPort: intstrFromInt(int(port))}}
|
||||||
|
if api.Spec.Service.LoadBalancerIP != "" {
|
||||||
|
svc.Spec.LoadBalancerIP = api.Spec.Service.LoadBalancerIP
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply creates or updates an owned object (in the BindTSIGAPI's namespace).
|
||||||
|
func (r *BindTSIGAPIReconciler) apply(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, obj client.Object, mutate func()) error {
|
||||||
|
return r.applyIn(ctx, api, obj, api.Namespace, mutate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyIn creates or updates an object; ownership is set only for objects in
|
||||||
|
// the BindTSIGAPI's own namespace (cross-namespace owner refs are not allowed).
|
||||||
|
func (r *BindTSIGAPIReconciler) applyIn(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, obj client.Object, namespace string, mutate func()) error {
|
||||||
|
key := client.ObjectKeyFromObject(obj)
|
||||||
|
err := r.Get(ctx, key, obj)
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
mutate()
|
||||||
|
if namespace == api.Namespace {
|
||||||
|
if serr := ctrl.SetControllerReference(api, obj, r.Scheme); serr != nil {
|
||||||
|
return serr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.Create(ctx, obj)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mutate()
|
||||||
|
return r.Update(ctx, obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BindTSIGAPIReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||||
|
return ctrl.NewControllerManagedBy(mgr).
|
||||||
|
For(&bindv1alpha1.BindTSIGAPI{}).
|
||||||
|
Owns(&appsv1.Deployment{}).
|
||||||
|
Owns(&corev1.Service{}).
|
||||||
|
Owns(&corev1.ConfigMap{}).
|
||||||
|
Owns(&corev1.ServiceAccount{}).
|
||||||
|
Complete(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T { return &v }
|
||||||
@@ -60,7 +60,7 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
return ctrl.Result{}, genErr
|
return ctrl.Result{}, genErr
|
||||||
}
|
}
|
||||||
newSecret := &corev1.Secret{
|
newSecret := &corev1.Secret{
|
||||||
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: key.Namespace, Labels: map[string]string{managedByLabel: managedByValue}},
|
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: key.Namespace},
|
||||||
Data: map[string][]byte{
|
Data: map[string][]byte{
|
||||||
"algorithm": []byte(algorithm),
|
"algorithm": []byte(algorithm),
|
||||||
"keyName": []byte(keyName),
|
"keyName": []byte(keyName),
|
||||||
@@ -68,6 +68,7 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
"key.conf": []byte(bind.KeyClause(keyName, algorithm, material)),
|
"key.conf": []byte(bind.KeyClause(keyName, algorithm, material)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
applySecretTemplate(&newSecret.ObjectMeta, key.Spec.SecretTemplate)
|
||||||
if err := ctrl.SetControllerReference(&key, newSecret, r.Scheme); err != nil {
|
if err := ctrl.SetControllerReference(&key, newSecret, r.Scheme); err != nil {
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
@@ -77,6 +78,21 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
logger.Info("generated TSIG key", "key", key.Name, "secret", secretName)
|
logger.Info("generated TSIG key", "key", key.Name, "secret", secretName)
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
|
default:
|
||||||
|
// Secret already exists: reconcile the template-managed metadata so that
|
||||||
|
// annotation/label changes on the CR (e.g. reflection hints) propagate
|
||||||
|
// without regenerating key material. Skip imported secrets, which are
|
||||||
|
// owned by an external manager (Vault/VSO, reflector) that we must not
|
||||||
|
// fight over metadata.
|
||||||
|
if key.Spec.ImportExisting {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if updated := applySecretTemplate(&secret.ObjectMeta, key.Spec.SecretTemplate); updated {
|
||||||
|
if err := r.Update(ctx, &secret); err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
logger.Info("updated TSIG key secret metadata", "key", key.Name, "secret", secretName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
key.Status.SecretName = secretName
|
key.Status.SecretName = secretName
|
||||||
@@ -90,6 +106,42 @@ func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
return ctrl.Result{}, nil
|
return ctrl.Result{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applySecretTemplate stamps the operator-managed label plus any
|
||||||
|
// user-supplied labels/annotations onto the Secret's metadata. It returns true
|
||||||
|
// if it mutated meta, so callers can decide whether an update is needed.
|
||||||
|
func applySecretTemplate(meta *metav1.ObjectMeta, tmpl *bindv1alpha1.SecretMetadata) bool {
|
||||||
|
changed := false
|
||||||
|
setLabel := func(k, v string) {
|
||||||
|
if meta.Labels == nil {
|
||||||
|
meta.Labels = map[string]string{}
|
||||||
|
}
|
||||||
|
if meta.Labels[k] != v {
|
||||||
|
meta.Labels[k] = v
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAnnotation := func(k, v string) {
|
||||||
|
if meta.Annotations == nil {
|
||||||
|
meta.Annotations = map[string]string{}
|
||||||
|
}
|
||||||
|
if meta.Annotations[k] != v {
|
||||||
|
meta.Annotations[k] = v
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLabel(managedByLabel, managedByValue)
|
||||||
|
if tmpl != nil {
|
||||||
|
for k, v := range tmpl.Labels {
|
||||||
|
setLabel(k, v)
|
||||||
|
}
|
||||||
|
for k, v := range tmpl.Annotations {
|
||||||
|
setAnnotation(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
func (r *BindTSIGKeyReconciler) fail(ctx context.Context, key *bindv1alpha1.BindTSIGKey, reason, msg string) (ctrl.Result, error) {
|
func (r *BindTSIGKeyReconciler) fail(ctx context.Context, key *bindv1alpha1.BindTSIGKey, reason, msg string) (ctrl.Result, error) {
|
||||||
key.Status.Ready = false
|
key.Status.Ready = false
|
||||||
key.Status.ObservedGeneration = key.Generation
|
key.Status.ObservedGeneration = key.Generation
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -35,6 +35,19 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
|
|||||||
return ctrl.Result{}, client.IgnoreNotFound(err)
|
return ctrl.Result{}, client.IgnoreNotFound(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forward zones are pure configuration rendered into named.conf by the
|
||||||
|
// BindCluster controller (on every pod), not added dynamically to the
|
||||||
|
// primary. Nothing to do here beyond reporting readiness.
|
||||||
|
if zone.Spec.Type == bindv1alpha1.ZoneForward {
|
||||||
|
zone.Status.Phase = "Ready"
|
||||||
|
zone.Status.ObservedGeneration = zone.Generation
|
||||||
|
setReady(&zone.Status.Conditions, zone.Generation, true, "Configured", "forward zone rendered into named.conf")
|
||||||
|
if err := r.Status().Update(ctx, &zone); err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
return ctrl.Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
cluster, err := getCluster(ctx, r.Client, zone.Namespace, zone.Spec.ClusterRef)
|
cluster, err := getCluster(ctx, r.Client, zone.Namespace, zone.Spec.ClusterRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.setPhase(ctx, &zone, "Error", "ClusterMissing", err.Error())
|
return r.setPhase(ctx, &zone, "Error", "ClusterMissing", err.Error())
|
||||||
@@ -67,7 +80,21 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
|
|||||||
return r.setPhase(ctx, &zone, "Pending", "PrimaryNotReady", "waiting for cluster primary to be ready")
|
return r.setPhase(ctx, &zone, "Pending", "PrimaryNotReady", "waiting for cluster primary to be ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
zoneConfig, err := r.buildZoneConfig(ctx, &zone)
|
// Primary zones replicated to secondaries (catalog members) get an
|
||||||
|
// also-notify pointing at the secondary pods, so a dynamic update NOTIFYs
|
||||||
|
// them immediately rather than waiting for the SOA refresh.
|
||||||
|
var notifyTargets []string
|
||||||
|
if isPrimaryType(zone.Spec.Type) && catalogEnabled(&zone) {
|
||||||
|
notifyTargets = secondaryPodIPs(ctx, r.Client, cluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalog transfer TSIG key doubles as the intra-cluster NOTIFY key: the
|
||||||
|
// primary signs its also-notify NOTIFYs with it and secondaries accept them
|
||||||
|
// via `allow-notify { key "<key>"; }`. Keying the NOTIFYs is what lets the
|
||||||
|
// secondary's allow-notify be a static key element (no pod IPs), so pod-IP
|
||||||
|
// churn never re-renders restart-scoped config (the v0.2.5 roll loop).
|
||||||
|
transferKey := r.zoneTransferKeyRef(ctx, &zone, cluster)
|
||||||
|
zoneConfig, err := r.buildZoneConfig(ctx, &zone, transferKey, notifyTargets, transferKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error())
|
return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error())
|
||||||
}
|
}
|
||||||
@@ -120,7 +147,11 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildZoneConfig renders the inner clause passed to rndc addzone/modzone.
|
// buildZoneConfig renders the inner clause passed to rndc addzone/modzone.
|
||||||
func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone) (string, error) {
|
// transferKey, when set, is the catalog transfer TSIG key name; catalog member
|
||||||
|
// primary zones must allow AXFR with it so secondaries can pull them. notifyKey,
|
||||||
|
// when set, is the TSIG key each also-notify entry is signed with, so
|
||||||
|
// secondaries can accept the NOTIFYs by key rather than by (churning) pod IP.
|
||||||
|
func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone, transferKey string, notifyTargets []string, notifyKey string) (string, error) {
|
||||||
zType := zone.Spec.Type
|
zType := zone.Spec.Type
|
||||||
if zType == "" {
|
if zType == "" {
|
||||||
zType = bindv1alpha1.ZonePrimary
|
zType = bindv1alpha1.ZonePrimary
|
||||||
@@ -132,8 +163,22 @@ func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1al
|
|||||||
if zone.Spec.DynamicUpdate && zone.Spec.UpdateKeyRef != "" {
|
if zone.Spec.DynamicUpdate && zone.Spec.UpdateKeyRef != "" {
|
||||||
parts = append(parts, fmt.Sprintf("allow-update { key \"%s\"; }", updateKeyName(ctx, r.Client, zone)))
|
parts = append(parts, fmt.Sprintf("allow-update { key \"%s\"; }", updateKeyName(ctx, r.Client, zone)))
|
||||||
}
|
}
|
||||||
if len(zone.Spec.AllowTransfer) > 0 {
|
switch {
|
||||||
|
case len(zone.Spec.AllowTransfer) > 0:
|
||||||
parts = append(parts, fmt.Sprintf("allow-transfer { %s }", matchListInline(zone.Spec.AllowTransfer)))
|
parts = append(parts, fmt.Sprintf("allow-transfer { %s }", matchListInline(zone.Spec.AllowTransfer)))
|
||||||
|
case transferKey != "":
|
||||||
|
// Catalog member: permit key-authenticated AXFR from secondaries.
|
||||||
|
parts = append(parts, fmt.Sprintf("allow-transfer { key \"%s\"; }", transferKey))
|
||||||
|
}
|
||||||
|
// NOTIFY only the secondaries we know about (their apex NS is the primary
|
||||||
|
// itself, so default `notify yes` would reach no one). `notify explicit`
|
||||||
|
// keeps NOTIFY off the query-serving VIP and scoped to the pod IPs. Each
|
||||||
|
// entry is signed with notifyKey so secondaries can admit the NOTIFY by key
|
||||||
|
// (`allow-notify { key ... }`) instead of by pod IP. This zone config is
|
||||||
|
// applied via rndc addzone/modzone — no pod restart — so listing pod IPs
|
||||||
|
// here is safe; only *restart-scoped* config must never depend on pod IPs.
|
||||||
|
if len(notifyTargets) > 0 {
|
||||||
|
parts = append(parts, "notify explicit", fmt.Sprintf("also-notify { %s }", alsoNotifyList(notifyTargets, notifyKey)))
|
||||||
}
|
}
|
||||||
if zone.Spec.DNSSECPolicyRef != "" {
|
if zone.Spec.DNSSECPolicyRef != "" {
|
||||||
parts = append(parts, fmt.Sprintf("dnssec-policy \"%s\"", zone.Spec.DNSSECPolicyRef), "inline-signing yes")
|
parts = append(parts, fmt.Sprintf("dnssec-policy \"%s\"", zone.Spec.DNSSECPolicyRef), "inline-signing yes")
|
||||||
@@ -189,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)
|
_ = r.Exec.RemoveCatalogMember(ctx, zone.Namespace, primaryPod, catalog.Spec.ZoneName, zone.Spec.ZoneName, creds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// zoneTransferKeyRef returns the catalog transfer TSIG key name that a catalog
|
||||||
|
// member primary zone must allow AXFR with, so secondaries (which present that
|
||||||
|
// key) can pull it. Returns "" for non-member zones, non-primary zones, or when
|
||||||
|
// the cluster has no catalog.
|
||||||
|
func (r *BindZoneReconciler) zoneTransferKeyRef(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) string {
|
||||||
|
if !isPrimaryType(zone.Spec.Type) || !catalogEnabled(zone) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var catalogs bindv1alpha1.BindCatalogZoneList
|
||||||
|
if err := r.List(ctx, &catalogs, client.InNamespace(zone.Namespace)); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for i := range catalogs.Items {
|
||||||
|
if catalogs.Items[i].Spec.ClusterRef == cluster.Name {
|
||||||
|
return catalogs.Items[i].Spec.TransferKeyRef
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (r *BindZoneReconciler) catalogFor(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) (*bindv1alpha1.BindCatalogZone, bind.TSIGCreds, bool) {
|
func (r *BindZoneReconciler) catalogFor(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) (*bindv1alpha1.BindCatalogZone, bind.TSIGCreds, bool) {
|
||||||
var catalogs bindv1alpha1.BindCatalogZoneList
|
var catalogs bindv1alpha1.BindCatalogZoneList
|
||||||
if err := r.List(ctx, &catalogs, client.InNamespace(zone.Namespace)); err != nil {
|
if err := r.List(ctx, &catalogs, client.InNamespace(zone.Namespace)); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A primary zone with known secondaries renders notify explicit + also-notify
|
||||||
|
// so a dynamic update NOTIFYs the secondaries immediately.
|
||||||
|
func TestBuildZoneConfigPrimaryAlsoNotify(t *testing.T) {
|
||||||
|
r := &BindZoneReconciler{}
|
||||||
|
zone := &bindv1alpha1.BindZone{
|
||||||
|
Spec: bindv1alpha1.BindZoneSpec{
|
||||||
|
ZoneName: "main.unkin.net",
|
||||||
|
Type: bindv1alpha1.ZonePrimary,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := r.buildZoneConfig(context.Background(), zone, "transfer-key", []string{"10.42.2.6", "10.42.1.5"}, "externaldns-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildZoneConfig: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cfg, "notify explicit") {
|
||||||
|
t.Errorf("expected notify explicit in %q", cfg)
|
||||||
|
}
|
||||||
|
// also-notify entries are keyed so secondaries can admit the NOTIFY by TSIG
|
||||||
|
// key (allow-notify { key ... }) instead of by (churning) pod IP.
|
||||||
|
if !strings.Contains(cfg, `also-notify { 10.42.2.6 key "externaldns-key"; 10.42.1.5 key "externaldns-key"; }`) {
|
||||||
|
t.Errorf("expected keyed also-notify with the secondary IPs in %q", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// also-notify entries carry no key when none is configured (unkeyed NOTIFY).
|
||||||
|
func TestBuildZoneConfigPrimaryAlsoNotifyUnkeyed(t *testing.T) {
|
||||||
|
r := &BindZoneReconciler{}
|
||||||
|
zone := &bindv1alpha1.BindZone{
|
||||||
|
Spec: bindv1alpha1.BindZoneSpec{ZoneName: "main.unkin.net", Type: bindv1alpha1.ZonePrimary},
|
||||||
|
}
|
||||||
|
cfg, err := r.buildZoneConfig(context.Background(), zone, "transfer-key", []string{"10.42.2.6"}, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildZoneConfig: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cfg, "also-notify { 10.42.2.6; }") {
|
||||||
|
t.Errorf("expected unkeyed also-notify in %q", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no secondaries, no also-notify is emitted (single-replica cluster).
|
||||||
|
func TestBuildZoneConfigPrimaryNoNotifyTargets(t *testing.T) {
|
||||||
|
r := &BindZoneReconciler{}
|
||||||
|
zone := &bindv1alpha1.BindZone{
|
||||||
|
Spec: bindv1alpha1.BindZoneSpec{ZoneName: "main.unkin.net", Type: bindv1alpha1.ZonePrimary},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := r.buildZoneConfig(context.Background(), zone, "transfer-key", nil, "externaldns-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildZoneConfig: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(cfg, "also-notify") || strings.Contains(cfg, "notify explicit") {
|
||||||
|
t.Errorf("did not expect notify clauses with no targets: %q", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
|
|
||||||
|
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestConfigHashIndependentOfPrimaryPodIP is the permanent regression guard for
|
||||||
|
// the v0.2.5 rolling-restart loop.
|
||||||
|
//
|
||||||
|
// v0.2.5 rendered an options-scope `allow-notify { <primaryPodIP>; ... }` into
|
||||||
|
// the (restart-scoped) named.conf. The config-hash annotation that rolls the
|
||||||
|
// StatefulSet is computed over that render (BindClusterReconciler.configHash), so
|
||||||
|
// a config change rolled the pods, the primary pod came back on a NEW IP, the
|
||||||
|
// operator re-rendered with the new IP, the hash changed, the pods rolled again,
|
||||||
|
// and so on — an infinite roll loop across every BindCluster.
|
||||||
|
//
|
||||||
|
// The fix removed all pod IPs from restart-scoped config (secondaries now admit
|
||||||
|
// intra-cluster NOTIFYs by TSIG key: `allow-notify { key "X"; }`). This test
|
||||||
|
// reconciles the ConfigMap with the primary pod on one IP, computes the hash,
|
||||||
|
// then does it again with the primary pod on a DIFFERENT IP, and asserts the
|
||||||
|
// hash is byte-identical. If any pod-IP dependency ever creeps back into
|
||||||
|
// restart-scoped config, this fails and the loop-class bug is caught.
|
||||||
|
func TestConfigHashIndependentOfPrimaryPodIP(t *testing.T) {
|
||||||
|
scheme := runtime.NewScheme()
|
||||||
|
if err := clientgoscheme.AddToScheme(scheme); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := bindv1alpha1.AddToScheme(scheme); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ns = "dns"
|
||||||
|
svcIP := "10.43.5.5" // stable primary Service ClusterIP (does NOT churn)
|
||||||
|
|
||||||
|
cluster := &bindv1alpha1.BindCluster{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: "auth", Namespace: ns},
|
||||||
|
Spec: bindv1alpha1.BindClusterSpec{
|
||||||
|
Mode: bindv1alpha1.ModeAuthoritative,
|
||||||
|
Replicas: 3,
|
||||||
|
PrimaryService: &bindv1alpha1.ClusterServiceSpec{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
primarySvc := &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: primaryServiceName(cluster.Name), Namespace: ns},
|
||||||
|
Spec: corev1.ServiceSpec{ClusterIP: svcIP},
|
||||||
|
}
|
||||||
|
catalog := &bindv1alpha1.BindCatalogZone{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: "cat", Namespace: ns},
|
||||||
|
Spec: bindv1alpha1.BindCatalogZoneSpec{
|
||||||
|
ClusterRef: cluster.Name,
|
||||||
|
ZoneName: "catalog.internal",
|
||||||
|
TransferKeyRef: "externaldns-key",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tsig := &bindv1alpha1.BindTSIGKey{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: "externaldns-key", Namespace: ns},
|
||||||
|
Spec: bindv1alpha1.BindTSIGKeySpec{ClusterRef: cluster.Name},
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashWithPrimaryIP reconciles the ConfigMap with the primary pod carrying the
|
||||||
|
// given IP, then returns the resulting config-hash.
|
||||||
|
hashWithPrimaryIP := func(ip string) string {
|
||||||
|
primaryPod := &corev1.Pod{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: primaryPodName(cluster.Name), Namespace: ns, Labels: commonLabels(cluster.Name)},
|
||||||
|
Status: corev1.PodStatus{PodIP: ip},
|
||||||
|
}
|
||||||
|
c := fake.NewClientBuilder().
|
||||||
|
WithScheme(scheme).
|
||||||
|
WithObjects(cluster, primarySvc, catalog, tsig, primaryPod).
|
||||||
|
Build()
|
||||||
|
r := &BindClusterReconciler{Client: c, Scheme: scheme}
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := r.reconcileConfigMap(ctx, cluster); err != nil {
|
||||||
|
t.Fatalf("reconcileConfigMap: %v", err)
|
||||||
|
}
|
||||||
|
// Sanity: the pod IP must not have leaked into the rendered config.
|
||||||
|
var cm corev1.ConfigMap
|
||||||
|
if err := c.Get(ctx, client.ObjectKey{Namespace: ns, Name: configMapName(cluster.Name)}, &cm); err != nil {
|
||||||
|
t.Fatalf("get configmap: %v", err)
|
||||||
|
}
|
||||||
|
for k, v := range cm.Data {
|
||||||
|
if ip != "" && strings.Contains(v, ip) {
|
||||||
|
t.Fatalf("primary pod IP %s leaked into restart-scoped config %s:\n%s", ip, k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.configHash(ctx, cluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 := hashWithPrimaryIP("10.42.3.197")
|
||||||
|
h2 := hashWithPrimaryIP("10.42.9.42") // primary pod restarted onto a new IP
|
||||||
|
if h1 == "" {
|
||||||
|
t.Fatal("config hash should not be empty")
|
||||||
|
}
|
||||||
|
if h1 != h2 {
|
||||||
|
t.Fatalf("config hash changed when only the primary pod IP changed — the v0.2.5 roll loop:\n%s\n%s", h1, h2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
@@ -29,11 +30,20 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func headlessServiceName(cluster string) string { return cluster + "-headless" }
|
func headlessServiceName(cluster string) string { return cluster + "-headless" }
|
||||||
func clientServiceName(cluster string) string { return cluster }
|
func primaryServiceName(cluster string) string { return cluster + "-primary" }
|
||||||
func primaryPodName(cluster string) string { return cluster + "-0" }
|
|
||||||
func configMapName(cluster string) string { return cluster + "-config" }
|
// primaryPodSelector selects only the primary pod (ordinal 0) via the stable
|
||||||
func keysSecretName(cluster string) string { return cluster + "-keys" }
|
// StatefulSet pod-name label, for the write Service.
|
||||||
func rndcSecretName(cluster string) string { return cluster + "-rndc" }
|
func primaryPodSelector(cluster string) map[string]string {
|
||||||
|
s := commonLabels(cluster)
|
||||||
|
s["statefulset.kubernetes.io/pod-name"] = primaryPodName(cluster)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
func clientServiceName(cluster string) string { return cluster }
|
||||||
|
func primaryPodName(cluster string) string { return cluster + "-0" }
|
||||||
|
func configMapName(cluster string) string { return cluster + "-config" }
|
||||||
|
func keysSecretName(cluster string) string { return cluster + "-keys" }
|
||||||
|
func rndcSecretName(cluster string) string { return cluster + "-rndc" }
|
||||||
|
|
||||||
// primaryAddress is the in-cluster DNS name of the primary pod (ordinal 0).
|
// primaryAddress is the in-cluster DNS name of the primary pod (ordinal 0).
|
||||||
func primaryAddress(cluster, namespace string) string {
|
func primaryAddress(cluster, namespace string) string {
|
||||||
@@ -99,6 +109,52 @@ func primaryPodIP(ctx context.Context, c client.Client, cluster *bindv1alpha1.Bi
|
|||||||
return pod.Status.PodIP
|
return pod.Status.PodIP
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// primaryTransferAddress returns the address secondaries use to reach the
|
||||||
|
// primary for catalog and zone AXFR. It prefers the primary Service ClusterIP,
|
||||||
|
// which is stable across primary pod restarts (the pod IP is not: it changes on
|
||||||
|
// every restart, leaving secondaries pointed at a dead address). It falls back
|
||||||
|
// to the primary pod IP when no primary Service is configured or its ClusterIP
|
||||||
|
// is not yet assigned.
|
||||||
|
func primaryTransferAddress(ctx context.Context, c client.Client, cluster *bindv1alpha1.BindCluster) string {
|
||||||
|
if cluster.Spec.PrimaryService != nil {
|
||||||
|
var svc corev1.Service
|
||||||
|
if err := c.Get(ctx, client.ObjectKey{Namespace: cluster.Namespace, Name: primaryServiceName(cluster.Name)}, &svc); err == nil {
|
||||||
|
if ip := svc.Spec.ClusterIP; ip != "" && ip != corev1.ClusterIPNone {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return primaryPodIP(ctx, c, cluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
// secondaryPodIPs returns the pod IPs of a cluster's secondary pods (every pod
|
||||||
|
// except the ordinal-0 primary) that currently have an address. The primary
|
||||||
|
// uses this list as its zone `also-notify` set, so a change to a primary zone
|
||||||
|
// (in particular a dynamic update) triggers an immediate NOTIFY -> IXFR to the
|
||||||
|
// secondaries instead of leaving them stale until the next SOA refresh. The
|
||||||
|
// list is sorted so the rendered zone config is stable and does not churn
|
||||||
|
// modzone on every reconcile. Pod IPs change across restarts, so the caller
|
||||||
|
// relies on the zone controller's periodic requeue to refresh the set (a
|
||||||
|
// restarted secondary re-transfers the whole zone on load regardless). Returns
|
||||||
|
// nil for a single-replica cluster.
|
||||||
|
func secondaryPodIPs(ctx context.Context, c client.Client, cluster *bindv1alpha1.BindCluster) []string {
|
||||||
|
var pods corev1.PodList
|
||||||
|
if err := c.List(ctx, &pods, client.InNamespace(cluster.Namespace), client.MatchingLabels(commonLabels(cluster.Name))); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
primary := primaryPodName(cluster.Name)
|
||||||
|
var ips []string
|
||||||
|
for i := range pods.Items {
|
||||||
|
p := &pods.Items[i]
|
||||||
|
if p.Name == primary || p.Status.PodIP == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ips = append(ips, p.Status.PodIP)
|
||||||
|
}
|
||||||
|
sort.Strings(ips)
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
|
||||||
// resolveTSIG reads the material of a BindTSIGKey into TSIG credentials.
|
// resolveTSIG reads the material of a BindTSIGKey into TSIG credentials.
|
||||||
func resolveTSIG(ctx context.Context, c client.Client, namespace, keyRef string) (bind.TSIGCreds, error) {
|
func resolveTSIG(ctx context.Context, c client.Client, namespace, keyRef string) (bind.TSIGCreds, error) {
|
||||||
var creds bind.TSIGCreds
|
var creds bind.TSIGCreds
|
||||||
|
|||||||
@@ -35,5 +35,8 @@ func SetupAll(mgr ctrl.Manager, exec *bind.Executor) error {
|
|||||||
if err := (&DNSRecordReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil {
|
if err := (&DNSRecordReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := (&BindTSIGAPIReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ func (r *BindClusterReconciler) upsertService(ctx context.Context, c *bindv1alph
|
|||||||
existing.Spec.Selector = desired.Spec.Selector
|
existing.Spec.Selector = desired.Spec.Selector
|
||||||
existing.Spec.Type = desired.Spec.Type
|
existing.Spec.Type = desired.Spec.Type
|
||||||
existing.Spec.LoadBalancerIP = desired.Spec.LoadBalancerIP
|
existing.Spec.LoadBalancerIP = desired.Spec.LoadBalancerIP
|
||||||
|
existing.Spec.ExternalTrafficPolicy = desired.Spec.ExternalTrafficPolicy
|
||||||
if desired.Annotations != nil {
|
if desired.Annotations != nil {
|
||||||
if existing.Annotations == nil {
|
if existing.Annotations == nil {
|
||||||
existing.Annotations = map[string]string{}
|
existing.Annotations = map[string]string{}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
@@ -58,12 +59,19 @@ func recordsToUpdates(zone string, records []bindv1alpha1.Record, defaultTTL int
|
|||||||
// updateKeyName returns the TSIG key name (as used in named.conf) for a zone's
|
// updateKeyName returns the TSIG key name (as used in named.conf) for a zone's
|
||||||
// update key, falling back to the object name.
|
// update key, falling back to the object name.
|
||||||
func updateKeyName(ctx context.Context, c client.Client, zone *bindv1alpha1.BindZone) string {
|
func updateKeyName(ctx context.Context, c client.Client, zone *bindv1alpha1.BindZone) string {
|
||||||
ref := zone.Spec.UpdateKeyRef
|
return tsigKeyName(ctx, c, zone.Namespace, zone.Spec.UpdateKeyRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tsigKeyName resolves a BindTSIGKey object reference to the TSIG key name used
|
||||||
|
// in named.conf (the KeyName override when set, otherwise the object name).
|
||||||
|
// Returns "" for an empty ref, and falls back to the ref if the object cannot be
|
||||||
|
// read.
|
||||||
|
func tsigKeyName(ctx context.Context, c client.Client, namespace, ref string) string {
|
||||||
if ref == "" {
|
if ref == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
var key bindv1alpha1.BindTSIGKey
|
var key bindv1alpha1.BindTSIGKey
|
||||||
if err := c.Get(ctx, client.ObjectKey{Namespace: zone.Namespace, Name: ref}, &key); err != nil {
|
if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: ref}, &key); err != nil {
|
||||||
return ref
|
return ref
|
||||||
}
|
}
|
||||||
if key.Spec.KeyName != "" {
|
if key.Spec.KeyName != "" {
|
||||||
@@ -86,3 +94,23 @@ func terminateInline(entries []string) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// alsoNotifyList renders also-notify entries, each optionally annotated with a
|
||||||
|
// TSIG key so the primary signs its NOTIFYs and secondaries can accept them by
|
||||||
|
// key (`allow-notify { key ... }`) rather than by pod IP. An entry that already
|
||||||
|
// carries a `key` clause is left untouched.
|
||||||
|
func alsoNotifyList(addrs []string, key string) string {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
var parts []string
|
||||||
|
for _, a := range addrs {
|
||||||
|
a = strings.TrimSpace(strings.TrimRight(a, ";"))
|
||||||
|
if a == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if key != "" && !strings.Contains(a, " key ") {
|
||||||
|
a = fmt.Sprintf("%s key \"%s\"", a, key)
|
||||||
|
}
|
||||||
|
parts = append(parts, a+";")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package tsigapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/types"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
|
||||||
|
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createKey creates a BindTSIGKey and waits for the operator to materialise it.
|
||||||
|
func (s *Server) createKey(ctx context.Context, req createRequest) (*keyResponse, error) {
|
||||||
|
algorithm := req.Algorithm
|
||||||
|
if algorithm == "" {
|
||||||
|
algorithm = string(bindv1alpha1.TSIGHMACSHA256)
|
||||||
|
}
|
||||||
|
key := &bindv1alpha1.BindTSIGKey{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: req.Name,
|
||||||
|
Namespace: s.Namespace,
|
||||||
|
Labels: map[string]string{"app.kubernetes.io/managed-by": "bind-tsig-api"},
|
||||||
|
},
|
||||||
|
Spec: bindv1alpha1.BindTSIGKeySpec{
|
||||||
|
Algorithm: bindv1alpha1.TSIGAlgorithm(algorithm),
|
||||||
|
ClusterRef: req.ClusterRef,
|
||||||
|
KeyName: req.Name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := s.Client.Create(ctx, key); err != nil && !apierrors.IsAlreadyExists(err) {
|
||||||
|
return nil, fmt.Errorf("create bindtsigkey: %w", err)
|
||||||
|
}
|
||||||
|
return s.waitForMaterial(ctx, req.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readKey returns the current material for a key.
|
||||||
|
func (s *Server) readKey(ctx context.Context, name string) (*keyResponse, error) {
|
||||||
|
var key bindv1alpha1.BindTSIGKey
|
||||||
|
if err := s.Client.Get(ctx, s.key(name), &key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.material(ctx, &key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotateKey replaces the key material: it deletes the key Secret (the operator
|
||||||
|
// regenerates it) and bumps a rotation annotation so the cluster controller
|
||||||
|
// re-renders keys.conf and reloads BIND.
|
||||||
|
func (s *Server) rotateKey(ctx context.Context, name string) (*keyResponse, error) {
|
||||||
|
var key bindv1alpha1.BindTSIGKey
|
||||||
|
if err := s.Client.Get(ctx, s.key(name), &key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
secretName := secretNameFor(&key)
|
||||||
|
var secret corev1.Secret
|
||||||
|
if err := s.Client.Get(ctx, client.ObjectKey{Namespace: s.Namespace, Name: secretName}, &secret); err == nil {
|
||||||
|
if derr := s.Client.Delete(ctx, &secret); derr != nil && !apierrors.IsNotFound(derr) {
|
||||||
|
return nil, fmt.Errorf("delete key secret: %w", derr)
|
||||||
|
}
|
||||||
|
} else if !apierrors.IsNotFound(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger re-generation + re-render by bumping the CR.
|
||||||
|
if key.Annotations == nil {
|
||||||
|
key.Annotations = map[string]string{}
|
||||||
|
}
|
||||||
|
key.Annotations[rotationAnnotation] = s.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
if err := s.Client.Update(ctx, &key); err != nil {
|
||||||
|
return nil, fmt.Errorf("bump rotation annotation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.waitForNewMaterial(ctx, name, secret.UID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteKey removes the BindTSIGKey (its Secret is garbage-collected).
|
||||||
|
func (s *Server) deleteKey(ctx context.Context, name string) error {
|
||||||
|
key := &bindv1alpha1.BindTSIGKey{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: s.Namespace}}
|
||||||
|
if err := s.Client.Delete(ctx, key); err != nil && !apierrors.IsNotFound(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// material reads the key material from the Secret referenced by a BindTSIGKey.
|
||||||
|
func (s *Server) material(ctx context.Context, key *bindv1alpha1.BindTSIGKey) (*keyResponse, error) {
|
||||||
|
var secret corev1.Secret
|
||||||
|
if err := s.Client.Get(ctx, client.ObjectKey{Namespace: s.Namespace, Name: secretNameFor(key)}, &secret); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
algorithm := string(secret.Data["algorithm"])
|
||||||
|
if algorithm == "" {
|
||||||
|
algorithm = string(key.Spec.Algorithm)
|
||||||
|
}
|
||||||
|
keyName := string(secret.Data["keyName"])
|
||||||
|
if keyName == "" {
|
||||||
|
keyName = key.Name
|
||||||
|
}
|
||||||
|
return &keyResponse{
|
||||||
|
Name: key.Name,
|
||||||
|
Algorithm: algorithm,
|
||||||
|
Secret: string(secret.Data["secret"]),
|
||||||
|
KeyName: keyName,
|
||||||
|
ClusterRef: key.Spec.ClusterRef,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForMaterial polls until the key's Secret exists (operator reconciled).
|
||||||
|
func (s *Server) waitForMaterial(ctx context.Context, name string) (*keyResponse, error) {
|
||||||
|
deadline := s.Now().Add(s.WaitTimeout)
|
||||||
|
for {
|
||||||
|
var key bindv1alpha1.BindTSIGKey
|
||||||
|
if err := s.Client.Get(ctx, s.key(name), &key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp, err := s.material(ctx, &key); err == nil && resp.Secret != "" {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
if s.Now().After(deadline) {
|
||||||
|
return nil, fmt.Errorf("timed out waiting for key %q material", name)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(250 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForNewMaterial polls until the Secret has been recreated (different UID).
|
||||||
|
func (s *Server) waitForNewMaterial(ctx context.Context, name string, oldUID types.UID) (*keyResponse, error) {
|
||||||
|
deadline := s.Now().Add(s.WaitTimeout)
|
||||||
|
for {
|
||||||
|
var key bindv1alpha1.BindTSIGKey
|
||||||
|
if err := s.Client.Get(ctx, s.key(name), &key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var secret corev1.Secret
|
||||||
|
err := s.Client.Get(ctx, client.ObjectKey{Namespace: s.Namespace, Name: secretNameFor(&key)}, &secret)
|
||||||
|
if err == nil && secret.UID != oldUID && len(secret.Data["secret"]) > 0 {
|
||||||
|
return s.material(ctx, &key)
|
||||||
|
}
|
||||||
|
if s.Now().After(deadline) {
|
||||||
|
return nil, fmt.Errorf("timed out waiting for key %q to rotate", name)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(250 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretNameFor returns the Secret name holding a key's material.
|
||||||
|
func secretNameFor(key *bindv1alpha1.BindTSIGKey) string {
|
||||||
|
if key.Status.SecretName != "" {
|
||||||
|
return key.Status.SecretName
|
||||||
|
}
|
||||||
|
if key.Spec.SecretName != "" {
|
||||||
|
return key.Spec.SecretName
|
||||||
|
}
|
||||||
|
return key.Name + "-tsig"
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// Package tsigapi implements the companion TSIG API deployed by the operator.
|
||||||
|
// It exposes the HTTP contract consumed by vault-plugin-secrets-bind-tsig and
|
||||||
|
// fulfils it by managing BindTSIGKey custom resources, which the operator
|
||||||
|
// reconciles into key material.
|
||||||
|
package tsigapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-logr/logr"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
"k8s.io/apimachinery/pkg/types"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
const rotationAnnotation = "bind.unkin.net/rotated-at"
|
||||||
|
|
||||||
|
// Server serves the TSIG key API.
|
||||||
|
type Server struct {
|
||||||
|
Client client.Client
|
||||||
|
Namespace string
|
||||||
|
Token string
|
||||||
|
Log logr.Logger
|
||||||
|
// WaitTimeout bounds how long a create/rotate waits for the operator to
|
||||||
|
// reconcile the key material.
|
||||||
|
WaitTimeout time.Duration
|
||||||
|
// Now is injectable for tests; defaults to time.Now.
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type keyResponse struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Algorithm string `json:"algorithm"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
KeyName string `json:"key_name"`
|
||||||
|
ClusterRef string `json:"cluster_ref,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Algorithm string `json:"algorithm"`
|
||||||
|
ClusterRef string `json:"cluster_ref"`
|
||||||
|
Static bool `json:"static"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler returns the API's HTTP handler.
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
if s.Now == nil {
|
||||||
|
s.Now = time.Now
|
||||||
|
}
|
||||||
|
if s.WaitTimeout == 0 {
|
||||||
|
s.WaitTimeout = 15 * time.Second
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
|
||||||
|
mux.HandleFunc("/v1/keys", s.auth(s.handleKeys))
|
||||||
|
mux.HandleFunc("/v1/keys/", s.auth(s.handleKey))
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.Token != "" {
|
||||||
|
want := "Bearer " + s.Token
|
||||||
|
got := r.Header.Get("Authorization")
|
||||||
|
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKeys serves POST /v1/keys (create).
|
||||||
|
func (s *Server) handleKeys(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" {
|
||||||
|
http.Error(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := s.createKey(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
s.fail(w, "create", req.Name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKey serves GET/DELETE /v1/keys/{name} and POST /v1/keys/{name}/rotate.
|
||||||
|
func (s *Server) handleKey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/keys/"), "/")
|
||||||
|
rotate := strings.HasSuffix(name, "/rotate")
|
||||||
|
name = strings.TrimSuffix(name, "/rotate")
|
||||||
|
if name == "" {
|
||||||
|
http.Error(w, "key name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case rotate && r.Method == http.MethodPost:
|
||||||
|
key, err := s.rotateKey(r.Context(), name)
|
||||||
|
if err != nil {
|
||||||
|
s.fail(w, "rotate", name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, key)
|
||||||
|
case r.Method == http.MethodGet:
|
||||||
|
key, err := s.readKey(r.Context(), name)
|
||||||
|
if err != nil {
|
||||||
|
s.fail(w, "read", name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, key)
|
||||||
|
case r.Method == http.MethodDelete:
|
||||||
|
if err := s.deleteKey(r.Context(), name); err != nil {
|
||||||
|
s.fail(w, "delete", name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) fail(w http.ResponseWriter, op, name string, err error) {
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Log.Error(err, "tsig api request failed", "op", op, "name", name)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) key(name string) types.NamespacedName {
|
||||||
|
return types.NamespacedName{Namespace: s.Namespace, Name: name}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package tsigapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-logr/logr"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/apimachinery/pkg/types"
|
||||||
|
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
|
|
||||||
|
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testNS = "bind-system"
|
||||||
|
|
||||||
|
// fakeOperator simulates the BindTSIGKey controller: whenever a key exists
|
||||||
|
// without its material Secret, it creates one. It stops when ctx is cancelled.
|
||||||
|
func fakeOperator(ctx context.Context, c client.Client) {
|
||||||
|
seq := 0
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(20 * time.Millisecond):
|
||||||
|
}
|
||||||
|
var keys bindv1alpha1.BindTSIGKeyList
|
||||||
|
if err := c.List(ctx, &keys); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range keys.Items {
|
||||||
|
k := &keys.Items[i]
|
||||||
|
secretName := k.Name + "-tsig"
|
||||||
|
var existing corev1.Secret
|
||||||
|
if err := c.Get(ctx, client.ObjectKey{Namespace: k.Namespace, Name: secretName}, &existing); err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seq++
|
||||||
|
material := "secret-material-" + itoa(seq)
|
||||||
|
_ = c.Create(ctx, &corev1.Secret{
|
||||||
|
// Fake client does not assign UIDs; set one so rotation
|
||||||
|
// (which detects a Secret with a new UID) is observable.
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: k.Namespace, UID: types.UID("uid-" + itoa(seq))},
|
||||||
|
Data: map[string][]byte{
|
||||||
|
"algorithm": []byte(k.Spec.Algorithm),
|
||||||
|
"keyName": []byte(k.Name),
|
||||||
|
"secret": []byte(material),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var b []byte
|
||||||
|
for n > 0 {
|
||||||
|
b = append([]byte{byte('0' + n%10)}, b...)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestServer(t *testing.T) (*Server, context.CancelFunc) {
|
||||||
|
t.Helper()
|
||||||
|
scheme := runtime.NewScheme()
|
||||||
|
if err := clientgoscheme.AddToScheme(scheme); err != nil {
|
||||||
|
t.Fatalf("clientgo scheme: %v", err)
|
||||||
|
}
|
||||||
|
if err := bindv1alpha1.AddToScheme(scheme); err != nil {
|
||||||
|
t.Fatalf("bind scheme: %v", err)
|
||||||
|
}
|
||||||
|
c := fake.NewClientBuilder().WithScheme(scheme).Build()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
go fakeOperator(ctx, c)
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
Client: c,
|
||||||
|
Namespace: testNS,
|
||||||
|
Token: "s3cr3t",
|
||||||
|
Log: logr.Discard(),
|
||||||
|
WaitTimeout: 3 * time.Second,
|
||||||
|
}, cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthzAndAuth(t *testing.T) {
|
||||||
|
srv, cancel := newTestServer(t)
|
||||||
|
defer cancel()
|
||||||
|
h := srv.Handler()
|
||||||
|
|
||||||
|
// healthz needs no auth.
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("healthz: want 200, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// missing token is rejected.
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/v1/keys", strings.NewReader(`{"name":"x"}`)))
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("no-auth: want 401, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyLifecycle(t *testing.T) {
|
||||||
|
srv, cancel := newTestServer(t)
|
||||||
|
defer cancel()
|
||||||
|
h := srv.Handler()
|
||||||
|
|
||||||
|
// create
|
||||||
|
created := do(t, h, http.MethodPost, "/v1/keys", `{"name":"host-a","cluster_ref":"bind-authoritative"}`, http.StatusOK)
|
||||||
|
if created.Secret == "" {
|
||||||
|
t.Fatalf("create returned empty secret")
|
||||||
|
}
|
||||||
|
if created.ClusterRef != "bind-authoritative" {
|
||||||
|
t.Fatalf("cluster_ref not propagated: %q", created.ClusterRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
// read returns the same material
|
||||||
|
got := do(t, h, http.MethodGet, "/v1/keys/host-a", "", http.StatusOK)
|
||||||
|
if got.Secret != created.Secret {
|
||||||
|
t.Fatalf("read secret %q != created %q", got.Secret, created.Secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotate returns new material
|
||||||
|
rotated := do(t, h, http.MethodPost, "/v1/keys/host-a/rotate", "", http.StatusOK)
|
||||||
|
if rotated.Secret == created.Secret {
|
||||||
|
t.Fatalf("rotate did not change secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req := authed(http.MethodDelete, "/v1/keys/host-a", "")
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete: want 204, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// read after delete → 404
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, authed(http.MethodGet, "/v1/keys/host-a", ""))
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("read-after-delete: want 404, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authed(method, path, body string) *http.Request {
|
||||||
|
var r *http.Request
|
||||||
|
if body == "" {
|
||||||
|
r = httptest.NewRequest(method, path, nil)
|
||||||
|
} else {
|
||||||
|
r = httptest.NewRequest(method, path, strings.NewReader(body))
|
||||||
|
}
|
||||||
|
r.Header.Set("Authorization", "Bearer s3cr3t")
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func do(t *testing.T, h http.Handler, method, path, body string, wantCode int) keyResponse {
|
||||||
|
t.Helper()
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, authed(method, path, body))
|
||||||
|
if rr.Code != wantCode {
|
||||||
|
t.Fatalf("%s %s: want %d, got %d (%s)", method, path, wantCode, rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
var resp keyResponse
|
||||||
|
if rr.Body.Len() > 0 {
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user