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

156 lines
4.3 KiB
Go

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