feat: M6 web client — view + control + audio in the dashboard
Adds a TypeScript SPA embedded in hbbs that lets a logged-in admin click
"Connect (web client)" on a Devices row and remote-control the peer from
the browser, no desktop client install required. View, mouse/keyboard
control, and host-audio playback all work end-to-end.
Architecture
------------
Pure browser app, no server-side WS proxy:
Browser ──ws://hbbs:21118── rendezvous (PunchHole + RequestRelay)
│
└──ws://hbbr:21119──── relay (paired by uuid)
│
└── peer (RustDesk desktop, any platform)
Same wire path the desktop client takes via `Client::request_relay` and
`Client::create_relay`. Browser-relay-only — no NAT punching, so we send
nat_type=SYMMETRIC in PunchHoleRequest to make the peer skip the direct
attempt and go straight to relay (initiate=true with a host-generated uuid
that we then use for our own relay leg).
The 5 wire steps:
1. PunchHoleRequest → harvests signed peer Ed25519 sign-pk + relay host
+ the peer's session uuid from RelayResponse
2. Verify the signed pk against /admin/connect's `id_ed25519.pub`
3. Open WS to hbbr:21119, send RequestRelay with that uuid
4. Read peer's Message{signed_id}, verify with peer_sign_pk, extract
Curve25519 box pk; box-seal a fresh secretbox key under it; send
Message{public_key} unencrypted
5. Secretbox-encrypted stream from here. Hash → LoginRequest →
LoginResponse with PeerInfo. Mode = Legacy (Translate silently drops
ControlKey/Unicode payloads on the host side).
New Rust surface
----------------
- `admin_ui/connect.html` — SPA shell with `{{CUSTOM_CONFIG}}` placeholder
- `src/api/admin/pages/connect.rs` — gates on AuthedUser, injects per-request
config (rendezvous host, relay host, server pubkey, peer_id, admin name)
into the `<script id="custom-config">` tag, serves bundle.{js,css} via
include_bytes!
- 3 routes added: GET /admin/connect/:peer_id and the two assets
- Devices dropdown gains a sky-blue "Connect (web client)" link that opens
in a new tab
New TypeScript SPA (`web_client/`)
----------------------------------
Stack: pure DOM/TS, no React/Vue. Bundled by esbuild → `dist/bundle.{js,css}`
which is committed (cargo build needs no Node toolchain).
src/main.ts boot + password retry loop + receive dispatch
src/crypto.ts tweetnacl wrapper (sign_open, box, secretbox)
+ @noble/hashes/sha2 (works on plain http;
SubtleCrypto requires a secure context)
src/proto/generated.{js,d.ts} pbjs static-module from
libs/hbb_common/protos/{rendezvous,message}.proto
src/transport/rendezvous.ts WS to hbbs; PunchHole + RequestRelay
src/transport/relay.ts WS to hbbr; duplex frame transport
src/transport/session.ts secure-handshake state machine + Hash/Login
+ 8-byte LE secretbox sequence counter
(PRE-increment, send/recv independent —
matches libs/hbb_common/src/tcp.rs:317-320)
+ preloginExtras stash for AudioFormat that
arrives before LoginResponse
src/decode/video.ts WebCodecs VideoDecoder (vp09.00.10.08 today;
h264/h265/av1/vp8 codec strings ready for M6f)
src/decode/audio.ts WebCodecs AudioDecoder (opus) → AudioContext;
detects f32 vs f32-planar AudioData layout
and deinterleaves when needed; gap-less
scheduling via a sliding playhead
src/ui/canvas.ts <canvas> with object-fit: contain letterbox;
auto-resizes on resolution change; FPS counter
src/input/mouse.ts MouseEvent → MouseEvent proto. Mask layout:
(button << 3) | type (0=move,1=down,2=up,
3=wheel). Letterbox-aware viewport→peer
coord mapping. Right-click suppresses the
browser context menu; left-click does NOT
preventDefault (would block focus)
src/input/keyboard.ts Window-level keydown/keyup → KeyEvent proto
in Legacy mode. Special keys → ControlKey
enum; printable → unicode codepoint (down
only, host's process_unicode does a single
key_click). Browser shortcuts allowlisted
(Cmd-T/N/W/R, Tab) so the user keeps tab
control. Ctrl+Alt+Del HUD button (host-side
`send_sas` is `#[cfg(windows)]`; no-op on
Mac/Linux hosts but present for parity)
Bundle size: 529 KB raw / ~74 KB gzipped. Tree-shaken protobufjs +
tweetnacl + @noble/hashes only.
Deployment notes
----------------
- WebCodecs and SubtleCrypto are gated to "secure context" origins —
HTTPS, or http://localhost. Plain http://lan-ip won't work. Open via
http://localhost during dev, or terminate TLS in front of hbbs (Caddy
/ nginx / Traefik) for production access.
- `--relay-servers <host>` on hbbs must point at a host where TCP/WS
21119 is reachable from end-user browsers.
Wire-format gotchas this commit nails (each one was a session of bisecting)
--------------------------------------------------------------------------
- Hash.salt / Hash.challenge are proto `string` fields used as raw UTF-8
bytes in the SHA-256 chain. NOT base64-decoded. `pwd_hash =
SHA256(pwd_text || salt_utf8)`, `resp = SHA256(pwd_hash || challenge_utf8)`.
- Translate keyboard mode silently drops Unicode + ControlKey payloads on
the host (input_service.rs:2022 has `// Do not handle unicode for now.`).
Only Seq + Chr work in Translate. Use Legacy (mode=0) for everything.
- Browser is forced to relay path by sending nat_type=SYMMETRIC. The peer
generates its OWN uuid in handle_punch_hole's symmetric branch; use that
uuid (carried back in RelayResponse) for the relay leg, not a fresh one.
- Misc{audio_format} fires from the host's audio_service first-snapshot
BETWEEN add_connection and login_response, so it lands on the wire
before our session.recv() loop is set up. Session.open() captures
pre-login messages into preloginExtras for the caller to replay.
- protobufjs static-module sets unpopulated oneof fields to JS `null`,
not `undefined`. A `if (msg.cursor_id !== undefined)` cursor branch
swallowed every other message type including Misc; switched to loose
`!= null` comparison.
- WebCodecs AudioDecoder for opus emits `f32` (interleaved) AudioData —
must deinterleave into separate AudioBuffer channels before playback.
- VideoDecoder/AudioDecoder/SubtleCrypto are SecureContext-only; need
http://localhost or https:// on the *page origin*, not the WS targets.
- libsodium-wrappers ESM ships a broken relative import (`./libsodium.mjs`
in a sibling package); switched to tweetnacl which has no such problem.
- WebCrypto's SubtleCrypto.digest() doesn't accept SharedArrayBuffer-backed
Uint8Arrays in newer TS lib types; doesn't matter — we use @noble/hashes
for sha256 anyway since Subtle is secure-context-only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -117,6 +117,21 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
|
||||
"/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))
|
||||
|
||||
@@ -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<String>,
|
||||
) -> Result<Html<String>, 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 <script id="custom-config" type="application/json">
|
||||
// so the JSON content is parsed verbatim by JSON.parse — no further escaping
|
||||
// beyond ensuring no literal "</script>" 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)
|
||||
}
|
||||
@@ -169,6 +169,11 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow) {
|
||||
<details class="text-right relative">
|
||||
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
|
||||
<div class="absolute right-2 mt-1 z-10 w-56 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
|
||||
<a class="block w-full text-left px-2 py-1 text-xs text-sky-300 hover:bg-sky-900/40 rounded"
|
||||
href="/admin/connect/{id}" target="_blank" rel="noopener">
|
||||
Connect (web client)
|
||||
</a>
|
||||
<hr class="border-slate-700 my-1" />
|
||||
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
|
||||
hx-post="/admin/pages/devices/{id}/disconnect"
|
||||
hx-target="#devices-region" hx-swap="innerHTML"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
pub mod address_books;
|
||||
pub mod audit;
|
||||
pub mod connect;
|
||||
pub mod deploy;
|
||||
pub mod devices;
|
||||
pub mod groups;
|
||||
|
||||
Reference in New Issue
Block a user