fb8a03cf0c
Add cmd/webgw: 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.
Endpoints (all under /api, gated by a session cookie except login):
POST /api/login unlock a session with the operator passphrase
POST /api/logout
GET /api/me operator identity the gateway acts as
GET /api/rooms ListMyRooms
POST /api/rooms CreateRoom (default policy: encrypted+persisted+signed)
POST /api/rooms/{id}/join Join (fetch room key)
POST /api/rooms/{id}/send Publish (sealed + signed by the peer)
GET /api/rooms/{id}/stream SSE of decrypted frames (history then live)
Design notes:
- One fan-out hub per room: a single bus subscription is multiplexed to N SSE
clients, avoiding the per-(room,endpoint) durable-consumer contention that
multiple Subscribe calls would cause.
- Posture seam mirrors unibus_admin/clientcheck: empty --ca = plaintext dev,
non-empty = TLS+nkey on both planes; RefreshSession after a membership change
only under the secured (ACL) posture.
- Identity loaded from `pass` or a 0600 file, held only in memory.
- Session auth: passphrase compared in constant time; opaque HttpOnly cookie
so EventSource (which cannot set headers) can authenticate the stream.
TRUST MODEL: room content stays end-to-end encrypted on the bus. The gateway
reads plaintext only because it acts AS the operator's client — a legitimate
member of each room holding the room key. The per-browser wallet (WebCrypto)
that moves decryption into the browser is phase 2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
181 lines
6.4 KiB
Go
181 lines
6.4 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 session (dev). Prefer --unlock-pass-entry")
|
|
unlockEntry = flag.String("unlock-pass-entry", "unibus/admin-panel-password", "pass(1) entry holding the unlock passphrase (used when --unlock-pass is empty)")
|
|
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)
|
|
|
|
gw, err := newGateway(gatewayConfig{
|
|
Identity: id,
|
|
NatsURL: *natsURL,
|
|
CtrlURL: *ctrlURL,
|
|
CtrlURLs: splitCSV(*ctrlURLs),
|
|
NatsURLs: splitCSV(*natsURLs),
|
|
CAPath: *caPath,
|
|
})
|
|
if err != nil {
|
|
log.Fatalf("%v", err)
|
|
}
|
|
defer gw.Close()
|
|
|
|
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")
|
|
}
|
|
|
|
srv := newServer(gw, 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
|
|
}
|