Public Access
style: apply cargo fmt to all source files
This commit is contained in:
+218
-110
@@ -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<String> {
|
||||
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<String> = {
|
||||
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<String> {
|
||||
Html(page("Login", r#"
|
||||
Html(page(
|
||||
"Login",
|
||||
r#"
|
||||
<div class="card" style="max-width:400px;margin:80px auto">
|
||||
<h2>🔐 Action Gateway</h2>
|
||||
<form method="post" action="/login">
|
||||
@@ -78,7 +89,8 @@ async fn login_page() -> Html<String> {
|
||||
<button type="submit">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
"#))
|
||||
"#,
|
||||
))
|
||||
}
|
||||
|
||||
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#"
|
||||
<div class="card" style="max-width:400px;margin:80px auto">
|
||||
<h2>🔐 Action Gateway</h2>
|
||||
<p style="color:#e74c3c">Invalid credentials.</p>
|
||||
@@ -126,7 +147,9 @@ async fn login_post(
|
||||
<button type="submit">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
"#)).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<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> impl IntoResponse {
|
||||
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();
|
||||
};
|
||||
@@ -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#"<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#"
|
||||
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#"
|
||||
"#,
|
||||
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">
|
||||
{} {} {}
|
||||
@@ -200,16 +233,23 @@ async fn dashboard(
|
||||
<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();
|
||||
"#,
|
||||
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#"
|
||||
let content = format!(
|
||||
r#"
|
||||
<div class="header">
|
||||
<h1>🤖 Action Gateway</h1>
|
||||
<span>Logged in as <strong>{}</strong> <a href="/logout">Logout</a></span>
|
||||
@@ -217,7 +257,14 @@ async fn dashboard(
|
||||
<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 });
|
||||
"#,
|
||||
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()
|
||||
}
|
||||
@@ -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#"
|
||||
<div class="card" style="max-width:600px;margin:40px auto">
|
||||
<h2>Review Action</h2>
|
||||
<p>{}</p>
|
||||
@@ -262,7 +310,12 @@ async fn action_detail(
|
||||
<button class="btn-reject">❌ Reject</button>
|
||||
</form>
|
||||
</div>
|
||||
"#, 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<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()
|
||||
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<SubmitAction>,
|
||||
) -> 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<String>,
|
||||
) -> 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<String>)> = 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<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)) => {
|
||||
@@ -434,17 +521,20 @@ async fn get_result(
|
||||
// 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();
|
||||
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#"<!DOCTYPE html>
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -553,5 +659,7 @@ fn page(title: &str, content: &str) -> String {
|
||||
<body>
|
||||
{}
|
||||
</body>
|
||||
</html>"#, title, content)
|
||||
</html>"#,
|
||||
title, content
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user