feat(admin): OIDC sign-in, role sync, deploy/delete UX, and docs

This commit lights up the missing pieces of the admin dashboard and the
OIDC flow that the desktop client already speaks. It bundles several
independent fixes that share enough touch points (oidc/callback,
admin/mod, database schema) that splitting was more churn than help.

OIDC — desktop client
- /api/login: when TOTP is enrolled, return type:"email_check"
  + tfa_type:"tfa_check" instead of type:"tfa_check". The Flutter
  client's switch only branches on access_token / email_check; the
  prior shape silently fell into "bad response from server".
- /api/login dispatcher: route the second leg to login_tfa_code when
  tfaCode + secret are both present, regardless of the declared type.
  The desktop client sends type:"email_code" for both email-code AND
  TOTP second legs and distinguishes by which field is set.
- /api/oidc/auth-query: drop the bogus extra {"body": "..."} envelope.
  The desktop client's http_request_sync already wraps every response
  in {status_code, headers, body}, and HbbHttpResponse::parse expects
  the auth payload at that level. Our extra envelope made the parser
  fail silently as DataTypeFormat and the poll loop spun until the
  180 s client timeout.
- UserPayload: add a required info: {} field; the Rust-side polling
  deserializer at src/hbbs_http/account.rs expects it (no
  #[serde(default)]). Without it the AuthBody parse failed on every
  poll, producing the same forever-pending symptom as above.
- Add an always-on info-level log line at the poll handler so this
  family of "client never advances" bugs is observable from hbbs.log.

OIDC — admin dashboard
- New unauthenticated entry points:
    GET /admin/oidc/providers      JSON list for login.html
    GET /admin/login/oidc/:provider 302 → IdP authorization endpoint
  The session is marked admin-flow via a sentinel ("__admin_ui__") in
  client_id_str / client_uuid so the existing /oidc/callback can tell
  it apart from a desktop device flow.
- /oidc/callback finishes admin sessions by setting the
  rd_admin_session cookie + 303 to /admin/. Non-admin users get a
  helpful error page instead of a session.
- Admin-flow callbacks SKIP device_claim() so the dashboard sign-in
  no longer inserts a phantom "__admin_ui__" device row in
  device_sysinfo, and the token's peer_id / peer_uuid columns stay
  blank instead of carrying the sentinel.
- admin_ui/login.html fetches the providers list on load and renders
  one button per enabled provider beneath the password form.

OIDC — role-based admin sync
- New per-provider config fields admin_role + roles_claim (in
  oidc.toml AND oidc_providers, via soft ALTER TABLE). When set, the
  callback evaluates the userinfo claim and forces users.is_admin
  accordingly on every login. Promotion AND demotion at the IdP
  propagate. Two claim shapes supported:
    - object key match  (Zitadel:
        urn:zitadel:iam:org:project:roles -> { "admin": {...} })
    - string-array contains (generic: roles -> ["admin","user"])
- user_upsert_oidc gains a desired_admin: Option<bool> arg so the same
  upsert path handles "leave admin alone" (desired_admin = None) and
  "force from IdP" (Some(bool)). Three unit tests cover both shapes
  plus the missing-claim case.

Admin dashboard — Address books
- Full CRUD for shared books from the dashboard:
  create, list shares, add/upgrade/remove a per-user share with
  read / read+write / full rules, delete the book.
- Personal books also get a Delete action — confirms with a stronger
  message that the user's desktop client will recreate an empty
  personal book on next sync if it's still signed in (deletion is
  effectively "reset to empty", not "permanently revoked"). Use in
  combination with user-delete to fully revoke.
- New DB methods: ab_create_shared, ab_delete (cascades peers/tags/
  peer_tags/shares), ab_get_owner_kind, ab_list_shares, ab_share_set
  (idempotent upsert), ab_share_remove.

Admin dashboard — Devices
- Delete action in the per-row menu. device_delete cascades through
  device_sysinfo, peer (rendezvous identity), heartbeat_commands and
  peer-scoped strategy_assignments. Audit logs, recordings, and AB
  entries that reference the peer are intentionally preserved
  (historical/manual data).

