feat: M5 admin dashboard (HTMX + Tailwind CDN, embedded HTML)

A web admin UI for the rustdesk-server, mounted at /admin/* on the
existing HTTP API listener. Single-binary deploy preserved — the two
HTML files live in admin_ui/ and are pulled into the binary via
include_str! at build time, so there's nothing extra to ship.

================================================================================
Architecture
================================================================================
- Stack: HTMX 1.9 + Tailwind play CDN. No SPA, no Node toolchain. Pages
  are server-rendered HTML fragments returned by Rust handlers via
  Html<String>; the index.html shell uses hx-get to drop a fragment into
  the main pane and hx-push-url for back-button history.
- Auth: same Bearer-token table the API uses. The dashboard log-in form
  POSTs username + password (+ optional TOTP) to /admin/login; on success
  the server mints a token and pins it in an HttpOnly + SameSite=Strict
  cookie (`rd_admin_session`). The AuthedUser extractor was extended to
  accept either the Authorization: Bearer header (curl, desktop client)
  OR the session cookie (browser).
- Embedding: src/api/admin/mod.rs has `include_str!("../../../admin_ui/index.html")`
  + login.html. No tower_http::ServeDir wildcard — we ran into axum 0.5
  routing conflicts between literal /admin/login routes and an /admin/*
  catch-all, so each HTML file is its own explicit route.

================================================================================
M5a — foundation
================================================================================
Files:
  admin_ui/index.html   page shell + sidebar + HTMX + 401-bounces-to-login
  admin_ui/login.html   credentials + TOTP form, posts to /admin/login
  src/api/admin/mod.rs  router + include_str! + Cache-Control: no-cache
  src/api/admin/auth.rs /admin/login POST (form-encoded), /admin/logout POST
  src/api/admin/me.rs   sidebar fragment ("Signed in as <name>")
  src/api/middleware.rs `AuthedUser` now reads either Bearer OR cookie
  src/api/state.rs      `admin_ui_dir` (informational; UI is embedded)
  src/main.rs           --admin-ui-dir flag (empty disables the dashboard)

The login flow asks for TOTP transparently in the same form when the
target user has a secret enrolled, so the dashboard inherits the TOTP
gate from the API auth surface for free.

================================================================================
M5b — full CRUD pages
================================================================================
- Users (src/api/admin/pages/users.rs) — list, create, password reset,
  toggle admin / status, TOTP enroll / unenroll, delete. TOTP enroll
  surfaces the secret + otpauth URL once, on a dismissible banner above
  the table.
- Devices (devices.rs) — list with hostname/OS/last-heartbeat/conn count,
  force-disconnect (queues `heartbeat_commands` row consumed at the next
  /api/heartbeat tick), force-sysinfo refresh.
- Device groups (groups.rs) — list / create / delete / add member /
  remove member. Per-group section, with an add-member dropdown of users
  not yet in the group.
- Strategies (strategies.rs) — list / create / edit config_options /
  delete. config_options is validated as a JSON object on the server side
  before persist; bad JSON is reflected to the page with a friendly
  error notice.
- Address books (address_books.rs) — read-only overview of all books
  with owner, kind (personal / shared badge), peer count, GUID.
- OIDC providers (oidc.rs) — read-only list of what's configured. Editing
  remains operator-side via --oidc-config TOML or direct SQL.

================================================================================
M5c — audit + recordings browsers
================================================================================
- Audit log (audit.rs) — three tabs (Connections / File transfers /
  Alarms), each capped at the latest 200 rows. Tab pills are HTMX links
  with hx-get + hx-target="#main" so the tab switch is a single fetch.
- Recordings (recordings.rs) — read-only list with peer / size / state /
  start / finish. Streaming download is a follow-up; for now operators
  pull files from --recording-dir directly.

================================================================================
DB methods added
================================================================================
- Users:    users_list_all, user_set_status, user_set_admin,
            user_set_password, user_delete, user_has_totp,
            raw_update_user_email
- Devices:  devices_list_all, device_sysinfo_get_conns,
            heartbeat_command_queue (also used elsewhere; surfaced)
- Groups:   device_groups_list_all, device_group_members,
            device_group_create, device_group_delete,
            device_group_add_member, device_group_remove_member
- Strategy: strategies_list_all, strategy_create,
            strategy_update_config, strategy_delete
- Audit:    audit_conn_list, audit_file_list, audit_alarm_list
- Misc:     ab_list_all_with_owner, recordings_list

All use the runtime sqlx::query("...") form (matching the project-wide
convention) so the SQLite compile-time-check macros don't require these
new tables to pre-exist in the dev DB.

================================================================================
Conventions enforced
================================================================================
- Every page handler gates on require_admin(&AuthedUser) — non-admin
  users get an HTTP 403 + JSON envelope, which the SPA shell catches and
  bounces back to the login form.
- HTML fragments are produced via `format!`-with-named-args; html_escape
  is centralized in src/api/admin/pages/shared.rs and applied to every
  user-supplied string before it lands in the DOM.
- All mutations return either the updated table fragment OR
  notice_html(kind, msg) + the table — same pattern across pages, so
  HTMX swap targets stay simple (always #region innerHTML).
- Cookie carries no path restriction so it also authorizes /api/* calls
  the dashboard might want to make from the browser; HttpOnly +
  SameSite=Strict mitigates XSS / CSRF; Max-Age tracks ApiConfig's
  session_ttl_secs (30 days).

================================================================================
Verification
================================================================================
1. cargo build --release — clean.
2. End-to-end smoke test:
   - /admin/ serves index.html (4406 bytes), /admin/login.html serves
     login.html (2598 bytes).
   - POST /admin/login with valid creds returns 200 + Set-Cookie
     `rd_admin_session=…; HttpOnly; Path=/; SameSite=Strict; Max-Age=…`.
   - All eight /admin/pages/* fragments return 200 with cookie.
   - Users CRUD round-trip: create alice → toggle admin → disable →
     reset password → enroll TOTP (32-char secret displayed once) →
     unenroll → delete; self-action guard rejects suicide deletes.
   - Groups CRUD: create engineering → add alice as member → SQL
     confirms the row.
   - Strategies: valid JSON accepted, invalid JSON rejected with a
     friendly notice.
   - Audit tabs: all three render 200; empty-state messages appear when
     no rows.
   - /admin/logout clears the cookie; subsequent /admin/me returns 401.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 20:13:35 +02:00
parent 8ecf05b106
commit 5b288d671c
21 changed files with 2633 additions and 9 deletions
+87
View File
@@ -0,0 +1,87 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8" />
<title>RustDesk Admin</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
.nav-link.active { background: rgb(15 23 42); color: rgb(125 211 252); }
</style>
</head>
<body class="h-full bg-slate-950 text-slate-100">
<!--
Single-page shell. The sidebar drives navigation via HTMX:
each link does an `hx-get` of an HTML fragment URL that returns the
body of the page. The fragments live under /admin/pages/ and are
server-rendered Rust handlers that return Html<String>.
This keeps the UI a flat directory of static files plus a small
set of fragment endpoints — no SPA, no Node, no build step.
-->
<div class="min-h-full flex">
<aside class="w-56 shrink-0 bg-slate-900 border-r border-slate-800 flex flex-col">
<div class="px-4 py-5 border-b border-slate-800">
<h1 class="text-base font-semibold">RustDesk Admin</h1>
<p id="me-display" class="text-xs text-slate-500 mt-1" hx-get="/admin/me" hx-trigger="load" hx-swap="innerHTML"></p>
</div>
<nav class="flex-1 px-2 py-3 space-y-1">
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
hx-get="/admin/pages/users" hx-target="#main" hx-push-url="#users">Users</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/devices" hx-target="#main" hx-push-url="#devices">Devices</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/groups" hx-target="#main" hx-push-url="#groups">Device groups</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/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"
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"
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>
</nav>
<div class="px-2 py-3 border-t border-slate-800">
<button
class="w-full text-left px-3 py-1.5 text-sm rounded text-slate-400 hover:bg-slate-800"
hx-post="/admin/logout"
hx-on::after-request="window.location.href = '/admin/login.html'"
>Sign out</button>
</div>
</aside>
<main id="main" class="flex-1 p-6 overflow-x-hidden"
hx-get="/admin/pages/users" hx-trigger="load">
<div class="text-slate-500 text-sm">Loading…</div>
</main>
</div>
<!-- Toast container used by all admin handlers via hx-trigger="load delay:1s" -->
<div id="toast"
class="fixed bottom-4 right-4 max-w-sm space-y-2 pointer-events-none"></div>
<!-- Highlight active link based on hash -->
<script>
function refreshActive() {
const hash = location.hash || '#users';
document.querySelectorAll('.nav-link').forEach(a => {
const ahash = a.getAttribute('hx-push-url');
a.classList.toggle('active', ahash === hash);
});
}
window.addEventListener('hashchange', refreshActive);
document.body.addEventListener('htmx:afterSwap', refreshActive);
refreshActive();
// Bounce to login if any HTMX request comes back 401.
document.body.addEventListener('htmx:responseError', (evt) => {
if (evt.detail.xhr.status === 401) {
window.location.href = '/admin/login.html';
}
});
</script>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8" />
<title>Sign in — RustDesk Admin</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
</style>
</head>
<body class="h-full bg-slate-950 text-slate-100 flex items-center justify-center">
<main class="w-full max-w-sm px-6">
<div class="text-center mb-8">
<h1 class="text-2xl font-semibold">RustDesk Admin</h1>
<p class="text-slate-400 text-sm mt-1">Sign in to manage the server.</p>
</div>
<form
class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-6 shadow-xl"
hx-post="/admin/login"
hx-target="#err"
hx-swap="innerHTML"
hx-on::after-request="if (event.detail.successful) window.location.href = '/admin/'"
>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1" for="username">Username</label>
<input
id="username" name="username" type="text" required autocomplete="username"
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500"
/>
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1" for="password">Password</label>
<input
id="password" name="password" type="password" required autocomplete="current-password"
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500"
/>
</div>
<div id="tfa-section" class="hidden">
<label class="block text-xs font-medium text-slate-400 mb-1" for="tfaCode">6-digit TOTP code</label>
<input
id="tfaCode" name="tfaCode" type="text" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code"
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm tracking-widest text-center focus:outline-none focus:border-sky-500"
/>
<input id="secret" name="secret" type="hidden" />
</div>
<button
type="submit"
class="w-full bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition"
>
Sign in
</button>
<div id="err" class="text-sm text-rose-400 min-h-[1.25em]"></div>
</form>
</main>
</body>
</html>
+172
View File
@@ -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<Arc<AppState>>,
Form(form): Form<LoginForm>,
) -> 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#"<span data-tfa-required="1" class="text-amber-300">Enter your 6-digit authenticator code.</span>
<script>
document.getElementById('tfa-section').classList.remove('hidden');
document.getElementById('tfaCode').focus();
</script>"#
);
// 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<Arc<AppState>>,
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<String> {
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!("<span>{}</span>", html_escape(msg));
(StatusCode::UNAUTHORIZED, Html(html)).into_response()
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
+21
View File
@@ -0,0 +1,21 @@
//! `/admin/me` — small HTMX fragment used by the sidebar to show "signed in
//! as <name>". 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<Html<String>, ApiError> {
Ok(Html(format!(
"Signed in as <span class=\"text-slate-300\">{}</span>",
html_escape(&user.name)
)))
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
+139
View File
@@ -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<crate::api::state::AppState>) -> Option<Router> {
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
}
+75
View File
@@ -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<Arc<AppState>>,
admin: AuthedUser,
) -> Result<Html<String>, 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##"<div class="space-y-4">
<header>
<h2 class="text-lg font-semibold">Address books</h2>
<p class="text-xs text-slate-500 mt-1">Read-only. Address-book contents are mutated from the desktop client; this page surfaces who owns what and how big each book is.</p>
</header>"##,
);
if books.is_empty() {
s.push_str(r##"<p class="text-slate-500 text-sm">No address books exist 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">Owner</th>
<th class="text-left font-medium px-3 py-2">Kind</th>
<th class="text-left font-medium px-3 py-2">Name</th>
<th class="text-left font-medium px-3 py-2">Peers</th>
<th class="text-left font-medium px-3 py-2">GUID</th>
<th class="text-left font-medium px-3 py-2">Created</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
);
for b in &books {
let kind = match b.kind {
0 => r#"<span class="text-xs px-1.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-slate-300">personal</span>"#,
1 => r#"<span class="text-xs px-1.5 py-0.5 rounded bg-violet-900/40 border border-violet-700/50 text-violet-300">shared</span>"#,
_ => "",
};
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 text-slate-200">{owner}</td>
<td class="px-3 py-2">{kind}</td>
<td class="px-3 py-2 text-slate-300">{name}</td>
<td class="px-3 py-2 text-slate-400">{count}</td>
<td class="px-3 py-2 font-mono text-xs text-slate-500">{guid}</td>
<td class="px-3 py-2 text-slate-500 text-xs">{created}</td>
</tr>"##,
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("</tbody></table></div></div>");
Ok(Html(s))
}
+213
View File
@@ -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<String>,
}
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Query(q): Query<TabQuery>,
) -> Result<Html<String>, 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##"<a href="#audit" hx-get="/admin/pages/audit?tab={id}" hx-target="#main" class="px-3 py-1 rounded border {cls}">{label}</a>"##,
id = id,
cls = cls,
label = label,
)
};
Ok(Html(format!(
r##"<div class="space-y-4">
<header class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Audit log</h2>
<p class="text-xs text-slate-500">Latest {n} rows.</p>
</header>
<div class="flex gap-2 text-xs">{pill_conn}{pill_file}{pill_alarm}</div>
{body}
</div>"##,
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<AppState>) -> Result<String, ApiError> {
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##"<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">When</th>
<th class="text-left font-medium px-3 py-2">Peer</th>
<th class="text-left font-medium px-3 py-2">Conn / Session</th>
<th class="text-left font-medium px-3 py-2">IP</th>
<th class="text-left font-medium px-3 py-2">Action</th>
<th class="text-left font-medium px-3 py-2">Note</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 text-slate-500 text-xs">{when}</td>
<td class="px-3 py-2 font-mono text-slate-200">{peer}</td>
<td class="px-3 py-2 text-slate-400">{conn} / {sess}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{ip}</td>
<td class="px-3 py-2 text-slate-300">{action}</td>
<td class="px-3 py-2 text-slate-400">{note}</td>
</tr>"##,
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("</tbody></table></div>");
Ok(s)
}
async fn render_file(state: &Arc<AppState>) -> Result<String, ApiError> {
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##"<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">When</th>
<th class="text-left font-medium px-3 py-2">Peer</th>
<th class="text-left font-medium px-3 py-2">Direction</th>
<th class="text-left font-medium px-3 py-2">Path</th>
<th class="text-left font-medium px-3 py-2">Remote</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
);
for r in &rows {
let dir = match r.direction {
0 => "→ remote",
1 => "← remote",
_ => "?",
};
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 text-slate-500 text-xs">{when}</td>
<td class="px-3 py-2 font-mono text-slate-200">{peer}</td>
<td class="px-3 py-2 text-slate-400">{dir}</td>
<td class="px-3 py-2 text-slate-300 font-mono text-xs">{path}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{remote}</td>
</tr>"##,
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("</tbody></table></div>");
Ok(s)
}
async fn render_alarm(state: &Arc<AppState>) -> Result<String, ApiError> {
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##"<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">When</th>
<th class="text-left font-medium px-3 py-2">Peer</th>
<th class="text-left font-medium px-3 py-2">Type</th>
<th class="text-left font-medium px-3 py-2">Info</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
);
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##"<tr>
<td class="px-3 py-2 text-slate-500 text-xs">{when}</td>
<td class="px-3 py-2 font-mono text-slate-200">{peer}</td>
<td class="px-3 py-2 text-amber-300">{typ}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs break-all">{info}</td>
</tr>"##,
when = html_escape(&fmt_unix(r.at)),
peer = html_escape(&r.peer_id),
typ = typ,
info = html_escape(&r.info_json)
);
}
s.push_str("</tbody></table></div>");
Ok(s)
}
fn empty_table(msg: &str) -> String {
format!(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-6 text-center text-sm text-slate-500">{}</div>"##,
html_escape(msg)
)
}
+211
View File
@@ -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<Arc<AppState>>,
admin: AuthedUser,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let table = render_table(&state).await?;
Ok(Html(format!(
r##"<div class="space-y-6">
<header class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Devices</h2>
<p class="text-xs text-slate-500">Force-disconnect / force-sysinfo are delivered on the peer's next heartbeat tick (~15 s).</p>
</header>
<section id="devices-region">
{table}
</section>
</div>"##
)))
}
pub async fn force_disconnect(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(peer_id): Path<String>,
) -> Result<Html<String>, 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<Arc<AppState>>,
admin: AuthedUser,
Path(peer_id): Path<String>,
) -> Result<Html<String>, 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<AppState>) -> Result<String, ApiError> {
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##"<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">Peer ID</th>
<th class="text-left font-medium px-3 py-2">Owner</th>
<th class="text-left font-medium px-3 py-2">Hostname</th>
<th class="text-left font-medium px-3 py-2">OS</th>
<th class="text-left font-medium px-3 py-2">Last heartbeat</th>
<th class="text-left font-medium px-3 py-2">Conns</th>
<th class="text-right font-medium px-3 py-2 w-1">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">"##
);
if devices.is_empty() {
s.push_str(
r##"<tr><td colspan="7" class="px-3 py-4 text-slate-500 text-center text-xs">No devices have heartbeated yet.</td></tr>"##,
);
}
for d in &devices {
render_device_row(&mut s, d);
}
let _ = write!(
s,
r##"</tbody>
</table>
<div class="px-3 py-2 text-xs text-slate-500 border-t border-slate-800">{total} device(s).</div>
</div>"##
);
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::<Vec<i64>>(&d.conns_json)
.map(|v| v.len())
.unwrap_or(0);
let _ = write!(
s,
r##"<tr class="hover:bg-slate-800/40">
<td class="px-3 py-2 font-mono text-slate-200">{id}</td>
<td class="px-3 py-2 text-slate-300">{owner}</td>
<td class="px-3 py-2 text-slate-400">{host}</td>
<td class="px-3 py-2 text-slate-400">{os}</td>
<td class="px-3 py-2 text-slate-500 text-xs">{last}</td>
<td class="px-3 py-2 text-slate-400">{n}</td>
<td class="px-3 py-2">
<details class="text-right relative">
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
<div class="absolute right-2 mt-1 z-10 w-56 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/disconnect"
hx-target="#devices-region" hx-swap="innerHTML"
hx-confirm="Disconnect all active sessions on {id}?">
Force disconnect
</button>
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/sysinfo-refresh"
hx-target="#devices-region" hx-swap="innerHTML">
Force sysinfo refresh
</button>
</div>
</details>
</td>
</tr>"##,
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<AppState>,
kind: &str,
msg: &str,
) -> Result<Html<String>, 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##"<div class="rounded border border-{border} bg-{bg} p-3 mb-4 text-sm text-{text}">{msg}</div>"##,
border = border,
bg = bg,
text = text,
msg = html_escape(msg),
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn require_admin(u: &AuthedUser) -> Result<(), ApiError> {
if u.is_admin {
Ok(())
} else {
Err(ApiError::Forbidden("admin required".into()))
}
}
+214
View File
@@ -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<Arc<AppState>>,
admin: AuthedUser,
) -> Result<Html<String>, 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<Arc<AppState>>,
admin: AuthedUser,
Form(form): Form<CreateForm>,
) -> Result<Html<String>, 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<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
) -> Result<Html<String>, 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<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
Form(form): Form<MemberForm>,
) -> Result<Html<String>, 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<Arc<AppState>>,
admin: AuthedUser,
Path((id, user_id)): Path<(i64, i64)>,
) -> Result<Html<String>, 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<AppState>,
kind: &str,
msg: &str,
) -> Result<Html<String>, ApiError> {
let mut html = notice_html(kind, msg);
html.push_str(&render_full(state).await?);
Ok(Html(html))
}
async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
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##"<div id="groups-region" class="space-y-6">
<header><h2 class="text-lg font-semibold">Device groups</h2></header>
<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-3">Create group</h3>
<form class="flex gap-2 text-sm" hx-post="/admin/pages/groups/create" hx-target="#groups-region" hx-swap="outerHTML">
<input name="name" placeholder="group name" required class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<button class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 font-medium text-white">Create</button>
</form>
</section>
"##,
);
if groups.is_empty() {
s.push_str(
r##"<p class="text-slate-500 text-sm">No device groups yet.</p>"##,
);
}
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##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-3">
<header class="flex items-center justify-between">
<h3 class="font-semibold">{name}</h3>
<button class="text-xs text-rose-400 hover:text-rose-300"
hx-post="/admin/pages/groups/{id}/delete"
hx-confirm="Delete group {name}? Members aren't deleted; just unassigned."
hx-target="#groups-region" hx-swap="outerHTML">Delete group</button>
</header>
<ul class="text-sm divide-y divide-slate-800">"##,
id = g.id,
name = html_escape(&g.name)
);
if members.is_empty() {
s.push_str(
r##"<li class="py-2 text-slate-500 text-xs">No members yet.</li>"##,
);
}
for u in &members {
let _ = write!(
s,
r##"<li class="py-2 flex items-center justify-between">
<span class="text-slate-200">{username}</span>
<button class="text-xs text-slate-400 hover:text-rose-300"
hx-post="/admin/pages/groups/{gid}/members/{uid}/remove"
hx-target="#groups-region" hx-swap="outerHTML">Remove</button>
</li>"##,
username = html_escape(&u.username),
gid = g.id,
uid = u.id
);
}
s.push_str("</ul>");
// Add-member form: dropdown of users not currently in the group.
let in_group: std::collections::HashSet<i64> =
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##"<form class="flex gap-2 text-sm pt-2 border-t border-slate-800"
hx-post="/admin/pages/groups/{id}/members/add"
hx-target="#groups-region" hx-swap="outerHTML">
<select name="user_id" class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5">
"##,
id = g.id
);
for u in &candidates {
let _ = write!(
s,
r##"<option value="{uid}">{username}</option>"##,
uid = u.id,
username = html_escape(&u.username)
);
}
s.push_str(
r##"</select>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">Add member</button>
</form>"##,
);
}
s.push_str("</section>");
}
s.push_str("</div>");
Ok(s)
}
+24
View File
@@ -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<String> {
Html(format!(
r##"<div class="space-y-2">
<h2 class="text-lg font-semibold">{title}</h2>
<p class="text-slate-400 text-sm">This page is part of M5b — the dashboard shell, login, and per-page navigation are wired in M5a; the actual table + form for <strong>{title}</strong> lands in the next slice.</p>
</div>"##
))
}
+73
View File
@@ -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<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
@@ -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<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))
}
}
+46
View File
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
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##"<div class="rounded border border-{border} bg-{bg} p-3 mb-4 text-sm text-{text}">{msg}</div>"##,
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())
}
+180
View File
@@ -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<Arc<AppState>>,
admin: AuthedUser,
) -> Result<Html<String>, 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<Arc<AppState>>,
admin: AuthedUser,
Form(form): Form<CreateForm>,
) -> Result<Html<String>, 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::<serde_json::Value>(&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<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
Form(form): Form<UpdateForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let cfg = match serde_json::from_str::<serde_json::Value>(&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<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
) -> Result<Html<String>, 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<AppState>,
kind: &str,
msg: &str,
) -> Result<Html<String>, ApiError> {
let mut html = notice_html(kind, msg);
html.push_str(&render_full(state).await?);
Ok(Html(html))
}
async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
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##"<div id="strategies-region" class="space-y-6">
<header>
<h2 class="text-lg font-semibold">Strategies</h2>
<p class="text-xs text-slate-500 mt-1">Pushed to clients via heartbeat. Use SQL to assign — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).</p>
</header>
<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-3">Create strategy</h3>
<form class="space-y-2 text-sm" hx-post="/admin/pages/strategies/create" hx-target="#strategies-region" hx-swap="outerHTML">
<input name="name" placeholder="name (unique)" required class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<textarea name="config_options_json" rows="3" placeholder='{"enable-udp": "N", "whitelist": ""}'
class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono text-xs"></textarea>
<button class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 font-medium text-white">Create</button>
</form>
</section>
"##,
);
if strategies.is_empty() {
s.push_str(r##"<p class="text-slate-500 text-sm">No strategies yet.</p>"##);
}
for str_ in &strategies {
let _ = write!(
s,
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-3">
<header class="flex items-center justify-between">
<div>
<h3 class="font-semibold">{name}</h3>
<p class="text-xs text-slate-500">id={id}, modified_at={mod_at}</p>
</div>
<button class="text-xs text-rose-400 hover:text-rose-300"
hx-post="/admin/pages/strategies/{id}/delete"
hx-confirm="Delete strategy {name}? Assignments will be cleaned up too."
hx-target="#strategies-region" hx-swap="outerHTML">Delete</button>
</header>
<form class="space-y-2 text-sm"
hx-post="/admin/pages/strategies/{id}/update"
hx-target="#strategies-region" hx-swap="outerHTML">
<label class="block text-xs text-slate-400">config_options (JSON object)</label>
<textarea name="config_options_json" rows="4"
class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono text-xs">{cfg}</textarea>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">Save</button>
</form>
</section>"##,
id = str_.id,
name = html_escape(&str_.name),
mod_at = str_.modified_at,
cfg = html_escape(&str_.config_options_json),
);
}
s.push_str("</div>");
Ok(s)
}
+473
View File
@@ -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<Arc<AppState>>,
admin: AuthedUser,
) -> Result<Html<String>, 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<String>,
}
pub async fn create(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Form(form): Form<CreateForm>,
) -> Result<Html<String>, 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<AppState>,
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<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
Form(form): Form<PasswordResetForm>,
) -> Result<Html<String>, 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<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
) -> Result<Html<String>, 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<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
) -> Result<Html<String>, 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<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
) -> Result<Html<String>, 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<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
) -> Result<Html<String>, 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##"<div class="rounded border border-emerald-700/50 bg-emerald-900/30 p-4 mb-4 text-sm">
<div class="font-semibold text-emerald-300 mb-1">TOTP enrolled for {user}</div>
<div class="space-y-1">
<div><span class="text-slate-400">Secret (base32):</span> <code class="text-emerald-200">{secret}</code></div>
<div><span class="text-slate-400">otpauth URL:</span> <code class="text-emerald-200 break-all">{otpauth}</code></div>
<div class="text-xs text-slate-400 pt-1">
Show this once to the user (or scan the URL as a QR code) — it isn't displayed again.
</div>
</div>
</div>"##,
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<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
) -> Result<Html<String>, 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<AppState>,
kind: &str,
msg: &str,
) -> Result<Html<String>, 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<AppState>) -> Result<String, ApiError> {
let table = render_table(state).await?;
Ok(format!(
r##"<div class="space-y-6">
<header class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Users</h2>
</header>
<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-3">Create user</h3>
<form
class="grid grid-cols-1 sm:grid-cols-6 gap-3 text-sm"
hx-post="/admin/pages/users/create"
hx-target="#users-region"
hx-swap="innerHTML"
hx-on::after-request="if (event.detail.successful) this.reset()"
>
<input name="username" placeholder="username" required class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<input name="display_name" placeholder="display name" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<input name="email" type="email" placeholder="email (optional)" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5 col-span-2"/>
<input name="password" type="password" placeholder="password" required class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<label class="flex items-center gap-2 text-slate-400 px-2"><input type="checkbox" name="is_admin"> admin</label>
<button type="submit" class="sm:col-span-6 bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 font-medium text-white">Create</button>
</form>
</section>
<section id="users-region">
{table}
</section>
</div>"##
))
}
async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
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##"<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">Username</th>
<th class="text-left font-medium px-3 py-2">Display name</th>
<th class="text-left font-medium px-3 py-2">Email</th>
<th class="text-left font-medium px-3 py-2">Status</th>
<th class="text-left font-medium px-3 py-2">Admin</th>
<th class="text-left font-medium px-3 py-2">TOTP</th>
<th class="text-right font-medium px-3 py-2 w-1">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">"##,
);
for u in &users {
render_user_row(&mut s, u, *totp.get(&u.id).unwrap_or(&false));
}
s.push_str(" </tbody>\n</table></div>");
Ok(s)
}
fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool) {
let status_badge = match u.status {
1 => r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-emerald-900/50 border border-emerald-700/50 text-emerald-300 text-xs">active</span>"#,
0 => r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-slate-400 text-xs">disabled</span>"#,
-1 => r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-amber-900/50 border border-amber-700/50 text-amber-300 text-xs">unverified</span>"#,
_ => "",
};
let admin_badge = if u.is_admin {
r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-sky-900/50 border border-sky-700/50 text-sky-300 text-xs">admin</span>"#
} else {
""
};
let totp_badge = if has_totp {
r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-violet-900/50 border border-violet-700/50 text-violet-300 text-xs">enrolled</span>"#
} else {
""
};
let _ = write!(
s,
r##"<tr class="hover:bg-slate-800/40">
<td class="px-3 py-2 font-medium text-slate-200">{username}</td>
<td class="px-3 py-2 text-slate-300">{display_name}</td>
<td class="px-3 py-2 text-slate-400">{email}</td>
<td class="px-3 py-2">{status}</td>
<td class="px-3 py-2">{admin}</td>
<td class="px-3 py-2">{totp}</td>
<td class="px-3 py-2">
<details class="text-right relative">
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
<div class="absolute right-2 mt-1 z-10 w-56 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
<form class="flex gap-1" hx-post="/admin/pages/users/{id}/password-reset" hx-target="#users-region" hx-swap="innerHTML">
<input name="password" type="password" required minlength="4" placeholder="new password" class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs"/>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-2 py-1 text-xs">Set</button>
</form>
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/users/{id}/toggle-admin" hx-target="#users-region" hx-swap="innerHTML">
{admin_label}
</button>
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/users/{id}/toggle-status" hx-target="#users-region" hx-swap="innerHTML">
{status_label}
</button>
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/users/{id}/totp-{totp_action}" hx-target="#users-region" hx-swap="innerHTML">
{totp_label}
</button>
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/30 rounded"
hx-post="/admin/pages/users/{id}/delete"
hx-confirm="Delete user {username}? This cascades into their tokens, group memberships and AB shares."
hx-target="#users-region" hx-swap="innerHTML">
Delete user
</button>
</div>
</details>
</td>
</tr>"##,
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##"<div class="rounded border border-{border} bg-{bg} p-3 mb-4 text-sm text-{text}">{msg}</div>"##,
border = border,
bg = bg,
text = text,
msg = html_escape(msg),
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
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()))
}
}
+1 -1
View File
@@ -311,7 +311,7 @@ async fn issue_session(
}))) })))
} }
fn verify_totp(secret_b32: &str, code: &str) -> Result<bool, ApiError> { pub(crate) fn verify_totp(secret_b32: &str, code: &str) -> Result<bool, ApiError> {
let secret = Secret::Encoded(secret_b32.to_string()) let secret = Secret::Encoded(secret_b32.to_string())
.to_bytes() .to_bytes()
.map_err(|e| ApiError::Internal(format!("bad TOTP secret: {:?}", e)))?; .map_err(|e| ApiError::Internal(format!("bad TOTP secret: {:?}", e)))?;
+41 -5
View File
@@ -1,10 +1,13 @@
use crate::api::error::ApiError; use crate::api::error::ApiError;
use crate::api::state::AppState; use crate::api::state::AppState;
use async_trait::async_trait; use async_trait::async_trait;
use axum::extract::{FromRequest, RequestParts, TypedHeader}; use axum::extract::{FromRequest, RequestParts};
use axum::headers::{authorization::Bearer, Authorization}; use axum::http::header;
use std::sync::Arc; 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 struct AuthedUser {
pub user_id: i64, pub user_id: i64,
pub name: String, pub name: String,
@@ -22,13 +25,11 @@ impl<B: Send> FromRequest<B> for AuthedUser {
type Rejection = ApiError; type Rejection = ApiError;
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> { async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
let bearer: TypedHeader<Authorization<Bearer>> =
TypedHeader::from_request(req).await.map_err(|_| ApiError::Unauthorized)?;
let state: axum::extract::Extension<Arc<AppState>> = let state: axum::extract::Extension<Arc<AppState>> =
axum::extract::Extension::from_request(req) axum::extract::Extension::from_request(req)
.await .await
.map_err(|_| ApiError::Internal("missing state".into()))?; .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 sha = sha256_token(&token);
let (user_id, _exp) = state let (user_id, _exp) = state
@@ -57,3 +58,38 @@ impl<B: Send> FromRequest<B> 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<B>(req: &RequestParts<B>) -> Option<String> {
// 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
}
+10 -3
View File
@@ -4,6 +4,7 @@
//! add address book, audit, OIDC, etc. //! add address book, audit, OIDC, etc.
pub mod ab; pub mod ab;
pub mod admin;
pub mod audit; pub mod audit;
pub mod auth; pub mod auth;
pub mod devices_cli; pub mod devices_cli;
@@ -34,7 +35,7 @@ use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
pub fn router(state: Arc<AppState>) -> Router { pub fn router(state: Arc<AppState>) -> Router {
Router::new() let app = Router::new()
// M1: auth + heartbeat + sysinfo // M1: auth + heartbeat + sysinfo
.route( .route(
"/api/login-options", "/api/login-options",
@@ -91,8 +92,14 @@ pub fn router(state: Arc<AppState>) -> Router {
// M4: OIDC device-flow login // M4: OIDC device-flow login
.route("/api/oidc/auth", post(oidc::auth::auth)) .route("/api/oidc/auth", post(oidc::auth::auth))
.route("/api/oidc/auth-query", get(oidc::poll::auth_query)) .route("/api/oidc/auth-query", get(oidc::poll::auth_query))
.route("/oidc/callback", get(oidc::callback::callback)) .route("/oidc/callback", get(oidc::callback::callback));
.layer(Extension(state)) // 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<AppState>) -> ResultType<()> { pub async fn serve(addr: SocketAddr, state: Arc<AppState>) -> ResultType<()> {
+5
View File
@@ -27,6 +27,9 @@ pub struct ApiConfig {
/// Externally reachable base URL of this server, e.g. for the OIDC /// Externally reachable base URL of this server, e.g. for the OIDC
/// redirect_uri. Empty disables OIDC. /// redirect_uri. Empty disables OIDC.
pub public_base_url: String, 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. /// SMTP wiring for email-code login.
@@ -68,6 +71,7 @@ impl AppState {
.unwrap_or(0); .unwrap_or(0);
let email = build_email_config(); let email = build_email_config();
let public_base_url = get_arg("public-base-url"); 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. // login_options advertises every login method this server accepts.
// The Flutter client uses this to render the matching button on the // The Flutter client uses this to render the matching button on the
// sign-in dialog. `email_code` and `oidc/<name>` are opt-in so a // sign-in dialog. `email_code` and `oidc/<name>` are opt-in so a
@@ -91,6 +95,7 @@ impl AppState {
audit_retention_days, audit_retention_days,
email, email,
public_base_url, public_base_url,
admin_ui_dir,
}, },
}) })
} }
+497
View File
@@ -108,6 +108,78 @@ pub struct DeviceGroupRow {
pub name: String, 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<i64>,
}
#[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)] #[derive(Debug, Clone, Default)]
pub struct PeerListRow { pub struct PeerListRow {
pub id: String, pub id: String,
@@ -347,6 +419,431 @@ impl Database {
Ok(row.map(row_to_user)) 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<UserRow>)> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<DashboardDeviceRow>)> {
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<String> {
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::<String, _>("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<Vec<AbOverviewRow>> {
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<Vec<DeviceGroupRow>> {
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<Vec<UserRow>> {
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<i64> {
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<bool> {
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<Vec<StrategyRow>> {
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<i64> {
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<bool> {
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<Vec<AuditConnRow>> {
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::<Option<String>, _>("ip").ok().flatten().unwrap_or_default(),
action: r.try_get("action").unwrap_or_default(),
note: r.try_get::<Option<String>, _>("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<Vec<AuditFileRow>> {
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::<Option<String>, _>("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::<i64, _>("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<Vec<AuditAlarmRow>> {
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<Vec<RecordingRow>> {
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::<Option<i64>, _>("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<bool> {
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<i64> { pub async fn user_insert(&self, u: NewUser<'_>) -> ResultType<i64> {
let admin_int: i64 = if u.is_admin { 1 } else { 0 }; let admin_int: i64 = if u.is_admin { 1 } else { 0 };
let res = sqlx::query( let res = sqlx::query(
+1
View File
@@ -37,6 +37,7 @@ fn main() -> ResultType<()> {
--smtp-tls=[on|off] 'STARTTLS on the SMTP connection (default: on)' --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' --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)' --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' , --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'", -k, --key=[KEY] 'Only allow the client with the same key'",
); );