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:
@@ -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)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user