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:
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user