feat(admin): signed customization page (custom.txt generator)
build / build-linux-amd64 (push) Successful in 1m49s
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:
Generated
+19
@@ -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"
|
||||
|
||||
+5
-1
@@ -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"
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
hx-get="/admin/pages/recordings" hx-target="#main" hx-push-url="#recordings">Recordings</a>
|
||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||
hx-get="/admin/pages/deploy" hx-target="#main" hx-push-url="#deploy">Deploy</a>
|
||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||
hx-get="/admin/pages/customization" hx-target="#main" hx-push-url="#customization">Customization</a>
|
||||
</nav>
|
||||
<div class="px-2 py-3 border-t border-slate-800 space-y-1">
|
||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-400 hover:bg-slate-800"
|
||||
|
||||
@@ -131,6 +131,18 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
|
||||
"/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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -176,6 +176,72 @@ pub fn gen_sk(wait: u64) -> (String, Option<sign::SecretKey>) {
|
||||
("".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<sign::SecretKey>) {
|
||||
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<sign::SecretKey> {
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> = std::env::args().collect();
|
||||
let Some(path) = args.get(1) else {
|
||||
eprintln!("usage: verify_branding <path-to-custom.txt>");
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user