This commit is contained in:
+92
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -117,6 +117,14 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
|
||||
"/admin/pages/devices/:peer_id/toggle-managed",
|
||||
post(pages::devices::toggle_managed),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/devices/:peer_id/exec",
|
||||
get(pages::exec::index).post(pages::exec::dispatch),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/devices/:peer_id/exec/:cmd_id/poll",
|
||||
get(pages::exec::poll),
|
||||
)
|
||||
// Groups
|
||||
.route("/admin/pages/groups/create", post(pages::groups::create))
|
||||
.route("/admin/pages/groups/:id/delete", post(pages::groups::delete))
|
||||
|
||||
@@ -461,6 +461,11 @@ fn render_device_row(
|
||||
hx-target="#devices-region" hx-swap="innerHTML">
|
||||
{details}
|
||||
</button>
|
||||
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
|
||||
hx-get="/admin/pages/devices/{id}/exec"
|
||||
hx-target="#devices-region" hx-swap="innerHTML">
|
||||
{run_command}
|
||||
</button>
|
||||
<hr class="border-slate-700 my-1" />
|
||||
{toggle_managed_item}
|
||||
<hr class="border-slate-700 my-1" />
|
||||
@@ -504,6 +509,7 @@ fn render_device_row(
|
||||
toggle_managed_item = toggle_managed_item,
|
||||
connect_web = t(lang, "devices.connect_web"),
|
||||
details = t(lang, "devices.details"),
|
||||
run_command = t(lang, "devices.run_command"),
|
||||
confirm_disc = html_escape(&tf1(lang, "devices.confirm_disconnect", &d.id)),
|
||||
force_disc = t(lang, "devices.force_disconnect"),
|
||||
force_sysinfo = t(lang, "devices.force_sysinfo"),
|
||||
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
fn require_admin(u: &AuthedUser) -> Result<(), ApiError> {
|
||||
if u.is_admin {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::Forbidden("admin required".into()))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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<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(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
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::<Vec<_>>()
|
||||
})
|
||||
.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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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<AppState>) -> 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),
|
||||
|
||||
@@ -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
@@ -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<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)]
|
||||
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<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
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user