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>
302 lines
9.1 KiB
Go
302 lines
9.1 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// sessionCookie is the name of the gateway's session cookie. The browser sends
|
|
// it automatically on same-origin fetches AND on EventSource (SSE) connections —
|
|
// EventSource cannot set custom headers, so a cookie is the only way to
|
|
// authenticate the stream. It is HttpOnly so page JS can never read the token.
|
|
const sessionCookie = "unibus_session"
|
|
|
|
// server is the gateway's HTTP surface: a small REST/SSE API under /api gated by
|
|
// a session cookie, plus an optional static file server for the built SPA. The
|
|
// gateway's privileged operator identity never leaves the process; the browser
|
|
// authenticates with a passphrase and thereafter holds only an opaque session
|
|
// token.
|
|
type server struct {
|
|
gw *gateway
|
|
unlock string // passphrase that unlocks a session (compared in constant time)
|
|
webDir string // optional path to the built SPA (web/dist); empty = API only
|
|
mux *http.ServeMux
|
|
|
|
mu sync.Mutex
|
|
sessions map[string]time.Time // token -> issued-at
|
|
}
|
|
|
|
func newServer(gw *gateway, unlock, webDir string) *server {
|
|
s := &server{
|
|
gw: gw,
|
|
unlock: unlock,
|
|
webDir: webDir,
|
|
mux: http.NewServeMux(),
|
|
sessions: map[string]time.Time{},
|
|
}
|
|
s.routes()
|
|
return s
|
|
}
|
|
|
|
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
|
|
|
|
func (s *server) routes() {
|
|
// Liveness, unauthenticated (systemd / deploy smoke).
|
|
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
})
|
|
|
|
// Auth: login is the only /api route reachable without a session.
|
|
s.mux.HandleFunc("POST /api/login", s.handleLogin)
|
|
s.mux.HandleFunc("POST /api/logout", s.auth(s.handleLogout))
|
|
s.mux.HandleFunc("GET /api/me", s.auth(s.handleMe))
|
|
|
|
s.mux.HandleFunc("GET /api/rooms", s.auth(s.handleListRooms))
|
|
s.mux.HandleFunc("POST /api/rooms", s.auth(s.handleCreateRoom))
|
|
s.mux.HandleFunc("POST /api/rooms/{id}/join", s.auth(s.handleJoin))
|
|
s.mux.HandleFunc("POST /api/rooms/{id}/send", s.auth(s.handleSend))
|
|
s.mux.HandleFunc("GET /api/rooms/{id}/stream", s.auth(s.handleStream))
|
|
|
|
// Everything else is the SPA (when --web-dir is set). Registered last.
|
|
if s.webDir != "" {
|
|
s.mux.Handle("/", s.spaHandler())
|
|
}
|
|
}
|
|
|
|
// ---- auth -----------------------------------------------------------------
|
|
|
|
// auth wraps a handler so it runs only with a valid session cookie. A missing or
|
|
// unknown token yields 401, which the SPA treats as "show the login screen".
|
|
func (s *server) auth(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
c, err := r.Cookie(sessionCookie)
|
|
if err != nil || !s.validSession(c.Value) {
|
|
writeErr(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
func (s *server) validSession(token string) bool {
|
|
if token == "" {
|
|
return false
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
_, ok := s.sessions[token]
|
|
return ok
|
|
}
|
|
|
|
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Passphrase string `json:"passphrase"`
|
|
}
|
|
if !decode(w, r, &req) {
|
|
return
|
|
}
|
|
// Constant-time compare so a wrong passphrase cannot be timed character by
|
|
// character. An empty configured passphrase never matches (main refuses to
|
|
// start without one, so this is defense in depth).
|
|
if s.unlock == "" || subtle.ConstantTimeCompare([]byte(req.Passphrase), []byte(s.unlock)) != 1 {
|
|
writeErr(w, http.StatusUnauthorized, "wrong passphrase")
|
|
return
|
|
}
|
|
tok := newToken()
|
|
s.mu.Lock()
|
|
s.sessions[tok] = time.Now()
|
|
s.mu.Unlock()
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookie,
|
|
Value: tok,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
writeJSON(w, http.StatusOK, s.gw.me())
|
|
}
|
|
|
|
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
if c, err := r.Cookie(sessionCookie); err == nil {
|
|
s.mu.Lock()
|
|
delete(s.sessions, c.Value)
|
|
s.mu.Unlock()
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
|
|
}
|
|
|
|
func (s *server) handleMe(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, s.gw.me())
|
|
}
|
|
|
|
// ---- rooms ----------------------------------------------------------------
|
|
|
|
func (s *server) handleListRooms(w http.ResponseWriter, _ *http.Request) {
|
|
rooms, err := s.gw.listRooms()
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadGateway, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rooms)
|
|
}
|
|
|
|
func (s *server) handleCreateRoom(w http.ResponseWriter, r *http.Request) {
|
|
var req createRoomReq
|
|
if !decode(w, r, &req) {
|
|
return
|
|
}
|
|
rv, err := s.gw.createRoom(req)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadGateway, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, rv)
|
|
}
|
|
|
|
func (s *server) handleJoin(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.gw.join(r.PathValue("id")); err != nil {
|
|
writeErr(w, http.StatusBadGateway, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "joined"})
|
|
}
|
|
|
|
func (s *server) handleSend(w http.ResponseWriter, r *http.Request) {
|
|
var req sendReq
|
|
if !decode(w, r, &req) {
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Body) == "" {
|
|
writeErr(w, http.StatusBadRequest, "body required")
|
|
return
|
|
}
|
|
if err := s.gw.send(r.PathValue("id"), req.Body); err != nil {
|
|
writeErr(w, http.StatusBadGateway, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "sent"})
|
|
}
|
|
|
|
// handleStream is the SSE endpoint: it joins the room, attaches to the room's
|
|
// fan-out hub, and streams each decrypted message as a `data:` event. For a
|
|
// persisted room the hub's underlying subscription delivers history first
|
|
// (scrollback) and then live messages; for an ephemeral room only live messages
|
|
// flow. The stream ends when the browser disconnects (ctx cancelled).
|
|
func (s *server) handleStream(w http.ResponseWriter, r *http.Request) {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
writeErr(w, http.StatusInternalServerError, "streaming unsupported")
|
|
return
|
|
}
|
|
ch, cleanup, err := s.gw.openStream(r.PathValue("id"))
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadGateway, err.Error())
|
|
return
|
|
}
|
|
defer cleanup()
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering (nginx/caddy)
|
|
w.WriteHeader(http.StatusOK)
|
|
// An initial comment opens the stream immediately so the browser's
|
|
// EventSource fires `onopen` without waiting for the first message.
|
|
_, _ = w.Write([]byte(": connected\n\n"))
|
|
flusher.Flush()
|
|
|
|
ctx := r.Context()
|
|
ping := time.NewTicker(25 * time.Second)
|
|
defer ping.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ping.C:
|
|
// Comment line keeps idle proxies from closing the connection.
|
|
if _, err := w.Write([]byte(": ping\n\n")); err != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
case m := <-ch:
|
|
b, err := json.Marshal(m)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if _, err := w.Write([]byte("data: " + string(b) + "\n\n")); err != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- SPA serving (optional) -----------------------------------------------
|
|
|
|
// spaHandler serves the built SPA from s.webDir. A request for an existing asset
|
|
// is served directly; any other path (a client-side route) falls back to
|
|
// index.html so the SPA router can take over. /api and /healthz are matched first.
|
|
func (s *server) spaHandler() http.Handler {
|
|
root := http.Dir(s.webDir)
|
|
fileServer := http.FileServer(root)
|
|
index := filepath.Join(s.webDir, "index.html")
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
p := strings.TrimPrefix(r.URL.Path, "/")
|
|
if p == "" {
|
|
http.ServeFile(w, r, index)
|
|
return
|
|
}
|
|
if f, err := root.Open(p); err == nil {
|
|
_ = f.Close()
|
|
fileServer.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
http.ServeFile(w, r, index) // unknown path -> SPA client-side routing
|
|
})
|
|
}
|
|
|
|
// ---- helpers --------------------------------------------------------------
|
|
|
|
func newToken() string {
|
|
b := make([]byte, 32)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeErr(w http.ResponseWriter, code int, msg string) {
|
|
writeJSON(w, code, map[string]string{"error": msg})
|
|
}
|
|
|
|
// decode reads a JSON body into v, writing a 400 and returning false on failure.
|
|
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
|
defer r.Body.Close()
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(v); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// statFile reports whether path exists and is a regular file (used to validate
|
|
// --web-dir at startup so a typo surfaces as a clear log line, not 404s later).
|
|
func statFile(path string) bool {
|
|
fi, err := os.Stat(path)
|
|
return err == nil && !fi.IsDir()
|
|
}
|