| 1 | # snowbound-relay |
| 2 | |
| 3 | The relay Live Share meets through when two Snowbounds aren't on one network. Peers join a |
| 4 | room named by a tag (a hash of the notebook's secret, or a code's number), and the relay |
| 5 | passes their messages between them, or copies one to everyone in the room (presence, a |
| 6 | host's news of its files) so a sender with thirty peers sends it once. Every message after |
| 7 | the opening is sealed end to end and numbered inside the seal, so the relay can't read, |
| 8 | alter, drop or reorder one unseen; it learns who talks to whom, when, and how much. |
| 9 | `src/lib.rs` describes the protocol. |
| 10 | |
| 11 | One static Linux executable with no configuration file and nothing on disk. It speaks plain |
| 12 | HTTP and WebSocket; a proxy in front of it terminates TLS. |
| 13 | |
| 14 | ## Build |
| 15 | |
| 16 | ```sh |
| 17 | python3 tools/release_relay.py # target/relay/snowbound-{relay,site}-linux-{x86_64,aarch64} |
| 18 | ``` |
| 19 | |
| 20 | It links with Rust's own lld against Rust's own musl, so it needs only `rustup`. The folder |
| 21 | it makes holds the executables, `SHA256SUMS`, this README and both systemd units. |
| 22 | |
| 23 | ## Deploy |
| 24 | |
| 25 | ```sh |
| 26 | scp target/relay/snowbound-relay-linux-x86_64 vps:/tmp/snowbound-relay |
| 27 | scp target/relay/snowbound-relay.service vps:/tmp/ |
| 28 | ssh vps |
| 29 | sudo install -m 755 /tmp/snowbound-relay /usr/local/bin/snowbound-relay |
| 30 | sudo install -m 644 /tmp/snowbound-relay.service /etc/systemd/system/ |
| 31 | sudo systemctl daemon-reload |
| 32 | sudo systemctl enable --now snowbound-relay |
| 33 | curl -s http://127.0.0.1:23592/health # {"rooms":0,"peers":0,"connections":1,"seconds":3,"bytes_in":0,"bytes_out":0} |
| 34 | ``` |
| 35 | |
| 36 | Then point a name at the server and put a TLS proxy in front. Caddy fetches its own |
| 37 | certificate and passes WebSocket upgrades through as they are: |
| 38 | |
| 39 | ```text |
| 40 | live.example.net { |
| 41 | reverse_proxy 127.0.0.1:23592 |
| 42 | } |
| 43 | ``` |
| 44 | |
| 45 | nginx, with a certificate from certbot: |
| 46 | |
| 47 | ```nginx |
| 48 | server { |
| 49 | listen 443 ssl; |
| 50 | server_name live.example.net; |
| 51 | ssl_certificate /etc/letsencrypt/live/live.example.net/fullchain.pem; |
| 52 | ssl_certificate_key /etc/letsencrypt/live/live.example.net/privkey.pem; |
| 53 | location / { |
| 54 | proxy_pass http://127.0.0.1:23592; |
| 55 | proxy_http_version 1.1; |
| 56 | proxy_set_header Upgrade $http_upgrade; |
| 57 | proxy_set_header Connection "upgrade"; |
| 58 | # Replaced, not appended to, so a client can't name its own address. |
| 59 | proxy_set_header X-Forwarded-For $remote_addr; |
| 60 | proxy_read_timeout 1h; |
| 61 | } |
| 62 | } |
| 63 | ``` |
| 64 | |
| 65 | The app uses `wss://relay.snowbound.paperclover.net` unless Options ▸ Sync & Storage ▸ Live |
| 66 | Share names another relay. |
| 67 | |
| 68 | `--trust-forwarded true`, as the unit sets it, counts each peer by the last |
| 69 | `X-Forwarded-For` entry, the one the proxy added. Without a proxy, leave it off: a client |
| 70 | could otherwise claim any address. Listening on a public address without TLS works but lets |
| 71 | anyone on the path see room tags and nameplates. |
| 72 | |
| 73 | ## Where WebSockets don't get through |
| 74 | |
| 75 | Some networks' proxies refuse WebSockets. A peer then joins with `?poll=1` on the same path, |
| 76 | and the relay answers `session <id>`; `GET /v1/poll/<id>` then waits up to 25 seconds for what |
| 77 | is to go to it, and `POST /v1/poll/<id>` brings what it sends, each body a run of WebSocket |
| 78 | frames as the socket would carry them, so the room sees no difference. A session that asks |
| 79 | nothing for a minute leaves. Through Caddy or nginx this needs nothing more than the |
| 80 | WebSocket's own proxying; keep nginx's `proxy_read_timeout` above 25 seconds. |
| 81 | |
| 82 | ## The site: snowbound.paperclover.net |
| 83 | |
| 84 | `snowbound-site` serves the hosted web build's folder, and for a path that is a Live Share |
| 85 | code (`/7KQ-4MZ-9XR`, read as loosely as the app reads one) a page that opens it with |
| 86 | `snowbound://join/<code>`, and in the web build where `--web` names where (once the web build |
| 87 | joins shares). Anything else under the folder is a file; `/` is its `index.html`. A build's |
| 88 | module and JavaScript sit in `b/<hash>/` and are cached for good; `index.html` and the |
| 89 | codes' pages are checked on every load, and the rest (fonts, dictionaries) for a day. |
| 90 | |
| 91 | `POST /crash` takes a crash report the app sends when its user chooses Send Report: plain |
| 92 | text or JSON up to 64 KB, ten an hour from one address and 500 an hour in all, each kept as |
| 93 | a file named for when it came (`2026-10-03T21-04-05Z-1a2b3c4d.txt`) in `--crashes`, by |
| 94 | default `crashes` beside the root. Nothing else is kept, and nothing sent comes back. Behind |
| 95 | a proxy, `--trust-forwarded true` counts senders by the address it adds, as the relay does. |
| 96 | |
| 97 | `python3 tools/release_web.py --deploy` builds the web app and the site for the VPS's |
| 98 | architecture, copies the build into `~/snowbound-web/site/` (keeping the last three builds' |
| 99 | folders for pages still running them) and the binary to `~/snowbound-web/snowbound-site`, |
| 100 | and restarts pm2's `snowbound-site` only when the binary changed. The first time: |
| 101 | |
| 102 | ```sh |
| 103 | ssh vps |
| 104 | mkdir -p ~/snowbound-web/site |
| 105 | # after the first `release_web.py --deploy` has put the binary there: |
| 106 | pm2 start ~/snowbound-web/snowbound-site --name snowbound-site -- \ |
| 107 | --listen 127.0.0.1:23593 --root "$HOME/snowbound-web/site" --trust-forwarded true |
| 108 | pm2 save |
| 109 | ``` |
| 110 | |
| 111 | Caddy in front: |
| 112 | |
| 113 | ```text |
| 114 | snowbound.paperclover.net { |
| 115 | encode gzip |
| 116 | reverse_proxy 127.0.0.1:23593 |
| 117 | } |
| 118 | ``` |
| 119 | |
| 120 | `snowbound-site.service` runs it under systemd instead (`--root /srv/snowbound/web`, the |
| 121 | reports in `/var/lib/snowbound-site`). |
| 122 | `snowbound-site --help` lists the options, each also `SNOWBOUND_SITE_<OPTION>`. |
| 123 | |
| 124 | ## Options |
| 125 | |
| 126 | `snowbound-relay --help` lists every option and its default. A room holds 64 peers and |
| 127 | admits 120 a minute, enough for a class joining at once; its bytes per second count what |
| 128 | the relay gives out, so a copy to thirty peers costs thirty times its size. Each can also be set in the |
| 129 | unit's environment as `SNOWBOUND_RELAY_<OPTION>` (`SNOWBOUND_RELAY_MAX_ROOMS=64`). |
| 130 | |
| 131 | Memory stays under about `max-connections × (max-message + queue)` plus two small thread |
| 132 | stacks per connection: some 320 MiB at the defaults, and a few MB in use by a handful of |
| 133 | people. The unit caps it at 512 MiB. |
| 134 | |
| 135 | ## Abuse |
| 136 | |
| 137 | - **Wrong codes.** A peer joining a code's room hears only the room's owner until the owner |
| 138 | tells the relay it met it (`met`), so the relay never needs the code. A peer the owner |
| 139 | says `failed`, one that leaves first, and one silent past `--pending` each count a wrong |
| 140 | code against its address (an IPv4 address or an IPv6 /64) and against the code. Ten in a |
| 141 | minute lock the address out for a minute, then two, four, up to an hour, forgotten after |
| 142 | a quiet hour; tries still waiting count toward the ten, so they can't run side by side. |
| 143 | Five burn the code: it admits no one new, and the owner makes another. |
| 144 | - **Load.** Joins per address and per room are rate limited; connections in all, per |
| 145 | address, rooms, and peers per room are capped; a room's bytes per second are throttled by |
| 146 | slowing its senders; a message over `--max-message` or a peer too slow to take what waits |
| 147 | for it (`--queue`) is hung up on; a silent connection closes after `--idle`. |
| 148 | |
| 149 | The relay logs lockouts and burned codes to stderr (`journalctl -u snowbound-relay`), and |
| 150 | nothing else. |