diff --git a/admin_ui/index.html b/admin_ui/index.html new file mode 100644 index 0000000..01abf42 --- /dev/null +++ b/admin_ui/index.html @@ -0,0 +1,87 @@ + + + + + RustDesk Admin + + + + + + + +
+ + +
+
Loading…
+
+
+ + +
+ + + + + diff --git a/admin_ui/login.html b/admin_ui/login.html new file mode 100644 index 0000000..7d1f0ac --- /dev/null +++ b/admin_ui/login.html @@ -0,0 +1,63 @@ + + + + + Sign in — RustDesk Admin + + + + + + +
+
+

RustDesk Admin

+

Sign in to manage the server.

+
+ +
+
+ + +
+ +
+ + +
+ + + + + +
+
+
+ + diff --git a/src/api/admin/auth.rs b/src/api/admin/auth.rs new file mode 100644 index 0000000..2dfaccf --- /dev/null +++ b/src/api/admin/auth.rs @@ -0,0 +1,172 @@ +//! `/admin/login` (POST form) and `/admin/logout` (POST). On success login +//! sets an HttpOnly + SameSite=Strict cookie containing the freshly-minted +//! Bearer token; the browser carries it on every subsequent request to +//! `/admin/*` and `/api/*`. The middleware in `api::middleware` already +//! accepts both `Authorization: Bearer …` and the cookie. + +use crate::api::auth::mint_token; +use crate::api::middleware::{sha256_token, SESSION_COOKIE}; +use crate::api::state::AppState; +use crate::api::users::verify_password; +use axum::extract::{Extension, Form}; +use axum::http::header::{COOKIE, SET_COOKIE}; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::response::{Html, IntoResponse, Response}; +use serde::Deserialize; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +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)] + pub tfa_code: String, + /// Echo of the TOTP nonce the first-leg response set on the form. + #[serde(default)] + pub secret: String, +} + +pub async fn login( + Extension(state): Extension>, + Form(form): Form, +) -> Response { + // First leg: password verify. Same DB call paths as `/api/login` — + // we re-use the existing helpers so the dashboard can't accidentally + // diverge from the API's auth contract. + let user = match state.db.user_find_by_username(&form.username).await { + Ok(Some(u)) => u, + Ok(None) => return error_fragment("Bad credentials"), + Err(e) => return error_fragment(&format!("internal: {}", e)), + }; + let pw_ok = match verify_password(user.password_hash.clone(), form.password.clone()).await { + Ok(b) => b, + Err(e) => return error_fragment(&format!("internal: {}", e)), + }; + if !pw_ok { + return error_fragment("Bad credentials"); + } + if user.status == 0 { + return error_fragment("Account disabled"); + } + if !user.is_admin { + // Only admins can use the dashboard. Non-admin users still get + // tokens via `/api/login` for the desktop client; they just don't + // see the management surface. + return error_fragment("Admin access required"); + } + // Optional second leg: TOTP. If the user has a secret enrolled and the + // form didn't carry a code, return a fragment that asks for one. + let totp_secret = state + .db + .totp_get_secret(user.id) + .await + .ok() + .flatten(); + if let Some(secret_b32) = totp_secret { + if form.tfa_code.is_empty() { + // Shape used by the JS in login.html to switch to the second + // leg: it watches for the special marker via HX-Trigger and + // reveals the #tfa-section. + let frag = format!( + r#"Enter your 6-digit authenticator code. +"# + ); + // We don't need a session yet — caller will resubmit with the + // same username/password plus the code. (No nonce involved on + // the dashboard path: the password is already in scope, so + // tfa_check / tfa_code are folded into one form.) + let _ = secret_b32; + return Html(frag).into_response(); + } + // Verify the supplied code. + let ok = match crate::api::auth::verify_totp(&secret_b32, &form.tfa_code) { + Ok(b) => b, + Err(_) => return error_fragment("Internal TOTP error"), + }; + if !ok { + return error_fragment("Bad TOTP code"); + } + } + + // Mint + persist a token, set the cookie. + let token = mint_token(); + let sha = sha256_token(&token); + if let Err(e) = state + .db + .token_insert( + user.id, + &sha, + "", + "", + r#"{"source":"admin-ui"}"#, + state.cfg.session_ttl_secs, + ) + .await + { + return error_fragment(&format!("internal: {}", e)); + } + let cookie = format!( + "{name}={token}; HttpOnly; Path=/; SameSite=Strict; Max-Age={ttl}", + name = SESSION_COOKIE, + token = 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); + } + // 200 with empty body; the form's hx-on::after-request redirects on + // success. + (StatusCode::OK, headers, "").into_response() +} + +pub async fn logout( + Extension(state): Extension>, + headers: HeaderMap, +) -> Response { + // Best-effort: pull the token out of the cookie, drop the row. + if let Some(tok) = cookie_token(&headers) { + let sha = sha256_token(&tok); + let _ = state.db.token_delete(&sha).await; + } + let mut out = HeaderMap::new(); + let clear = format!( + "{name}=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0", + name = SESSION_COOKIE + ); + if let Ok(v) = HeaderValue::from_str(&clear) { + out.insert(SET_COOKIE, v); + } + (StatusCode::OK, out, "").into_response() +} + +fn cookie_token(headers: &HeaderMap) -> Option { + let s = headers.get(COOKIE)?.to_str().ok()?; + for pair in s.split(';') { + if let Some((name, value)) = pair.trim().split_once('=') { + if name.trim() == SESSION_COOKIE { + let v = value.trim(); + if !v.is_empty() { + return Some(v.to_string()); + } + } + } + } + None +} + +fn error_fragment(msg: &str) -> Response { + let html = format!("{}", html_escape(msg)); + (StatusCode::UNAUTHORIZED, Html(html)).into_response() +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} diff --git a/src/api/admin/me.rs b/src/api/admin/me.rs new file mode 100644 index 0000000..25b5b87 --- /dev/null +++ b/src/api/admin/me.rs @@ -0,0 +1,21 @@ +//! `/admin/me` — small HTMX fragment used by the sidebar to show "signed in +//! as ". Doubles as a cheap auth-check for the dashboard shell: if +//! the cookie isn't valid, the AuthedUser extractor 401s and the page-level +//! HTMX response handler bounces back to the login form. + +use crate::api::error::ApiError; +use crate::api::middleware::AuthedUser; +use axum::response::Html; + +pub async fn me(user: AuthedUser) -> Result, ApiError> { + Ok(Html(format!( + "Signed in as {}", + html_escape(&user.name) + ))) +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs new file mode 100644 index 0000000..a7c97c3 --- /dev/null +++ b/src/api/admin/mod.rs @@ -0,0 +1,139 @@ +//! Admin dashboard router. Mounted at `/admin/*` by `api::router` when +//! the operator hasn't disabled it via `--admin-ui-dir=` (empty). +//! +//! Static HTML/CSS lives in `admin_ui/` next to the source tree and is +//! embedded into the binary at build time via `include_str!` — no separate +//! deploy artifact, no ServeDir wildcard route conflicting with the +//! literal /admin/login etc. The ASSETS table at the bottom is the +//! authoritative list of files we ship. +//! +//! Layout served at runtime: +//! /admin/ ← index.html (the SPA shell) +//! /admin/login.html ← login form +//! /admin/login POST handler (form-encoded, sets session cookie) +//! /admin/logout POST handler (clears session cookie) +//! /admin/me GET fragment (current user, sidebar widget) +//! /admin/pages/* GET fragments (one per page) + +pub mod auth; +pub mod me; +pub mod pages; + +use axum::http::header; +use axum::response::{Html, IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::Router; +use std::sync::Arc; + +/// Files embedded into the binary. Paths are relative to this source file +/// per `include_str!`. Adding a new HTML asset = one new entry here. +const INDEX_HTML: &str = include_str!("../../../admin_ui/index.html"); +const LOGIN_HTML: &str = include_str!("../../../admin_ui/login.html"); + +pub fn build(state: Arc) -> Option { + if state.cfg.admin_ui_dir.is_empty() { + // Operator opted out by setting the flag to empty. + return None; + } + let r = Router::new() + // Static HTML pages — explicit routes per file, no wildcard. + .route("/admin", get(serve_index)) + .route("/admin/", get(serve_index)) + .route("/admin/index.html", get(serve_index)) + .route("/admin/login.html", get(serve_login)) + // Dynamic dashboard endpoints. + .route("/admin/login", post(auth::login)) + .route("/admin/logout", post(auth::logout)) + .route("/admin/me", get(me::me)) + // Page fragments — one per sidebar entry. + .route("/admin/pages/users", get(pages::users::index)) + .route("/admin/pages/users/create", post(pages::users::create)) + .route( + "/admin/pages/users/:id/password-reset", + post(pages::users::reset_password), + ) + .route( + "/admin/pages/users/:id/toggle-admin", + post(pages::users::toggle_admin), + ) + .route( + "/admin/pages/users/:id/toggle-status", + post(pages::users::toggle_status), + ) + .route( + "/admin/pages/users/:id/totp-enroll", + post(pages::users::totp_enroll), + ) + .route( + "/admin/pages/users/:id/totp-unenroll", + post(pages::users::totp_unenroll), + ) + .route("/admin/pages/users/:id/delete", post(pages::users::delete)) + // Devices + .route( + "/admin/pages/devices/:peer_id/disconnect", + post(pages::devices::force_disconnect), + ) + .route( + "/admin/pages/devices/:peer_id/sysinfo-refresh", + post(pages::devices::force_sysinfo), + ) + // Groups + .route("/admin/pages/groups/create", post(pages::groups::create)) + .route("/admin/pages/groups/:id/delete", post(pages::groups::delete)) + .route( + "/admin/pages/groups/:id/members/add", + post(pages::groups::add_member), + ) + .route( + "/admin/pages/groups/:id/members/:user_id/remove", + post(pages::groups::remove_member), + ) + // Strategies + .route( + "/admin/pages/strategies/create", + post(pages::strategies::create), + ) + .route( + "/admin/pages/strategies/:id/update", + post(pages::strategies::update), + ) + .route( + "/admin/pages/strategies/:id/delete", + post(pages::strategies::delete), + ) + .route("/admin/pages/devices", get(pages::devices::index)) + .route("/admin/pages/groups", get(pages::groups::index)) + .route("/admin/pages/strategies", get(pages::strategies::index)) + .route( + "/admin/pages/address-books", + get(pages::address_books::index), + ) + .route("/admin/pages/oidc", get(pages::oidc::index)) + .route("/admin/pages/audit", get(pages::audit::index)) + .route("/admin/pages/recordings", get(pages::recordings::index)); + hbb_common::log::info!( + "admin dashboard mounted at /admin (HTML embedded; --admin-ui-dir is informational)" + ); + Some(r) +} + +async fn serve_index() -> Response { + html_response(INDEX_HTML) +} + +async fn serve_login() -> Response { + html_response(LOGIN_HTML) +} + +fn html_response(body: &'static str) -> Response { + // We hand back `Html<&'static str>` so axum sets `text/html` for us. + // Cache-Control: no-cache so the operator sees fresh HTML after a + // server upgrade without having to bump asset URLs. + let mut resp = Html(body).into_response(); + resp.headers_mut().insert( + header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("no-cache"), + ); + resp +} diff --git a/src/api/admin/pages/address_books.rs b/src/api/admin/pages/address_books.rs new file mode 100644 index 0000000..4054859 --- /dev/null +++ b/src/api/admin/pages/address_books.rs @@ -0,0 +1,75 @@ +//! 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. + +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; + +pub async fn index( + Extension(state): Extension>, + admin: AuthedUser, +) -> Result, ApiError> { + require_admin(&admin)?; + let books = state + .db + .ab_list_all_with_owner() + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let mut s = String::new(); + s.push_str( + 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.

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

