diff --git a/admin_ui/index.html b/admin_ui/index.html index ec21291..595e546 100644 --- a/admin_ui/index.html +++ b/admin_ui/index.html @@ -37,12 +37,8 @@ hx-get="/admin/pages/strategies" hx-target="#main" hx-push-url="#strategies">Strategies Address books - OIDC providers Audit log - Recordings Deploy diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index 9808596..fd06cfc 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -121,6 +121,14 @@ pub fn build(state: Arc) -> Option { "/admin/pages/groups/:id/members/:user_id/remove", post(pages::groups::remove_member), ) + .route( + "/admin/pages/groups/:id/peers/add", + post(pages::groups::add_peer), + ) + .route( + "/admin/pages/groups/:id/peers/:peer_id/remove", + post(pages::groups::remove_peer), + ) // Strategies .route( "/admin/pages/strategies/create", @@ -181,7 +189,6 @@ pub fn build(state: Arc) -> Option { "/admin/pages/address-books/:guid/shares/:user_id/remove", post(pages::address_books::share_remove), ) - .route("/admin/pages/oidc", get(pages::oidc::index)) // Self-service profile — cookie-only, no admin gate. .route("/admin/pages/profile", get(pages::profile::index)) .route( @@ -204,8 +211,7 @@ pub fn build(state: Arc) -> Option { "/admin/pages/profile/totp/remove", post(pages::profile::totp_remove), ) - .route("/admin/pages/audit", get(pages::audit::index)) - .route("/admin/pages/recordings", get(pages::recordings::index)); + .route("/admin/pages/audit", get(pages::audit::index)); hbb_common::log::info!( "admin dashboard mounted at /admin (HTML embedded; --admin-ui-dir is informational)" ); diff --git a/src/api/admin/pages/deploy.rs b/src/api/admin/pages/deploy.rs index 0195b61..e206bd1 100644 --- a/src/api/admin/pages/deploy.rs +++ b/src/api/admin/pages/deploy.rs @@ -253,12 +253,13 @@ fn render_result(host: &str, key: &str, api: &str, relay: &str, blob: &str) -> S licensed = licensed ); let cmd_unix = format!("rustdesk --config {}", licensed); + let cmd_hello = format!("hello-agent.exe --install --config {}", blob); format!( r##"

Deployment artifact

-

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

+

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

@@ -274,6 +275,12 @@ fn render_result(host: &str, key: &str, api: &str, relay: &str, blob: &str) -> S {renamed_note}
+
+ +
{cmd_hello}
+

Headless agent — registers the Windows service and imports this config in a single command. Run elevated.

+
+
Raw blob
{blob}
@@ -281,6 +288,7 @@ fn render_result(host: &str, key: &str, api: &str, relay: &str, blob: &str) -> S
"##, cmd_win = html_escape(&cmd_win), cmd_unix = html_escape(&cmd_unix), + cmd_hello = html_escape(&cmd_hello), renamed = html_escape(&renamed), renamed_note = renamed_note, blob = html_escape(blob), diff --git a/src/api/admin/pages/groups.rs b/src/api/admin/pages/groups.rs index 57b1c22..0c726c4 100644 --- a/src/api/admin/pages/groups.rs +++ b/src/api/admin/pages/groups.rs @@ -95,8 +95,78 @@ pub async fn remove_member( Ok(Html(render_full(&state).await?)) } +#[derive(Debug, Deserialize)] +pub struct PeerForm { + pub peer_id: String, +} + +pub async fn add_peer( + Extension(state): Extension>, + admin: AuthedUser, + Path(id): Path, + Form(form): Form, +) -> Result, ApiError> { + require_admin(&admin)?; + let peer_id = form.peer_id.trim(); + if peer_id.is_empty() { + return notice_then(&state, "error", "Device ID required").await; + } + let exists = state + .db + .peer_exists(peer_id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + if !exists { + return notice_then( + &state, + "error", + &format!("No device '{}' has reported in yet.", peer_id), + ) + .await; + } + state + .db + .device_group_add_peer(id, peer_id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + Ok(Html(render_full(&state).await?)) +} + +pub async fn remove_peer( + Extension(state): Extension>, + admin: AuthedUser, + Path((id, peer_id)): Path<(i64, String)>, +) -> Result, ApiError> { + require_admin(&admin)?; + state + .db + .device_group_remove_peer(id, &peer_id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + Ok(Html(render_full(&state).await?)) +} + // ---------- rendering ---------- +/// Minimal percent-encoder for path segments. Peer IDs are usually digits, +/// but the schema allows arbitrary text — encode anything outside the +/// unreserved set so a literal `/` or `?` in a peer id can't break routing. +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 +} + async fn notice_then( state: &Arc, kind: &str, @@ -144,9 +214,14 @@ async fn render_full(state: &Arc) -> Result { .device_group_members(g.id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; + let peer_members = state + .db + .device_group_peer_members(g.id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; let _ = write!( s, - r##"
+ r##"

