From 8e5c0969ef21003213c7faf53b438c71f07056ef Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 6 May 2026 18:48:18 +0200 Subject: [PATCH] feat(admin): signed customization page (custom.txt generator) Adds an admin UI page that produces an Ed25519-signed custom.txt the patched RustDesk client picks up at startup. The blob carries app-name, default-settings, override-settings, an optional logo image, and arbitrary buildin keys. - gen_branding_sk + read_branding_sk + read_branding_pubkey in common.rs; called once on hbbs startup so the branding keypair (separate from the rendezvous id_ed25519) is created on first boot alongside it. Decoupling lets the two rotate independently. - src/api/admin/pages/customization.rs: form with multipart logo upload (PNG/ICO/JPEG, magic-byte sniffed, capped at 256 KiB), JSON default/override settings fields, kv-pair rows for arbitrary buildin keys. Signs with sodiumoxide and emits standard-base64 matching the client's decode64 path. Result block has a copyable blob and a "Download custom.txt" button. - Routes wired under /admin/pages/customization{,/generate,/download} and the admin nav gets a Customization link. - src/verify_branding.rs: smoke-test binary the operator can run before cutting a client release to confirm the signature round-trips. - axum gains the multipart feature flag for the upload handler. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 19 ++ Cargo.toml | 6 +- admin_ui/index.html | 2 + src/api/admin/mod.rs | 12 + src/api/admin/pages/customization.rs | 479 +++++++++++++++++++++++++++ src/api/admin/pages/mod.rs | 1 + src/common.rs | 66 ++++ src/rendezvous_server.rs | 4 + src/verify_branding.rs | 72 ++++ 9 files changed, 660 insertions(+), 1 deletion(-) create mode 100644 src/api/admin/pages/customization.rs create mode 100644 src/verify_branding.rs diff --git a/Cargo.lock b/Cargo.lock index e16d1ac..c84d03d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,6 +153,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde", @@ -1711,6 +1712,24 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "multer" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01acbdc23469fd8fe07ab135923371d5f5a422fbf9c522158677c8eb15bc51c2" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "log", + "memchr", + "mime", + "spin 0.9.3", + "version_check", +] + [[package]] name = "native-tls" version = "0.2.12" diff --git a/Cargo.toml b/Cargo.toml index c647267..8d157d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,10 @@ path = "src/hbbr.rs" name = "rustdesk-utils" path = "src/utils.rs" +[[bin]] +name = "verify_branding" +path = "src/verify_branding.rs" + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] @@ -34,7 +38,7 @@ machine-uid = "0.2" mac_address = "1.1.5" whoami = "1.2" base64 = "0.13" -axum = { version = "0.5", features = ["headers"] } +axum = { version = "0.5", features = ["headers", "multipart"] } sqlx = { version = "0.6", features = [ "runtime-tokio-rustls", "sqlite", "macros", "chrono", "json" ] } deadpool = "0.8" async-trait = "0.1" diff --git a/admin_ui/index.html b/admin_ui/index.html index e01622b..44a6a40 100644 --- a/admin_ui/index.html +++ b/admin_ui/index.html @@ -45,6 +45,8 @@ hx-get="/admin/pages/recordings" hx-target="#main" hx-push-url="#recordings">Recordings Deploy + Customization
) -> Option { "/admin/pages/deploy/generate", post(pages::deploy::generate), ) + .route( + "/admin/pages/customization", + get(pages::customization::index), + ) + .route( + "/admin/pages/customization/generate", + post(pages::customization::generate), + ) + .route( + "/admin/pages/customization/download", + post(pages::customization::download), + ) // Web client (M6) — full-page SPA, NOT an HTMX fragment. Mounted // outside /admin/pages/ because it's a standalone document the // operator opens in a new tab from the Devices action menu. diff --git a/src/api/admin/pages/customization.rs b/src/api/admin/pages/customization.rs new file mode 100644 index 0000000..33293e3 --- /dev/null +++ b/src/api/admin/pages/customization.rs @@ -0,0 +1,479 @@ +//! Customization page — produces a signed `custom.txt` blob the patched +//! RustDesk client accepts at startup. The client's `read_custom_client` +//! decodes the file as standard base64, verifies it against the embedded +//! branding public key (Ed25519 / sodium `sign::verify`), and applies the +//! contained JSON: `app-name`, `default-settings`, `override-settings`, +//! `app-icon` (base64 PNG/ICO/JPEG), plus arbitrary string keys that map +//! into the client's various settings registries. +//! +//! Distinct from the Deploy page (`deploy.rs`), which produces *unsigned* +//! `CustomServer` blobs for embedding in the installer filename. That blob +//! travels in a different channel and uses URL_SAFE_NO_PAD base64 — do not +//! reuse `encode_blob` from there. + +use super::shared::{html_escape, notice_html, require_admin}; +use crate::api::error::ApiError; +use crate::api::middleware::AuthedUser; +use axum::extract::{Form, Multipart}; +use axum::http::{header, HeaderValue, StatusCode}; +use axum::response::{Html, IntoResponse, Response}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use sodiumoxide::crypto::sign; + +/// Hard cap on uploaded logo bytes (raw, before base64). 256 KiB is enough +/// for a 256x256 PNG with room to spare; base64 inflation puts the JSON +/// payload around 340 KiB which the signed blob comfortably absorbs. +const MAX_LOGO_BYTES: usize = 256 * 1024; + +pub async fn index(admin: AuthedUser) -> Result, ApiError> { + require_admin(&admin)?; + let pubkey = crate::common::read_branding_pubkey(); + Ok(Html(render_form(&FormState::default(), &pubkey, "", None))) +} + +pub async fn generate( + admin: AuthedUser, + mut multipart: Multipart, +) -> Result, ApiError> { + require_admin(&admin)?; + + let pubkey = crate::common::read_branding_pubkey(); + let mut state = FormState::default(); + let mut logo_bytes: Option> = None; + let mut logo_too_big = false; + let mut logo_bad_format = false; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| ApiError::BadRequest(format!("multipart parse: {e}")))? + { + let name = field.name().unwrap_or("").to_string(); + match name.as_str() { + "app_name" => { + state.app_name = field.text().await.unwrap_or_default(); + } + "default_settings_json" => { + state.default_settings_json = field.text().await.unwrap_or_default(); + } + "override_settings_json" => { + state.override_settings_json = field.text().await.unwrap_or_default(); + } + "kv_key" => { + state.kv_keys.push(field.text().await.unwrap_or_default()); + } + "kv_value" => { + state.kv_values.push(field.text().await.unwrap_or_default()); + } + "logo" => { + let bytes = field.bytes().await.unwrap_or_default(); + if bytes.is_empty() { + continue; + } + if bytes.len() > MAX_LOGO_BYTES { + logo_too_big = true; + continue; + } + if !is_supported_image(&bytes) { + logo_bad_format = true; + continue; + } + logo_bytes = Some(bytes.to_vec()); + } + _ => { + let _ = field.bytes().await; + } + } + } + + if state.app_name.trim().is_empty() { + return Ok(Html(render_form( + &state, + &pubkey, + "", + Some(("error", "App name is required.")), + ))); + } + if logo_too_big { + return Ok(Html(render_form( + &state, + &pubkey, + "", + Some(( + "error", + "Logo exceeds 256 KiB. Please upload a smaller PNG/ICO/JPEG.", + )), + ))); + } + if logo_bad_format { + return Ok(Html(render_form( + &state, + &pubkey, + "", + Some(( + "error", + "Logo must be a PNG, ICO, or JPEG image (detected by magic bytes).", + )), + ))); + } + + let default_obj = match parse_settings_json(&state.default_settings_json) { + Ok(o) => o, + Err(msg) => { + return Ok(Html(render_form( + &state, + &pubkey, + "", + Some(("error", &format!("default-settings: {msg}"))), + ))) + } + }; + let override_obj = match parse_settings_json(&state.override_settings_json) { + Ok(o) => o, + Err(msg) => { + return Ok(Html(render_form( + &state, + &pubkey, + "", + Some(("error", &format!("override-settings: {msg}"))), + ))) + } + }; + + let Some(sk) = crate::common::read_branding_sk() else { + return Ok(Html(render_form( + &state, + &pubkey, + "", + Some(( + "error", + "branding_ed25519 not found or malformed on the server. Restart hbbs to regenerate.", + )), + ))); + }; + + let mut payload: Map = Map::new(); + payload.insert("app-name".into(), json!(state.app_name)); + if !default_obj.is_empty() { + payload.insert("default-settings".into(), Value::Object(default_obj)); + } + if !override_obj.is_empty() { + payload.insert("override-settings".into(), Value::Object(override_obj)); + } + for (k, v) in collect_kv_pairs(&state.kv_keys, &state.kv_values) { + payload.insert(k, json!(v)); + } + if let Some(bytes) = &logo_bytes { + payload.insert("app-icon".into(), json!(base64::encode(bytes))); + } + + let body = match serde_json::to_vec(&Value::Object(payload)) { + Ok(v) => v, + Err(e) => { + return Ok(Html(render_form( + &state, + &pubkey, + "", + Some(("error", &format!("json encode: {e}"))), + ))) + } + }; + let signed = sign::sign(&body, &sk); + let blob = base64::encode(&signed); + + let result = render_result(&blob, logo_bytes.as_deref()); + Ok(Html(render_form(&state, &pubkey, &result, None))) +} + +#[derive(Debug, Deserialize)] +pub struct DownloadForm { + pub blob: String, +} + +pub async fn download(admin: AuthedUser, Form(f): Form) -> Result { + require_admin(&admin)?; + if f.blob.trim().is_empty() { + return Err(ApiError::BadRequest("blob is empty".into())); + } + let mut resp = (StatusCode::OK, f.blob).into_response(); + let h = resp.headers_mut(); + h.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + h.insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_static(r#"attachment; filename="custom.txt""#), + ); + Ok(resp) +} + +// ---------- helpers ---------- + +#[derive(Default)] +struct FormState { + app_name: String, + default_settings_json: String, + override_settings_json: String, + kv_keys: Vec, + kv_values: Vec, +} + +fn parse_settings_json(raw: &str) -> Result, String> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(Map::new()); + } + let parsed: Value = serde_json::from_str(trimmed).map_err(|e| e.to_string())?; + match parsed { + Value::Object(m) => Ok(m), + _ => Err("must be a JSON object".into()), + } +} + +fn collect_kv_pairs(keys: &[String], values: &[String]) -> Vec<(String, String)> { + keys.iter() + .zip(values.iter()) + .filter_map(|(k, v)| { + let k = k.trim(); + if k.is_empty() { + None + } else { + Some((k.to_string(), v.clone())) + } + }) + .collect() +} + +/// Magic-byte sniff. Don't trust the multipart Content-Type — clients can +/// lie. PNG, JPEG (any variant), and ICO cover the formats the Flutter +/// `Image.memory` widget renders without a decoder plugin. +fn is_supported_image(bytes: &[u8]) -> bool { + if bytes.len() < 4 { + return false; + } + let png = bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]); + let jpeg = bytes.starts_with(&[0xFF, 0xD8, 0xFF]); + let ico = bytes.starts_with(&[0x00, 0x00, 0x01, 0x00]); + png || jpeg || ico +} + +fn render_form( + state: &FormState, + pubkey: &str, + result_html: &str, + notice: Option<(&str, &str)>, +) -> String { + let notice_block = match notice { + Some((kind, msg)) => notice_html(kind, msg), + None => String::new(), + }; + + let pubkey_display = if pubkey.is_empty() { + r#"branding_ed25519.pub not found on the server. Restart hbbs to generate it."#.to_string() + } else { + format!( + r#"{}"#, + html_escape(pubkey) + ) + }; + + let default_settings_default = if state.default_settings_json.is_empty() { + "{}" + } else { + &state.default_settings_json + }; + let override_settings_default = if state.override_settings_json.is_empty() { + "{}" + } else { + &state.override_settings_json + }; + + let kv_rows = render_kv_rows(&state.kv_keys, &state.kv_values); + + format!( + r##"
+
+