No address books exist yet.

"##); + return Ok(Html(s)); + } + s.push_str( + r##"
+ + + + + + + + + + "##, + ); + for b in &books { + let kind = match b.kind { + 0 => r#"personal"#, + 1 => r#"shared"#, + _ => "", + }; + let _ = write!( + s, + r##" + + + + + + +"##, + owner = html_escape(&b.owner_username), + kind = kind, + name = html_escape(&b.name), + count = b.peer_count, + guid = html_escape(&b.guid), + created = html_escape(&fmt_unix(b.created_at)), + ); + } + s.push_str("
OwnerKindNamePeersGUIDCreated
{owner}{kind}{name}{count}{guid}{created}
"); + Ok(Html(s)) +} diff --git a/src/api/admin/pages/audit.rs b/src/api/admin/pages/audit.rs new file mode 100644 index 0000000..eb67a9b --- /dev/null +++ b/src/api/admin/pages/audit.rs @@ -0,0 +1,213 @@ +//! Audit log browser — three tabs (conn / file / alarm), each capped at the +//! latest 200 rows. M5c MVP. Pagination/filtering by date range can come in +//! a follow-up if the operator outgrows this view. + +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, Query}; +use axum::response::Html; +use serde::Deserialize; +use std::fmt::Write as _; +use std::sync::Arc; + +const PAGE_SIZE: i64 = 200; + +#[derive(Debug, Deserialize)] +pub struct TabQuery { + #[serde(default)] + pub tab: Option, +} + +pub async fn index( + Extension(state): Extension>, + admin: AuthedUser, + Query(q): Query, +) -> Result, ApiError> { + require_admin(&admin)?; + let tab = q.tab.as_deref().unwrap_or("conn"); + let body = match tab { + "file" => render_file(&state).await?, + "alarm" => render_alarm(&state).await?, + _ => render_conn(&state).await?, + }; + let pill = |id: &str, label: &str| { + let active = id == tab; + let cls = if active { + "bg-slate-800 text-sky-300 border-sky-800" + } else { + "bg-slate-900 text-slate-400 border-slate-800 hover:text-slate-200" + }; + format!( + r##"{label}"##, + id = id, + cls = cls, + label = label, + ) + }; + Ok(Html(format!( + r##"
+
+

Audit log

+

Latest {n} rows.

