// Command webgw is the web gateway for the unibus chat SPA. It is a single Go // binary that holds the operator's bus identity, connects to the bus as a real // authenticated peer (pkg/client), and exposes a small REST + SSE API the // browser consumes. The browser never signs, never speaks NATS, and never sees a // private key: it authenticates to the gateway with a passphrase and thereafter // holds only an opaque session cookie. // // TRUST MODEL (MVP, single operator): room content stays end-to-end encrypted on // the bus. The gateway can read plaintext because it acts AS the operator's // client — a legitimate member of each room holding the room key. Decryption // happens server-side in this process; cleartext then crosses an authenticated // (loopback or TLS-fronted) SSE channel to the browser. The wallet phase (issue: // per-browser WebCrypto identity) can move decryption into the browser; see the // report for the FASE 2 plan. // // # local dev against a loopback membershipd (plaintext), operator from pass: // webgw --identity-pass unibus/operator-identity \ // --ctrl-url http://127.0.0.1:8470 --nats-url nats://127.0.0.1:4250 // // # secured cluster (TLS + nkey on both planes), identity from a 0600 file: // webgw --ca ca.crt --identity-file operator.id \ // --ctrl-url https://node-a:8470 --nats-url nats://node-a:4250 \ // --ctrl-urls https://node-b:8470,https://node-c:8470 \ // --nats-urls nats://node-b:4250,nats://node-c:4250 package main import ( "context" "flag" "log" "net/http" "os" "os/signal" "path/filepath" "strings" "syscall" "time" cs "fn-registry/functions/cybersecurity" ) func main() { var ( bind = flag.String("bind", "127.0.0.1", "interface to bind the gateway HTTP server to (loopback by default)") port = flag.String("port", "8481", "gateway 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)") identityFile = flag.String("identity-file", "", "path to the operator identity JSON file (0600). Mutually exclusive with --identity-pass") identityPass = flag.String("identity-pass", "", "pass(1) entry holding the operator identity JSON, e.g. unibus/operator-identity") unlockPass = flag.String("unlock-pass", "", "literal passphrase the browser must send to unlock a LEGACY operator session (dev). Prefer --unlock-pass-entry") unlockEntry = flag.String("unlock-pass-entry", "unibus/admin-panel-password", "pass(1) entry holding the operator unlock passphrase (used when --unlock-pass is empty)") registerURL = flag.String("register-url", "", "bus POST /register URL for wallet onboarding. Empty = derive from --ctrl-url (/register)") mockTokens = flag.String("mock-tokens", "", "DEV ONLY: comma-separated one-shot invite tokens for local testing, 'token=handle:role'. Empty in production (real invites come from the bus). Example: demo=demo:member") webDir = flag.String("web-dir", "", "OPTIONAL path to the built SPA (web/dist) to serve. Empty = API only (use vite dev server)") ) flag.Parse() log.SetFlags(log.LstdFlags | log.Lmsgprefix) log.SetPrefix("[webgw] ") id, err := loadIdentity(*identityFile, *identityPass) if err != nil { log.Fatalf("%v", err) } unlock := *unlockPass if unlock == "" { unlock, err = loadPassValue(*unlockEntry) if err != nil { log.Fatalf("resolve unlock passphrase: %v", err) } } if unlock == "" { log.Fatalf("an unlock passphrase is required: set --unlock-pass or a non-empty --unlock-pass-entry (default unibus/admin-panel-password)") } resolvedWebDir := resolveWebDir(*webDir) // busTemplate is the connection config every bus client uses. The operator // gateway uses it as-is; each wallet session clones it and overrides Identity // with the logged-in user's keypair. busTemplate := gatewayConfig{ Identity: id, NatsURL: *natsURL, CtrlURL: *ctrlURL, CtrlURLs: splitCSV(*ctrlURLs), NatsURLs: splitCSV(*natsURLs), CAPath: *caPath, } gw, err := newGateway(busTemplate) if err != nil { log.Fatalf("%v", err) } defer gw.Close() // Wallet onboarding backend: POST /api/register targets the bus's /register // (added by the user-accounts work). When --register-url is empty we derive it // from --ctrl-url; --mock-tokens supplies one-shot invites for local testing // before that endpoint is deployed. regURL := *registerURL if regURL == "" { regURL = strings.TrimRight(*ctrlURL, "/") + "/register" } registrar := newRegistrar(regURL, *mockTokens) log.Printf("operator endpoint: %s", gw.endpoint) log.Printf("control plane: %s (+%d failover)", *ctrlURL, len(splitCSV(*ctrlURLs))) tls := "OFF (plaintext dev)" if *caPath != "" { tls = "ON (CA " + *caPath + ")" } log.Printf("bus TLS+nkey: %s", tls) if resolvedWebDir != "" { log.Printf("serving SPA from: %s", resolvedWebDir) } else { log.Printf("API only (no --web-dir): use the vite dev server with a /api+stream proxy") } log.Printf("wallet register: %s (mock tokens: %d)", regURL, mockTokenCount(*mockTokens)) srv := newServer(gw, busTemplate, registrar, unlock, resolvedWebDir) addr := *bind + ":" + *port httpSrv := &http.Server{ Addr: addr, Handler: srv, // No global write timeout: SSE streams are long-lived. Header timeout still // bounds slowloris on the request line/headers. ReadHeaderTimeout: 10 * time.Second, } go func() { log.Printf("web gateway: 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 operator 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 loadIdentityFromFile(file) case passEntry != "": return loadIdentityFromPass(passEntry) default: return cs.Identity{}, errFlag("an identity is required: pass --identity-file or --identity-pass ") } } // resolveWebDir validates the --web-dir flag. An empty flag means API-only. A // non-empty dir is kept only if it actually holds an index.html, so a typo logs // "API only" rather than serving 404s. func resolveWebDir(dir string) string { if dir == "" { return "" } abs, err := filepath.Abs(dir) if err != nil { log.Printf("WARN --web-dir %q: %v; serving API only", dir, err) return "" } if !statFile(filepath.Join(abs, "index.html")) { log.Printf("WARN --web-dir %q has no index.html; serving API only", abs) return "" } return abs } type flagErr string func (e flagErr) Error() string { return string(e) } func errFlag(s string) error { return flagErr("webgw: " + 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 }