diff --git a/.gitignore b/.gitignore index 2ce6b17..b83d222 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1 @@ --e target/ -__pycache__/ -node_modules/ -*.pyc -.env -dist/ +/target/ diff --git a/src/cli_main.rs b/src/cli_main.rs index aed6cd7..99f3a7b 100644 --- a/src/cli_main.rs +++ b/src/cli_main.rs @@ -2,10 +2,13 @@ mod config; mod db; mod models; +use argon2::{ + password_hash::{rand_core::OsRng, SaltString}, + Argon2, PasswordHasher, +}; use clap::{Parser, Subcommand}; -use argon2::{Argon2, PasswordHasher, password_hash::{SaltString, rand_core::OsRng}}; -use uuid::Uuid; use rand::Rng; +use uuid::Uuid; #[derive(Parser)] #[command(name = "gateway-cli", about = "Action Gateway admin CLI")] @@ -17,16 +20,11 @@ struct Cli { #[derive(Subcommand)] enum Commands { /// Add a user - AddUser { - username: String, - password: String, - }, + AddUser { username: String, password: String }, /// List all users ListUsers, /// Remove a user - RemoveUser { - username: String, - }, + RemoveUser { username: String }, /// Generate a new API key for the agent GenApiKey { #[arg(default_value = "agent")] @@ -51,25 +49,38 @@ fn main() { conn.execute( "INSERT INTO users (id, username, password_hash) VALUES (?1, ?2, ?3)", [&Uuid::new_v4().to_string(), &username, &hash], - ).unwrap(); + ) + .unwrap(); println!("✅ User '{}' created.", username); } Commands::ListUsers => { - let mut stmt = conn.prepare("SELECT username, created_at FROM users ORDER BY created_at").unwrap(); - let users: Vec<(String, String)> = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?))).unwrap() - .filter_map(|r| r.ok()).collect(); + let mut stmt = conn + .prepare("SELECT username, created_at FROM users ORDER BY created_at") + .unwrap(); + let users: Vec<(String, String)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); if users.is_empty() { println!("No users."); } else { println!("{:<20} {}", "Username", "Created"); println!("{}", "-".repeat(40)); - for (u, c) in users { println!("{:<20} {}", u, c); } + for (u, c) in users { + println!("{:<20} {}", u, c); + } } } Commands::RemoveUser { username } => { - let n = conn.execute("DELETE FROM users WHERE username = ?1", [&username]).unwrap(); - if n > 0 { println!("✅ User '{}' removed.", username); } - else { println!("❌ User '{}' not found.", username); } + let n = conn + .execute("DELETE FROM users WHERE username = ?1", [&username]) + .unwrap(); + if n > 0 { + println!("✅ User '{}' removed.", username); + } else { + println!("❌ User '{}' not found.", username); + } } Commands::GenApiKey { label } => { // Generate random key @@ -86,21 +97,29 @@ fn main() { conn.execute( "INSERT INTO api_keys (id, key_hash, label) VALUES (?1, ?2, ?3)", [&Uuid::new_v4().to_string(), &hash, &label], - ).unwrap(); + ) + .unwrap(); println!("✅ API key generated for '{}':", label); println!("\n {}\n", key); println!("Store this key securely — it won't be shown again."); } Commands::ListKeys => { - let mut stmt = conn.prepare("SELECT label, created_at FROM api_keys ORDER BY created_at").unwrap(); - let keys: Vec<(String, String)> = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?))).unwrap() - .filter_map(|r| r.ok()).collect(); + let mut stmt = conn + .prepare("SELECT label, created_at FROM api_keys ORDER BY created_at") + .unwrap(); + let keys: Vec<(String, String)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); if keys.is_empty() { println!("No API keys."); } else { println!("{:<20} {}", "Label", "Created"); println!("{}", "-".repeat(40)); - for (l, c) in keys { println!("{:<20} {}", l, c); } + for (l, c) in keys { + println!("{:<20} {}", l, c); + } } } } diff --git a/src/config.rs b/src/config.rs index 35a6782..72aef85 100644 --- a/src/config.rs +++ b/src/config.rs @@ -33,7 +33,9 @@ impl Config { "/home/node/.openclaw/workspace/config/action-gateway.toml".into(), ]; for path in &paths { - if path.is_empty() { continue; } + if path.is_empty() { + continue; + } if let Ok(contents) = fs::read_to_string(path) { return toml::from_str(&contents).unwrap_or_default(); } diff --git a/src/db.rs b/src/db.rs index 6c930a0..173ad0d 100644 --- a/src/db.rs +++ b/src/db.rs @@ -8,7 +8,8 @@ pub fn open(path: &str) -> Result { } fn migrate(conn: &Connection) -> Result<()> { - conn.execute_batch(" + conn.execute_batch( + " CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, @@ -40,5 +41,6 @@ fn migrate(conn: &Connection) -> Result<()> { submitted_at TEXT NOT NULL DEFAULT (datetime('now')), resolved_at TEXT ); - ") + ", + ) } diff --git a/src/main.rs b/src/main.rs index a0848ca..8612d04 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,10 +3,9 @@ mod db; mod models; mod routes; - -use std::sync::{Arc, Mutex}; -use rusqlite::Connection; use config::Config; +use rusqlite::Connection; +use std::sync::{Arc, Mutex}; #[derive(Clone)] pub struct AppState { diff --git a/src/routes.rs b/src/routes.rs index afd6945..6d5c4ce 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,19 +1,18 @@ -use axum::{ - Router, - routing::{get, post}, - extract::{State, Path, Query, Form}, - response::{Html, Redirect, IntoResponse}, - http::{StatusCode, HeaderMap}, - Json, -}; -use axum_extra::extract::cookie::{CookieJar, Cookie}; -use serde::Deserialize; -use uuid::Uuid; 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::{AppState, models::*}; +use crate::{models::*, AppState}; pub fn router(state: AppState) -> Router { Router::new() @@ -40,16 +39,20 @@ fn session_user(jar: &CookieJar, state: &AppState) -> Option { WHERE s.token = ?1 AND s.expires_at > datetime('now')", [&token], |row| row.get(0), - ).ok() + ) + .ok() } fn verify_api_key(headers: &HeaderMap, state: &AppState) -> bool { - let auth = headers.get("authorization") + 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; } + 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(); @@ -60,14 +63,22 @@ fn verify_api_key(headers: &HeaderMap, state: &AppState) -> bool { }; 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) + 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#" + Html(page( + "Login", + r#"

