7d93d550d1
Add the gateway backend for the wallet onboarding flow so each browser session carries its OWN bus identity instead of sharing the single operator client. - POST /api/session (session.go): the browser hands its full wallet keypair (unlocked from the local encrypted key, over TLS) and the gateway spins up a dedicated bus client that acts AS that user. The private key lives only in process memory for the life of the session and is dropped on logout/shutdown. identityFromHex enforces the exact key sizes (sign_pub 32, sign_priv 64, kex_pub 32, kex_priv 32) that match cs.Identity on the Go side. - POST /api/register (register.go): unauthenticated onboarding gated by a one-shot invite token. Validates the two PUBLIC key halves, then either consumes a configured --mock-tokens invite (local testing) or proxies to the bus POST /register (--register-url, bus >= 0.12.0). The handle/role come from the invite, never from the client. - server.go: sessions move from a token->time map to a sessionStore of per-user *session records; auth() now resolves the session and passes its gateway to each handler. The legacy operator passphrase login (POST /api/login) is kept, bound to the shared operator gateway. - main.go: build a busTemplate config that wallet sessions clone with their own Identity; wire --register-url / --mock-tokens. - webgw_test.go: identity-size validation, hex-key validation, mock token parsing, and single-use register (201 then 409) using a fixed browser-derived wallet vector. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
200 lines
7.5 KiB
Go
200 lines
7.5 KiB
Go
// 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 (<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 <path> or --identity-pass <entry>")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|