From 8d893d216b73cd573f25480ce907d8408214ebc6 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 19:27:49 +0200 Subject: [PATCH] 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) --- .gitignore | 18 ++ embed.go | 21 +++ go.mod | 41 ++++ go.sum | 77 ++++++++ internal/admin/identity.go | 78 ++++++++ internal/admin/repo.go | 130 +++++++++++++ internal/admin/repo_bus.go | 366 ++++++++++++++++++++++++++++++++++++ internal/admin/repo_mock.go | 157 ++++++++++++++++ internal/admin/server.go | 246 ++++++++++++++++++++++++ main.go | 188 ++++++++++++++++++ web/dist/index.html | 1 + 11 files changed, 1323 insertions(+) create mode 100644 .gitignore create mode 100644 embed.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/admin/identity.go create mode 100644 internal/admin/repo.go create mode 100644 internal/admin/repo_bus.go create mode 100644 internal/admin/repo_mock.go create mode 100644 internal/admin/server.go create mode 100644 main.go create mode 100644 web/dist/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..959b08b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/embed.go b/embed.go new file mode 100644 index 0000000..596e75e --- /dev/null +++ b/embed.go @@ -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") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bb8c0dd --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f761849 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/admin/identity.go b/internal/admin/identity.go new file mode 100644 index 0000000..baa0a29 --- /dev/null +++ b/internal/admin/identity.go @@ -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 ` 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) +} diff --git a/internal/admin/repo.go b/internal/admin/repo.go new file mode 100644 index 0000000..45157a8 --- /dev/null +++ b/internal/admin/repo.go @@ -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 +} diff --git a/internal/admin/repo_bus.go b/internal/admin/repo_bus.go new file mode 100644 index 0000000..3092be6 --- /dev/null +++ b/internal/admin/repo_bus.go @@ -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) +} diff --git a/internal/admin/repo_mock.go b/internal/admin/repo_mock.go new file mode 100644 index 0000000..9c4f775 --- /dev/null +++ b/internal/admin/repo_mock.go @@ -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 +} diff --git a/internal/admin/server.go b/internal/admin/server.go new file mode 100644 index 0000000..390f81b --- /dev/null +++ b/internal/admin/server.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..a757140 --- /dev/null +++ b/main.go @@ -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 or --identity-pass (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 +} diff --git a/web/dist/index.html b/web/dist/index.html new file mode 100644 index 0000000..714bf24 --- /dev/null +++ b/web/dist/index.html @@ -0,0 +1 @@ +unibus_admin

build pending

\ No newline at end of file