8e5c0969ef
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>
480 lines
18 KiB
Rust
480 lines
18 KiB
Rust
//! 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"
|
||
}
|
||
}
|