f65271dc92
Wire the bus's new account surface into the admin gateway:
- POST /api/invites, GET /api/invites: mint and list single-use registration
invites (CreateInvite/ListInvites on the Repo). The gateway pre-builds the
shareable join link (JoinURL) from a configurable end-user client base URL so
the SPA does not need to know where the client lives.
- DELETE /api/users/{pub}: hard-delete (purge) a user, distinct from the existing
revoke.
- Both backends covered: signed control-plane (cluster default) via the unibus
client's CreateInvite/ListInvites/DeleteUser, and the direct membership store
(single-node --db fallback). For the direct store, ListInvites filters to
pending (the control plane already does so server-side).
- New --join-base-url flag / UNIBUS_JOIN_BASE_URL env feeds the join link base
URL (the END-USER client, NOT the panel's own URL); surfaced on /api/me.
- Mock repo gains the same methods for UI iteration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
205 lines
7.0 KiB
Go
205 lines
7.0 KiB
Go
// 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", "", "OPTIONAL membership SQLite path for single-node user management. Empty (default) = manage users via the signed control-plane API, which works in cluster")
|
|
joinBaseURL = flag.String("join-base-url", "", "base URL of the END-USER client that hosts /join?token=… (e.g. https://chat.unibus.example). Used to build shareable invite links. Falls back to env UNIBUS_JOIN_BASE_URL")
|
|
mock = flag.Bool("mock", false, "serve sample data instead of talking to the bus (UI iteration)")
|
|
)
|
|
flag.Parse()
|
|
|
|
// The end-user client base URL (for invite join links) comes from the flag or,
|
|
// if unset, the env var. It is NOT the admin panel's own URL — the join link
|
|
// points at the user-facing client, a separate app. Empty leaves the SPA to
|
|
// fall back to its own origin and warn.
|
|
joinBase := *joinBaseURL
|
|
if joinBase == "" {
|
|
joinBase = os.Getenv("UNIBUS_JOIN_BASE_URL")
|
|
}
|
|
|
|
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 := "control-plane"
|
|
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 (single-node direct store)", *dbPath)
|
|
} else {
|
|
log.Printf("users backend: control-plane (signed admin HTTP to the bus; works in cluster)")
|
|
}
|
|
|
|
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,
|
|
JoinBaseURL: joinBase,
|
|
})
|
|
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)
|
|
if joinBase != "" {
|
|
log.Printf("invite join base: %s", joinBase)
|
|
} else {
|
|
log.Printf("invite join base: (unset; SPA falls back to its own origin — set --join-base-url or UNIBUS_JOIN_BASE_URL)")
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|