diff --git a/admin_ui/connect.html b/admin_ui/connect.html new file mode 100644 index 0000000..3a4098f --- /dev/null +++ b/admin_ui/connect.html @@ -0,0 +1,19 @@ + + + + + + RustDesk — Connect + + + + + +
+ + + diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index 6baab25..f0ec965 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -117,6 +117,21 @@ pub fn build(state: Arc) -> Option { "/admin/pages/deploy/generate", post(pages::deploy::generate), ) + // 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. + .route( + "/admin/connect/:peer_id", + get(pages::connect::index), + ) + .route( + "/admin/connect/assets/bundle.js", + get(pages::connect::bundle_js), + ) + .route( + "/admin/connect/assets/bundle.css", + get(pages::connect::bundle_css), + ) .route("/admin/pages/devices", get(pages::devices::index)) .route("/admin/pages/groups", get(pages::groups::index)) .route("/admin/pages/strategies", get(pages::strategies::index)) diff --git a/src/api/admin/pages/connect.rs b/src/api/admin/pages/connect.rs new file mode 100644 index 0000000..3700e0d --- /dev/null +++ b/src/api/admin/pages/connect.rs @@ -0,0 +1,136 @@ +//! `/admin/connect/:peer_id` — serves the embedded web client SPA. +//! +//! Architecture: the SPA at web_client/src/main.ts opens WebSockets directly +//! to the existing rendezvous (hbbs:21118) and relay (hbbr:21119) endpoints +//! and speaks the same protocol the desktop client speaks. The role of this +//! handler is to (a) gate access via the AuthedUser cookie middleware, +//! (b) inject per-request config (rendezvous host, relay host, server pubkey, +//! peer id, admin name) into the SPA, and (c) serve the bundled JS/CSS via +//! `include_bytes!` so the binary is self-contained. +//! +//! Same `{{CUSTOM_CONFIG}}` template substitution pattern as deploy.rs. + +use super::shared::{html_escape, require_admin}; +use crate::api::error::ApiError; +use crate::api::middleware::AuthedUser; +use axum::extract::Path; +use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; +use axum::response::{Html, IntoResponse, Response}; +use serde_json::json; + +const CONNECT_HTML: &str = include_str!("../../../../admin_ui/connect.html"); +const BUNDLE_JS: &[u8] = include_bytes!("../../../../web_client/dist/bundle.js"); +const BUNDLE_CSS: &[u8] = include_bytes!("../../../../web_client/dist/bundle.css"); + +/// `GET /admin/connect/:peer_id` — render the SPA shell with config injected. +pub async fn index( + admin: AuthedUser, + headers: HeaderMap, + Path(peer_id): Path, +) -> Result, ApiError> { + require_admin(&admin)?; + + // Derive default rendezvous/relay hosts from the request Host header so + // operators don't need to configure separately for the common case where + // hbbs and hbbr live on the same machine the browser is currently talking + // to. Same approach as the deploy page. + let host = headers + .get(header::HOST) + .and_then(|v| v.to_str().ok()) + .map(host_only) + .unwrap_or("") + .to_string(); + + let pubkey = read_pubkey(); + let api_server = format!( + "{}://{}", + if is_https(&headers) { "https" } else { "http" }, + headers + .get(header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + ); + + let cfg = json!({ + "api_server": api_server, + "rendezvous_server": host, + "relay_server": host, + "key": pubkey, + "peer_id": peer_id, + "admin_name": admin.name.clone(), + }); + + let cfg_str = cfg.to_string(); + // The placeholder is inside " appears (which a JSON serializer + // never produces) and HTML-escaping any peer_id we substitute elsewhere. + let html = CONNECT_HTML.replace("{{CUSTOM_CONFIG}}", &cfg_str); + + // Defensive: if a peer_id ever ends up reflected outside the JSON tag + // (the template doesn't currently do this, but future edits might), + // having html_escape called as part of the page-build flow is a habit + // worth preserving. + let _ = html_escape; + + Ok(Html(html)) +} + +/// `GET /admin/connect/assets/bundle.js` — serve the SPA bundle. +pub async fn bundle_js() -> Response { + asset_response(BUNDLE_JS, "application/javascript; charset=utf-8") +} + +/// `GET /admin/connect/assets/bundle.css` — serve the SPA stylesheet. +pub async fn bundle_css() -> Response { + asset_response(BUNDLE_CSS, "text/css; charset=utf-8") +} + +fn asset_response(body: &'static [u8], content_type: &'static str) -> Response { + let mut resp = (StatusCode::OK, body).into_response(); + let headers = resp.headers_mut(); + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + // Bundles are content-addressed by SHA in name? Not yet — until we add + // hashed filenames, force fresh fetches so admin upgrades pick up new JS. + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-cache"), + ); + resp +} + +// ---------- helpers ---------- + +/// Read the server's Ed25519 public key from `id_ed25519.pub` in CWD — +/// same path `common::gen_sk` writes it to and what the deploy page reads. +fn read_pubkey() -> String { + std::fs::read_to_string("id_ed25519.pub") + .ok() + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// Strip `:port` (and IPv6 brackets) from a Host-header value. Borrowed +/// from the deploy page; kept inline here rather than promoting to shared +/// to avoid a cross-module dep on a one-liner. +fn host_only(s: &str) -> &str { + if let Some(rest) = s.strip_prefix('[') { + if let Some(end) = rest.find(']') { + return &rest[..end]; + } + } + s.rsplit_once(':').map(|(h, _)| h).unwrap_or(s) +} + +/// Heuristic: were we reached via HTTPS? The presence of any +/// `X-Forwarded-Proto: https` from a reverse proxy is the standard signal. +/// Falls back to false; the SPA only uses this to construct the displayed +/// API URL, the actual WebSockets pick `ws://` vs `wss://` based on the +/// page's own protocol. +fn is_https(headers: &HeaderMap) -> bool { + headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .map(|s| s.eq_ignore_ascii_case("https")) + .unwrap_or(false) +} diff --git a/src/api/admin/pages/devices.rs b/src/api/admin/pages/devices.rs index 9da3b63..71736a7 100644 --- a/src/api/admin/pages/devices.rs +++ b/src/api/admin/pages/devices.rs @@ -169,6 +169,11 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow) {
···
+ + Connect (web client) + +