Files
unkinben 53db084c2d
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
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.
2026-07-11 12:44:13 +10:00

169 lines
5.3 KiB
Go

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