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:
2026-05-03 13:55:40 +02:00
parent 8b0219a877
commit d07e98e607
28 changed files with 56983 additions and 0 deletions
+15
View File
@@ -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))
+136
View File
@@ -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)
}
+5
View File
@@ -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"
+1
View File
@@ -3,6 +3,7 @@
pub mod address_books;
pub mod audit;
pub mod connect;
pub mod deploy;
pub mod devices;
pub mod groups;