{name}

-
    "##, +
    +

    Users

    +
      "##, id = g.id, name = html_escape(&g.name) ); if members.is_empty() { s.push_str( - r##"
    • No members yet.
    • "##, + r##"
    • No user members yet.
    • "##, ); } for u in &members { @@ -203,10 +280,63 @@ async fn render_full(state: &Arc) -> Result { } s.push_str( r##" - + "##, ); } + s.push_str("
    "); + + // ---- Devices section ---- + let _ = write!( + s, + r##"
    +

    Devices

    +
      "## + ); + if peer_members.is_empty() { + s.push_str( + r##"
    • No devices added directly. (Devices owned by user members above are also visible to them.)
    • "##, + ); + } + for (peer_id, owner) in &peer_members { + let owner_label = if owner.is_empty() { + String::from(r##"unowned"##) + } else { + format!( + r##"owner: {}"##, + html_escape(owner) + ) + }; + let _ = write!( + s, + r##"
    • + {pid} + + {owner_label} + + +
    • "##, + pid = html_escape(peer_id), + pid_url = url_encode(peer_id), + gid = g.id, + owner_label = owner_label, + ); + } + s.push_str("
    "); + let _ = write!( + s, + r##"
    + + +
    "##, + id = g.id + ); + s.push_str("
"); } s.push_str(""); diff --git a/src/api/admin/pages/mod.rs b/src/api/admin/pages/mod.rs index 80a1c11..19d4c00 100644 --- a/src/api/admin/pages/mod.rs +++ b/src/api/admin/pages/mod.rs @@ -7,9 +7,7 @@ pub mod connect; pub mod deploy; pub mod devices; pub mod groups; -pub mod oidc; pub mod profile; -pub mod recordings; pub mod shared; pub mod strategies; pub mod users; diff --git a/src/api/admin/pages/oidc.rs b/src/api/admin/pages/oidc.rs deleted file mode 100644 index 223b9cb..0000000 --- a/src/api/admin/pages/oidc.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! OIDC providers — read-only listing of what's currently in -//! `oidc_providers`. Editing providers is operator-side via the -//! `--oidc-config` TOML or hand-inserted SQL; the dashboard surfaces them -//! so admins can confirm what's wired up without leaving the UI. - -use super::shared::{html_escape, require_admin}; -use crate::api::error::ApiError; -use crate::api::middleware::AuthedUser; -use crate::api::state::AppState; -use axum::extract::Extension; -use axum::response::Html; -use std::fmt::Write as _; -use std::sync::Arc; - -pub async fn index( - Extension(state): Extension>, - admin: AuthedUser, -) -> Result, ApiError> { - require_admin(&admin)?; - let providers = state - .db - .oidc_provider_list_enabled() - .await - .map_err(|e| ApiError::Internal(e.to_string()))?; - let mut s = String::new(); - s.push_str( - r##"
-
-

OIDC providers

-