+
+
{pill_conn}{pill_file}{pill_alarm}
+ {body} +
"##, + n = PAGE_SIZE, + pill_conn = pill("conn", "Connections"), + pill_file = pill("file", "File transfers"), + pill_alarm = pill("alarm", "Alarms"), + body = body, + ))) +} + +async fn render_conn(state: &Arc) -> Result { + let rows = state + .db + .audit_conn_list(PAGE_SIZE) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + if rows.is_empty() { + return Ok(empty_table("No connection audit rows yet.")); + } + let mut s = String::new(); + s.push_str( + r##"
+ + + + + + + + + + "##, + ); + for r in &rows { + let _ = write!( + s, + r##" + + + + + + +"##, + when = html_escape(&fmt_unix(r.started_at)), + peer = html_escape(&r.peer_id), + conn = r.conn_id, + sess = r.session_id, + ip = html_escape(&r.ip), + action = html_escape(&r.action), + note = html_escape(&r.note) + ); + } + s.push_str("
WhenPeerConn / SessionIPActionNote
{when}{peer}{conn} / {sess}{ip}{action}{note}
"); + Ok(s) +} + +async fn render_file(state: &Arc) -> Result { + let rows = state + .db + .audit_file_list(PAGE_SIZE) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + if rows.is_empty() { + return Ok(empty_table("No file-transfer audit rows yet.")); + } + let mut s = String::new(); + s.push_str( + r##"
+ + + + + + + + + "##, + ); + for r in &rows { + let dir = match r.direction { + 0 => "→ remote", + 1 => "← remote", + _ => "?", + }; + let _ = write!( + s, + r##" + + + + + +"##, + when = html_escape(&fmt_unix(r.at)), + peer = html_escape(&r.peer_id), + dir = dir, + path = html_escape(&r.path), + remote = html_escape(&r.remote_peer) + ); + } + s.push_str("
WhenPeerDirectionPathRemote
{when}{peer}{dir}{path}{remote}
"); + Ok(s) +} + +async fn render_alarm(state: &Arc) -> Result { + let rows = state + .db + .audit_alarm_list(PAGE_SIZE) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + if rows.is_empty() { + return Ok(empty_table("No alarm audit rows yet.")); + } + let mut s = String::new(); + s.push_str( + r##"
+ + + + + + + + "##, + ); + for r in &rows { + let typ = match r.typ { + 0 => "IpWhitelist", + 1 => "ExceedThirtyAttempts", + 2 => "SixAttemptsWithinOneMinute", + 6 => "ExceedIPv6PrefixAttempts", + n => return Ok(format!("(unknown alarm type {})", n)), + }; + let _ = write!( + s, + r##" + + + + +"##, + when = html_escape(&fmt_unix(r.at)), + peer = html_escape(&r.peer_id), + typ = typ, + info = html_escape(&r.info_json) + ); + } + s.push_str("
WhenPeerTypeInfo
{when}{peer}{typ}{info}
"); + Ok(s) +} + +fn empty_table(msg: &str) -> String { + format!( + r##"
{}
"##, + html_escape(msg) + ) +} diff --git a/src/api/admin/pages/devices.rs b/src/api/admin/pages/devices.rs new file mode 100644 index 0000000..76539df --- /dev/null +++ b/src/api/admin/pages/devices.rs @@ -0,0 +1,211 @@ +//! Devices page — list devices currently / recently registered, with +//! force-disconnect (queues a `heartbeat_commands` row consumed on the +//! peer's next /api/heartbeat tick) and force-sysinfo refresh. + +use crate::api::error::ApiError; +use crate::api::middleware::AuthedUser; +use crate::api::state::AppState; +use crate::database::DashboardDeviceRow; +use axum::extract::{Extension, Path}; +use axum::response::Html; +use std::fmt::Write as _; +use std::sync::Arc; + +const PAGE_SIZE: i64 = 100; + +pub async fn index( + Extension(state): Extension>, + admin: AuthedUser, +) -> Result, ApiError> { + require_admin(&admin)?; + let table = render_table(&state).await?; + Ok(Html(format!( + r##"
+
+

Devices

+

Force-disconnect / force-sysinfo are delivered on the peer's next heartbeat tick (~15 s).

+
+
+ {table} +
+
"## + ))) +} + +pub async fn force_disconnect( + Extension(state): Extension>, + admin: AuthedUser, + Path(peer_id): Path, +) -> Result, ApiError> { + require_admin(&admin)?; + let conns = state + .db + .device_sysinfo_get_conns(&peer_id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + state + .db + .heartbeat_command_queue(&peer_id, "disconnect", Some(&conns)) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + notice_then_table( + &state, + "ok", + &format!("Queued disconnect for {} (conns={})", peer_id, conns), + ) + .await +} + +pub async fn force_sysinfo( + Extension(state): Extension>, + admin: AuthedUser, + Path(peer_id): Path, +) -> Result, ApiError> { + require_admin(&admin)?; + state + .db + .heartbeat_command_queue(&peer_id, "sysinfo", None) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + notice_then_table( + &state, + "ok", + &format!("Queued sysinfo refresh for {}", peer_id), + ) + .await +} + +// ---------- helpers ---------- + +async fn render_table(state: &Arc) -> Result { + let (total, devices) = state + .db + .devices_list_all(0, PAGE_SIZE) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let mut s = String::new(); + let _ = write!( + s, + r##"
+ + + + + + + + + + + + + "## + ); + if devices.is_empty() { + s.push_str( + r##""##, + ); + } + for d in &devices { + render_device_row(&mut s, d); + } + let _ = write!( + s, + r##" +
Peer IDOwnerHostnameOSLast heartbeatConnsActions
No devices have heartbeated yet.
+
{total} device(s).
+
"## + ); + Ok(s) +} + +fn render_device_row(s: &mut String, d: &DashboardDeviceRow) { + let parsed: serde_json::Value = + serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null); + let pick = |k: &str| -> String { + parsed + .get(k) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string() + }; + let hostname = pick("hostname"); + let os = pick("os"); + let conn_count = serde_json::from_str::>(&d.conns_json) + .map(|v| v.len()) + .unwrap_or(0); + let _ = write!( + s, + r##" + {id} + {owner} + {host} + {os} + {last} + {n} + +
+ ··· +
+ + +
+
+ +"##, + id = html_escape(&d.id), + owner = html_escape(&d.owner_username), + host = html_escape(&hostname), + os = html_escape(&os), + last = html_escape(&d.last_heartbeat_at), + n = conn_count + ); +} + +async fn notice_then_table( + state: &Arc, + kind: &str, + msg: &str, +) -> Result, ApiError> { + let mut html = notice_html(kind, msg); + html.push_str(&render_table(state).await?); + Ok(Html(html)) +} + +fn notice_html(kind: &str, msg: &str) -> String { + let (border, bg, text) = match kind { + "ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"), + _ => ("rose-700/50", "rose-900/30", "rose-300"), + }; + format!( + r##"
{msg}
"##, + border = border, + bg = bg, + text = text, + msg = html_escape(msg), + ) +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn require_admin(u: &AuthedUser) -> Result<(), ApiError> { + if u.is_admin { + Ok(()) + } else { + Err(ApiError::Forbidden("admin required".into())) + } +} diff --git a/src/api/admin/pages/groups.rs b/src/api/admin/pages/groups.rs new file mode 100644 index 0000000..57b1c22 --- /dev/null +++ b/src/api/admin/pages/groups.rs @@ -0,0 +1,214 @@ +//! Device-groups page — list, create, delete, add/remove member. +//! Strategies and AB shares hang off device-group membership, so this is +//! the canonical place to manage who can see whose devices. + +use super::shared::{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, Form, Path}; +use axum::response::Html; +use serde::Deserialize; +use std::fmt::Write as _; +use std::sync::Arc; + +pub async fn index( + Extension(state): Extension>, + admin: AuthedUser, +) -> Result, ApiError> { + require_admin(&admin)?; + Ok(Html(render_full(&state).await?)) +} + +#[derive(Debug, Deserialize)] +pub struct CreateForm { + pub name: String, +} + +pub async fn create( + Extension(state): Extension>, + admin: AuthedUser, + Form(form): Form, +) -> Result, ApiError> { + require_admin(&admin)?; + if form.name.trim().is_empty() { + return notice_then(&state, "error", "Name required").await; + } + state + .db + .device_group_create(form.name.trim()) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + notice_then(&state, "ok", &format!("Group '{}' created.", form.name)).await +} + +pub async fn delete( + Extension(state): Extension>, + admin: AuthedUser, + Path(id): Path, +) -> Result, ApiError> { + require_admin(&admin)?; + let ok = state + .db + .device_group_delete(id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + notice_then( + &state, + if ok { "ok" } else { "error" }, + if ok { "Group deleted." } else { "Already gone." }, + ) + .await +} + +#[derive(Debug, Deserialize)] +pub struct MemberForm { + pub user_id: i64, +} + +pub async fn add_member( + Extension(state): Extension>, + admin: AuthedUser, + Path(id): Path, + Form(form): Form, +) -> Result, ApiError> { + require_admin(&admin)?; + state + .db + .device_group_add_member(id, form.user_id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + Ok(Html(render_full(&state).await?)) +} + +pub async fn remove_member( + Extension(state): Extension>, + admin: AuthedUser, + Path((id, user_id)): Path<(i64, i64)>, +) -> Result, ApiError> { + require_admin(&admin)?; + state + .db + .device_group_remove_member(id, user_id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + Ok(Html(render_full(&state).await?)) +} + +// ---------- rendering ---------- + +async fn notice_then( + state: &Arc, + kind: &str, + msg: &str, +) -> Result, ApiError> { + let mut html = notice_html(kind, msg); + html.push_str(&render_full(state).await?); + Ok(Html(html)) +} + +async fn render_full(state: &Arc) -> Result { + let groups = state + .db + .device_groups_list_all() + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let (_, all_users) = state + .db + .users_list_all(0, 1000) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + + let mut s = String::new(); + s.push_str( + r##"
+