Admin dashboard — Deploy page
- New page that generates the unsigned CustomServer blob the desktop
  client accepts via `rustdesk --config <blob>` (see
  rustdesk/src/custom_server.rs:get_custom_server_from_config_string;
  the unsigned-JSON path is a real codepath, no Pro signing key
  needed). Form prefills the public key from id_ed25519.pub in CWD.
- Also emits the equivalent renamed-installer filename
  (rustdesk-host=...,key=... .exe). Strips api= from the filename
  when it equals the default http://<host>:21114 (Windows can't store
  : or / in filenames); warns when the API URL is non-default.

Login form fixes
- TOTP form field: rename serde wire field from tfa_code to tfaCode
  so the dashboard's HTMX form (input name="tfaCode") actually
  populates it. The previous mismatch silently dropped the code and
  the server kept asking for it.
- TOTP redirect guard: only redirect on empty 2xx body (real login).
  The TOTP-required path returns 2xx with an HTML prompt fragment
  that must NOT be redirected away from.

Docs
- New docs/CONFIGURATION.md covering all CLI flags, OIDC setup
  (generic + Zitadel walk-through), role-based admin sync, TOTP,
  strategies, address books, dashboard URL map, DB notes, and a
  pre-prod security checklist.

Schema
- soft ALTER TABLE oidc_providers ADD COLUMN admin_role / roles_claim
  (guarded by the duplicate-column-name swallower for SQLite < 3.35).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 01:05:52 +02:00