Read-only. Add/edit via --oidc-config TOML at startup, or by inserting into the oidc_providers table.

-
"##, - ); - if providers.is_empty() { - s.push_str( - r##"

No OIDC providers configured.

"##, - ); - return Ok(Html(s)); - } - s.push_str( - r##"
- - - - - - - - - - "##, - ); - for p in &providers { - let _ = write!( - s, - r##" - - - - - - -"##, - name = html_escape(&p.name), - display = html_escape(p.display_name.as_deref().unwrap_or("")), - issuer = html_escape(&p.issuer_url), - client_id = html_escape(&p.client_id), - scopes = html_escape(&p.scopes), - redirect = html_escape(&p.redirect_url), - ); - } - s.push_str("
NameDisplay nameIssuerClient IDScopesRedirect
{name}{display}{issuer}{client_id}{scopes}{redirect}
"); - Ok(Html(s)) -} diff --git a/src/api/admin/pages/recordings.rs b/src/api/admin/pages/recordings.rs deleted file mode 100644 index 17d7f40..0000000 --- a/src/api/admin/pages/recordings.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Recordings — list-only. Adding a streaming download handler is a -//! follow-up; for now the operator looks at the filenames + sizes and -//! pulls files from `--recording-dir` directly. - -use super::shared::{fmt_unix, html_escape, require_admin}; -use crate::api::error::ApiError; -use crate::api::middleware::AuthedUser; -use crate::api::state::AppState; -use axum::extract::Extension; -use axum::response::Html; -use std::fmt::Write as _; -use std::sync::Arc; - -const PAGE_SIZE: i64 = 200; - -pub async fn index( - Extension(state): Extension>, - admin: AuthedUser, -) -> Result, ApiError> { - require_admin(&admin)?; - let rows = state - .db - .recordings_list(PAGE_SIZE) - .await - .map_err(|e| ApiError::Internal(e.to_string()))?; - let mut s = String::new(); - s.push_str( - r##"
-
-

Recordings

-

Files live under --recording-dir. Pull them with scp / rsync for now; an in-browser download is coming.

-
"##, - ); - if rows.is_empty() { - s.push_str( - r##"

No session recordings yet.

