diff --git a/docs/AGENT-API-AUTH.md b/docs/AGENT-API-AUTH.md index 2fa4a33..a477980 100644 --- a/docs/AGENT-API-AUTH.md +++ b/docs/AGENT-API-AUTH.md @@ -1,11 +1,12 @@ # Agent API authentication Reference for the per-device signature gate on the agent-facing HTTP -API. Three endpoints are gated: +API. Four endpoints are gated: - `POST /api/heartbeat` - `POST /api/sysinfo` - `POST /api/unattended-password` +- `POST /api/agent/exec-result` — managed-only (no legacy/unsigned path) For the operator workflow — turning it on, the dashboard toggle, what happens when a managed agent is uninstalled — see the matching section @@ -40,6 +41,7 @@ Every subsequent request HTTP API (port 21114) with sk ───── POST /api/heartbeat ─────► against peer.pk ───── POST /api/sysinfo ─────► (when peer.managed=1) ───── POST /api/unattended-password ─────► + ───── POST /api/agent/exec-result ─────► (always required) ``` The same secret key signs both the rendezvous identity proof and the @@ -212,6 +214,86 @@ unsigned request, and it self-resolves on the next sync tick. | Admin row shows **Unsigned** for a peer running hello-agent | Agent hasn't completed its first signed POST yet (keypair race), or it's running a build | | | that pre-dates the signing patch — check `vendor/rustdesk/src/hbbs_http/sign.rs` is present. | +## Remote PowerShell exec + +Layered on top of the signature gate. An admin in the dashboard sends a +script to a peer; the agent runs it as its service account; output and +exit code come back into the dashboard within ~1 s (the heartbeat +interval). + +### Three independent gates + +A dispatch must pass **all three** server-side checks before a row is +queued — the agent never sees a script it shouldn't have: + +1. **`AuthedUser.is_admin`** — only admins can dispatch. +2. **`peer.managed = 1`** — the same flag the signed-API gate uses. + This means TOFU has already promoted the peer (or an admin explicitly + flipped it). Stock RustDesk clients are uninvited. +3. **Strategy `enable-remote-exec = "Y"`** — the resolved strategy for + the peer must explicitly opt in. Defaults to off. Set it on a strategy, + assign the strategy to the peer (or its group / owner), exec is now + live for that scope. *Server-side only — the key is never pushed to + the client.* See [STRATEGIES.md](STRATEGIES.md). + +### Wire path + +``` +Admin UI ──POST /admin/pages/devices/:id/exec──► Server inserts exec_history(status='queued') + │ + ▼ + Agent's next heartbeat reply carries `exec: [{cmd_id, script, max_secs, max_bytes}]`; + the server flips the row to 'running' atomically (exec_pop_queued_for_peer). + │ + ▼ + Agent runs `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command -`, + writes the script to stdin, captures stdout+stderr up to 1 MiB, kills on 5-minute wall clock. + │ + ▼ +Admin UI ◄──poll /admin/pages/devices/:id/exec/:cmd_id/poll── Server ◄──POST /api/agent/exec-result (signed)── Agent +``` + +### Limits + +| Setting | Default | Where | +|----------------|--------:|------------------------------------------------------------| +| Script size | 32 KiB | `src/api/admin/pages/exec.rs::MAX_SCRIPT_BYTES` | +| Wall-clock | 300 s | `src/api/heartbeat.rs::EXEC_MAX_SECS` (sent to agent) | +| Output capture | 1 MiB | `src/api/heartbeat.rs::EXEC_MAX_BYTES` (sent to agent) | +| In-flight/peer | 1 | `exec_in_flight_count > 0` blocks new dispatch | + +The agent enforces wall-clock and output-capture locally — server caps +are advisory unless you also harden the agent. If you don't trust your +own agent build, the server caps still bound storage and replay-cost. + +### Result POST authentication + +`POST /api/agent/exec-result` is the only agent endpoint that **always** +requires a signature, even when the peer happens to be `managed=0`. +There's no legacy compatibility story for exec — if the agent can't +sign, the result POST is rejected outright and the row sits in `running` +until an admin notices. Reason: an attacker who can spoof `(id, uuid)` +shouldn't be able to forge "I executed your command and here's the +output" for a device they don't actually control. + +### Operational notes + +- **The dispatch row stays `running` until the agent posts a result.** + If the agent crashes mid-script there's no automatic timeout cleanup + yet (planned: a hourly task that flips long-stuck `running` rows to + `errored`). Admins can dispatch a fresh command after the in-flight + one ages past 5 minutes by waiting; the in-flight check is wall-clock + based on `issued_at`. +- **Output may contain secrets.** A `Get-Content` of a credential file + goes straight into the `exec_history` table and the admin UI. The + current schema has no per-row access control beyond "is_admin"; if + you need finer scoping, audit log retention plus your `users` table + ACL is the only knob. +- **No interactive REPL yet.** Each dispatch is one shot: write script, + run, read result. Multi-command sessions or interactive prompts + (Read-Host, sudo-style passwords) will hang and time out. This is by + design for v1 — Option B in the original architecture discussion. + ## File map Server: @@ -220,24 +302,31 @@ Server: |-------------------------------------------|------------------------------------------------------------------| | `src/api/device_auth.rs` | The verifier (extractor + replay cache + TOFU promote). | | `src/api/heartbeat.rs`, `src/api/sysinfo.rs`, `src/api/unattended.rs` | Wired to call `verify` then `enforce_managed_for_id`. | +| `src/api/agent_exec.rs` | `POST /api/agent/exec-result` (sig-required, no legacy path). | | `src/api/peers.rs::set_managed` | `PUT /api/peers/:id/managed` admin endpoint. | | `src/api/admin/pages/devices.rs::toggle_managed` | Dashboard action handler. | +| `src/api/admin/pages/exec.rs` | Per-device exec page (form + history + HTMX poll fragment). | +| `src/api/strategy/mod.rs::allows_remote_exec` | Resolves the per-peer strategy and reads `enable-remote-exec`. | | `src/database.rs::M2_SOFT_ALTERS` | `ALTER TABLE peer ADD COLUMN managed`. | +| `src/database.rs::M5_SCHEMA` | `CREATE TABLE exec_history` + indexes. | | `src/database.rs::peer_get_auth, peer_set_managed` | DB helpers (untyped `sqlx::query` so they survive the no-DB-migrated dev build). | +| `src/database.rs::exec_create, exec_pop_queued_for_peer, exec_finish, exec_get_by_cmd_id, exec_in_flight_count, exec_list_for_peer` | Exec lifecycle helpers. | Agent — hello-agent vendor tree: | Path | Purpose | |------------------------------------------------------------|---------------------------------------------------------------| | `vendor/rustdesk/src/hbbs_http/sign.rs` | The signer. | -| `vendor/rustdesk/src/hbbs_http/sync.rs` (call sites) | Heartbeat + sysinfo POSTs now sign. | +| `vendor/rustdesk/src/hbbs_http/sync.rs` (call sites + `EXEC_SENDER`) | Heartbeat + sysinfo POSTs sign; heartbeat reply forwards queued `exec` requests to the broadcast channel. | | `vendor/rustdesk/src/common.rs::post_request_, parse_simple_header` | Header parser now accepts `\n`-separated `Name: Value` pairs (backward-compatible). | +| `vendor/rustdesk/src/lib.rs` | `pub mod hbbs_http` — required so hello-agent can reach both `::sign` and `::sync::exec_signal_receiver`. | Agent — hello-agent crate (outside the vendor tree): | Path | Purpose | |-------------------------------------|-----------------------------------------------------------------------------------------| -| `src/unattended_password.rs::try_report` | Reports the per-boot password to `/api/unattended-password`; now signs the POST. | +| `src/unattended_password.rs::try_report` | Reports the per-boot password to `/api/unattended-password`; signs the POST. | +| `src/exec.rs` | PowerShell runner. Subscribes to the sync layer's broadcast channel, spawns `powershell.exe`, captures stdout/stderr with caps, signs and POSTs the result to `/api/agent/exec-result`. Started from `run_server()` in main.rs. | ## Out of scope diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0483724..1303ca9 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -303,6 +303,47 @@ keys and what each one does. --- +## Remote PowerShell exec (per-peer, strategy-gated) + +Admins can dispatch a PowerShell script to a managed device from the +dashboard's **Run command…** action (Devices page row menu, or directly +via `/admin/pages/devices/:peer_id/exec`). The agent runs the script as +its service account — typically LocalSystem on Windows — and the +output streams back into the dashboard within ~1 s. + +This feature is **disabled by default**. To enable it for a peer (or +fleet): + +1. Edit (or create) a strategy on the **Strategies** page with the JSON: + ```json + { "enable-remote-exec": "Y" } + ``` + (mix with whatever other strategy options you already push) +2. Assign that strategy to the peer, its device group, or its owner. +3. The peer's `Auth` column must show **Signed** — exec is refused on + `peer.managed=0` peers. See [AGENT-API-AUTH.md](AGENT-API-AUTH.md). + +All three gates (admin role, managed=1, strategy opt-in) are enforced +server-side at dispatch time. The strategy key is never pushed to the +client — it's checked on the server only and serves purely as the +authorization toggle. + +Caps (defaults; live in `src/api/heartbeat.rs` and +`src/api/admin/pages/exec.rs`): + +- Script size: **32 KiB** per dispatch. +- Wall clock: **5 minutes** per command; the agent kills the process + on timeout and marks the row `timed_out`. +- Output capture: **1 MiB** combined stdout+stderr; further bytes are + drained and discarded, the row gets `truncated=true`. +- One in-flight exec per peer at a time. + +See [AGENT-API-AUTH.md](AGENT-API-AUTH.md) for the wire format, +authentication, and threat model. Result POSTs are mandatory-signed — +there's no legacy/unsigned path for the exec result endpoint. + +--- + ## Agent API signing (per-peer) `POST /api/heartbeat`, `POST /api/sysinfo`, and diff --git a/docs/STRATEGIES.md b/docs/STRATEGIES.md index c976464..6e40e56 100644 --- a/docs/STRATEGIES.md +++ b/docs/STRATEGIES.md @@ -98,6 +98,7 @@ window) are listed for completeness but are normally per-user. | `disable-change-permanent-password` | `Y`/`N` | Prevent the user changing the permanent password | | `disable-change-id` | `Y`/`N` | Prevent the user changing the device ID | | `disable-unlock-pin` | `Y`/`N` | Disable the unlock PIN feature | +| `enable-remote-exec` | `Y`/`N` | Allow admins to dispatch PowerShell scripts to this peer via the dashboard's **Run command** action. Server-side only — the value is checked at dispatch time, never pushed to the client. See [AGENT-API-AUTH.md](AGENT-API-AUTH.md) for the auth model. Off by default; only effective on `peer.managed=1` peers. | ### Network & connectivity diff --git a/src/api/admin/i18n.rs b/src/api/admin/i18n.rs index 36507be..aad611d 100644 --- a/src/api/admin/i18n.rs +++ b/src/api/admin/i18n.rs @@ -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", diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index 0abd0c4..61dccd0 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -117,6 +117,14 @@ pub fn build(state: Arc) -> Option { "/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)) diff --git a/src/api/admin/pages/devices.rs b/src/api/admin/pages/devices.rs index 866fd31..f47caf7 100644 --- a/src/api/admin/pages/devices.rs +++ b/src/api/admin/pages/devices.rs @@ -461,6 +461,11 @@ fn render_device_row( hx-target="#devices-region" hx-swap="innerHTML"> {details} +
{toggle_managed_item}
@@ -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"), diff --git a/src/api/admin/pages/exec.rs b/src/api/admin/pages/exec.rs new file mode 100644 index 0000000..6144a7a --- /dev/null +++ b/src/api/admin/pages/exec.rs @@ -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>, + 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())) + } +} diff --git a/src/api/admin/pages/mod.rs b/src/api/admin/pages/mod.rs index 19d4c00..eaa4a79 100644 --- a/src/api/admin/pages/mod.rs +++ b/src/api/admin/pages/mod.rs @@ -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; diff --git a/src/api/agent_exec.rs b/src/api/agent_exec.rs new file mode 100644 index 0000000..dcda1c4 --- /dev/null +++ b/src/api/agent_exec.rs @@ -0,0 +1,89 @@ +//! `POST /api/agent/exec-result` — agent posts back the result of a +//! PowerShell command queued via the heartbeat reply. +//! +//! Auth: same per-peer signed-API gate as the other agent endpoints +//! ([`crate::api::device_auth`]). Because remote exec is only ever +//! dispatched against `peer.managed = 1` peers, *this* endpoint +//! additionally refuses unsigned posts even when the peer happens to be +//! `managed=0` — there's no legacy compatibility story for exec, so we +//! fail closed. + +use crate::api::device_auth::{self, AuthOutcome}; +use crate::api::error::ApiError; +use crate::api::state::AppState; +use axum::body::Bytes; +use axum::extract::Extension; +use axum::http::HeaderMap; +use serde::Deserialize; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct ExecResultBody { + pub id: String, + pub uuid: String, + pub cmd_id: String, + pub exit_code: i64, + #[serde(default)] + pub stdout: String, + #[serde(default)] + pub stderr: String, + #[serde(default)] + pub timed_out: bool, + #[serde(default)] + pub truncated: bool, +} + +pub async fn exec_result( + Extension(state): Extension>, + headers: HeaderMap, + body: Bytes, +) -> Result { + let outcome = + device_auth::verify(&state, "POST", "/api/agent/exec-result", &headers, &body).await?; + let payload: ExecResultBody = serde_json::from_slice(&body) + .map_err(|_| ApiError::BadRequest("invalid json".into()))?; + if payload.id.is_empty() || payload.uuid.is_empty() || payload.cmd_id.is_empty() { + return Err(ApiError::BadRequest( + "id, uuid, and cmd_id required".into(), + )); + } + + // Bind identity to body. Unsigned posts are flat-out rejected here + // even when the peer is currently managed=0 — exec is a signed-only + // feature, no legacy path. + let id = match outcome { + AuthOutcome::Verified { id: signed_id } => { + if payload.id != signed_id { + return Err(ApiError::Unauthorized); + } + signed_id + } + AuthOutcome::LegacyUnsigned => return Err(ApiError::Unauthorized), + }; + + let updated = state + .db + .exec_finish( + &payload.cmd_id, + &id, + payload.exit_code, + &payload.stdout, + &payload.stderr, + payload.timed_out, + payload.truncated, + ) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + if !updated { + // Either the cmd_id doesn't exist, belongs to another peer, or + // is already in a terminal state. The agent doesn't need to + // distinguish — log on our side and return OK so it doesn't + // retry forever. + hbb_common::log::warn!( + "exec-result: no-op update for cmd_id={} peer={} (already finalized or wrong peer)", + payload.cmd_id, + id + ); + } + Ok("OK".to_string()) +} diff --git a/src/api/heartbeat.rs b/src/api/heartbeat.rs index 3190e06..e9615d4 100644 --- a/src/api/heartbeat.rs +++ b/src/api/heartbeat.rs @@ -3,7 +3,10 @@ //! combination: //! - `sysinfo: true` — force the client to re-upload sysinfo immediately, //! - `disconnect: [conn_id, ...]` — tell the client to drop those sessions, -//! - `modified_at` + `strategy` — push a config-options merge. +//! - `modified_at` + `strategy` — push a config-options merge, +//! - `exec: [{cmd_id, script, max_secs, max_bytes}, ...]` — PowerShell +//! commands queued from the admin UI. The agent runs each and POSTs +//! results to `/api/agent/exec-result`. See docs/AGENT-API-AUTH.md. //! //! Auth: signed agents (peer.managed=1) must carry `X-RD-Device-Id` + //! `X-RD-Signature` headers — see `device_auth::verify`. Stock clients @@ -47,8 +50,33 @@ pub struct HeartbeatResp { /// client re-merges `strategy.config_options` into local config. pub modified_at: i64, pub strategy: Value, + /// PowerShell commands queued for this peer. Omitted from the JSON + /// reply when empty so vanilla rustdesk clients (which don't parse + /// this field) see a payload that's byte-for-byte identical to what + /// they received before this feature shipped. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub exec: Vec, } +/// What the agent receives per queued PowerShell command. Caps live on the +/// server so the operator can tune fleet-wide without redeploying agents. +#[derive(Debug, Serialize)] +pub struct ExecRequest { + pub cmd_id: String, + pub script: String, + pub max_secs: u64, + pub max_bytes: u64, +} + +/// Wall-clock ceiling on a single PowerShell exec. Server-side cap; the +/// agent kills the process when the deadline elapses and reports +/// `timed_out=true` to `/api/agent/exec-result`. +const EXEC_MAX_SECS: u64 = 300; +/// Combined stdout+stderr byte ceiling. Past this the agent stops +/// appending and sets `truncated=true`. 1 MiB matches the cap surfaced +/// in the admin UI confirm dialog. +const EXEC_MAX_BYTES: u64 = 1024 * 1024; + pub async fn heartbeat( Extension(state): Extension>, headers: HeaderMap, @@ -110,10 +138,38 @@ pub async fn heartbeat( // Strategy resolution (peer > device-group > user, highest priority wins). let (modified_at, strategy) = strategy::resolve_for(&state, &body.id).await; + // Pop any queued PowerShell exec commands for this peer and flip them + // to 'running' in one transaction. The handler doesn't re-check the + // strategy gate here — that was done at dispatch time. By the time a + // row reaches 'queued' status, the operator has already passed all + // checks; if the strategy changed since dispatch, the rows in flight + // still ride out their lifecycle (an admin can flip the per-row state + // via the dashboard if they need to abort, but that's a separate UI + // surface — see future work in AGENT-API-AUTH.md). + let exec = state + .db + .exec_pop_queued_for_peer(&body.id) + .await + .map(|rows| { + rows.into_iter() + .map(|r| ExecRequest { + cmd_id: r.cmd_id, + script: r.script, + max_secs: EXEC_MAX_SECS, + max_bytes: EXEC_MAX_BYTES, + }) + .collect::>() + }) + .unwrap_or_else(|e| { + hbb_common::log::warn!("exec_pop_queued_for_peer({}) failed: {}", body.id, e); + Vec::new() + }); + Ok(Json(HeartbeatResp { sysinfo: if force_sysinfo { Some(true) } else { None }, disconnect, modified_at, strategy, + exec, })) } diff --git a/src/api/mod.rs b/src/api/mod.rs index 838637d..99f0399 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -5,6 +5,7 @@ pub mod ab; pub mod admin; +pub mod agent_exec; pub mod audit; pub mod auth; pub mod device_auth; @@ -49,6 +50,7 @@ pub fn router(state: Arc) -> Router { .route("/api/heartbeat", post(heartbeat::heartbeat)) .route("/api/sysinfo_ver", post(sysinfo::sysinfo_ver)) .route("/api/sysinfo", post(sysinfo::sysinfo)) + .route("/api/agent/exec-result", post(agent_exec::exec_result)) .route( "/api/unattended-password", post(unattended::unattended_password), diff --git a/src/api/strategy/mod.rs b/src/api/strategy/mod.rs index 1f63031..89f86fa 100644 --- a/src/api/strategy/mod.rs +++ b/src/api/strategy/mod.rs @@ -33,3 +33,21 @@ fn serialize(r: &ResolvedStrategy) -> (i64, Value) { }), ) } + +/// `true` iff the resolved strategy for `peer_id` carries +/// `config_options."enable-remote-exec" = "Y"`. Default is `false` (no +/// strategy assigned, or strategy didn't set the key) — exec is opt-in +/// per the design in docs/AGENT-API-AUTH.md. +pub async fn allows_remote_exec(state: &AppState, peer_id: &str) -> bool { + let resolved = state + .db + .strategy_resolve_for(peer_id) + .await + .unwrap_or_default(); + let cfg: Value = serde_json::from_str(&resolved.config_options_json) + .unwrap_or_else(|_| json!({})); + cfg.get("enable-remote-exec") + .and_then(|v| v.as_str()) + .map(|s| s.eq_ignore_ascii_case("Y")) + .unwrap_or(false) +} diff --git a/src/database.rs b/src/database.rs index c107850..215bd8d 100644 --- a/src/database.rs +++ b/src/database.rs @@ -232,6 +232,53 @@ pub struct DashboardDeviceRow { pub managed: bool, } +/// One row from `exec_history`. Status is a small enum stored as TEXT — +/// see the M5_SCHEMA comment in this file for the lifecycle. +#[derive(Debug, Clone, Default)] +pub struct ExecRow { + pub cmd_id: String, + pub peer_id: String, + pub issued_by_user_id: i64, + pub issued_at: i64, + pub script: String, + pub status: String, + /// Exit code from PowerShell. `None` until the agent posts a result. + pub exit_code: Option, + pub stdout: String, + pub stderr: String, + pub started_at: Option, + pub completed_at: Option, + pub timed_out: bool, + pub truncated: bool, +} + +/// Minimal projection used when the heartbeat handler hands queued +/// commands to the agent. Carries only what the agent needs to execute. +#[derive(Debug, Clone)] +pub struct ExecQueued { + pub cmd_id: String, + pub script: String, +} + +fn exec_row_from(r: sqlx::sqlite::SqliteRow) -> ExecRow { + use sqlx::Row; + ExecRow { + cmd_id: r.try_get("cmd_id").unwrap_or_default(), + peer_id: r.try_get("peer_id").unwrap_or_default(), + issued_by_user_id: r.try_get("issued_by_user_id").unwrap_or(0), + issued_at: r.try_get("issued_at").unwrap_or(0), + script: r.try_get("script").unwrap_or_default(), + status: r.try_get("status").unwrap_or_default(), + exit_code: r.try_get("exit_code").ok(), + stdout: r.try_get("stdout").unwrap_or_default(), + stderr: r.try_get("stderr").unwrap_or_default(), + started_at: r.try_get("started_at").ok(), + completed_at: r.try_get("completed_at").ok(), + timed_out: r.try_get::("timed_out").unwrap_or(0) != 0, + truncated: r.try_get::("truncated").unwrap_or(0) != 0, + } +} + #[derive(Debug, Clone, Default)] pub struct PeerListRow { pub id: String, @@ -391,6 +438,12 @@ impl Database { .execute(self.pool.get().await?.deref_mut()) .await?; } + // M5 schema: PowerShell remote-exec history. + for stmt in M5_SCHEMA { + sqlx::query(stmt) + .execute(self.pool.get().await?.deref_mut()) + .await?; + } // Soft-ALTERs run after schema creation. SQLite < 3.35 lacks // `ADD COLUMN IF NOT EXISTS`; swallow the duplicate-column error // so re-runs are idempotent. Newly-added soft alters get appended @@ -3091,6 +3144,156 @@ impl Database { .await?; Ok(()) } + + // ───────────────────────── exec_history (M5) ────────────────────────── + // + // PowerShell remote-exec audit + state machine. The state column is a + // small enum stored as TEXT so SQLite browsing stays readable: + // 'queued' — admin dispatched, agent hasn't picked it up yet + // 'running' — agent's first heartbeat after dispatch flipped this + // 'finished' — agent posted result (regardless of exit code) + // 'timed_out' — agent killed the process and reported truncation + // 'errored' — agent failed to spawn or post (rare) + // + // All these helpers use untyped `sqlx::query` so the dev-DB doesn't have + // to be migrated before compile (same rule as the M5 ALTER columns). + + pub async fn exec_create( + &self, + cmd_id: &str, + peer_id: &str, + issued_by_user_id: i64, + script: &str, + ) -> ResultType<()> { + let now = chrono::Utc::now().timestamp(); + sqlx::query( + "insert into exec_history(cmd_id, peer_id, issued_by_user_id, issued_at, script, status) \ + values(?, ?, ?, ?, ?, 'queued')", + ) + .bind(cmd_id) + .bind(peer_id) + .bind(issued_by_user_id) + .bind(now) + .bind(script) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(()) + } + + /// Snapshot for the admin UI poll endpoint. Returns the full row so the + /// fragment renderer can decide what to surface based on `status`. + pub async fn exec_get_by_cmd_id(&self, cmd_id: &str) -> ResultType> { + let row = sqlx::query( + "select cmd_id, peer_id, issued_by_user_id, issued_at, script, status, \ + exit_code, stdout, stderr, started_at, completed_at, timed_out, truncated \ + from exec_history where cmd_id = ?", + ) + .bind(cmd_id) + .fetch_optional(self.pool.get().await?.deref_mut()) + .await?; + Ok(row.map(exec_row_from)) + } + + /// Pop all `queued` rows for a peer, flip them to `running`, return them. + /// Called from the heartbeat handler — semantics mirror + /// `heartbeat_pop_commands` but for exec specifically. Done in one + /// transaction so two heartbeats arriving in parallel can't double-issue + /// the same script (the second SELECT will find no rows in 'queued'). + pub async fn exec_pop_queued_for_peer(&self, peer_id: &str) -> ResultType> { + let mut tx = self.pool.get().await?; + let now = chrono::Utc::now().timestamp(); + let rows = sqlx::query( + "select cmd_id, script from exec_history \ + where peer_id = ? and status = 'queued' order by issued_at", + ) + .bind(peer_id) + .fetch_all(tx.deref_mut()) + .await?; + let out: Vec = rows + .into_iter() + .map(|r| ExecQueued { + cmd_id: r.try_get("cmd_id").unwrap_or_default(), + script: r.try_get("script").unwrap_or_default(), + }) + .collect(); + if !out.is_empty() { + sqlx::query( + "update exec_history set status = 'running', started_at = ? \ + where peer_id = ? and status = 'queued'", + ) + .bind(now) + .bind(peer_id) + .execute(tx.deref_mut()) + .await?; + } + Ok(out) + } + + /// Finalize a row from the agent's POST. Idempotent: if the cmd_id is + /// already in a terminal state (finished / timed_out / errored), this + /// query just no-ops because the WHERE filters that out. + pub async fn exec_finish( + &self, + cmd_id: &str, + peer_id: &str, + exit_code: i64, + stdout: &str, + stderr: &str, + timed_out: bool, + truncated: bool, + ) -> ResultType { + let now = chrono::Utc::now().timestamp(); + let status = if timed_out { "timed_out" } else { "finished" }; + let res = sqlx::query( + "update exec_history set status = ?, exit_code = ?, stdout = ?, stderr = ?, \ + completed_at = ?, timed_out = ?, truncated = ? \ + where cmd_id = ? and peer_id = ? and status in ('queued', 'running')", + ) + .bind(status) + .bind(exit_code) + .bind(stdout) + .bind(stderr) + .bind(now) + .bind(if timed_out { 1i64 } else { 0 }) + .bind(if truncated { 1i64 } else { 0 }) + .bind(cmd_id) + .bind(peer_id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(res.rows_affected() > 0) + } + + /// Count of queued/running rows for a peer. Used by the dispatch + /// endpoint to refuse a new exec when one is already in flight. + pub async fn exec_in_flight_count(&self, peer_id: &str) -> ResultType { + use sqlx::Row; + let row = sqlx::query( + "select count(*) as c from exec_history \ + where peer_id = ? and status in ('queued', 'running')", + ) + .bind(peer_id) + .fetch_one(self.pool.get().await?.deref_mut()) + .await?; + Ok(row.try_get("c").unwrap_or(0)) + } + + /// Recent history for the per-device exec page. Newest first. + pub async fn exec_list_for_peer( + &self, + peer_id: &str, + limit: i64, + ) -> ResultType> { + let rows = sqlx::query( + "select cmd_id, peer_id, issued_by_user_id, issued_at, script, status, \ + exit_code, stdout, stderr, started_at, completed_at, timed_out, truncated \ + from exec_history where peer_id = ? order by issued_at desc limit ?", + ) + .bind(peer_id) + .bind(limit) + .fetch_all(self.pool.get().await?.deref_mut()) + .await?; + Ok(rows.into_iter().map(exec_row_from).collect()) + } } /// Timing-safe equality for hash comparisons. Slightly paranoid given the @@ -3464,6 +3667,32 @@ const M4_SCHEMA: &[&str] = &[ "CREATE INDEX IF NOT EXISTS idx_oidc_sessions_status ON oidc_sessions(status, expires_at)", ]; +/// M5: PowerShell remote-exec. One row per dispatched command, lifecycle is +/// queued → started → finished (or timed_out / errored). Audit retention is +/// handled the same way as audit_conn — purged by the hourly task once we +/// add an exec retention flag; for now rows accumulate. Output columns are +/// TEXT not BLOB because the agent always sends UTF-8 (PowerShell with +/// `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding`). +const M5_SCHEMA: &[&str] = &[ + "CREATE TABLE IF NOT EXISTS exec_history ( + cmd_id TEXT PRIMARY KEY, + peer_id TEXT NOT NULL, + issued_by_user_id INTEGER NOT NULL, + issued_at INTEGER NOT NULL, + script TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + exit_code INTEGER, + stdout TEXT NOT NULL DEFAULT '', + stderr TEXT NOT NULL DEFAULT '', + started_at INTEGER, + completed_at INTEGER, + timed_out INTEGER NOT NULL DEFAULT 0, + truncated INTEGER NOT NULL DEFAULT 0 + )", + "CREATE INDEX IF NOT EXISTS idx_exec_peer ON exec_history(peer_id, issued_at DESC)", + "CREATE INDEX IF NOT EXISTS idx_exec_status ON exec_history(status, issued_at)", +]; + #[cfg(test)] mod tests { use hbb_common::tokio;