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 }