"##, - ); - return Ok(Html(s)); - } - s.push_str( - r##"
- - - - - - - - - - "##, - ); - for r in &rows { - let _ = write!( - s, - r##" - - - - - - -"##, - file = html_escape(&r.filename), - peer = html_escape(&r.peer_id), - size = human_size(r.size), - state = html_escape(&r.state), - started = html_escape(&fmt_unix(r.started_at)), - finished = html_escape(&r.finished_at.map(fmt_unix).unwrap_or_else(|| "—".into())) - ); - } - s.push_str("
FilenamePeerSizeStateStartedFinished
{file}{peer}{size}{state}{started}{finished}
"); - Ok(Html(s)) -} - -fn human_size(bytes: i64) -> String { - let b = bytes as f64; - if bytes < 1024 { - format!("{} B", bytes) - } else if b < 1024.0 * 1024.0 { - format!("{:.1} KiB", b / 1024.0) - } else if b < 1024.0 * 1024.0 * 1024.0 { - format!("{:.1} MiB", b / (1024.0 * 1024.0)) - } else { - format!("{:.2} GiB", b / (1024.0 * 1024.0 * 1024.0)) - } -} diff --git a/src/database.rs b/src/database.rs index 4531482..cba74bc 100644 --- a/src/database.rs +++ b/src/database.rs @@ -586,6 +586,10 @@ impl Database { .bind(peer_id) .execute(self.pool.get().await?.deref_mut()) .await; + let _ = sqlx::query("DELETE FROM device_group_peers 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()) @@ -773,6 +777,10 @@ impl Database { .bind(group_id) .execute(self.pool.get().await?.deref_mut()) .await; + let _ = sqlx::query("DELETE FROM device_group_peers WHERE device_group_id = ?") + .bind(group_id) + .execute(self.pool.get().await?.deref_mut()) + .await; let res = sqlx::query("DELETE FROM device_groups WHERE id = ?") .bind(group_id) .execute(self.pool.get().await?.deref_mut()) @@ -810,6 +818,77 @@ impl Database { Ok(()) } + /// Devices explicitly added to a group via `device_group_peers`. + /// Returns (peer_id, owner_username); owner is empty if the device has + /// never been bound to a user (vanilla rustdesk client). + pub async fn device_group_peer_members( + &self, + group_id: i64, + ) -> ResultType> { + let rows = sqlx::query( + "SELECT dgp.peer_id AS pid, COALESCE(u.username, '') AS owner \ + FROM device_group_peers dgp \ + LEFT JOIN device_sysinfo ds ON ds.id = dgp.peer_id \ + LEFT JOIN users u ON u.id = ds.user_id \ + WHERE dgp.device_group_id = ? \ + GROUP BY dgp.peer_id \ + ORDER BY dgp.peer_id", + ) + .bind(group_id) + .fetch_all(self.pool.get().await?.deref_mut()) + .await?; + Ok(rows + .into_iter() + .map(|r| { + ( + r.try_get::("pid").unwrap_or_default(), + r.try_get::("owner").unwrap_or_default(), + ) + }) + .collect()) + } + + pub async fn device_group_add_peer( + &self, + group_id: i64, + peer_id: &str, + ) -> ResultType<()> { + sqlx::query( + "INSERT OR IGNORE INTO device_group_peers(device_group_id, peer_id) VALUES(?, ?)", + ) + .bind(group_id) + .bind(peer_id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(()) + } + + pub async fn device_group_remove_peer( + &self, + group_id: i64, + peer_id: &str, + ) -> ResultType<()> { + sqlx::query( + "DELETE FROM device_group_peers WHERE device_group_id = ? AND peer_id = ?", + ) + .bind(group_id) + .bind(peer_id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(()) + } + + /// True if `peer_id` exists in `device_sysinfo`. Used to validate before + /// inserting into `device_group_peers` so the admin UI can show a + /// helpful error instead of accepting a typo silently. + pub async fn peer_exists(&self, peer_id: &str) -> ResultType { + let row = sqlx::query("SELECT 1 FROM device_sysinfo WHERE id = ? LIMIT 1") + .bind(peer_id) + .fetch_optional(self.pool.get().await?.deref_mut()) + .await?; + Ok(row.is_some()) + } + pub async fn strategies_list_all(&self) -> ResultType> { let rows = sqlx::query( "SELECT id, name, modified_at, config_options_json, extra_json \ @@ -1944,7 +2023,8 @@ impl Database { limit: i64, ) -> ResultType<(i64, Vec)> { // Common select: device_sysinfo joined to its owner. We pick the - // alphabetically-first device-group name as the surfaced group. + // alphabetically-first device-group name as the surfaced group, + // considering both owner-based and direct peer-group membership. let where_clause = if is_admin { "1 = 1" } else { @@ -1952,6 +2032,11 @@ impl Database { SELECT m2.user_id FROM device_group_members m1 \ JOIN device_group_members m2 USING(device_group_id) \ WHERE m1.user_id = ? \ + ) OR ds.id IN ( \ + SELECT dgp.peer_id FROM device_group_peers dgp \ + JOIN device_group_members dgm \ + ON dgm.device_group_id = dgp.device_group_id \ + WHERE dgm.user_id = ? \ ))" }; let count_sql = format!( @@ -1965,8 +2050,14 @@ impl Database { COALESCE(u.status, 1) AS owner_status, \ ds.payload AS sysinfo, \ ( SELECT dg.name FROM device_groups dg \ - JOIN device_group_members mm ON mm.device_group_id = dg.id \ - WHERE mm.user_id = ds.user_id ORDER BY dg.name LIMIT 1 \ + WHERE dg.id IN ( \ + SELECT device_group_id FROM device_group_members \ + WHERE user_id = ds.user_id \ + UNION \ + SELECT device_group_id FROM device_group_peers \ + WHERE peer_id = ds.id \ + ) \ + ORDER BY dg.name LIMIT 1 \ ) AS device_group_name \ FROM device_sysinfo ds \ LEFT JOIN users u ON u.id = ds.user_id \ @@ -1981,6 +2072,7 @@ impl Database { .try_get("c")? } else { sqlx::query(&count_sql) + .bind(viewer_id) .bind(viewer_id) .bind(viewer_id) .fetch_one(self.pool.get().await?.deref_mut()) @@ -1995,6 +2087,7 @@ impl Database { .await? } else { sqlx::query(&list_sql) + .bind(viewer_id) .bind(viewer_id) .bind(viewer_id) .bind(limit) @@ -2214,21 +2307,22 @@ impl Database { { return Ok(s); } - // Look up the device's owner; without an owner there's nothing to - // join on, so we stop here. - let owner = sqlx::query( + // Look up the device's owner. May be NULL on a vanilla rustdesk + // client that never bound to a user — in that case we still want to + // honor any direct device-group membership before giving up. + let owner_id_opt: Option = sqlx::query( "SELECT user_id FROM device_sysinfo WHERE id = ? AND user_id IS NOT NULL LIMIT 1", ) .bind(peer_id) .fetch_optional(self.pool.get().await?.deref_mut()) - .await?; - let Some(owner_row) = owner else { - return Ok(ResolvedStrategy::default()); - }; - let owner_id: i64 = owner_row.try_get("user_id")?; - let owner_id_str = owner_id.to_string(); + .await? + .and_then(|r| r.try_get("user_id").ok()); + let owner_id_str = owner_id_opt.unwrap_or(0).to_string(); // Device-group assignment: any strategy assigned to a group that the - // owner is a member of. + // owner is a user-member of, OR that the peer itself is directly a + // member of via `device_group_peers`. The owner_id placeholder binds + // to 0 for ownerless peers — `device_group_members.user_id` is never + // 0, so the first branch of the UNION matches nothing in that case. if let Some(s) = self .strategy_lookup( "SELECT s.modified_at, s.config_options_json, s.extra_json \ @@ -2236,14 +2330,19 @@ impl Database { JOIN strategy_assignments sa ON sa.strategy_id = s.id \ WHERE sa.device_group_id IN ( \ SELECT device_group_id FROM device_group_members WHERE user_id = ? \ + UNION \ + SELECT device_group_id FROM device_group_peers WHERE peer_id = ? \ ) \ ORDER BY sa.priority DESC LIMIT 1", - &[&owner_id_str], + &[&owner_id_str, peer_id], ) .await? { return Ok(s); } + if owner_id_opt.is_none() { + return Ok(ResolvedStrategy::default()); + } // User assignment. if let Some(s) = self .strategy_lookup( @@ -2989,6 +3088,16 @@ const M2_SCHEMA: &[&str] = &[ PRIMARY KEY (device_group_id, user_id) )", "CREATE INDEX IF NOT EXISTS idx_dgm_user ON device_group_members(user_id)", + // Direct device membership in a group. Independent of owner-based + // membership (device_group_members) — a group can contain users, + // peers, or both. Peer is keyed by id (text) since device_sysinfo + // is keyed on (id, uuid) and operators identify devices by id alone. + "CREATE TABLE IF NOT EXISTS device_group_peers ( + device_group_id INTEGER NOT NULL, + peer_id TEXT NOT NULL, + PRIMARY KEY (device_group_id, peer_id) + )", + "CREATE INDEX IF NOT EXISTS idx_dgp_peer ON device_group_peers(peer_id)", // SQLite forbids expressions in PRIMARY KEY constraints, so we use a // unique index over the COALESCEd tuple to enforce one share per // (ab, user) and one per (ab, group). NULLs collapse to 0 so two NULL