Public Access
feat: initial source commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
-e target/
|
||||||
|
__pycache__/
|
||||||
|
node_modules/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
dist/
|
||||||
Generated
+2693
File diff suppressed because it is too large
Load Diff
+31
@@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "action-gateway"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "action-gateway"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "gateway-cli"
|
||||||
|
path = "src/cli_main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
argon2 = "0.5"
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
|
tower-http = { version = "0.5", features = ["fs", "trace"] }
|
||||||
|
axum-extra = { version = "0.9", features = ["cookie"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
toml = "0.8"
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
rand = "0.8"
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Action Gateway
|
||||||
|
|
||||||
|
Human-in-the-loop approval service for agent actions. The agent submits actions; you approve or reject them via a web UI.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Config (optional)
|
||||||
|
Create `/home/node/.openclaw/workspace/config/action-gateway.toml`:
|
||||||
|
```toml
|
||||||
|
database_path = "/home/node/.openclaw/workspace/config/action-gateway.db"
|
||||||
|
bind = "0.0.0.0:7878"
|
||||||
|
base_url = "https://yourdomain.com:7878"
|
||||||
|
notification_webhook = "" # optional Matrix/webhook URL
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add a user
|
||||||
|
```bash
|
||||||
|
./gateway-cli add-user admin yourpassword
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Generate an API key for the agent
|
||||||
|
```bash
|
||||||
|
./gateway-cli gen-api-key agent
|
||||||
|
# Copy the key — store it in pass: pass insert gateway/api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run the server
|
||||||
|
```bash
|
||||||
|
./action-gateway
|
||||||
|
# 🚀 Action Gateway running at http://0.0.0.0:7878
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent usage (from inside the container)
|
||||||
|
```bash
|
||||||
|
# Submit an action
|
||||||
|
curl -s -X POST http://host:7878/api/actions \
|
||||||
|
-H "Authorization: Bearer <api-key>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"description":"List files in /tmp","command":"ls /tmp"}'
|
||||||
|
|
||||||
|
# Poll for result
|
||||||
|
curl -s http://host:7878/api/actions/<id>/result \
|
||||||
|
-H "Authorization: Bearer <api-key>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI commands
|
||||||
|
```bash
|
||||||
|
gateway-cli add-user <username> <password>
|
||||||
|
gateway-cli list-users
|
||||||
|
gateway-cli remove-user <username>
|
||||||
|
gateway-cli gen-api-key [label]
|
||||||
|
gateway-cli list-keys
|
||||||
|
```
|
||||||
|
|
||||||
|
## Binaries
|
||||||
|
After `cargo build --release`:
|
||||||
|
- `target/release/action-gateway` — the web server
|
||||||
|
- `target/release/gateway-cli` — admin CLI
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
mod config;
|
||||||
|
mod db;
|
||||||
|
mod models;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use argon2::{Argon2, PasswordHasher, password_hash::{SaltString, rand_core::OsRng}};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "gateway-cli", about = "Action Gateway admin CLI")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Add a user
|
||||||
|
AddUser {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
},
|
||||||
|
/// List all users
|
||||||
|
ListUsers,
|
||||||
|
/// Remove a user
|
||||||
|
RemoveUser {
|
||||||
|
username: String,
|
||||||
|
},
|
||||||
|
/// Generate a new API key for the agent
|
||||||
|
GenApiKey {
|
||||||
|
#[arg(default_value = "agent")]
|
||||||
|
label: String,
|
||||||
|
},
|
||||||
|
/// List API keys
|
||||||
|
ListKeys,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let config = config::Config::load();
|
||||||
|
let conn = db::open(&config.database_path).expect("Failed to open database");
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::AddUser { username, password } => {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users (id, username, password_hash) VALUES (?1, ?2, ?3)",
|
||||||
|
[&Uuid::new_v4().to_string(), &username, &hash],
|
||||||
|
).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();
|
||||||
|
if users.is_empty() {
|
||||||
|
println!("No users.");
|
||||||
|
} else {
|
||||||
|
println!("{:<20} {}", "Username", "Created");
|
||||||
|
println!("{}", "-".repeat(40));
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
Commands::GenApiKey { label } => {
|
||||||
|
// Generate random key
|
||||||
|
let key: String = rand::thread_rng()
|
||||||
|
.sample_iter(&rand::distributions::Alphanumeric)
|
||||||
|
.take(48)
|
||||||
|
.map(char::from)
|
||||||
|
.collect();
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(key.as_bytes(), &salt)
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO api_keys (id, key_hash, label) VALUES (?1, ?2, ?3)",
|
||||||
|
[&Uuid::new_v4().to_string(), &hash, &label],
|
||||||
|
).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();
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub database_path: String,
|
||||||
|
pub bind: String,
|
||||||
|
pub notification_webhook: Option<String>,
|
||||||
|
pub notification_webhook_token: Option<String>,
|
||||||
|
pub base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
database_path: "/home/node/.openclaw/workspace/config/action-gateway.db".into(),
|
||||||
|
bind: "0.0.0.0:7878".into(),
|
||||||
|
notification_webhook: None,
|
||||||
|
notification_webhook_token: None,
|
||||||
|
base_url: "http://localhost:7878".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Self {
|
||||||
|
// Check multiple locations in order
|
||||||
|
let paths = [
|
||||||
|
std::env::var("GATEWAY_CONFIG").unwrap_or_default(),
|
||||||
|
"/srv/action-gateway/action-gateway.toml".into(),
|
||||||
|
"/home/node/.openclaw/workspace/config/action-gateway.toml".into(),
|
||||||
|
];
|
||||||
|
for path in &paths {
|
||||||
|
if path.is_empty() { continue; }
|
||||||
|
if let Ok(contents) = fs::read_to_string(path) {
|
||||||
|
return toml::from_str(&contents).unwrap_or_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Config::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
use rusqlite::{Connection, Result, params};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn open(path: &str) -> Result<Connection> {
|
||||||
|
let conn = Connection::open(path)?;
|
||||||
|
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
|
||||||
|
migrate(&conn)?;
|
||||||
|
Ok(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migrate(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute_batch("
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
token TEXT UNIQUE NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
key_hash TEXT UNIQUE NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS actions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
chain_id TEXT,
|
||||||
|
sequence INTEGER NOT NULL DEFAULT 0,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
command TEXT NOT NULL,
|
||||||
|
run_as TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
result TEXT,
|
||||||
|
approve_token TEXT UNIQUE NOT NULL,
|
||||||
|
submitted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
resolved_at TEXT
|
||||||
|
);
|
||||||
|
")
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
mod config;
|
||||||
|
mod db;
|
||||||
|
mod models;
|
||||||
|
mod routes;
|
||||||
|
|
||||||
|
use axum::{Router, middleware};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use config::Config;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: Arc<Mutex<Connection>>,
|
||||||
|
pub config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
let config = Config::load();
|
||||||
|
let conn = db::open(&config.database_path).expect("Failed to open database");
|
||||||
|
let state = AppState {
|
||||||
|
db: Arc::new(Mutex::new(conn)),
|
||||||
|
config: config.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = routes::router(state);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(&config.bind).await.unwrap();
|
||||||
|
println!("🚀 Action Gateway running at http://{}", config.bind);
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Action {
|
||||||
|
pub id: String,
|
||||||
|
pub chain_id: Option<String>,
|
||||||
|
pub sequence: i64,
|
||||||
|
pub description: String,
|
||||||
|
pub command: String,
|
||||||
|
pub run_as: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub result: Option<String>,
|
||||||
|
pub approve_token: String,
|
||||||
|
pub submitted_at: String,
|
||||||
|
pub resolved_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SubmitAction {
|
||||||
|
pub description: String,
|
||||||
|
pub command: String,
|
||||||
|
pub chain_id: Option<String>,
|
||||||
|
pub sequence: Option<i64>,
|
||||||
|
/// Who to run the command as: "oc", "openclaw", "root", or omit for service user
|
||||||
|
pub run_as: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ActionResult {
|
||||||
|
pub id: String,
|
||||||
|
pub status: String,
|
||||||
|
pub result: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginForm {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
+557
@@ -0,0 +1,557 @@
|
|||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
routing::{get, post},
|
||||||
|
extract::{State, Path, Query, Form},
|
||||||
|
response::{Html, Redirect, IntoResponse, Response},
|
||||||
|
http::{StatusCode, HeaderMap},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::{CookieJar, Cookie};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||||
|
use chrono::Utc;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use crate::{AppState, models::*};
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn session_user(jar: &CookieJar, state: &AppState) -> Option<String> {
|
||||||
|
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<String> = {
|
||||||
|
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<String> {
|
||||||
|
Html(page("Login", r#"
|
||||||
|
<div class="card" style="max-width:400px;margin:80px auto">
|
||||||
|
<h2>🔐 Action Gateway</h2>
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<label>Username</label>
|
||||||
|
<input name="username" type="text" required autofocus>
|
||||||
|
<label>Password</label>
|
||||||
|
<input name="password" type="password" required>
|
||||||
|
<button type="submit">Sign In</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
"#))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Form(form): Form<LoginForm>,
|
||||||
|
) -> 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#"
|
||||||
|
<div class="card" style="max-width:400px;margin:80px auto">
|
||||||
|
<h2>🔐 Action Gateway</h2>
|
||||||
|
<p style="color:#e74c3c">Invalid credentials.</p>
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<label>Username</label>
|
||||||
|
<input name="username" type="text" required autofocus>
|
||||||
|
<label>Password</label>
|
||||||
|
<input name="password" type="password" required>
|
||||||
|
<button type="submit">Sign In</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
"#)).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<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let Some(username) = session_user(&jar, &state) else {
|
||||||
|
return Redirect::to("/login").into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
let actions: Vec<Action> = {
|
||||||
|
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#"<span class="badge pending">⏳ Pending</span>"#,
|
||||||
|
"approved" => r#"<span class="badge approved">✅ Approved</span>"#,
|
||||||
|
"executed" => r#"<span class="badge executed">✔ Executed</span>"#,
|
||||||
|
"rejected" => r#"<span class="badge rejected">❌ Rejected</span>"#,
|
||||||
|
"failed" => r#"<span class="badge failed">💥 Failed</span>"#,
|
||||||
|
_ => r#"<span class="badge">Unknown</span>"#,
|
||||||
|
};
|
||||||
|
let actions_html = if a.status == "pending" {
|
||||||
|
format!(r#"
|
||||||
|
<form method="post" action="/actions/{}/approve" style="display:inline">
|
||||||
|
<button class="btn-approve" type="submit">✅ Approve</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/actions/{}/reject" style="display:inline">
|
||||||
|
<button class="btn-reject" type="submit">❌ Reject</button>
|
||||||
|
</form>
|
||||||
|
"#, a.id, a.id)
|
||||||
|
} else {
|
||||||
|
a.result.clone().map(|r| format!("<pre class='result'>{}</pre>", html_escape(&r))).unwrap_or_default()
|
||||||
|
};
|
||||||
|
let run_as_badge = a.run_as.as_ref()
|
||||||
|
.map(|u| format!("<span class='run-as'>run as: {}</span>", u))
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!(r#"
|
||||||
|
<div class="action-card">
|
||||||
|
<div class="action-header">
|
||||||
|
{} {} {}
|
||||||
|
<span class="time">{}</span>
|
||||||
|
</div>
|
||||||
|
<div class="description">{}</div>
|
||||||
|
<pre class="command">$ {}</pre>
|
||||||
|
{}
|
||||||
|
</div>
|
||||||
|
"#, status_badge,
|
||||||
|
a.chain_id.as_ref().map(|c| format!("<span class='chain'>chain: {}</span>", &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#"
|
||||||
|
<div class="header">
|
||||||
|
<h1>🤖 Action Gateway</h1>
|
||||||
|
<span>Logged in as <strong>{}</strong> <a href="/logout">Logout</a></span>
|
||||||
|
</div>
|
||||||
|
<div class="actions-list" hx-get="/" hx-trigger="every 5s" hx-swap="outerHTML" hx-select=".actions-list">
|
||||||
|
{}
|
||||||
|
</div>
|
||||||
|
"#, username, if rows.is_empty() { "<p style='text-align:center;opacity:0.5'>No actions yet.</p>".into() } else { rows });
|
||||||
|
|
||||||
|
Html(page("Action Gateway", &content)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ApproveQuery {
|
||||||
|
token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn action_detail(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Query(q): Query<ApproveQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Allow approve via token link (no login required)
|
||||||
|
if let Some(token) = q.token {
|
||||||
|
let action: Option<Action> = {
|
||||||
|
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#"
|
||||||
|
<div class="card" style="max-width:600px;margin:40px auto">
|
||||||
|
<h2>Review Action</h2>
|
||||||
|
<p>{}</p>
|
||||||
|
<pre class="command">$ {}</pre>
|
||||||
|
<form method="post" action="/actions/{}/approve" style="display:inline">
|
||||||
|
<button class="btn-approve">✅ Approve</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/actions/{}/reject" style="display:inline">
|
||||||
|
<button class="btn-reject">❌ Reject</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
"#, html_escape(&a.description), html_escape(&a.command), a.id, a.id);
|
||||||
|
return Html(page("Approve Action", &content)).into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Html(page("Invalid", "<p>Invalid or expired token.</p>")).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<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if session_user(&jar, &state).is_none() {
|
||||||
|
return Redirect::to("/login").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let action: Option<Action> = {
|
||||||
|
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<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> 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<String> = {
|
||||||
|
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<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<SubmitAction>,
|
||||||
|
) -> 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<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> 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<String>)> = 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<String> 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 [ ... <letter>
|
||||||
|
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#"<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{}</title>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||||
|
body {{ font-family: system-ui, sans-serif; background: #0f1117; color: #e0e0e0; min-height: 100vh; padding: 20px; }}
|
||||||
|
a {{ color: #7eb8f7; }}
|
||||||
|
h1, h2 {{ font-weight: 600; }}
|
||||||
|
.header {{ display: flex; justify-content: space-between; align-items: center; padding: 0 0 24px 0; border-bottom: 1px solid #2a2d3a; margin-bottom: 24px; }}
|
||||||
|
.card {{ background: #1a1d27; border: 1px solid #2a2d3a; border-radius: 10px; padding: 24px; }}
|
||||||
|
.card h2 {{ margin-bottom: 16px; }}
|
||||||
|
label {{ display: block; margin: 12px 0 4px; font-size: 0.9em; color: #aaa; }}
|
||||||
|
input {{ width: 100%; padding: 10px 12px; background: #0f1117; border: 1px solid #2a2d3a; border-radius: 6px; color: #e0e0e0; font-size: 1em; }}
|
||||||
|
input:focus {{ outline: none; border-color: #7eb8f7; }}
|
||||||
|
button {{ padding: 10px 20px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.95em; font-weight: 500; margin-top: 16px; }}
|
||||||
|
.btn-approve {{ background: #27ae60; color: white; margin-right: 8px; }}
|
||||||
|
.btn-approve:hover {{ background: #2ecc71; }}
|
||||||
|
.btn-reject {{ background: #c0392b; color: white; }}
|
||||||
|
.btn-reject:hover {{ background: #e74c3c; }}
|
||||||
|
button[type="submit"] {{ background: #7eb8f7; color: #0f1117; width: 100%; margin-top: 16px; }}
|
||||||
|
.actions-list {{ display: flex; flex-direction: column; gap: 12px; }}
|
||||||
|
.action-card {{ background: #1a1d27; border: 1px solid #2a2d3a; border-radius: 10px; padding: 20px; }}
|
||||||
|
.action-header {{ display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }}
|
||||||
|
.badge {{ padding: 3px 10px; border-radius: 20px; font-size: 0.8em; font-weight: 600; }}
|
||||||
|
.badge.pending {{ background: #7f6500; color: #ffd700; }}
|
||||||
|
.badge.approved {{ background: #1a5c33; color: #2ecc71; }}
|
||||||
|
.badge.executed {{ background: #1a4060; color: #7eb8f7; }}
|
||||||
|
.badge.rejected {{ background: #5c1a1a; color: #e74c3c; }}
|
||||||
|
.badge.failed {{ background: #5c1a1a; color: #ff6b6b; }}
|
||||||
|
.chain {{ font-size: 0.75em; color: #888; font-family: monospace; }}
|
||||||
|
.run-as {{ font-size: 0.75em; color: #b39ddb; font-family: monospace; background: #2a2040; padding: 2px 8px; border-radius: 10px; }}
|
||||||
|
.time {{ font-size: 0.8em; color: #666; margin-left: auto; }}
|
||||||
|
.description {{ margin-bottom: 10px; color: #ccc; }}
|
||||||
|
.command {{ background: #0a0c13; border: 1px solid #2a2d3a; border-radius: 6px; padding: 12px; font-size: 0.9em; color: #a8d8a8; overflow-x: auto; margin-bottom: 12px; }}
|
||||||
|
.result {{ background: #0a0c13; border: 1px solid #2a2d3a; border-radius: 6px; padding: 12px; font-size: 0.85em; color: #ccc; overflow-x: auto; max-height: 200px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; }}
|
||||||
|
pre {{ white-space: pre-wrap; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{}
|
||||||
|
</body>
|
||||||
|
</html>"#, title, content)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user