// 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 }