5b288d671c
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>
173 lines
5.9 KiB
Rust
173 lines
5.9 KiB
Rust
//! `/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('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
}
|