From 53db084c2d17fb0c3436bf316716db0355382699 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sat, 11 Jul 2026 12:44:13 +1000 Subject: [PATCH] Add companion TSIG API and BindTSIGAPI CRD The vault-plugin-secrets-bind-tsig plugin needs an HTTP endpoint that creates, reads, rotates and deletes TSIG keys on its behalf, decoupling Vault from direct Kubernetes API access. This adds that companion API and lets the operator deploy it declaratively. - Add BindTSIGAPI CRD: creating one makes the operator reconcile a Deployment, Service, ConfigMap (env vars), token Secret and namespaced RBAC for the companion API. Spec covers image, replicas, port, targetNamespace, tokenSecretName, extra env, service exposure and resources. - Generate the master access token Secret only when absent, so a VaultStaticSecret may pre-seed/overwrite it; the operator does not own it. - Add the companion API server (internal/tsigapi): bearer-auth HTTP contract POST /v1/keys, GET/DELETE /v1/keys/{name}, POST /v1/keys/{name}/rotate, backed by BindTSIGKey custom resources the operator reconciles into key material. - Add cmd/tsigapi entrypoint and Dockerfile.tsigapi (distroless). - Wire the reconciler into setup, regenerate CRDs/RBAC/deepcopy, and add Woodpecker build (PR dry-run) and release (tag push) steps for the bind-tsig-api image. - Cover the API server with auth and key-lifecycle unit tests. --- .woodpecker/build.yaml | 7 + .woodpecker/docker.yaml | 13 + Dockerfile.tsigapi | 20 ++ api/v1alpha1/bindtsigapi_types.go | 104 +++++++ api/v1alpha1/zz_generated.deepcopy.go | 105 +++++++ cmd/tsigapi/main.go | 72 +++++ .../bases/bind.unkin.net_bindtsigapis.yaml | 267 +++++++++++++++++ config/crd/install.yaml | 267 +++++++++++++++++ config/rbac/role.yaml | 17 ++ internal/controller/bindtsigapi_controller.go | 274 ++++++++++++++++++ internal/controller/setup.go | 3 + internal/tsigapi/keys.go | 168 +++++++++++ internal/tsigapi/server.go | 155 ++++++++++ internal/tsigapi/server_test.go | 185 ++++++++++++ 14 files changed, 1657 insertions(+) create mode 100644 Dockerfile.tsigapi create mode 100644 api/v1alpha1/bindtsigapi_types.go create mode 100644 cmd/tsigapi/main.go create mode 100644 config/crd/bases/bind.unkin.net_bindtsigapis.yaml create mode 100644 internal/controller/bindtsigapi_controller.go create mode 100644 internal/tsigapi/keys.go create mode 100644 internal/tsigapi/server.go create mode 100644 internal/tsigapi/server_test.go diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml index c62cf66..4414efc 100644 --- a/.woodpecker/build.yaml +++ b/.woodpecker/build.yaml @@ -8,3 +8,10 @@ steps: repo: git.unkin.net/unkin/bind-operator dockerfile: Dockerfile.operator dry_run: true + + - name: docker-build-tsig-api + image: woodpeckerci/plugin-docker-buildx + settings: + repo: git.unkin.net/unkin/bind-tsig-api + dockerfile: Dockerfile.tsigapi + dry_run: true diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml index c40f724..300c8a8 100644 --- a/.woodpecker/docker.yaml +++ b/.woodpecker/docker.yaml @@ -15,3 +15,16 @@ steps: tags: - ${CI_COMMIT_TAG} - latest + + - name: docker-tsig-api + image: woodpeckerci/plugin-docker-buildx + settings: + registry: git.unkin.net + repo: git.unkin.net/unkin/bind-tsig-api + dockerfile: Dockerfile.tsigapi + username: droneci + password: + from_secret: DRONECI_PASSWORD + tags: + - ${CI_COMMIT_TAG} + - latest diff --git a/Dockerfile.tsigapi b/Dockerfile.tsigapi new file mode 100644 index 0000000..c8ca07c --- /dev/null +++ b/Dockerfile.tsigapi @@ -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"] diff --git a/api/v1alpha1/bindtsigapi_types.go b/api/v1alpha1/bindtsigapi_types.go new file mode 100644 index 0000000..c2d063e --- /dev/null +++ b/api/v1alpha1/bindtsigapi_types.go @@ -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 "-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{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 3e3210b..1cfcd39 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -581,6 +581,111 @@ func (in *BindPolicyStatus) DeepCopy() *BindPolicyStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindTSIGAPI) DeepCopyInto(out *BindTSIGAPI) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGAPI. +func (in *BindTSIGAPI) DeepCopy() *BindTSIGAPI { + if in == nil { + return nil + } + out := new(BindTSIGAPI) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BindTSIGAPI) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindTSIGAPIList) DeepCopyInto(out *BindTSIGAPIList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BindTSIGAPI, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGAPIList. +func (in *BindTSIGAPIList) DeepCopy() *BindTSIGAPIList { + if in == nil { + return nil + } + out := new(BindTSIGAPIList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BindTSIGAPIList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindTSIGAPISpec) DeepCopyInto(out *BindTSIGAPISpec) { + *out = *in + if in.Env != nil { + in, out := &in.Env, &out.Env + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + in.Service.DeepCopyInto(&out.Service) + in.Resources.DeepCopyInto(&out.Resources) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGAPISpec. +func (in *BindTSIGAPISpec) DeepCopy() *BindTSIGAPISpec { + if in == nil { + return nil + } + out := new(BindTSIGAPISpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindTSIGAPIStatus) DeepCopyInto(out *BindTSIGAPIStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGAPIStatus. +func (in *BindTSIGAPIStatus) DeepCopy() *BindTSIGAPIStatus { + if in == nil { + return nil + } + out := new(BindTSIGAPIStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BindTSIGKey) DeepCopyInto(out *BindTSIGKey) { *out = *in diff --git a/cmd/tsigapi/main.go b/cmd/tsigapi/main.go new file mode 100644 index 0000000..38442f2 --- /dev/null +++ b/cmd/tsigapi/main.go @@ -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" +} diff --git a/config/crd/bases/bind.unkin.net_bindtsigapis.yaml b/config/crd/bases/bind.unkin.net_bindtsigapis.yaml new file mode 100644 index 0000000..5d7c0eb --- /dev/null +++ b/config/crd/bases/bind.unkin.net_bindtsigapis.yaml @@ -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 "-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: {} diff --git a/config/crd/install.yaml b/config/crd/install.yaml index d33523f..bd96752 100644 --- a/config/crd/install.yaml +++ b/config/crd/install.yaml @@ -2023,6 +2023,273 @@ spec: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: bindtsigapis.bind.unkin.net +spec: + group: bind.unkin.net + names: + kind: BindTSIGAPI + listKind: BindTSIGAPIList + plural: bindtsigapis + shortNames: + - btapi + singular: bindtsigapi + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.endpoint + name: Endpoint + type: string + - jsonPath: .status.readyReplicas + name: Ready + type: integer + - jsonPath: .status.phase + name: Phase + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + BindTSIGAPI deploys the companion TSIG API. Creating one makes the operator + reconcile a Deployment, Service, ConfigMap, token Secret and RBAC for it. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + BindTSIGAPISpec configures the companion TSIG API that the operator deploys. + The API exposes an HTTP contract (used by vault-plugin-secrets-bind-tsig) for + creating, rotating and deleting TSIG keys; it does so by managing BindTSIGKey + custom resources, which the operator then reconciles into key material. + properties: + env: + additionalProperties: + type: string + description: Env are extra environment variables rendered into the + API ConfigMap. + type: object + image: + default: git.unkin.net/unkin/bind-tsig-api:latest + description: Image is the companion API container image. + type: string + imagePullPolicy: + description: ImagePullPolicy for the API container. + type: string + port: + default: 8443 + description: Port the API listens on. Defaults to 8443. + format: int32 + type: integer + replicas: + default: 1 + description: Replicas of the API. Defaults to 1. + format: int32 + type: integer + resources: + description: Resources for the API container. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + service: + description: Service controls how the API is exposed (defaults to + ClusterIP). + properties: + annotations: + additionalProperties: + type: string + description: Annotations added to the client-facing Service (e.g. + PureLB/MetalLB hints). + type: object + externalTrafficPolicy: + description: |- + ExternalTrafficPolicy for a LoadBalancer/NodePort Service. Local preserves + client source IPs (required for source-IP ACLs on the DNS servers) but + only routes to nodes running a pod. Defaults to Cluster. + enum: + - Cluster + - Local + type: string + loadBalancerIP: + description: LoadBalancerIP requests a specific address when Type + is LoadBalancer. + type: string + type: + description: Type of the client-facing Service. Defaults to ClusterIP. + enum: + - ClusterIP + - LoadBalancer + - NodePort + type: string + type: object + targetNamespace: + description: |- + TargetNamespace is where the API creates BindTSIGKey resources. Defaults + to the API's own namespace. + type: string + tokenSecretName: + description: |- + TokenSecretName holds the master access token clients present to the API. + The operator generates a token if the Secret does not exist, so a + VaultStaticSecret may pre-seed it instead. Defaults to "-token". + type: string + type: object + status: + description: BindTSIGAPIStatus reports observed API state. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + endpoint: + description: Endpoint is the in-cluster URL clients (Vault) use to + reach the API. + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + readyReplicas: + format: int32 + type: integer + tokenSecret: + description: TokenSecret is the Secret holding the master access token. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.17.3 diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index a80b4ed..65b72ab 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -9,6 +9,7 @@ rules: resources: - configmaps - secrets + - serviceaccounts - services verbs: - create @@ -36,6 +37,7 @@ rules: - apiGroups: - apps resources: + - deployments - statefulsets verbs: - create @@ -53,6 +55,7 @@ rules: - bindclusters - binddnssecpolicies - bindpolicies + - bindtsigapis - bindtsigkeys - bindviews - bindzones @@ -73,6 +76,7 @@ rules: - bindclusters/status - binddnssecpolicies/status - bindpolicies/status + - bindtsigapis/status - bindtsigkeys/status - bindviews/status - bindzones/status @@ -81,3 +85,16 @@ rules: - get - patch - update +- apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + - roles + verbs: + - create + - delete + - get + - list + - patch + - update + - watch diff --git a/internal/controller/bindtsigapi_controller.go b/internal/controller/bindtsigapi_controller.go new file mode 100644 index 0000000..c8ca3f0 --- /dev/null +++ b/internal/controller/bindtsigapi_controller.go @@ -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 } diff --git a/internal/controller/setup.go b/internal/controller/setup.go index 69d9673..a96165e 100644 --- a/internal/controller/setup.go +++ b/internal/controller/setup.go @@ -35,5 +35,8 @@ func SetupAll(mgr ctrl.Manager, exec *bind.Executor) error { if err := (&DNSRecordReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil { return err } + if err := (&BindTSIGAPIReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil { + return err + } return nil } diff --git a/internal/tsigapi/keys.go b/internal/tsigapi/keys.go new file mode 100644 index 0000000..d95fc62 --- /dev/null +++ b/internal/tsigapi/keys.go @@ -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" +} diff --git a/internal/tsigapi/server.go b/internal/tsigapi/server.go new file mode 100644 index 0000000..cda189d --- /dev/null +++ b/internal/tsigapi/server.go @@ -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} +} diff --git a/internal/tsigapi/server_test.go b/internal/tsigapi/server_test.go new file mode 100644 index 0000000..90064a3 --- /dev/null +++ b/internal/tsigapi/server_test.go @@ -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 +}