浏览代码

web: secure dashboard auth — Bearer header + session cookie, no token in URLs; --insecure mode

The token used to ride as ?token= on every fetch, the /events SSE and
the /recipes iframe — it leaked into browser history, server/proxy logs
and Referer headers. Now:

- the UI keeps the token in localStorage (as before) and sends it as
  Authorization: Bearer on every fetch;
- POST /api/login (Bearer) issues a HttpOnly SameSite=Strict session
  cookie (24 h sliding TTL, random 32-byte id, getrandom) that covers
  the headerless clients: the /events SSE stream and the /recipes
  iframe (the report page's own auth() becomes a pass-through);
- the ?token= query form is REMOVED (the leak vector);
- GET /api/auth/mode (public) tells the UI whether to prompt;
- sm121 web --insecure runs the dashboard unauthenticated — for
  isolated LANs where the operator explicitly accepts the risk (the
  non-loopback-no-token refusal is skipped and a loud warning logs).

Tests: token gate (Bearer/cookie/expired/tampered/mode probe), recipes
panel via cookie, SSE harness with a sync-thenable ensureSession stub.
Auth tests split into src/web/tests_auth.rs (the 500-line cap).
Alexander Korolev 1 周之前
父节点
当前提交
89ddf56e62
共有 12 个文件被更改,包括 338 次插入 和 90 次删除
  1. 2 1
      AGENTS.md
  2. 1 0
      Cargo.lock
  3. 1 0
      Cargo.toml
  4. 8 6
      DEVELOPMENT.md
  5. 8 1
      src/cli.rs
  6. 2 1
      src/main.rs
  7. 6 6
      src/report/page.html
  8. 105 20
      src/web/mod.rs
  9. 32 37
      src/web/tests.rs
  10. 135 0
      src/web/tests_auth.rs
  11. 37 18
      src/web/ui.html
  12. 1 0
      src/web/ui_js_tests.rs

+ 2 - 1
AGENTS.md

@@ -803,7 +803,8 @@ Local dev: `make addon-image` (rsyncs this repo into the add-on store repo's
 `file://` recipe repo + curl /api/state). The entrypoint writes the
 effective config from the add-on options (env), drops the SSH key
 (base64 option), `sm121 repos sync`, then `sm121 web --port 3100 --bind
-0.0.0.0` (a bearer token is required) with a background re-sync loop
+0.0.0.0` (a bearer token is required; `sm121_insecure: true` runs it
+unauthenticated) with a background re-sync loop
 (`SM121_REPO_RESYNC_MINUTES`). Its `custom_components/`
 custom-integration prototype is superseded (entities are a phase-2
 nicety over the add-on's REST API). The cluster definitions (private IPs

+ 1 - 0
Cargo.lock

@@ -2860,6 +2860,7 @@ dependencies = [
  "flate2",
  "fluent-i18n",
  "futures",
+ "getrandom 0.3.4",
  "md5 0.7.0",
  "notify",
  "reqwest",

+ 1 - 0
Cargo.toml

@@ -18,6 +18,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
 sha2 = "0.11"
 md5 = "0.7"
 base64 = "0.22"
+getrandom = "0.3"
 futures = "0.3"
 walkdir = "2"
 dirs = "6"

+ 8 - 6
DEVELOPMENT.md

@@ -135,12 +135,14 @@ Full design: `specs/tech-architecture/ha-addon-recipe-repos.md`.
   points at build/push or the `repo` fallback). Mutually exclusive with
   `--build-node`.
 - **The add-on image** (`docker-images/sm121-addon/`): multi-stage
-  (sm121 is compiled in-container — the binary always matches the
-  container arch), debian-slim + git + jq, entrypoint writes the
-  effective config from env (the HA add-on options), drops the SSH key
-  (base64 option), `sm121 repos sync`, then `sm121 web --port 3100
-  --bind 0.0.0.0` with a re-sync supervisor. `make addon-image` /
-  `addon-push`; smoke: `scripts/smoke-addon.sh`. The HA add-on manifest
+  (the binary is baked in by CI — a pinned rust:alpine build, the Pi
+  never compiles), alpine + git + jq, entrypoint writes the
+  effective config from /data/options.json (the HA add-on options —
+  env injection is legacy-app only), drops the SSH key (base64
+  option), `sm121 repos sync`, then `sm121 web --port 3100 --bind
+  0.0.0.0` (Bearer token; `sm121_insecure: true` disables auth) with a
+  re-sync supervisor. `make addon-image` / `addon-push`; smoke:
+  `scripts/smoke-addon.sh`. The HA add-on manifest
   lives in the `github.com/kilork/sm121-ha-integration` repo (its
   `custom_components/` prototype is superseded — entities are a phase-2
   nicety over the add-on's REST API).

+ 8 - 1
src/cli.rs

@@ -326,10 +326,17 @@ pub enum Commands {
         #[arg(long, default_value = "127.0.0.1")]
         bind: String,
 
-        /// Bearer token for data endpoints (required when --bind is not loopback)
+        /// Bearer token for data endpoints (required when --bind is not loopback,
+        /// unless --insecure)
         #[arg(long)]
         token: Option<String>,
 
+        /// Serve WITHOUT authentication (the token check is skipped entirely —
+        /// anyone who can reach the port can serve/stop/power the cluster). For
+        /// isolated LANs where the operator explicitly accepts that.
+        #[arg(long)]
+        insecure: bool,
+
         /// Open the browser once the server is up
         #[arg(long)]
         open: bool,

+ 2 - 1
src/main.rs

@@ -276,8 +276,9 @@ async fn main() -> Result<()> {
             bind,
             token,
             open,
+            insecure,
         } => {
-            crate::web::run(config, port, bind, token, open).await?;
+            crate::web::run(config, port, bind, token, open, insecure).await?;
         }
         Commands::Exec {
             cluster,

+ 6 - 6
src/report/page.html

@@ -107,12 +107,12 @@
 // `sm121 web --token` value in localStorage — the SSE stream needs it as a
 // query param (EventSource cannot set headers). `sm121 report --serve`
 // (standalone) never sets a token, so auth() is a no-op there.
-const TOKEN = (() => {
-  const q = new URLSearchParams(location.search).get("token");
-  if (q) localStorage.setItem("sm121token", q);
-  return localStorage.getItem("sm121token") || "";
-})();
-const auth = (p) => TOKEN ? p + (p.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(TOKEN) : p;
+// Auth: when embedded in the sm121 web dashboard's recipes panel, the
+// parent has already exchanged the dashboard token for a HttpOnly session
+// cookie — every same-origin request (SSE, the /r/ links) carries it. The
+// standalone `report --serve` is ungated. The legacy ?token= form is gone
+// (URLs must never carry the token).
+const auth = (p) => p;
 let DATA = {{DATA}};
 const COLS = [
   ["name", "Recipe"], ["state", "State"], ["uptime", "Uptime"], ["runs", "Launches"],

+ 105 - 20
src/web/mod.rs

@@ -24,11 +24,12 @@ use axum::{
     http::{HeaderMap, Request, StatusCode},
     middleware::{self, Next},
     response::{
-        Html, Response,
+        Html, Json, Response,
         sse::{Event as SseEvent, KeepAlive, Sse},
     },
     routing::{get, post},
 };
+use std::collections::HashMap;
 use std::net::SocketAddr;
 use std::sync::{Arc, Mutex};
 use tokio::sync::broadcast;
@@ -37,12 +38,19 @@ use tokio::time::Duration;
 const UI_HTML: &str = include_str!("ui.html");
 const POLL_SECS: u64 = 30;
 const BUS_CAP: usize = 1024;
+/// Dashboard session lifetime (the `sm121_session` cookie issued by
+/// POST /api/login); sliding — refreshed on every authenticated use.
+const SESSION_TTL_SECS: i64 = 24 * 3600;
 
 #[derive(Clone)]
 pub struct AppState {
     pub config: Arc<Config>,
     pub bus: broadcast::Sender<Event>,
     pub token: Option<String>,
+    /// Issued dashboard sessions (id → expiry unix secs); the cookie
+    /// covers the clients that cannot send headers (SSE, the recipes
+    /// iframe) so the raw token never rides in a URL.
+    pub sessions: Arc<Mutex<HashMap<String, i64>>>,
     /// Latest snapshot (written by the supervisor; `/api/state` serves it).
     pub last: Arc<Mutex<Option<events::Snapshot>>>,
     /// Tracked action jobs (serve/stop/power); capped at `jobs::MAX_JOBS`.
@@ -122,36 +130,79 @@ fn sse_event(ev: &Event) -> Result<SseEvent, std::convert::Infallible> {
     Ok(SseEvent::default().data(serde_json::to_string(ev).unwrap_or_default()))
 }
 
-/// Bearer-token gate for data endpoints (`/` stays public — it is static
-/// HTML with no secrets). Accepts `Authorization: Bearer <t>` or `?token=<t>`
-/// (SSE `EventSource` cannot set headers, so the UI uses the query form).
+/// Auth gate for data endpoints (`/` and `/api/auth/mode` stay public —
+/// static HTML / a mode probe carry no secrets). Accepts:
+/// - `Authorization: Bearer <token>` — fetch/scripts (the token never
+///   lands in a URL: no history, log or Referer leakage),
+/// - the `sm121_session` cookie from POST /api/login — covers the clients
+///   that cannot set headers (SSE `EventSource`, the recipes iframe).
+///
+/// The legacy `?token=` query form is gone (that was the leak vector).
 async fn token_guard(
     State(app): State<AppState>,
     headers: HeaderMap,
     req: Request<axum::body::Body>,
     next: Next,
 ) -> Result<Response, (StatusCode, String)> {
-    if let Some(expected) = &app.token {
-        // ponytail: raw query param, no percent-decoding — tokens are
-        // operator-generated; decode if exotic tokens are ever used.
-        let from_query = req.uri().query().and_then(|q| {
-            q.split('&').find_map(|kv| {
-                kv.split_once('=')
-                    .and_then(|(k, v)| (k == "token").then_some(v))
-            })
-        });
-        let got = headers
+    if app.token.is_some() {
+        let bearer = headers
             .get("authorization")
             .and_then(|v| v.to_str().ok())
-            .and_then(|v| v.strip_prefix("Bearer "))
-            .or(from_query);
-        if got != Some(expected.as_str()) {
+            .and_then(|v| v.strip_prefix("Bearer "));
+        let cookie_ok = bearer.is_none()
+            && headers
+                .get("cookie")
+                .and_then(|v| v.to_str().ok())
+                .and_then(|c| {
+                    c.split(';').find_map(|p| {
+                        let p = p.trim();
+                        p.strip_prefix("sm121_session=")
+                    })
+                })
+                .is_some_and(|id| session_valid(&app, id));
+        if bearer != Some(app.token.as_deref().unwrap_or("")) && !cookie_ok {
             return Err((StatusCode::UNAUTHORIZED, "unauthorized".into()));
         }
     }
     Ok(next.run(req).await)
 }
 
+/// Issue a fresh session id (32 random bytes, hex) with a sliding expiry.
+fn new_session(app: &AppState) -> String {
+    let mut buf = [0u8; 32];
+    getrandom::fill(&mut buf).expect("getrandom");
+    let id: String = buf.iter().map(|b| format!("{b:02x}")).collect();
+    let exp = crate::stamp::now_secs() + SESSION_TTL_SECS;
+    {
+        let mut s = app
+            .sessions
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        s.retain(|_, exp| *exp > crate::stamp::now_secs()); // drop the stale ones
+        s.insert(id.clone(), exp);
+    }
+    id
+}
+
+/// Whether a session id is live; sliding — the expiry moves forward on use.
+fn session_valid(app: &AppState, id: &str) -> bool {
+    let now = crate::stamp::now_secs();
+    let mut s = app
+        .sessions
+        .lock()
+        .unwrap_or_else(std::sync::PoisonError::into_inner);
+    match s.get(id) {
+        Some(exp) if *exp > now => {
+            s.insert(id.to_string(), now + SESSION_TTL_SECS);
+            true
+        }
+        _ => {
+            s.remove(id);
+            false
+        }
+    }
+}
+
 /// Supervisor: poll the fleet and broadcast a state snapshot every tick.
 async fn supervisor(app: AppState) {
     loop {
@@ -173,6 +224,31 @@ fn lock_last(app: &AppState) -> std::sync::MutexGuard<'_, Option<events::Snapsho
         .unwrap_or_else(std::sync::PoisonError::into_inner)
 }
 
+/// POST /api/login: exchange the Bearer token for a session cookie —
+/// the cookie is what SSE and the recipes iframe (which cannot set
+/// headers) ride on. 24 h sliding TTL; HttpOnly + SameSite=Strict.
+async fn login(State(app): State<AppState>) -> Response {
+    let id = new_session(&app);
+    let mut res = axum::response::Response::builder()
+        .status(StatusCode::NO_CONTENT)
+        .header(
+            "set-cookie",
+            format!(
+                "sm121_session={id}; Path=/; HttpOnly; SameSite=Strict; Max-Age={SESSION_TTL_SECS}"
+            ),
+        )
+        .body(axum::body::Body::empty())
+        .unwrap();
+    *res.status_mut() = StatusCode::NO_CONTENT;
+    res
+}
+
+/// GET /api/auth/mode (public): `open` (no token — loopback or --insecure)
+/// or `token` — the UI decides whether to prompt.
+async fn auth_mode(State(app): State<AppState>) -> Json<serde_json::Value> {
+    Json(serde_json::json!({ "mode": if app.token.is_some() { "token" } else { "open" } }))
+}
+
 fn is_loopback(addr: SocketAddr) -> bool {
     addr.ip().is_loopback()
 }
@@ -186,6 +262,7 @@ pub fn router(app: AppState) -> Router {
         .route("/api/stop", post(api::stop))
         .route("/api/power", post(api::power))
         .route("/api/recipes", get(api::recipes))
+        .route("/api/login", post(login))
         .route(
             "/api/logs",
             get(api::logs_history)
@@ -211,6 +288,7 @@ pub fn router(app: AppState) -> Router {
     .layer(middleware::from_fn_with_state(app.clone(), token_guard));
     Router::new()
         .route("/", get(|| async { Html(UI_HTML) }))
+        .route("/api/auth/mode", get(auth_mode))
         .merge(protected)
         .with_state(app)
         .nest("/recipes", recipes)
@@ -223,18 +301,25 @@ pub async fn run(
     bind: String,
     token: Option<String>,
     open: bool,
+    insecure: bool,
 ) -> anyhow::Result<()> {
     let addr: SocketAddr = format!("{bind}:{port}").parse()?;
-    if !is_loopback(addr) && token.is_none() {
+    if !is_loopback(addr) && token.is_none() && !insecure {
         anyhow::bail!(
-            "refusing to bind {bind} (non-loopback) without --token — the dashboard can act on the cluster"
+            "refusing to bind {bind} (non-loopback) without --token — the dashboard can act on the cluster (pass --insecure to accept the risk)"
+        );
+    }
+    if insecure {
+        tracing::warn!(
+            "sm121 web: INSECURE MODE on {bind}:{port} — no authentication; anyone who can reach the port can serve/stop/power the cluster"
         );
     }
     let (tx, _) = broadcast::channel(BUS_CAP);
     let app = AppState {
         config: Arc::new(config),
         bus: tx,
-        token,
+        token: token.filter(|_| !insecure),
+        sessions: Arc::new(Mutex::new(HashMap::new())),
         last: Arc::new(Mutex::new(None)),
         jobs: Arc::new(Mutex::new(Vec::new())),
         log_attaches: Arc::new(Mutex::new(Vec::new())),

+ 32 - 37
src/web/tests.rs

@@ -52,6 +52,9 @@ pub(crate) fn fake_config() -> Config {
     }
 }
 
+#[path = "tests_auth.rs"]
+mod auth;
+
 pub(crate) fn app_state(token: Option<&str>) -> AppState {
     app_state_with(fake_config(), token)
 }
@@ -62,6 +65,7 @@ fn app_state_with(config: Config, token: Option<&str>) -> AppState {
         config: std::sync::Arc::new(config),
         bus: tx,
         token: token.map(String::from),
+        sessions: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
         last: std::sync::Arc::new(std::sync::Mutex::new(None)),
         jobs: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
         log_attaches: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
@@ -133,35 +137,6 @@ async fn recipes_panel_is_mounted_under_prefix() {
     handle.abort();
 }
 
-#[tokio::test]
-async fn token_gate_enforces_when_set() {
-    let app = app_state(Some("s3cret"));
-    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
-    let addr = listener.local_addr().unwrap();
-    tokio::spawn(async move {
-        axum::serve(listener, router(app)).await.unwrap();
-    });
-    let base = format!("http://{addr}/api/state");
-    // No token → 401.
-    assert_eq!(reqwest::get(&base).await.unwrap().status(), 401);
-    // Wrong token → 401.
-    assert_eq!(
-        reqwest::get(format!("{base}?token=wrong"))
-            .await
-            .unwrap()
-            .status(),
-        401
-    );
-    // Right token (query form — what SSE clients use) → 200.
-    assert_eq!(
-        reqwest::get(format!("{base}?token=s3cret"))
-            .await
-            .unwrap()
-            .status(),
-        200
-    );
-}
-
 #[test]
 fn jobs_persist_and_mark_interrupted() {
     use crate::web::jobs;
@@ -247,21 +222,41 @@ async fn token_gate_covers_recipes_panel() {
             .status();
         assert_eq!(st, 401, "{path} must be gated");
     }
-    // ...and the token (query form, what the iframe and SSE use) opens them
-    let page = reqwest::get(format!("{base}/recipes?token=s3cret"))
+    // ...and the session cookie (issued by POST /api/login) opens them —
+    // the iframe and the report page's SSE ride the cookie, not the token.
+    let client = reqwest::Client::new();
+    let res = client
+        .post(format!("{base}/api/login"))
+        .header("authorization", "Bearer s3cret")
+        .send()
         .await
+        .unwrap();
+    assert_eq!(res.status(), 204);
+    let cookie = res
+        .headers()
+        .get("set-cookie")
         .unwrap()
-        .text()
+        .to_str()
+        .unwrap()
+        .split(';')
+        .next()
+        .unwrap()
+        .to_string();
+    let page = client
+        .get(format!("{base}/recipes"))
+        .header("cookie", &cookie)
+        .send()
         .await
         .unwrap();
-    assert!(
-        page.contains("auth(\"/recipes/events\")"),
-        "page wires the token"
-    );
-    let sse = reqwest::get(format!("{base}/recipes/events?token=s3cret"))
+    assert_eq!(page.status(), 200, "the recipes page opens with the cookie");
+    let sse = client
+        .get(format!("{base}/recipes/events"))
+        .header("cookie", &cookie)
+        .send()
         .await
         .unwrap();
     assert_eq!(sse.status(), 200);
+    drop(sse);
     handle.abort();
 }
 

+ 135 - 0
src/web/tests_auth.rs

@@ -0,0 +1,135 @@
+//! Auth-gate tests for the web dashboard (split from tests.rs for the
+//! 500-line cap): Bearer + session-cookie gating, the /api/login flow,
+//! session expiry, and the public /api/auth/mode probe.
+
+use super::*;
+
+#[tokio::test]
+async fn token_gate_enforces_when_set() {
+    let app = app_state(Some("s3cret"));
+    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let addr = listener.local_addr().unwrap();
+    tokio::spawn(async move {
+        axum::serve(listener, router(app)).await.unwrap();
+    });
+    let base = format!("http://{addr}");
+    // No token → 401.
+    assert_eq!(
+        reqwest::get(format!("{base}/api/state"))
+            .await
+            .unwrap()
+            .status(),
+        401
+    );
+    // The legacy ?token= query form is GONE (it was the leak vector).
+    assert_eq!(
+        reqwest::get(format!("{base}/api/state?token=s3cret"))
+            .await
+            .unwrap()
+            .status(),
+        401
+    );
+    // Right token (Bearer header) → 200.
+    let ok = reqwest::Client::new()
+        .get(format!("{base}/api/state"))
+        .header("authorization", "Bearer s3cret")
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(ok.status(), 200);
+    // POST /api/login (Bearer) → session cookie; the cookie then gates the
+    // headerless clients (SSE, the recipes iframe).
+    let client = reqwest::Client::new();
+    let res = client
+        .post(format!("{base}/api/login"))
+        .header("authorization", "Bearer s3cret")
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), 204);
+    let cookie = res
+        .headers()
+        .get("set-cookie")
+        .unwrap()
+        .to_str()
+        .unwrap()
+        .split(';')
+        .next()
+        .unwrap()
+        .to_string();
+    assert!(cookie.starts_with("sm121_session="));
+    let res = client
+        .get(format!("{base}/api/state"))
+        .header("cookie", &cookie)
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), 200);
+    // A tampered cookie → 401.
+    let res = client
+        .get(format!("{base}/api/state"))
+        .header("cookie", "sm121_session=deadbeef")
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), 401);
+    // The /events SSE endpoint accepts the cookie too (that is its purpose).
+    let res = client
+        .get(format!("{base}/events"))
+        .header("cookie", &cookie)
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), 200);
+    drop(res); // the SSE response body would stream; the request is done
+}
+
+#[tokio::test]
+async fn token_gate_session_expiry_and_mode_probe() {
+    let app = app_state(Some("s3cret"));
+    // An expired session is rejected (and purged).
+    app.sessions.lock().unwrap().insert("old".into(), 1); // unix epoch — long past
+    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let addr = listener.local_addr().unwrap();
+    tokio::spawn(async move {
+        axum::serve(listener, router(app)).await.unwrap();
+    });
+    let base = format!("http://{addr}");
+    let res = reqwest::Client::new()
+        .get(format!("{base}/api/state"))
+        .header("cookie", "sm121_session=old")
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), 401);
+    // The mode probe is public and reports the token requirement.
+    let m: serde_json::Value = reqwest::get(format!("{base}/api/auth/mode"))
+        .await
+        .unwrap()
+        .json()
+        .await
+        .unwrap();
+    assert_eq!(m["mode"], "token");
+    // No token configured (loopback / --insecure) → open mode, no gate.
+    let app = app_state(None);
+    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let addr = listener.local_addr().unwrap();
+    tokio::spawn(async move {
+        axum::serve(listener, router(app)).await.unwrap();
+    });
+    let base = format!("http://{addr}");
+    let m: serde_json::Value = reqwest::get(format!("{base}/api/auth/mode"))
+        .await
+        .unwrap()
+        .json()
+        .await
+        .unwrap();
+    assert_eq!(m["mode"], "open");
+    assert_eq!(
+        reqwest::get(format!("{base}/api/state"))
+            .await
+            .unwrap()
+            .status(),
+        200
+    );
+}

+ 37 - 18
src/web/ui.html

@@ -421,16 +421,20 @@
 <script>
 "use strict";
 // Auth: the server may gate data endpoints with `--token` (required off
-// loopback). The token travels as `?token=...` (SSE EventSource cannot set
-// headers) — a one-shot URL param is promoted to localStorage, and every
-// data URL is wrapped with auth(). The /recipes iframe is same-origin, so
-// the embedded report page reads the same localStorage.
-const TOKEN = (() => {
-  const q = new URLSearchParams(location.search).get("token");
-  if (q) localStorage.setItem("sm121token", q);
-  return localStorage.getItem("sm121token") || "";
-})();
-const auth = (p) => TOKEN ? p + (p.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(TOKEN) : p;
+// loopback unless the operator passes --insecure). The token itself NEVER
+// rides in a URL — every fetch sends `Authorization: Bearer` (the token is
+// kept in this browser only, localStorage), and POST /api/login exchanges
+// it for a HttpOnly session cookie that covers the headerless clients
+// (the /events SSE stream and the same-origin /recipes iframe).
+const TOKEN = localStorage.getItem("sm121token") || "";
+const AUTH = () => (TOKEN ? { "authorization": "Bearer " + TOKEN } : {});
+// Issue (or refresh) the session cookie. Idempotent; failures are fine —
+// a dead session surfaces as 401 → the token prompt.
+async function ensureSession() {
+  try {
+    await fetch("/api/login", { method: "POST", headers: AUTH() });
+  } catch {}
+}
 function showTokenPrompt() {
   const d = document.createElement("div");
   d.style.cssText = "position:fixed;inset:0;z-index:100;background:var(--shade);display:flex;align-items:center;justify-content:center";
@@ -839,7 +843,10 @@ function connectSSE() {
   const bounce = (txt) => {
     if (es) es.close();
     sse("re", txt);
-    es = new EventSource(auth("/events"));
+    // the session cookie may have expired while we were away — refresh it
+    // before reopening (the guard accepts Bearer OR the cookie)
+    ensureSession().then(() => {
+      es = new EventSource("/events");
     // connection is up and a snapshot is already on its way — don't sit on
     // "connecting…" for a whole poll interval
     es.onopen = () => { last = Date.now(); sse("ok", "live"); };
@@ -859,6 +866,7 @@ function connectSSE() {
       // without a manual page reload)
       if (es.readyState === 2) setTimeout(() => bounce("reconnecting…"), 1000);
     };
+    });
   };
   bounce("connecting…");
   setInterval(() => {
@@ -1816,7 +1824,7 @@ function detachLog(attachId, hide = true) {
   const w = logWins.get(attachId);
   if (!w) return;
   logWins.delete(attachId);
-  if (w.attach) fetch(auth("/api/logs"), { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ attach_id: w.attach }) }).catch(() => {});
+  if (w.attach) fetch("/api/logs", { method: "DELETE", headers: { ...AUTH(), "content-type": "application/json" }, body: JSON.stringify({ attach_id: w.attach }) }).catch(() => {});
   if (hide && w.win && w.win.isConnected) { w.win.style.display = "none"; renderDock(); }
 }
 
@@ -1843,7 +1851,7 @@ function openRecipes(g) {
   recipesWin = makeWin({
     id: "win-recipes", title: "Recipes", x: r("left", 24), y: r("top", 24),
     w: r("width", Math.min(1180, innerWidth - 48)), h: r("height", Math.min(760, innerHeight - 80)),
-    bodyHtml: `<iframe src="${auth("/recipes")}" title="recipes report"></iframe>`,
+    bodyHtml: `<iframe src="/recipes" title="recipes report"></iframe>`,
     extraCls: "recwin",
     removeOnClose: true,
     onClosed: () => { recipesWin = null; },
@@ -1901,7 +1909,7 @@ async function openRaw(g) {
     if (!kind) return;
     const path = kind === "config" ? "/api/raw/config" : kind === "effective" ? "/api/raw/effective" : "/api/raw/recipe/" + encodeURIComponent(kind.slice("recipe:".length));
     try {
-      const res = await fetch(auth(path));
+      const res = await fetch(path, { headers: AUTH() });
       const text = await res.text();
       if (mine !== seq) return; // a newer selection already answered
       if (!res.ok) { note.textContent = "error: " + text; return; }
@@ -1928,7 +1936,7 @@ function dryOf(cluster) {
   return d ? d.checked : false;
 }
 async function api(path, body) {
-  const res = await fetch(auth(path), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
+  const res = await fetch(path, { method: "POST", headers: { ...AUTH(), "content-type": "application/json" }, body: JSON.stringify(body) });
   if (!res.ok) {
     if (res.status === 401) { showTokenPrompt(); throw new Error("unauthorized"); }
     let m = res.status;
@@ -2135,9 +2143,20 @@ function initManagedWindows() {
   renderDock();
 }
 async function boot() {
+  // Auth mode first: `open` (loopback / --insecure) never prompts; token
+  // mode prompts when this browser has no stored token yet. Then exchange
+  // the token for the session cookie (covers SSE + the recipes iframe).
+  try {
+    const res = await fetch("/api/auth/mode");
+    if (res.ok) {
+      const m = (await res.json()).mode;
+      if (m === "token" && !TOKEN) { showTokenPrompt(); return; }
+    }
+  } catch {}
+  await ensureSession();
   initManagedWindows();
   try {
-    const res = await fetch(auth("/api/state"));
+    const res = await fetch("/api/state", { headers: AUTH() });
     if (res.status === 401) { showTokenPrompt(); }
     if (res.ok) {
       const snap = await res.json();
@@ -2152,7 +2171,7 @@ async function boot() {
     }
   } catch {}
   try {
-    const res = await fetch(auth("/api/recipes"));
+    const res = await fetch("/api/recipes", { headers: AUTH() });
     if (res.ok) {
       const d = await res.json();
       state.recipes = (d.recipes || []).sort((a, b) => a.name.localeCompare(b.name));
@@ -2168,7 +2187,7 @@ async function boot() {
   restoreWindow("raw", openRaw);
   setInterval(async () => {
     try {
-      const res = await fetch(auth("/api/state"));
+      const res = await fetch("/api/state", { headers: AUTH() });
       if (res.ok) onState(await res.json());
     } catch {}
   }, 30000);

+ 1 - 0
src/web/ui_js_tests.rs

@@ -95,6 +95,7 @@ global.document = { visibilityState: "visible", addEventListener: (t, f) => { ha
 global.addEventListener = (t, f) => { handlers["w:" + t] = f; };
 global.EventSource = FakeES;
 global.auth = (p) => p; // token wrapper (no-op in the harness)
+global.ensureSession = () => ({ then: (f) => f() }); // session refresh (sync-thenable: keeps the probe synchronous)
 global.setTimeout = (f, ms) => { timers.push({ f, ms }); return 0; };
 global.setInterval = (f) => { timers.push({ f, every: true }); return 0; };
 global.$ = () => ({ set className(v) {}, set textContent(v) { sseLog.push(v); } });