//! Per-device PowerShell remote-exec page. //! //! Layout: //! GET /admin/pages/devices/:peer_id/exec — full page //! POST /admin/pages/devices/:peer_id/exec — dispatch //! GET /admin/pages/devices/:peer_id/exec/:cmd_id/poll — single-row fragment, auto-refreshes //! //! Gates: //! - AuthedUser.is_admin //! - peer.managed = 1 (no exec on legacy/unsigned peers) //! - strategy.config_options."enable-remote-exec" = "Y" //! - no other in-flight exec for this peer use crate::api::admin::i18n::{t, tf1, tf2, Lang}; use crate::api::error::ApiError; use crate::api::middleware::AuthedUser; use crate::api::state::AppState; use crate::api::strategy; use crate::database::ExecRow; use axum::extract::{Extension, Form, Path}; use axum::response::Html; use serde::Deserialize; use std::fmt::Write as _; use std::sync::Arc; const HISTORY_LIMIT: i64 = 20; const MAX_SCRIPT_BYTES: usize = 32 * 1024; /// Wall-clock cap, mirrored from heartbeat.rs::EXEC_MAX_SECS so the dispatch /// confirm dialog can surface the same number. Kept duplicated rather than /// shared as a pub const because the two values legitimately differ in /// future (per-strategy override is a likely next step). const UI_MAX_SECS: u64 = 300; const UI_MAX_BYTES: u64 = 1024 * 1024; #[derive(Debug, Deserialize)] pub struct DispatchForm { pub script: String, } /// Main page (full content for `#main`). Renders the gate banner, the /// script form (only when allowed), and the recent-history table. pub async fn index( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Path(peer_id): Path, ) -> Result, ApiError> { require_admin(&admin)?; Ok(Html(render_page(&state, lang, &peer_id, None).await?)) } /// Dispatch handler. Re-checks all gates server-side (the UI also gates /// the form, but the form is just HTML — never trust the client). On /// success: insert into exec_history with status='queued', return the /// page with a success notice; the next heartbeat will flip it to /// 'running' and the history row picks up auto-refresh. pub async fn dispatch( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Path(peer_id): Path, Form(form): Form, ) -> Result, ApiError> { require_admin(&admin)?; let script = form.script.trim_end_matches(['\r', '\n']).to_string(); // Validation runs BEFORE the gate checks so an empty script doesn't // get a confusing "managed required" error. Order: shape → policy. if script.trim().is_empty() { return Ok(Html( render_page(&state, lang, &peer_id, Some(("error", t(lang, "exec.error_empty").to_string()))).await?, )); } if script.len() > MAX_SCRIPT_BYTES { return Ok(Html( render_page( &state, lang, &peer_id, Some(( "error", tf2( lang, "exec.error_too_large", &script.len().to_string(), &MAX_SCRIPT_BYTES.to_string(), ), )), ) .await?, )); } let gate = check_gate(&state, &peer_id).await?; if !gate.allowed { return Ok(Html( render_page( &state, lang, &peer_id, Some(("error", gate_reason_message(lang, &gate))), ) .await?, )); } if gate.in_flight > 0 { return Ok(Html( render_page( &state, lang, &peer_id, Some(("error", t(lang, "exec.error_in_flight").to_string())), ) .await?, )); } let cmd_id = uuid::Uuid::new_v4().to_string(); state .db .exec_create(&cmd_id, &peer_id, admin.user_id, &script) .await .map_err(|e| ApiError::Internal(e.to_string()))?; hbb_common::log::info!( "admin {} queued exec cmd_id={} for peer {} ({} bytes)", admin.name, cmd_id, peer_id, script.len() ); Ok(Html( render_page( &state, lang, &peer_id, Some(("ok", tf1(lang, "exec.queued", &cmd_id))), ) .await?, )) } /// Single-row poll fragment. Returned by an HTMX `hx-get` whose /// `hx-trigger` keeps firing until the row reaches a terminal state. pub async fn poll( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Path((peer_id, cmd_id)): Path<(String, String)>, ) -> Result, ApiError> { require_admin(&admin)?; let row = state .db .exec_get_by_cmd_id(&cmd_id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let row = match row { Some(r) if r.peer_id == peer_id => r, _ => { return Ok(Html(format!( r##"{}"##, t(lang, "exec.not_found"), ))); } }; Ok(Html(render_history_row(lang, &row))) } // ───────────────────────── helpers ───────────────────────── async fn check_gate(state: &Arc, peer_id: &str) -> Result { let auth = state .db .peer_get_auth(peer_id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let managed = matches!(auth, Some((_, true))); let strategy_allows = strategy::allows_remote_exec(state, peer_id).await; let in_flight = state .db .exec_in_flight_count(peer_id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; Ok(GateCheck { allowed: managed && strategy_allows, managed, strategy_allows, in_flight, }) } struct GateCheck { allowed: bool, managed: bool, strategy_allows: bool, in_flight: i64, } fn gate_reason_message(lang: Lang, g: &GateCheck) -> String { if !g.managed { return t(lang, "exec.reason_not_managed").to_string(); } if !g.strategy_allows { return t(lang, "exec.reason_strategy").to_string(); } t(lang, "exec.reason_unknown").to_string() } async fn render_page( state: &Arc, lang: Lang, peer_id: &str, notice: Option<(&'static str, String)>, ) -> Result { let gate = check_gate(state, peer_id).await?; let history = state .db .exec_list_for_peer(peer_id, HISTORY_LIMIT) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let mut out = String::new(); let _ = write!( &mut out, r##"

{heading} {id}

