Add companion TSIG API and BindTSIGAPI CRD
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful

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.
This commit is contained in:
2026-07-11 12:44:13 +10:00
parent 49df29a072
commit 53db084c2d
14 changed files with 1657 additions and 0 deletions
@@ -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 }
+3
View File
@@ -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
}
+168
View File
@@ -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"
}
+155
View File
@@ -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}
}
+185
View File
@@ -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
}