|
@@ -24,11 +24,12 @@ use axum::{
|
|
|
http::{HeaderMap, Request, StatusCode},
|
|
http::{HeaderMap, Request, StatusCode},
|
|
|
middleware::{self, Next},
|
|
middleware::{self, Next},
|
|
|
response::{
|
|
response::{
|
|
|
- Html, Response,
|
|
|
|
|
|
|
+ Html, Json, Response,
|
|
|
sse::{Event as SseEvent, KeepAlive, Sse},
|
|
sse::{Event as SseEvent, KeepAlive, Sse},
|
|
|
},
|
|
},
|
|
|
routing::{get, post},
|
|
routing::{get, post},
|
|
|
};
|
|
};
|
|
|
|
|
+use std::collections::HashMap;
|
|
|
use std::net::SocketAddr;
|
|
use std::net::SocketAddr;
|
|
|
use std::sync::{Arc, Mutex};
|
|
use std::sync::{Arc, Mutex};
|
|
|
use tokio::sync::broadcast;
|
|
use tokio::sync::broadcast;
|
|
@@ -37,12 +38,19 @@ use tokio::time::Duration;
|
|
|
const UI_HTML: &str = include_str!("ui.html");
|
|
const UI_HTML: &str = include_str!("ui.html");
|
|
|
const POLL_SECS: u64 = 30;
|
|
const POLL_SECS: u64 = 30;
|
|
|
const BUS_CAP: usize = 1024;
|
|
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)]
|
|
#[derive(Clone)]
|
|
|
pub struct AppState {
|
|
pub struct AppState {
|
|
|
pub config: Arc<Config>,
|
|
pub config: Arc<Config>,
|
|
|
pub bus: broadcast::Sender<Event>,
|
|
pub bus: broadcast::Sender<Event>,
|
|
|
pub token: Option<String>,
|
|
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).
|
|
/// Latest snapshot (written by the supervisor; `/api/state` serves it).
|
|
|
pub last: Arc<Mutex<Option<events::Snapshot>>>,
|
|
pub last: Arc<Mutex<Option<events::Snapshot>>>,
|
|
|
/// Tracked action jobs (serve/stop/power); capped at `jobs::MAX_JOBS`.
|
|
/// 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()))
|
|
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(
|
|
async fn token_guard(
|
|
|
State(app): State<AppState>,
|
|
State(app): State<AppState>,
|
|
|
headers: HeaderMap,
|
|
headers: HeaderMap,
|
|
|
req: Request<axum::body::Body>,
|
|
req: Request<axum::body::Body>,
|
|
|
next: Next,
|
|
next: Next,
|
|
|
) -> Result<Response, (StatusCode, String)> {
|
|
) -> 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")
|
|
.get("authorization")
|
|
|
.and_then(|v| v.to_str().ok())
|
|
.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()));
|
|
return Err((StatusCode::UNAUTHORIZED, "unauthorized".into()));
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
Ok(next.run(req).await)
|
|
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.
|
|
/// Supervisor: poll the fleet and broadcast a state snapshot every tick.
|
|
|
async fn supervisor(app: AppState) {
|
|
async fn supervisor(app: AppState) {
|
|
|
loop {
|
|
loop {
|
|
@@ -173,6 +224,31 @@ fn lock_last(app: &AppState) -> std::sync::MutexGuard<'_, Option<events::Snapsho
|
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.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 {
|
|
fn is_loopback(addr: SocketAddr) -> bool {
|
|
|
addr.ip().is_loopback()
|
|
addr.ip().is_loopback()
|
|
|
}
|
|
}
|
|
@@ -186,6 +262,7 @@ pub fn router(app: AppState) -> Router {
|
|
|
.route("/api/stop", post(api::stop))
|
|
.route("/api/stop", post(api::stop))
|
|
|
.route("/api/power", post(api::power))
|
|
.route("/api/power", post(api::power))
|
|
|
.route("/api/recipes", get(api::recipes))
|
|
.route("/api/recipes", get(api::recipes))
|
|
|
|
|
+ .route("/api/login", post(login))
|
|
|
.route(
|
|
.route(
|
|
|
"/api/logs",
|
|
"/api/logs",
|
|
|
get(api::logs_history)
|
|
get(api::logs_history)
|
|
@@ -211,6 +288,7 @@ pub fn router(app: AppState) -> Router {
|
|
|
.layer(middleware::from_fn_with_state(app.clone(), token_guard));
|
|
.layer(middleware::from_fn_with_state(app.clone(), token_guard));
|
|
|
Router::new()
|
|
Router::new()
|
|
|
.route("/", get(|| async { Html(UI_HTML) }))
|
|
.route("/", get(|| async { Html(UI_HTML) }))
|
|
|
|
|
+ .route("/api/auth/mode", get(auth_mode))
|
|
|
.merge(protected)
|
|
.merge(protected)
|
|
|
.with_state(app)
|
|
.with_state(app)
|
|
|
.nest("/recipes", recipes)
|
|
.nest("/recipes", recipes)
|
|
@@ -223,18 +301,25 @@ pub async fn run(
|
|
|
bind: String,
|
|
bind: String,
|
|
|
token: Option<String>,
|
|
token: Option<String>,
|
|
|
open: bool,
|
|
open: bool,
|
|
|
|
|
+ insecure: bool,
|
|
|
) -> anyhow::Result<()> {
|
|
) -> anyhow::Result<()> {
|
|
|
let addr: SocketAddr = format!("{bind}:{port}").parse()?;
|
|
let addr: SocketAddr = format!("{bind}:{port}").parse()?;
|
|
|
- if !is_loopback(addr) && token.is_none() {
|
|
|
|
|
|
|
+ if !is_loopback(addr) && token.is_none() && !insecure {
|
|
|
anyhow::bail!(
|
|
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 (tx, _) = broadcast::channel(BUS_CAP);
|
|
|
let app = AppState {
|
|
let app = AppState {
|
|
|
config: Arc::new(config),
|
|
config: Arc::new(config),
|
|
|
bus: tx,
|
|
bus: tx,
|
|
|
- token,
|
|
|
|
|
|
|
+ token: token.filter(|_| !insecure),
|
|
|
|
|
+ sessions: Arc::new(Mutex::new(HashMap::new())),
|
|
|
last: Arc::new(Mutex::new(None)),
|
|
last: Arc::new(Mutex::new(None)),
|
|
|
jobs: Arc::new(Mutex::new(Vec::new())),
|
|
jobs: Arc::new(Mutex::new(Vec::new())),
|
|
|
log_attaches: Arc::new(Mutex::new(Vec::new())),
|
|
log_attaches: Arc::new(Mutex::new(Vec::new())),
|