diff --git a/admin_ui/index.html b/admin_ui/index.html index 01abf42..debd043 100644 --- a/admin_ui/index.html +++ b/admin_ui/index.html @@ -43,6 +43,8 @@ hx-get="/admin/pages/audit" hx-target="#main" hx-push-url="#audit">Audit log Recordings + Deploy
+ + + + + diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..41e9700 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,340 @@ +# RustDesk Server (hbbs) — Configuration Guide + +This document covers the runtime flags exposed by `hbbs`, the file formats +it reads (notably `oidc.toml`), and the operator workflows that string +those together — bootstrap admin, OIDC sign-in, TOTP, address books, +strategies, recordings, the admin dashboard. + +The matching desktop-client API surface is documented separately in the +`rustdesk` repo at `docs/CONSOLE_API.md`. + +--- + +## CLI flags + +Pass flags directly on the command line. There is no config file for +hbbs itself (only the optional `oidc.toml` referenced from a flag). + +### Networking & rendezvous + +| Flag | Default | Purpose | +|---|---|---| +| `--port=` | `21116` | TCP/UDP rendezvous port. | +| `--rendezvous-servers=` | unset | Peer rendezvous servers (comma-separated). | +| `--relay-servers=` | unset | Default relay hosts handed to clients. | +| `--rmem=` | platform default | UDP recv buffer size. Bump along with `net.core.rmem_max`. | +| `--mask=` | unset | LAN mask (e.g. `192.168.0.0/16`) used to flag local connections. | +| `--key=` | derived from `id_ed25519` | Force a specific public key; clients must match. Leave unset to auto-load `id_ed25519` next to the binary. | + +### HTTP API & dashboard + +| Flag | Default | Purpose | +|---|---|---| +| `--http-port=` | `21114` | HTTP API port (`/api/*`) and admin dashboard (`/admin/*`). `0` disables both. | +| `--admin-ui-dir=` | `./admin_ui` | Hint at where the dashboard's static HTML lives. The HTML is *embedded* in the binary; this flag is informational. Setting it to empty (`--admin-ui-dir=`) disables the dashboard entirely. | +| `--public-base-url=` | unset | The externally-reachable HTTP base of this server, e.g. `https://rustdesk.example.com:21114`. **Required when OIDC is enabled** — used to build `/oidc/callback` redirect URIs. | + +### Bootstrap admin + +| Flag | Purpose | +|---|---| +| `--bootstrap-admin-username=` | On first startup, if the `users` table is empty *and* both flags are set, insert one admin user. Subsequent restarts ignore these flags (no overwrite). | +| `--bootstrap-admin-password=` | Same. Bcrypt-hashed at insert time. | + +If you forget to bootstrap, hbbs logs a warning at startup ("no users in users table"); recover by either restarting with the flags or `INSERT INTO users` directly via `sqlite3`. + +### Address books + +| Flag | Default | Purpose | +|---|---|---| +| `--ab-legacy-mode=` | `off` | When `on`, `/api/ab/personal` returns 404. Forces clients into the legacy single-blob AB mode. | +| `--ab-max-peers-per-book=` | `100` | Surfaced via `/api/ab/settings.max_peer_one_ab`. Soft cap; the client uses it for UI hints. | + +### Recordings + +| Flag | Default | Purpose | +|---|---|---| +| `--recording-dir=` | `./recordings` | Root for `/api/record` uploads. One subdirectory per peer. | +| `--recording-max-size-mb=` | unset (=unlimited) | Per-file ceiling. Aborts oversized parts. | + +> **Note:** Stock OSS RustDesk clients **do not upload** recordings to `/api/record` — the uploader's `ENABLE` flag at `src/hbbs_http/record_upload.rs` has no setter in OSS source. Server-side recording requires a custom client build that flips that flag. The `Recordings` admin tab will stay empty for stock clients; the endpoint is provided for wire parity with Pro clients. + +### Audit retention + +| Flag | Default | Purpose | +|---|---|---| +| `--audit-retention-days=` | `0` (=keep forever) | Hourly task deletes `audit_conn` / `audit_file` / `audit_alarm` rows older than N days. | + +### Email-code login (`/api/login` with `type:"email_code"`) + +| Flag | Default | Purpose | +|---|---|---| +| `--smtp-host=` | unset | If unset, codes are *logged to stdout* (dev mode) instead of mailed. The `email_code` login option is also dropped from `/api/login-options` until SMTP is configured. | +| `--smtp-port=` | `587` | | +| `--smtp-user=` | unset | Omit for unauthenticated relays. | +| `--smtp-pass=` | unset | | +| `--smtp-from=` | `noreply@` | From: header. | +| `--smtp-tls=` | `on` | STARTTLS on the SMTP transport. | + +### OIDC + +| Flag | Purpose | +|---|---| +| `--oidc-config=` | TOML file (see below). Providers are upserted into `oidc_providers` at startup. Re-run with a different file to change providers; rows missing from the new file remain in the DB but can be `enabled=0`'d via SQL. | +| `--public-base-url=` | **Required** if any provider is configured. Determines the redirect URI registered with the IdP. | + +--- + +## OIDC integration + +The server speaks standard OIDC Authorization Code flow with discovery +(`/.well-known/openid-configuration`). Tested against Zitadel; should work +with any standards-compliant IdP (Keycloak, Auth0, Google, Okta, Authelia, +Dex, etc.). + +Two entry points are wired: + +1. **Desktop client** — `/api/login-options` advertises `oidc/` per enabled provider. The Flutter login dialog renders a button per advertised name. Clicking starts the device-flow polling cycle (`/api/oidc/auth` → browser → `/oidc/callback` → `/api/oidc/auth-query` poll). +2. **Admin dashboard** — `/admin/login.html` fetches `/admin/oidc/providers` and renders a "Sign in with X" button per provider. Clicking jumps the browser to `/admin/login/oidc/` which 302-redirects to the IdP. After the IdP returns to `/oidc/callback`, the server detects the admin-flow sentinel and finishes by setting the dashboard session cookie + redirecting to `/admin/`. + +### `oidc.toml` schema + +Pass via `--oidc-config /path/to/oidc.toml`. + +```toml +[[providers]] +# Slug used in URLs (`/admin/login/oidc/`, `/api/login-options` +# advertises `oidc/`). Lowercase, no spaces. +name = "zitadel" + +# Display label on the sign-in button. +display_name = "Sign in with Zitadel" + +# Optional. Square icon URL shown next to the label (not used yet by all +# UIs; reserved for future button rendering). +# icon_url = "https://example.com/zitadel.svg" + +# OIDC issuer. The server fetches `/.well-known/openid-configuration` +# and caches the discovery doc in-process. Trailing slash is stripped. +issuer_url = "https://idp.example.com" + +# Application credentials from the IdP. +client_id = "..." +client_secret = "..." + +# Scopes requested at the authorization endpoint. Most setups want +# "openid email profile". Zitadel additionally needs the project audience +# scope to receive role claims (see Role-based admin sync below). +scopes = "openid email profile" + +# Optional. If unset, computed as `<--public-base-url>/oidc/callback`. +# Override only when you reverse-proxy under a different host. +# redirect_url = "https://rustdesk.example.com/oidc/callback" + +# Optional. Defaults to true. +enabled = true + +# --- Role-based admin sync (optional) --- +# When `admin_role` is set, every successful sign-in via this provider +# evaluates the userinfo claim at `roles_claim` and forces the local +# user's `is_admin` to (role present in claim). Promotion AND demotion +# at the IdP propagate. Leave both unset to manage admin status manually +# from the dashboard. +# admin_role = "admin" +# roles_claim = "roles" # or e.g. "urn:zitadel:iam:org:project:roles" +``` + +`oidc.toml` may contain multiple `[[providers]]` blocks for multi-IdP setups. + +### Walk-through: Zitadel + +#### In Zitadel + +1. **Project → New project** (or pick an existing one). +2. **New application** under the project: + - Type: **Web** + - Authentication flow: **Code** (Authorization Code with client secret) + - Auth method: **Basic** *or* **Post** (server sends `client_id` + `client_secret` in the form body — both modes accept that) + - **Redirect URIs**: `/oidc/callback` — character-exact, including scheme. Zitadel rejects `http://` redirects on non-localhost unless dev mode is on, so use TLS in production. +3. **Authorizations** — assign the project's roles to whichever users you want to be admins. +4. **Project → General**: turn on **"Assert Roles On Authentication"** so roles flow into the userinfo response. +5. Copy **Client ID** and **Client Secret** from the application's overview page. + +#### `oidc.toml` + +```toml +[[providers]] +name = "zitadel" +display_name = "Sign in with Zitadel" +issuer_url = "https://your-instance.zitadel.cloud" +client_id = "PASTE_FROM_ZITADEL" +client_secret = "PASTE_FROM_ZITADEL" +# `urn:zitadel:iam:org:project:id:zitadel:aud` is required for the project's +# roles to be included in the userinfo response. +scopes = "openid email profile urn:zitadel:iam:org:project:id:zitadel:aud" +admin_role = "admin" +roles_claim = "urn:zitadel:iam:org:project:roles" +``` + +#### `hbbs` flags + +```sh +./hbbs --http-port 21114 \ + --public-base-url 'https://rustdesk.example.com:21114' \ + --oidc-config /etc/rustdesk/oidc.toml +``` + +#### Verify + +After hbbs starts, look for: + +``` +oidc: provider "zitadel" configured +oidc: loaded 1 providers from /etc/rustdesk/oidc.toml +``` + +Then: + +```sh +# 1. Provider visible to the desktop client +curl -s http://127.0.0.1:21114/api/login-options +# expect a list including "oidc/zitadel" + +# 2. Provider visible to the admin dashboard +curl -s http://127.0.0.1:21114/admin/oidc/providers +# expect [{"name":"zitadel","display_name":"Sign in with Zitadel",...}] + +# 3. Discovery is reachable (IdP-side) +curl -s https://your-instance.zitadel.cloud/.well-known/openid-configuration | jq .issuer +``` + +### Role-based admin sync + +When `admin_role` is set on a provider, every successful sign-in evaluates +the userinfo claim at `roles_claim` (defaults to `"roles"` if unset) and +forces `users.is_admin` accordingly. **Promotion and demotion at the IdP +propagate on the next login.** + +Two claim shapes are supported: + +- **Object** (Zitadel default at `urn:zitadel:iam:org:project:roles`): role names are keys. + ```json + "urn:zitadel:iam:org:project:roles": { + "admin": {"123": "myorg"}, + "user": {"123": "myorg"} + } + ``` +- **Array of strings** (generic, common with Keycloak, Auth0 custom claims): + ```json + "roles": ["admin", "user"] + ``` + +Set `admin_role = "admin"` and either set `roles_claim` to the exact claim +name (Zitadel) or omit it to default to `"roles"` (generic). + +> **Sharp edge:** when role-sync is configured, manually-granted admin +> rights in the dashboard get **revoked** on the next OIDC login if the +> role isn't present at the IdP. This is the correct contract for a +> single source of truth, but surprising if you forget. Manage admin +> status in *one* place at a time. + +### Troubleshooting OIDC + +- **"Sign-in complete" page in browser but desktop client stays at "Waiting account auth"**: usually a state mismatch between server and client. Check `hbbs.log` — the poll endpoint logs every tick at INFO. If you see `status=success` lines that don't stop, suspect a wire-shape mismatch. (This was a real bug we hit and fixed; see git log for `oidc envelope`.) +- **Browser shows "identity provider returned an error"**: check `oidc_sessions.error` for the row that just failed. Most common: `redirect_uri` mismatch between Zitadel and `--public-base-url`. +- **No "Sign in with X" button in the dashboard or desktop client**: check `oidc_provider_list_enabled()` returns rows. If `--public-base-url` is empty, `/admin/oidc/providers` and `/api/login-options` both suppress OIDC entries (the redirect URI would be unbuildable). +- **Admin landing on the "no admin access" error after first OIDC sign-in**: expected if `admin_role` isn't configured. Either configure role-sync (preferred), or have the user sign in once to create their row, then promote them on the Users page. The next OIDC sign-in resolves to that row. + +--- + +## TOTP / 2FA + +Per-user TOTP is enrolled from the dashboard: + +1. Sign in as an admin → **Users** page. +2. Pick a user → action menu → **Enroll TOTP**. +3. Scan the QR code into an authenticator (1Password, Authy, Google Authenticator, etc.). The secret is shown once and stored in `user_totp_secrets`. + +After enrollment, the next desktop-client login flow is: + +1. Username + password → server returns `{"type":"email_check","tfa_type":"tfa_check","secret":}`. +2. Client opens its verification-code dialog → user enters the 6-digit code → re-POSTs `/api/login` with `type:"email_code"` (yes, that's what the desktop client sends for both email and TOTP second legs), `tfaCode` set, `secret` echoed back. +3. Server verifies the code against `user_totp_secrets`, mints an access token, returns `{"type":"access_token", ...}`. + +For dashboard logins, the inline form at `/admin/login.html` shows the TOTP field after the first password submit returns the prompt fragment. + +--- + +## Strategies (server-pushed config) + +Strategies push `config_options` to peers via heartbeat replies. They are +managed entirely from the dashboard's **Strategies** page. Resolution +order per peer: + +1. Direct peer-scoped assignment (`strategy_assignments.peer_id`) +2. Device-group assignment via the peer's owner +3. User assignment + +The peer's `Config::get_option` calls reflect the resolved values within +~15 s of any change to `modified_at` on the strategy row. + +--- + +## Address books + +- **Personal books** are owned per-user and managed from the user's desktop client. The dashboard surfaces them read-only. +- **Shared books** are server-side artifacts. Create from the dashboard's **Address books** page → "Manage shares" → grant per-user `read` / `read+write` / `full` access. Clients pick up shared books on their next AB sync (~30 s). + +If you set `--ab-legacy-mode=on`, `/api/ab/personal` 404s and clients fall back to the single-blob `/api/ab` path. Use only if a stock client misbehaves on the modern path. + +--- + +## Admin dashboard URLs + +| Path | Auth | What | +|---|---|---| +| `/admin/`, `/admin/index.html` | none (login page redirects in JS) | Single-page shell | +| `/admin/login.html` | none | Sign-in form (password / TOTP / OIDC buttons) | +| `/admin/login` | none (POST form) | Password+TOTP submit → sets `rd_admin_session` cookie | +| `/admin/logout` | cookie | Clears cookie | +| `/admin/me` | cookie | Sidebar's logged-in-as widget | +| `/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 | +| `/admin/pages/devices` | cookie + admin | Devices (incl. delete) | +| `/admin/pages/groups` | cookie + admin | Device groups | +| `/admin/pages/strategies` | cookie + admin | Strategy management | +| `/admin/pages/address-books` | cookie + admin | Personal + shared books | +| `/admin/pages/oidc` | cookie + admin | Read-only OIDC provider listing | +| `/admin/pages/audit` | cookie + admin | Audit log browser | +| `/admin/pages/recordings` | cookie + admin | Recording file listing | +| `/admin/pages/deploy` | cookie + admin | `--config` blob + renamed-installer generator | + +The session cookie (`rd_admin_session`) is HttpOnly + SameSite=Strict. +The middleware accepts the same cookie *or* `Authorization: Bearer …`, +so the same auth covers `/api/*` for the desktop client and `/admin/*` +for the dashboard with no separate session model. + +--- + +## Database + +SQLite, file `db_v2.sqlite3` in hbbs's working directory. Tables created +at startup with `CREATE TABLE IF NOT EXISTS`; column additions use +`ALTER TABLE ADD COLUMN` guarded by a duplicate-column-name swallower +(SQLite < 3.35 lacks `ADD COLUMN IF NOT EXISTS`). + +Backup is a plain file copy while hbbs is stopped, or `sqlite3 +db_v2.sqlite3 .dump > backup.sql` while running. There is no +multi-instance HA; run a single hbbs against a single SQLite file. + +--- + +## Security checklist before exposing to the internet + +- TLS in front of `--http-port` (Caddy / nginx / Traefik). Required for OIDC redirect URIs in production. +- `--public-base-url` set to the *externally* reachable URL, including the scheme. +- `--bootstrap-admin-password` rotated immediately after first login (Users page → reset password). +- `--key` / `id_ed25519` not committed to source control. Treat the private key as a deploy secret. +- Audit retention (`--audit-retention-days`) set to a value that matches your data-retention policy. +- If running behind a reverse proxy: forward the original `Host:` header so OIDC redirect-URI validation matches. diff --git a/src/api/admin/auth.rs b/src/api/admin/auth.rs index 2dfaccf..672847c 100644 --- a/src/api/admin/auth.rs +++ b/src/api/admin/auth.rs @@ -20,8 +20,10 @@ pub struct LoginForm { pub username: String, pub password: String, /// 6-digit TOTP code, present on the second leg when the first leg - /// returned `tfa_check`. - #[serde(default)] + /// returned `tfa_check`. The HTML input is `name="tfaCode"` (camelCase) + /// to match the rest of the dashboard's form conventions, so we rename + /// the wire field rather than renaming the input. + #[serde(default, rename = "tfaCode")] pub tfa_code: String, /// Echo of the TOTP nonce the first-leg response set on the form. #[serde(default)] diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index a7c97c3..6baab25 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -17,6 +17,7 @@ pub mod auth; pub mod me; +pub mod oidc_login; pub mod pages; use axum::http::header; @@ -45,6 +46,11 @@ pub fn build(state: Arc) -> Option { .route("/admin/login", post(auth::login)) .route("/admin/logout", post(auth::logout)) .route("/admin/me", get(me::me)) + // OIDC entry points consumed by login.html (unauthenticated — they + // *initiate* a sign-in). The matching /oidc/callback is mounted by + // the public api router and finishes both desktop and admin flows. + .route("/admin/oidc/providers", get(oidc_login::list_providers)) + .route("/admin/login/oidc/:provider", get(oidc_login::start_login)) // Page fragments — one per sidebar entry. .route("/admin/pages/users", get(pages::users::index)) .route("/admin/pages/users/create", post(pages::users::create)) @@ -78,6 +84,10 @@ pub fn build(state: Arc) -> Option { "/admin/pages/devices/:peer_id/sysinfo-refresh", post(pages::devices::force_sysinfo), ) + .route( + "/admin/pages/devices/:peer_id/delete", + post(pages::devices::delete), + ) // Groups .route("/admin/pages/groups/create", post(pages::groups::create)) .route("/admin/pages/groups/:id/delete", post(pages::groups::delete)) @@ -102,6 +112,11 @@ pub fn build(state: Arc) -> Option { "/admin/pages/strategies/:id/delete", post(pages::strategies::delete), ) + .route("/admin/pages/deploy", get(pages::deploy::index)) + .route( + "/admin/pages/deploy/generate", + post(pages::deploy::generate), + ) .route("/admin/pages/devices", get(pages::devices::index)) .route("/admin/pages/groups", get(pages::groups::index)) .route("/admin/pages/strategies", get(pages::strategies::index)) @@ -109,6 +124,26 @@ pub fn build(state: Arc) -> Option { "/admin/pages/address-books", get(pages::address_books::index), ) + .route( + "/admin/pages/address-books/create", + post(pages::address_books::create), + ) + .route( + "/admin/pages/address-books/:guid/delete", + post(pages::address_books::delete), + ) + .route( + "/admin/pages/address-books/:guid/manage", + get(pages::address_books::manage), + ) + .route( + "/admin/pages/address-books/:guid/shares/add", + post(pages::address_books::share_add), + ) + .route( + "/admin/pages/address-books/:guid/shares/:user_id/remove", + post(pages::address_books::share_remove), + ) .route("/admin/pages/oidc", get(pages::oidc::index)) .route("/admin/pages/audit", get(pages::audit::index)) .route("/admin/pages/recordings", get(pages::recordings::index)); diff --git a/src/api/admin/oidc_login.rs b/src/api/admin/oidc_login.rs new file mode 100644 index 0000000..2ba8b31 --- /dev/null +++ b/src/api/admin/oidc_login.rs @@ -0,0 +1,107 @@ +//! OIDC login entry points for the admin dashboard. +//! +//! Two unauthenticated GET endpoints used by `admin_ui/login.html`: +//! +//! - `GET /admin/oidc/providers` returns the enabled providers as JSON so +//! the login page can render a button per provider. +//! - `GET /admin/login/oidc/:provider` creates an OIDC session marked as +//! admin-flow (via the sentinel below) and 302-redirects the browser to +//! the IdP authorization URL. After the IdP redirects to +//! `/oidc/callback`, the existing callback handler detects the sentinel +//! and finishes by setting `rd_admin_session` + redirecting to `/admin/` +//! (see api/oidc/callback.rs). +//! +//! We keep this module separate from the desktop-client OIDC flow so the +//! "device polls /api/oidc/auth-query" semantics stay untouched. + +use crate::api::error::ApiError; +use crate::api::oidc::{discovery, random_token, require_provider, OIDC_SESSION_TTL_SECS}; +use crate::api::state::AppState; +use crate::database::OidcSessionInsert; +use axum::extract::{Extension, Path}; +use axum::response::Redirect; +use axum::Json; +use serde_json::{json, Value}; +use std::sync::Arc; + +/// Sentinel stuffed into `client_id_str` / `client_uuid` of an OidcSession +/// so the callback can tell admin-UI flows apart from desktop-client flows. +/// Real device UUIDs from the desktop client are hex-formatted GUIDs and +/// won't collide. +pub const ADMIN_SENTINEL: &str = "__admin_ui__"; + +pub async fn list_providers( + Extension(state): Extension>, +) -> Json { + let mut out: Vec = Vec::new(); + if !state.cfg.public_base_url.is_empty() { + if let Ok(providers) = state.db.oidc_provider_list_enabled().await { + for p in providers { + out.push(json!({ + "name": p.name, + "display_name": p.display_name.unwrap_or_else(|| p.name.clone()), + "icon_url": p.icon_url, + })); + } + } + } + Json(json!(out)) +} + +pub async fn start_login( + Extension(state): Extension>, + Path(provider_name): Path, +) -> Result { + if state.cfg.public_base_url.is_empty() { + return Err(ApiError::Internal( + "OIDC requires --public-base-url to be set".into(), + )); + } + let provider = require_provider(&state, &provider_name).await?; + let disc = discovery::discover(&provider.issuer_url) + .await + .map_err(ApiError::Internal)?; + + let code = random_token(); + let csrf_state = random_token(); + let expires_at = chrono::Utc::now().timestamp() + OIDC_SESSION_TTL_SECS; + state + .db + .oidc_session_create(&OidcSessionInsert { + code: &code, + provider: &provider.name, + state: &csrf_state, + client_id_str: ADMIN_SENTINEL, + client_uuid: ADMIN_SENTINEL, + device_info_json: r#"{"source":"admin-ui"}"#, + expires_at, + }) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + + let url = format!( + "{auth}?response_type=code&client_id={cid}&redirect_uri={ru}&scope={scope}&state={st}", + auth = disc.authorization_endpoint, + cid = url_encode(&provider.client_id), + ru = url_encode(&provider.redirect_url), + scope = url_encode(&provider.scopes), + st = url_encode(&csrf_state), + ); + Ok(Redirect::temporary(&url)) +} + +fn url_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.as_bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(*b as char); + } + _ => { + use std::fmt::Write; + let _ = write!(out, "%{:02X}", b); + } + } + } + out +} diff --git a/src/api/admin/pages/address_books.rs b/src/api/admin/pages/address_books.rs index 4054859..64843d4 100644 --- a/src/api/admin/pages/address_books.rs +++ b/src/api/admin/pages/address_books.rs @@ -1,13 +1,17 @@ -//! Address books — read-only overview. Showing every AB on the server with -//! its owner, kind (personal/shared), and peer count. Mutations live in the -//! desktop client; admins use this page to confirm what's in place. +//! Address books — list, create shared books, manage shares, delete. +//! Personal books are owned by individual users and managed from the +//! desktop client (the dashboard refuses to mutate them). Shared books +//! are server-side artifacts: an admin creates one, then grants users +//! `read` / `read+write` / `full` access; the desktop client picks them +//! up via `/api/ab/shared/profiles` on the next AB sync (~30 s). -use super::shared::{fmt_unix, html_escape, require_admin}; +use super::shared::{fmt_unix, html_escape, notice_html, require_admin}; use crate::api::error::ApiError; use crate::api::middleware::AuthedUser; use crate::api::state::AppState; -use axum::extract::Extension; +use axum::extract::{Extension, Form, Path}; use axum::response::Html; +use serde::Deserialize; use std::fmt::Write as _; use std::sync::Arc; @@ -16,25 +20,156 @@ pub async fn index( admin: AuthedUser, ) -> Result, ApiError> { require_admin(&admin)?; + Ok(Html(render_index(&state, None).await?)) +} + +#[derive(Debug, Deserialize)] +pub struct CreateForm { + pub name: String, +} + +pub async fn create( + Extension(state): Extension>, + admin: AuthedUser, + Form(f): Form, +) -> Result, ApiError> { + require_admin(&admin)?; + let name = f.name.trim(); + if name.is_empty() { + return Ok(Html(render_index(&state, Some(("error", "Name is required."))).await?)); + } + let res = state.db.ab_create_shared(admin.user_id, name).await; + let notice = match res { + Ok(_) => Some(("ok", format!("Shared address book '{}' created.", name))), + Err(e) => { + // The unique index trips when the same admin creates two books + // with the same name. Surface that cleanly instead of leaking + // the raw SQL error. + let msg = if e.to_string().to_lowercase().contains("unique") { + "An address book with that name already exists.".to_string() + } else { + format!("Create failed: {}", e) + }; + Some(("error", msg)) + } + }; + let n = notice.as_ref().map(|(k, m)| (*k, m.as_str())); + Ok(Html(render_index(&state, n).await?)) +} + +pub async fn delete( + Extension(state): Extension>, + admin: AuthedUser, + Path(guid): Path, +) -> Result, ApiError> { + require_admin(&admin)?; + let ok = state + .db + .ab_delete(&guid) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let notice = if ok { + ("ok", "Deleted.") + } else { + ("error", "Address book not found.") + }; + Ok(Html(render_index(&state, Some(notice)).await?)) +} + +pub async fn manage( + Extension(state): Extension>, + admin: AuthedUser, + Path(guid): Path, +) -> Result, ApiError> { + require_admin(&admin)?; + Ok(Html(render_manage(&state, &guid, None).await?)) +} + +#[derive(Debug, Deserialize)] +pub struct ShareForm { + pub user_id: i64, + pub rule: i64, +} + +pub async fn share_add( + Extension(state): Extension>, + admin: AuthedUser, + Path(guid): Path, + Form(f): Form, +) -> Result, ApiError> { + require_admin(&admin)?; + if !(1..=3).contains(&f.rule) { + return Ok(Html(render_manage(&state, &guid, Some(("error", "Invalid rule."))).await?)); + } + state + .db + .ab_share_set(&guid, f.user_id, f.rule) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + Ok(Html(render_manage(&state, &guid, Some(("ok", "Share saved."))).await?)) +} + +pub async fn share_remove( + Extension(state): Extension>, + admin: AuthedUser, + Path((guid, user_id)): Path<(String, i64)>, +) -> Result, ApiError> { + require_admin(&admin)?; + let _ = state + .db + .ab_share_remove(&guid, user_id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + Ok(Html(render_manage(&state, &guid, Some(("ok", "Share removed."))).await?)) +} + +// ---------- rendering ---------- + +async fn render_index( + state: &Arc, + notice: Option<(&str, &str)>, +) -> Result { let books = state .db .ab_list_all_with_owner() .await .map_err(|e| ApiError::Internal(e.to_string()))?; + let notice_html = notice.map(|(k, m)| notice_html(k, m)).unwrap_or_default(); let mut s = String::new(); - s.push_str( - r##"
+ let _ = write!( + s, + r##"

Address books

-

Read-only. Address-book contents are mutated from the desktop client; this page surfaces who owns what and how big each book is.

-
"##, +

Personal books are owned by users and managed from their desktop client. Shared books are server-side: create one here, share it with users / rules, and the client picks it up on its next AB sync.

+ + + {notice_html} + +
+
+ + +
+ +
+"## ); if books.is_empty() { s.push_str(r##"

No address books exist yet.

"##); - return Ok(Html(s)); + return Ok(s); } s.push_str( - r##"
+ r##"
@@ -43,15 +178,60 @@ pub async fn index( + "##, ); for b in &books { - let kind = match b.kind { + let kind_pill = match b.kind { 0 => r#"personal"#, 1 => r#"shared"#, _ => "", }; + // Both kinds get a delete action. Shared books additionally get + // "Manage shares". Personal books carry an extra warning in the + // confirm because the owning user's desktop client may resync + // and recreate the book on next AB tick — deletion is "reset to + // empty", not "permanently revoked". + let actions = if b.kind == 1 { + format!( + r##"
+ ··· +
+ +
+ +
+
"##, + guid = html_escape(&b.guid), + name = html_escape(&b.name) + ) + } else { + format!( + r##"
+ ··· +
+ +
+
"##, + guid = html_escape(&b.guid), + owner = html_escape(&b.owner_username) + ) + }; let _ = write!( s, r##" @@ -61,15 +241,170 @@ pub async fn index( + "##, owner = html_escape(&b.owner_username), - kind = kind, + kind = kind_pill, name = html_escape(&b.name), count = b.peer_count, guid = html_escape(&b.guid), created = html_escape(&fmt_unix(b.created_at)), + actions = actions, ); } s.push_str("
OwnerPeers GUID CreatedActions
{count} {guid} {created}{actions}
"); - Ok(Html(s)) + Ok(s) +} + +async fn render_manage( + state: &Arc, + guid: &str, + notice: Option<(&str, &str)>, +) -> Result { + let owner_kind = state + .db + .ab_get_owner_kind(guid) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let Some((_owner_id, kind)) = owner_kind else { + return Ok(format!( + r##"
{notice}
"##, + notice = notice_html("error", "Address book not found."), + )); + }; + if kind != 1 { + return Ok(format!( + r##"
{notice}
"##, + notice = notice_html("error", "Personal address books are managed from the desktop client."), + )); + } + let shares = state + .db + .ab_list_shares(guid) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let (_total, users) = state + .db + .users_list_all(0, 1000) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let already_shared: std::collections::HashSet = + shares.iter().map(|s| s.user_id).collect(); + + let notice_html = notice.map(|(k, m)| notice_html(k, m)).unwrap_or_default(); + let mut s = String::new(); + let _ = write!( + s, + r##"
+
+
+

Manage shares

+

{guid}

+
+ +
+ + {notice_html} + +
+

Add or update a share

+
+
+ + +
+
+ + +
+ +
+
+ +
+
+ Current shares ({n}) +
+"##, + n = shares.len() + ); + if shares.is_empty() { + s.push_str(r##"

No shares yet. The book is invisible to non-owners until you add at least one user share.

"##); + return Ok(s); + } + s.push_str( + r##" + + + + + + "##, + ); + for sh in &shares { + let rule = match sh.rule { + 1 => "Read", + 2 => "Read + write", + 3 => "Full", + _ => "?", + }; + let _ = write!( + s, + r##" + + + +"##, + user = html_escape(&sh.username), + rule = rule, + guid = html_escape(guid), + uid = sh.user_id, + ); + } + s.push_str("
UserRule
{user}{rule} + +
"); + Ok(s) } diff --git a/src/api/admin/pages/deploy.rs b/src/api/admin/pages/deploy.rs new file mode 100644 index 0000000..13cfeb9 --- /dev/null +++ b/src/api/admin/pages/deploy.rs @@ -0,0 +1,249 @@ +//! Deploy page — generates a `CustomServer` blob the Windows / macOS / Linux +//! client accepts via `rustdesk --config `, plus the equivalent +//! rename-the-installer filename. The blob format is documented at +//! `/src/custom_server.rs`: JSON → URL-safe-no-pad base64 → +//! reverse-the-string. The client tries the unsigned JSON path before +//! signature verification, so we don't need the Pro private key. + +use super::shared::{html_escape, require_admin}; +use crate::api::error::ApiError; +use crate::api::middleware::AuthedUser; +use axum::extract::Form; +use axum::response::Html; +use serde::Deserialize; +use serde_json::json; + +pub async fn index(admin: AuthedUser) -> Result, ApiError> { + require_admin(&admin)?; + let pubkey = read_pubkey(); + Ok(Html(render_form(&pubkey, "", "", "", "", None))) +} + +#[derive(Debug, Deserialize)] +pub struct DeployForm { + #[serde(default)] + pub host: String, + #[serde(default)] + pub api: String, + #[serde(default)] + pub relay: String, + #[serde(default)] + pub key: String, +} + +pub async fn generate( + admin: AuthedUser, + Form(f): Form, +) -> Result, ApiError> { + require_admin(&admin)?; + if f.host.trim().is_empty() { + return Ok(Html(render_form( + &f.key, + &f.host, + &f.api, + &f.relay, + "", + Some(("error", "Host is required.")), + ))); + } + let blob = encode_blob(&f.host, &f.key, &f.api, &f.relay); + let result = render_result(&f.host, &f.key, &f.api, &f.relay, &blob); + Ok(Html(render_form( + &f.key, + &f.host, + &f.api, + &f.relay, + &result, + None, + ))) +} + +// ---------- helpers ---------- + +/// Best-effort read of the server's public key from `id_ed25519.pub` in CWD — +/// the same path `common::gen_sk` writes it to. If the file is missing +/// (operator passed `--key` explicitly, or the binary runs from a directory +/// they can't read), the field is left blank for them to paste. +fn read_pubkey() -> String { + std::fs::read_to_string("id_ed25519.pub") + .ok() + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// Encode a `CustomServer` payload the way the client's +/// `get_custom_server_from_config_string` expects: JSON → URL-safe-no-pad +/// base64 → reverse the resulting string. The client reverses it back, base64 +/// decodes, then JSON parses. +fn encode_blob(host: &str, key: &str, api: &str, relay: &str) -> String { + let payload = json!({ + "host": host, + "key": key, + "api": api, + "relay": relay, + }); + let b64 = base64::encode_config(payload.to_string().as_bytes(), base64::URL_SAFE_NO_PAD); + b64.chars().rev().collect() +} + +fn render_form( + key: &str, + host: &str, + api: &str, + relay: &str, + result_html: &str, + notice: Option<(&str, &str)>, +) -> String { + let notice_html = match notice { + Some((kind, msg)) => super::shared::notice_html(kind, msg), + None => String::new(), + }; + format!( + r##"
+
+

Deploy

+

Generate a config blob the stock client accepts via rustdesk --config <blob>, or the equivalent renamed-installer filename. The public key below is read from id_ed25519.pub on the server; override if you bootstrapped a custom keypair.

+
+ + {notice_html} + +
+
+ + +

The hostname or IP clients reach hbbs at (TCP/UDP 21116).

+
+ +
+ + +

Full URL of this admin/login API. Leave blank to disable login on the client.

+
+ +
+ + +

Only set if hbbr runs on a separate host; otherwise leave blank.

+
+ +
+ + +

Base64 contents of id_ed25519.pub.

+
+ + +
+ + {result_html} +
"##, + host = html_escape(host), + api = html_escape(api), + relay = html_escape(relay), + key = html_escape(key), + notice_html = notice_html, + result_html = result_html, + ) +} + +fn render_result(host: &str, key: &str, api: &str, relay: &str, blob: &str) -> String { + // Build the rename-the-installer alternative. Windows filenames disallow + // `:` and `/`, which the API URL is full of (`http://host:21114`). The + // client falls back to `http://:21114` when `api` is empty + // (rustdesk/src/common.rs:get_api_server_), so we can omit the API field + // from the filename whenever it matches that default. If the operator + // supplied a non-default API URL we still build a "renamed" string for + // reference but mark it as unusable on Windows and steer them to the + // --config path. + let default_api = format!("http://{}:21114", host); + let api_is_default = api.is_empty() || api == default_api; + let unsafe_chars = str_has_filename_unsafe_chars(api) && !api_is_default; + + let mut renamed = format!("rustdesk-host={}", host); + if !key.is_empty() { + renamed.push_str(&format!(",key={}", key)); + } + if !api_is_default && !unsafe_chars { + renamed.push_str(&format!(",api={}", api)); + } + if !relay.is_empty() && !str_has_filename_unsafe_chars(relay) { + renamed.push_str(&format!(",relay={}", relay)); + } + renamed.push_str(".exe"); + + let renamed_note = if unsafe_chars { + r##"

⚠ Your API URL contains : or /, which Windows forbids in filenames. The renamed-installer approach cannot carry it — use approach A above instead.

"##.to_string() + } else if !api_is_default { + format!( + r##"

⚠ Your API URL ({api_disp}) is not the default http://<host>:21114. The client will auto-derive the default from the rendezvous host on first launch, so this filename will deploy with the wrong API URL. Use approach A instead.

"##, + api_disp = html_escape(api) + ) + } else if !api.is_empty() { + r##"

API URL omitted from filename (Windows can't store : / /); the client auto-derives http://<host>:21114 from the rendezvous host.

"##.to_string() + } else { + String::new() + }; + + let licensed = format!("rustdesk-licensed-{}", blob); + let cmd_win = format!( + r#""C:\Program Files\RustDesk\rustdesk.exe" --config {licensed}"#, + licensed = licensed + ); + let cmd_unix = format!("rustdesk --config {}", licensed); + + format!( + r##"
+
+

Deployment artifact

+

Pick whichever path fits your rollout. Both produce the same client config.

+
+ +
+ +
{cmd_win}
+

Requires the client to be installed and running as admin. Equivalent on macOS/Linux: {cmd_unix}.

+
+ +
+ +
{renamed}
+

Rename the official RustDesk installer to this exact name and run it; the client reads its own filename on first launch and writes the config into the registry.

+ {renamed_note} +
+ +
+ Raw blob +
{blob}
+
+
"##, + cmd_win = html_escape(&cmd_win), + cmd_unix = html_escape(&cmd_unix), + renamed = html_escape(&renamed), + renamed_note = renamed_note, + blob = html_escape(blob), + ) +} + +/// Rough check for characters Windows disallows in filenames. We don't try +/// to be exhaustive (NUL, control chars etc. won't realistically appear in a +/// hostname or URL), just the ones a typical URL/relay value will trip on. +fn str_has_filename_unsafe_chars(s: &str) -> bool { + s.chars() + .any(|c| matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')) +} diff --git a/src/api/admin/pages/devices.rs b/src/api/admin/pages/devices.rs index b713541..9da3b63 100644 --- a/src/api/admin/pages/devices.rs +++ b/src/api/admin/pages/devices.rs @@ -75,6 +75,25 @@ pub async fn force_sysinfo( .await } +pub async fn delete( + Extension(state): Extension>, + admin: AuthedUser, + Path(peer_id): Path, +) -> Result, ApiError> { + require_admin(&admin)?; + let ok = state + .db + .device_delete(&peer_id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let msg = if ok { + format!("Deleted device {}.", peer_id) + } else { + "Device already gone.".to_string() + }; + notice_then_table(&state, if ok { "ok" } else { "error" }, &msg).await +} + // ---------- helpers ---------- async fn render_table(state: &Arc) -> Result { @@ -161,6 +180,13 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow) { hx-target="#devices-region" hx-swap="innerHTML"> Force sysinfo refresh +
+ diff --git a/src/api/admin/pages/mod.rs b/src/api/admin/pages/mod.rs index 6180958..06cab25 100644 --- a/src/api/admin/pages/mod.rs +++ b/src/api/admin/pages/mod.rs @@ -3,6 +3,7 @@ pub mod address_books; pub mod audit; +pub mod deploy; pub mod devices; pub mod groups; pub mod oidc; diff --git a/src/api/auth.rs b/src/api/auth.rs index d28395d..541ec37 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -75,9 +75,16 @@ pub async fn login( Extension(state): Extension>, Json(req): Json, ) -> Result, ApiError> { - // Branch on `type`. Empty / "account" is the password path; "tfa_code" - // is the second leg of a TOTP challenge issued earlier in this same - // dance. Reject anything else for now — M4 will add email_code etc. + // The desktop client reuses the email-code dialog for the TOTP second + // leg: it POSTs `type: "email_code"` with `tfaCode` set (and the email + // `verificationCode` field also set, but we ignore that when tfaCode is + // present). Detect that shape up-front and route to the TOTP verifier; + // otherwise dispatch on the declared `type`. + let has_tfa = req.tfa_code.as_deref().is_some_and(|s| !s.is_empty()) + && req.secret.as_deref().is_some_and(|s| !s.is_empty()); + if has_tfa { + return login_tfa_code(state, req).await; + } let kind = req.kind.as_deref().unwrap_or("account"); match kind { "account" | "" => login_account(state, req).await, @@ -213,14 +220,22 @@ async fn login_account( // 2FA gate: if the user has TOTP enrolled, mint a short-lived nonce and // tell the client we want the TOTP code in a follow-up POST. The client // echoes the nonce back as `secret`. + // + // Wire shape matches the Flutter client's expectations + // (flutter/lib/common/widgets/login.dart:485): the outer `type` is the + // generic `email_check` envelope (the dialog the client opens for any + // second-leg challenge), and `tfa_type` distinguishes TOTP (`tfa_check`) + // from email (`email_check`). Returning `type:"tfa_check"` directly + // would miss the switch's only branch and surface as the unhelpful + // "bad response from server" toast. if state.db.totp_get_secret(user.id).await?.is_some() { let nonce = state .db .tfa_challenge_create(user.id, TFA_CHALLENGE_TTL_SECS) .await?; return Ok(Json(json!({ - "type": "tfa_check", - "tfa_type": "totp", + "type": "email_check", + "tfa_type": "tfa_check", "secret": nonce, }))); } diff --git a/src/api/oidc/callback.rs b/src/api/oidc/callback.rs index a483092..1182f5f 100644 --- a/src/api/oidc/callback.rs +++ b/src/api/oidc/callback.rs @@ -6,12 +6,15 @@ //! The browser sees a small "you can close this window" page; the desktop //! client picks up the token via `/api/oidc/auth-query`. +use crate::api::admin::oidc_login::ADMIN_SENTINEL; use crate::api::auth::mint_token; -use crate::api::middleware::sha256_token; +use crate::api::middleware::{sha256_token, SESSION_COOKIE}; use crate::api::oidc::{discovery, require_provider}; use crate::api::state::AppState; use axum::extract::{Extension, Query}; -use axum::response::Html; +use axum::http::header::{LOCATION, SET_COOKIE}; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::response::{Html, IntoResponse, Response}; use serde::Deserialize; use serde_json::Value; use std::sync::Arc; @@ -33,17 +36,50 @@ pub struct CallbackQuery { pub async fn callback( Extension(state): Extension>, Query(q): Query, -) -> Html { - match handle(state, q).await { - Ok(()) => Html(html_page( +) -> Response { + match handle(state.clone(), q).await { + Ok(ok) if ok.is_admin_flow => { + if !ok.user_is_admin { + return Html(html_page( + "Sign-in failed", + "This account does not have admin access. Ask an existing admin to grant it on the Users page, then try again.", + )) + .into_response(); + } + // Set the dashboard session cookie and redirect to /admin/. + // Same cookie shape /admin/login uses on success. + let cookie = format!( + "{name}={token}; HttpOnly; Path=/; SameSite=Strict; Max-Age={ttl}", + name = SESSION_COOKIE, + token = ok.token, + ttl = state.cfg.session_ttl_secs, + ); + let mut headers = HeaderMap::new(); + if let Ok(v) = HeaderValue::from_str(&cookie) { + headers.insert(SET_COOKIE, v); + } + headers.insert(LOCATION, HeaderValue::from_static("/admin/")); + (StatusCode::SEE_OTHER, headers).into_response() + } + Ok(_) => Html(html_page( "Sign-in complete", "You can close this window and return to RustDesk.", - )), - Err(msg) => Html(html_page("Sign-in failed", &html_escape(&msg))), + )) + .into_response(), + Err(msg) => Html(html_page("Sign-in failed", &html_escape(&msg))).into_response(), } } -async fn handle(state: Arc, q: CallbackQuery) -> Result<(), String> { +struct HandleOk { + /// Bearer token freshly minted for the local user. For the admin flow + /// we set it as `rd_admin_session`; for the desktop flow it's already + /// stashed on the OidcSession row for `/api/oidc/auth-query` polling. + token: String, + user_is_admin: bool, + is_admin_flow: bool, +} + +async fn handle(state: Arc, q: CallbackQuery) -> Result { if q.state.is_empty() { return Err("missing state parameter".into()); } @@ -123,9 +159,22 @@ async fn handle(state: Arc, q: CallbackQuery) -> Result<(), String> { .and_then(|v| v.as_str()) .or_else(|| userinfo.get("preferred_username").and_then(|v| v.as_str())); + // Optional role-based admin sync. When the provider is configured with + // `admin_role`, we look up the userinfo claim at `roles_claim` (default + // "roles") and set is_admin accordingly. Two shapes are supported: + // - object: presence of the role name as a key + // (Zitadel default: `"urn:zitadel:iam:org:project:roles": + // {"admin": {"": ""}}`) + // - array of strings: presence of the role name as an element + // (e.g. a custom claim mapping `"roles": ["admin", "user"]`) + let desired_admin = provider.admin_role.as_deref().map(|role| { + let claim_name = provider.roles_claim.as_deref().unwrap_or("roles"); + eval_admin_role(&userinfo, claim_name, role) + }); + let user = state .db - .user_upsert_oidc(sub, email, display_name) + .user_upsert_oidc(sub, email, display_name, desired_admin) .await .map_err(|e| e.to_string())?; if user.status == 0 { @@ -135,30 +184,50 @@ async fn handle(state: Arc, q: CallbackQuery) -> Result<(), String> { // Mint our own access token, store hashed, mark session complete. let token = mint_token(); let sha = sha256_token(&token); + let is_admin_flow = session.client_uuid == ADMIN_SENTINEL; + // For admin-UI OIDC the "device id/uuid" fields carry the sentinel — + // don't pollute the tokens.peer_* columns with it. + let (token_peer_id, token_peer_uuid): (&str, &str) = if is_admin_flow { + ("", "") + } else { + ( + session.client_id_str.as_str(), + session.client_uuid.as_str(), + ) + }; state .db .token_insert( user.id, &sha, - &session.client_id_str, - &session.client_uuid, + token_peer_id, + token_peer_uuid, &session.device_info_json, state.cfg.session_ttl_secs, ) .await .map_err(|e| e.to_string())?; - // Best-effort device claim — same path as `/api/login`. - state - .db - .device_claim(user.id, &session.client_id_str, &session.client_uuid) - .await; + // Best-effort device claim — same path as `/api/login`. Skipped for + // admin-UI flow because the "device" is the operator's browser, not a + // real RustDesk peer; calling device_claim with the sentinel would + // insert a phantom row in device_sysinfo. + if !is_admin_flow { + state + .db + .device_claim(user.id, &session.client_id_str, &session.client_uuid) + .await; + } state .db .oidc_session_complete(&session.code, &token, user.id) .await .map_err(|e| e.to_string())?; - Ok(()) + Ok(HandleOk { + token, + user_is_admin: user.is_admin, + is_admin_flow, + }) } fn html_page(title: &str, body: &str) -> String { @@ -189,3 +258,55 @@ fn html_escape(s: &str) -> String { .replace('<', "<") .replace('>', ">") } + +/// Returns true iff the userinfo's `claim_name` field carries `role` — +/// either as an object key (Zitadel) or as an element of a string array +/// (generic). Anything else (missing claim, wrong type, role not present) +/// is treated as "not admin" so a misconfigured claim demotes rather than +/// silently grants. +fn eval_admin_role(userinfo: &Value, claim_name: &str, role: &str) -> bool { + let Some(node) = userinfo.get(claim_name) else { + return false; + }; + if let Some(obj) = node.as_object() { + return obj.contains_key(role); + } + if let Some(arr) = node.as_array() { + return arr + .iter() + .any(|v| v.as_str().map(|s| s == role).unwrap_or(false)); + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn admin_role_zitadel_object_shape() { + let u = json!({ + "sub": "1", + "urn:zitadel:iam:org:project:roles": { + "admin": {"123": "myorg"}, + "user": {"123": "myorg"}, + }, + }); + assert!(eval_admin_role(&u, "urn:zitadel:iam:org:project:roles", "admin")); + assert!(!eval_admin_role(&u, "urn:zitadel:iam:org:project:roles", "owner")); + } + + #[test] + fn admin_role_generic_array_shape() { + let u = json!({"sub": "1", "roles": ["admin", "user"]}); + assert!(eval_admin_role(&u, "roles", "admin")); + assert!(!eval_admin_role(&u, "roles", "owner")); + } + + #[test] + fn admin_role_missing_claim_is_not_admin() { + let u = json!({"sub": "1"}); + assert!(!eval_admin_role(&u, "roles", "admin")); + } +} diff --git a/src/api/oidc/mod.rs b/src/api/oidc/mod.rs index 81287c5..9196a24 100644 --- a/src/api/oidc/mod.rs +++ b/src/api/oidc/mod.rs @@ -27,7 +27,7 @@ use crate::api::error::ApiError; use crate::api::state::AppState; use crate::database::OidcProviderRow; -const OIDC_SESSION_TTL_SECS: i64 = 600; // 10 minutes — the user has to sign in fast +pub(crate) const OIDC_SESSION_TTL_SECS: i64 = 600; // 10 minutes — the user has to sign in fast /// Convenience: resolve a provider name to its row, or an ApiError if it /// doesn't exist or is disabled. diff --git a/src/api/oidc/poll.rs b/src/api/oidc/poll.rs index 1d18879..fa12ba2 100644 --- a/src/api/oidc/poll.rs +++ b/src/api/oidc/poll.rs @@ -1,13 +1,17 @@ -//! `GET /api/oidc/auth-query?code=&id=&uuid=` — client poll loop. +//! `GET /api/oidc/auth-query?code=&id=&uuid=` — desktop-client poll loop. //! -//! The Flutter client (src/hbbs_http/account.rs) wraps the response in an -//! outer envelope where the `body` field is itself JSON. We mirror that: +//! Wire shape: return the inner payload as the HTTP body directly. Do NOT +//! wrap in another `{ "body": ... }` envelope — the desktop client's +//! transport (`http_request_sync` in src/common.rs) already wraps every +//! response in `{ status_code, headers, body }` and feeds the inner `body` +//! string to `HbbHttpResponse::parse`. An extra envelope makes the parser +//! see `{"body": "..."}`, fail to deserialize as `AuthBody`, and silently +//! retry until the 180 s client timeout. Spent half a day on this — keep +//! the bare shape. //! -//! `{ "body": "" }` -//! -//! The inner JSON is one of: +//! Inner payloads: //! - while pending: `{"error":"No authed oidc is found"}` — client keeps polling. -//! - on success: the standard AuthBody (`{access_token, type:"access_token", user}`). +//! - on success: `{access_token, type:"access_token", user}` — client stops. //! - on error: `{"error":""}` — client surfaces and stops polling. use crate::api::error::ApiError; @@ -39,19 +43,26 @@ pub async fn auth_query( .await .map_err(|e| ApiError::Internal(e.to_string()))? .ok_or_else(|| ApiError::BadRequest("unknown oidc session".into()))?; + hbb_common::log::info!( + "oidc poll: code={} status={} user_id={:?} elapsed_to_expiry={}", + q.code, + session.status, + session.user_id, + session.expires_at - now, + ); if session.expires_at <= now && session.status == "pending" { // The client treats this as an ordinary "still pending" tick and // gives up on its own timeout (180 s). - return Ok(wrap_inner(json!({"error": "No authed oidc is found"}))); + return Ok(Json(json!({"error": "No authed oidc is found"}))); } match session.status.as_str() { - "pending" => Ok(wrap_inner(json!({"error": "No authed oidc is found"}))), + "pending" => Ok(Json(json!({"error": "No authed oidc is found"}))), "error" => { let msg = session .error .clone() .unwrap_or_else(|| "OIDC sign-in failed".to_string()); - Ok(wrap_inner(json!({ "error": msg }))) + Ok(Json(json!({ "error": msg }))) } "success" => { let access_token = session @@ -67,12 +78,11 @@ pub async fn auth_query( .await .map_err(|e| ApiError::Internal(e.to_string()))? .ok_or_else(|| ApiError::Internal("user vanished mid-flow".into()))?; - let body = json!({ + Ok(Json(json!({ "access_token": access_token, "type": "access_token", "user": UserPayload::from(&user), - }); - Ok(wrap_inner(body)) + }))) } other => Err(ApiError::Internal(format!( "unknown oidc status {:?}", @@ -80,7 +90,3 @@ pub async fn auth_query( ))), } } - -fn wrap_inner(inner: Value) -> Json { - Json(json!({ "body": inner.to_string() })) -} diff --git a/src/api/oidc/providers.rs b/src/api/oidc/providers.rs index 2bccad4..8bb2d0c 100644 --- a/src/api/oidc/providers.rs +++ b/src/api/oidc/providers.rs @@ -45,6 +45,16 @@ struct ProviderEntry { redirect_url: Option, #[serde(default = "default_true")] enabled: bool, + /// Role-based admin sync. Set both to drive `is_admin` from the IdP: + /// admin_role = "admin" + /// roles_claim = "urn:zitadel:iam:org:project:roles" # Zitadel + /// Or for a generic IdP that emits `roles: ["admin","user"]`: + /// admin_role = "admin" + /// # roles_claim defaults to "roles" + #[serde(default)] + admin_role: Option, + #[serde(default)] + roles_claim: Option, } fn default_scopes() -> String { @@ -83,6 +93,8 @@ pub async fn load_from_file( scopes: p.scopes, redirect_url, enabled: p.enabled, + admin_role: p.admin_role.filter(|s| !s.is_empty()), + roles_claim: p.roles_claim.filter(|s| !s.is_empty()), }; db.oidc_provider_upsert(&row) .await diff --git a/src/api/users.rs b/src/api/users.rs index abadf54..37620a3 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -7,6 +7,7 @@ use axum::extract::{Extension, Query}; use axum::Json; use hbb_common::ResultType; use serde::Serialize; +use serde_json::{json, Value}; use std::sync::Arc; #[derive(Debug, Serialize)] @@ -18,6 +19,13 @@ pub struct UserPayload { pub note: String, pub status: i64, pub is_admin: bool, + /// The desktop client's OIDC poll loop deserializes the AuthBody using + /// the Rust struct in src/hbbs_http/account.rs, where `info` is a + /// REQUIRED field (no #[serde(default)]). Missing it makes serde fail, + /// the poll loop's `Ok(_)` arm fires, and the client polls forever + /// even though the OIDC session was successful. Emit an empty object + /// — the client's own UserInfo defaults handle the rest. + pub info: Value, } impl From<&UserRow> for UserPayload { @@ -30,6 +38,7 @@ impl From<&UserRow> for UserPayload { note: u.note.clone(), status: u.status, is_admin: u.is_admin, + info: json!({}), } } } diff --git a/src/database.rs b/src/database.rs index 80cbccc..251f50c 100644 --- a/src/database.rs +++ b/src/database.rs @@ -118,6 +118,15 @@ pub struct AbOverviewRow { pub created_at: i64, } +#[derive(Debug, Clone)] +pub struct AbShareDetailRow { + pub user_id: i64, + pub username: String, + /// 1=read, 2=read+write, 3=full (matches the desktop client's enum + /// in src/hbbs_http/account.rs and the §4.3 wire contract). + pub rule: i64, +} + #[derive(Debug, Clone)] pub struct StrategyRow { pub id: i64, @@ -234,6 +243,15 @@ pub struct OidcProviderRow { pub scopes: String, pub redirect_url: String, pub enabled: bool, + /// If `Some`, every successful sign-in via this provider sets the local + /// user's `is_admin` to whether the userinfo claim at `roles_claim` + /// contains this role name. `None` means "don't touch is_admin" + /// (existing behavior — admins are managed in the dashboard). + pub admin_role: Option, + /// Userinfo claim that holds the user's roles. Defaults to `"roles"` + /// when `admin_role` is set but this is not. Zitadel's default format + /// lives at `"urn:zitadel:iam:org:project:roles"`. + pub roles_claim: Option, } pub struct OidcSessionInsert<'a> { @@ -497,6 +515,35 @@ impl Database { Ok(res.rows_affected() > 0) } + /// Drop a device by `peer_id`. Removes the dashboard-visible row + /// (`device_sysinfo`), the rendezvous-side identity (`peer` — so a + /// stale public key doesn't reject a fresh reinstall under the same + /// ID), and any pending peer-scoped operational state + /// (`heartbeat_commands`, peer-scoped `strategy_assignments`). + /// Audit rows, recordings, and address-book entries are intentionally + /// preserved — they're historical/manual data the operator may still + /// want even after the device is gone. Returns `true` iff a + /// `device_sysinfo` row actually existed. + pub async fn device_delete(&self, peer_id: &str) -> ResultType { + let _ = sqlx::query("DELETE FROM heartbeat_commands WHERE peer_id = ?") + .bind(peer_id) + .execute(self.pool.get().await?.deref_mut()) + .await; + let _ = sqlx::query("DELETE FROM strategy_assignments WHERE peer_id = ?") + .bind(peer_id) + .execute(self.pool.get().await?.deref_mut()) + .await; + let _ = sqlx::query("DELETE FROM peer WHERE id = ?") + .bind(peer_id) + .execute(self.pool.get().await?.deref_mut()) + .await; + let res = sqlx::query("DELETE FROM device_sysinfo WHERE id = ?") + .bind(peer_id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(res.rows_affected() > 0) + } + /// Devices listed for the dashboard. Returns each row of device_sysinfo /// joined to its owner's username, sorted by recency. pub async fn devices_list_all( @@ -1036,6 +1083,132 @@ impl Database { } } + /// Create a new shared (kind=1) address book owned by `owner_user_id`. + /// The unique index on (owner_user_id, kind, name) means a duplicate + /// name from the same owner surfaces as a SQL error — caller should + /// translate to a friendly notice. + pub async fn ab_create_shared( + &self, + owner_user_id: i64, + name: &str, + ) -> ResultType { + let guid = uuid::Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO address_books(guid, owner_user_id, name, kind) VALUES(?, ?, ?, 1)", + ) + .bind(&guid) + .bind(owner_user_id) + .bind(name) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(guid) + } + + /// Drop an address book and every dependent row. Used by the dashboard + /// to remove shared books; personal books are protected at the handler + /// layer (the desktop client owns those). + pub async fn ab_delete(&self, guid: &str) -> ResultType { + let _ = sqlx::query("DELETE FROM address_book_peer_tags WHERE ab_guid = ?") + .bind(guid) + .execute(self.pool.get().await?.deref_mut()) + .await; + let _ = sqlx::query("DELETE FROM address_book_tags WHERE ab_guid = ?") + .bind(guid) + .execute(self.pool.get().await?.deref_mut()) + .await; + let _ = sqlx::query("DELETE FROM address_book_peers WHERE ab_guid = ?") + .bind(guid) + .execute(self.pool.get().await?.deref_mut()) + .await; + let _ = sqlx::query("DELETE FROM address_book_shares WHERE ab_guid = ?") + .bind(guid) + .execute(self.pool.get().await?.deref_mut()) + .await; + let res = sqlx::query("DELETE FROM address_books WHERE guid = ?") + .bind(guid) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(res.rows_affected() > 0) + } + + /// Returns (owner_user_id, kind) for a book, or None if unknown. + /// Handlers use this to check ownership and to refuse mutations on + /// personal (kind=0) books. + pub async fn ab_get_owner_kind(&self, guid: &str) -> ResultType> { + let row = + sqlx::query("SELECT owner_user_id, kind FROM address_books WHERE guid = ? LIMIT 1") + .bind(guid) + .fetch_optional(self.pool.get().await?.deref_mut()) + .await?; + Ok(row.map(|r| { + let owner: i64 = r.try_get("owner_user_id").unwrap_or(0); + let kind: i64 = r.try_get("kind").unwrap_or(0); + (owner, kind) + })) + } + + /// Per-user shares attached to a book (group shares aren't surfaced in + /// the dashboard yet — operators can use device-groups when they want + /// many users at once and assign by group via SQL; the common case is + /// per-user). Returns rows of (user_id, username, rule) for direct + /// `address_book_shares.user_id IS NOT NULL` rows. + pub async fn ab_list_shares(&self, guid: &str) -> ResultType> { + let rows = sqlx::query( + "SELECT s.user_id, COALESCE(u.username,'') AS username, s.rule \ + FROM address_book_shares s LEFT JOIN users u ON u.id = s.user_id \ + WHERE s.ab_guid = ? AND s.user_id IS NOT NULL \ + ORDER BY u.username", + ) + .bind(guid) + .fetch_all(self.pool.get().await?.deref_mut()) + .await?; + Ok(rows + .into_iter() + .map(|r| AbShareDetailRow { + user_id: r.try_get("user_id").unwrap_or(0), + username: r.try_get("username").unwrap_or_default(), + rule: r.try_get("rule").unwrap_or(1), + }) + .collect()) + } + + /// Idempotent upsert of a per-user share. Replaces an existing rule + /// row for the same (ab, user) pair so admins can promote/demote + /// without having to remove first. + pub async fn ab_share_set( + &self, + guid: &str, + user_id: i64, + rule: i64, + ) -> ResultType<()> { + let _ = sqlx::query( + "DELETE FROM address_book_shares WHERE ab_guid = ? AND user_id = ?", + ) + .bind(guid) + .bind(user_id) + .execute(self.pool.get().await?.deref_mut()) + .await; + sqlx::query( + "INSERT INTO address_book_shares(ab_guid, user_id, rule) VALUES(?, ?, ?)", + ) + .bind(guid) + .bind(user_id) + .bind(rule) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(()) + } + + pub async fn ab_share_remove(&self, guid: &str, user_id: i64) -> ResultType { + let res = + sqlx::query("DELETE FROM address_book_shares WHERE ab_guid = ? AND user_id = ?") + .bind(guid) + .bind(user_id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(res.rows_affected() > 0) + } + /// Look up the personal AB for a user, creating it if missing. pub async fn ab_get_or_create_personal(&self, user_id: i64) -> ResultType { let row = sqlx::query("SELECT guid FROM address_books WHERE owner_user_id = ? AND kind = 0") @@ -2221,8 +2394,9 @@ impl Database { pub async fn oidc_provider_upsert(&self, p: &OidcProviderRow) -> ResultType<()> { sqlx::query( "INSERT INTO oidc_providers(name, display_name, icon_url, issuer_url, \ - client_id, client_secret, scopes, redirect_url, enabled) \ - VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) \ + client_id, client_secret, scopes, redirect_url, enabled, \ + admin_role, roles_claim) \ + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ ON CONFLICT(name) DO UPDATE SET \ display_name = excluded.display_name, \ icon_url = excluded.icon_url, \ @@ -2231,7 +2405,9 @@ impl Database { client_secret = excluded.client_secret, \ scopes = excluded.scopes, \ redirect_url = excluded.redirect_url, \ - enabled = excluded.enabled", + enabled = excluded.enabled, \ + admin_role = excluded.admin_role, \ + roles_claim = excluded.roles_claim", ) .bind(&p.name) .bind(p.display_name.as_deref()) @@ -2242,6 +2418,8 @@ impl Database { .bind(&p.scopes) .bind(&p.redirect_url) .bind(if p.enabled { 1 } else { 0 }) + .bind(p.admin_role.as_deref()) + .bind(p.roles_claim.as_deref()) .execute(self.pool.get().await?.deref_mut()) .await?; Ok(()) @@ -2250,7 +2428,7 @@ impl Database { pub async fn oidc_provider_get(&self, name: &str) -> ResultType> { let row = sqlx::query( "SELECT name, display_name, icon_url, issuer_url, client_id, client_secret, \ - scopes, redirect_url, enabled \ + scopes, redirect_url, enabled, admin_role, roles_claim \ FROM oidc_providers WHERE name = ? AND enabled = 1", ) .bind(name) @@ -2262,7 +2440,7 @@ impl Database { pub async fn oidc_provider_list_enabled(&self) -> ResultType> { let rows = sqlx::query( "SELECT name, display_name, icon_url, issuer_url, client_id, client_secret, \ - scopes, redirect_url, enabled \ + scopes, redirect_url, enabled, admin_role, roles_claim \ FROM oidc_providers WHERE enabled = 1 ORDER BY name", ) .fetch_all(self.pool.get().await?.deref_mut()) @@ -2377,11 +2555,18 @@ impl Database { /// Create or update a user from an OIDC identity. The local username is /// either the email (preferred) or the sub if no email. Subsequent /// logins re-use the same row via oidc_subject. + /// Find-or-create a local user from an OIDC sign-in. When + /// `desired_admin` is `Some(b)`, the user's `is_admin` flag is forced + /// to `b` on every login (used when role-based admin sync is configured + /// on the provider — both promotion and demotion at the IdP propagate). + /// `None` leaves `is_admin` untouched on existing rows and defaults to + /// `false` for new ones. pub async fn user_upsert_oidc( &self, oidc_subject: &str, email: Option<&str>, display_name: Option<&str>, + desired_admin: Option, ) -> ResultType { let username = email .filter(|s| !s.is_empty()) @@ -2400,6 +2585,13 @@ impl Database { .bind(existing.id) .execute(self.pool.get().await?.deref_mut()) .await?; + if let Some(want) = desired_admin { + sqlx::query("UPDATE users SET is_admin = ? WHERE id = ?") + .bind(if want { 1i64 } else { 0 }) + .bind(existing.id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + } return Ok(self .user_find_by_id(existing.id) .await? @@ -2407,13 +2599,15 @@ impl Database { } // New user. Empty password_hash blocks password login until the // operator (or the user) sets one. + let initial_admin: i64 = if matches!(desired_admin, Some(true)) { 1 } else { 0 }; sqlx::query( "INSERT INTO users(username, password_hash, display_name, email, status, is_admin, oidc_subject) \ - VALUES(?, '', ?, ?, 1, 0, ?)", + VALUES(?, '', ?, ?, 1, ?, ?)", ) .bind(&username) .bind(display_name.unwrap_or("")) .bind(email.unwrap_or("")) + .bind(initial_admin) .bind(oidc_subject) .execute(self.pool.get().await?.deref_mut()) .await?; @@ -2561,6 +2755,14 @@ fn row_to_oidc_provider(row: sqlx::sqlite::SqliteRow) -> OidcProviderRow { .unwrap_or_else(|_| "openid email profile".to_string()), redirect_url: row.try_get("redirect_url").unwrap_or_default(), enabled: enabled != 0, + admin_role: row + .try_get::, _>("admin_role") + .ok() + .flatten(), + roles_claim: row + .try_get::, _>("roles_claim") + .ok() + .flatten(), } } @@ -2703,6 +2905,12 @@ const M2_SOFT_ALTERS: &[&str] = &[ // OIDC `sub` claim, used to map an IdP identity to a local user across // sessions. Nullable so password-only users keep working. "ALTER TABLE users ADD COLUMN oidc_subject TEXT", + // Optional role-based admin sync. When `admin_role` is non-NULL the + // OIDC callback evaluates the userinfo claim at `roles_claim` + // (defaulting to "roles") and sets is_admin accordingly on every + // login — promotion AND demotion at the IdP propagate. + "ALTER TABLE oidc_providers ADD COLUMN admin_role TEXT", + "ALTER TABLE oidc_providers ADD COLUMN roles_claim TEXT", ]; const M3_SCHEMA: &[&str] = &[