Device groups

+
+

Create group

+
+ + +
+
+"##, + ); + + if groups.is_empty() { + s.push_str( + r##"

No device groups yet.

"##, + ); + } + for g in &groups { + let members = state + .db + .device_group_members(g.id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let _ = write!( + s, + r##"
+
+

{name}

+ +
+
    "##, + id = g.id, + name = html_escape(&g.name) + ); + if members.is_empty() { + s.push_str( + r##"
  • No members yet.
  • "##, + ); + } + for u in &members { + let _ = write!( + s, + r##"
  • + {username} + +
  • "##, + username = html_escape(&u.username), + gid = g.id, + uid = u.id + ); + } + s.push_str("
"); + // Add-member form: dropdown of users not currently in the group. + let in_group: std::collections::HashSet = + members.iter().map(|u| u.id).collect(); + let candidates: Vec<_> = + all_users.iter().filter(|u| !in_group.contains(&u.id)).collect(); + if !candidates.is_empty() { + let _ = write!( + s, + r##"
+ + +
"##, + ); + } + s.push_str("
"); + } + s.push_str("
"); + Ok(s) +} diff --git a/src/api/admin/pages/mod.rs b/src/api/admin/pages/mod.rs new file mode 100644 index 0000000..6180958 --- /dev/null +++ b/src/api/admin/pages/mod.rs @@ -0,0 +1,24 @@ +//! Per-page HTMX fragment handlers. Each page returns a chunk of HTML that +//! the dashboard shell drops into `#main`. Filled in across M5b/M5c. + +pub mod address_books; +pub mod audit; +pub mod devices; +pub mod groups; +pub mod oidc; +pub mod recordings; +pub mod shared; +pub mod strategies; +pub mod users; + +use axum::response::Html; + +/// Tiny placeholder fragment — replaced by the real page handlers in M5b. +pub fn placeholder(title: &str) -> Html { + Html(format!( + r##"
+

{title}

+

This page is part of M5b — the dashboard shell, login, and per-page navigation are wired in M5a; the actual table + form for {title} lands in the next slice.

+
"## + )) +} diff --git a/src/api/admin/pages/oidc.rs b/src/api/admin/pages/oidc.rs new file mode 100644 index 0000000..223b9cb --- /dev/null +++ b/src/api/admin/pages/oidc.rs @@ -0,0 +1,73 @@ +//! 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 new file mode 100644 index 0000000..17d7f40 --- /dev/null +++ b/src/api/admin/pages/recordings.rs @@ -0,0 +1,87 @@ +//! 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/api/admin/pages/shared.rs b/src/api/admin/pages/shared.rs new file mode 100644 index 0000000..018c19b --- /dev/null +++ b/src/api/admin/pages/shared.rs @@ -0,0 +1,46 @@ +//! Tiny rendering helpers shared by every admin page. Splitting these out +//! keeps each page module under ~200 LOC. + +use crate::api::error::ApiError; +use crate::api::middleware::AuthedUser; + +pub fn require_admin(u: &AuthedUser) -> Result<(), ApiError> { + if u.is_admin { + Ok(()) + } else { + Err(ApiError::Forbidden("admin required".into())) + } +} + +pub fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +pub fn notice_html(kind: &str, msg: &str) -> String { + let (border, bg, text) = match kind { + "ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"), + _ => ("rose-700/50", "rose-900/30", "rose-300"), + }; + format!( + r##"
{msg}
"##, + border = border, + bg = bg, + text = text, + msg = html_escape(msg), + ) +} + +/// Format a unix timestamp as a short ISO-ish string for table cells. +pub fn fmt_unix(ts: i64) -> String { + if ts <= 0 { + return "—".into(); + } + use chrono::{TimeZone, Utc}; + Utc.timestamp_opt(ts, 0) + .single() + .map(|t| t.format("%Y-%m-%d %H:%M:%SZ").to_string()) + .unwrap_or_else(|| ts.to_string()) +} diff --git a/src/api/admin/pages/strategies.rs b/src/api/admin/pages/strategies.rs new file mode 100644 index 0000000..4f2f130 --- /dev/null +++ b/src/api/admin/pages/strategies.rs @@ -0,0 +1,180 @@ +//! Strategies page — list / create / edit-config / delete. Assignment to +//! peers/groups/users is intentionally still SQL-driven for v1; building a +//! full assignment matrix UI is a follow-up. + +use super::shared::{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, Form, Path}; +use axum::response::Html; +use serde::Deserialize; +use std::fmt::Write as _; +use std::sync::Arc; + +pub async fn index( + Extension(state): Extension>, + admin: AuthedUser, +) -> Result, ApiError> { + require_admin(&admin)?; + Ok(Html(render_full(&state).await?)) +} + +#[derive(Debug, Deserialize)] +pub struct CreateForm { + pub name: String, + #[serde(default)] + pub config_options_json: String, +} + +pub async fn create( + Extension(state): Extension>, + admin: AuthedUser, + Form(form): Form, +) -> Result, ApiError> { + require_admin(&admin)?; + if form.name.trim().is_empty() { + return notice_then(&state, "error", "Name required").await; + } + let cfg = if form.config_options_json.trim().is_empty() { + "{}".to_string() + } else { + // Validate it's a JSON object — empty object is fine, anything else + // gets rejected with a friendly message. + match serde_json::from_str::(&form.config_options_json) { + Ok(v) if v.is_object() => form.config_options_json.clone(), + Ok(_) => { + return notice_then(&state, "error", "config_options must be a JSON object").await + } + Err(e) => return notice_then(&state, "error", &format!("invalid JSON: {}", e)).await, + } + }; + state + .db + .strategy_create(form.name.trim(), &cfg, "{}") + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + notice_then( + &state, + "ok", + &format!("Strategy '{}' created.", form.name), + ) + .await +} + +#[derive(Debug, Deserialize)] +pub struct UpdateForm { + pub config_options_json: String, +} + +pub async fn update( + Extension(state): Extension>, + admin: AuthedUser, + Path(id): Path, + Form(form): Form, +) -> Result, ApiError> { + require_admin(&admin)?; + let cfg = match serde_json::from_str::(&form.config_options_json) { + Ok(v) if v.is_object() => form.config_options_json.clone(), + _ => { + return notice_then(&state, "error", "config_options must be a JSON object").await + } + }; + state + .db + .strategy_update_config(id, &cfg) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + notice_then(&state, "ok", "Strategy updated.").await +} + +pub async fn delete( + Extension(state): Extension>, + admin: AuthedUser, + Path(id): Path, +) -> Result, ApiError> { + require_admin(&admin)?; + let ok = state + .db + .strategy_delete(id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + notice_then( + &state, + if ok { "ok" } else { "error" }, + if ok { "Strategy deleted." } else { "Already gone." }, + ) + .await +} + +// ---------- rendering ---------- + +async fn notice_then( + state: &Arc, + kind: &str, + msg: &str, +) -> Result, ApiError> { + let mut html = notice_html(kind, msg); + html.push_str(&render_full(state).await?); + Ok(Html(html)) +} + +async fn render_full(state: &Arc) -> Result { + let strategies = state + .db + .strategies_list_all() + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let mut s = String::new(); + s.push_str( + r##"
+
+

Strategies

+

Pushed to clients via heartbeat. Use SQL to assign — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).

+
+
+

Create strategy

+
+ + + +
+
+"##, + ); + if strategies.is_empty() { + s.push_str(r##"

No strategies yet.

"##); + } + for str_ in &strategies { + let _ = write!( + s, + r##"
+
+
+

{name}

+

id={id}, modified_at={mod_at}