🔐 Action Gateway

@@ -78,7 +89,8 @@ async fn login_page() -> Html {
- "#)) + "#, + )) } async fn login_post( @@ -92,20 +104,27 @@ async fn login_post( "SELECT id, password_hash FROM users WHERE username = ?1", [&form.username], |row| Ok((row.get(0)?, row.get(1)?)), - ).ok() + ) + .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() { + 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 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(); + ) + .unwrap(); } let cookie = Cookie::build(("session", token)) .path("/") @@ -114,7 +133,9 @@ async fn login_post( return (jar.add(cookie), Redirect::to("/")).into_response(); } } - Html(page("Login", r#" + Html(page( + "Login", + r#"

🔐 Action Gateway

Invalid credentials.

@@ -126,7 +147,9 @@ async fn login_post(
- "#)).into_response() + "#, + )) + .into_response() } async fn logout(jar: CookieJar) -> impl IntoResponse { @@ -134,10 +157,7 @@ async fn logout(jar: CookieJar) -> impl IntoResponse { (jar, Redirect::to("/login")) } -async fn dashboard( - State(state): State, - jar: CookieJar, -) -> impl IntoResponse { +async fn dashboard(State(state): State, jar: CookieJar) -> impl IntoResponse { let Some(username) = session_user(&jar, &state) else { return Redirect::to("/login").into_response(); }; @@ -148,49 +168,62 @@ async fn dashboard( "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)?, - })) + 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#" + 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#" + "#, + 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#"
{} {} {} @@ -200,16 +233,23 @@ async fn dashboard(
$ {}
{}
- "#, 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(); + "#, + 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#" + let content = format!( + r#"

🤖 Action Gateway

Logged in as {}   Logout @@ -217,7 +257,14 @@ async fn dashboard(
{}
- "#, username, if rows.is_empty() { "

No actions yet.

".into() } else { rows }); + "#, + username, + if rows.is_empty() { + "

No actions yet.

".into() + } else { + rows + } + ); Html(page("Action Gateway", &content)).into_response() } @@ -250,7 +297,8 @@ async fn action_detail( }; if let Some(a) = action { if a.status == "pending" { - let content = format!(r#" + let content = format!( + r#"

Review Action

{}

@@ -262,7 +310,12 @@ async fn action_detail(
- "#, html_escape(&a.description), html_escape(&a.command), a.id, a.id); + "#, + html_escape(&a.description), + html_escape(&a.command), + a.id, + a.id + ); return Html(page("Approve Action", &content)).into_response(); } } @@ -303,8 +356,12 @@ async fn approve_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(), + 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(), }; @@ -312,8 +369,16 @@ async fn approve_action( 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) } + 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()), }; @@ -341,14 +406,19 @@ async fn reject_action( // 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() + 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(); + ) + .unwrap(); if let Some(cid) = chain_id { db.execute( @@ -368,7 +438,11 @@ async fn submit_action( Json(body): Json, ) -> impl IntoResponse { if !verify_api_key(&headers, &state) { - return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "unauthorized"}))).into_response(); + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"error": "unauthorized"})), + ) + .into_response(); } let id = Uuid::new_v4().to_string(); @@ -385,7 +459,10 @@ async fn submit_action( } // Send notification webhook - let approve_url = format!("{}/actions/{}?token={}", state.config.base_url, id, approve_token); + 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(); @@ -406,11 +483,15 @@ async fn submit_action( }); } - (StatusCode::CREATED, Json(serde_json::json!({ - "id": id, - "approve_url": approve_url, - "status": "pending" - }))).into_response() + ( + StatusCode::CREATED, + Json(serde_json::json!({ + "id": id, + "approve_url": approve_url, + "status": "pending" + })), + ) + .into_response() } async fn get_result( @@ -419,14 +500,20 @@ async fn get_result( Path(id): Path, ) -> impl IntoResponse { if !verify_api_key(&headers, &state) { - return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "unauthorized"}))).into_response(); + 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(); + 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)) => { @@ -434,17 +521,20 @@ async fn get_result( // 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(); + 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(), @@ -454,16 +544,24 @@ async fn get_result( StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/json")], body, - ).into_response() + ) + .into_response() } - None => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "not found"}))).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('"', """) + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) } /// Strip ANSI/VT100 escape sequences and other control characters from command output. @@ -481,13 +579,20 @@ fn sanitize_output(s: &str) -> String { Some('[') => { chars.next(); for nc in chars.by_ref() { - if nc.is_ascii_alphabetic() { break; } + if nc.is_ascii_alphabetic() { + break; + } } } // Character set designations: ESC ( X or ESC ) X - Some('(' | ')') => { chars.next(); chars.next(); } + Some('(' | ')') => { + chars.next(); + chars.next(); + } // Any other ESC sequence: skip one char - _ => { chars.next(); } + _ => { + chars.next(); + } } } // CR: collapse \r\n → \n, standalone \r → \n @@ -508,7 +613,8 @@ fn sanitize_output(s: &str) -> String { } fn page(title: &str, content: &str) -> String { - format!(r#" + format!( + r#" @@ -553,5 +659,7 @@ fn page(title: &str, content: &str) -> String { {} -"#, title, content) +"#, + title, content + ) }