"##, heading = t(lang, "exec.heading"), id = html_escape(peer_id), back = t(lang, "devices.back"), ); if let Some((kind, msg)) = notice { let _ = write!(&mut out, "{}", notice_html(kind, &msg)); } // Gate banner let _ = write!(&mut out, "{}", render_gate_banner(lang, &gate)); // Script form — only rendered when gate is open if gate.allowed { let _ = write!( &mut out, r##"

{caps}

"##, id = html_escape(peer_id), label = t(lang, "exec.script_label"), confirm = html_escape(&tf1(lang, "exec.confirm_dispatch", peer_id)), caps = html_escape(&tf2( lang, "exec.caps_note", &UI_MAX_SECS.to_string(), &(UI_MAX_BYTES / 1024 / 1024).to_string(), )), run = t(lang, "exec.run"), ); } // History table let _ = write!( &mut out, r##"

{hist}

"##, hist = t(lang, "exec.history"), c_when = t(lang, "exec.col_when"), c_who = t(lang, "exec.col_who"), c_status = t(lang, "exec.col_status"), c_script = t(lang, "exec.col_script"), c_output = t(lang, "exec.col_output"), ); if history.is_empty() { let _ = write!( &mut out, r##""##, t(lang, "exec.no_history"), ); } else { for row in &history { out.push_str(&render_history_row(lang, row)); } } out.push_str(r##"
{c_when} {c_who} {c_status} {c_script} {c_output}
{}
"##); Ok(out) } fn render_gate_banner(lang: Lang, g: &GateCheck) -> String { let (kind, msg) = if g.allowed { ("ok", t(lang, "exec.gate_open").to_string()) } else if !g.managed { ("error", t(lang, "exec.reason_not_managed").to_string()) } else if !g.strategy_allows { ("error", t(lang, "exec.reason_strategy").to_string()) } else { ("error", t(lang, "exec.reason_unknown").to_string()) }; notice_html(kind, &msg) } fn render_history_row(lang: Lang, r: &ExecRow) -> String { // Auto-refresh while the row is non-terminal so the operator sees // running → finished without manual reload. HTMX `hx-trigger` with // `every 1s` fires until the SERVER emits a row missing the trigger // (i.e. the row reached finished/timed_out/errored). let row_id = format!("exec-row-{}", html_escape(&r.cmd_id)); let polling_attrs = if matches!(r.status.as_str(), "queued" | "running") { format!( r##"hx-get="/admin/pages/devices/{peer}/exec/{cmd}/poll" hx-trigger="load delay:1s" hx-target="this" hx-swap="outerHTML""##, peer = html_escape(&r.peer_id), cmd = html_escape(&r.cmd_id), ) } else { String::new() }; let status_cell = render_status_badge(lang, r); let when = fmt_unix(r.issued_at); let script_preview = preview(&r.script, 80); let output_block = render_output_block(lang, r); format!( r##" {when} #{user} {status} {script} {output} "##, row_id = row_id, polling = polling_attrs, when = html_escape(&when), user = r.issued_by_user_id, status = status_cell, script = html_escape(&script_preview), output = output_block, ) } fn render_status_badge(lang: Lang, r: &ExecRow) -> String { let (border, bg, text_color, label) = match r.status.as_str() { "queued" => ("slate-700", "slate-800/40", "slate-300", t(lang, "exec.status_queued")), "running" => ("amber-700/50", "amber-900/30", "amber-300", t(lang, "exec.status_running")), "finished" => { if r.exit_code == Some(0) { ("emerald-700/50", "emerald-900/30", "emerald-300", t(lang, "exec.status_finished_ok")) } else { ("rose-700/50", "rose-900/30", "rose-300", t(lang, "exec.status_finished_err")) } } "timed_out" => ("rose-700/50", "rose-900/30", "rose-300", t(lang, "exec.status_timed_out")), _ => ("rose-700/50", "rose-900/30", "rose-300", t(lang, "exec.status_errored")), }; let exit_suffix = match (r.status.as_str(), r.exit_code) { ("finished", Some(c)) => format!(" (exit {c})"), _ => String::new(), }; format!( r##"{label}{exit}"##, b = border, bg = bg, t = text_color, label = html_escape(label), exit = exit_suffix, ) } fn render_output_block(lang: Lang, r: &ExecRow) -> String { if matches!(r.status.as_str(), "queued" | "running") { return format!( r##"{}"##, t(lang, "exec.output_pending"), ); } let mut s = String::new(); if !r.stdout.is_empty() { let _ = write!( &mut s, r##"
stdout ({n} bytes)
{out}
"##, n = r.stdout.len(), out = html_escape(&r.stdout), ); } if !r.stderr.is_empty() { let _ = write!( &mut s, r##"
stderr ({n} bytes)
{out}
"##, n = r.stderr.len(), out = html_escape(&r.stderr), ); } if r.truncated { let _ = write!( &mut s, r##"

{}

"##, t(lang, "exec.output_truncated"), ); } if s.is_empty() { s = format!(r##"{}"##, t(lang, "exec.output_empty")); } s } fn fmt_unix(ts: i64) -> String { use chrono::TimeZone; chrono::Utc .timestamp_opt(ts, 0) .single() .map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string()) .unwrap_or_else(|| "—".to_string()) } fn preview(s: &str, max: usize) -> String { let first_line = s.lines().next().unwrap_or(""); if first_line.len() <= max { first_line.to_string() } else { format!("{}…", &first_line[..max]) } } fn notice_html(kind: &str, msg: &str) -> String { let (border, bg, text) = match kind { "ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"), _ => ("rose-700/50", "rose-900/30", "rose-300"), }; format!( r##"
{msg}
"##, border = border, bg = bg, text = text, msg = html_escape(msg), ) } fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } fn require_admin(u: &AuthedUser) -> Result<(), ApiError> { if u.is_admin { Ok(()) } else { Err(ApiError::Forbidden("admin required".into())) } }