1# snowbound-relay
2
3The relay Live Share meets through when two Snowbounds aren't on one network. Peers join a
4room named by a tag (a hash of the notebook's secret, or a code's number), and the relay
5passes their messages between them, or copies one to everyone in the room (presence, a
6host's news of its files) so a sender with thirty peers sends it once. Every message after
7the opening is sealed end to end and numbered inside the seal, so the relay can't read,
8alter, drop or reorder one unseen; it learns who talks to whom, when, and how much.
9`src/lib.rs` describes the protocol.
10
11One static Linux executable with no configuration file and nothing on disk. It speaks plain
12HTTP and WebSocket; a proxy in front of it terminates TLS.
13
14## Build
15
16```sh
17python3 tools/release_relay.py # target/relay/snowbound-{relay,site}-linux-{x86_64,aarch64}
18```
19
20It links with Rust's own lld against Rust's own musl, so it needs only `rustup`. The folder
21it makes holds the executables, `SHA256SUMS`, this README and both systemd units.
22
23## Deploy
24
25```sh
26scp target/relay/snowbound-relay-linux-x86_64 vps:/tmp/snowbound-relay
27scp target/relay/snowbound-relay.service vps:/tmp/
28ssh vps
29sudo install -m 755 /tmp/snowbound-relay /usr/local/bin/snowbound-relay
30sudo install -m 644 /tmp/snowbound-relay.service /etc/systemd/system/
31sudo systemctl daemon-reload
32sudo systemctl enable --now snowbound-relay
33curl -s http://127.0.0.1:23592/health # {"rooms":0,"peers":0,"connections":1,"seconds":3,"bytes_in":0,"bytes_out":0}
34```
35
36Then point a name at the server and put a TLS proxy in front. Caddy fetches its own
37certificate and passes WebSocket upgrades through as they are:
38
39```text
40live.example.net {
41 reverse_proxy 127.0.0.1:23592
42}
43```
44
45nginx, with a certificate from certbot:
46
47```nginx
48server {
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
65The app uses `wss://relay.snowbound.paperclover.net` unless Options ▸ Sync & Storage ▸ Live
66Share 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
70could otherwise claim any address. Listening on a public address without TLS works but lets
71anyone on the path see room tags and nameplates.
72
73## Where WebSockets don't get through
74
75Some networks' proxies refuse WebSockets. A peer then joins with `?poll=1` on the same path,
76and the relay answers `session <id>`; `GET /v1/poll/<id>` then waits up to 25 seconds for what
77is to go to it, and `POST /v1/poll/<id>` brings what it sends, each body a run of WebSocket
78frames as the socket would carry them, so the room sees no difference. A session that asks
79nothing for a minute leaves. Through Caddy or nginx this needs nothing more than the
80WebSocket'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
85code (`/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
87joins shares). Anything else under the folder is a file; `/` is its `index.html`. A build's
88module and JavaScript sit in `b/<hash>/` and are cached for good; `index.html` and the
89codes' 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
92text or JSON up to 64 KB, ten an hour from one address and 500 an hour in all, each kept as
93a file named for when it came (`2026-10-03T21-04-05Z-1a2b3c4d.txt`) in `--crashes`, by
94default `crashes` beside the root. Nothing else is kept, and nothing sent comes back. Behind
95a 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
98architecture, copies the build into `~/snowbound-web/site/` (keeping the last three builds'
99folders for pages still running them) and the binary to `~/snowbound-web/snowbound-site`,
100and restarts pm2's `snowbound-site` only when the binary changed. The first time:
101
102```sh
103ssh vps
104mkdir -p ~/snowbound-web/site
105# after the first `release_web.py --deploy` has put the binary there:
106pm2 start ~/snowbound-web/snowbound-site --name snowbound-site -- \
107 --listen 127.0.0.1:23593 --root "$HOME/snowbound-web/site" --trust-forwarded true
108pm2 save
109```
110
111Caddy in front:
112
113```text
114snowbound.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
121reports 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
127admits 120 a minute, enough for a class joining at once; its bytes per second count what
128the relay gives out, so a copy to thirty peers costs thirty times its size. Each can also be set in the
129unit's environment as `SNOWBOUND_RELAY_<OPTION>` (`SNOWBOUND_RELAY_MAX_ROOMS=64`).
130
131Memory stays under about `max-connections × (max-message + queue)` plus two small thread
132stacks per connection: some 320 MiB at the defaults, and a few MB in use by a handful of
133people. 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
149The relay logs lockouts and burned codes to stderr (`journalctl -u snowbound-relay`), and
150nothing else.