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.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user