+
+ +
+
+ + + +
+
"##, + id = str_.id, + name = html_escape(&str_.name), + mod_at = str_.modified_at, + cfg = html_escape(&str_.config_options_json), + ); + } + s.push_str("
"); + Ok(s) +} diff --git a/src/api/admin/pages/users.rs b/src/api/admin/pages/users.rs new file mode 100644 index 0000000..9d9a276 --- /dev/null +++ b/src/api/admin/pages/users.rs @@ -0,0 +1,473 @@ +//! Users page — list / create / set-password / toggle-admin / toggle-status +//! / TOTP enroll-unenroll / delete. + +use crate::api::error::ApiError; +use crate::api::middleware::AuthedUser; +use crate::api::state::AppState; +use crate::api::users::hash_password; +use crate::database::{NewUser, UserRow}; +use axum::extract::{Extension, Form, Path}; +use axum::response::Html; +use serde::Deserialize; +use std::fmt::Write as _; +use std::sync::Arc; +use totp_rs::Secret; + +const PAGE_SIZE: i64 = 50; + +// ---------- index page ---------- + +pub async fn index( + Extension(state): Extension>, + admin: AuthedUser, +) -> Result, ApiError> { + require_admin(&admin)?; + Ok(Html(render_full_page(&state).await?)) +} + +// ---------- create ---------- + +#[derive(Debug, Deserialize)] +pub struct CreateForm { + pub username: String, + #[serde(default)] + pub display_name: String, + #[serde(default)] + pub email: String, + pub password: String, + /// Checkbox: "on" if checked, absent otherwise. + #[serde(default)] + pub is_admin: Option, +} + +pub async fn create( + Extension(state): Extension>, + admin: AuthedUser, + Form(form): Form, +) -> Result, ApiError> { + require_admin(&admin)?; + if form.username.trim().is_empty() { + return notice_then_table(&state, "error", "Username required").await; + } + if form.password.len() < 4 { + return notice_then_table( + &state, + "error", + "Password must be at least 4 characters", + ) + .await; + } + let hash = hash_password(form.password) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let id = state + .db + .user_insert(NewUser { + username: form.username.trim(), + password_hash: &hash, + display_name: form.display_name.trim(), + is_admin: form.is_admin.as_deref() == Some("on"), + }) + .await + .map_err(|e| ApiError::Internal(format!("user_insert: {}", e)))?; + if !form.email.trim().is_empty() { + let _ = set_email_inline(&state, id, form.email.trim()).await; + } + notice_then_table(&state, "ok", &format!("Created user '{}'.", form.username)).await +} + +async fn set_email_inline( + state: &Arc, + user_id: i64, + email: &str, +) -> Result<(), String> { + state + .db + .raw_update_user_email(user_id, email) + .await + .map_err(|e| e.to_string()) +} + +// ---------- per-row actions ---------- + +#[derive(Debug, Deserialize)] +pub struct PasswordResetForm { + pub password: String, +} + +pub async fn reset_password( + Extension(state): Extension>, + admin: AuthedUser, + Path(id): Path, + Form(form): Form, +) -> Result, ApiError> { + require_admin(&admin)?; + if form.password.len() < 4 { + return notice_then_table( + &state, + "error", + "Password must be at least 4 characters", + ) + .await; + } + let hash = hash_password(form.password) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let ok = state + .db + .user_set_password(id, &hash) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + notice_then_table( + &state, + if ok { "ok" } else { "error" }, + if ok { "Password updated." } else { "User not found." }, + ) + .await +} + +pub async fn toggle_admin( + Extension(state): Extension>, + admin: AuthedUser, + Path(id): Path, +) -> Result, ApiError> { + require_admin(&admin)?; + if id == admin.user_id { + return notice_then_table( + &state, + "error", + "You can't revoke your own admin flag here. Edit another admin's row instead.", + ) + .await; + } + let user = state + .db + .user_find_by_id(id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))? + .ok_or(ApiError::NotFound)?; + state + .db + .user_set_admin(id, !user.is_admin) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + Ok(Html(render_table(&state).await?)) +} + +pub async fn toggle_status( + Extension(state): Extension>, + admin: AuthedUser, + Path(id): Path, +) -> Result, ApiError> { + require_admin(&admin)?; + if id == admin.user_id { + return notice_then_table( + &state, + "error", + "You can't disable your own account from here.", + ) + .await; + } + let user = state + .db + .user_find_by_id(id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))? + .ok_or(ApiError::NotFound)?; + let new_status: i64 = if user.status == 1 { 0 } else { 1 }; + state + .db + .user_set_status(id, new_status) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + Ok(Html(render_table(&state).await?)) +} + +pub async fn delete( + Extension(state): Extension>, + admin: AuthedUser, + Path(id): Path, +) -> Result, ApiError> { + require_admin(&admin)?; + if id == admin.user_id { + return notice_then_table( + &state, + "error", + "You can't delete the account you're signed in with.", + ) + .await; + } + let ok = state + .db + .user_delete(id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + notice_then_table( + &state, + if ok { "ok" } else { "error" }, + if ok { "User deleted." } else { "Already gone." }, + ) + .await +} + +// ---------- TOTP ---------- + +pub async fn totp_enroll( + Extension(state): Extension>, + admin: AuthedUser, + Path(id): Path, +) -> Result, ApiError> { + require_admin(&admin)?; + let user = state + .db + .user_find_by_id(id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))? + .ok_or(ApiError::NotFound)?; + let raw = sodiumoxide::randombytes::randombytes(20); + let secret_b32 = Secret::Raw(raw).to_encoded().to_string(); + state + .db + .totp_enroll(id, &secret_b32) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let issuer = "RustDesk"; + let otpauth = format!( + "otpauth://totp/{issuer}:{account}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30", + issuer = url_encode(issuer), + account = url_encode(&user.username), + secret = url_encode(&secret_b32), + ); + let mut html = format!( + r##"
+
TOTP enrolled for {user}
+
+
Secret (base32): {secret}
+
otpauth URL: {otpauth}
+
+ Show this once to the user (or scan the URL as a QR code) — it isn't displayed again. +
+
+
"##, + user = html_escape(&user.username), + secret = html_escape(&secret_b32), + otpauth = html_escape(&otpauth), + ); + html.push_str(&render_table(&state).await?); + Ok(Html(html)) +} + +pub async fn totp_unenroll( + Extension(state): Extension>, + admin: AuthedUser, + Path(id): Path, +) -> Result, ApiError> { + require_admin(&admin)?; + let removed = state + .db + .totp_unenroll(id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + notice_then_table( + &state, + if removed { "ok" } else { "error" }, + if removed { "TOTP removed." } else { "User had no TOTP." }, + ) + .await +} + +// ---------- rendering helpers ---------- + +async fn notice_then_table( + state: &Arc, + kind: &str, + msg: &str, +) -> Result, ApiError> { + let mut html = notice_html(kind, msg); + html.push_str(&render_table(state).await?); + Ok(Html(html)) +} + +async fn render_full_page(state: &Arc) -> Result { + let table = render_table(state).await?; + Ok(format!( + r##"
+
+

Users

+
+ +
+

Create user

