Implement signed API communication to improve security
build / build-linux-amd64 (push) Successful in 1m54s
build / build-linux-amd64 (push) Successful in 1m54s
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
# Agent API authentication
|
||||
|
||||
Reference for the per-device signature gate on the agent-facing HTTP API
|
||||
(`POST /api/heartbeat`, `POST /api/sysinfo`).
|
||||
|
||||
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
|
||||
|
||||
Both 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,
|
||||
and connection lists.
|
||||
|
||||
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)
|
||||
```
|
||||
|
||||
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: <id>
|
||||
X-RD-Signature: v1.<unix_ts>.<base64(ed25519_sig)>
|
||||
```
|
||||
|
||||
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`).
|
||||
- `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 <user> set peer X managed=<bool> 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` | 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). |
|
||||
+77
-1
@@ -303,6 +303,82 @@ keys and what each one does.
|
||||
|
||||
---
|
||||
|
||||
## Agent API signing (per-peer)
|
||||
|
||||
`POST /api/heartbeat` and `POST /api/sysinfo` are the two endpoints the
|
||||
agent uses for its management loop. Stock RustDesk and managed builds
|
||||
(hello-agent) both call them. Each peer row has a `managed` flag that
|
||||
gates whether the server requires a per-request Ed25519 signature on
|
||||
those two endpoints; everything else (`/api/peers`, `/api/ab/*`,
|
||||
audit, recordings, OIDC, etc.) is unaffected.
|
||||
|
||||
| `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":<bool>}`. 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 +402,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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -113,6 +113,10 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
|
||||
"/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))
|
||||
|
||||
@@ -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<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(peer_id): Path<String>,
|
||||
) -> Result<Html<String>, 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<AppState>, lang: Lang) -> Result<String, ApiEr
|
||||
<th class="text-left font-medium px-3 py-2">{c_ver}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_last}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_conns}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_auth}</th>
|
||||
<th class="text-right font-medium px-3 py-2 w-1">{c_actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -202,12 +253,13 @@ async fn render_table(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiEr
|
||||
c_ver = t(lang, "devices.col_version"),
|
||||
c_last = t(lang, "devices.col_last_heartbeat"),
|
||||
c_conns = t(lang, "devices.col_conns"),
|
||||
c_auth = t(lang, "devices.col_auth"),
|
||||
c_actions = t(lang, "common.actions"),
|
||||
);
|
||||
if devices.is_empty() {
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<tr><td colspan="10" class="px-3 py-4 text-slate-500 text-center text-xs">{}</td></tr>"##,
|
||||
r##"<tr><td colspan="11" class="px-3 py-4 text-slate-500 text-center text-xs">{}</td></tr>"##,
|
||||
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##"<td class="px-3 py-2 whitespace-nowrap">
|
||||
<span class="inline-flex items-center gap-1 rounded border border-emerald-700/50 bg-emerald-900/30 px-2 py-0.5 text-xs text-emerald-300" title="{tt}">{label}</span>
|
||||
</td>"##,
|
||||
tt = html_escape(t(lang, "devices.auth_signed_tooltip")),
|
||||
label = t(lang, "devices.auth_signed"),
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r##"<td class="px-3 py-2 whitespace-nowrap">
|
||||
<span class="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-800/40 px-2 py-0.5 text-xs text-slate-400" title="{tt}">{label}</span>
|
||||
</td>"##,
|
||||
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##"<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
|
||||
hx-post="/admin/pages/devices/{id}/toggle-managed"
|
||||
hx-target="#devices-region" hx-swap="innerHTML"
|
||||
hx-confirm="{confirm}">
|
||||
{label}
|
||||
</button>"##,
|
||||
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##"<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
|
||||
hx-post="/admin/pages/devices/{id}/toggle-managed"
|
||||
hx-target="#devices-region" hx-swap="innerHTML">
|
||||
{label}
|
||||
</button>"##,
|
||||
id = html_escape(&d.id),
|
||||
label = t(lang, "devices.mark_managed"),
|
||||
)
|
||||
};
|
||||
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<tr class="hover:bg-slate-800/40">
|
||||
@@ -344,6 +447,7 @@ fn render_device_row(
|
||||
<td class="px-3 py-2 text-slate-400 whitespace-nowrap">{ver}</td>
|
||||
<td class="px-3 py-2 text-slate-500 text-xs">{last}</td>
|
||||
<td class="px-3 py-2 text-slate-400">{n}</td>
|
||||
{auth_cell}
|
||||
<td class="px-3 py-2">
|
||||
<details class="text-right relative">
|
||||
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
|
||||
@@ -358,6 +462,8 @@ fn render_device_row(
|
||||
{details}
|
||||
</button>
|
||||
<hr class="border-slate-700 my-1" />
|
||||
{toggle_managed_item}
|
||||
<hr class="border-slate-700 my-1" />
|
||||
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
|
||||
hx-post="/admin/pages/devices/{id}/disconnect"
|
||||
hx-target="#devices-region" hx-swap="innerHTML"
|
||||
@@ -394,6 +500,8 @@ fn render_device_row(
|
||||
ver = html_escape(&version_label),
|
||||
last = html_escape(&d.last_heartbeat_at),
|
||||
n = conn_count,
|
||||
auth_cell = auth_cell,
|
||||
toggle_managed_item = toggle_managed_item,
|
||||
connect_web = t(lang, "devices.connect_web"),
|
||||
details = t(lang, "devices.details"),
|
||||
confirm_disc = html_escape(&tf1(lang, "devices.confirm_disconnect", &d.id)),
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Ed25519-signature gate for the agent-facing HTTP API
|
||||
//! (`/api/heartbeat`, `/api/sysinfo`).
|
||||
//!
|
||||
//! Trust root: the device's Ed25519 public key is already written into
|
||||
//! `peer.pk` during the rendezvous `RegisterPk` handshake (TCP/protobuf,
|
||||
//! port 21116). That handshake proves possession of the matching private key
|
||||
//! to the rendezvous server — so any later HTTP request signed by the same
|
||||
//! key is provably from the same device.
|
||||
//!
|
||||
//! Cutover: per-peer. `peer.managed = 0` (default) keeps stock-client
|
||||
//! behaviour — no signature required. `managed = 1` requires a valid sig on
|
||||
//! every request. The flag flips from 0→1 on the first valid signature we
|
||||
//! observe (TOFU) or via the admin endpoint. It never flips back from a
|
||||
//! request — only an admin can downgrade.
|
||||
//!
|
||||
//! Wire format (both headers required on signed requests):
|
||||
//! X-RD-Device-Id: <id>
|
||||
//! X-RD-Signature: v1.<unix_ts>.<base64(ed25519_sig)>
|
||||
//! where the signed message is:
|
||||
//! "rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(body)
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
use axum::http::HeaderMap;
|
||||
use lazy_static::lazy_static;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::Arc;
|
||||
|
||||
const SIG_VERSION: &str = "v1";
|
||||
const HEADER_ID: &str = "x-rd-device-id";
|
||||
const HEADER_SIG: &str = "x-rd-signature";
|
||||
const SKEW_TOLERANCE_SECS: i64 = 300;
|
||||
const REPLAY_WINDOW_SECS: i64 = 600;
|
||||
const REPLAY_CACHE_MAX: usize = 16_384;
|
||||
|
||||
/// Outcome of running the gate. The handler uses this to decide which `id`
|
||||
/// to trust as the device identity:
|
||||
/// - `Verified` → caller is cryptographically that device.
|
||||
/// - `LegacyUnsigned` → managed=0 peer that sent no sig headers; the
|
||||
/// handler may proceed but the body `id` is trusted only weakly
|
||||
/// (same risk as today). The handler still calls `get_peer` to confirm
|
||||
/// the id is known.
|
||||
pub enum AuthOutcome {
|
||||
Verified { id: String },
|
||||
LegacyUnsigned,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
/// Replay cache. Key: "<id>|<ts>|<sig_first32>". Value: expiry unix ts.
|
||||
/// Small enough that the sweep-on-insert cost is negligible.
|
||||
static ref REPLAY: Mutex<HashMap<String, i64>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
pub async fn verify(
|
||||
state: &Arc<AppState>,
|
||||
method: &str,
|
||||
path: &str,
|
||||
headers: &HeaderMap,
|
||||
body: &[u8],
|
||||
) -> Result<AuthOutcome, ApiError> {
|
||||
let sig_hdr = headers.get(HEADER_SIG).and_then(|v| v.to_str().ok());
|
||||
let id_hdr = headers.get(HEADER_ID).and_then(|v| v.to_str().ok());
|
||||
|
||||
// No signature headers at all → legacy path. Even then we still need to
|
||||
// check that the peer (if it claims an id in the body) isn't marked
|
||||
// `managed=1`. The handler doesn't know the body id yet, so we defer
|
||||
// the managed-check to a second call (`enforce_managed_for_id`) after
|
||||
// the handler has parsed the body. Returning LegacyUnsigned here just
|
||||
// means "no sig present, you must call enforce_managed_for_id next".
|
||||
let (sig_hdr, id_hdr) = match (sig_hdr, id_hdr) {
|
||||
(Some(s), Some(i)) if !s.is_empty() && !i.is_empty() => (s, i),
|
||||
(None, None) => return Ok(AuthOutcome::LegacyUnsigned),
|
||||
// Partial headers: someone tried to sign but messed up the request.
|
||||
// Don't fall through to legacy — treat as an outright failure so we
|
||||
// don't silently downgrade a misconfigured agent.
|
||||
_ => return Err(ApiError::Unauthorized),
|
||||
};
|
||||
|
||||
// Parse "v1.<ts>.<b64>".
|
||||
let mut parts = sig_hdr.splitn(3, '.');
|
||||
let ver = parts.next().unwrap_or("");
|
||||
let ts_s = parts.next().unwrap_or("");
|
||||
let sig_b64 = parts.next().unwrap_or("");
|
||||
if ver != SIG_VERSION || ts_s.is_empty() || sig_b64.is_empty() {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
let ts: i64 = ts_s.parse().map_err(|_| ApiError::Unauthorized)?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
if (now - ts).abs() > SKEW_TOLERANCE_SECS {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
let sig_bytes = base64::decode(sig_b64).map_err(|_| ApiError::Unauthorized)?;
|
||||
|
||||
// Replay check before the expensive crypto. The (id, ts, sig-prefix)
|
||||
// tuple is unique per request from a non-broken agent.
|
||||
let replay_key = {
|
||||
let prefix: String = sig_b64.chars().take(32).collect();
|
||||
format!("{}|{}|{}", id_hdr, ts, prefix)
|
||||
};
|
||||
{
|
||||
let mut cache = REPLAY.lock().unwrap();
|
||||
cache.retain(|_, exp| *exp > now);
|
||||
if cache.contains_key(&replay_key) {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
if cache.len() < REPLAY_CACHE_MAX {
|
||||
cache.insert(replay_key, now + REPLAY_WINDOW_SECS);
|
||||
}
|
||||
// If the cache is full we accept (no DoS via cache exhaustion). The
|
||||
// 5-min skew window already bounds replay risk.
|
||||
}
|
||||
|
||||
// Look up the peer's pk and managed flag in one query.
|
||||
let (pk_bytes, managed) = state
|
||||
.db
|
||||
.peer_get_auth(id_hdr)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
if pk_bytes.is_empty() {
|
||||
// No PK registered — rendezvous hasn't completed. Can't verify.
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
|
||||
// Build the canonical signed message:
|
||||
// "rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(body)
|
||||
let body_sha = sodiumoxide::crypto::hash::sha256::hash(body);
|
||||
let mut msg = Vec::with_capacity(64 + method.len() + path.len());
|
||||
msg.extend_from_slice(b"rd-api-v1\n");
|
||||
msg.extend_from_slice(method.as_bytes());
|
||||
msg.push(b'\n');
|
||||
msg.extend_from_slice(path.as_bytes());
|
||||
msg.push(b'\n');
|
||||
msg.extend_from_slice(ts_s.as_bytes());
|
||||
msg.push(b'\n');
|
||||
msg.extend_from_slice(body_sha.as_ref());
|
||||
|
||||
let pk = sodiumoxide::crypto::sign::PublicKey::from_slice(&pk_bytes)
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
let sig = sodiumoxide::crypto::sign::Signature::from_bytes(&sig_bytes)
|
||||
.map_err(|_| ApiError::Unauthorized)?;
|
||||
if !sodiumoxide::crypto::sign::verify_detached(&sig, &msg, &pk) {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
|
||||
// TOFU promote: first valid sig flips managed=0 → 1. After this, the
|
||||
// same device can no longer fall back to the legacy unsigned path.
|
||||
if !managed {
|
||||
if let Err(e) = state.db.peer_set_managed(id_hdr, true).await {
|
||||
hbb_common::log::warn!("peer_set_managed({}) failed: {}", id_hdr, e);
|
||||
// Don't fail the request — the sig was valid, the promote is
|
||||
// best-effort. Next request will retry the promote.
|
||||
} else {
|
||||
hbb_common::log::info!("peer {} TOFU-promoted to managed=1", id_hdr);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(AuthOutcome::Verified {
|
||||
id: id_hdr.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Called by handlers AFTER they've parsed the body and extracted the
|
||||
/// device id. Only meaningful when `verify` returned `LegacyUnsigned`.
|
||||
/// Enforces: if the peer is currently managed=1, an unsigned request for
|
||||
/// that id must be rejected.
|
||||
pub async fn enforce_managed_for_id(
|
||||
state: &Arc<AppState>,
|
||||
id: &str,
|
||||
) -> Result<(), ApiError> {
|
||||
if id.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let row = state
|
||||
.db
|
||||
.peer_get_auth(id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
match row {
|
||||
Some((_, true)) => Err(ApiError::Unauthorized),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
+22
-2
@@ -5,12 +5,18 @@
|
||||
//! - `disconnect: [conn_id, ...]` — tell the client to drop those sessions,
|
||||
//! - `modified_at` + `strategy` — push a config-options merge.
|
||||
//!
|
||||
//! Auth: none (the client identifies the device by `(id, uuid)` body fields).
|
||||
//! Auth: signed agents (peer.managed=1) must carry `X-RD-Device-Id` +
|
||||
//! `X-RD-Signature` headers — see `device_auth::verify`. Stock clients
|
||||
//! (peer.managed=0) keep posting unsigned bodies; the first valid sig we
|
||||
//! see flips the peer to managed=1 (TOFU).
|
||||
|
||||
use crate::api::device_auth::{self, AuthOutcome};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
use crate::api::strategy;
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::Extension;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -45,11 +51,25 @@ pub struct HeartbeatResp {
|
||||
|
||||
pub async fn heartbeat(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Json(body): Json<HeartbeatBody>,
|
||||
headers: HeaderMap,
|
||||
raw: Bytes,
|
||||
) -> Result<Json<HeartbeatResp>, ApiError> {
|
||||
let outcome = device_auth::verify(&state, "POST", "/api/heartbeat", &headers, &raw).await?;
|
||||
let body: HeartbeatBody = serde_json::from_slice(&raw)
|
||||
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
|
||||
if body.id.is_empty() || body.uuid.is_empty() {
|
||||
return Err(ApiError::BadRequest("id and uuid required".into()));
|
||||
}
|
||||
match outcome {
|
||||
AuthOutcome::Verified { id: signed_id } => {
|
||||
if body.id != signed_id {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
}
|
||||
AuthOutcome::LegacyUnsigned => {
|
||||
device_auth::enforce_managed_for_id(&state, &body.id).await?;
|
||||
}
|
||||
}
|
||||
let conns_json = serde_json::to_string(&body.conns.unwrap_or_default())
|
||||
.unwrap_or_else(|_| "[]".into());
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod ab;
|
||||
pub mod admin;
|
||||
pub mod audit;
|
||||
pub mod auth;
|
||||
pub mod device_auth;
|
||||
pub mod devices_cli;
|
||||
pub mod email;
|
||||
pub mod error;
|
||||
@@ -80,6 +81,7 @@ pub fn router(state: Arc<AppState>) -> Router {
|
||||
)
|
||||
.route("/api/users", get(users::list))
|
||||
.route("/api/peers", get(peers::list))
|
||||
.route("/api/peers/:id/managed", put(peers::set_managed))
|
||||
// M3: audit
|
||||
.route("/api/audit/conn", post(audit::conn::conn))
|
||||
.route("/api/audit/file", post(audit::file::file))
|
||||
|
||||
+46
-2
@@ -7,9 +7,9 @@ use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::pagination::{Page, PageQuery};
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::extract::{Extension, Path, Query};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -64,3 +64,47 @@ pub async fn list(
|
||||
.collect();
|
||||
Ok(Json(Page { total, data }))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetManagedBody {
|
||||
pub managed: bool,
|
||||
}
|
||||
|
||||
/// `PUT /api/peers/:id/managed` — admin-only toggle for the signed-API gate.
|
||||
/// Setting `managed=true` is also done TOFU-style by the sig-verify helper
|
||||
/// on the first valid signature, so this endpoint is mainly useful for:
|
||||
/// - Pre-enrolling a peer before its agent boots.
|
||||
/// - Downgrading a peer back to the unsigned path after a managed agent
|
||||
/// is uninstalled or replaced with stock RustDesk.
|
||||
pub async fn set_managed(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<SetManagedBody>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if !user.is_admin {
|
||||
return Err(ApiError::Forbidden("admin only".into()));
|
||||
}
|
||||
// Confirm the peer exists before flipping, so an admin typo doesn't
|
||||
// silently create a row-not-found situation.
|
||||
let row = state
|
||||
.db
|
||||
.peer_get_auth(&id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if row.is_none() {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
state
|
||||
.db
|
||||
.peer_set_managed(&id, body.managed)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
hbb_common::log::info!(
|
||||
"admin {} set peer {} managed={}",
|
||||
user.name,
|
||||
id,
|
||||
body.managed
|
||||
);
|
||||
Ok(Json(json!({ "ok": true, "managed": body.managed })))
|
||||
}
|
||||
|
||||
+33
-6
@@ -1,7 +1,9 @@
|
||||
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::Json;
|
||||
use axum::http::HeaderMap;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -17,9 +19,18 @@ pub async fn sysinfo_ver(Extension(state): Extension<Arc<AppState>>) -> String {
|
||||
/// `==` comparison on these — do not wrap in JSON.
|
||||
pub async fn sysinfo(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Json(payload): Json<Value>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<String, ApiError> {
|
||||
let id = payload
|
||||
// Step 1: signature gate. Verified → trust the id from the signed
|
||||
// header. LegacyUnsigned → fall through but enforce that the body id
|
||||
// isn't a managed peer (would be downgrade attempt).
|
||||
let outcome = device_auth::verify(&state, "POST", "/api/sysinfo", &headers, &body).await?;
|
||||
|
||||
// Step 2: parse body.
|
||||
let payload: Value = serde_json::from_slice(&body)
|
||||
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
|
||||
let body_id = payload
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
@@ -27,15 +38,31 @@ pub async fn sysinfo(
|
||||
.get("uuid")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
if id.is_empty() || uuid.is_empty() {
|
||||
if body_id.is_empty() || uuid.is_empty() {
|
||||
return Err(ApiError::BadRequest("id and uuid required".into()));
|
||||
}
|
||||
|
||||
// Step 3: bind the trusted identity to the body. For signed requests,
|
||||
// the body id must match the header id — otherwise the agent is trying
|
||||
// to write inventory for someone else.
|
||||
let id = match outcome {
|
||||
AuthOutcome::Verified { id: signed_id } => {
|
||||
if body_id != signed_id {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
signed_id
|
||||
}
|
||||
AuthOutcome::LegacyUnsigned => {
|
||||
device_auth::enforce_managed_for_id(&state, body_id).await?;
|
||||
body_id.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
// Tie sysinfo storage to a real rendezvous-registered peer. Without this
|
||||
// gate, any caller could populate device_sysinfo for arbitrary IDs.
|
||||
let peer = state
|
||||
.db
|
||||
.get_peer(id)
|
||||
.get_peer(&id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if peer.is_none() {
|
||||
@@ -45,7 +72,7 @@ pub async fn sysinfo(
|
||||
let version = parse_version_number(payload.get("version").and_then(|v| v.as_str()));
|
||||
state
|
||||
.db
|
||||
.sysinfo_upsert(id, uuid, &payload.to_string(), &state.cfg.sysinfo_ver, version)
|
||||
.sysinfo_upsert(&id, uuid, &payload.to_string(), &state.cfg.sysinfo_ver, version)
|
||||
.await?;
|
||||
Ok("SYSINFO_UPDATED".to_string())
|
||||
}
|
||||
|
||||
+53
-2
@@ -224,6 +224,12 @@ pub struct DashboardDeviceRow {
|
||||
/// is logged in.
|
||||
pub unattended_password: String,
|
||||
pub unattended_password_set_at: String,
|
||||
/// `peer.managed` from the rendezvous identity table. `true` means the
|
||||
/// agent-facing HTTP API will reject unsigned heartbeats/sysinfo for
|
||||
/// this device — see `api::device_auth`. Joined LEFT so devices that
|
||||
/// reported sysinfo before completing rendezvous render as `false`
|
||||
/// rather than disappearing from the list.
|
||||
pub managed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -631,9 +637,11 @@ impl Database {
|
||||
ds.payload AS payload, \
|
||||
ds.conns AS conns, \
|
||||
COALESCE(ds.unattended_password, '') AS u_pw, \
|
||||
COALESCE(ds.unattended_password_set_at, '') AS u_pw_at \
|
||||
COALESCE(ds.unattended_password_set_at, '') AS u_pw_at, \
|
||||
COALESCE(p.managed, 0) AS managed \
|
||||
FROM device_sysinfo ds \
|
||||
LEFT JOIN users u ON u.id = ds.user_id \
|
||||
LEFT JOIN peer p ON p.id = ds.id \
|
||||
ORDER BY ds.last_heartbeat_at DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(limit)
|
||||
@@ -651,6 +659,7 @@ impl Database {
|
||||
conns_json: r.try_get("conns").unwrap_or_default(),
|
||||
unattended_password: r.try_get("u_pw").unwrap_or_default(),
|
||||
unattended_password_set_at: r.try_get("u_pw_at").unwrap_or_default(),
|
||||
managed: r.try_get::<i64, _>("managed").unwrap_or(0) != 0,
|
||||
})
|
||||
.collect();
|
||||
Ok((total, data))
|
||||
@@ -672,9 +681,11 @@ impl Database {
|
||||
ds.payload AS payload, \
|
||||
ds.conns AS conns, \
|
||||
COALESCE(ds.unattended_password, '') AS u_pw, \
|
||||
COALESCE(ds.unattended_password_set_at, '') AS u_pw_at \
|
||||
COALESCE(ds.unattended_password_set_at, '') AS u_pw_at, \
|
||||
COALESCE(p.managed, 0) AS managed \
|
||||
FROM device_sysinfo ds \
|
||||
LEFT JOIN users u ON u.id = ds.user_id \
|
||||
LEFT JOIN peer p ON p.id = ds.id \
|
||||
WHERE ds.id = ? LIMIT 1",
|
||||
)
|
||||
.bind(peer_id)
|
||||
@@ -689,6 +700,7 @@ impl Database {
|
||||
conns_json: r.try_get("conns").unwrap_or_default(),
|
||||
unattended_password: r.try_get("u_pw").unwrap_or_default(),
|
||||
unattended_password_set_at: r.try_get("u_pw_at").unwrap_or_default(),
|
||||
managed: r.try_get::<i64, _>("managed").unwrap_or(0) != 0,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -3047,6 +3059,38 @@ impl Database {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read just the auth-relevant peer columns for the signed-API gate.
|
||||
/// Untyped `sqlx::query` (not `query!`) because the `managed` column is
|
||||
/// added by a soft ALTER at runtime — the compile-time check against the
|
||||
/// dev DB would fail on fresh checkouts before the migration has run.
|
||||
/// Returns `(pk, managed)`. `None` means no row for this id.
|
||||
pub async fn peer_get_auth(&self, id: &str) -> ResultType<Option<(Vec<u8>, bool)>> {
|
||||
use sqlx::Row;
|
||||
let row = sqlx::query("select pk, managed from peer where id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(row.map(|r| {
|
||||
let pk: Vec<u8> = r.try_get("pk").unwrap_or_default();
|
||||
let managed: i64 = r.try_get("managed").unwrap_or(0);
|
||||
(pk, managed != 0)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Flip the `managed` flag. Called from two places:
|
||||
/// 1. The TOFU promote inside the sig-verify helper on first valid sig.
|
||||
/// 2. The admin endpoint PUT /api/peers/:id/managed.
|
||||
/// Idempotent — calling with the current value is a no-op update.
|
||||
pub async fn peer_set_managed(&self, id: &str, managed: bool) -> ResultType<()> {
|
||||
let v: i64 = if managed { 1 } else { 0 };
|
||||
sqlx::query("update peer set managed = ? where id = ?")
|
||||
.bind(v)
|
||||
.bind(id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Timing-safe equality for hash comparisons. Slightly paranoid given the
|
||||
@@ -3268,6 +3312,13 @@ const M2_SOFT_ALTERS: &[&str] = &[
|
||||
// displays it for the operator to read, and it rotates each boot.
|
||||
"ALTER TABLE device_sysinfo ADD COLUMN unattended_password TEXT",
|
||||
"ALTER TABLE device_sysinfo ADD COLUMN unattended_password_set_at DATETIME",
|
||||
// Per-device flag that gates Ed25519 signature enforcement on the agent
|
||||
// HTTP API (heartbeat, sysinfo). 0 = legacy/stock-rustdesk path, 1 =
|
||||
// managed peer, signatures required. Set TOFU-style on the first valid
|
||||
// signature we observe from the peer, or explicitly via the admin
|
||||
// endpoint PUT /api/peers/:id/managed. Never written from request body
|
||||
// — only the server flips it.
|
||||
"ALTER TABLE peer ADD COLUMN managed INTEGER NOT NULL DEFAULT 0",
|
||||
];
|
||||
|
||||
const M3_SCHEMA: &[&str] = &[
|
||||
|
||||
Reference in New Issue
Block a user