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