Initial vault-plugin-secrets-rancher scaffold

Vault/OpenBao secrets engine managing Rancher API tokens via the public
tokens.ext.cattle.io API.

- config: Rancher connection (URL + TLS)
- service-accounts/<name>: seeded root tokens, auto-rotated before Rancher's
  TTL cap via a PeriodicFunc (default 45d rotation, 90d token TTL); the current
  token mints its own replacement. Manual /rotate endpoint too.
- roles/<name>: mint policy referencing a service account; cluster_name + TTL
  scoping (Rancher tokens inherit the seeding user's RBAC).
- creds/<role>: dynamic, lease-bound tokens deleted from Rancher on revoke.

Ports the bind-tsig Woodpecker RPM release, nfpm packaging, and a mock-Rancher
e2e (Vault + OpenBao). Unit tests cover the full lifecycle.
This commit is contained in:
Ben Vincent
2026-07-15 22:11:31 +10:00
commit c46641dafb
27 changed files with 2544 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
# End-to-end test stack. A mock Rancher ext.cattle.io Token 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:
rancher:
image: golang:1.25-alpine
working_dir: /src
environment:
MOCKRANCHER_ADDR: ":8443"
MOCKRANCHER_TOKEN: "seed-token"
GOFLAGS: "-mod=mod"
command: ["go", "run", "./test/mockrancher"]
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:
rancher:
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:
rancher:
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
+156
View File
@@ -0,0 +1,156 @@
// Command mockrancher is an in-memory stand-in for the Rancher aggregated API
// server's tokens.ext.cattle.io resource, used by the e2e tests. It implements
// just enough of the Token contract the plugin uses (create/get/delete) — no
// Kubernetes or real Rancher required. Not for production use.
//
// A bearer is accepted if it is the seed token (MOCKRANCHER_TOKEN) or any token
// this server previously minted; that lets a rotated token mint further tokens,
// exactly as the plugin's root-rotation relies on.
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"log"
"net/http"
"os"
"strings"
"sync"
)
type tokenMeta struct {
Name string `json:"name,omitempty"`
GenerateName string `json:"generateName,omitempty"`
}
type tokenSpec struct {
Description string `json:"description,omitempty"`
TTL int64 `json:"ttl,omitempty"`
ClusterName string `json:"clusterName,omitempty"`
}
type tokenStatus struct {
Value string `json:"value,omitempty"`
}
type token struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata tokenMeta `json:"metadata"`
Spec tokenSpec `json:"spec"`
Status tokenStatus `json:"status"`
}
type store struct {
mu sync.Mutex
tokens map[string]*token // by metadata.name
valid map[string]string // bearer value -> metadata.name
}
func randHex(n int) string {
buf := make([]byte, n)
_, _ = rand.Read(buf)
return hex.EncodeToString(buf)
}
func (s *store) authName(r *http.Request) (string, bool) {
bearer := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if bearer == "" {
return "", false
}
s.mu.Lock()
defer s.mu.Unlock()
name, ok := s.valid[bearer]
return name, ok
}
const apiPrefix = "/apis/ext.cattle.io/v1/tokens"
func (s *store) handle(w http.ResponseWriter, r *http.Request) {
if _, ok := s.authName(r); !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
name := strings.Trim(strings.TrimPrefix(r.URL.Path, apiPrefix), "/")
s.mu.Lock()
defer s.mu.Unlock()
switch {
case r.Method == http.MethodPost && name == "": // create
var req token
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
newName := req.Metadata.Name
if newName == "" {
newName = req.Metadata.GenerateName + randHex(4)
}
value := "token-" + newName + ":" + randHex(16)
t := &token{
APIVersion: "ext.cattle.io/v1",
Kind: "Token",
Metadata: tokenMeta{Name: newName},
Spec: req.Spec,
Status: tokenStatus{Value: value},
}
s.tokens[newName] = t
s.valid[value] = newName
writeJSON(w, http.StatusCreated, t)
case r.Method == http.MethodGet && name != "": // read
t, ok := s.tokens[name]
if !ok {
http.NotFound(w, r)
return
}
// The value is only returned at creation; blank it on read.
out := *t
out.Status.Value = ""
writeJSON(w, http.StatusOK, &out)
case r.Method == http.MethodDelete && name != "": // delete
t, ok := s.tokens[name]
if !ok {
http.NotFound(w, r)
return
}
for v, n := range s.valid {
if n == name {
delete(s.valid, v)
}
}
delete(s.tokens, name)
_ = t
w.WriteHeader(http.StatusOK)
default:
http.Error(w, "not found", http.StatusNotFound)
}
}
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func main() {
addr := os.Getenv("MOCKRANCHER_ADDR")
if addr == "" {
addr = ":8443"
}
seed := os.Getenv("MOCKRANCHER_TOKEN")
if seed == "" {
seed = "seed-token"
}
s := &store{
tokens: map[string]*token{},
valid: map[string]string{seed: "seed"},
}
mux := http.NewServeMux()
mux.HandleFunc(apiPrefix, s.handle)
mux.HandleFunc(apiPrefix+"/", s.handle)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
log.Printf("mock rancher ext.cattle.io 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"