cfbc06669e
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
161 lines
4.5 KiB
Go
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
|
|
}
|