Improve admin UI (remove unused functions, added hello-agent deployment
build / build-linux-amd64 (push) Successful in 2m2s

This commit is contained in:
2026-05-09 14:14:58 +02:00
parent f7c359a8a0
commit a7b3e83f02
8 changed files with 275 additions and 188 deletions
-4
View File
@@ -37,12 +37,8 @@
hx-get="/admin/pages/strategies" hx-target="#main" hx-push-url="#strategies">Strategies</a> hx-get="/admin/pages/strategies" hx-target="#main" hx-push-url="#strategies">Strategies</a>
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800" <a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
hx-get="/admin/pages/address-books" hx-target="#main" hx-push-url="#address-books">Address books</a> hx-get="/admin/pages/address-books" hx-target="#main" hx-push-url="#address-books">Address books</a>
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
hx-get="/admin/pages/oidc" hx-target="#main" hx-push-url="#oidc">OIDC providers</a>
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800" <a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
hx-get="/admin/pages/audit" hx-target="#main" hx-push-url="#audit">Audit log</a> hx-get="/admin/pages/audit" hx-target="#main" hx-push-url="#audit">Audit log</a>
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
hx-get="/admin/pages/recordings" hx-target="#main" hx-push-url="#recordings">Recordings</a>
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800" <a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
hx-get="/admin/pages/deploy" hx-target="#main" hx-push-url="#deploy">Deploy</a> hx-get="/admin/pages/deploy" hx-target="#main" hx-push-url="#deploy">Deploy</a>
</nav> </nav>
+9 -3
View File
@@ -121,6 +121,14 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
"/admin/pages/groups/:id/members/:user_id/remove", "/admin/pages/groups/:id/members/:user_id/remove",
post(pages::groups::remove_member), 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 // Strategies
.route( .route(
"/admin/pages/strategies/create", "/admin/pages/strategies/create",
@@ -181,7 +189,6 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
"/admin/pages/address-books/:guid/shares/:user_id/remove", "/admin/pages/address-books/:guid/shares/:user_id/remove",
post(pages::address_books::share_remove), post(pages::address_books::share_remove),
) )
.route("/admin/pages/oidc", get(pages::oidc::index))
// Self-service profile — cookie-only, no admin gate. // Self-service profile — cookie-only, no admin gate.
.route("/admin/pages/profile", get(pages::profile::index)) .route("/admin/pages/profile", get(pages::profile::index))
.route( .route(
@@ -204,8 +211,7 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
"/admin/pages/profile/totp/remove", "/admin/pages/profile/totp/remove",
post(pages::profile::totp_remove), post(pages::profile::totp_remove),
) )
.route("/admin/pages/audit", get(pages::audit::index)) .route("/admin/pages/audit", get(pages::audit::index));
.route("/admin/pages/recordings", get(pages::recordings::index));
hbb_common::log::info!( hbb_common::log::info!(
"admin dashboard mounted at /admin (HTML embedded; --admin-ui-dir is informational)" "admin dashboard mounted at /admin (HTML embedded; --admin-ui-dir is informational)"
); );
+9 -1
View File
@@ -253,12 +253,13 @@ fn render_result(host: &str, key: &str, api: &str, relay: &str, blob: &str) -> S
licensed = licensed licensed = licensed
); );
let cmd_unix = format!("rustdesk --config {}", licensed); let cmd_unix = format!("rustdesk --config {}", licensed);
let cmd_hello = format!("hello-agent.exe --install --config {}", blob);
format!( format!(
r##"<section class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-4"> r##"<section class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-4">
<header> <header>
<h3 class="text-sm font-semibold text-slate-200">Deployment artifact</h3> <h3 class="text-sm font-semibold text-slate-200">Deployment artifact</h3>
<p class="text-xs text-slate-500 mt-1">Pick whichever path fits your rollout. Both produce the same client config.</p> <p class="text-xs text-slate-500 mt-1">Pick whichever path fits your rollout. All three produce the same client config.</p>
</header> </header>
<div> <div>
@@ -274,6 +275,12 @@ fn render_result(host: &str, key: &str, api: &str, relay: &str, blob: &str) -> S
{renamed_note} {renamed_note}
</div> </div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1">C. HelloAgent (Windows, MDM one-liner)</label>
<pre class="text-xs bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{cmd_hello}</pre>
<p class="text-xs text-slate-500 mt-1">Headless agent — registers the Windows service and imports this config in a single command. Run elevated.</p>
</div>
<details class="text-xs text-slate-400"> <details class="text-xs text-slate-400">
<summary class="cursor-pointer text-slate-300 select-none">Raw blob</summary> <summary class="cursor-pointer text-slate-300 select-none">Raw blob</summary>
<pre class="mt-2 bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{blob}</pre> <pre class="mt-2 bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{blob}</pre>
@@ -281,6 +288,7 @@ fn render_result(host: &str, key: &str, api: &str, relay: &str, blob: &str) -> S
</section>"##, </section>"##,
cmd_win = html_escape(&cmd_win), cmd_win = html_escape(&cmd_win),
cmd_unix = html_escape(&cmd_unix), cmd_unix = html_escape(&cmd_unix),
cmd_hello = html_escape(&cmd_hello),
renamed = html_escape(&renamed), renamed = html_escape(&renamed),
renamed_note = renamed_note, renamed_note = renamed_note,
blob = html_escape(blob), blob = html_escape(blob),
+133 -3
View File
@@ -95,8 +95,78 @@ pub async fn remove_member(
Ok(Html(render_full(&state).await?)) Ok(Html(render_full(&state).await?))
} }
#[derive(Debug, Deserialize)]
pub struct PeerForm {
pub peer_id: String,
}
pub async fn add_peer(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
Form(form): Form<PeerForm>,
) -> Result<Html<String>, 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<Arc<AppState>>,
admin: AuthedUser,
Path((id, peer_id)): Path<(i64, String)>,
) -> Result<Html<String>, 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 ---------- // ---------- 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( async fn notice_then(
state: &Arc<AppState>, state: &Arc<AppState>,
kind: &str, kind: &str,
@@ -144,9 +214,14 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
.device_group_members(g.id) .device_group_members(g.id)
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .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!( let _ = write!(
s, s,
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-3"> r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-4">
<header class="flex items-center justify-between"> <header class="flex items-center justify-between">
<h3 class="font-semibold">{name}</h3> <h3 class="font-semibold">{name}</h3>
<button class="text-xs text-rose-400 hover:text-rose-300" <button class="text-xs text-rose-400 hover:text-rose-300"
@@ -154,13 +229,15 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
hx-confirm="Delete group {name}? Members aren't deleted; just unassigned." hx-confirm="Delete group {name}? Members aren't deleted; just unassigned."
hx-target="#groups-region" hx-swap="outerHTML">Delete group</button> hx-target="#groups-region" hx-swap="outerHTML">Delete group</button>
</header> </header>
<div>
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1">Users</h4>
<ul class="text-sm divide-y divide-slate-800">"##, <ul class="text-sm divide-y divide-slate-800">"##,
id = g.id, id = g.id,
name = html_escape(&g.name) name = html_escape(&g.name)
); );
if members.is_empty() { if members.is_empty() {
s.push_str( s.push_str(
r##"<li class="py-2 text-slate-500 text-xs">No members yet.</li>"##, r##"<li class="py-2 text-slate-500 text-xs">No user members yet.</li>"##,
); );
} }
for u in &members { for u in &members {
@@ -203,10 +280,63 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
} }
s.push_str( s.push_str(
r##"</select> r##"</select>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">Add member</button> <button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">Add user</button>
</form>"##, </form>"##,
); );
} }
s.push_str("</div>");
// ---- Devices section ----
let _ = write!(
s,
r##"<div>
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1">Devices</h4>
<ul class="text-sm divide-y divide-slate-800">"##
);
if peer_members.is_empty() {
s.push_str(
r##"<li class="py-2 text-slate-500 text-xs">No devices added directly. (Devices owned by user members above are also visible to them.)</li>"##,
);
}
for (peer_id, owner) in &peer_members {
let owner_label = if owner.is_empty() {
String::from(r##"<span class="text-slate-500">unowned</span>"##)
} else {
format!(
r##"<span class="text-slate-500">owner: {}</span>"##,
html_escape(owner)
)
};
let _ = write!(
s,
r##"<li class="py-2 flex items-center justify-between">
<span class="font-mono text-slate-200">{pid}</span>
<span class="text-xs flex items-center gap-3">
{owner_label}
<button class="text-slate-400 hover:text-rose-300"
hx-post="/admin/pages/groups/{gid}/peers/{pid_url}/remove"
hx-target="#groups-region" hx-swap="outerHTML">Remove</button>
</span>
</li>"##,
pid = html_escape(peer_id),
pid_url = url_encode(peer_id),
gid = g.id,
owner_label = owner_label,
);
}
s.push_str("</ul>");
let _ = write!(
s,
r##"<form class="flex gap-2 text-sm pt-2 border-t border-slate-800"
hx-post="/admin/pages/groups/{id}/peers/add"
hx-target="#groups-region" hx-swap="outerHTML">
<input name="peer_id" placeholder="Device ID (e.g. 123456789)" required
class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono"/>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">Add device</button>
</form></div>"##,
id = g.id
);
s.push_str("</section>"); s.push_str("</section>");
} }
s.push_str("</div>"); s.push_str("</div>");
-2
View File
@@ -7,9 +7,7 @@ pub mod connect;
pub mod deploy; pub mod deploy;
pub mod devices; pub mod devices;
pub mod groups; pub mod groups;
pub mod oidc;
pub mod profile; pub mod profile;
pub mod recordings;
pub mod shared; pub mod shared;
pub mod strategies; pub mod strategies;
pub mod users; pub mod users;
-73
View File
@@ -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<Arc<AppState>>,
admin: AuthedUser,
) -> Result<Html<String>, 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##"<div class="space-y-4">
<header>
<h2 class="text-lg font-semibold">OIDC providers</h2>
<p class="text-xs text-slate-500 mt-1">Read-only. Add/edit via <code>--oidc-config</code> TOML at startup, or by inserting into the <code>oidc_providers</code> table.</p>
</header>"##,
);
if providers.is_empty() {
s.push_str(
r##"<p class="text-slate-500 text-sm">No OIDC providers configured.</p></div>"##,
);
return Ok(Html(s));
}
s.push_str(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
<table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
<th class="text-left font-medium px-3 py-2">Name</th>
<th class="text-left font-medium px-3 py-2">Display name</th>
<th class="text-left font-medium px-3 py-2">Issuer</th>
<th class="text-left font-medium px-3 py-2">Client ID</th>
<th class="text-left font-medium px-3 py-2">Scopes</th>
<th class="text-left font-medium px-3 py-2">Redirect</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
);
for p in &providers {
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 font-medium text-slate-200">{name}</td>
<td class="px-3 py-2 text-slate-300">{display}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{issuer}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{client_id}</td>
<td class="px-3 py-2 text-slate-400 text-xs">{scopes}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{redirect}</td>
</tr>"##,
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("</tbody></table></div></div>");
Ok(Html(s))
}
-87
View File
@@ -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<Arc<AppState>>,
admin: AuthedUser,
) -> Result<Html<String>, 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##"<div class="space-y-4">
<header>
<h2 class="text-lg font-semibold">Recordings</h2>
<p class="text-xs text-slate-500 mt-1">Files live under <code>--recording-dir</code>. Pull them with <code>scp</code> / <code>rsync</code> for now; an in-browser download is coming.</p>
</header>"##,
);
if rows.is_empty() {
s.push_str(
r##"<p class="text-slate-500 text-sm">No session recordings yet.</p></div>"##,
);
return Ok(Html(s));
}
s.push_str(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
<table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
<th class="text-left font-medium px-3 py-2">Filename</th>
<th class="text-left font-medium px-3 py-2">Peer</th>
<th class="text-left font-medium px-3 py-2">Size</th>
<th class="text-left font-medium px-3 py-2">State</th>
<th class="text-left font-medium px-3 py-2">Started</th>
<th class="text-left font-medium px-3 py-2">Finished</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
);
for r in &rows {
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 font-mono text-slate-200 text-xs">{file}</td>
<td class="px-3 py-2 font-mono text-slate-300">{peer}</td>
<td class="px-3 py-2 text-slate-400">{size}</td>
<td class="px-3 py-2 text-slate-400">{state}</td>
<td class="px-3 py-2 text-slate-500 text-xs">{started}</td>
<td class="px-3 py-2 text-slate-500 text-xs">{finished}</td>
</tr>"##,
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("</tbody></table></div></div>");
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))
}
}
+123 -14
View File
@@ -586,6 +586,10 @@ impl Database {
.bind(peer_id) .bind(peer_id)
.execute(self.pool.get().await?.deref_mut()) .execute(self.pool.get().await?.deref_mut())
.await; .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 = ?") let _ = sqlx::query("DELETE FROM peer WHERE id = ?")
.bind(peer_id) .bind(peer_id)
.execute(self.pool.get().await?.deref_mut()) .execute(self.pool.get().await?.deref_mut())
@@ -773,6 +777,10 @@ impl Database {
.bind(group_id) .bind(group_id)
.execute(self.pool.get().await?.deref_mut()) .execute(self.pool.get().await?.deref_mut())
.await; .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 = ?") let res = sqlx::query("DELETE FROM device_groups WHERE id = ?")
.bind(group_id) .bind(group_id)
.execute(self.pool.get().await?.deref_mut()) .execute(self.pool.get().await?.deref_mut())
@@ -810,6 +818,77 @@ impl Database {
Ok(()) 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<Vec<(String, String)>> {
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::<String, _>("pid").unwrap_or_default(),
r.try_get::<String, _>("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<bool> {
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<Vec<StrategyRow>> { pub async fn strategies_list_all(&self) -> ResultType<Vec<StrategyRow>> {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT id, name, modified_at, config_options_json, extra_json \ "SELECT id, name, modified_at, config_options_json, extra_json \
@@ -1944,7 +2023,8 @@ impl Database {
limit: i64, limit: i64,
) -> ResultType<(i64, Vec<PeerListRow>)> { ) -> ResultType<(i64, Vec<PeerListRow>)> {
// Common select: device_sysinfo joined to its owner. We pick the // 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 { let where_clause = if is_admin {
"1 = 1" "1 = 1"
} else { } else {
@@ -1952,6 +2032,11 @@ impl Database {
SELECT m2.user_id FROM device_group_members m1 \ SELECT m2.user_id FROM device_group_members m1 \
JOIN device_group_members m2 USING(device_group_id) \ JOIN device_group_members m2 USING(device_group_id) \
WHERE m1.user_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!( let count_sql = format!(
@@ -1965,8 +2050,14 @@ impl Database {
COALESCE(u.status, 1) AS owner_status, \ COALESCE(u.status, 1) AS owner_status, \
ds.payload AS sysinfo, \ ds.payload AS sysinfo, \
( SELECT dg.name FROM device_groups dg \ ( SELECT dg.name FROM device_groups dg \
JOIN device_group_members mm ON mm.device_group_id = dg.id \ WHERE dg.id IN ( \
WHERE mm.user_id = ds.user_id ORDER BY dg.name LIMIT 1 \ 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 \ ) AS device_group_name \
FROM device_sysinfo ds \ FROM device_sysinfo ds \
LEFT JOIN users u ON u.id = ds.user_id \ LEFT JOIN users u ON u.id = ds.user_id \
@@ -1981,6 +2072,7 @@ impl Database {
.try_get("c")? .try_get("c")?
} else { } else {
sqlx::query(&count_sql) sqlx::query(&count_sql)
.bind(viewer_id)
.bind(viewer_id) .bind(viewer_id)
.bind(viewer_id) .bind(viewer_id)
.fetch_one(self.pool.get().await?.deref_mut()) .fetch_one(self.pool.get().await?.deref_mut())
@@ -1995,6 +2087,7 @@ impl Database {
.await? .await?
} else { } else {
sqlx::query(&list_sql) sqlx::query(&list_sql)
.bind(viewer_id)
.bind(viewer_id) .bind(viewer_id)
.bind(viewer_id) .bind(viewer_id)
.bind(limit) .bind(limit)
@@ -2214,21 +2307,22 @@ impl Database {
{ {
return Ok(s); return Ok(s);
} }
// Look up the device's owner; without an owner there's nothing to // Look up the device's owner. May be NULL on a vanilla rustdesk
// join on, so we stop here. // client that never bound to a user — in that case we still want to
let owner = sqlx::query( // honor any direct device-group membership before giving up.
let owner_id_opt: Option<i64> = sqlx::query(
"SELECT user_id FROM device_sysinfo WHERE id = ? AND user_id IS NOT NULL LIMIT 1", "SELECT user_id FROM device_sysinfo WHERE id = ? AND user_id IS NOT NULL LIMIT 1",
) )
.bind(peer_id) .bind(peer_id)
.fetch_optional(self.pool.get().await?.deref_mut()) .fetch_optional(self.pool.get().await?.deref_mut())
.await?; .await?
let Some(owner_row) = owner else { .and_then(|r| r.try_get("user_id").ok());
return Ok(ResolvedStrategy::default()); let owner_id_str = owner_id_opt.unwrap_or(0).to_string();
};
let owner_id: i64 = owner_row.try_get("user_id")?;
let owner_id_str = owner_id.to_string();
// Device-group assignment: any strategy assigned to a group that the // 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 if let Some(s) = self
.strategy_lookup( .strategy_lookup(
"SELECT s.modified_at, s.config_options_json, s.extra_json \ "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 \ JOIN strategy_assignments sa ON sa.strategy_id = s.id \
WHERE sa.device_group_id IN ( \ WHERE sa.device_group_id IN ( \
SELECT device_group_id FROM device_group_members WHERE user_id = ? \ 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", ORDER BY sa.priority DESC LIMIT 1",
&[&owner_id_str], &[&owner_id_str, peer_id],
) )
.await? .await?
{ {
return Ok(s); return Ok(s);
} }
if owner_id_opt.is_none() {
return Ok(ResolvedStrategy::default());
}
// User assignment. // User assignment.
if let Some(s) = self if let Some(s) = self
.strategy_lookup( .strategy_lookup(
@@ -2989,6 +3088,16 @@ const M2_SCHEMA: &[&str] = &[
PRIMARY KEY (device_group_id, user_id) PRIMARY KEY (device_group_id, user_id)
)", )",
"CREATE INDEX IF NOT EXISTS idx_dgm_user ON device_group_members(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 // SQLite forbids expressions in PRIMARY KEY constraints, so we use a
// unique index over the COALESCEd tuple to enforce one share per // 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 // (ab, user) and one per (ab, group). NULLs collapse to 0 so two NULL