From d248675643cb4da30498701d69a54a4629eade1b Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Sun, 18 Oct 2026 21:35:52 +0530 Subject: [PATCH] Add the RustDesk server playbook --- rustdesk/playbook.yml | 14 +++ rustdesk/readme.md | 116 ++++++++++++++++++++++++ rustdesk/roles/caddy/handlers/main.yaml | 5 + rustdesk/roles/caddy/tasks/main.yaml | 22 +++++ rustdesk/roles/network/tasks/main.yaml | 10 ++ rustdesk/roles/rustdesk/tasks/main.yaml | 50 ++++++++++ rustdesk/vars/default.yml | 56 ++++++++++++ 7 files changed, 273 insertions(+) create mode 100644 rustdesk/playbook.yml create mode 100644 rustdesk/readme.md create mode 100644 rustdesk/roles/caddy/handlers/main.yaml create mode 100644 rustdesk/roles/caddy/tasks/main.yaml create mode 100644 rustdesk/roles/network/tasks/main.yaml create mode 100644 rustdesk/roles/rustdesk/tasks/main.yaml create mode 100644 rustdesk/vars/default.yml diff --git a/rustdesk/playbook.yml b/rustdesk/playbook.yml new file mode 100644 index 0000000..77166b9 --- /dev/null +++ b/rustdesk/playbook.yml @@ -0,0 +1,14 @@ +################################################# +# RustDesk Server Playbook +################################################# +--- +- name: Deploy the RustDesk ID/relay server behind Caddy + hosts: monoco + become: true + vars_files: + - vars/default.yml + + roles: + - network + - rustdesk + - caddy diff --git a/rustdesk/readme.md b/rustdesk/readme.md new file mode 100644 index 0000000..9476acf --- /dev/null +++ b/rustdesk/readme.md @@ -0,0 +1,116 @@ +# RustDesk Server on Docker, behind Caddy + +Deploys the self-hosted [RustDesk](https://rustdesk.com) server (OSS) on `monoco`: the +ID/rendezvous server `hbbs` and the relay server `hbbr`, both supervised by s6 inside the +official `rustdesk/rustdesk-server-s6` container, reachable only through Caddy on +`rust.tanshu.com:443`. No RustDesk port is published on the host. + +## How the traffic is carried + +RustDesk's native transports on 21115/21116/21117 are a custom TCP/UDP protocol, not HTTP, +so an HTTP reverse proxy cannot carry them. What *is* HTTP is the WebSocket transport that +`hbbs` and `hbbr` also listen on (21118 and 21119), and clients that enable WebSocket use +it for both rendezvous and relay. Caddy terminates TLS on 443 and forwards two paths into +the container over the Docker network: + +| Path | Upstream | Purpose | +| --- | --- | --- | +| `/ws/id` | `rustdesk:21118` (hbbs) | ID registration and signalling | +| `/ws/relay` | `rustdesk:21119` (hbbr) | Relayed sessions | + +The code that decides this is in the client: `hbb_common::websocket::check_ws` maps a +domain `host:21116` to `wss://host/ws/id` and `host:21117` to `wss://host/ws/relay`, and +the path names are fixed there. Caddy sets `X-Real-IP`, which `hbbs`/`hbbr` trust to keep +the real client address for rate limiting and logs (upstream warns that these ports must +therefore never be exposed directly). + +## Settings + +- `host`: the public name, `rust.tanshu.com`. Both the Caddy site and the address `hbbs` + advertises come from it. +- `docker_container`, `image_name`, `image_version`: container name and the + `rustdesk/rustdesk-server-s6` tag to run. +- `data_location`: host directory bind-mounted to `/data` (`/var/lib/rustdesk/data`), + holding the SQLite database and the server key pair. +- `rustdesk_relay_server`: handed to clients by `hbbs` as the relay address. It keeps the + public name, so clients rewrite it to `wss://rust.tanshu.com/ws/relay`. +- `rustdesk_publish_raw_ports` / `rustdesk_raw_ports`: publish 21115/21116/21117 as well. + Off by default, see "Clients that cannot use WebSocket" below. +- `rustdesk_encrypted_only`: `ENCRYPTED_ONLY=1`, so `hbbr` validates the server key too + instead of accepting unkeyed clients. +- `rustdesk_always_use_relay`: `ALWAYS_USE_RELAY=Y`, forcing relayed sessions. WebSocket + clients are relay-only anyway, so this only affects clients on the raw ports. + +## Prerequisites + +- A DNS `A` record for `rust.tanshu.com` pointing at `monoco`; Caddy issues the certificate + over ports 80/443. +- The Caddy container already running on `monoco` (`caddy/playbook.yml`), with its Caddyfile + at `/var/lib/caddy/conf/Caddyfile`. + +## Running this Playbook + +The inventory in `/etc/ansible/hosts` already lists `monoco`, which is what `hosts:` +targets: + +```command +ansible-playbook playbook.yml +``` + +The playbook creates the `rustdesk_net` network, attaches Caddy to it, starts the +container, appends the site block to the Caddyfile, reloads Caddy, and prints the public +key. + +## Configuring the clients + +On each client, open **Settings → Network → ID/Relay Server** and set: + +| Field | Value | +| --- | --- | +| ID Server | `rust.tanshu.com` | +| API Server | `https://rust.tanshu.com` | +| Key | the printed value, i.e. `/var/lib/rustdesk/data/id_ed25519.pub` on the host | + +then enable **Settings → Network → Use WebSocket** (`allow-websocket=Y`). + +The **API Server** field looks odd for an OSS deployment, which has no API, but it is what +selects the TLS scheme: the client uses `wss://` only when the API server starts with +`https`, and otherwise silently falls back to plaintext `ws://rust.tanshu.com/...` on port +80. Because the client also skips its own client↔server key exchange whenever WebSocket is +enabled — on the assumption that the transport is already encrypted — that fallback would +leave the connection unprotected rather than merely failing. + +## Trade-offs + +- **Relayed sessions only.** With WebSocket enabled the client never hole-punches + (`force_relay` is set, and `hbbs` skips the same-intranet path for WebSocket peers), so + every session flows through `monoco`. Remote control works, but throughput is bounded by + the server, and latency is higher than a direct connection. +- **Upstream labels this a Pro feature.** The client's own tooltip says "RustDesk server + OSS doesn't include this feature", even though the OSS binaries do listen on 21118/21119 + and the OSS docs describe proxying them. Treat the WSS path as needing a real test: if + clients fail to register, the fallback is the raw ports below. +- **The address has to be the domain.** `check_ws` only builds a TLS URL and the `/ws` + paths for hostnames; an IP would become `ws://ip:21118` and bypass Caddy entirely. + +### Clients that cannot use WebSocket + +Clients without the WebSocket option (older builds, third-party clients, or anything that +must connect directly) need the native ports. Set `rustdesk_publish_raw_ports: true` and +re-run the playbook, then open 21115-21116 (TCP) plus 21116/udp and 21117 (TCP) on the host +and in any cloud firewall: + +```command +ufw allow 21115:21117/tcp +ufw allow 21116/udp +``` + +WebSocket clients keep working at the same time; they ignore the raw ports. + +## Notes + +- **Logs.** `docker logs rustdesk` covers both services. +- **Server key.** If `/var/lib/rustdesk/data/id_ed25519` is lost, every client has to be + reconfigured with the new public key. Back it up. +- **Upgrading.** Re-running the playbook pulls the tag in `image_version` and recreates the + container; the key pair and database live in `data_location` and survive. diff --git a/rustdesk/roles/caddy/handlers/main.yaml b/rustdesk/roles/caddy/handlers/main.yaml new file mode 100644 index 0000000..bc77d4f --- /dev/null +++ b/rustdesk/roles/caddy/handlers/main.yaml @@ -0,0 +1,5 @@ +--- +# Handlers for Caddy role +- name: Reload Caddy configuration + ansible.builtin.command: "docker exec -w /etc/caddy {{ caddy_container }} caddy reload" + listen: "Reload Caddy" diff --git a/rustdesk/roles/caddy/tasks/main.yaml b/rustdesk/roles/caddy/tasks/main.yaml new file mode 100644 index 0000000..2f514e4 --- /dev/null +++ b/rustdesk/roles/caddy/tasks/main.yaml @@ -0,0 +1,22 @@ +--- +- name: Read current Caddyfile + ansible.builtin.slurp: + path: "{{ caddyfile_path }}" + register: caddyfile_raw + +- name: Decode Caddyfile content + set_fact: + caddyfile_content: "{{ caddyfile_raw['content'] | b64decode }}" + +- name: Check if snippet already exists + set_fact: + snippet_present: "{{ snippet_block in caddyfile_content }}" + +- name: Add snippet if missing + ansible.builtin.blockinfile: + path: "{{ caddyfile_path }}" + marker: "# {mark} Ansible managed Caddy snippet for {{ host }}" + block: "{{ snippet_block }}" + create: yes + when: not snippet_present + notify: "Reload Caddy" diff --git a/rustdesk/roles/network/tasks/main.yaml b/rustdesk/roles/network/tasks/main.yaml new file mode 100644 index 0000000..62e3619 --- /dev/null +++ b/rustdesk/roles/network/tasks/main.yaml @@ -0,0 +1,10 @@ +--- +# Caddy is the only thing that talks to hbbs/hbbr, so it joins the network the +# RustDesk container lives on and can resolve it by container name. +- name: "Ensure {{ docker_network }} Docker network exists" + docker_network: + name: "{{ docker_network }}" + state: present + connected: + - "{{ caddy_container }}" + appends: yes diff --git a/rustdesk/roles/rustdesk/tasks/main.yaml b/rustdesk/roles/rustdesk/tasks/main.yaml new file mode 100644 index 0000000..9cb0c48 --- /dev/null +++ b/rustdesk/roles/rustdesk/tasks/main.yaml @@ -0,0 +1,50 @@ +--- +# Tasks for the RustDesk server role +- name: Pull RustDesk server image + docker_image: + name: "{{ image_name }}:{{ image_version }}" + source: pull + force_source: yes + +- name: Ensure the RustDesk data directory exists + file: + path: "{{ data_location }}" + state: directory + mode: 0755 + +# One container runs both hbbs and hbbr under s6. Its WebSocket ports are left +# unpublished: Caddy reaches them over the shared Docker network, so clients +# only ever connect to 443. +- name: Create the RustDesk server container + docker_container: + name: "{{ docker_container }}" + image: "{{ image_name }}:{{ image_version }}" + state: started + restart_policy: "unless-stopped" + env: "{{ rustdesk_env }}" + published_ports: "{{ rustdesk_published_ports }}" + volumes: + - "{{ data_location }}:/data" + networks: + - name: "{{ docker_network }}" + +- name: Wait for hbbs to write out its key pair + ansible.builtin.wait_for: + path: "{{ data_location }}/id_ed25519.pub" + timeout: 60 + register: rustdesk_key_file + failed_when: false + +- name: Read the public key clients have to be configured with + ansible.builtin.slurp: + src: "{{ data_location }}/id_ed25519.pub" + register: rustdesk_public_key + when: rustdesk_key_file is succeeded + +- name: Show the connection details + ansible.builtin.debug: + msg: >- + RustDesk clients: ID Server {{ host }}, API Server https://{{ host }}, + enable "Use WebSocket", and use + {{ rustdesk_public_key['content'] | b64decode | trim }} as the key. + when: rustdesk_public_key is not skipped diff --git a/rustdesk/vars/default.yml b/rustdesk/vars/default.yml new file mode 100644 index 0000000..d48ba89 --- /dev/null +++ b/rustdesk/vars/default.yml @@ -0,0 +1,56 @@ +--- +docker_container: rustdesk +image_name: "rustdesk/rustdesk-server-s6" +image_version: "latest" + +# Public name clients use, and the name Caddy serves. hbbs hands this out as the +# relay address, and a WebSocket client rewrites a hostname to +# wss:///ws/relay (an IP would get neither TLS nor the path). +host: rust.tanshu.com +rustdesk_relay_server: "{{ host }}:21117" + +host_directory: rustdesk +data_location: "/var/lib/{{ host_directory }}/data" + +docker_network: "{{ docker_container }}_net" +caddy_container: caddy + +# hbbs/hbbr also speak their own TCP/UDP protocol on 21115-21117, which is not +# HTTP and so cannot go through Caddy. Leave this off to keep the host's only +# open ports at 80/443: clients then have to enable WebSocket, and every session +# is relayed. Turn it on for clients that do not use WebSocket. +rustdesk_publish_raw_ports: false +rustdesk_raw_ports: + - "21115:21115" + - "21116:21116" + - "21116:21116/udp" + - "21117:21117" + +rustdesk_published_ports: "{{ rustdesk_raw_ports if rustdesk_publish_raw_ports | bool else [] }}" + +# hbbs writes id_ed25519/id_ed25519.pub under /data on first start. Clients are +# configured with the public key, and ENCRYPTED_ONLY makes hbbr check it too. +rustdesk_encrypted_only: true + +# "Y" pushes every session through the relay. WebSocket clients are relay-only +# regardless, so this only matters for clients on the raw ports. +rustdesk_always_use_relay: false + +rustdesk_env: + RELAY: "{{ rustdesk_relay_server }}" + ENCRYPTED_ONLY: "{{ '1' if rustdesk_encrypted_only | bool else '0' }}" + ALWAYS_USE_RELAY: "{{ 'Y' if rustdesk_always_use_relay | bool else 'N' }}" + +# Caddy terminates TLS on 443 and carries the WebSocket transports: 21118 is +# hbbs's WebSocket listener, 21119 hbbr's. Neither is published on the host. +caddyfile_path: /var/lib/caddy/conf/Caddyfile +snippet_block: | + {{ host }} { + reverse_proxy /ws/id* {{ docker_container }}:21118 { + header_up X-Real-IP {remote_host} + } + + reverse_proxy /ws/relay* {{ docker_container }}:21119 { + header_up X-Real-IP {remote_host} + } + }