use argon2::{Argon2, PasswordHash, PasswordVerifier}; use axum::{ extract::{Form, Path, Query, State}, http::{HeaderMap, StatusCode}, response::{Html, IntoResponse, Redirect}, routing::{get, post}, Json, Router, }; use axum_extra::extract::cookie::{Cookie, CookieJar}; use chrono::Utc; use serde::Deserialize; use std::process::Command; use uuid::Uuid; use crate::{models::*, AppState}; pub fn router(state: AppState) -> Router { Router::new() // Health check (no auth) .route("/health", get(health_check)) // Web UI .route("/", get(dashboard)) .route("/login", get(login_page).post(login_post)) .route("/logout", get(logout)) .route("/actions/:id/approve", post(approve_action)) .route("/actions/:id/reject", post(reject_action)) .route("/actions/:id", get(action_detail)) // Agent API .route("/api/actions", post(submit_action)) .route("/api/actions/:id/result", get(get_result)) // Approval API. Authenticated by the per-action approve_token, NOT by // the agent API key -- an agent must not be able to approve its own // actions, or the human-in-the-loop guarantee is worthless. .route("/api/actions/:id/approve", post(api_approve_action)) .route("/api/actions/:id/reject", post(api_reject_action)) .with_state(state) } // ── Health Check ───────────────────────────────────────────────── async fn health_check(State(state): State) -> impl IntoResponse { // Verify the database is accessible with a simple query let db_ok = { let db = state.db.lock().unwrap(); db.query_row("SELECT 1", [], |_| Ok(())).is_ok() }; if db_ok { ( StatusCode::OK, Json(serde_json::json!({"ok": true, "status": "live"})), ) .into_response() } else { ( StatusCode::SERVICE_UNAVAILABLE, Json(serde_json::json!({"ok": false, "status": "database unreachable"})), ) .into_response() } } // ── Helpers ────────────────────────────────────────────────────── fn session_user(jar: &CookieJar, state: &AppState) -> Option { let token = jar.get("session")?.value().to_string(); let db = state.db.lock().unwrap(); db.query_row( "SELECT u.username FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?1 AND s.expires_at > datetime('now')", [&token], |row| row.get(0), ) .ok() } fn verify_api_key(headers: &HeaderMap, state: &AppState) -> bool { let auth = headers .get("authorization") .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .unwrap_or("") .to_string(); if auth.is_empty() { return false; } let db = state.db.lock().unwrap(); let keys: Vec = { let mut stmt = db.prepare("SELECT key_hash FROM api_keys").unwrap(); stmt.query_map([], |row| row.get(0)) .unwrap() .filter_map(|r| r.ok()) .collect() }; keys.iter().any(|hash| { let parsed = PasswordHash::new(hash).ok(); parsed .map(|h| { Argon2::default() .verify_password(auth.as_bytes(), &h) .is_ok() }) .unwrap_or(false) }) } /// Column list for `actions`, in the order `row_to_action` expects. const ACTION_COLUMNS: &str = "id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at"; fn row_to_action(row: &rusqlite::Row) -> rusqlite::Result { Ok(Action { id: row.get(0)?, chain_id: row.get(1)?, sequence: row.get(2)?, description: row.get(3)?, command: row.get(4)?, run_as: row.get(5)?, status: row.get(6)?, result: row.get(7)?, approve_token: row.get(8)?, submitted_at: row.get(9)?, resolved_at: row.get(10)?, }) } fn fetch_action(state: &AppState, id: &str) -> Option { let db = state.db.lock().ok()?; db.query_row( &format!("SELECT {ACTION_COLUMNS} FROM actions WHERE id = ?1"), [id], row_to_action, ) .ok() } /// Look up an action by id *and* its approve_token. Both must match, so a /// bare action id is not enough to act on it. fn fetch_action_with_token(state: &AppState, id: &str, token: &str) -> Option { let db = state.db.lock().ok()?; db.query_row( &format!("SELECT {ACTION_COLUMNS} FROM actions WHERE id = ?1 AND approve_token = ?2"), [id, token], row_to_action, ) .ok() } /// Execute an approved action's command and record the outcome. /// Returns the resulting status ("executed" or "failed") and the captured output. fn execute_action(state: &AppState, action: &Action) -> (String, String) { let output = match action.run_as.as_deref() { Some("root") => Command::new("sudo") .args(["-n", "sh", "-c", &action.command]) .output(), Some(user) => Command::new("sudo") .args(["-n", "-u", user, "sh", "-c", &action.command]) .output(), None => Command::new("sh").args(["-c", &action.command]).output(), }; let (status, result) = match output { Ok(out) => { let stdout = sanitize_output(&String::from_utf8_lossy(&out.stdout)); let stderr = sanitize_output(&String::from_utf8_lossy(&out.stderr)); let combined = if stderr.is_empty() { stdout } else { format!("{}\nSTDERR:\n{}", stdout, stderr) }; if out.status.success() { ("executed", combined) } else { ("failed", combined) } } Err(e) => ("failed", e.to_string()), }; if let Ok(db) = state.db.lock() { let _ = db.execute( "UPDATE actions SET status = ?1, result = ?2, resolved_at = datetime('now') WHERE id = ?3", [status, &result, &action.id], ); } (status.to_string(), result) } /// Mark an action rejected, along with every still-pending action in its chain. fn reject_action_and_chain(state: &AppState, id: &str) { let Ok(db) = state.db.lock() else { return }; let chain_id: Option = db .query_row("SELECT chain_id FROM actions WHERE id = ?1", [id], |row| { row.get(0) }) .ok() .flatten(); let _ = db.execute( "UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE id = ?1", [id], ); if let Some(cid) = chain_id { let _ = db.execute( "UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE chain_id = ?1 AND status = 'pending'", [&cid], ); } } // ── Web UI ──────────────────────────────────────────────────────── async fn login_page() -> Html { Html(page( "Login", r#"

🔐 Action Gateway

"#, )) } async fn login_post( State(state): State, jar: CookieJar, Form(form): Form, ) -> impl IntoResponse { let result: Option<(String, String)> = { let db = state.db.lock().unwrap(); db.query_row( "SELECT id, password_hash FROM users WHERE username = ?1", [&form.username], |row| Ok((row.get(0)?, row.get(1)?)), ) .ok() }; if let Some((user_id, hash)) = result { let parsed = PasswordHash::new(&hash).unwrap(); if Argon2::default() .verify_password(form.password.as_bytes(), &parsed) .is_ok() { let token = Uuid::new_v4().to_string(); let expires = (Utc::now() + chrono::Duration::days(7)) .format("%Y-%m-%d %H:%M:%S") .to_string(); { let db = state.db.lock().unwrap(); db.execute( "INSERT INTO sessions (id, user_id, token, expires_at) VALUES (?1, ?2, ?3, ?4)", [&Uuid::new_v4().to_string(), &user_id, &token, &expires], ) .unwrap(); } let cookie = Cookie::build(("session", token)) .path("/") .http_only(true) .build(); return (jar.add(cookie), Redirect::to("/")).into_response(); } } Html(page( "Login", r#"

🔐 Action Gateway

Invalid credentials.

"#, )) .into_response() } async fn logout(jar: CookieJar) -> impl IntoResponse { let jar = jar.remove(Cookie::from("session")); (jar, Redirect::to("/login")) } async fn dashboard(State(state): State, jar: CookieJar) -> impl IntoResponse { let Some(username) = session_user(&jar, &state) else { return Redirect::to("/login").into_response(); }; let actions: Vec = { let db = state.db.lock().unwrap(); let mut stmt = db .prepare(&format!( "SELECT {ACTION_COLUMNS} FROM actions ORDER BY submitted_at DESC LIMIT 50" )) .unwrap(); stmt.query_map([], row_to_action) .unwrap() .filter_map(|r| r.ok()) .collect() }; let rows: String = actions .iter() .map(|a| { let status_badge = match a.status.as_str() { "pending" => r#"⏳ Pending"#, "approved" => r#"✅ Approved"#, "executed" => r#"✔ Executed"#, "rejected" => r#"❌ Rejected"#, "failed" => r#"💥 Failed"#, _ => r#"Unknown"#, }; let actions_html = if a.status == "pending" { format!( r#"
"#, a.id, a.id ) } else { a.result .clone() .map(|r| format!("
{}
", html_escape(&r))) .unwrap_or_default() }; let run_as_badge = a .run_as .as_ref() .map(|u| format!("run as: {}", u)) .unwrap_or_default(); format!( r#"
{} {} {} {}
{}
$ {}
{}
"#, status_badge, a.chain_id .as_ref() .map(|c| format!("chain: {}", &c[..8])) .unwrap_or_default(), run_as_badge, a.submitted_at, html_escape(&a.description), html_escape(&a.command), actions_html ) }) .collect(); let content = format!( r#"

🤖 Action Gateway

Logged in as {}   Logout
{}
"#, username, if rows.is_empty() { "

No actions yet.

".into() } else { rows } ); Html(page("Action Gateway", &content)).into_response() } #[derive(Deserialize)] struct ApproveQuery { token: Option, } async fn action_detail( State(state): State, jar: CookieJar, Path(id): Path, Query(q): Query, ) -> impl IntoResponse { // Allow approve via token link (no login required) if let Some(token) = q.token { if let Some(a) = fetch_action_with_token(&state, &id, &token) { if a.status == "pending" { let content = format!( r#"

Review Action

{}

$ {}
"#, html_escape(&a.description), html_escape(&a.command), a.id, a.id ); return Html(page("Approve Action", &content)).into_response(); } } return Html(page("Invalid", "

Invalid or expired token.

")).into_response(); } // Otherwise require login if session_user(&jar, &state).is_none() { return Redirect::to("/login").into_response(); } Redirect::to("/").into_response() } async fn approve_action( State(state): State, jar: CookieJar, Path(id): Path, ) -> impl IntoResponse { if session_user(&jar, &state).is_none() { return Redirect::to("/login").into_response(); } if let Some(a) = fetch_action(&state, &id) { if a.status == "pending" { execute_action(&state, &a); } } Redirect::to("/").into_response() } async fn reject_action( State(state): State, jar: CookieJar, Path(id): Path, ) -> impl IntoResponse { if session_user(&jar, &state).is_none() { return Redirect::to("/login").into_response(); } reject_action_and_chain(&state, &id); Redirect::to("/").into_response() } // ── Agent API ───────────────────────────────────────────────────── async fn submit_action( State(state): State, headers: HeaderMap, Json(body): Json, ) -> impl IntoResponse { if !verify_api_key(&headers, &state) { return ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "unauthorized"})), ) .into_response(); } let id = Uuid::new_v4().to_string(); let approve_token = Uuid::new_v4().to_string(); let chain_id = body.chain_id.unwrap_or_else(|| Uuid::new_v4().to_string()); let sequence = body.sequence.unwrap_or(0); { let db = state.db.lock().unwrap(); db.execute( "INSERT INTO actions (id, chain_id, sequence, description, command, run_as, approve_token) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", rusqlite::params![&id, &chain_id, sequence, &body.description, &body.command, &body.run_as, &approve_token], ).unwrap(); } // Send notification webhook let approve_url = format!( "{}/actions/{}?token={}", state.config.base_url, id, approve_token ); if let Some(webhook) = &state.config.notification_webhook { let webhook = webhook.clone(); let token = state.config.notification_webhook_token.clone(); let description = body.description.clone(); let command = body.command.clone(); let url = approve_url.clone(); let action_id = id.clone(); let run_as = body.run_as.clone(); let approve_token_out = approve_token.clone(); let api_base = state.config.base_url.clone(); tokio::spawn(async move { let client = reqwest::Client::new(); let mut req = client.post(&webhook) .json(&serde_json::json!({ // `text` is kept verbatim so existing consumers keep working. "text": format!("[ActionGateway] New action needs approval.\nDescription: {}\nCommand: {}\nApprove: {}", description, command, url), "mode": "now", // Structured form, so a notifier can render buttons and call // the approval API without scraping the text blob above. "action": { "id": action_id, "description": description, "command": command, "run_as": run_as, "approve_url": url, "approve_token": approve_token_out, "api_approve": format!("{}/api/actions/{}/approve", api_base, action_id), "api_reject": format!("{}/api/actions/{}/reject", api_base, action_id), } })); if let Some(t) = token { req = req.header("Authorization", format!("Bearer {}", t)); } let _ = req.send().await; }); } ( StatusCode::CREATED, Json(serde_json::json!({ "id": id, "approve_url": approve_url, "status": "pending" })), ) .into_response() } // ── Approval API ────────────────────────────────────────────────── // // These endpoints exist so a notifier (desktop popup, chat bot, phone) can // resolve an action without driving the web UI or holding a login session. // // Auth is the per-action `approve_token`: a UUIDv4 minted at submit time, // scoped to exactly one action, and delivered only in the approval // notification. The agent API key is deliberately NOT accepted here -- the // agent submits actions, so letting it approve them too would defeat the // entire point of the service. /// Resolve `token` from either the JSON body or the `?token=` query string, /// so callers can use whichever is convenient. Body wins if both are present. fn approve_token_from(body: &Option>, q: &ApproveQuery) -> Option { body.as_ref() .map(|b| b.approve_token.clone()) .or_else(|| q.token.clone()) } async fn api_approve_action( State(state): State, Path(id): Path, Query(q): Query, body: Option>, ) -> impl IntoResponse { let Some(token) = approve_token_from(&body, &q) else { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "missing approve_token"})), ) .into_response(); }; let Some(action) = fetch_action_with_token(&state, &id, &token) else { return ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "unknown action id or invalid approve_token"})), ) .into_response(); }; if action.status != "pending" { return ( StatusCode::CONFLICT, Json(serde_json::json!({ "error": "action is no longer pending", "id": action.id, "status": action.status, })), ) .into_response(); } let (status, result) = execute_action(&state, &action); ( StatusCode::OK, Json(serde_json::json!({"id": action.id, "status": status, "result": result})), ) .into_response() } async fn api_reject_action( State(state): State, Path(id): Path, Query(q): Query, body: Option>, ) -> impl IntoResponse { let Some(token) = approve_token_from(&body, &q) else { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "missing approve_token"})), ) .into_response(); }; let Some(action) = fetch_action_with_token(&state, &id, &token) else { return ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "unknown action id or invalid approve_token"})), ) .into_response(); }; if action.status != "pending" { return ( StatusCode::CONFLICT, Json(serde_json::json!({ "error": "action is no longer pending", "id": action.id, "status": action.status, })), ) .into_response(); } reject_action_and_chain(&state, &action.id); ( StatusCode::OK, Json(serde_json::json!({"id": action.id, "status": "rejected"})), ) .into_response() } async fn get_result( State(state): State, headers: HeaderMap, Path(id): Path, ) -> impl IntoResponse { if !verify_api_key(&headers, &state) { return ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "unauthorized"})), ) .into_response(); } let db = state.db.lock().unwrap(); let result: Option<(String, Option)> = db .query_row( "SELECT status, result FROM actions WHERE id = ?1", [&id], |row| Ok((row.get(0)?, row.get(1)?)), ) .ok(); match result { Some((status, result)) => { // Build JSON manually: serde_json's json! macro does not reliably escape // newlines/control chars in Option in this build environment. let result_json = match result { Some(s) => { let escaped: String = s .chars() .flat_map(|c| match c { '"' => vec!['\\', '"'], '\\' => vec!['\\', '\\'], '\n' => vec!['\\', 'n'], '\r' => vec!['\\', 'r'], '\t' => vec!['\\', 't'], c if (c as u32) < 0x20 => { format!("\\u{:04x}", c as u32).chars().collect() } c => vec![c], }) .collect(); format!("\"{}\"", escaped) } None => "null".to_string(), }; let body = format!(r#"{{"status":"{}","result":{}}}"#, status, result_json); ( StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/json")], body, ) .into_response() } None => ( StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "not found"})), ) .into_response(), } } // ── HTML helpers ────────────────────────────────────────────────── fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } /// Strip ANSI/VT100 escape sequences and other control characters from command output. /// Keeps printable text, newlines, and tabs. This ensures stored results are clean /// UTF-8 text that serializes safely to JSON and renders correctly in the UI. fn sanitize_output(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut chars = s.chars().peekable(); while let Some(c) = chars.next() { match c { // ESC: swallow escape sequences '\x1b' => { match chars.peek().copied() { // CSI sequence: ESC [ ... Some('[') => { chars.next(); for nc in chars.by_ref() { if nc.is_ascii_alphabetic() { break; } } } // Character set designations: ESC ( X or ESC ) X Some('(' | ')') => { chars.next(); chars.next(); } // Any other ESC sequence: skip one char _ => { chars.next(); } } } // CR: collapse \r\n → \n, standalone \r → \n '\r' => { if chars.peek() != Some(&'\n') { out.push('\n'); } } // Keep newlines and tabs as-is '\n' | '\t' => out.push(c), // Drop other control characters (0x00–0x1f, 0x7f) c if c.is_control() => {} // Keep everything else c => out.push(c), } } out } fn page(title: &str, content: &str) -> String { format!( r#" {} {} "#, title, content ) }