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
Generated
+19
View File
@@ -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
View File
@@ -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"
+2
View File
@@ -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"
+12
View File
@@ -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.
+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"
}
}
+1
View File
@@ -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;
+66
View File
@@ -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;
+4
View File
@@ -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
+72
View File
@@ -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)
}
}
}