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