Scaffold the bind-tsig secrets engine
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

A Vault/OpenBao secrets engine that manages BIND TSIG keys via the
bind-operator companion API (Vault -> HTTP API -> BindTSIGKey CRs).

- backend + cmd entry point (plugin.ServeMultiplex), modelled on
  vault-plugin-secrets-litellm
- config path: companion API url/token/tls + defaults
- static-roles/static-creds: stable named key with managed rotation
- roles/creds: dynamic, lease-bound keys (revoke deletes the CR)
- tsig_key secret type with revoke/renew
- HTTP client for the companion API contract (/v1/keys CRUD + rotate)
- Makefile, Woodpecker CI (pre-commit/build/test + tag release RPMs),
  nfpm packaging (vault + openbao flavours)
- e2e: mock companion API + Vault + OpenBao in docker-compose, full
  lifecycle per engine; unit tests for the dynamic + static flows
This commit is contained in:
2026-07-11 02:17:09 +10:00
parent 1083bdd09a
commit b4b8915d3e
27 changed files with 2369 additions and 1 deletions
+63
View File
@@ -0,0 +1,63 @@
# End-to-end test stack. A mock bind-operator companion API (in-memory, no k8s)
# plus two secrets-engine hosts running the exact same plugin binary: HashiCorp
# Vault and OpenBao. Bind mounts use ":z" so they work under SELinux.
services:
companion-api:
image: golang:1.25-alpine
working_dir: /src
environment:
MOCKAPI_ADDR: ":8443"
MOCKAPI_TOKEN: "e2e-token"
GOFLAGS: "-mod=mod"
command: ["go", "run", "./test/mockapi"]
volumes:
- ..:/src:ro,z
ports:
- "8443:8443"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8443/healthz"]
interval: 3s
timeout: 3s
retries: 40
vault:
image: hashicorp/vault:1.18
depends_on:
companion-api:
condition: service_healthy
cap_add: [IPC_LOCK]
environment:
VAULT_DEV_ROOT_TOKEN_ID: root
VAULT_ADDR: http://127.0.0.1:8200
VAULT_TOKEN: root
command: ["server", "-dev", "-dev-listen-address=0.0.0.0:8200", "-config=/vault/vault.hcl"]
volumes:
- ../dist:/vault/plugins:ro,z
- ./vault/vault.hcl:/vault/vault.hcl:ro,z
ports: ["8200:8200"]
healthcheck:
test: ["CMD", "vault", "status", "-address=http://127.0.0.1:8200"]
interval: 3s
timeout: 3s
retries: 20
openbao:
image: openbao/openbao:latest
depends_on:
companion-api:
condition: service_healthy
cap_add: [IPC_LOCK]
environment:
BAO_DEV_ROOT_TOKEN_ID: root
BAO_ADDR: http://127.0.0.1:8200
BAO_TOKEN: root
command: ["server", "-dev", "-dev-listen-address=0.0.0.0:8200", "-config=/openbao/bao.hcl"]
volumes:
- ../dist:/openbao/plugins:ro,z
- ./openbao/bao.hcl:/openbao/bao.hcl:ro,z
ports: ["8300:8200"]
healthcheck:
test: ["CMD", "bao", "status", "-address=http://127.0.0.1:8200"]
interval: 3s
timeout: 3s
retries: 20
+117
View File
@@ -0,0 +1,117 @@
// Command mockapi is an in-memory stand-in for the bind-operator companion API,
// used by the e2e tests. It implements the /v1/keys contract the plugin expects
// (create/read/rotate/delete), generating random TSIG material — no Kubernetes
// required. Not for production use.
package main
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"log"
"net/http"
"os"
"strings"
"sync"
)
type key 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 store struct {
mu sync.Mutex
keys map[string]*key
token string
}
func genSecret() string {
buf := make([]byte, 32)
_, _ = rand.Read(buf)
return base64.StdEncoding.EncodeToString(buf)
}
func (s *store) authOK(r *http.Request) bool {
if s.token == "" {
return true
}
return r.Header.Get("Authorization") == "Bearer "+s.token
}
func (s *store) handle(w http.ResponseWriter, r *http.Request) {
if !s.authOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// /v1/keys or /v1/keys/<name>[/rotate]
rest := strings.TrimPrefix(r.URL.Path, "/v1/keys")
name := strings.Trim(rest, "/")
rotate := strings.HasSuffix(name, "/rotate")
name = strings.TrimSuffix(name, "/rotate")
s.mu.Lock()
defer s.mu.Unlock()
switch {
case r.Method == http.MethodPost && name == "": // create
var req key
_ = json.NewDecoder(r.Body).Decode(&req)
if req.Name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
if req.Algorithm == "" {
req.Algorithm = "hmac-sha256"
}
k := &key{Name: req.Name, Algorithm: req.Algorithm, Secret: genSecret(), KeyName: req.Name, ClusterRef: req.ClusterRef}
s.keys[req.Name] = k
writeJSON(w, k)
case r.Method == http.MethodPost && rotate: // rotate
k, ok := s.keys[name]
if !ok {
http.NotFound(w, r)
return
}
k.Secret = genSecret()
writeJSON(w, k)
case r.Method == http.MethodGet && name != "": // read
k, ok := s.keys[name]
if !ok {
http.NotFound(w, r)
return
}
writeJSON(w, k)
case r.Method == http.MethodDelete && name != "": // delete
if _, ok := s.keys[name]; !ok {
http.NotFound(w, r)
return
}
delete(s.keys, name)
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "not found", http.StatusNotFound)
}
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func main() {
addr := os.Getenv("MOCKAPI_ADDR")
if addr == "" {
addr = ":8443"
}
s := &store{keys: map[string]*key{}, token: os.Getenv("MOCKAPI_TOKEN")}
mux := http.NewServeMux()
mux.HandleFunc("/v1/keys", s.handle)
mux.HandleFunc("/v1/keys/", s.handle)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
log.Printf("mock companion API listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux)) //nolint:gosec // test-only mock
}
+4
View File
@@ -0,0 +1,4 @@
# OpenBao is plugin-protocol compatible with Vault, so the very same plugin
# binary registers and runs here unchanged. Combined with `-dev` at runtime.
plugin_directory = "/openbao/plugins"
api_addr = "http://127.0.0.1:8200"
+4
View File
@@ -0,0 +1,4 @@
# Combined with `-dev` at runtime; supplies the plugin_directory the dev server
# would otherwise leave unset, so the plugin binary in ../dist can be registered.
plugin_directory = "/vault/plugins"
api_addr = "http://127.0.0.1:8200"