Customization

+

Generate a signed custom.txt the patched RustDesk client picks up at startup. The client must be built with this server's branding public key embedded (see below). Drop the resulting custom.txt next to rustdesk.exe on Windows, in RustDesk.app/Contents/Resources/ on macOS, or alongside the Linux binary.

+
+ +
+

Branding public key

+
{pubkey_display}
+

Bake this into your client build with RUSTDESK_BRANDING_PUBKEY=$(cat branding_ed25519.pub) cargo build --release. Stock RustDesk clients will silently ignore the resulting custom.txt.

+
+ + {notice_block} + +
+
+ + +

Replaces "RustDesk" in window titles, the about dialog, and other in-app surfaces. Setting this flips is_custom_client() on the client.

+
+ +
+ + +

Rendered in the connection page header, the about dialog, and the system tray when the patched client supports it. OS-level taskbar/dock icons are unaffected (those need build-time resource patching).

+
+ +
+ + +

Defaults applied on first launch — user can still change these. Example: {{"theme": "dark"}}

+
+ +
+ + +

Forced settings — user cannot change these. Example: {{"hide-powered-by-me": "Y", "force-always-relay": "Y"}}

+
+ +
+ +

Top-level keys merged into the signed JSON. Useful for buildin flags like hide-tray, preset-user-name, etc.

