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
+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)
}