feat(admin): signed customization page (custom.txt generator)
build / build-linux-amd64 (push) Successful in 1m49s

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>
This commit is contained in:
2026-05-06 18:48:18 +02:00
parent d9847960a9
commit 8e5c0969ef
9 changed files with 660 additions and 1 deletions
+479
View File
@@ -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<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"
}
}