+
+ {kv_rows} +
+ +
+ + +
+ + + + {result_html} +
"##, + notice_block = notice_block, + pubkey_display = pubkey_display, + app_name = html_escape(&state.app_name), + default_settings = html_escape(default_settings_default), + override_settings = html_escape(override_settings_default), + kv_rows = kv_rows, + result_html = result_html, + ) +} + +fn render_kv_rows(keys: &[String], values: &[String]) -> String { + if keys.is_empty() { + return String::new(); + } + keys.iter() + .zip(values.iter().chain(std::iter::repeat(&String::new()))) + .map(|(k, v)| { + format!( + r##"
+ + + +
"##, + k = html_escape(k), + v = html_escape(v), + ) + }) + .collect::>() + .join("\n") +} + +fn render_result(blob: &str, logo_bytes: Option<&[u8]>) -> String { + let preview = match logo_bytes { + Some(bytes) if !bytes.is_empty() => { + let mime = sniff_mime(bytes); + format!( + r##"
+

Logo preview

+ logo +
"##, + mime = mime, + b64 = base64::encode(bytes), + ) + } + _ => String::new(), + }; + + format!( + r##"
+
+

Signed customization blob

+

Save the blob below as custom.txt and drop it next to the patched RustDesk binary.

+
+ + {preview} + +
+ +
{blob}
+
+ +
+ + +
+ +
+ Where to drop custom.txt +
    +
  • Windows: C:\Program Files\RustDesk\custom.txt
  • +
  • macOS: /Applications/RustDesk.app/Contents/Resources/custom.txt
  • +
  • Linux: alongside the binary (e.g. /usr/lib/rustdesk/custom.txt)
  • +
