From 475da0e95003a695f89c028240e692c325310914 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Fri, 22 May 2026 12:50:42 +0200 Subject: [PATCH] Implement signed API communication to improve security --- docs/AGENT-API-AUTH.md | 254 +++++++++++++++++++++++++++++++++ docs/CONFIGURATION.md | 81 ++++++++++- src/api/admin/i18n.rs | 77 ++++++++++ src/api/admin/mod.rs | 4 + src/api/admin/pages/devices.rs | 110 +++++++++++++- src/api/device_auth.rs | 184 ++++++++++++++++++++++++ src/api/heartbeat.rs | 24 +++- src/api/mod.rs | 2 + src/api/peers.rs | 48 ++++++- src/api/sysinfo.rs | 39 ++++- src/api/unattended.rs | 52 +++++-- src/database.rs | 55 ++++++- 12 files changed, 906 insertions(+), 24 deletions(-) create mode 100644 docs/AGENT-API-AUTH.md create mode 100644 src/api/device_auth.rs diff --git a/docs/AGENT-API-AUTH.md b/docs/AGENT-API-AUTH.md new file mode 100644 index 0000000..2fa4a33 --- /dev/null +++ b/docs/AGENT-API-AUTH.md @@ -0,0 +1,254 @@ +# Agent API authentication + +Reference for the per-device signature gate on the agent-facing HTTP +API. Three endpoints are gated: + +- `POST /api/heartbeat` +- `POST /api/sysinfo` +- `POST /api/unattended-password` + +For the operator workflow — turning it on, the dashboard toggle, what +happens when a managed agent is uninstalled — see the matching section +in [CONFIGURATION.md](CONFIGURATION.md). + +## Why this exists + +All three endpoints originally accepted any caller who supplied an `id` +and `uuid` in the JSON body. Knowing those two values (plaintext on the +device, sent over the rendezvous wire) was enough to inject arbitrary +inventory or heartbeat state for that device — including BIOS serials, +BitLocker recovery keys, the active console user, network interfaces, +connection lists, and the per-boot unattended-access password the +admin UI surfaces to support staff. + +The fix reuses the Ed25519 keypair that the agent **already** generates +on first run and registers with the rendezvous server via `RegisterPk`. +Every signed HTTP request is verified against the public key the +rendezvous handshake stored in `peer.pk`, so the trust root is the same +one the relay encryption already depends on. No new credential to +provision, no new secret to leak. + +## Trust root + +``` +First run Rendezvous (port 21116, TCP/protobuf) + agent generates sk,pk ───── RegisterPk(id, pk) ─────► server stores + in hello-agent.toml peer.pk + +Every subsequent request HTTP API (port 21114) + agent signs body server verifies sig + with sk ───── POST /api/heartbeat ─────► against peer.pk + ───── POST /api/sysinfo ─────► (when peer.managed=1) + ───── POST /api/unattended-password ─────► +``` + +The same secret key signs both the rendezvous identity proof and the +HTTP-API payload — there's only one credential per device. + +## Per-peer `managed` flag + +The gate is per-device, controlled by the `peer.managed` column +(`INTEGER NOT NULL DEFAULT 0`, added by a soft `ALTER` at startup). + +| `managed` | Server behaviour | +|-----------|----------------------------------------------------------------------------------| +| `0` | Legacy path. Signed requests are still verified if present, but absence is OK. | +| `1` | Signature required. Any unsigned request claiming this `id` returns 401. | + +How the flag transitions: + +- **TOFU promote (0 → 1).** The first request that arrives with a valid + signature flips `managed` to 1. Hello-agent signs from boot one, so + the first heartbeat after a hello-agent install transparently locks + the peer down. No admin action required. +- **Admin promote (0 → 1).** `PUT /api/peers/:id/managed {"managed":true}` + or the **Require signed API** action in the dashboard's Devices row + menu. Useful for pre-enrolling a peer record before the agent has + posted anything. +- **Admin downgrade (1 → 0).** Same endpoint, `{"managed":false}`, or + **Allow unsigned API** in the dashboard. Use when the managed agent + has been replaced with stock RustDesk on that device. The dashboard + toggle requires a confirm because the operation reopens the + spoofing surface. +- **Never auto-downgraded.** A failed signature on a `managed=1` peer + is a 401, full stop — there is no "fall back to unsigned" path. +- **Invalid sig on a `managed=0` peer is also 401**, never silently + treated as legacy. This prevents an attacker from probing for the + legacy path by deliberately sending a broken signature. + +## Wire format + +A signed agent request carries two headers in addition to the JSON body: + +``` +X-RD-Device-Id: +X-RD-Signature: v1.. +``` + +The signed message is the byte concatenation: + +``` +"rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(BODY) +``` + +Where: + +- `METHOD` is the uppercase HTTP method (`POST`). +- `PATH` is the request path with leading slash and no query string + (`/api/heartbeat`, `/api/sysinfo`, `/api/unattended-password`). +- `TS` is the same decimal Unix timestamp that appears in the header. +- `sha256(BODY)` is the raw 32-byte SHA-256 of the request body — *not* + hex-encoded, *not* base64-encoded. It is concatenated as binary. +- The signature is detached Ed25519 over that 32-byte-plus-prefix + message, base64-encoded with the standard alphabet and no + URL-safe substitutions. + +The `v1.` prefix on the header value reserves a rotation point. The +server rejects any other version string. + +### Why this shape + +- **Domain separator (`rd-api-v1\n`)** prevents the same `sk` being + tricked into signing data interpretable as another protocol. +- **Method + path** stop a captured `POST /api/sysinfo` signature from + being replayed as some future `POST /api/disconnect`. +- **`sha256(body)`** lets us sign without holding the body twice in + memory on the verify side, and survives any future proxy + re-chunking. +- **Timestamp in both the header and the signed message** makes the + skew check trivial without re-parsing the signature value. + +## Server-side verification + +The extractor [`api::device_auth::verify`](../src/api/device_auth.rs) +runs before each agent handler: + +1. **Parse headers.** Both `X-RD-Device-Id` and `X-RD-Signature` must + be present, or both absent. Mixed states are 401. +2. **Validate the signature envelope.** Version must be `v1`. The + timestamp must be within ±300 seconds of the server's clock. The + base64 decode must succeed. +3. **Replay-check.** A keyed-by-`(id, ts, sig-prefix)` LRU cache (size + 16 384, sliding 600-second TTL, sweep-on-insert) rejects exact + replays inside the window. If the cache is full, we accept and skip + the cache — DoS-by-cache-exhaustion is uninteresting compared to + the rest of the surface. +4. **Look up `peer.pk` and `peer.managed`** in one query. +5. **Verify the detached Ed25519 signature** against the canonical + signed-message bytes (see *Wire format* above). +6. **TOFU promote.** A valid signature on a `managed=0` peer flips the + flag to 1 in the same request. The promote is best-effort — if the + DB write fails, the original request is still served, the next + heartbeat will retry. +7. **Bind the trusted id to the body.** After the handler parses JSON, + the body's `id` field must match the header's `X-RD-Device-Id`. + Mismatch is 401 — this is the gate that stops a signed request from + being repurposed to write to a different peer's row. + +If no signature headers are present and the peer is `managed=0`, the +verifier returns `LegacyUnsigned`; the handler then calls +`enforce_managed_for_id(body.id)` after parsing the body, which still +rejects unsigned requests for any *other* peer that has since become +managed. + +## Agent-side signing + +The signer is one small module: [`vendor/rustdesk/src/hbbs_http/sign.rs`](https://example.invalid/sign.rs) +in the hello-agent vendor tree. It reads the existing +`Config::get_key_pair()` (returns `(sk, pk)` from `hello-agent.toml`) +and the existing `Config::get_id()`, builds the canonical message, and +calls `sodiumoxide::crypto::sign::sign_detached`. Returns the two +header lines joined by `\n`, ready for the multi-header parser in +`common.rs::post_request_`. + +The agent always tries to sign. If the keypair hasn't been generated +yet (extremely early boot, before rendezvous has run), the signer +returns `None`, the request goes out unsigned, and: + +- If `peer.managed=0`: server accepts it (legacy path). +- If `peer.managed=1`: server returns 401, the agent's next heartbeat + retries. + +This is the only condition under which a hello-agent build sends an +unsigned request, and it self-resolves on the next sync tick. + +## Operational gotchas + +- **Stock RustDesk clients keep working** because they post unsigned + and their peer rows stay at `managed=0`. The first time you install + hello-agent on a device, the existing `peer.pk` row gets reused (the + agent re-generated a keypair iff `hello-agent.toml` was wiped). The + first signed heartbeat then promotes the row. +- **`hello-agent --uninstall` preserves the keypair.** A reinstall is + transparent — signing keeps working. +- **Wiping `hello-agent.toml` between sessions** does mean the next + boot generates a new keypair. The rendezvous server will treat that + as a key roll (`register_pk of … due to key not confirmed`) and + store the new `pk`. The signed HTTP API picks up the new key as soon + as that rendezvous step completes — usually within a few seconds. + See [the stale-key recovery note in hello-agent's README](https://example.invalid/README.md) + for the supporter-side symptoms of a key drift. +- **Clock skew over ±5 minutes** will reject signatures. If your + fleet shows scattered 401s on heartbeat, check NTP on the affected + hosts. The server side is the canonical clock. +- **Replay cache survives only inside a single hbbs process.** A + restart clears it. Combined with the 300-second skew window this + means a captured signature is replayable across a restart if and + only if both restarts happen inside that window — an acceptable + trade-off for keeping the cache in-memory. +- **One server, mixed fleet.** Stock clients and hello-agent clients + can target the same hbbs without any flag-level config. The gate is + per-peer. + +## Failure modes & log lines + +| Symptom | Likely cause | +|-------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------| +| Heartbeats from a known peer suddenly return 401 | Peer was just promoted (TOFU or admin) and the agent build doesn't sign yet → upgrade agent. | +| Heartbeats fail intermittently with 401 | Clock skew > 5 min, or NAT churn replaying a captured request inside the window. | +| `peer X TOFU-promoted to managed=1` in hbbs log | Normal — first valid signature from a previously-unsigned peer. | +| `admin set peer X managed= via dashboard` | Normal — operator used the Devices toggle. | +| `peer_set_managed(X) failed: …` | DB write failed during TOFU promote. The request was still served; next request will retry. | +| 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. | + +## File map + +Server: + +| Path | Purpose | +|-------------------------------------------|------------------------------------------------------------------| +| `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/peers.rs::set_managed` | `PUT /api/peers/:id/managed` admin endpoint. | +| `src/api/admin/pages/devices.rs::toggle_managed` | Dashboard action handler. | +| `src/database.rs::M2_SOFT_ALTERS` | `ALTER TABLE peer ADD COLUMN managed`. | +| `src/database.rs::peer_get_auth, peer_set_managed` | DB helpers (untyped `sqlx::query` so they survive the no-DB-migrated dev build). | + +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/common.rs::post_request_, parse_simple_header` | Header parser now accepts `\n`-separated `Name: Value` pairs (backward-compatible). | + +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. | + +## Out of scope + +Other agent / management endpoints exist on the same server. They are +deliberately *not* covered by this gate because their trust model is +different: + +| Endpoint | Why it isn't signature-gated | +|--------------------------------|-----------------------------------------------------------------------------------------------------------| +| `POST /api/devices/cli` | Enrollment via `rustdesk --assign --token …`. Already authenticated by a user/admin bearer session; the operator's job is to *supply* an arbitrary `(id, uuid)` for binding. Requiring the device's `sk` would defeat the use case. | +| `GET /api/sysinfo_ver` | Returns a single public version string. No body, no DB write — no spoof surface to gate. | +| `POST /api/record` | Session-recording upload. Disabled by default in the OSS uploader; managed builds use it under a separate auth model. Out of scope for the current sweep. | +| `POST /api/login`, `/api/login-options`, `/api/currentUser`, `/api/logout` | User session management — separate auth model (password + TOTP / OIDC). | +| Everything under `/api/ab/*`, `/api/audit/*`, `/api/peers*`, `/api/2fa/*`, `/api/oidc/*`, `/admin/*` | Already gated by `AuthedUser` (cookie or bearer). | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e8757b4..0483724 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -303,6 +303,85 @@ keys and what each one does. --- +## Agent API signing (per-peer) + +`POST /api/heartbeat`, `POST /api/sysinfo`, and +`POST /api/unattended-password` are the three agent-facing endpoints +that write per-device state. Stock RustDesk and managed builds +(hello-agent) both call the first two; only managed builds use the +third. Each peer row has a `managed` flag that gates whether the +server requires a per-request Ed25519 signature on these endpoints; +everything else (`/api/peers`, `/api/ab/*`, audit, recordings, OIDC, +etc.) is unaffected. See [AGENT-API-AUTH.md](AGENT-API-AUTH.md) for +the full out-of-scope list. + +| `peer.managed` | Heartbeat / sysinfo behaviour | +|----------------|----------------------------------------------------------------------------------------| +| `0` (default) | Unsigned posts accepted (stock-client compatible). Signed posts still verified. | +| `1` | Signature required; unsigned posts return 401. First valid sig auto-promoted to here. | + +Default is `0` after the migration, so **stock RustDesk clients are not +affected by the rollout** — they keep posting unsigned, the server keeps +accepting. The first valid signature the server sees from a peer is the +TOFU promote: that peer's `managed` flips to `1` for good, and unsigned +requests claiming that `id` are rejected from then on. + +The wire format and verification details live in +[AGENT-API-AUTH.md](AGENT-API-AUTH.md). What you need to know to operate: + +### Dashboard + +The Devices page has a per-row **Auth** column: + +- *Signed* (emerald badge) — `peer.managed = 1`. The peer's heartbeat + and sysinfo posts must carry a valid signature; spoofed unsigned + requests are rejected. +- *Unsigned* (slate badge) — `peer.managed = 0`. Legacy path. Anyone + who knows the id+uuid can post inventory and heartbeats as this + device. + +The row's action menu has two new entries (mutually exclusive based on +current state): + +- **Require signed API** — flips `managed` to 1 (no confirm — it + strengthens security). Useful for pre-enrolling a peer record + before the agent has booted, or for force-locking a peer if you + want to fail fast when an agent is not signing yet. +- **Allow unsigned API** — flips `managed` to 0 (confirm dialog, + because this reopens the spoofing surface). Use when a managed + agent has been uninstalled and replaced with stock RustDesk on the + same hardware. + +### API + +`PUT /api/peers/:id/managed` with body `{"managed": true|false}`, gated +on the `is_admin` flag of the calling session, returns +`{"ok":true,"managed":}`. Same effect as the dashboard toggle — +the dashboard handler just calls this internally after reading the +current value to avoid stale-toggle races. + +### Operational notes + +- **Mixed fleets are fine.** Stock and hello-agent clients can target + the same hbbs. The gate is per-peer, not per-deployment. +- **Replacing hello-agent with stock RustDesk on a device.** The + device's `peer.managed` is stuck at 1; the stock client doesn't + sign and will start getting 401s. Either re-deploy a signing build + *or* flip the peer back to Unsigned in the dashboard. +- **TLS still recommended.** Signing protects against id+uuid spoof, + not against the unsigned-by-default endpoint surface elsewhere + (`/api/login`, `/api/record`, dashboard) — those still rely on + whatever TLS termination is in front of hbbs. See *TLS deployment* + earlier in this doc. +- **Clock skew tolerance is ±5 minutes.** If a host's clock drifts + past that, heartbeat starts failing 401. Keep NTP healthy on + managed peers; the server's clock is the canonical one. +- **The replay cache lives in-memory only.** A hbbs restart clears + it. The 5-minute timestamp window bounds the worst-case replay + exposure across restarts. + +--- + ## Address books - **Personal books** are owned per-user and managed from the user's desktop client. The dashboard surfaces them read-only. @@ -326,7 +405,7 @@ If you set `--ab-legacy-mode=on`, `/api/ab/personal` 404s and clients fall back | `/admin/oidc/providers` | none | JSON list of enabled providers, used by login.html | | `/admin/login/oidc/:name` | none | Starts admin OIDC flow (302s to IdP) | | `/admin/pages/users` | cookie + admin | Users page fragment (incl. inline edit-profile / password-reset / TOTP-disable per row) | -| `/admin/pages/devices` | cookie + admin | Devices (incl. delete) | +| `/admin/pages/devices` | cookie + admin | Devices (incl. delete, force-disconnect, force-sysinfo, toggle managed-auth — see [AGENT-API-AUTH.md](AGENT-API-AUTH.md)) | | `/admin/pages/groups` | cookie + admin | Device groups | | `/admin/pages/strategies` | cookie + admin | Strategy management | | `/admin/pages/address-books` | cookie + admin | Personal + shared books | diff --git a/src/api/admin/i18n.rs b/src/api/admin/i18n.rs index ca7f4e5..36507be 100644 --- a/src/api/admin/i18n.rs +++ b/src/api/admin/i18n.rs @@ -801,6 +801,83 @@ pub fn t(lang: Lang, key: &str) -> &'static str { "Dispozitivul a fost deja șters.", "El dispositivo ya se eliminó.", ), + "devices.col_auth" => ( + "Auth", + "Auth", + "Auth", + "Auth", + "Auth", + ), + "devices.auth_signed" => ( + "Signed", + "Signiert", + "Signé", + "Semnat", + "Firmado", + ), + "devices.auth_signed_tooltip" => ( + "Heartbeat and sysinfo posts must carry a valid Ed25519 signature. Unsigned requests for this peer are rejected.", + "Heartbeat- und Sysinfo-Posts müssen eine gültige Ed25519-Signatur enthalten. Unsignierte Anfragen für dieses Gerät werden abgelehnt.", + "Les requêtes heartbeat et sysinfo doivent porter une signature Ed25519 valide. Les requêtes non signées pour ce pair sont rejetées.", + "Cererile heartbeat și sysinfo trebuie să poarte o semnătură Ed25519 validă. Cererile nesemnate pentru acest peer sunt respinse.", + "Las solicitudes heartbeat y sysinfo deben llevar una firma Ed25519 válida. Las solicitudes sin firma para este par se rechazan.", + ), + "devices.auth_unsigned" => ( + "Unsigned", + "Unsigniert", + "Non signé", + "Nesemnat", + "Sin firma", + ), + "devices.auth_unsigned_tooltip" => ( + "Legacy / stock-client path. The server accepts unsigned heartbeat and sysinfo posts identifying as this peer — any caller knowing the id+uuid could inject inventory. The first valid signature flips this to Signed automatically.", + "Legacy-/Stock-Client-Pfad. Der Server akzeptiert unsignierte Heartbeat- und Sysinfo-Posts mit dieser Peer-Identität — jeder Aufrufer, der id+uuid kennt, kann Inventar einschleusen. Die erste gültige Signatur schaltet automatisch auf Signiert.", + "Chemin client hérité / standard. Le serveur accepte des requêtes heartbeat et sysinfo non signées identifiées comme ce pair — tout appelant connaissant id+uuid peut injecter de l'inventaire. La première signature valide bascule automatiquement sur Signé.", + "Calea client legacy / standard. Serverul acceptă cereri heartbeat și sysinfo nesemnate care se identifică drept acest peer — orice apelant care cunoaște id+uuid poate injecta inventar. Prima semnătură validă comută automat la Semnat.", + "Ruta cliente heredada / estándar. El servidor acepta solicitudes heartbeat y sysinfo sin firmar identificadas como este par — cualquier llamador que conozca id+uuid puede inyectar inventario. La primera firma válida cambia automáticamente a Firmado.", + ), + "devices.mark_managed" => ( + "Require signed API", + "Signierte API erzwingen", + "Exiger l'API signée", + "Cere API semnat", + "Requerir API firmada", + ), + "devices.mark_unsigned" => ( + "Allow unsigned API", + "Unsignierte API erlauben", + "Autoriser l'API non signée", + "Permite API nesemnat", + "Permitir API sin firma", + ), + "devices.confirm_managed_off" => ( + "Downgrade {0} to the unsigned API path? Anyone who knows the id+uuid will again be able to post inventory and heartbeats as this device.", + "{0} auf den unsignierten API-Pfad herabstufen? Jeder, der id+uuid kennt, kann dann wieder Inventar und Heartbeats als dieses Gerät posten.", + "Rétrograder {0} vers le chemin API non signé ? Toute personne connaissant id+uuid pourra de nouveau publier de l'inventaire et des heartbeats en tant que cet appareil.", + "Retrogradați {0} la calea API nesemnat? Oricine cunoaște id+uuid va putea din nou să publice inventar și heartbeat-uri ca acest dispozitiv.", + "¿Degradar {0} a la ruta API sin firma? Cualquiera que conozca id+uuid podrá volver a publicar inventario y heartbeats como este dispositivo.", + ), + "devices.managed_now_on" => ( + "{0} now requires signed API requests.", + "{0} erfordert nun signierte API-Anfragen.", + "{0} exige désormais des requêtes API signées.", + "{0} cere acum cereri API semnate.", + "{0} ahora requiere solicitudes API firmadas.", + ), + "devices.managed_now_off" => ( + "{0} now accepts unsigned API requests.", + "{0} akzeptiert nun unsignierte API-Anfragen.", + "{0} accepte désormais des requêtes API non signées.", + "{0} acceptă acum cereri API nesemnate.", + "{0} ahora acepta solicitudes API sin firma.", + ), + "devices.managed_no_peer" => ( + "Peer {0} not found in the rendezvous identity table — cannot set managed flag. The agent must complete a rendezvous handshake first.", + "Peer {0} nicht in der Rendezvous-Identitätstabelle gefunden — Managed-Flag kann nicht gesetzt werden. Der Agent muss zuerst einen Rendezvous-Handshake abschließen.", + "Pair {0} introuvable dans la table d'identité rendezvous — impossible de définir l'indicateur managed. L'agent doit d'abord effectuer 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.", + ), "devices.back" => ( "← Back to devices", "← Zurück zu Geräten", diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index a2a8ac4..0abd0c4 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -113,6 +113,10 @@ pub fn build(state: Arc) -> Option { "/admin/pages/devices/:peer_id/delete", post(pages::devices::delete), ) + .route( + "/admin/pages/devices/:peer_id/toggle-managed", + post(pages::devices::toggle_managed), + ) // Groups .route("/admin/pages/groups/create", post(pages::groups::create)) .route("/admin/pages/groups/:id/delete", post(pages::groups::delete)) diff --git a/src/api/admin/pages/devices.rs b/src/api/admin/pages/devices.rs index e21e8db..866fd31 100644 --- a/src/api/admin/pages/devices.rs +++ b/src/api/admin/pages/devices.rs @@ -109,6 +109,56 @@ pub async fn delete( notice_then_table(&state, lang, if ok { "ok" } else { "error" }, &msg).await } +/// Flip `peer.managed` between 0 and 1. Same effect as calling the JSON +/// API `PUT /api/peers/:id/managed`, but rendered as an HTMX action so the +/// table refreshes in place. The handler reads the current value, flips +/// it, and writes back — this avoids a stale-toggle race where the row +/// the admin clicked on showed a stale state (e.g. TOFU just promoted it +/// in the background) and a "set to N" command would no-op silently. +pub async fn toggle_managed( + Extension(state): Extension>, + admin: AuthedUser, + lang: Lang, + Path(peer_id): Path, +) -> Result, ApiError> { + require_admin(&admin)?; + let row = state + .db + .peer_get_auth(&peer_id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let (_pk, was_managed) = match row { + Some(r) => r, + None => { + return notice_then_table( + &state, + lang, + "error", + &tf1(lang, "devices.managed_no_peer", &peer_id), + ) + .await; + } + }; + let new_value = !was_managed; + state + .db + .peer_set_managed(&peer_id, new_value) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + hbb_common::log::info!( + "admin {} set peer {} managed={} via dashboard", + admin.name, + peer_id, + new_value + ); + let key = if new_value { + "devices.managed_now_on" + } else { + "devices.managed_now_off" + }; + notice_then_table(&state, lang, "ok", &tf1(lang, key, &peer_id)).await +} + /// Per-device detail page: hardware / OS inventory reported by hello-agent /// alongside the standard sysinfo (CPU/RAM/OS/hostname). Replaces the /// devices list in `#devices-region` via HTMX; a "Back to devices" button @@ -189,6 +239,7 @@ async fn render_table(state: &Arc, lang: Lang) -> Result{c_ver} {c_last} {c_conns} + {c_auth} {c_actions} @@ -202,12 +253,13 @@ async fn render_table(state: &Arc, lang: Lang) -> Result{}"##, + r##"{}"##, t(lang, "devices.no_devices"), ); } @@ -332,6 +384,57 @@ fn render_device_row( dot = dot_class, id = html_escape(&d.id), ); + + // Auth badge: `Signed` (emerald) when peer.managed=1 — heartbeat / + // sysinfo posts must carry a valid Ed25519 signature; `—` (slate) when + // managed=0 and the device still posts unsigned bodies. The tooltip + // gives the operator the one-line explanation so they know what + // flipping the flag will do. + let auth_cell = if d.managed { + format!( + r##" + {label} + "##, + tt = html_escape(t(lang, "devices.auth_signed_tooltip")), + label = t(lang, "devices.auth_signed"), + ) + } else { + format!( + r##" + {label} + "##, + tt = html_escape(t(lang, "devices.auth_unsigned_tooltip")), + label = t(lang, "devices.auth_unsigned"), + ) + }; + // Auth toggle: the menu entry's label flips based on current state, + // and only the off→on transition needs no confirm (it strengthens + // security). on→off removes the signature requirement and reintroduces + // the spoofing surface, so we require a confirm on that direction. + let toggle_managed_item = if d.managed { + format!( + r##""##, + id = html_escape(&d.id), + confirm = html_escape(&tf1(lang, "devices.confirm_managed_off", &d.id)), + label = t(lang, "devices.mark_unsigned"), + ) + } else { + format!( + r##""##, + id = html_escape(&d.id), + label = t(lang, "devices.mark_managed"), + ) + }; + let _ = write!( s, r##" @@ -344,6 +447,7 @@ fn render_device_row( {ver} {last} {n} + {auth_cell}
··· @@ -358,6 +462,8 @@ fn render_device_row( {details}
+ {toggle_managed_item} +