+
+ + + + + + +
+
+ +
+ {table} +
+
"## + )) +} + +async fn render_table(state: &Arc) -> Result { + let (_total, users) = state + .db + .users_list_all(0, PAGE_SIZE) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + // One small query per row for the TOTP-enrolled flag — N is small. + let mut totp = std::collections::HashMap::new(); + for u in &users { + if let Ok(b) = state.db.user_has_totp(u.id).await { + totp.insert(u.id, b); + } + } + let mut s = String::new(); + s.push_str( + r##"
+ + + + + + + + + + + + + "##, + ); + for u in &users { + render_user_row(&mut s, u, *totp.get(&u.id).unwrap_or(&false)); + } + s.push_str(" \n
UsernameDisplay nameEmailStatusAdminTOTPActions
"); + Ok(s) +} + +fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool) { + let status_badge = match u.status { + 1 => r#"active"#, + 0 => r#"disabled"#, + -1 => r#"unverified"#, + _ => "", + }; + let admin_badge = if u.is_admin { + r#"admin"# + } else { + "" + }; + let totp_badge = if has_totp { + r#"enrolled"# + } else { + "" + }; + let _ = write!( + s, + r##" + {username} + {display_name} + {email} + {status} + {admin} + {totp} + +
+ ··· +
+
+ + +
+ + + + +
+
+ +"##, + id = u.id, + username = html_escape(&u.username), + display_name = html_escape(&u.display_name), + email = html_escape(&u.email), + status = status_badge, + admin = admin_badge, + totp = totp_badge, + admin_label = if u.is_admin { "Revoke admin" } else { "Grant admin" }, + status_label = if u.status == 1 { "Disable user" } else { "Enable user" }, + totp_action = if has_totp { "unenroll" } else { "enroll" }, + totp_label = if has_totp { "Disable TOTP" } else { "Enroll TOTP" }, + ); +} + +fn notice_html(kind: &str, msg: &str) -> String { + let (border, bg, text) = match kind { + "ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"), + _ => ("rose-700/50", "rose-900/30", "rose-300"), + }; + format!( + r##"
{msg}
"##, + border = border, + bg = bg, + text = text, + msg = html_escape(msg), + ) +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +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); + } + _ => { + let _ = write!(out, "%{:02X}", b); + } + } + } + out +} + +fn require_admin(u: &AuthedUser) -> Result<(), ApiError> { + if u.is_admin { + Ok(()) + } else { + Err(ApiError::Forbidden("admin required".into())) + } +} diff --git a/src/api/auth.rs b/src/api/auth.rs index 85a6add..d28395d 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -311,7 +311,7 @@ async fn issue_session( }))) } -fn verify_totp(secret_b32: &str, code: &str) -> Result { +pub(crate) fn verify_totp(secret_b32: &str, code: &str) -> Result { let secret = Secret::Encoded(secret_b32.to_string()) .to_bytes() .map_err(|e| ApiError::Internal(format!("bad TOTP secret: {:?}", e)))?; diff --git a/src/api/middleware.rs b/src/api/middleware.rs index 497b6b8..8b53044 100644 --- a/src/api/middleware.rs +++ b/src/api/middleware.rs @@ -1,10 +1,13 @@ use crate::api::error::ApiError; use crate::api::state::AppState; use async_trait::async_trait; -use axum::extract::{FromRequest, RequestParts, TypedHeader}; -use axum::headers::{authorization::Bearer, Authorization}; +use axum::extract::{FromRequest, RequestParts}; +use axum::http::header; use std::sync::Arc; +/// Cookie name used by the admin dashboard. Browser-set, HttpOnly, SameSite=Strict. +pub const SESSION_COOKIE: &str = "rd_admin_session"; + pub struct AuthedUser { pub user_id: i64, pub name: String, @@ -22,13 +25,11 @@ impl FromRequest for AuthedUser { type Rejection = ApiError; async fn from_request(req: &mut RequestParts) -> Result { - let bearer: TypedHeader> = - TypedHeader::from_request(req).await.map_err(|_| ApiError::Unauthorized)?; let state: axum::extract::Extension> = axum::extract::Extension::from_request(req) .await .map_err(|_| ApiError::Internal("missing state".into()))?; - let token = bearer.0 .0.token().to_string(); + let token = extract_token(req).ok_or(ApiError::Unauthorized)?; let sha = sha256_token(&token); let (user_id, _exp) = state @@ -57,3 +58,38 @@ impl FromRequest for AuthedUser { }) } } + +/// Extract a token from either the `Authorization: Bearer …` header (preferred, +/// for the desktop client and curl) or the `rd_admin_session` cookie (for the +/// browser-driven admin dashboard). Returns `None` if neither is present. +fn extract_token(req: &RequestParts) -> Option { + // Bearer header wins so a curl smoke test always behaves predictably, + // even when run from the same browser session. + if let Some(auth) = req.headers().get(header::AUTHORIZATION) { + if let Ok(s) = auth.to_str() { + if let Some(tok) = s.strip_prefix("Bearer ").map(str::trim) { + if !tok.is_empty() { + return Some(tok.to_string()); + } + } + } + } + // Cookie header is a single line: `name=value; name2=value2; …`. Walk + // the comma-or-semicolon-separated pairs without pulling in a cookie crate. + if let Some(cookie_hdr) = req.headers().get(header::COOKIE) { + if let Ok(s) = cookie_hdr.to_str() { + for pair in s.split(';') { + let pair = pair.trim(); + if let Some((name, value)) = pair.split_once('=') { + if name.trim() == SESSION_COOKIE { + let v = value.trim(); + if !v.is_empty() { + return Some(v.to_string()); + } + } + } + } + } + } + None +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 7e5b092..f95187a 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -4,6 +4,7 @@ //! add address book, audit, OIDC, etc. pub mod ab; +pub mod admin; pub mod audit; pub mod auth; pub mod devices_cli; @@ -34,7 +35,7 @@ use std::net::SocketAddr; use std::sync::Arc; pub fn router(state: Arc) -> Router { - Router::new() + let app = Router::new() // M1: auth + heartbeat + sysinfo .route( "/api/login-options", @@ -91,8 +92,14 @@ pub fn router(state: Arc) -> Router { // M4: OIDC device-flow login .route("/api/oidc/auth", post(oidc::auth::auth)) .route("/api/oidc/auth-query", get(oidc::poll::auth_query)) - .route("/oidc/callback", get(oidc::callback::callback)) - .layer(Extension(state)) + .route("/oidc/callback", get(oidc::callback::callback)); + // M5: admin dashboard (HTMX + embedded HTML). Merged BEFORE the + // Extension layer so the merged router carries the shared state. + let app = match admin::build(state.clone()) { + Some(admin_router) => app.merge(admin_router), + None => app, + }; + app.layer(Extension(state)) } pub async fn serve(addr: SocketAddr, state: Arc) -> ResultType<()> { diff --git a/src/api/state.rs b/src/api/state.rs index 73bcfe8..bd3e952 100644 --- a/src/api/state.rs +++ b/src/api/state.rs @@ -27,6 +27,9 @@ pub struct ApiConfig { /// Externally reachable base URL of this server, e.g. for the OIDC /// redirect_uri. Empty disables OIDC. pub public_base_url: String, + /// On-disk root for the admin dashboard's static files. Empty disables + /// the dashboard entirely. + pub admin_ui_dir: String, } /// SMTP wiring for email-code login. @@ -68,6 +71,7 @@ impl AppState { .unwrap_or(0); let email = build_email_config(); let public_base_url = get_arg("public-base-url"); + let admin_ui_dir = get_arg_or("admin-ui-dir", "./admin_ui".to_string()); // login_options advertises every login method this server accepts. // The Flutter client uses this to render the matching button on the // sign-in dialog. `email_code` and `oidc/` are opt-in so a @@ -91,6 +95,7 @@ impl AppState { audit_retention_days, email, public_base_url, + admin_ui_dir, }, }) } diff --git a/src/database.rs b/src/database.rs index 803e23e..80cbccc 100644 --- a/src/database.rs +++ b/src/database.rs @@ -108,6 +108,78 @@ pub struct DeviceGroupRow { pub name: String, } +#[derive(Debug, Clone)] +pub struct AbOverviewRow { + pub guid: String, + pub name: String, + pub kind: i64, // 0=personal, 1=shared + pub owner_username: String, + pub peer_count: i64, + pub created_at: i64, +} + +#[derive(Debug, Clone)] +pub struct StrategyRow { + pub id: i64, + pub name: String, + pub modified_at: i64, + pub config_options_json: String, + pub extra_json: String, +} + +#[derive(Debug, Clone)] +pub struct AuditConnRow { + pub guid: String, + pub peer_id: String, + pub conn_id: i64, + pub session_id: i64, + pub ip: String, + pub action: String, + pub note: String, + pub started_at: i64, +} + +#[derive(Debug, Clone)] +pub struct AuditFileRow { + pub id: i64, + pub peer_id: String, + pub remote_peer: String, + pub direction: i64, + pub path: String, + pub is_file: bool, + pub info_json: String, + pub at: i64, +} + +#[derive(Debug, Clone)] +pub struct AuditAlarmRow { + pub id: i64, + pub peer_id: String, + pub typ: i64, + pub info_json: String, + pub at: i64, +} + +#[derive(Debug, Clone)] +pub struct RecordingRow { + pub filename: String, + pub peer_id: String, + pub size: i64, + pub state: String, + pub started_at: i64, + pub finished_at: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct DashboardDeviceRow { + pub id: String, + pub uuid: String, + pub owner_username: String, + pub last_heartbeat_at: String, + pub sysinfo_payload: String, + pub conns_json: String, +} + #[derive(Debug, Clone, Default)] pub struct PeerListRow { pub id: String, @@ -347,6 +419,431 @@ impl Database { Ok(row.map(row_to_user)) } + /// All users, including disabled ones — distinct from + /// `users_list_accessible`, which the API uses (filtering by status=1 + /// and visibility through device-groups). The dashboard wants the + /// full picture. + pub async fn users_list_all( + &self, + offset: i64, + limit: i64, + ) -> ResultType<(i64, Vec)> { + let total: i64 = sqlx::query("SELECT COUNT(*) AS c FROM users") + .fetch_one(self.pool.get().await?.deref_mut()) + .await? + .try_get("c")?; + let rows = sqlx::query( + "SELECT id, username, password_hash, display_name, email, note, avatar, status, is_admin \ + FROM users ORDER BY username LIMIT ? OFFSET ?", + ) + .bind(limit) + .bind(offset) + .fetch_all(self.pool.get().await?.deref_mut()) + .await?; + Ok((total, rows.into_iter().map(row_to_user).collect())) + } + + pub async fn user_set_status(&self, id: i64, status: i64) -> ResultType { + let res = sqlx::query("UPDATE users SET status = ?, updated_at = current_timestamp WHERE id = ?") + .bind(status) + .bind(id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(res.rows_affected() > 0) + } + + pub async fn user_set_admin(&self, id: i64, is_admin: bool) -> ResultType { + let res = sqlx::query( + "UPDATE users SET is_admin = ?, updated_at = current_timestamp WHERE id = ?", + ) + .bind(if is_admin { 1i64 } else { 0i64 }) + .bind(id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(res.rows_affected() > 0) + } + + pub async fn user_set_password(&self, id: i64, hash: &str) -> ResultType { + let res = sqlx::query( + "UPDATE users SET password_hash = ?, updated_at = current_timestamp WHERE id = ?", + ) + .bind(hash) + .bind(id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(res.rows_affected() > 0) + } + + /// Deletes the user row. Cascade hits `tokens` (FK ON DELETE CASCADE) + /// — TOTP secrets and AB ownership are best-effort cleaned by separate + /// queries below. + pub async fn user_delete(&self, id: i64) -> ResultType { + let _ = sqlx::query("DELETE FROM user_totp_secrets WHERE user_id = ?") + .bind(id) + .execute(self.pool.get().await?.deref_mut()) + .await; + let _ = sqlx::query("DELETE FROM device_group_members WHERE user_id = ?") + .bind(id) + .execute(self.pool.get().await?.deref_mut()) + .await; + let _ = sqlx::query("DELETE FROM address_book_shares WHERE user_id = ?") + .bind(id) + .execute(self.pool.get().await?.deref_mut()) + .await; + let res = sqlx::query("DELETE FROM users WHERE id = ?") + .bind(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( + &self, + offset: i64, + limit: i64, + ) -> ResultType<(i64, Vec)> { + let total: i64 = sqlx::query("SELECT COUNT(*) AS c FROM device_sysinfo") + .fetch_one(self.pool.get().await?.deref_mut()) + .await? + .try_get("c")?; + let rows = sqlx::query( + "SELECT ds.id AS pid, ds.uuid AS puuid, \ + COALESCE(u.username, '') AS owner_username, \ + ds.last_heartbeat_at AS last_hb, \ + ds.payload AS payload, \ + ds.conns AS conns \ + FROM device_sysinfo ds \ + LEFT JOIN users u ON u.id = ds.user_id \ + ORDER BY ds.last_heartbeat_at DESC LIMIT ? OFFSET ?", + ) + .bind(limit) + .bind(offset) + .fetch_all(self.pool.get().await?.deref_mut()) + .await?; + let data = rows + .into_iter() + .map(|r| DashboardDeviceRow { + id: r.try_get("pid").unwrap_or_default(), + uuid: r.try_get("puuid").unwrap_or_default(), + owner_username: r.try_get("owner_username").unwrap_or_default(), + last_heartbeat_at: r.try_get("last_hb").unwrap_or_default(), + sysinfo_payload: r.try_get("payload").unwrap_or_default(), + conns_json: r.try_get("conns").unwrap_or_default(), + }) + .collect(); + Ok((total, data)) + } + + pub async fn device_sysinfo_get_conns(&self, peer_id: &str) -> ResultType { + let row = sqlx::query("SELECT conns FROM device_sysinfo WHERE id = ? LIMIT 1") + .bind(peer_id) + .fetch_optional(self.pool.get().await?.deref_mut()) + .await?; + Ok(row + .and_then(|r| r.try_get::("conns").ok()) + .unwrap_or_else(|| "[]".to_string())) + } + + pub async fn heartbeat_command_queue( + &self, + peer_id: &str, + kind: &str, + payload: Option<&str>, + ) -> ResultType<()> { + sqlx::query( + "INSERT OR REPLACE INTO heartbeat_commands(peer_id, kind, payload, created_at) \ + VALUES(?, ?, ?, strftime('%s','now'))", + ) + .bind(peer_id) + .bind(kind) + .bind(payload) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(()) + } + + /// All address books, with the owner's username and an optional + /// per-AB peer count. Used by the dashboard's read-only AB overview. + pub async fn ab_list_all_with_owner(&self) -> ResultType> { + let rows = sqlx::query( + "SELECT ab.guid, ab.name, ab.kind, ab.created_at, \ + COALESCE(u.username, '') AS owner_username, \ + (SELECT COUNT(*) FROM address_book_peers abp WHERE abp.ab_guid = ab.guid) AS peer_count \ + FROM address_books ab LEFT JOIN users u ON u.id = ab.owner_user_id \ + ORDER BY ab.kind, owner_username, ab.name", + ) + .fetch_all(self.pool.get().await?.deref_mut()) + .await?; + Ok(rows + .into_iter() + .map(|r| AbOverviewRow { + guid: r.try_get("guid").unwrap_or_default(), + name: r.try_get("name").unwrap_or_default(), + kind: r.try_get("kind").unwrap_or(0), + owner_username: r.try_get("owner_username").unwrap_or_default(), + peer_count: r.try_get("peer_count").unwrap_or(0), + created_at: r.try_get("created_at").unwrap_or(0), + }) + .collect()) + } + + // ---- M5 dashboard helpers: groups / strategies / audit / recordings ---- + + pub async fn device_groups_list_all(&self) -> ResultType> { + let rows = sqlx::query("SELECT id, name FROM device_groups ORDER BY name") + .fetch_all(self.pool.get().await?.deref_mut()) + .await?; + Ok(rows + .into_iter() + .map(|r| DeviceGroupRow { + id: r.try_get("id").unwrap_or(0), + name: r.try_get("name").unwrap_or_default(), + }) + .collect()) + } + + pub async fn device_group_members(&self, group_id: i64) -> ResultType> { + let rows = sqlx::query( + "SELECT u.id, u.username, u.password_hash, u.display_name, u.email, u.note, u.avatar, u.status, u.is_admin \ + FROM users u JOIN device_group_members m ON m.user_id = u.id \ + WHERE m.device_group_id = ? ORDER BY u.username", + ) + .bind(group_id) + .fetch_all(self.pool.get().await?.deref_mut()) + .await?; + Ok(rows.into_iter().map(row_to_user).collect()) + } + + pub async fn device_group_create(&self, name: &str) -> ResultType { + sqlx::query("INSERT OR IGNORE INTO device_groups(name) VALUES(?)") + .bind(name) + .execute(self.pool.get().await?.deref_mut()) + .await?; + let row = sqlx::query("SELECT id FROM device_groups WHERE name = ?") + .bind(name) + .fetch_one(self.pool.get().await?.deref_mut()) + .await?; + Ok(row.try_get("id")?) + } + + pub async fn device_group_delete(&self, group_id: i64) -> ResultType { + let _ = sqlx::query("DELETE FROM device_group_members 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()) + .await?; + Ok(res.rows_affected() > 0) + } + + pub async fn device_group_add_member( + &self, + group_id: i64, + user_id: i64, + ) -> ResultType<()> { + sqlx::query( + "INSERT OR IGNORE INTO device_group_members(device_group_id, user_id) VALUES(?, ?)", + ) + .bind(group_id) + .bind(user_id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(()) + } + + pub async fn device_group_remove_member( + &self, + group_id: i64, + user_id: i64, + ) -> ResultType<()> { + sqlx::query( + "DELETE FROM device_group_members WHERE device_group_id = ? AND user_id = ?", + ) + .bind(group_id) + .bind(user_id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(()) + } + + pub async fn strategies_list_all(&self) -> ResultType> { + let rows = sqlx::query( + "SELECT id, name, modified_at, config_options_json, extra_json \ + FROM strategies ORDER BY name", + ) + .fetch_all(self.pool.get().await?.deref_mut()) + .await?; + Ok(rows + .into_iter() + .map(|r| StrategyRow { + id: r.try_get("id").unwrap_or(0), + name: r.try_get("name").unwrap_or_default(), + modified_at: r.try_get("modified_at").unwrap_or(0), + config_options_json: r + .try_get("config_options_json") + .unwrap_or_else(|_| "{}".to_string()), + extra_json: r.try_get("extra_json").unwrap_or_else(|_| "{}".to_string()), + }) + .collect()) + } + + pub async fn strategy_create( + &self, + name: &str, + config_options_json: &str, + extra_json: &str, + ) -> ResultType { + let res = sqlx::query( + "INSERT INTO strategies(name, modified_at, config_options_json, extra_json) \ + VALUES(?, strftime('%s','now'), ?, ?)", + ) + .bind(name) + .bind(config_options_json) + .bind(extra_json) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(res.last_insert_rowid()) + } + + pub async fn strategy_update_config( + &self, + id: i64, + config_options_json: &str, + ) -> ResultType<()> { + sqlx::query( + "UPDATE strategies SET config_options_json = ?, modified_at = strftime('%s','now') \ + WHERE id = ?", + ) + .bind(config_options_json) + .bind(id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(()) + } + + pub async fn strategy_delete(&self, id: i64) -> ResultType { + let _ = sqlx::query("DELETE FROM strategy_assignments WHERE strategy_id = ?") + .bind(id) + .execute(self.pool.get().await?.deref_mut()) + .await; + let res = sqlx::query("DELETE FROM strategies WHERE id = ?") + .bind(id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(res.rows_affected() > 0) + } + + /// Audit listings (newest first) — used by the dashboard browser. Each + /// returns at most `limit` rows; the dashboard caps at a few hundred. + pub async fn audit_conn_list(&self, limit: i64) -> ResultType> { + let rows = sqlx::query( + "SELECT guid, peer_id, conn_id, session_id, ip, action, note, started_at \ + FROM audit_conn ORDER BY started_at DESC LIMIT ?", + ) + .bind(limit) + .fetch_all(self.pool.get().await?.deref_mut()) + .await?; + Ok(rows + .into_iter() + .map(|r| AuditConnRow { + guid: r.try_get("guid").unwrap_or_default(), + peer_id: r.try_get("peer_id").unwrap_or_default(), + conn_id: r.try_get("conn_id").unwrap_or(0), + session_id: r.try_get("session_id").unwrap_or(0), + ip: r.try_get::, _>("ip").ok().flatten().unwrap_or_default(), + action: r.try_get("action").unwrap_or_default(), + note: r.try_get::, _>("note").ok().flatten().unwrap_or_default(), + started_at: r.try_get("started_at").unwrap_or(0), + }) + .collect()) + } + + pub async fn audit_file_list(&self, limit: i64) -> ResultType> { + let rows = sqlx::query( + "SELECT id, peer_id, remote_peer, direction, path, is_file, info_json, at \ + FROM audit_file ORDER BY at DESC LIMIT ?", + ) + .bind(limit) + .fetch_all(self.pool.get().await?.deref_mut()) + .await?; + Ok(rows + .into_iter() + .map(|r| AuditFileRow { + id: r.try_get("id").unwrap_or(0), + peer_id: r.try_get("peer_id").unwrap_or_default(), + remote_peer: r.try_get::, _>("remote_peer").ok().flatten().unwrap_or_default(), + direction: r.try_get("direction").unwrap_or(0), + path: r.try_get("path").unwrap_or_default(), + is_file: r.try_get::("is_file").unwrap_or(0) != 0, + info_json: r.try_get("info_json").unwrap_or_default(), + at: r.try_get("at").unwrap_or(0), + }) + .collect()) + } + + pub async fn audit_alarm_list(&self, limit: i64) -> ResultType> { + let rows = sqlx::query( + "SELECT id, peer_id, typ, info_json, at \ + FROM audit_alarm ORDER BY at DESC LIMIT ?", + ) + .bind(limit) + .fetch_all(self.pool.get().await?.deref_mut()) + .await?; + Ok(rows + .into_iter() + .map(|r| AuditAlarmRow { + id: r.try_get("id").unwrap_or(0), + peer_id: r.try_get("peer_id").unwrap_or_default(), + typ: r.try_get("typ").unwrap_or(0), + info_json: r.try_get("info_json").unwrap_or_default(), + at: r.try_get("at").unwrap_or(0), + }) + .collect()) + } + + pub async fn recordings_list(&self, limit: i64) -> ResultType> { + let rows = sqlx::query( + "SELECT filename, peer_id, size, state, started_at, finished_at \ + FROM recordings ORDER BY started_at DESC LIMIT ?", + ) + .bind(limit) + .fetch_all(self.pool.get().await?.deref_mut()) + .await?; + Ok(rows + .into_iter() + .map(|r| RecordingRow { + filename: r.try_get("filename").unwrap_or_default(), + peer_id: r.try_get("peer_id").unwrap_or_default(), + size: r.try_get("size").unwrap_or(0), + state: r.try_get("state").unwrap_or_default(), + started_at: r.try_get("started_at").unwrap_or(0), + finished_at: r.try_get::, _>("finished_at").ok().flatten(), + }) + .collect()) + } + + pub async fn raw_update_user_email(&self, user_id: i64, email: &str) -> ResultType<()> { + sqlx::query("UPDATE users SET email = ?, updated_at = current_timestamp WHERE id = ?") + .bind(email) + .bind(user_id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(()) + } + + pub async fn user_has_totp(&self, user_id: i64) -> ResultType { + let row = + sqlx::query("SELECT 1 AS ok FROM user_totp_secrets WHERE user_id = ?") + .bind(user_id) + .fetch_optional(self.pool.get().await?.deref_mut()) + .await?; + Ok(row.is_some()) + } + pub async fn user_insert(&self, u: NewUser<'_>) -> ResultType { let admin_int: i64 = if u.is_admin { 1 } else { 0 }; let res = sqlx::query( diff --git a/src/main.rs b/src/main.rs index 0a945b0..4b50700 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,7 @@ fn main() -> ResultType<()> { --smtp-tls=[on|off] 'STARTTLS on the SMTP connection (default: on)' --public-base-url=[URL] 'Externally reachable HTTP base URL (e.g. https://rustdesk.example.com:21114) — required for OIDC redirect callbacks' --oidc-config=[PATH] 'TOML file describing OIDC providers (upserted into oidc_providers at startup)' + --admin-ui-dir=[PATH] 'Directory of static admin-dashboard files served at /admin/ (default: ./admin_ui; empty disables)' , --mask=[MASK] 'Determine if the connection comes from LAN, e.g. 192.168.0.0/16' -k, --key=[KEY] 'Only allow the client with the same key'", );