diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index fa7b456..3caf5c9 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -355,9 +355,163 @@ unconditionally. **Direct peer-to-peer is never attempted.** ### Network requirements - The **relay host** advertised to clients (`--relay-servers=` on hbbs) must resolve and be reachable from the end-user's browser on port 21119. The relay is what carries the actual session bytes — if a user's browser can't open `ws://:21119/`, the session dies after the rendezvous step. A common gotcha: setting `--relay-servers=hbbr-internal.local` works for desktop clients on the LAN but breaks for browsers off-LAN. -- **Reverse proxies** must forward the WebSocket upgrade for both 21118 (rendezvous) and 21119 (relay). Caddy: `reverse_proxy /ws/* hbbs:21118` plus equivalent for 21119; nginx: the standard `proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";` block. - Audit rows are written under the admin's cookie via the existing `/api/audit/conn` endpoint; no new server endpoint. +### TLS deployment with nginx + +The dashboard and the two browser-facing WebSocket ports (21118 = rendezvous, 21119 = relay) all need TLS in front of them when accessed from a browser, since the page is served over HTTPS and mixed-content `ws://` is blocked. nginx is the canonical setup; Caddy works similarly with much less ceremony. + +#### Port plan + +| Public port | TLS terminator | Backed by | +|---|---|---| +| 443/tcp | nginx | `127.0.0.1:21114` (hbbs HTTP API + dashboard) | +| 21118/tcp | nginx | `127.0.0.1:21118` (hbbs WS rendezvous) | +| 21119/tcp | nginx | `127.0.0.1:21119` (hbbr WS relay) | +| 21115/tcp | — | hbbs (NAT test, plain TCP, desktop clients only) | +| 21116/tcp+udp | — | hbbs (main rendezvous, desktop clients only) | +| 21117/tcp | — | hbbr (relay for desktop clients, plain TCP) | + +Desktop clients use plain TCP/UDP on 21115 / 21116 / 21117 and bring their own framing + secretbox encryption — no TLS needed. Only browsers go through nginx. + +#### Pin hbbs / hbbr to localhost + +By default both binaries bind every port to the wildcard (`[::]`), which collides with nginx wanting to take the same public port. Use the bind flags so nginx can claim the public port and forward to localhost: + +```sh +# hbbs — desktop-client ports stay on the wildcard, browser ports go local +./hbbs --port 21116 \ + --http-port 21114 --http-listen 127.0.0.1 \ + --ws-listen 127.0.0.1 \ + --relay-servers rd.example.com \ + --public-base-url https://rd.example.com \ + # ... rest of your flags + +# hbbr — TCP relay (21117) stays public, WS relay (21119) goes local +./hbbr --port 21117 --ws-listen 127.0.0.1 +``` + +After restart, `ss -tlnp` should show: + +``` +LISTEN 127.0.0.1:21114 <-- hbbs HTTP, fronted by nginx 443 +LISTEN 127.0.0.1:21118 <-- hbbs WS, fronted by nginx 21118 +LISTEN 127.0.0.1:21119 <-- hbbr WS, fronted by nginx 21119 +LISTEN 0.0.0.0:21115 <-- hbbs NAT test (public) +LISTEN 0.0.0.0:21116 <-- hbbs rendezvous tcp (public) +LISTEN 0.0.0.0:21117 <-- hbbr relay tcp (public) +# plus 0.0.0.0:21116/udp +``` + +#### nginx site config + +Three `server { }` blocks. The dashboard one is normal HTTP/2 + reverse-proxy; the two WS blocks need the `Upgrade`/`Connection` headers and a long `proxy_read_timeout` so idle web sessions don't get severed mid-screen-share. + +```nginx +# /etc/nginx/sites-available/rustdesk + +# Helper for WS upgrade — referenced by both WS blocks below. +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +# 1. Dashboard + admin API on 443 +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name rd.example.com; + + ssl_certificate /etc/letsencrypt/live/rd.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/rd.example.com/privkey.pem; + + # The dashboard streams audit logs / device events via plain HTTP today + # but we still need WS-upgrade pass-through here for the /admin/connect/ + # SPA's own asset requests are HTTP, but if you ever proxy ws under + # /ws/* in the future, this stays correct. + location / { + proxy_pass http://127.0.0.1:21114; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 3600s; + } +} + +# Force HTTPS — drop this block if you don't need port 80 at all. +server { + listen 80; + listen [::]:80; + server_name rd.example.com; + return 301 https://$host$request_uri; +} + +# 2. WSS rendezvous on 21118 +server { + listen 21118 ssl http2; + listen [::]:21118 ssl http2; + server_name rd.example.com; + + ssl_certificate /etc/letsencrypt/live/rd.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/rd.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:21118; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + # Web sessions can sit idle on the rendezvous WS; bump the read + # timeout so nginx doesn't reset the connection before the relay + # leg finishes negotiating. + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } +} + +# 3. WSS relay on 21119 +server { + listen 21119 ssl http2; + listen [::]:21119 ssl http2; + server_name rd.example.com; + + ssl_certificate /etc/letsencrypt/live/rd.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/rd.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:21119; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + # Relay carries the live session for as long as the user is + # remote-controlling. Pick a value larger than the longest + # session you expect (24h here). + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } +} +``` + +The Let's Encrypt cert covers all three ports — same hostname, just different listen ports. With certbot's nginx plugin the cert was already obtained for the 443 block; the other two blocks just point at the same files. + +Open the firewall for **80, 443, 21115, 21116, 21117, 21118, 21119** (TCP) and **21116** (UDP). Everything else can stay closed. + +Verify after reload: in DevTools → Network, `wss://rd.example.com:21118/` and `wss://rd.example.com:21119/` should each show status `101 Switching Protocols`. + +Common failure modes: + +- **`ERR_SSL_PROTOCOL_ERROR`** on 21118 or 21119 — nginx isn't terminating TLS on that port. Check the listener block + cert paths. +- **`ERR_CONNECTION_REFUSED`** — firewall is blocking the public port, OR nginx itself isn't listening on it (check `ss -tlnp`). +- **`502 Bad Gateway`** at the dashboard — hbbs isn't running, or `--http-listen` doesn't match what nginx is `proxy_pass`ing to. +- **WS upgrade hangs / 200 instead of 101** — `Upgrade` / `Connection` headers aren't being forwarded. The `$connection_upgrade` map at the top of the config is what makes this work; without it, `proxy_set_header Connection "upgrade"` would also work but breaks plain HTTP requests. + ### Features | Feature | Status | diff --git a/src/common.rs b/src/common.rs index 9c76f41..8245021 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1,6 +1,7 @@ use clap::App; use hbb_common::{ - allow_err, anyhow::{Context, Result}, get_version_number, log, tokio, ResultType + allow_err, anyhow::{Context, Result}, get_version_number, log, tcp::listen_any, tokio, + tokio::net::TcpListener, ResultType, }; use ini::Ini; use sodiumoxide::crypto::sign; @@ -11,6 +12,27 @@ use std::{ time::{Instant, SystemTime}, }; +/// Bind a TCP listener for `port`. When `host` is empty (the default for +/// every flag that accepts it), falls through to `listen_any` which binds +/// the dual-stack `[::]` wildcard. When `host` is set, binds only to that +/// address — used by deployments that put nginx/Caddy out front for TLS +/// termination on the WS / HTTP ports and want hbbs/hbbr's plain sockets +/// reachable only from localhost. +pub async fn bind_tcp_listener(host: &str, port: i32) -> ResultType { + if host.is_empty() { + return listen_any(port as u16).await; + } + let host_with_brackets = if host.contains(':') && !host.starts_with('[') { + format!("[{}]", host) + } else { + host.to_string() + }; + let addr: SocketAddr = format!("{}:{}", host_with_brackets, port).parse()?; + let l = TcpListener::bind(addr).await?; + log::info!("listen on tcp {}", addr); + Ok(l) +} + #[allow(dead_code)] pub(crate) fn get_expired_time() -> Instant { let now = Instant::now(); diff --git a/src/hbbr.rs b/src/hbbr.rs index 4c8a784..37a0c64 100644 --- a/src/hbbr.rs +++ b/src/hbbr.rs @@ -15,6 +15,7 @@ fn main() -> ResultType<()> { let args = format!( "-p, --port=[NUMBER(default={RELAY_PORT})] 'Sets the listening port' -k, --key=[KEY] 'Only allow the client with the same key' + --ws-listen=[HOST] 'Bind address for the browser-facing WebSocket relay port (port+2). Default = wildcard. Set to 127.0.0.1 (or ::1) when a reverse proxy claims the public port for TLS termination.' ", ); let matches = App::new("hbbr") @@ -40,6 +41,7 @@ fn main() -> ResultType<()> { matches .value_of("key") .unwrap_or(&std::env::var("KEY").unwrap_or_default()), + matches.value_of("ws-listen").unwrap_or(""), )?; Ok(()) } diff --git a/src/main.rs b/src/main.rs index 4b50700..a83586e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,8 @@ fn main() -> ResultType<()> { -r, --relay-servers=[HOST] 'Sets the default relay servers, separated by comma' -M, --rmem=[NUMBER(default={RMEM})] 'Sets UDP recv buffer size, set system rmem_max first, e.g., sudo sysctl -w net.core.rmem_max=52428800. vi /etc/sysctl.conf, net.core.rmem_max=52428800, sudo sysctl –p' --http-port=[NUMBER(default=21114)] 'HTTP management API port (0 disables)' + --http-listen=[HOST] 'Bind address for --http-port. Default = wildcard. Set to 127.0.0.1 (or ::1) when nginx/Caddy fronts this port for TLS.' + --ws-listen=[HOST] 'Bind address for the browser-facing WebSocket rendezvous port (port+2). Default = wildcard. Set to 127.0.0.1 (or ::1) when a reverse proxy claims the public port for TLS termination.' --bootstrap-admin-username=[USERNAME] 'Username to seed on first startup if users table is empty' --bootstrap-admin-password=[PASSWORD] 'Password to seed on first startup if users table is empty' --ab-legacy-mode=[on|off] 'When on, /api/ab/personal returns 404 to force legacy single-blob AB' @@ -58,6 +60,8 @@ fn main() -> ResultType<()> { &get_arg_or("key", "-".to_owned()), rmem, http_port, + &get_arg("ws-listen"), + &get_arg("http-listen"), )?; Ok(()) } diff --git a/src/relay_server.rs b/src/relay_server.rs index 0ec190a..3557039 100644 --- a/src/relay_server.rs +++ b/src/relay_server.rs @@ -46,7 +46,7 @@ const BLACKLIST_FILE: &str = "blacklist.txt"; const BLOCKLIST_FILE: &str = "blocklist.txt"; #[tokio::main(flavor = "multi_thread")] -pub async fn start(port: &str, key: &str) -> ResultType<()> { +pub async fn start(port: &str, key: &str, ws_listen: &str) -> ResultType<()> { let key = get_server_sk(key); if let Ok(mut file) = std::fs::File::open(BLACKLIST_FILE) { let mut contents = String::new(); @@ -82,10 +82,21 @@ pub async fn start(port: &str, key: &str) -> ResultType<()> { log::info!("Listening on tcp :{}", port); let port2 = port + 2; log::info!("Listening on websocket :{}", port2); + // The WS port (21119 default) is the only browser-facing endpoint at + // hbbr — operators put nginx/Caddy in front of it for TLS. Allow + // pinning it to localhost so the reverse proxy can claim the public + // port without colliding. The plain TCP relay port (21117) is for + // desktop clients and stays on the wildcard. + let ws_listen = ws_listen.to_owned(); let main_task = async move { loop { log::info!("Start"); - io_loop(listen_any(port).await?, listen_any(port2).await?, &key).await; + io_loop( + listen_any(port).await?, + crate::common::bind_tcp_listener(&ws_listen, port2 as i32).await?, + &key, + ) + .await; } }; let listen_signal = crate::common::listen_signal(); diff --git a/src/rendezvous_server.rs b/src/rendezvous_server.rs index ef53d58..46626c5 100644 --- a/src/rendezvous_server.rs +++ b/src/rendezvous_server.rs @@ -110,10 +110,16 @@ impl RendezvousServer { key: &str, rmem: usize, http_port: i32, + ws_listen: &str, + http_listen: &str, ) -> ResultType<()> { let (key, sk) = Self::get_server_sk(key); let nat_port = port - 1; let ws_port = port + 2; + // Capture the bind addresses as owned Strings so the async move + // closures below can hold onto them across reconnect retries. + let ws_listen = ws_listen.to_owned(); + let http_listen = http_listen.to_owned(); let pm = PeerMap::new().await?; // M1: build the HTTP API state and seed the admin user if requested. // Done here (right after PeerMap::new) so the API server, the seeding, @@ -199,7 +205,11 @@ impl RendezvousServer { rs.parse_relay_servers(&get_arg("relay-servers")); let mut listener = create_tcp_listener(port).await?; let mut listener2 = create_tcp_listener(nat_port).await?; - let mut listener3 = create_tcp_listener(ws_port).await?; + // The WS port is the only browser-facing endpoint at hbbs — it's + // the one operators put nginx/Caddy in front of for TLS. Allow + // pinning it to localhost so the reverse proxy can claim + // `[::]:21118` without colliding. + let mut listener3 = crate::common::bind_tcp_listener(&ws_listen, ws_port).await?; let test_addr = std::env::var("TEST_HBBS").unwrap_or_default(); if std::env::var("ALWAYS_USE_RELAY") .unwrap_or_default() @@ -266,7 +276,7 @@ impl RendezvousServer { } LoopFailure::Listener3 => { drop(listener3); - listener3 = create_tcp_listener(ws_port).await?; + listener3 = crate::common::bind_tcp_listener(&ws_listen, ws_port).await?; } } } @@ -278,7 +288,15 @@ impl RendezvousServer { let api_task: std::pin::Pin< Box> + Send>, > = if http_port > 0 { - let addr: SocketAddr = format!("0.0.0.0:{http_port}").parse()?; + let bind_host = if http_listen.is_empty() { "0.0.0.0" } else { http_listen.as_str() }; + // Allow IPv6 / [::1] / hostnames — wrap bare IPv6 in brackets for the URL form. + let host_with_brackets = if bind_host.contains(':') && !bind_host.starts_with('[') { + format!("[{}]", bind_host) + } else { + bind_host.to_string() + }; + let addr: SocketAddr = format!("{}:{}", host_with_brackets, http_port).parse()?; + log::info!("HTTP API listening on {}", addr); let st = api_state.clone(); Box::pin(crate::api::serve(addr, st)) } else {