Files
rustdesk-server/src/api/admin/pages/customization.rs
T
mike 8e5c0969ef
build / build-linux-amd64 (push) Successful in 1m49s
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) <noreply@anthropic.com>
2026-05-06 18:48:18 +02:00

480 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Html<String>, 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<Html<String>, ApiError> {
require_admin(&admin)?;
let pubkey = crate::common::read_branding_pubkey();
let mut state = FormState::default();
let mut logo_bytes: Option<Vec<u8>> = 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<String, Value> = 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<DownloadForm>) -> Result<Response, ApiError> {
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<String>,
kv_values: Vec<String>,
}
fn parse_settings_json(raw: &str) -> Result<Map<String, Value>, 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#"<span class="text-rose-300">branding_ed25519.pub not found on the server. Restart hbbs to generate it.</span>"#.to_string()
} else {
format!(
r#"<code class="text-emerald-300 break-all">{}</code>"#,
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##"<div class="space-y-6">
<header>
<h2 class="text-lg font-semibold">Customization</h2>
<p class="text-xs text-slate-500 mt-1">Generate a signed <code>custom.txt</code> 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 <code>custom.txt</code> next to <code>rustdesk.exe</code> on Windows, in <code>RustDesk.app/Contents/Resources/</code> on macOS, or alongside the Linux binary.</p>
</header>
<div class="bg-slate-900 border border-slate-800 rounded-lg p-4">
<p class="text-xs font-medium text-slate-400 mb-1">Branding public key</p>
<div class="text-xs font-mono">{pubkey_display}</div>
<p class="text-xs text-slate-500 mt-2">Bake this into your client build with <code>RUSTDESK_BRANDING_PUBKEY=$(cat branding_ed25519.pub) cargo build --release</code>. Stock RustDesk clients will silently ignore the resulting <code>custom.txt</code>.</p>
</div>
{notice_block}
<form
class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-4"
hx-post="/admin/pages/customization/generate"
hx-target="#main"
hx-swap="innerHTML"
hx-encoding="multipart/form-data"
>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1" for="app_name">App name (required)</label>
<input id="app_name" name="app_name" type="text" required value="{app_name}"
placeholder="Acme Remote"
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">Replaces "RustDesk" in window titles, the about dialog, and other in-app surfaces. Setting this flips <code>is_custom_client()</code> on the client.</p>
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1" for="logo">Logo (PNG / ICO / JPEG, ≤ 256 KiB, optional)</label>
<input id="logo" name="logo" type="file" accept="image/png,image/jpeg,image/x-icon,image/vnd.microsoft.icon"
class="block w-full text-xs text-slate-300 file:mr-3 file:py-1.5 file:px-3 file:rounded file:border-0 file:text-xs file:bg-slate-800 file:text-slate-200 hover:file:bg-slate-700" />
<p class="text-xs text-slate-500 mt-1">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).</p>
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1" for="default_settings_json">default-settings (JSON object, optional)</label>
<textarea id="default_settings_json" name="default_settings_json" rows="3"
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">{default_settings}</textarea>
<p class="text-xs text-slate-500 mt-1">Defaults applied on first launch — user can still change these. Example: <code>{{"theme": "dark"}}</code></p>
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1" for="override_settings_json">override-settings (JSON object, optional)</label>
<textarea id="override_settings_json" name="override_settings_json" rows="3"
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">{override_settings}</textarea>
<p class="text-xs text-slate-500 mt-1">Forced settings — user cannot change these. Example: <code>{{"hide-powered-by-me": "Y", "force-always-relay": "Y"}}</code></p>
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1">Extra key/value pairs (optional)</label>
<p class="text-xs text-slate-500 mb-2">Top-level keys merged into the signed JSON. Useful for buildin flags like <code>hide-tray</code>, <code>preset-user-name</code>, etc.</p>
<div id="kv-rows" class="space-y-2">
{kv_rows}
</div>
<button type="button"
onclick="
const tpl = document.getElementById('kv-row-template');
document.getElementById('kv-rows').appendChild(tpl.content.cloneNode(true));
"
class="mt-2 text-xs text-sky-400 hover:text-sky-300">+ Add row</button>
</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 signed blob
</button>
</form>
<template id="kv-row-template">
<div class="flex gap-2">
<input type="text" name="kv_key" placeholder="key"
class="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-2 text-xs font-mono focus:outline-none focus:border-sky-500" />
<input type="text" name="kv_value" placeholder="value"
class="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-2 text-xs font-mono focus:outline-none focus:border-sky-500" />
<button type="button"
onclick="this.closest('div').remove()"
class="text-rose-400 hover:text-rose-300 text-sm px-2">×</button>
</div>
</template>
{result_html}
</div>"##,
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##"<div class="flex gap-2">
<input type="text" name="kv_key" value="{k}" placeholder="key"
class="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-2 text-xs font-mono focus:outline-none focus:border-sky-500" />
<input type="text" name="kv_value" value="{v}" placeholder="value"
class="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-2 text-xs font-mono focus:outline-none focus:border-sky-500" />
<button type="button"
onclick="this.closest('div').remove()"
class="text-rose-400 hover:text-rose-300 text-sm px-2">×</button>
</div>"##,
k = html_escape(k),
v = html_escape(v),
)
})
.collect::<Vec<_>>()
.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##"<div class="mb-3">
<p class="text-xs font-medium text-slate-400 mb-1">Logo preview</p>
<img src="data:{mime};base64,{b64}" alt="logo" class="h-16 w-16 object-contain bg-slate-800 rounded border border-slate-700" />
</div>"##,
mime = mime,
b64 = base64::encode(bytes),
)
}
_ => String::new(),
};
format!(
r##"<section class="space-y-4 bg-slate-900 border border-emerald-800/40 rounded-lg p-4">
<header>
<h3 class="text-sm font-semibold text-emerald-300">Signed customization blob</h3>
<p class="text-xs text-slate-500 mt-1">Save the blob below as <code>custom.txt</code> and drop it next to the patched RustDesk binary.</p>
</header>
{preview}
<div>
<label class="block text-xs font-medium text-slate-400 mb-1">Base64 blob</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">{blob}</pre>
</div>
<form action="/admin/pages/customization/download" method="post" target="_blank">
<input type="hidden" name="blob" value="{blob_attr}" />
<button type="submit"
class="bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-medium rounded px-4 py-2 transition">
Download custom.txt
</button>
</form>
<details class="text-xs text-slate-400">
<summary class="cursor-pointer text-slate-300 select-none">Where to drop custom.txt</summary>
<ul class="mt-2 space-y-1 list-disc pl-5">
<li>Windows: <code>C:\Program Files\RustDesk\custom.txt</code></li>
<li>macOS: <code>/Applications/RustDesk.app/Contents/Resources/custom.txt</code></li>
<li>Linux: alongside the binary (e.g. <code>/usr/lib/rustdesk/custom.txt</code>)</li>
</ul>
<p class="mt-2">The file is read once at process start; restart the client (or its tray service) after dropping it.</p>
</details>
</section>"##,
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"
}
}