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
+92 -3
View File
@@ -1,11 +1,12 @@
# Agent API authentication # Agent API authentication
Reference for the per-device signature gate on the agent-facing HTTP 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/heartbeat`
- `POST /api/sysinfo` - `POST /api/sysinfo`
- `POST /api/unattended-password` - `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 For the operator workflow — turning it on, the dashboard toggle, what
happens when a managed agent is uninstalled — see the matching section 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 with sk ───── POST /api/heartbeat ─────► against peer.pk
───── POST /api/sysinfo ─────► (when peer.managed=1) ───── POST /api/sysinfo ─────► (when peer.managed=1)
───── POST /api/unattended-password ─────► ───── POST /api/unattended-password ─────►
───── POST /api/agent/exec-result ─────► (always required)
``` ```
The same secret key signs both the rendezvous identity proof and the 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 | | 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. | | | 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 ## File map
Server: Server:
@@ -220,24 +302,31 @@ Server:
|-------------------------------------------|------------------------------------------------------------------| |-------------------------------------------|------------------------------------------------------------------|
| `src/api/device_auth.rs` | The verifier (extractor + replay cache + TOFU promote). | | `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/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/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/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::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::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: Agent — hello-agent vendor tree:
| Path | Purpose | | Path | Purpose |
|------------------------------------------------------------|---------------------------------------------------------------| |------------------------------------------------------------|---------------------------------------------------------------|
| `vendor/rustdesk/src/hbbs_http/sign.rs` | The signer. | | `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/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): Agent — hello-agent crate (outside the vendor tree):
| Path | Purpose | | 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 ## Out of scope
+41
View File
@@ -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) ## Agent API signing (per-peer)
`POST /api/heartbeat`, `POST /api/sysinfo`, and `POST /api/heartbeat`, `POST /api/sysinfo`, and
+1
View File
@@ -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-permanent-password` | `Y`/`N` | Prevent the user changing the permanent password |
| `disable-change-id` | `Y`/`N` | Prevent the user changing the device ID | | `disable-change-id` | `Y`/`N` | Prevent the user changing the device ID |
| `disable-unlock-pin` | `Y`/`N` | Disable the unlock PIN feature | | `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 ### Network & connectivity
+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.", "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.", "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" => ( "devices.back" => (
"← Back to devices", "← Back to devices",
"← Zurück zu Geräten", "← 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", "/admin/pages/devices/:peer_id/toggle-managed",
post(pages::devices::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 // Groups
.route("/admin/pages/groups/create", post(pages::groups::create)) .route("/admin/pages/groups/create", post(pages::groups::create))
.route("/admin/pages/groups/:id/delete", post(pages::groups::delete)) .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"> hx-target="#devices-region" hx-swap="innerHTML">
{details} {details}
</button> </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" /> <hr class="border-slate-700 my-1" />
{toggle_managed_item} {toggle_managed_item}
<hr class="border-slate-700 my-1" /> <hr class="border-slate-700 my-1" />
@@ -504,6 +509,7 @@ fn render_device_row(
toggle_managed_item = toggle_managed_item, toggle_managed_item = toggle_managed_item,
connect_web = t(lang, "devices.connect_web"), connect_web = t(lang, "devices.connect_web"),
details = t(lang, "devices.details"), details = t(lang, "devices.details"),
run_command = t(lang, "devices.run_command"),
confirm_disc = html_escape(&tf1(lang, "devices.confirm_disconnect", &d.id)), confirm_disc = html_escape(&tf1(lang, "devices.confirm_disconnect", &d.id)),
force_disc = t(lang, "devices.force_disconnect"), force_disc = t(lang, "devices.force_disconnect"),
force_sysinfo = t(lang, "devices.force_sysinfo"), 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 connect;
pub mod deploy; pub mod deploy;
pub mod devices; pub mod devices;
pub mod exec;
pub mod groups; pub mod groups;
pub mod profile; pub mod profile;
pub mod shared; pub mod shared;
+89
View File
@@ -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<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Result<String, ApiError> {
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())
}
+57 -1
View File
@@ -3,7 +3,10 @@
//! combination: //! combination:
//! - `sysinfo: true` — force the client to re-upload sysinfo immediately, //! - `sysinfo: true` — force the client to re-upload sysinfo immediately,
//! - `disconnect: [conn_id, ...]` — tell the client to drop those sessions, //! - `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` + //! Auth: signed agents (peer.managed=1) must carry `X-RD-Device-Id` +
//! `X-RD-Signature` headers — see `device_auth::verify`. Stock clients //! `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. /// client re-merges `strategy.config_options` into local config.
pub modified_at: i64, pub modified_at: i64,
pub strategy: Value, 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<ExecRequest>,
} }
/// 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( pub async fn heartbeat(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
@@ -110,10 +138,38 @@ pub async fn heartbeat(
// Strategy resolution (peer > device-group > user, highest priority wins). // Strategy resolution (peer > device-group > user, highest priority wins).
let (modified_at, strategy) = strategy::resolve_for(&state, &body.id).await; 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::<Vec<_>>()
})
.unwrap_or_else(|e| {
hbb_common::log::warn!("exec_pop_queued_for_peer({}) failed: {}", body.id, e);
Vec::new()
});
Ok(Json(HeartbeatResp { Ok(Json(HeartbeatResp {
sysinfo: if force_sysinfo { Some(true) } else { None }, sysinfo: if force_sysinfo { Some(true) } else { None },
disconnect, disconnect,
modified_at, modified_at,
strategy, strategy,
exec,
})) }))
} }
+2
View File
@@ -5,6 +5,7 @@
pub mod ab; pub mod ab;
pub mod admin; pub mod admin;
pub mod agent_exec;
pub mod audit; pub mod audit;
pub mod auth; pub mod auth;
pub mod device_auth; pub mod device_auth;
@@ -49,6 +50,7 @@ pub fn router(state: Arc<AppState>) -> Router {
.route("/api/heartbeat", post(heartbeat::heartbeat)) .route("/api/heartbeat", post(heartbeat::heartbeat))
.route("/api/sysinfo_ver", post(sysinfo::sysinfo_ver)) .route("/api/sysinfo_ver", post(sysinfo::sysinfo_ver))
.route("/api/sysinfo", post(sysinfo::sysinfo)) .route("/api/sysinfo", post(sysinfo::sysinfo))
.route("/api/agent/exec-result", post(agent_exec::exec_result))
.route( .route(
"/api/unattended-password", "/api/unattended-password",
post(unattended::unattended_password), post(unattended::unattended_password),
+18
View File
@@ -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)
}
+229
View File
@@ -232,6 +232,53 @@ pub struct DashboardDeviceRow {
pub managed: bool, 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<i64>,
pub stdout: String,
pub stderr: String,
pub started_at: Option<i64>,
pub completed_at: Option<i64>,
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::<i64, _>("timed_out").unwrap_or(0) != 0,
truncated: r.try_get::<i64, _>("truncated").unwrap_or(0) != 0,
}
}
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct PeerListRow { pub struct PeerListRow {
pub id: String, pub id: String,
@@ -391,6 +438,12 @@ impl Database {
.execute(self.pool.get().await?.deref_mut()) .execute(self.pool.get().await?.deref_mut())
.await?; .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 // Soft-ALTERs run after schema creation. SQLite < 3.35 lacks
// `ADD COLUMN IF NOT EXISTS`; swallow the duplicate-column error // `ADD COLUMN IF NOT EXISTS`; swallow the duplicate-column error
// so re-runs are idempotent. Newly-added soft alters get appended // so re-runs are idempotent. Newly-added soft alters get appended
@@ -3091,6 +3144,156 @@ impl Database {
.await?; .await?;
Ok(()) 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<Option<ExecRow>> {
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<Vec<ExecQueued>> {
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<ExecQueued> = 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<bool> {
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<i64> {
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<Vec<ExecRow>> {
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 /// 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)", "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)] #[cfg(test)]
mod tests { mod tests {
use hbb_common::tokio; use hbb_common::tokio;