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