mod.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. //! `sm121 web` — axum + SSE dashboard.
  2. //!
  3. //! Read-only LED board (state snapshots every 30 s + on job finish),
  4. //! actions — serve/stop/power run as tracked jobs calling the same
  5. //! handlers the CLI uses — and live log windows (SSH `docker logs -f`
  6. //! attaches with ring-buffer history). Data endpoints are token-gated
  7. //! when `--token` is set (required when binding off-loopback).
  8. //!
  9. //! Design: `specs/tech-architecture/web-dashboard.md`.
  10. mod api;
  11. pub mod events;
  12. pub mod jobs;
  13. pub mod logs;
  14. pub mod probe;
  15. pub mod ssh_stream;
  16. pub mod topo;
  17. use crate::config::Config;
  18. use crate::web::events::{Event, build_snapshot};
  19. use axum::{
  20. Router,
  21. extract::State,
  22. http::{HeaderMap, Request, StatusCode},
  23. middleware::{self, Next},
  24. response::{
  25. Html, Json, Response,
  26. sse::{Event as SseEvent, KeepAlive, Sse},
  27. },
  28. routing::{get, post},
  29. };
  30. use std::collections::HashMap;
  31. use std::net::SocketAddr;
  32. use std::sync::{Arc, Mutex};
  33. use tokio::sync::broadcast;
  34. use tokio::time::Duration;
  35. const UI_HTML: &str = include_str!("ui.html");
  36. const POLL_SECS: u64 = 30;
  37. const BUS_CAP: usize = 1024;
  38. /// Dashboard session lifetime (the `sm121_session` cookie issued by
  39. /// POST /api/login); sliding — refreshed on every authenticated use.
  40. const SESSION_TTL_SECS: i64 = 24 * 3600;
  41. #[derive(Clone)]
  42. pub struct AppState {
  43. pub config: Arc<Config>,
  44. pub bus: broadcast::Sender<Event>,
  45. pub token: Option<String>,
  46. /// Issued dashboard sessions (id → expiry unix secs); the cookie
  47. /// covers the clients that cannot send headers (SSE, the recipes
  48. /// iframe) so the raw token never rides in a URL.
  49. pub sessions: Arc<Mutex<HashMap<String, i64>>>,
  50. /// Latest snapshot (written by the supervisor; `/api/state` serves it).
  51. pub last: Arc<Mutex<Option<events::Snapshot>>>,
  52. /// Tracked action jobs (serve/stop/power); capped at `jobs::MAX_JOBS`.
  53. pub jobs: Arc<Mutex<Vec<jobs::Job>>>,
  54. /// Tracked log streams; capped at `logs::MAX_ATTACHES`.
  55. pub log_attaches: Arc<Mutex<Vec<logs::LogAttach>>>,
  56. }
  57. /// Probe the fleet and broadcast a fresh state snapshot (supervisor tick
  58. /// and job-finish both route through here).
  59. pub(crate) async fn push_state(app: &AppState) {
  60. let fleet = probe::probe(&app.config).await;
  61. let jobs = app.jobs.lock().unwrap().clone();
  62. let attaches: Vec<events::AttachSnap> = app
  63. .log_attaches
  64. .lock()
  65. .unwrap()
  66. .iter()
  67. .map(|a| events::AttachSnap {
  68. id: a.id.clone(),
  69. cluster: a.cluster.clone(),
  70. node: a.node.clone(),
  71. recipe: a.recipe.clone(),
  72. rank: a.rank,
  73. done: a.done.load(std::sync::atomic::Ordering::Relaxed),
  74. })
  75. .collect();
  76. let snap = build_snapshot(&app.config, &fleet, &jobs, &attaches, &fleet.ha);
  77. let _ = app.bus.send(Event::State {
  78. ts: snap.ts,
  79. snapshot: snap.clone(),
  80. });
  81. *lock_last(app) = Some(snap);
  82. }
  83. /// `GET /events` — forward every bus event as an SSE message. A fresh
  84. /// client gets the cached snapshot first, so it paints (and flips its
  85. /// live indicator) at once instead of waiting for the next poll tick.
  86. async fn events_stream(
  87. app: State<AppState>,
  88. ) -> Sse<impl futures::Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
  89. let head = app
  90. .last
  91. .lock()
  92. .unwrap()
  93. .clone()
  94. .map(|snapshot| Event::State {
  95. ts: snapshot.ts,
  96. snapshot,
  97. });
  98. let rx = app.bus.subscribe();
  99. Sse::new(futures::stream::unfold(
  100. (rx, head),
  101. |(mut rx, mut head)| async move {
  102. if let Some(ev) = head.take() {
  103. return Some((sse_event(&ev), (rx, None)));
  104. }
  105. loop {
  106. match rx.recv().await {
  107. Ok(ev) => return Some((sse_event(&ev), (rx, None))),
  108. Err(broadcast::error::RecvError::Lagged(n)) => {
  109. tracing::debug!("SSE client lagged {n} events");
  110. // Same receiver is still valid — keep reading.
  111. }
  112. Err(broadcast::error::RecvError::Closed) => return None,
  113. }
  114. }
  115. },
  116. ))
  117. // comment lines between events: NAT/VPN/proxy idle timers (and frozen
  118. // tabs on reconnect) see traffic, so a dead stream is noticed in seconds,
  119. // not minutes
  120. .keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
  121. }
  122. fn sse_event(ev: &Event) -> Result<SseEvent, std::convert::Infallible> {
  123. Ok(SseEvent::default().data(serde_json::to_string(ev).unwrap_or_default()))
  124. }
  125. /// Auth gate for data endpoints (`/` and `/api/auth/mode` stay public —
  126. /// static HTML / a mode probe carry no secrets). Accepts:
  127. /// - `Authorization: Bearer <token>` — fetch/scripts (the token never
  128. /// lands in a URL: no history, log or Referer leakage),
  129. /// - the `sm121_session` cookie from POST /api/login — covers the clients
  130. /// that cannot set headers (SSE `EventSource`, the recipes iframe).
  131. ///
  132. /// The legacy `?token=` query form is gone (that was the leak vector).
  133. async fn token_guard(
  134. State(app): State<AppState>,
  135. headers: HeaderMap,
  136. req: Request<axum::body::Body>,
  137. next: Next,
  138. ) -> Result<Response, (StatusCode, String)> {
  139. if app.token.is_some() {
  140. let bearer = headers
  141. .get("authorization")
  142. .and_then(|v| v.to_str().ok())
  143. .and_then(|v| v.strip_prefix("Bearer "));
  144. let cookie_ok = bearer.is_none()
  145. && headers
  146. .get("cookie")
  147. .and_then(|v| v.to_str().ok())
  148. .and_then(|c| {
  149. c.split(';').find_map(|p| {
  150. let p = p.trim();
  151. p.strip_prefix("sm121_session=")
  152. })
  153. })
  154. .is_some_and(|id| session_valid(&app, id));
  155. if bearer != Some(app.token.as_deref().unwrap_or("")) && !cookie_ok {
  156. return Err((StatusCode::UNAUTHORIZED, "unauthorized".into()));
  157. }
  158. }
  159. Ok(next.run(req).await)
  160. }
  161. /// Issue a fresh session id (32 random bytes, hex) with a sliding expiry.
  162. fn new_session(app: &AppState) -> String {
  163. let mut buf = [0u8; 32];
  164. getrandom::fill(&mut buf).expect("getrandom");
  165. let id: String = buf.iter().map(|b| format!("{b:02x}")).collect();
  166. let exp = crate::stamp::now_secs() + SESSION_TTL_SECS;
  167. {
  168. let mut s = app
  169. .sessions
  170. .lock()
  171. .unwrap_or_else(std::sync::PoisonError::into_inner);
  172. s.retain(|_, exp| *exp > crate::stamp::now_secs()); // drop the stale ones
  173. s.insert(id.clone(), exp);
  174. }
  175. id
  176. }
  177. /// Whether a session id is live; sliding — the expiry moves forward on use.
  178. fn session_valid(app: &AppState, id: &str) -> bool {
  179. let now = crate::stamp::now_secs();
  180. let mut s = app
  181. .sessions
  182. .lock()
  183. .unwrap_or_else(std::sync::PoisonError::into_inner);
  184. match s.get(id) {
  185. Some(exp) if *exp > now => {
  186. s.insert(id.to_string(), now + SESSION_TTL_SECS);
  187. true
  188. }
  189. _ => {
  190. s.remove(id);
  191. false
  192. }
  193. }
  194. }
  195. /// Supervisor: poll the fleet and broadcast a state snapshot every tick.
  196. async fn supervisor(app: AppState) {
  197. loop {
  198. // isolate each tick: a probe bug or a poisoned lock must not kill
  199. // the poll loop for good (a dead supervisor means a dead live feed)
  200. let tick = app.clone();
  201. if let Err(e) = tokio::spawn(async move { push_state(&tick).await }).await {
  202. tracing::warn!("sm121 web: supervisor tick panicked: {e}");
  203. }
  204. tokio::time::sleep(Duration::from_secs(POLL_SECS)).await;
  205. }
  206. }
  207. /// `AppState.last` guard that survives a poisoned mutex (a panic while
  208. /// holding the lock would otherwise take the whole live feed down).
  209. fn lock_last(app: &AppState) -> std::sync::MutexGuard<'_, Option<events::Snapshot>> {
  210. app.last
  211. .lock()
  212. .unwrap_or_else(std::sync::PoisonError::into_inner)
  213. }
  214. /// POST /api/login: exchange the Bearer token for a session cookie —
  215. /// the cookie is what SSE and the recipes iframe (which cannot set
  216. /// headers) ride on. 24 h sliding TTL; HttpOnly + SameSite=Strict.
  217. async fn login(State(app): State<AppState>) -> Response {
  218. let id = new_session(&app);
  219. let mut res = axum::response::Response::builder()
  220. .status(StatusCode::NO_CONTENT)
  221. .header(
  222. "set-cookie",
  223. format!(
  224. "sm121_session={id}; Path=/; HttpOnly; SameSite=Strict; Max-Age={SESSION_TTL_SECS}"
  225. ),
  226. )
  227. .body(axum::body::Body::empty())
  228. .unwrap();
  229. *res.status_mut() = StatusCode::NO_CONTENT;
  230. res
  231. }
  232. /// GET /api/auth/mode (public): `open` (no token — loopback or --insecure)
  233. /// or `token` — the UI decides whether to prompt.
  234. async fn auth_mode(State(app): State<AppState>) -> Json<serde_json::Value> {
  235. Json(serde_json::json!({ "mode": if app.token.is_some() { "token" } else { "open" } }))
  236. }
  237. fn is_loopback(addr: SocketAddr) -> bool {
  238. addr.ip().is_loopback()
  239. }
  240. /// Build the router (separate from `run` so tests can drive it without
  241. /// binding a port).
  242. pub fn router(app: AppState) -> Router {
  243. let protected = Router::new()
  244. .route("/api/state", get(api::state))
  245. .route("/api/serve", post(api::serve))
  246. .route("/api/stop", post(api::stop))
  247. .route("/api/power", post(api::power))
  248. .route("/api/recipes", get(api::recipes))
  249. .route("/api/login", post(login))
  250. .route(
  251. "/api/logs",
  252. get(api::logs_history)
  253. .post(api::logs_attach)
  254. .delete(api::logs_detach),
  255. )
  256. .route("/api/raw/config", get(api::raw_config))
  257. .route("/api/raw/recipe/{name}", get(api::raw_recipe))
  258. .route("/api/raw/effective", get(api::raw_effective))
  259. .route("/events", get(events_stream))
  260. .layer(middleware::from_fn_with_state(app.clone(), token_guard));
  261. // Recipes panel: the very page `sm121 report --serve` serves, nested at
  262. // `/recipes` — its own SSE stream and `/r/<recipe>/<file>` viewer move
  263. // under the prefix (see `report::html_page_at`), live refresh included.
  264. let rows = crate::report::collect(&app.config, false);
  265. let recipes = crate::report::serve::router(
  266. app.config.clone(),
  267. crate::report::html_page_at(&rows, "/recipes"),
  268. serde_json::to_string(&rows).unwrap_or_default(),
  269. false,
  270. "/recipes",
  271. )
  272. .layer(middleware::from_fn_with_state(app.clone(), token_guard));
  273. Router::new()
  274. .route("/", get(|| async { Html(UI_HTML) }))
  275. .route("/api/auth/mode", get(auth_mode))
  276. .merge(protected)
  277. .with_state(app)
  278. .nest("/recipes", recipes)
  279. }
  280. /// Run the dashboard until the process exits.
  281. pub async fn run(
  282. config: Config,
  283. port: u16,
  284. bind: String,
  285. token: Option<String>,
  286. open: bool,
  287. insecure: bool,
  288. ) -> anyhow::Result<()> {
  289. let addr: SocketAddr = format!("{bind}:{port}").parse()?;
  290. if !is_loopback(addr) && token.is_none() && !insecure {
  291. anyhow::bail!(
  292. "refusing to bind {bind} (non-loopback) without --token — the dashboard can act on the cluster (pass --insecure to accept the risk)"
  293. );
  294. }
  295. if insecure {
  296. tracing::warn!(
  297. "sm121 web: INSECURE MODE on {bind}:{port} — no authentication; anyone who can reach the port can serve/stop/power the cluster"
  298. );
  299. }
  300. let (tx, _) = broadcast::channel(BUS_CAP);
  301. let app = AppState {
  302. config: Arc::new(config),
  303. bus: tx,
  304. token: token.filter(|_| !insecure),
  305. sessions: Arc::new(Mutex::new(HashMap::new())),
  306. last: Arc::new(Mutex::new(None)),
  307. jobs: Arc::new(Mutex::new(Vec::new())),
  308. log_attaches: Arc::new(Mutex::new(Vec::new())),
  309. };
  310. jobs::load(&app); // history survives restarts; in-flight jobs -> failed
  311. tokio::spawn(supervisor(app.clone()));
  312. let listener = tokio::net::TcpListener::bind(addr).await?;
  313. if open {
  314. let url = format!("http://{addr}/");
  315. tokio::spawn(async move {
  316. tokio::time::sleep(Duration::from_millis(400)).await;
  317. let prog = if cfg!(target_os = "macos") {
  318. "open"
  319. } else {
  320. "xdg-open"
  321. };
  322. let _ = std::process::Command::new(prog).arg(&url).spawn();
  323. });
  324. }
  325. tracing::info!("sm121 web: http://{addr}/ (poll every {POLL_SECS}s)");
  326. axum::serve(listener, router(app)).await?;
  327. Ok(())
  328. }
  329. #[cfg(test)]
  330. mod api_tests;
  331. #[cfg(test)]
  332. pub(crate) mod tests;
  333. #[cfg(test)]
  334. mod ui_js_tests;