+

The file is read once at process start; restart the client (or its tray service) after dropping it.

+
+
"##, + preview = preview, + blob = html_escape(blob), + blob_attr = html_escape(blob), + ) +} + +fn sniff_mime(bytes: &[u8]) -> &'static str { + if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) { + "image/png" + } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { + "image/jpeg" + } else if bytes.starts_with(&[0x00, 0x00, 0x01, 0x00]) { + "image/x-icon" + } else { + "application/octet-stream" + } +} diff --git a/src/api/admin/pages/mod.rs b/src/api/admin/pages/mod.rs index 80a1c11..bd09699 100644 --- a/src/api/admin/pages/mod.rs +++ b/src/api/admin/pages/mod.rs @@ -4,6 +4,7 @@ pub mod address_books; pub mod audit; pub mod connect; +pub mod customization; pub mod deploy; pub mod devices; pub mod groups; diff --git a/src/common.rs b/src/common.rs index 8245021..9570894 100644 --- a/src/common.rs +++ b/src/common.rs @@ -176,6 +176,72 @@ pub fn gen_sk(wait: u64) -> (String, Option) { ("".to_owned(), None) } +/// Ensure a separate Ed25519 keypair exists for signing client customization +/// blobs (custom.txt). The patched RustDesk client embeds this public key +/// (compile-time) and uses it to verify signed branding payloads. Decoupled +/// from the rendezvous keypair (`gen_sk`) so rotating one doesn't invalidate +/// the other. Files: `branding_ed25519` (secret) and `branding_ed25519.pub` +/// (public key in base64), both in CWD next to `id_ed25519`. +pub fn gen_branding_sk() -> (String, Option) { + let sk_file = "branding_ed25519"; + if let Ok(mut file) = std::fs::File::open(sk_file) { + let mut contents = String::new(); + if file.read_to_string(&mut contents).is_ok() { + let contents = contents.trim(); + let sk = base64::decode(contents).unwrap_or_default(); + if sk.len() == sign::SECRETKEYBYTES { + let mut tmp = [0u8; sign::SECRETKEYBYTES]; + tmp[..].copy_from_slice(&sk); + let pk = base64::encode(&tmp[sign::SECRETKEYBYTES / 2..]); + log::info!("Branding private key comes from {}", sk_file); + return (pk, Some(sign::SecretKey(tmp))); + } else { + println!("Fatal error: malformed private key in {sk_file}."); + std::process::exit(1); + } + } + } else { + let (pk, sk) = sign::gen_keypair(); + let pk = base64::encode(pk); + let pub_file = format!("{sk_file}.pub"); + if let Ok(mut f) = std::fs::File::create(&pub_file) { + f.write_all(pk.as_bytes()).ok(); + if let Ok(mut f) = std::fs::File::create(sk_file) { + let s = base64::encode(&sk); + if f.write_all(s.as_bytes()).is_ok() { + log::info!("Branding keypair written to {}/{}", sk_file, pub_file); + log::debug!("Branding public key: {}", pk); + return (pk, Some(sk)); + } + } + } + } + ("".to_owned(), None) +} + +/// Read the branding secret key from disk. Returns `None` if the file is +/// missing or malformed; callers surface a user-facing error in that case +/// rather than crashing the request. +pub fn read_branding_sk() -> Option { + let contents = std::fs::read_to_string("branding_ed25519").ok()?; + let sk = base64::decode(contents.trim()).ok()?; + if sk.len() != sign::SECRETKEYBYTES { + return None; + } + let mut tmp = [0u8; sign::SECRETKEYBYTES]; + tmp[..].copy_from_slice(&sk); + Some(sign::SecretKey(tmp)) +} + +/// Read the branding public key from disk for display in the admin UI. +/// Empty string when the file is missing — same idiom as `deploy.rs`. +pub fn read_branding_pubkey() -> String { + std::fs::read_to_string("branding_ed25519.pub") + .ok() + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + #[cfg(unix)] pub async fn listen_signal() -> Result<()> { use hbb_common::tokio; diff --git a/src/rendezvous_server.rs b/src/rendezvous_server.rs index 46626c5..ece1614 100644 --- a/src/rendezvous_server.rs +++ b/src/rendezvous_server.rs @@ -114,6 +114,10 @@ impl RendezvousServer { http_listen: &str, ) -> ResultType<()> { let (key, sk) = Self::get_server_sk(key); + // Ensure the branding signing keypair exists. Used by the admin UI's + // Customization page to sign client custom.txt blobs. Decoupled from + // the rendezvous key above so the two can rotate independently. + let _ = crate::common::gen_branding_sk(); let nat_port = port - 1; let ws_port = port + 2; // Capture the bind addresses as owned Strings so the async move diff --git a/src/verify_branding.rs b/src/verify_branding.rs new file mode 100644 index 0000000..a22079e --- /dev/null +++ b/src/verify_branding.rs @@ -0,0 +1,72 @@ +//! Smoke-test binary: verify a `custom.txt` blob against the server's +//! branding public key. Run on the server before cutting a client release +//! to confirm the signing pipeline round-trips end-to-end: +//! +//! cargo run --bin verify_branding -- /path/to/custom.txt +//! +//! Reads `branding_ed25519.pub` from CWD (the same path the admin UI +//! displays). Exits non-zero if verification fails or the JSON payload +//! doesn't decode as UTF-8. + +use sodiumoxide::crypto::sign; +use std::process::ExitCode; + +fn main() -> ExitCode { + let args: Vec = std::env::args().collect(); + let Some(path) = args.get(1) else { + eprintln!("usage: verify_branding "); + return ExitCode::from(2); + }; + + let pk_b64 = match std::fs::read_to_string("branding_ed25519.pub") { + Ok(s) => s, + Err(e) => { + eprintln!("read branding_ed25519.pub: {e}"); + return ExitCode::from(1); + } + }; + let pk_bytes = match base64::decode(pk_b64.trim()) { + Ok(b) => b, + Err(e) => { + eprintln!("decode pubkey: {e}"); + return ExitCode::from(1); + } + }; + let Some(pk) = sign::PublicKey::from_slice(&pk_bytes) else { + eprintln!("pubkey wrong length: {}", pk_bytes.len()); + return ExitCode::from(1); + }; + + let blob = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(e) => { + eprintln!("read {path}: {e}"); + return ExitCode::from(1); + } + }; + let signed = match base64::decode(blob.trim()) { + Ok(b) => b, + Err(e) => { + eprintln!("decode blob: {e}"); + return ExitCode::from(1); + } + }; + let body = match sign::verify(&signed, &pk) { + Ok(b) => b, + Err(_) => { + eprintln!("signature verification FAILED"); + return ExitCode::from(1); + } + }; + + match std::str::from_utf8(&body) { + Ok(s) => { + println!("{s}"); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("payload not utf-8: {e}"); + ExitCode::from(1) + } + } +}