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:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user