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)) .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) }) } // ── 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( "SELECT id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at FROM actions ORDER BY submitted_at DESC LIMIT 50" ).unwrap(); stmt.query_map([], |row| { 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)?, }) }) .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 { let action: Option = { let db = state.db.lock().unwrap(); db.query_row( "SELECT id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at FROM actions WHERE id = ?1 AND approve_token = ?2", [&id, &token], |row| 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)?, }), ).ok() }; if let Some(a) = action { 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(); } let action: Option = { let db = state.db.lock().unwrap(); db.query_row( "SELECT id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at FROM actions WHERE id = ?1", [&id], |row| 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)?, }), ).ok() }; if let Some(a) = action { if a.status == "pending" { // Build command with run_as support let output = match a.run_as.as_deref() { Some("root") => Command::new("sudo") .args(["-n", "sh", "-c", &a.command]) .output(), Some(user) => Command::new("sudo") .args(["-n", "-u", user, "sh", "-c", &a.command]) .output(), None => Command::new("sh").args(["-c", &a.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()), }; let db = state.db.lock().unwrap(); db.execute( "UPDATE actions SET status = ?1, result = ?2, resolved_at = datetime('now') WHERE id = ?3", [status, &result, &a.id], ).unwrap(); } } 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 this action and all pending actions in same chain let chain_id: Option = { let db = state.db.lock().unwrap(); db.query_row("SELECT chain_id FROM actions WHERE id = ?1", [&id], |row| { row.get(0) }) .ok() .flatten() }; let db = state.db.lock().unwrap(); db.execute( "UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE id = ?1", [&id], ) .unwrap(); if let Some(cid) = chain_id { db.execute( "UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE chain_id = ?1 AND status = 'pending'", [&cid], ).unwrap(); } 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(); tokio::spawn(async move { let client = reqwest::Client::new(); let mut req = client.post(&webhook) .json(&serde_json::json!({ "text": format!("[ActionGateway] New action needs approval.\nDescription: {}\nCommand: {}\nApprove: {}", description, command, url), "mode": "now" })); 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() } 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 ) }