feat: scaffold unibus_admin gateway (Go REST + embed SPA placeholder)

Single Go binary: serves an embedded Mantine SPA and a small REST API over the
unibus control plane. Holds the operator ADMIN identity, signs every
control-plane request, never exposes a private key to the browser.

- internal/admin: Repo interface + mock + bus implementations, REST server
- repo_bus: rooms via pkg/client, members via signed GET (CanonicalRequest +
  SignEd25519), cluster via /healthz (CA-pinned), users via membership.Store
- identity loaded from pass entry or 0600 file (operator-identity JSON)
- go build CGO_ENABLED=0 green; go vet clean

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Egutierrez
2026-06-07 19:27:49 +02:00
commit 8d893d216b
11 changed files with 1323 additions and 0 deletions
+246
View File
@@ -0,0 +1,246 @@
package admin
import (
"context"
"encoding/json"
"errors"
"io/fs"
"log"
"net/http"
"strings"
"time"
)
// Server is the HTTP surface of the admin panel: a small REST API under /api and
// the embedded SPA on every other path. It is intentionally unauthenticated at
// this layer — the deployment fronts it with Caddy basic-auth and the gateway
// itself binds to loopback, so the network boundary is the auth boundary. The
// gateway's privileged identity never leaves this process.
type Server struct {
repo Repo
spa http.Handler
mux *http.ServeMux
}
// NewServer wires the REST handlers and the embedded SPA file server. spaFiles
// is the SPA rooted at its dist directory (index.html + assets/ at the root).
func NewServer(repo Repo, spaFiles fs.FS) *Server {
s := &Server{
repo: repo,
spa: spaHandler(spaFiles),
mux: http.NewServeMux(),
}
s.routes()
return s
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
func (s *Server) routes() {
// The admin gateway's own liveness (for systemd / deploy smoke). Distinct from
// the bus nodes' /healthz surfaced under /api/cluster.
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
s.mux.HandleFunc("GET /api/me", s.handleMe)
s.mux.HandleFunc("GET /api/cluster", s.handleCluster)
s.mux.HandleFunc("GET /api/rooms", s.handleListRooms)
s.mux.HandleFunc("POST /api/rooms", s.handleCreateRoom)
s.mux.HandleFunc("GET /api/rooms/{id}/members", s.handleListMembers)
s.mux.HandleFunc("POST /api/rooms/{id}/invite", s.handleInvite)
s.mux.HandleFunc("POST /api/rooms/{id}/kick", s.handleKick)
s.mux.HandleFunc("GET /api/users", s.handleListUsers)
s.mux.HandleFunc("POST /api/users", s.handleAddUser)
s.mux.HandleFunc("POST /api/users/revoke", s.handleRevokeUser)
// Everything else is the SPA (and its assets). Registered last as the catch-all.
s.mux.Handle("/", s.spa)
}
// ---- handlers -------------------------------------------------------------
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.repo.Me(r.Context()))
}
func (s *Server) handleCluster(w http.ResponseWriter, r *http.Request) {
ctx, cancel := withTimeout(r)
defer cancel()
writeJSON(w, http.StatusOK, s.repo.Cluster(ctx))
}
func (s *Server) handleListRooms(w http.ResponseWriter, r *http.Request) {
rooms, err := s.repo.ListRooms(r.Context())
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, rooms)
}
func (s *Server) handleCreateRoom(w http.ResponseWriter, r *http.Request) {
var req CreateRoomReq
if !decode(w, r, &req) {
return
}
if strings.TrimSpace(req.Subject) == "" {
writeErr(w, http.StatusBadRequest, "subject required")
return
}
rv, err := s.repo.CreateRoom(r.Context(), req)
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusCreated, rv)
}
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
members, err := s.repo.ListMembers(r.Context(), r.PathValue("id"))
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, members)
}
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
var req InviteReq
if !decode(w, r, &req) {
return
}
if err := s.repo.Invite(r.Context(), r.PathValue("id"), req); err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "invited"})
}
func (s *Server) handleKick(w http.ResponseWriter, r *http.Request) {
var req struct {
Endpoint string `json:"endpoint"`
}
if !decode(w, r, &req) {
return
}
if strings.TrimSpace(req.Endpoint) == "" {
writeErr(w, http.StatusBadRequest, "endpoint required")
return
}
if err := s.repo.KickMember(r.Context(), r.PathValue("id"), req.Endpoint); err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "rekeyed"})
}
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.repo.ListUsers(r.Context())
if err != nil {
if errors.Is(err, ErrUsersUnavailable) {
writeErr(w, http.StatusServiceUnavailable, err.Error())
return
}
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, users)
}
func (s *Server) handleAddUser(w http.ResponseWriter, r *http.Request) {
var req AddUserReq
if !decode(w, r, &req) {
return
}
if strings.TrimSpace(req.SignPub) == "" || strings.TrimSpace(req.Handle) == "" {
writeErr(w, http.StatusBadRequest, "sign_pub and handle required")
return
}
if err := s.repo.AddUser(r.Context(), req); err != nil {
code := http.StatusBadGateway
if errors.Is(err, ErrUsersUnavailable) {
code = http.StatusServiceUnavailable
}
writeErr(w, code, err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "added"})
}
func (s *Server) handleRevokeUser(w http.ResponseWriter, r *http.Request) {
var req struct {
SignPub string `json:"sign_pub"`
}
if !decode(w, r, &req) {
return
}
if strings.TrimSpace(req.SignPub) == "" {
writeErr(w, http.StatusBadRequest, "sign_pub required")
return
}
if err := s.repo.RevokeUser(r.Context(), req.SignPub); err != nil {
code := http.StatusBadGateway
if errors.Is(err, ErrUsersUnavailable) {
code = http.StatusServiceUnavailable
}
writeErr(w, code, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
// ---- SPA serving ----------------------------------------------------------
// spaHandler serves the embedded SPA. A request for an existing asset is served
// directly; any other path (a client-side route) falls back to index.html so the
// SPA router can take over. /api and /healthz never reach here (matched first).
func spaHandler(files fs.FS) http.Handler {
fileServer := http.FileServer(http.FS(files))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
if p == "" {
p = "index.html"
}
if f, err := files.Open(p); err == nil {
_ = f.Close()
fileServer.ServeHTTP(w, r)
return
}
// Unknown path: serve index.html for SPA client-side routing.
r2 := r.Clone(r.Context())
r2.URL.Path = "/"
fileServer.ServeHTTP(w, r2)
})
}
// ---- helpers --------------------------------------------------------------
// withTimeout bounds a request-scoped operation (e.g. probing every cluster
// node) so a slow/dead node cannot hang the handler indefinitely.
func withTimeout(r *http.Request) (context.Context, context.CancelFunc) {
return context.WithTimeout(r.Context(), 6*time.Second)
}
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 writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
// decode reads a JSON body into v, writing a 400 and returning false on failure.
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
defer r.Body.Close()
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(v); err != nil {
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
log.Printf("[admin] decode body: %v", err)
return false
}
return true
}