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
+18
View File
@@ -0,0 +1,18 @@
# Per-PC writable runtime state (never distributed).
local_files/
*.db
*.db-shm
*.db-wal
*.id
# Build artifacts
/unibus_admin
*.exe
registry.db
# Web build inputs (the compiled bundle in web/dist IS committed so the Go
# binary always embeds a ready SPA; only the toolchain inputs are ignored).
web/node_modules/
web/.vite/
web/*.tsbuildinfo
*.local
+21
View File
@@ -0,0 +1,21 @@
package main
import (
"embed"
"io/fs"
)
// webDist holds the compiled Mantine SPA. The `all:` prefix makes go:embed
// include files whose names start with `_` or `.` too, so a hashed Vite asset is
// never silently dropped from the bundle. The build pipeline (web/ -> pnpm build)
// writes web/dist before `go build`, so the binary always ships a ready UI with
// nothing to serve from disk at runtime.
//
//go:embed all:web/dist
var webDist embed.FS
// spaFS returns the embedded SPA rooted at its dist directory, so the file
// server sees index.html and assets/ at the root rather than under web/dist/.
func spaFS() (fs.FS, error) {
return fs.Sub(webDist, "web/dist")
}
+41
View File
@@ -0,0 +1,41 @@
module github.com/enmanuel/unibus_admin
go 1.26.4
// The admin panel imports unibus (control-plane client, membership store, TLS
// helpers) and fn-registry (the cybersecurity primitives the bus signs with).
// Both are sibling working copies, never network dependencies, so the binary is
// always built against the exact bus code it ships next to.
replace fn-registry => ../../../../
replace github.com/enmanuel/unibus => ../unibus
require (
fn-registry v0.0.0-00010101000000-000000000000
github.com/enmanuel/unibus v0.0.0-00010101000000-000000000000
)
require (
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/nats-server/v2 v2.11.15 // indirect
github.com/nats-io/nats.go v1.49.0 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oklog/ulid/v2 v2.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/time v0.15.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.47.0 // indirect
)
+77
View File
@@ -0,0 +1,77 @@
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE=
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk=
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/nats-server/v2 v2.11.15 h1:StSf9TINInaZtr4oww2+kXmfwa9SkN//g/LwS19/UJ0=
github.com/nats-io/nats-server/v2 v2.11.15/go.mod h1:zwhv8Y0PE3KHyKgznJc/9Xoai638SaJd83zzJ5GJn74=
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+78
View File
@@ -0,0 +1,78 @@
package admin
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"os/exec"
cs "fn-registry/functions/cybersecurity"
)
// identityJSON mirrors the on-disk / pass-stored identity format shared across
// the unibus tooling: the four keypair halves, each std-base64. It is the SAME
// shape the bus client persists (pkg/client identityFile) and the operator's
// `pass` entry unibus/operator-identity, so the admin panel loads the operator's
// identity without a divergent serialization.
type identityJSON struct {
SignPub string `json:"sign_pub"`
SignPriv string `json:"sign_priv"`
KexPub string `json:"kex_pub"`
KexPriv string `json:"kex_priv"`
}
// decodeIdentity turns the JSON identity bytes into a cs.Identity. The private
// halves stay only in memory; this never writes them anywhere.
func decodeIdentity(raw []byte) (cs.Identity, error) {
var f identityJSON
if err := json.Unmarshal(raw, &f); err != nil {
return cs.Identity{}, fmt.Errorf("admin: parse identity json: %w", err)
}
dec := base64.StdEncoding.DecodeString
signPub, err := dec(f.SignPub)
if err != nil {
return cs.Identity{}, fmt.Errorf("admin: decode sign_pub: %w", err)
}
signPriv, err := dec(f.SignPriv)
if err != nil {
return cs.Identity{}, fmt.Errorf("admin: decode sign_priv: %w", err)
}
kexPub, err := dec(f.KexPub)
if err != nil {
return cs.Identity{}, fmt.Errorf("admin: decode kex_pub: %w", err)
}
kexPriv, err := dec(f.KexPriv)
if err != nil {
return cs.Identity{}, fmt.Errorf("admin: decode kex_priv: %w", err)
}
if len(signPub) != 32 || len(signPriv) != 64 || len(kexPub) != 32 || len(kexPriv) != 32 {
return cs.Identity{}, fmt.Errorf("admin: identity has wrong key sizes (sign_pub=%d sign_priv=%d kex_pub=%d kex_priv=%d)",
len(signPub), len(signPriv), len(kexPub), len(kexPriv))
}
return cs.Identity{SignPub: signPub, SignPriv: signPriv, KexPub: kexPub, KexPriv: kexPriv}, nil
}
// LoadIdentityFromFile reads a 0600 identity JSON file (the same format the bus
// client writes) and decodes it. Used in production on the deploy host, where
// `pass` is not available and the operator identity is delivered as a protected
// file under the service's local_files directory.
func LoadIdentityFromFile(path string) (cs.Identity, error) {
raw, err := os.ReadFile(path)
if err != nil {
return cs.Identity{}, fmt.Errorf("admin: read identity file %q: %w", path, err)
}
return decodeIdentity(raw)
}
// LoadIdentityFromPass shells out to `pass show <entry>` and decodes the JSON
// identity it returns. The secret is held only in memory; this process never
// writes it to disk or argv. Used in local operator workflows where the GNU
// password store holds unibus/operator-identity.
func LoadIdentityFromPass(entry string) (cs.Identity, error) {
out, err := exec.Command("pass", "show", entry).Output()
if err != nil {
return cs.Identity{}, fmt.Errorf("admin: pass show %q: %w", entry, err)
}
return decodeIdentity(out)
}
+130
View File
@@ -0,0 +1,130 @@
// Package admin is the gateway behind the unibus admin panel: it holds the
// operator's ADMIN identity, talks to the unibus control plane (signing every
// request), and exposes a small REST API the embedded SPA consumes. The browser
// never signs, never touches NATS, and never sees a private key — every
// privileged action is mediated here.
package admin
import (
"context"
"errors"
)
// ErrUsersUnavailable is returned by the users operations when the gateway was
// started without a membership store (no --db / no KV access). The bus control
// plane exposes no user-management HTTP endpoint — users live only in the store
// — so the Users tab is read-and-write only when the gateway can reach that
// store directly. Without it the tab degrades to an explanatory empty state
// rather than failing opaquely.
var ErrUsersUnavailable = errors.New("admin: user management requires direct store access (start with --db or a KV-backed store)")
// Posture is the security posture a membershipd node publishes on /healthz. It
// mirrors membership.Posture but is duplicated here so the wire shape the SPA
// consumes is owned by the gateway, not coupled to the bus package's struct tags.
type Posture struct {
Enforce bool `json:"enforce"`
ACL bool `json:"acl"`
TLS bool `json:"tls"`
Cluster bool `json:"cluster"`
Store string `json:"store"`
}
// NodeHealth is one cluster node's liveness + posture as seen from the gateway.
type NodeHealth struct {
Name string `json:"name"`
URL string `json:"url"`
Up bool `json:"up"`
Posture Posture `json:"posture"`
LatencyMs int64 `json:"latency_ms"`
Error string `json:"error,omitempty"`
}
// RoomView is a room as the admin sees it (a room the admin owns or belongs to).
type RoomView struct {
RoomID string `json:"room_id"`
Subject string `json:"subject"`
Epoch int `json:"epoch"`
Encrypt bool `json:"encrypt"`
Persist bool `json:"persist"`
SignMsgs bool `json:"sign_msgs"`
Role string `json:"role"`
}
// MemberView is one member of a room with public keys rendered as hex (the
// browser never needs the raw bytes).
type MemberView struct {
Endpoint string `json:"endpoint"`
Role string `json:"role"`
SignPub string `json:"sign_pub"`
KexPub string `json:"kex_pub"`
}
// UserView is one bus allowlist entry.
type UserView struct {
SignPub string `json:"sign_pub"`
Handle string `json:"handle"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
RevokedAt string `json:"revoked_at,omitempty"`
}
// CreateRoomReq is the room-creation payload from the SPA.
type CreateRoomReq struct {
Subject string `json:"subject"`
Encrypt bool `json:"encrypt"`
Persist bool `json:"persist"`
SignMsgs bool `json:"sign_msgs"`
}
// InviteReq is the invite payload. The invitee's public keys are supplied as hex
// because an encrypted room seals the room key to the invitee's X25519 key, and
// that key is not derivable from the endpoint id alone.
type InviteReq struct {
Endpoint string `json:"endpoint"`
SignPub string `json:"sign_pub"`
KexPub string `json:"kex_pub"`
}
// AddUserReq is the user-registration payload.
type AddUserReq struct {
SignPub string `json:"sign_pub"`
Handle string `json:"handle"`
Role string `json:"role"`
}
// MeInfo describes the gateway's own identity and which capabilities are wired,
// so the SPA can render the operator endpoint and gate the Users tab.
type MeInfo struct {
Endpoint string `json:"endpoint"`
SignPub string `json:"sign_pub"`
UsersBackend string `json:"users_backend"` // "sqlite" | "kv" | "none"
Mock bool `json:"mock"`
}
// Repo is the data source behind the REST API. Two implementations exist:
// busRepo (the real control-plane + store gateway) and mockRepo (sample data for
// UI iteration). Keeping it an interface lets the SPA be developed and demoed
// against mock data with the exact same handlers the live bus uses.
type Repo interface {
Me(ctx context.Context) MeInfo
// Cluster liveness + posture of every configured node.
Cluster(ctx context.Context) []NodeHealth
// Rooms the admin owns / belongs to, plus mutations the control plane allows.
ListRooms(ctx context.Context) ([]RoomView, error)
CreateRoom(ctx context.Context, req CreateRoomReq) (RoomView, error)
ListMembers(ctx context.Context, roomID string) ([]MemberView, error)
Invite(ctx context.Context, roomID string, req InviteReq) error
// KickMember removes a member and rotates the room key to a new epoch
// (forward secrecy). This is the rekey-on-kick primitive the bus exposes.
KickMember(ctx context.Context, roomID, endpoint string) error
// Users (the bus allowlist). Available only with direct store access;
// otherwise these return ErrUsersUnavailable.
UsersWritable() bool
ListUsers(ctx context.Context) ([]UserView, error)
AddUser(ctx context.Context, req AddUserReq) error
RevokeUser(ctx context.Context, signPub string) error
}
+366
View File
@@ -0,0 +1,366 @@
package admin
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/busauth"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/enmanuel/unibus/pkg/membership"
"github.com/enmanuel/unibus/pkg/room"
)
// NodeTarget is one cluster node the gateway probes for /healthz.
type NodeTarget struct {
Name string
URL string // e.g. https://magnus.internal:8470
}
// busRepo is the live gateway: it owns the operator's admin identity, a connected
// unibus client (for crypto-bearing room operations), a CA-pinned HTTP client for
// signed control-plane GETs and node health probes, and — when available — a
// direct membership store for the user allowlist.
type busRepo struct {
id cs.Identity
endpoint string
ctrlURLs []string // control-plane bases, tried in order (failover)
httpc *http.Client // CA-pinned (or plain) client for signed GETs + healthz
cli *client.Client
nodes []NodeTarget
store membership.Store // optional; nil => Users tab degraded
storeBackend string // "sqlite" | "kv" | "none"
}
// BusConfig wires a live gateway.
type BusConfig struct {
Identity cs.Identity
NatsURL string
CtrlURL string // primary control-plane base
CtrlURLs []string // additional control-plane bases (cluster failover)
NatsURLs []string // additional NATS seeds (cluster failover)
CAPath string // bus CA; empty => plaintext dev connection
Nodes []NodeTarget // nodes to probe for /healthz
Store membership.Store
StoreBackend string
}
// NewBusRepo connects the unibus client with the admin identity and builds the
// CA-pinned HTTP client used for signed GETs and node health probes. The client
// connection follows the same posture seam every peer uses (client.Connect): a
// non-empty CA path means TLS + nkey, empty means plaintext dev.
func NewBusRepo(cfg BusConfig) (*busRepo, error) {
opts := client.Options{
CtrlURLs: cfg.CtrlURLs,
NatsServers: cfg.NatsURLs,
}
if cfg.CAPath != "" {
tlsCfg, err := busauth.LoadCATLSConfig(cfg.CAPath)
if err != nil {
return nil, fmt.Errorf("admin: load bus CA %q: %w", cfg.CAPath, err)
}
opts.UseNkey = true
opts.TLS = tlsCfg
opts.CtrlTLS = tlsCfg
}
cli, err := client.NewWithOptions(cfg.NatsURL, cfg.CtrlURL, cfg.Identity, opts)
if err != nil {
return nil, fmt.Errorf("admin: connect bus client: %w", err)
}
httpc := &http.Client{Timeout: 8 * time.Second}
if cfg.CAPath != "" {
tlsCfg, err := busauth.LoadCATLSConfig(cfg.CAPath)
if err != nil {
return nil, fmt.Errorf("admin: load bus CA for http %q: %w", cfg.CAPath, err)
}
httpc.Transport = &http.Transport{TLSClientConfig: tlsCfg}
}
ctrlURLs := append([]string{cfg.CtrlURL}, cfg.CtrlURLs...)
backend := cfg.StoreBackend
if cfg.Store == nil {
backend = "none"
}
return &busRepo{
id: cfg.Identity,
endpoint: frame.EndpointID(cfg.Identity.SignPub),
ctrlURLs: ctrlURLs,
httpc: httpc,
cli: cli,
nodes: cfg.Nodes,
store: cfg.Store,
storeBackend: backend,
}, nil
}
// Close releases the bus client connection.
func (r *busRepo) Close() error {
if r.cli != nil {
return r.cli.Close()
}
return nil
}
func (r *busRepo) Me(context.Context) MeInfo {
return MeInfo{
Endpoint: r.endpoint,
SignPub: hex.EncodeToString(r.id.SignPub),
UsersBackend: r.storeBackend,
Mock: false,
}
}
// ---- cluster health -------------------------------------------------------
// healthzResp is the shape membershipd's GET /healthz returns.
type healthzResp struct {
Status string `json:"status"`
Posture struct {
Enforce bool `json:"enforce"`
ACL bool `json:"acl"`
TLS bool `json:"tls"`
Cluster bool `json:"cluster"`
Store string `json:"store"`
} `json:"posture"`
}
func (r *busRepo) Cluster(ctx context.Context) []NodeHealth {
out := make([]NodeHealth, 0, len(r.nodes))
for _, n := range r.nodes {
out = append(out, r.probeNode(ctx, n))
}
return out
}
// probeNode does an unauthenticated GET /healthz (the one auth-exempt route) and
// maps the response to NodeHealth. Any transport or decode failure is reported
// as down with the error, never panicking the whole cluster view.
func (r *busRepo) probeNode(ctx context.Context, n NodeTarget) NodeHealth {
nh := NodeHealth{Name: n.Name, URL: n.URL}
url := strings.TrimRight(n.URL, "/") + "/healthz"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
nh.Error = err.Error()
return nh
}
start := time.Now()
resp, err := r.httpc.Do(req)
nh.LatencyMs = time.Since(start).Milliseconds()
if err != nil {
nh.Error = err.Error()
return nh
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode != http.StatusOK {
nh.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
return nh
}
var hr healthzResp
if err := json.Unmarshal(body, &hr); err != nil {
nh.Error = "bad healthz json: " + err.Error()
return nh
}
nh.Up = hr.Status == "ok"
nh.Posture = Posture{
Enforce: hr.Posture.Enforce,
ACL: hr.Posture.ACL,
TLS: hr.Posture.TLS,
Cluster: hr.Posture.Cluster,
Store: hr.Posture.Store,
}
return nh
}
// ---- rooms ----------------------------------------------------------------
func (r *busRepo) ListRooms(context.Context) ([]RoomView, error) {
rooms, err := r.cli.ListMyRooms()
if err != nil {
return nil, err
}
out := make([]RoomView, 0, len(rooms))
for _, rm := range rooms {
out = append(out, RoomView{
RoomID: rm.RoomID,
Subject: rm.Subject,
Epoch: rm.Epoch,
Encrypt: rm.Policy.Encrypt,
Persist: rm.Policy.Persist,
SignMsgs: rm.Policy.SignMsgs,
Role: rm.Role,
})
}
return out, nil
}
func (r *busRepo) CreateRoom(_ context.Context, req CreateRoomReq) (RoomView, error) {
p := room.Policy{Encrypt: req.Encrypt, Persist: req.Persist, SignMsgs: req.SignMsgs}
roomID, err := r.cli.CreateRoom(req.Subject, p)
if err != nil {
return RoomView{}, err
}
// Under a per-subject ACL the admin's frozen NATS permissions do not yet cover
// the new room's subject; refresh the session so subsequent data-plane use of
// this room works. On a non-ACL bus this is a harmless reconnect.
_ = r.cli.RefreshSession()
return RoomView{
RoomID: roomID,
Subject: req.Subject,
Epoch: 1,
Encrypt: req.Encrypt,
Persist: req.Persist,
SignMsgs: req.SignMsgs,
Role: "owner",
}, nil
}
func (r *busRepo) Invite(_ context.Context, roomID string, req InviteReq) error {
signPub, err := hex.DecodeString(strings.TrimSpace(req.SignPub))
if err != nil || len(signPub) != 32 {
return fmt.Errorf("admin: invite: sign_pub must be 32-byte hex")
}
kexPub, err := hex.DecodeString(strings.TrimSpace(req.KexPub))
if err != nil || len(kexPub) != 32 {
return fmt.Errorf("admin: invite: kex_pub must be 32-byte hex")
}
endpoint := strings.TrimSpace(req.Endpoint)
if endpoint == "" {
endpoint = frame.EndpointID(signPub)
}
return r.cli.Invite(roomID, client.Endpoint{ID: endpoint, SignPub: signPub, KexPub: kexPub})
}
func (r *busRepo) KickMember(_ context.Context, roomID, endpoint string) error {
return r.cli.Kick(roomID, endpoint)
}
// ListMembers performs a signed GET /rooms/{id}/members. The unibus client does
// not export a member listing, so the gateway builds the request with the
// canonical signing construction the bus owns (membership.CanonicalRequest +
// cs.SignEd25519) — reusing the bus's single source of truth for the byte layout
// rather than reimplementing signing. The admin must be a member of the room
// (it is, for rooms it owns) or the control plane answers 403.
func (r *busRepo) ListMembers(_ context.Context, roomID string) ([]MemberView, error) {
path := "/rooms/" + roomID + "/members"
body, err := r.signedGET(path)
if err != nil {
return nil, err
}
var wire []struct {
Endpoint string `json:"endpoint"`
Role string `json:"role"`
SignPub []byte `json:"sign_pub"`
KexPub []byte `json:"kex_pub"`
}
if err := json.Unmarshal(body, &wire); err != nil {
return nil, fmt.Errorf("admin: decode members: %w", err)
}
out := make([]MemberView, 0, len(wire))
for _, m := range wire {
out = append(out, MemberView{
Endpoint: m.Endpoint,
Role: m.Role,
SignPub: hex.EncodeToString(m.SignPub),
KexPub: hex.EncodeToString(m.KexPub),
})
}
return out, nil
}
// signedGET issues a transport-authenticated GET against each control-plane base
// in turn (failover), signing the canonical request bytes with the admin's
// Ed25519 key under the same X-Unibus-* header scheme the bus client uses.
func (r *busRepo) signedGET(path string) ([]byte, error) {
var lastErr error
for _, base := range r.ctrlURLs {
req, err := http.NewRequest(http.MethodGet, strings.TrimRight(base, "/")+path, nil)
if err != nil {
return nil, err
}
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonceRaw := make([]byte, 16)
if _, err := rand.Read(nonceRaw); err != nil {
return nil, fmt.Errorf("admin: nonce: %w", err)
}
nonce := base64.StdEncoding.EncodeToString(nonceRaw)
canonical := membership.CanonicalRequest(http.MethodGet, path, ts, nonce, nil)
sig := cs.SignEd25519(r.id.SignPriv, canonical)
req.Header.Set("X-Unibus-Pub", hex.EncodeToString(r.id.SignPub))
req.Header.Set("X-Unibus-Ts", ts)
req.Header.Set("X-Unibus-Nonce", nonce)
req.Header.Set("X-Unibus-Sig", base64.StdEncoding.EncodeToString(sig))
resp, err := r.httpc.Do(req)
if err != nil {
lastErr = err
continue // dead node: try the next control plane
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 300 {
var er struct {
Error string `json:"error"`
}
if json.Unmarshal(body, &er) == nil && er.Error != "" {
return nil, fmt.Errorf("%s (HTTP %d)", er.Error, resp.StatusCode)
}
return nil, fmt.Errorf("admin: GET %s -> %d", path, resp.StatusCode)
}
return body, nil
}
return nil, fmt.Errorf("admin: GET %s: all control planes failed: %w", path, lastErr)
}
// ---- users ----------------------------------------------------------------
func (r *busRepo) UsersWritable() bool { return r.store != nil }
func (r *busRepo) ListUsers(context.Context) ([]UserView, error) {
if r.store == nil {
return nil, ErrUsersUnavailable
}
users, err := r.store.ListUsers()
if err != nil {
return nil, err
}
out := make([]UserView, 0, len(users))
for _, u := range users {
out = append(out, UserView{
SignPub: u.SignPub,
Handle: u.Handle,
Role: u.Role,
Status: u.Status,
CreatedAt: u.CreatedAt,
RevokedAt: u.RevokedAt,
})
}
return out, nil
}
func (r *busRepo) AddUser(_ context.Context, req AddUserReq) error {
if r.store == nil {
return ErrUsersUnavailable
}
return r.store.AddUser(req.SignPub, req.Handle, req.Role)
}
func (r *busRepo) RevokeUser(_ context.Context, signPub string) error {
if r.store == nil {
return ErrUsersUnavailable
}
return r.store.RevokeUser(signPub)
}
+157
View File
@@ -0,0 +1,157 @@
package admin
import (
"context"
"fmt"
"sync"
)
// mockRepo serves sample data so the SPA can be iterated and demoed without a
// live bus. It is selected with --mock. All mutations are kept in memory so the
// UI feels real during a session (create a room, see it appear) without touching
// any control plane.
type mockRepo struct {
mu sync.Mutex
rooms []RoomView
users []UserView
mem map[string][]MemberView
}
// NewMockRepo returns a Repo backed by in-memory sample data (--mock).
func NewMockRepo() Repo { return newMockRepo() }
func newMockRepo() *mockRepo {
return &mockRepo{
rooms: []RoomView{
{RoomID: "01HV...GENERAL", Subject: "team.general", Epoch: 1, Encrypt: true, Persist: true, SignMsgs: true, Role: "owner"},
{RoomID: "01HV...BOARD", Subject: "board.private", Epoch: 3, Encrypt: true, Persist: true, SignMsgs: true, Role: "owner"},
{RoomID: "01HV...BOTS", Subject: "bots.echo", Epoch: 1, Encrypt: false, Persist: false, SignMsgs: false, Role: "member"},
{RoomID: "01HV...INFRA", Subject: "infra.alerts", Epoch: 2, Encrypt: true, Persist: true, SignMsgs: true, Role: "owner"},
},
users: []UserView{
{SignPub: "48bc0dc829571a1332b4b2deb0bd78326a06bcf149e7d560728d8dc0b98173fa", Handle: "operator", Role: "admin", Status: "active", CreatedAt: "2026-06-01T10:00:00Z"},
{SignPub: "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00", Handle: "ana", Role: "member", Status: "active", CreatedAt: "2026-06-02T11:30:00Z"},
{SignPub: "ffeeddccbbaa99887766554433221100f0e1d2c3b4a5968778695a4b3c2d1e0f", Handle: "lucas", Role: "member", Status: "active", CreatedAt: "2026-06-03T09:15:00Z"},
{SignPub: "0011223344556677889900aabbccddeeff112233445566778899aabbccddeeff", Handle: "leo-revoked", Role: "member", Status: "revoked", CreatedAt: "2026-05-20T08:00:00Z", RevokedAt: "2026-06-04T14:00:00Z"},
},
mem: map[string][]MemberView{
"01HV...GENERAL": {
{Endpoint: "ep-operator", Role: "owner", SignPub: "48bc0dc8...", KexPub: "9f3a..."},
{Endpoint: "ep-ana", Role: "member", SignPub: "a1b2c3d4...", KexPub: "7c2b..."},
{Endpoint: "ep-lucas", Role: "member", SignPub: "ffeeddcc...", KexPub: "5e1d..."},
},
},
}
}
func (m *mockRepo) Me(context.Context) MeInfo {
return MeInfo{
Endpoint: "ep-operator",
SignPub: "48bc0dc829571a1332b4b2deb0bd78326a06bcf149e7d560728d8dc0b98173fa",
UsersBackend: "sqlite",
Mock: true,
}
}
func (m *mockRepo) Cluster(context.Context) []NodeHealth {
p := Posture{Enforce: true, ACL: true, TLS: true, Cluster: true, Store: "kv"}
return []NodeHealth{
{Name: "magnus", URL: "https://127.0.0.1:8470", Up: true, Posture: p, LatencyMs: 4},
{Name: "homer", URL: "https://10.0.0.2:8470", Up: true, Posture: p, LatencyMs: 11},
{Name: "datardos", URL: "https://10.0.0.3:8470", Up: false, Posture: Posture{}, LatencyMs: 0, Error: "dial tcp: i/o timeout"},
}
}
func (m *mockRepo) ListRooms(context.Context) ([]RoomView, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]RoomView, len(m.rooms))
copy(out, m.rooms)
return out, nil
}
func (m *mockRepo) CreateRoom(_ context.Context, req CreateRoomReq) (RoomView, error) {
m.mu.Lock()
defer m.mu.Unlock()
rv := RoomView{
RoomID: fmt.Sprintf("01HV...NEW%d", len(m.rooms)),
Subject: req.Subject,
Epoch: 1,
Encrypt: req.Encrypt,
Persist: req.Persist,
SignMsgs: req.SignMsgs,
Role: "owner",
}
m.rooms = append(m.rooms, rv)
return rv, nil
}
func (m *mockRepo) ListMembers(_ context.Context, roomID string) ([]MemberView, error) {
m.mu.Lock()
defer m.mu.Unlock()
if ms, ok := m.mem[roomID]; ok {
return ms, nil
}
return []MemberView{{Endpoint: "ep-operator", Role: "owner", SignPub: "48bc0dc8...", KexPub: "9f3a..."}}, nil
}
func (m *mockRepo) Invite(context.Context, string, InviteReq) error { return nil }
func (m *mockRepo) KickMember(_ context.Context, roomID, endpoint string) error {
m.mu.Lock()
defer m.mu.Unlock()
if ms, ok := m.mem[roomID]; ok {
kept := ms[:0]
for _, mv := range ms {
if mv.Endpoint != endpoint {
kept = append(kept, mv)
}
}
m.mem[roomID] = kept
}
for i := range m.rooms {
if m.rooms[i].RoomID == roomID {
m.rooms[i].Epoch++
}
}
return nil
}
func (m *mockRepo) UsersWritable() bool { return true }
func (m *mockRepo) ListUsers(context.Context) ([]UserView, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]UserView, len(m.users))
copy(out, m.users)
return out, nil
}
func (m *mockRepo) AddUser(_ context.Context, req AddUserReq) error {
m.mu.Lock()
defer m.mu.Unlock()
role := req.Role
if role == "" {
role = "member"
}
m.users = append(m.users, UserView{
SignPub: req.SignPub,
Handle: req.Handle,
Role: role,
Status: "active",
CreatedAt: "2026-06-07T12:00:00Z",
})
return nil
}
func (m *mockRepo) RevokeUser(_ context.Context, signPub string) error {
m.mu.Lock()
defer m.mu.Unlock()
for i := range m.users {
if m.users[i].SignPub == signPub {
m.users[i].Status = "revoked"
m.users[i].RevokedAt = "2026-06-07T12:30:00Z"
}
}
return nil
}
+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
}
+188
View File
@@ -0,0 +1,188 @@
// Command unibus_admin is the web administration panel for the unibus message
// bus. It is a single Go binary that (a) serves an embedded Mantine SPA and (b)
// exposes a small REST API. The binary holds the operator's ADMIN identity and
// mediates every privileged action against the unibus control plane (signing
// each request) and, when given direct store access, the bus user allowlist. The
// browser never signs, never speaks NATS, and never sees a private key.
package main
import (
"context"
"flag"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/membership"
"github.com/enmanuel/unibus_admin/internal/admin"
)
func main() {
var (
bind = flag.String("bind", "127.0.0.1", "interface to bind the admin HTTP server to (loopback by default; Caddy fronts it)")
port = flag.String("port", "8480", "admin HTTP port")
ctrlURL = flag.String("ctrl-url", "http://127.0.0.1:8470", "primary unibus control-plane base URL")
ctrlURLs = flag.String("ctrl-urls", "", "comma-separated ADDITIONAL control-plane base URLs (cluster failover)")
natsURL = flag.String("nats-url", "nats://127.0.0.1:4250", "primary NATS URL")
natsURLs = flag.String("nats-urls", "", "comma-separated ADDITIONAL NATS seed URLs (cluster failover)")
caPath = flag.String("ca", "", "bus CA cert path; set to talk TLS+nkey to a secured bus (empty = plaintext dev)")
nodesCSV = flag.String("nodes", "", "cluster nodes to probe for /healthz as name=url,name=url (default: derive one from --ctrl-url)")
identityFile = flag.String("identity-file", "", "path to the admin identity JSON file (0600). Mutually exclusive with --identity-pass")
identityPass = flag.String("identity-pass", "", "pass(1) entry holding the admin identity JSON, e.g. unibus/operator-identity")
dbPath = flag.String("db", "", "membership SQLite path for the Users tab (single-node/dev). Empty = Users tab read-only-unavailable unless --mock")
mock = flag.Bool("mock", false, "serve sample data instead of talking to the bus (UI iteration)")
)
flag.Parse()
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("[unibus_admin] ")
files, err := spaFS()
if err != nil {
log.Fatalf("embed SPA: %v", err)
}
var repo admin.Repo
if *mock {
repo = admin.NewMockRepo()
log.Printf("MODE: mock (sample data, no bus connection)")
} else {
id, err := loadIdentity(*identityFile, *identityPass)
if err != nil {
log.Fatalf("%v", err)
}
var store membership.Store
backend := "none"
if *dbPath != "" {
store, err = membership.Open(*dbPath)
if err != nil {
log.Fatalf("open membership store %q: %v", *dbPath, err)
}
defer store.Close()
backend = "sqlite"
log.Printf("users backend: sqlite %s", *dbPath)
} else {
log.Printf("users backend: none (Users tab degraded; pass --db for single-node user management)")
}
nodes := parseNodes(*nodesCSV, *ctrlURL)
busRepo, err := admin.NewBusRepo(admin.BusConfig{
Identity: id,
NatsURL: *natsURL,
CtrlURL: *ctrlURL,
CtrlURLs: splitCSV(*ctrlURLs),
NatsURLs: splitCSV(*natsURLs),
CAPath: *caPath,
Nodes: nodes,
Store: store,
StoreBackend: backend,
})
if err != nil {
log.Fatalf("%v", err)
}
defer busRepo.Close()
repo = busRepo
me := busRepo.Me(context.Background())
log.Printf("admin endpoint: %s", me.Endpoint)
log.Printf("control plane: %s (+%d failover)", *ctrlURL, len(splitCSV(*ctrlURLs)))
log.Printf("cluster nodes probed: %d", len(nodes))
tls := "OFF (plaintext dev)"
if *caPath != "" {
tls = "ON (CA " + *caPath + ")"
}
log.Printf("bus TLS+nkey: %s", tls)
}
srv := admin.NewServer(repo, files)
addr := *bind + ":" + *port
httpSrv := &http.Server{
Addr: addr,
Handler: srv,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
log.Printf("admin panel: http://%s", addr)
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
log.Printf("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = httpSrv.Shutdown(ctx)
log.Printf("bye")
}
// loadIdentity resolves the admin identity from exactly one of --identity-file or
// --identity-pass.
func loadIdentity(file, passEntry string) (cs.Identity, error) {
switch {
case file != "" && passEntry != "":
return cs.Identity{}, errFlag("set only one of --identity-file or --identity-pass")
case file != "":
return admin.LoadIdentityFromFile(file)
case passEntry != "":
return admin.LoadIdentityFromPass(passEntry)
default:
return cs.Identity{}, errFlag("an identity is required: pass --identity-file <path> or --identity-pass <entry> (or run with --mock)")
}
}
type flagErr string
func (e flagErr) Error() string { return string(e) }
func errFlag(s string) error { return flagErr("unibus_admin: " + s) }
// parseNodes builds the cluster probe list from a name=url CSV, falling back to a
// single node derived from the primary control-plane URL when none is given.
func parseNodes(csv, ctrlURL string) []admin.NodeTarget {
var out []admin.NodeTarget
for _, item := range splitCSV(csv) {
name, url, ok := strings.Cut(item, "=")
if !ok {
// Bare URL: name it by its host.
out = append(out, admin.NodeTarget{Name: hostOf(item), URL: item})
continue
}
out = append(out, admin.NodeTarget{Name: strings.TrimSpace(name), URL: strings.TrimSpace(url)})
}
if len(out) == 0 && ctrlURL != "" {
out = append(out, admin.NodeTarget{Name: hostOf(ctrlURL), URL: ctrlURL})
}
return out
}
func hostOf(url string) string {
s := url
s = strings.TrimPrefix(s, "https://")
s = strings.TrimPrefix(s, "http://")
if i := strings.IndexAny(s, ":/"); i >= 0 {
s = s[:i]
}
if s == "" {
return "node"
}
return s
}
func splitCSV(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><title>unibus_admin</title><p>build pending</p>