Files
vault-plugin-secrets-netbox/test/mocknetbox/main.go
T
Ben Vincent cfbc06669e
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Initial vault-plugin-secrets-netbox
Vault/OpenBao secrets engine that mints NetBox API tokens via
/api/users/tokens/. A single seeded admin token (config) mints short-lived,
per-user tokens (roles -> creds) whose NetBox expiry is aligned to the Vault
lease; revoke deletes the token, renew extends its expiry. config/rotate
reissues the seeded admin token. Handles NetBox 4.6 v2 tokens (Bearer
nbt_<key>.<secret>) and legacy v1. Unit tests against an httptest NetBox mock;
dual Vault/OpenBao RPMs via nfpm; tag-driven release to artifactapi.

Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
2026-08-08 22:14:59 +10:00

161 lines
4.5 KiB
Go

// Command mocknetbox is an in-memory stand-in for the NetBox token API used by
// the e2e tests. It implements just enough of /api/users/tokens/ (create by user,
// lookup by key, patch expires, delete) and /api/users/users/ (lookup by
// username) that the plugin exercises — no NetBox or database required.
//
// Any credential it has issued (or the seed admin, MOCKNETBOX_TOKEN) is accepted
// as auth, so admin-token rotation chains work exactly as the plugin relies on.
// Not for production use.
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
)
const (
tokenPrefix = "nbt_"
tokensPath = "/api/users/tokens/"
usersPath = "/api/users/users/"
)
type tok struct {
id int
key string
plaintext string
version int
writeEnabled bool
expires string
userID int
}
func (t *tok) credential() string {
if t.version == 2 {
return tokenPrefix + t.key + "." + t.plaintext
}
return t.plaintext
}
type store struct {
mu sync.Mutex
seq int
tokens map[int]*tok
valid map[string]bool
users map[string]int
}
func (s *store) handle(w http.ResponseWriter, r *http.Request) {
cred := strings.TrimPrefix(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), "Token ")
s.mu.Lock()
defer s.mu.Unlock()
if !s.valid[cred] {
w.WriteHeader(http.StatusUnauthorized)
return
}
switch {
case r.URL.Path == usersPath && r.Method == http.MethodGet:
results := []map[string]any{}
if id, ok := s.users[r.URL.Query().Get("username")]; ok {
results = append(results, map[string]any{"id": id})
}
writeJSON(w, 200, map[string]any{"results": results})
case r.URL.Path == tokensPath && r.Method == http.MethodPost:
var in struct {
User int `json:"user"`
WriteEnabled bool `json:"write_enabled"`
Version int `json:"version"`
Expires string `json:"expires"`
}
_ = json.NewDecoder(r.Body).Decode(&in)
s.seq++
t := &tok{id: s.seq, plaintext: fmt.Sprintf("secret%d", s.seq), version: in.Version, writeEnabled: in.WriteEnabled, expires: in.Expires, userID: in.User}
if t.version == 0 {
t.version = 2
}
if t.version == 2 {
t.key = fmt.Sprintf("key%d", s.seq)
}
s.tokens[t.id] = t
s.valid[t.credential()] = true
writeJSON(w, 201, map[string]any{"id": t.id, "key": t.key, "token": t.plaintext, "version": t.version, "write_enabled": t.writeEnabled, "expires": t.expires, "user": map[string]any{"id": t.userID}})
case r.URL.Path == tokensPath && r.Method == http.MethodGet:
key := r.URL.Query().Get("key")
results := []map[string]any{}
for _, t := range s.tokens {
if key != "" && t.key == key {
results = append(results, map[string]any{"id": t.id, "key": t.key, "version": t.version, "user": map[string]any{"id": t.userID}})
}
}
writeJSON(w, 200, map[string]any{"results": results})
case strings.HasPrefix(r.URL.Path, tokensPath):
id, err := strconv.Atoi(strings.Trim(strings.TrimPrefix(r.URL.Path, tokensPath), "/"))
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
t, ok := s.tokens[id]
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
switch r.Method {
case http.MethodPatch:
var in struct {
Expires string `json:"expires"`
}
_ = json.NewDecoder(r.Body).Decode(&in)
t.expires = in.Expires
writeJSON(w, 200, map[string]any{"id": t.id, "expires": t.expires})
case http.MethodDelete:
delete(s.tokens, id)
delete(s.valid, t.credential())
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
default:
w.WriteHeader(http.StatusNotFound)
}
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func main() {
addr := os.Getenv("MOCKNETBOX_ADDR")
if addr == "" {
addr = ":8080"
}
seed := os.Getenv("MOCKNETBOX_TOKEN")
if seed == "" {
seed = "nbt_admkey.admsecret"
}
adminUser := 1
s := &store{
tokens: map[int]*tok{1: {id: 1, key: "admkey", plaintext: "admsecret", version: 2, writeEnabled: true, userID: adminUser}},
valid: map[string]bool{seed: true},
users: map[string]int{"svc-terraform-ipam": 42, "svc-puppet-facts": 43},
}
s.seq = 1
mux := http.NewServeMux()
mux.HandleFunc(usersPath, s.handle)
mux.HandleFunc(tokensPath, s.handle)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
log.Printf("mock netbox token API listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux)) //nolint:gosec // test-only mock
}