Implement remote execution
build / build-linux-amd64 (push) Successful in 1m58s

This commit is contained in:
2026-05-22 14:18:48 +02:00
parent 475da0e950
commit 6a0b698384
13 changed files with 1188 additions and 4 deletions
+163
View File
@@ -878,6 +878,169 @@ pub fn t(lang: Lang, key: &str) -> &'static str {
"Peer-ul {0} nu a fost găsit în tabelul de identitate rendezvous — nu se poate seta indicatorul managed. Agentul trebuie să finalizeze mai întâi un handshake rendezvous.",
"Par {0} no encontrado en la tabla de identidad rendezvous — no se puede establecer el indicador managed. El agente debe completar primero un apretón de manos rendezvous.",
),
"devices.run_command" => (
"Run command…",
"Befehl ausführen…",
"Exécuter une commande…",
"Rulează comandă…",
"Ejecutar comando…",
),
"exec.heading" => (
"Remote PowerShell",
"Remote-PowerShell",
"PowerShell distant",
"PowerShell la distanță",
"PowerShell remoto",
),
"exec.script_label" => (
"PowerShell script (runs as the hello-agent service account — typically LocalSystem):",
"PowerShell-Skript (läuft unter dem hello-agent-Dienstkonto — typischerweise LocalSystem):",
"Script PowerShell (exécuté sous le compte de service hello-agent — généralement LocalSystem) :",
"Script PowerShell (rulează sub contul de serviciu hello-agent — de obicei LocalSystem):",
"Script de PowerShell (se ejecuta bajo la cuenta del servicio hello-agent — habitualmente LocalSystem):",
),
"exec.caps_note" => (
"Limits: {0}s wall-clock, {1} MiB combined stdout+stderr.",
"Grenzen: {0}s Wall-Clock, {1} MiB stdout+stderr kombiniert.",
"Limites : {0}s horloge, {1} Mio stdout+stderr cumulés.",
"Limite: {0}s timp real, {1} MiB stdout+stderr combinat.",
"Límites: {0}s reloj, {1} MiB stdout+stderr combinados.",
),
"exec.run" => (
"Run",
"Ausführen",
"Exécuter",
"Rulează",
"Ejecutar",
),
"exec.confirm_dispatch" => (
"Dispatch this PowerShell script to {0}? It will run with the agent's service privileges and the output will be visible in this dashboard.",
"Dieses PowerShell-Skript an {0} senden? Es läuft mit den Dienst-Rechten des Agents und die Ausgabe ist in diesem Dashboard sichtbar.",
"Envoyer ce script PowerShell à {0} ? Il s'exécutera avec les privilèges du service de l'agent et la sortie sera visible dans ce tableau de bord.",
"Trimite acest script PowerShell la {0}? Va rula cu privilegiile serviciului agentului iar ieșirea va fi vizibilă în acest panou.",
"¿Enviar este script de PowerShell a {0}? Se ejecutará con los privilegios del servicio del agente y la salida será visible en este panel.",
),
"exec.queued" => (
"Queued. The agent will pick this up on its next heartbeat. Command id: {0}",
"In Warteschlange. Der Agent übernimmt dies beim nächsten Heartbeat. Befehls-ID: {0}",
"En file. L'agent prendra cela en charge à son prochain heartbeat. Identifiant de commande : {0}",
"În coadă. Agentul îl va prelua la următorul heartbeat. ID comandă: {0}",
"En cola. El agente lo recogerá en su próximo heartbeat. Id. de comando: {0}",
),
"exec.history" => (
"Recent executions",
"Letzte Ausführungen",
"Exécutions récentes",
"Execuții recente",
"Ejecuciones recientes",
),
"exec.col_when" => ("When", "Wann", "Quand", "Când", "Cuándo"),
"exec.col_who" => ("By", "Von", "Par", "De", "Por"),
"exec.col_status" => ("Status", "Status", "Statut", "Stare", "Estado"),
"exec.col_script" => ("Script", "Skript", "Script", "Script", "Script"),
"exec.col_output" => ("Output", "Ausgabe", "Sortie", "Ieșire", "Salida"),
"exec.no_history" => (
"No commands have been dispatched to this device yet.",
"An dieses Gerät wurden noch keine Befehle gesendet.",
"Aucune commande n'a encore été envoyée à cet appareil.",
"Niciun comandă nu a fost trimisă încă la acest dispozitiv.",
"Aún no se ha enviado ningún comando a este dispositivo.",
),
"exec.status_queued" => ("Queued", "In Warteschlange", "En file", "În coadă", "En cola"),
"exec.status_running" => ("Running", "Läuft", "En cours", "În execuție", "En ejecución"),
"exec.status_finished_ok" => ("Finished", "Abgeschlossen", "Terminé", "Finalizat", "Finalizado"),
"exec.status_finished_err" => (
"Finished with error",
"Mit Fehler abgeschlossen",
"Terminé avec erreur",
"Finalizat cu eroare",
"Finalizado con error",
),
"exec.status_timed_out" => (
"Timed out",
"Zeitüberschreitung",
"Délai dépassé",
"Expirat",
"Tiempo agotado",
),
"exec.status_errored" => ("Errored", "Fehlgeschlagen", "Échec", "Eroare", "Error"),
"exec.output_pending" => (
"Pending output…",
"Ausgabe steht aus…",
"Sortie en attente…",
"Ieșire în așteptare…",
"Salida pendiente…",
),
"exec.output_empty" => (
"(no output)",
"(keine Ausgabe)",
"(aucune sortie)",
"(fără ieșire)",
"(sin salida)",
),
"exec.output_truncated" => (
"Output truncated at the 1 MiB cap.",
"Ausgabe bei 1 MiB-Grenze abgeschnitten.",
"Sortie tronquée à la limite de 1 Mio.",
"Ieșire trunchiată la limita de 1 MiB.",
"Salida truncada al límite de 1 MiB.",
),
"exec.gate_open" => (
"Remote exec is enabled for this peer. PowerShell commands you dispatch here run as the agent's service account.",
"Remote-Ausführung ist für dieses Gerät aktiviert. Hier gesendete PowerShell-Befehle laufen unter dem Dienstkonto des Agents.",
"L'exécution distante est activée pour ce pair. Les commandes PowerShell envoyées ici s'exécutent sous le compte de service de l'agent.",
"Execuția la distanță este activată pentru acest peer. Comenzile PowerShell trimise aici rulează sub contul de serviciu al agentului.",
"La ejecución remota está habilitada para este par. Los comandos de PowerShell enviados desde aquí se ejecutan bajo la cuenta del servicio del agente.",
),
"exec.reason_not_managed" => (
"Remote exec requires the peer's signed-API gate (Auth: Signed). Flip the row's Auth toggle on the Devices page first, or wait for the agent's first signed heartbeat to TOFU-promote.",
"Remote-Ausführung erfordert den Signed-API-Schutz dieses Geräts (Auth: Signiert). Schalten Sie den Auth-Schalter auf der Geräte-Seite zuerst um oder warten Sie auf den ersten signierten Heartbeat des Agents (TOFU-Promote).",
"L'exécution distante exige le verrou API signée du pair (Auth : Signé). Activez d'abord le commutateur Auth sur la page Appareils, ou attendez le premier heartbeat signé de l'agent (TOFU).",
"Execuția la distanță necesită protecția API semnată a peer-ului (Auth: Semnat). Comutați mai întâi comutatorul Auth pe pagina Dispozitive sau așteptați primul heartbeat semnat al agentului (TOFU).",
"La ejecución remota requiere la protección de API firmada del par (Auth: Firmado). Active primero el conmutador Auth en la página de Dispositivos, o espere al primer heartbeat firmado del agente (TOFU).",
),
"exec.reason_strategy" => (
"Remote exec is disabled by the peer's resolved strategy. Add config_options.\"enable-remote-exec\" = \"Y\" to a strategy that applies to this peer and assign it on the Strategies page.",
"Remote-Ausführung ist durch die zugewiesene Strategie deaktiviert. Setzen Sie config_options.\"enable-remote-exec\" = \"Y\" in einer Strategie für dieses Gerät und weisen Sie sie auf der Strategien-Seite zu.",
"L'exécution distante est désactivée par la stratégie résolue pour ce pair. Ajoutez config_options.\"enable-remote-exec\" = \"Y\" à une stratégie applicable à ce pair et assignez-la sur la page Stratégies.",
"Execuția la distanță este dezactivată de strategia rezolvată pentru peer. Adăugați config_options.\"enable-remote-exec\" = \"Y\" la o strategie aplicabilă acestui peer și atribuiți-o pe pagina Strategii.",
"La ejecución remota está deshabilitada por la estrategia resuelta del par. Añada config_options.\"enable-remote-exec\" = \"Y\" a una estrategia aplicable a este par y asígnela en la página Estrategias.",
),
"exec.reason_unknown" => (
"Remote exec is not available for this peer.",
"Remote-Ausführung ist für dieses Gerät nicht verfügbar.",
"L'exécution distante n'est pas disponible pour ce pair.",
"Execuția la distanță nu este disponibilă pentru acest peer.",
"La ejecución remota no está disponible para este par.",
),
"exec.error_in_flight" => (
"Another exec is already in flight for this peer. Wait for it to finish or time out (5 min max) before dispatching the next one.",
"Für dieses Gerät läuft bereits eine Ausführung. Warten Sie auf den Abschluss oder die Zeitüberschreitung (max. 5 Min.).",
"Une exécution est déjà en cours pour ce pair. Attendez sa fin ou son expiration (5 min max) avant d'en envoyer une autre.",
"O execuție este deja în curs pentru acest peer. Așteptați finalizarea sau expirarea (max 5 min) înainte de a trimite alta.",
"Ya hay una ejecución en curso para este par. Espere a que termine o expire (5 min máx) antes de enviar otra.",
),
"exec.error_empty" => (
"Script is empty.",
"Skript ist leer.",
"Le script est vide.",
"Scriptul este gol.",
"El script está vacío.",
),
"exec.error_too_large" => (
"Script is {0} bytes; max is {1} bytes.",
"Skript ist {0} Bytes; Maximum ist {1} Bytes.",
"Le script fait {0} octets ; max {1} octets.",
"Scriptul are {0} octeți; maxim {1} octeți.",
"El script tiene {0} bytes; el máximo es {1} bytes.",
),
"exec.not_found" => (
"Command not found.",
"Befehl nicht gefunden.",
"Commande introuvable.",
"Comanda nu a fost găsită.",
"Comando no encontrado.",
),
"devices.back" => (
"← Back to devices",
"← Zurück zu Geräten",
+8
View File
@@ -117,6 +117,14 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
"/admin/pages/devices/:peer_id/toggle-managed",
post(pages::devices::toggle_managed),
)
.route(
"/admin/pages/devices/:peer_id/exec",
get(pages::exec::index).post(pages::exec::dispatch),
)
.route(
"/admin/pages/devices/:peer_id/exec/:cmd_id/poll",
get(pages::exec::poll),
)
// Groups
.route("/admin/pages/groups/create", post(pages::groups::create))
.route("/admin/pages/groups/:id/delete", post(pages::groups::delete))
+6
View File
@@ -461,6 +461,11 @@ fn render_device_row(
hx-target="#devices-region" hx-swap="innerHTML">
{details}
</button>
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-get="/admin/pages/devices/{id}/exec"
hx-target="#devices-region" hx-swap="innerHTML">
{run_command}
</button>
<hr class="border-slate-700 my-1" />
{toggle_managed_item}
<hr class="border-slate-700 my-1" />
@@ -504,6 +509,7 @@ fn render_device_row(
toggle_managed_item = toggle_managed_item,
connect_web = t(lang, "devices.connect_web"),
details = t(lang, "devices.details"),
run_command = t(lang, "devices.run_command"),
confirm_disc = html_escape(&tf1(lang, "devices.confirm_disconnect", &d.id)),
force_disc = t(lang, "devices.force_disconnect"),
force_sysinfo = t(lang, "devices.force_sysinfo"),
+481
View File
@@ -0,0 +1,481 @@
//! 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<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(peer_id): Path<String>,
) -> Result<Html<String>, 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<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(peer_id): Path<String>,
Form(form): Form<DispatchForm>,
) -> Result<Html<String>, 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<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path((peer_id, cmd_id)): Path<(String, String)>,
) -> Result<Html<String>, 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##"<tr><td colspan="5" class="px-3 py-2 text-rose-300 text-xs">{}</td></tr>"##,
t(lang, "exec.not_found"),
)));
}
};
Ok(Html(render_history_row(lang, &row)))
}
// ───────────────────────── helpers ─────────────────────────
async fn check_gate(state: &Arc<AppState>, peer_id: &str) -> Result<GateCheck, ApiError> {
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<AppState>,
lang: Lang,
peer_id: &str,
notice: Option<(&'static str, String)>,
) -> Result<String, ApiError> {
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##"<div class="space-y-4">
<header class="flex items-center justify-between">
<h2 class="text-lg font-semibold">{heading} <code class="font-mono text-sky-300">{id}</code></h2>
<button class="text-xs text-sky-300 hover:text-sky-200"
hx-get="/admin/pages/devices/list-fragment"
hx-target="#devices-region" hx-swap="innerHTML">{back}</button>
</header>"##,
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##"<form class="space-y-2" hx-post="/admin/pages/devices/{id}/exec"
hx-target="#devices-region" hx-swap="innerHTML"
hx-confirm="{confirm}">
<label class="block text-xs text-slate-400">{label}</label>
<textarea name="script" rows="8" required
class="w-full font-mono text-sm rounded border border-slate-700 bg-slate-950 text-slate-200 p-2"
placeholder="Get-Service hello-agent | Select-Object Status, Name"></textarea>
<div class="flex items-center justify-between">
<p class="text-xs text-slate-500">{caps}</p>
<button type="submit" class="rounded bg-sky-700 hover:bg-sky-600 text-sky-100 text-xs px-3 py-1.5">{run}</button>
</div>
</form>"##,
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##"<section>
<h3 class="text-sm font-semibold text-slate-300 mb-2">{hist}</h3>
<div class="rounded-md border border-slate-800 bg-slate-900">
<table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950">
<tr>
<th class="text-left font-medium px-3 py-2">{c_when}</th>
<th class="text-left font-medium px-3 py-2">{c_who}</th>
<th class="text-left font-medium px-3 py-2">{c_status}</th>
<th class="text-left font-medium px-3 py-2">{c_script}</th>
<th class="text-left font-medium px-3 py-2">{c_output}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">"##,
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##"<tr><td colspan="5" class="px-3 py-3 text-center text-xs text-slate-500">{}</td></tr>"##,
t(lang, "exec.no_history"),
);
} else {
for row in &history {
out.push_str(&render_history_row(lang, row));
}
}
out.push_str(r##" </tbody>
</table>
</div>
</section>
</div>"##);
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##"<tr id="{row_id}" {polling}>
<td class="px-3 py-2 text-xs text-slate-500 whitespace-nowrap">{when}</td>
<td class="px-3 py-2 text-xs text-slate-400">#{user}</td>
<td class="px-3 py-2 whitespace-nowrap">{status}</td>
<td class="px-3 py-2"><code class="font-mono text-xs text-slate-300">{script}</code></td>
<td class="px-3 py-2">{output}</td>
</tr>"##,
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##"<span class="inline-flex items-center gap-1 rounded border border-{b} bg-{bg} px-2 py-0.5 text-xs text-{t}">{label}{exit}</span>"##,
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##"<span class="text-xs text-slate-500">{}</span>"##,
t(lang, "exec.output_pending"),
);
}
let mut s = String::new();
if !r.stdout.is_empty() {
let _ = write!(
&mut s,
r##"<details class="text-xs"><summary class="cursor-pointer text-slate-400 hover:text-slate-200">stdout ({n} bytes)</summary>
<pre class="mt-1 max-h-80 overflow-auto rounded bg-slate-950 border border-slate-800 p-2 font-mono text-slate-300 whitespace-pre-wrap">{out}</pre>
</details>"##,
n = r.stdout.len(),
out = html_escape(&r.stdout),
);
}
if !r.stderr.is_empty() {
let _ = write!(
&mut s,
r##"<details class="text-xs mt-1"><summary class="cursor-pointer text-rose-400 hover:text-rose-300">stderr ({n} bytes)</summary>
<pre class="mt-1 max-h-80 overflow-auto rounded bg-slate-950 border border-slate-800 p-2 font-mono text-rose-300 whitespace-pre-wrap">{out}</pre>
</details>"##,
n = r.stderr.len(),
out = html_escape(&r.stderr),
);
}
if r.truncated {
let _ = write!(
&mut s,
r##"<p class="text-xs text-amber-300 mt-1">{}</p>"##,
t(lang, "exec.output_truncated"),
);
}
if s.is_empty() {
s = format!(r##"<span class="text-xs text-slate-500">{}</span>"##, 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##"<div class="rounded border border-{border} bg-{bg} p-3 text-sm text-{text}">{msg}</div>"##,
border = border,
bg = bg,
text = text,
msg = html_escape(msg),
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn require_admin(u: &AuthedUser) -> Result<(), ApiError> {
if u.is_admin {
Ok(())
} else {
Err(ApiError::Forbidden("admin required".into()))
}
}
+1
View File
@@ -6,6 +6,7 @@ pub mod audit;
pub mod connect;
pub mod deploy;
pub mod devices;
pub mod exec;
pub mod groups;
pub mod profile;
pub mod shared;