feat(deploy): bind-address flags for browser-facing ports + nginx docs

By default hbbs and hbbr bind every port to the wildcard, which collides
with operators wanting to put nginx/Caddy in front of the dashboard
(443) and the two browser-facing WebSocket ports (21118 rendezvous,
21119 relay) for TLS termination. Operators reported having to choose
between exposing hbbs directly (no TLS for `wss://`, breaks browsers
since the page is HTTPS) or moving the daemon to a different port.

New flags:
- hbbs `--http-listen=<HOST>` pins the HTTP API + dashboard port.
- hbbs `--ws-listen=<HOST>`   pins the WS rendezvous port (port + 2).
- hbbr `--ws-listen=<HOST>`   pins the WS relay port (port + 2).

All default to the wildcard (current behaviour). Set to `127.0.0.1` to
free up the corresponding public port for nginx.

The plain TCP/UDP ports used by desktop clients (21115 NAT test, 21116
rendezvous, 21117 relay) intentionally stay on the wildcard — desktop
clients bring their own framing + secretbox encryption and don't go
through nginx.

Implementation: a small `bind_tcp_listener(host, port)` helper in
common.rs that falls through to the existing `listen_any` when host is
empty, otherwise binds explicitly. Reused for both ws_port (rendezvous +
relay) and the http_port; the latter just builds a `SocketAddr` inline
since axum::serve takes one.

Documentation: new "TLS deployment with nginx" section in
docs/CONFIGURATION.md covering the port plan, the bind flags, full
example nginx vhost config (three server blocks: 443 dashboard,
21118 WSS rendezvous, 21119 WSS relay) with the WebSocket Upgrade
plumbing and bump-up timeouts that long sessions need, plus the
firewall list and the four common failure modes (SSL protocol error,
connection refused, 502, hung 200 instead of 101).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 19:43:20 +02:00
parent 4ccfe7a0e6
commit aa40784dc6
6 changed files with 218 additions and 7 deletions
+155 -1
View File
@@ -355,9 +355,163 @@ unconditionally. **Direct peer-to-peer is never attempted.**
### Network requirements
- The **relay host** advertised to clients (`--relay-servers=<HOSTS>` 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://<relay-host>: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 |