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() }