parent e183b386a1
commit 98b55e138e
17 changed files with 1560 additions and 62 deletions
+4 -2
View File
@@ -20,8 +20,10 @@ 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)]
/// returned `tfa_check`. The HTML input is `name="tfaCode"` (camelCase)
/// to match the rest of the dashboard's form conventions, so we rename
/// the wire field rather than renaming the input.
#[serde(default, rename = "tfaCode")]
pub tfa_code: String,
/// Echo of the TOTP nonce the first-leg response set on the form.
#[serde(default)]
+35
View File
@@ -17,6 +17,7 @@
pub mod auth;
pub mod me;
pub mod oidc_login;
pub mod pages;
use axum::http::header;
@@ -45,6 +46,11 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
.route("/admin/login", post(auth::login))
.route("/admin/logout", post(auth::logout))
.route("/admin/me", get(me::me))
// OIDC entry points consumed by login.html (unauthenticated — they
// *initiate* a sign-in). The matching /oidc/callback is mounted by
// the public api router and finishes both desktop and admin flows.
.route("/admin/oidc/providers", get(oidc_login::list_providers))
.route("/admin/login/oidc/:provider", get(oidc_login::start_login))
// Page fragments — one per sidebar entry.
.route("/admin/pages/users", get(pages::users::index))
.route("/admin/pages/users/create", post(pages::users::create))
@@ -78,6 +84,10 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
"/admin/pages/devices/:peer_id/sysinfo-refresh",
post(pages::devices::force_sysinfo),
)
.route(
"/admin/pages/devices/:peer_id/delete",
post(pages::devices::delete),
)
// Groups
.route("/admin/pages/groups/create", post(pages::groups::create))
.route("/admin/pages/groups/:id/delete", post(pages::groups::delete))
@@ -102,6 +112,11 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
"/admin/pages/strategies/:id/delete",
post(pages::strategies::delete),
)
.route("/admin/pages/deploy", get(pages::deploy::index))
.route(
"/admin/pages/deploy/generate",
post(pages::deploy::generate),
)
.route("/admin/pages/devices", get(pages::devices::index))
.route("/admin/pages/groups", get(pages::groups::index))
.route("/admin/pages/strategies", get(pages::strategies::index))
@@ -109,6 +124,26 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
"/admin/pages/address-books",
get(pages::address_books::index),
)
.route(
"/admin/pages/address-books/create",
post(pages::address_books::create),
)
.route(
"/admin/pages/address-books/:guid/delete",
post(pages::address_books::delete),
)
.route(
"/admin/pages/address-books/:guid/manage",
get(pages::address_books::manage),
)
.route(
"/admin/pages/address-books/:guid/shares/add",
post(pages::address_books::share_add),
)
.route(
"/admin/pages/address-books/:guid/shares/:user_id/remove",
post(pages::address_books::share_remove),
)
.route("/admin/pages/oidc", get(pages::oidc::index))
.route("/admin/pages/audit", get(pages::audit::index))
.route("/admin/pages/recordings", get(pages::recordings::index));
+107
View File
@@ -0,0 +1,107 @@
//! OIDC login entry points for the admin dashboard.
//!
//! Two unauthenticated GET endpoints used by `admin_ui/login.html`:
//!
//! - `GET /admin/oidc/providers` returns the enabled providers as JSON so
//! the login page can render a button per provider.
//! - `GET /admin/login/oidc/:provider` creates an OIDC session marked as
//! admin-flow (via the sentinel below) and 302-redirects the browser to
//! the IdP authorization URL. After the IdP redirects to
//! `/oidc/callback`, the existing callback handler detects the sentinel
//! and finishes by setting `rd_admin_session` + redirecting to `/admin/`
//! (see api/oidc/callback.rs).
//!
//! We keep this module separate from the desktop-client OIDC flow so the
//! "device polls /api/oidc/auth-query" semantics stay untouched.
use crate::api::error::ApiError;
use crate::api::oidc::{discovery, random_token, require_provider, OIDC_SESSION_TTL_SECS};
use crate::api::state::AppState;
use crate::database::OidcSessionInsert;
use axum::extract::{Extension, Path};
use axum::response::Redirect;
use axum::Json;
use serde_json::{json, Value};
use std::sync::Arc;
/// Sentinel stuffed into `client_id_str` / `client_uuid` of an OidcSession
/// so the callback can tell admin-UI flows apart from desktop-client flows.
/// Real device UUIDs from the desktop client are hex-formatted GUIDs and
/// won't collide.
pub const ADMIN_SENTINEL: &str = "__admin_ui__";
pub async fn list_providers(
Extension(state): Extension<Arc<AppState>>,
) -> Json<Value> {
let mut out: Vec<Value> = Vec::new();
if !state.cfg.public_base_url.is_empty() {
if let Ok(providers) = state.db.oidc_provider_list_enabled().await {
for p in providers {
out.push(json!({
"name": p.name,
"display_name": p.display_name.unwrap_or_else(|| p.name.clone()),
"icon_url": p.icon_url,
}));
}
}
}
Json(json!(out))
}
pub async fn start_login(
Extension(state): Extension<Arc<AppState>>,
Path(provider_name): Path<String>,
) -> Result<Redirect, ApiError> {
if state.cfg.public_base_url.is_empty() {
return Err(ApiError::Internal(
"OIDC requires --public-base-url to be set".into(),
));
}
let provider = require_provider(&state, &provider_name).await?;
let disc = discovery::discover(&provider.issuer_url)
.await
.map_err(ApiError::Internal)?;
let code = random_token();
let csrf_state = random_token();
let expires_at = chrono::Utc::now().timestamp() + OIDC_SESSION_TTL_SECS;
state
.db
.oidc_session_create(&OidcSessionInsert {
code: &code,
provider: &provider.name,
state: &csrf_state,
client_id_str: ADMIN_SENTINEL,
client_uuid: ADMIN_SENTINEL,
device_info_json: r#"{"source":"admin-ui"}"#,
expires_at,
})
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let url = format!(
"{auth}?response_type=code&client_id={cid}&redirect_uri={ru}&scope={scope}&state={st}",
auth = disc.authorization_endpoint,
cid = url_encode(&provider.client_id),
ru = url_encode(&provider.redirect_url),
scope = url_encode(&provider.scopes),
st = url_encode(&csrf_state),
);
Ok(Redirect::temporary(&url))
}
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);
}
_ => {
use std::fmt::Write;
let _ = write!(out, "%{:02X}", b);
}
}
}
out
}
+349 -14
View File
@@ -1,13 +1,17 @@
//! 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.
//! Address books — list, create shared books, manage shares, delete.
//! Personal books are owned by individual users and managed from the
//! desktop client (the dashboard refuses to mutate them). Shared books
//! are server-side artifacts: an admin creates one, then grants users
//! `read` / `read+write` / `full` access; the desktop client picks them
//! up via `/api/ab/shared/profiles` on the next AB sync (~30 s).
use super::shared::{fmt_unix, html_escape, require_admin};
use super::shared::{fmt_unix, 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;
use axum::extract::{Extension, Form, Path};
use axum::response::Html;
use serde::Deserialize;
use std::fmt::Write as _;
use std::sync::Arc;
@@ -16,25 +20,156 @@ pub async fn index(
admin: AuthedUser,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
Ok(Html(render_index(&state, None).await?))
}
#[derive(Debug, Deserialize)]
pub struct CreateForm {
pub name: String,
}
pub async fn create(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Form(f): Form<CreateForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let name = f.name.trim();
if name.is_empty() {
return Ok(Html(render_index(&state, Some(("error", "Name is required."))).await?));
}
let res = state.db.ab_create_shared(admin.user_id, name).await;
let notice = match res {
Ok(_) => Some(("ok", format!("Shared address book '{}' created.", name))),
Err(e) => {
// The unique index trips when the same admin creates two books
// with the same name. Surface that cleanly instead of leaking
// the raw SQL error.
let msg = if e.to_string().to_lowercase().contains("unique") {
"An address book with that name already exists.".to_string()
} else {
format!("Create failed: {}", e)
};
Some(("error", msg))
}
};
let n = notice.as_ref().map(|(k, m)| (*k, m.as_str()));
Ok(Html(render_index(&state, n).await?))
}
pub async fn delete(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(guid): Path<String>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let ok = state
.db
.ab_delete(&guid)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let notice = if ok {
("ok", "Deleted.")
} else {
("error", "Address book not found.")
};
Ok(Html(render_index(&state, Some(notice)).await?))
}
pub async fn manage(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(guid): Path<String>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
Ok(Html(render_manage(&state, &guid, None).await?))
}
#[derive(Debug, Deserialize)]
pub struct ShareForm {
pub user_id: i64,
pub rule: i64,
}
pub async fn share_add(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(guid): Path<String>,
Form(f): Form<ShareForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
if !(1..=3).contains(&f.rule) {
return Ok(Html(render_manage(&state, &guid, Some(("error", "Invalid rule."))).await?));
}
state
.db
.ab_share_set(&guid, f.user_id, f.rule)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(render_manage(&state, &guid, Some(("ok", "Share saved."))).await?))
}
pub async fn share_remove(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path((guid, user_id)): Path<(String, i64)>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let _ = state
.db
.ab_share_remove(&guid, user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(render_manage(&state, &guid, Some(("ok", "Share removed."))).await?))
}
// ---------- rendering ----------
async fn render_index(
state: &Arc<AppState>,
notice: Option<(&str, &str)>,
) -> Result<String, ApiError> {
let books = state
.db
.ab_list_all_with_owner()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let notice_html = notice.map(|(k, m)| notice_html(k, m)).unwrap_or_default();
let mut s = String::new();
s.push_str(
r##"<div class="space-y-4">
let _ = write!(
s,
r##"<div id="ab-region" class="space-y-6">
<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>"##,
<p class="text-xs text-slate-500 mt-1">Personal books are owned by users and managed from their desktop client. Shared books are server-side: create one here, share it with users / rules, and the client picks it up on its next AB sync.</p>
</header>
{notice_html}
<form
class="flex items-end gap-2 bg-slate-900 border border-slate-800 rounded-lg p-3"
hx-post="/admin/pages/address-books/create"
hx-target="#ab-region" hx-swap="outerHTML"
>
<div class="flex-1">
<label class="block text-xs font-medium text-slate-400 mb-1" for="ab-name">New shared book</label>
<input id="ab-name" name="name" type="text" required
placeholder="Engineering laptops"
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>
<button type="submit"
class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
Create
</button>
</form>
"##
);
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));
return Ok(s);
}
s.push_str(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
r##"<div class="rounded-md border border-slate-800 bg-slate-900">
<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>
@@ -43,15 +178,60 @@ pub async fn index(
<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>
<th class="text-right font-medium px-3 py-2 w-1">Actions</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
);
for b in &books {
let kind = match b.kind {
let kind_pill = 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>"#,
_ => "",
};
// Both kinds get a delete action. Shared books additionally get
// "Manage shares". Personal books carry an extra warning in the
// confirm because the owning user's desktop client may resync
// and recreate the book on next AB tick — deletion is "reset to
// empty", not "permanently revoked".
let actions = if b.kind == 1 {
format!(
r##"<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-48 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-get="/admin/pages/address-books/{guid}/manage"
hx-target="#ab-region" hx-swap="outerHTML">
Manage shares
</button>
<hr class="border-slate-700 my-1" />
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/40 rounded"
hx-post="/admin/pages/address-books/{guid}/delete"
hx-target="#ab-region" hx-swap="outerHTML"
hx-confirm="Delete shared book '{name}'? Peers, tags, and shares will be removed.">
Delete book
</button>
</div>
</details>"##,
guid = html_escape(&b.guid),
name = html_escape(&b.name)
)
} else {
format!(
r##"<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 text-rose-300 hover:bg-rose-900/40 rounded"
hx-post="/admin/pages/address-books/{guid}/delete"
hx-target="#ab-region" hx-swap="outerHTML"
hx-confirm="Delete {owner}'s personal book? This wipes all peers and tags on the server. If {owner}'s desktop client is still signed in, it will recreate an empty personal book on its next sync (~30 s).">
Delete book
</button>
</div>
</details>"##,
guid = html_escape(&b.guid),
owner = html_escape(&b.owner_username)
)
};
let _ = write!(
s,
r##"<tr>
@@ -61,15 +241,170 @@ pub async fn index(
<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>
<td class="px-3 py-2">{actions}</td>
</tr>"##,
owner = html_escape(&b.owner_username),
kind = kind,
kind = kind_pill,
name = html_escape(&b.name),
count = b.peer_count,
guid = html_escape(&b.guid),
created = html_escape(&fmt_unix(b.created_at)),
actions = actions,
);
}
s.push_str("</tbody></table></div></div>");
Ok(Html(s))
Ok(s)
}
async fn render_manage(
state: &Arc<AppState>,
guid: &str,
notice: Option<(&str, &str)>,
) -> Result<String, ApiError> {
let owner_kind = state
.db
.ab_get_owner_kind(guid)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let Some((_owner_id, kind)) = owner_kind else {
return Ok(format!(
r##"<div id="ab-region">{notice}</div>"##,
notice = notice_html("error", "Address book not found."),
));
};
if kind != 1 {
return Ok(format!(
r##"<div id="ab-region">{notice}</div>"##,
notice = notice_html("error", "Personal address books are managed from the desktop client."),
));
}
let shares = state
.db
.ab_list_shares(guid)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let (_total, users) = state
.db
.users_list_all(0, 1000)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let already_shared: std::collections::HashSet<i64> =
shares.iter().map(|s| s.user_id).collect();
let notice_html = notice.map(|(k, m)| notice_html(k, m)).unwrap_or_default();
let mut s = String::new();
let _ = write!(
s,
r##"<div id="ab-region" class="space-y-6">
<header class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold">Manage shares</h2>
<p class="text-xs text-slate-500 mt-1 font-mono">{guid}</p>
</div>
<button
class="text-xs text-slate-300 hover:text-slate-100 px-2 py-1 rounded border border-slate-700 hover:border-slate-500"
hx-get="/admin/pages/address-books"
hx-target="#ab-region" hx-swap="outerHTML">
← Back
</button>
</header>
{notice_html}
<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-3">
<h3 class="text-sm font-semibold text-slate-200">Add or update a share</h3>
<form
class="flex flex-wrap items-end gap-2"
hx-post="/admin/pages/address-books/{guid}/shares/add"
hx-target="#ab-region" hx-swap="outerHTML"
>
<div class="flex-1 min-w-[200px]">
<label class="block text-xs font-medium text-slate-400 mb-1" for="share-user">User</label>
<select id="share-user" name="user_id" required
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500">"##,
guid = html_escape(guid),
notice_html = notice_html,
);
if users.is_empty() {
s.push_str(r#"<option disabled>No users defined</option>"#);
}
for u in &users {
let already = if already_shared.contains(&u.id) { " (existing — will update rule)" } else { "" };
let _ = write!(
s,
r#"<option value="{id}">{name}{already}</option>"#,
id = u.id,
name = html_escape(&u.username),
already = html_escape(already),
);
}
let _ = write!(
s,
r##"</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1" for="share-rule">Rule</label>
<select id="share-rule" name="rule"
class="bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500">
<option value="1">Read</option>
<option value="2" selected>Read + write</option>
<option value="3">Full</option>
</select>
</div>
<button type="submit"
class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
Save
</button>
</form>
</section>
<section class="rounded-md border border-slate-800 bg-slate-900">
<header class="px-4 py-2 text-xs uppercase text-slate-500 border-b border-slate-800">
Current shares ({n})
</header>
"##,
n = shares.len()
);
if shares.is_empty() {
s.push_str(r##"<p class="px-4 py-3 text-slate-500 text-sm">No shares yet. The book is invisible to non-owners until you add at least one user share.</p></section></div>"##);
return Ok(s);
}
s.push_str(
r##"<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">User</th>
<th class="text-left font-medium px-3 py-2">Rule</th>
<th class="text-right font-medium px-3 py-2 w-1"></th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
);
for sh in &shares {
let rule = match sh.rule {
1 => "Read",
2 => "Read + write",
3 => "Full",
_ => "?",
};
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 text-slate-200">{user}</td>
<td class="px-3 py-2 text-slate-400">{rule}</td>
<td class="px-3 py-2 text-right">
<button class="text-xs text-rose-300 hover:text-rose-200 px-2 py-1 rounded hover:bg-rose-900/40"
hx-post="/admin/pages/address-books/{guid}/shares/{uid}/remove"
hx-target="#ab-region" hx-swap="outerHTML"
hx-confirm="Remove {user}'s access?">
Remove
</button>
</td>
</tr>"##,
user = html_escape(&sh.username),
rule = rule,
guid = html_escape(guid),
uid = sh.user_id,
);
}
s.push_str("</tbody></table></section></div>");
Ok(s)
}
+249
View File
@@ -0,0 +1,249 @@
//! Deploy page — generates a `CustomServer` blob the Windows / macOS / Linux
//! client accepts via `rustdesk --config <blob>`, plus the equivalent
//! rename-the-installer filename. The blob format is documented at
//! `<rustdesk>/src/custom_server.rs`: JSON → URL-safe-no-pad base64 →
//! reverse-the-string. The client tries the unsigned JSON path before
//! signature verification, so we don't need the Pro private key.
use super::shared::{html_escape, require_admin};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use axum::extract::Form;
use axum::response::Html;
use serde::Deserialize;
use serde_json::json;
pub async fn index(admin: AuthedUser) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let pubkey = read_pubkey();
Ok(Html(render_form(&pubkey, "", "", "", "", None)))
}
#[derive(Debug, Deserialize)]
pub struct DeployForm {
#[serde(default)]
pub host: String,
#[serde(default)]
pub api: String,
#[serde(default)]
pub relay: String,
#[serde(default)]
pub key: String,
}
pub async fn generate(
admin: AuthedUser,
Form(f): Form<DeployForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
if f.host.trim().is_empty() {
return Ok(Html(render_form(
&f.key,
&f.host,
&f.api,
&f.relay,
"",
Some(("error", "Host is required.")),
)));
}
let blob = encode_blob(&f.host, &f.key, &f.api, &f.relay);
let result = render_result(&f.host, &f.key, &f.api, &f.relay, &blob);
Ok(Html(render_form(
&f.key,
&f.host,
&f.api,
&f.relay,
&result,
None,
)))
}
// ---------- helpers ----------
/// Best-effort read of the server's public key from `id_ed25519.pub` in CWD —
/// the same path `common::gen_sk` writes it to. If the file is missing
/// (operator passed `--key` explicitly, or the binary runs from a directory
/// they can't read), the field is left blank for them to paste.
fn read_pubkey() -> String {
std::fs::read_to_string("id_ed25519.pub")
.ok()
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
/// Encode a `CustomServer` payload the way the client's
/// `get_custom_server_from_config_string` expects: JSON → URL-safe-no-pad
/// base64 → reverse the resulting string. The client reverses it back, base64
/// decodes, then JSON parses.
fn encode_blob(host: &str, key: &str, api: &str, relay: &str) -> String {
let payload = json!({
"host": host,
"key": key,
"api": api,
"relay": relay,
});
let b64 = base64::encode_config(payload.to_string().as_bytes(), base64::URL_SAFE_NO_PAD);
b64.chars().rev().collect()
}
fn render_form(
key: &str,
host: &str,
api: &str,
relay: &str,
result_html: &str,
notice: Option<(&str, &str)>,
) -> String {
let notice_html = match notice {
Some((kind, msg)) => super::shared::notice_html(kind, msg),
None => String::new(),
};
format!(
r##"<div class="space-y-6">
<header>
<h2 class="text-lg font-semibold">Deploy</h2>
<p class="text-xs text-slate-500 mt-1">Generate a config blob the stock client accepts via <code>rustdesk --config &lt;blob&gt;</code>, or the equivalent renamed-installer filename. The public key below is read from <code>id_ed25519.pub</code> on the server; override if you bootstrapped a custom keypair.</p>
</header>
{notice_html}
<form
class="space-y-3 bg-slate-900 border border-slate-800 rounded-lg p-4"
hx-post="/admin/pages/deploy/generate"
hx-target="#main"
hx-swap="innerHTML"
>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1" for="host">Rendezvous host (required)</label>
<input id="host" name="host" type="text" required value="{host}"
placeholder="rustdesk.example.com or 203.0.113.10"
oninput="document.getElementById('api').placeholder='http://'+this.value+':21114'"
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
<p class="text-xs text-slate-500 mt-1">The hostname or IP clients reach hbbs at (TCP/UDP 21116).</p>
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1" for="api">API URL (optional)</label>
<input id="api" name="api" type="text" value="{api}"
placeholder="http://host:21114"
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
<p class="text-xs text-slate-500 mt-1">Full URL of this admin/login API. Leave blank to disable login on the client.</p>
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1" for="relay">Relay host (optional)</label>
<input id="relay" name="relay" type="text" value="{relay}"
placeholder="rustdesk.example.com"
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
<p class="text-xs text-slate-500 mt-1">Only set if hbbr runs on a separate host; otherwise leave blank.</p>
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1" for="key">Public key</label>
<textarea id="key" name="key" rows="2"
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-xs font-mono focus:outline-none focus:border-sky-500">{key}</textarea>
<p class="text-xs text-slate-500 mt-1">Base64 contents of <code>id_ed25519.pub</code>.</p>
</div>
<button type="submit"
class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
Generate
</button>
</form>
{result_html}
</div>"##,
host = html_escape(host),
api = html_escape(api),
relay = html_escape(relay),
key = html_escape(key),
notice_html = notice_html,
result_html = result_html,
)
}
fn render_result(host: &str, key: &str, api: &str, relay: &str, blob: &str) -> String {
// Build the rename-the-installer alternative. Windows filenames disallow
// `:` and `/`, which the API URL is full of (`http://host:21114`). The
// client falls back to `http://<host>:21114` when `api` is empty
// (rustdesk/src/common.rs:get_api_server_), so we can omit the API field
// from the filename whenever it matches that default. If the operator
// supplied a non-default API URL we still build a "renamed" string for
// reference but mark it as unusable on Windows and steer them to the
// --config path.
let default_api = format!("http://{}:21114", host);
let api_is_default = api.is_empty() || api == default_api;
let unsafe_chars = str_has_filename_unsafe_chars(api) && !api_is_default;
let mut renamed = format!("rustdesk-host={}", host);
if !key.is_empty() {
renamed.push_str(&format!(",key={}", key));
}
if !api_is_default && !unsafe_chars {
renamed.push_str(&format!(",api={}", api));
}
if !relay.is_empty() && !str_has_filename_unsafe_chars(relay) {
renamed.push_str(&format!(",relay={}", relay));
}
renamed.push_str(".exe");
let renamed_note = if unsafe_chars {
r##"<p class="text-xs text-rose-300 mt-1">⚠ Your API URL contains <code>:</code> or <code>/</code>, which Windows forbids in filenames. The renamed-installer approach cannot carry it — use approach A above instead.</p>"##.to_string()
} else if !api_is_default {
format!(
r##"<p class="text-xs text-amber-300 mt-1">⚠ Your API URL ({api_disp}) is not the default <code>http://&lt;host&gt;:21114</code>. The client will auto-derive the default from the rendezvous host on first launch, so this filename will deploy with the wrong API URL. Use approach A instead.</p>"##,
api_disp = html_escape(api)
)
} else if !api.is_empty() {
r##"<p class="text-xs text-slate-500 mt-1">API URL omitted from filename (Windows can't store <code>:</code> / <code>/</code>); the client auto-derives <code>http://&lt;host&gt;:21114</code> from the rendezvous host.</p>"##.to_string()
} else {
String::new()
};
let licensed = format!("rustdesk-licensed-{}", blob);
let cmd_win = format!(
r#""C:\Program Files\RustDesk\rustdesk.exe" --config {licensed}"#,
licensed = licensed
);
let cmd_unix = format!("rustdesk --config {}", licensed);
format!(
r##"<section class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-4">
<header>
<h3 class="text-sm font-semibold text-slate-200">Deployment artifact</h3>
<p class="text-xs text-slate-500 mt-1">Pick whichever path fits your rollout. Both produce the same client config.</p>
</header>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1">A. Post-install command (Windows, Administrator)</label>
<pre class="text-xs bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{cmd_win}</pre>
<p class="text-xs text-slate-500 mt-1">Requires the client to be installed and running as admin. Equivalent on macOS/Linux: <code class="text-slate-300">{cmd_unix}</code>.</p>
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1">B. Renamed installer (drop-in)</label>
<pre class="text-xs bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{renamed}</pre>
<p class="text-xs text-slate-500 mt-1">Rename the official RustDesk installer to this exact name and run it; the client reads its own filename on first launch and writes the config into the registry.</p>
{renamed_note}
</div>
<details class="text-xs text-slate-400">
<summary class="cursor-pointer text-slate-300 select-none">Raw blob</summary>
<pre class="mt-2 bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{blob}</pre>
</details>
</section>"##,
cmd_win = html_escape(&cmd_win),
cmd_unix = html_escape(&cmd_unix),
renamed = html_escape(&renamed),
renamed_note = renamed_note,
blob = html_escape(blob),
)
}
/// Rough check for characters Windows disallows in filenames. We don't try
/// to be exhaustive (NUL, control chars etc. won't realistically appear in a
/// hostname or URL), just the ones a typical URL/relay value will trip on.
fn str_has_filename_unsafe_chars(s: &str) -> bool {
s.chars()
.any(|c| matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'))
}
+26
View File
@@ -75,6 +75,25 @@ pub async fn force_sysinfo(
.await
}
pub async fn delete(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let ok = state
.db
.device_delete(&peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let msg = if ok {
format!("Deleted device {}.", peer_id)
} else {
"Device already gone.".to_string()
};
notice_then_table(&state, if ok { "ok" } else { "error" }, &msg).await
}
// ---------- helpers ----------
async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
@@ -161,6 +180,13 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow) {
hx-target="#devices-region" hx-swap="innerHTML">
Force sysinfo refresh
</button>
<hr class="border-slate-700 my-1" />
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/40 rounded"
hx-post="/admin/pages/devices/{id}/delete"
hx-target="#devices-region" hx-swap="innerHTML"
hx-confirm="Delete {id}? This removes the dashboard row and the rendezvous identity. Audit logs and recordings are kept.">
Delete device
</button>
</div>
</details>
</td>
+1
View File
@@ -3,6 +3,7 @@
pub mod address_books;
pub mod audit;
pub mod deploy;
pub mod devices;
pub mod groups;
pub mod oidc;