@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +1309 -0
  2. package/LICENSE +21 -0
  3. package/PROTOCOL-v0.1.2.md +165 -0
  4. package/PROTOCOL-v0.3.0.md +454 -0
  5. package/PROTOCOL-v0.3.9.md +207 -0
  6. package/PROTOCOL-v0.4.0.md +255 -0
  7. package/PROTOCOL.md +339 -0
  8. package/README.md +665 -2
  9. package/bin/w2m-localside.mjs +303 -0
  10. package/bin/w2m-rabbit.mjs +396 -0
  11. package/bin/w2m-stun.mjs +231 -0
  12. package/cordis.patch.yml +94 -0
  13. package/deploy/caddy/Caddyfile.example +118 -0
  14. package/deploy/docker/Dockerfile +144 -0
  15. package/deploy/docker/README.md +338 -0
  16. package/deploy/docker/docker-compose.yml +179 -0
  17. package/deploy/nginx/w2m.conf.example +196 -0
  18. package/deploy/shared-server/README.md +118 -0
  19. package/deploy/shared-server/install.sh +255 -0
  20. package/deploy/systemd/w2m-rabbit.service +150 -0
  21. package/deploy/systemd/w2m-stun.service +69 -0
  22. package/deploy/windows/README.md +115 -0
  23. package/deploy/windows/install-service.ps1 +195 -0
  24. package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
  25. package/deploy/windows/winsw/w2m-localside.xml +50 -0
  26. package/docs/DEPLOY.md +902 -0
  27. package/docs/LINT-AUDIT.md +492 -0
  28. package/docs/PIPELINE-DESIGN.md +95 -0
  29. package/docs/TROUBLESHOOTING.md +560 -0
  30. package/docs/UI-CARDS.md +81 -0
  31. package/lib/index.js +24 -0
  32. package/lib/tools.js +11 -0
  33. package/package.json +133 -4
  34. package/src/agent/agent.mjs +3317 -0
  35. package/src/agent/allowed-commands.mjs +116 -0
  36. package/src/agent/caps.mjs +437 -0
  37. package/src/agent/exec.mjs +356 -0
  38. package/src/agent/git.mjs +616 -0
  39. package/src/agent/identity.mjs +253 -0
  40. package/src/agent/p2p-node.mjs +1243 -0
  41. package/src/agent/p2p.mjs +605 -0
  42. package/src/agent/resolve.mjs +148 -0
  43. package/src/agent/spool.mjs +252 -0
  44. package/src/agent/stun.mjs +397 -0
  45. package/src/agent/url.mjs +127 -0
  46. package/src/plugin/auto-update.mjs +574 -0
  47. package/src/plugin/schedule.mjs +452 -0
  48. package/src/plugin/shared-config.mjs +264 -0
  49. package/src/plugin/tools.mjs +3891 -0
  50. package/src/plugin/update-install.mjs +764 -0
  51. package/src/plugin/update-source.mjs +686 -0
  52. package/src/relay/metrics.mjs +284 -0
  53. package/src/relay/peers.mjs +180 -0
  54. package/src/relay/persistence.mjs +207 -0
  55. package/src/relay/report.mjs +591 -0
  56. package/src/relay/server.mjs +1187 -0
  57. package/src/relay/state.mjs +2004 -0
  58. package/src/relay/stun-server.mjs +733 -0
  59. package/src/signing.mjs +359 -0
  60. package/src/util/cli.mjs +70 -0
package/docs/DEPLOY.md ADDED
@@ -0,0 +1,902 @@
1
+ # Deploying the W2M relay across regions and networks
2
+
3
+ > Version covered: **0.1.2** (protocol version stays **1**; 0.1.2 is a
4
+ > backwards-compatible addition — see `PROTOCOL-v0.1.2.md`).
5
+ >
6
+ > This document is the operational companion to the protocol spec. The spec
7
+ > says what the wire looks like; this says what to type.
8
+ >
9
+ > **Reading order**: §1 chooses your deployment shape. §2–§4 are the three
10
+ > shapes, each end-to-end. §5 is the `rabbitUrl` reference table. §6 covers
11
+ > SSE and reverse proxies. §7 is what was actually verified on the authoring
12
+ > machine, and what was not. §8 is the current implementation status.
13
+
14
+ ---
15
+
16
+ ## 0. The four shapes at a glance
17
+
18
+ | | **0. The shared server** (v0.4.0 default) | A. Tailscale / WireGuard | B. Public VPS + domain + TLS | C. Tunnel (Cloudflare / ngrok) |
19
+ |---|---|---|---|---|
20
+ | **TLS terminated by** | nothing — plaintext, stated up front | the network (WireGuard) | the relay (`--tls-cert`) **or** a proxy | the tunnel service |
21
+ | **Relay listens on** | `0.0.0.0:8787`, plus `:80` through nginx | the tailnet address, e.g. `100.x.y.z:8787` | `127.0.0.1:8787` (proxy) or `0.0.0.0:8443` (self-TLS) | `127.0.0.1:8787` |
22
+ | **Client `rabbitUrl`** | `http://202.182.123.154:8787` (already the default) | `http://100.x.y.z:8787` | `https://w2m.<your-domain>` | `https://<random>.example.com` or `https://<host>/w2m` |
23
+ | **Plaintext HTTP on the wire?** | **yes** — see §10 | **yes, and that is correct** — see §2.3 | no | no |
24
+ | **Certificate to manage** | none (and none to leak) | none | yes (Let's Encrypt or self-signed) | none (the tunnel service has one) |
25
+ | **Firewall opens** | 8787/tcp, 3478/udp (+80 for the proxy) | nothing (tailnet only) | 443 only | nothing inbound |
26
+ | **Best for** | getting two machines talking today, and as a fallback rendezvous | machines you own, private mesh | a stable public endpoint you control | quick access, no public IP, no DNS |
27
+ | **Main risk** | tokens and results are readable on the path; the allow-list is the load-bearing control | none added — but you must not bind `0.0.0.0` by mistake | a public hostname, so pairing/rate limits are your only fence | free tiers reconnect and rotate hostnames |
28
+
29
+ **All four need the same client-side step**: pair each machine with a pairing
30
+ code, then point it at the relay with `--rabbit` (or leave `rabbitUrl` empty for
31
+ shape 0).
32
+
33
+ ---
34
+
35
+ ## 1. Before you start: pick the relay host
36
+
37
+ The relay is a single process. Put it wherever it is reachable by every machine
38
+ that must take part, and where it can stay up:
39
+
40
+ | Situation | Recommended shape |
41
+ |---|---|
42
+ | You have neither a mesh nor a VPS, and want it working now | **0** — the shared server is already running, and it is the default |
43
+ | All machines can join a private mesh (Tailscale/WireGuard) | **A** — least moving parts, no certificates |
44
+ | You have a VPS and a domain | **B** |
45
+ | You have a public host but no domain | **0 with your own host** — `deploy/shared-server/install.sh` |
46
+ | You have neither, and want a throwaway | **C** |
47
+
48
+ Whichever you pick, three things stay true:
49
+
50
+ 1. **The relay holds no project code and no model credentials.** It routes task
51
+ envelopes. Compromise of the relay leaks the task ledger and device tokens,
52
+ not your repository or your API key.
53
+ 2. **The operator token is the only thing that can send work.** A paired
54
+ machine can take work and return results, but cannot dispatch. See §5 of the
55
+ protocol spec.
56
+ 3. **State must survive restarts.** Devices and the task ledger live in
57
+ `--state`; put it somewhere persistent or every restart forces every machine
58
+ to pair again.
59
+
60
+ ---
61
+
62
+ ## 2. Shape A — Tailscale / WireGuard
63
+
64
+ ### 2.1 Why this is the recommended shape
65
+
66
+ WireGuard (which Tailscale is built on) is a layer-3 encrypted tunnel: every
67
+ packet between two tailnet members is encrypted and authenticated with
68
+ WireGuard's own keys, and membership is controlled by your tailnet ACLs. The
69
+ relay's traffic never touches a public network in cleartext **even though the
70
+ relay itself speaks `http://`**.
71
+
72
+ ### 2.2 Install and find the address
73
+
74
+ On the relay host (Linux shown; the same steps work on macOS, and on Windows
75
+ via the Tailscale tray app):
76
+
77
+ ```bash
78
+ # Install (Debian/Ubuntu shown; see tailscale.com/download for others)
79
+ curl -fsSL https://tailscale.com/install.sh | sh
80
+
81
+ # Join the tailnet, then confirm it is up
82
+ sudo tailscale up
83
+ tailscale status
84
+
85
+ # The address the relay must bind, and clients must dial
86
+ tailscale ip -4
87
+ # => 100.x.y.z (100.64.0.0/10 is the CGNAT range Tailscale uses)
88
+ ```
89
+
90
+ Join every participating machine to the same tailnet the same way. From a
91
+ second machine, verify reachability before touching W2M:
92
+
93
+ ```bash
94
+ tailscale ping <relay-host> # should report a direct or relayed path
95
+ curl http://100.x.y.z:8787/healthz # after step 2.4; refused before that is expected
96
+ ```
97
+
98
+ ### 2.3 Why plain HTTP over `100.x` is correct (read this if nothing else)
99
+
100
+ This is the one place the deployment looks wrong and is not.
101
+
102
+ - **The encryption is already there.** WireGuard encrypts and authenticates
103
+ every packet between tailnet nodes with ChaCha20-Poly1305. Adding TLS on top
104
+ would encrypt an already-encrypted stream.
105
+ - **TLS would add nothing an attacker can exploit the absence of.** The threat
106
+ TLS defends against is a network attacker reading or modifying traffic in
107
+ transit. On a WireGuard link there is no such position to occupy: an observer
108
+ sees ciphertext, and cannot forge packets without a tailnet key.
109
+ - **TLS here would cost real things.** A certificate for a `100.x` address
110
+ cannot be issued by a public CA (the address is not publicly resolvable), so
111
+ you would either run a private CA and distribute its root to every machine,
112
+ or use self-signed certificates and teach every client to skip verification.
113
+ The second option is strictly worse than plain HTTP: it trains operators to
114
+ ignore certificate errors, on a link that is already encrypted.
115
+ - **The relay's own token model still applies.** Device tokens and the operator
116
+ token are enforced regardless of transport. TLS is not part of that model.
117
+
118
+ So: **bind the tailnet address, leave TLS off, and do not add a certificate.**
119
+
120
+ ```bash
121
+ # Correct for shape A
122
+ w2m-rabbit --host 100.x.y.z --port 8787 --state /var/lib/w2m
123
+ ```
124
+
125
+ ### 2.4 Start the relay
126
+
127
+ ```bash
128
+ # Foreground, to read the pairing code and operator token once.
129
+ # Without --json the banner below goes to stdout; WITH --json stdout is a single
130
+ # JSON line and every line shown here moves to stderr (see §8).
131
+ w2m-rabbit --host 100.x.y.z --port 8787 --state /var/lib/w2m
132
+ ```
133
+
134
+ The startup output prints (measured on a live relay — the order below is the
135
+ real one):
136
+
137
+ ```
138
+ [w2m-rabbit] listening on http://127.0.0.1:8787 pairing code: PAIR-65TWL21P
139
+ [w2m-rabbit] relay_id=68dfbfa9d982319a
140
+ [w2m-rabbit] base_path=/ (root)
141
+ [w2m-rabbit] trust_proxy=false (X-Forwarded-* ignored)
142
+ [w2m-rabbit] pair_rate_limit=5/min/IP
143
+ [w2m-rabbit] persistence=on dir=/var/lib/w2m revived_devices=0 revived_tasks=0
144
+ ... plus a banner repeating the OPERATOR TOKEN, which is also written to
145
+ <state>/operator-token.txt with mode 0600
146
+ ```
147
+
148
+ With `--json`, all of the above goes to **stderr** and stdout carries exactly
149
+ one JSON line; the operator token is reported only as
150
+ `"operatorTokenPresent": true`. Read the token from
151
+ `<state>/operator-token.txt`, never from stdout.
152
+
153
+ - the **pairing code** (`PAIR-XXXXXXXX`) — hand this to each machine, once. It
154
+ **rotates after every successful pairing**, and the new one is printed, so you
155
+ can onboard several machines without restarting the relay.
156
+ - the **operator token** — **keep this secret**; it authorises sending work.
157
+ - the **`relay_id`** — a fresh id per process start; clients use it to notice a
158
+ relay restart.
159
+
160
+ For a long-lived relay, install the systemd unit instead of leaving a terminal
161
+ open:
162
+
163
+ ```bash
164
+ sudo install -m 0644 deploy/systemd/w2m-rabbit.service /etc/systemd/system/
165
+ # edit it first: replace @RELAY_BIN@ with your node path and the absolute
166
+ # path to bin/w2m-rabbit.mjs, and set --host to the tailnet address
167
+ sudo systemctl daemon-reload
168
+ sudo systemctl enable --now w2m-rabbit
169
+ journalctl -u w2m-rabbit -f
170
+ ```
171
+
172
+ ### 2.5 Pair each machine
173
+
174
+ On every machine that should take part (Windows, macOS, Linux):
175
+
176
+ ```bash
177
+ w2m-localside \
178
+ --rabbit http://100.x.y.z:8787 \
179
+ --pair PAIR-XXXXXXXX \
180
+ --project /path/to/the/project \
181
+ --name <machine-name> \
182
+ --allowed-commands '["node --test","git status"]'
183
+ ```
184
+
185
+ - `--pair` is only needed once; the device token is stored afterwards.
186
+ - `--allowed-commands` is **default-deny**. With no allow-list, every offer is
187
+ refused. Start read-only (`git status`, `node --test`) and widen deliberately.
188
+ - `--name` is how the machine appears in `w2m_devices`; use the hostname.
189
+ - **Do not add `--operator-token` here.** It is only for a host that also
190
+ dispatches tasks; see §8.1.
191
+
192
+ ### 2.6 Verify
193
+
194
+ ```bash
195
+ # 1. The relay is alive and reachable from a client machine
196
+ curl http://100.x.y.z:8787/healthz
197
+
198
+ # 2. Every machine is registered (needs a device token; the client stores one
199
+ # after pairing, under $DSH_HOME/xclient/)
200
+ curl -H "Authorization: Bearer <device_token>" http://100.x.y.z:8787/v1/devices
201
+
202
+ # 3. From the model side, the five tools should now list the machines:
203
+ # w2m_devices
204
+ ```
205
+
206
+ **What "healthy" looks like**: `/healthz` returns `ok: true`, `devices` matches
207
+ the number of machines you paired, and `relay_id` stays constant. If `relay_id`
208
+ changed since you last looked, the relay restarted (see `TROUBLESHOOTING.md`).
209
+
210
+ ### 2.7 Risks and boundaries
211
+
212
+ - **Do not bind `0.0.0.0`.** On a machine that is also on a public or untrusted
213
+ network, `0.0.0.0` exposes the relay to that network as well as the tailnet.
214
+ Always name the tailnet address.
215
+ - **The pairing code is a bearer credential.** Anyone who can reach the relay
216
+ and has the code can join. Codes rotate after each successful pairing, and
217
+ `/v1/pair` is rate-limited per IP (`--pair-rate-limit`, default 5/minute).
218
+ - **Tailscale ACLs are your real fence.** Restrict which nodes may reach
219
+ `100.x.y.z:8787`; do not rely on the relay's own limits for that.
220
+ - **`--trust-proxy` must stay off here.** There is no proxy, so no
221
+ `X-Forwarded-*` header should be believed. Leaving it off means a malicious
222
+ client cannot forge its address to escape the pairing rate limit.
223
+
224
+ ---
225
+
226
+ ## 3. Shape B — public VPS + domain + TLS
227
+
228
+ ### 3.1 Pick who terminates TLS (exactly one)
229
+
230
+ | Option | Relay flag | When |
231
+ |---|---|---|
232
+ | **B1. Reverse proxy terminates TLS** (recommended) | none; relay stays on `127.0.0.1:8787` | you want certificate renewal, access logs and rate limiting in one familiar place |
233
+ | **B2. Relay terminates TLS itself** | `--tls-cert <fullchain.pem> --tls-key <privkey.pem>` | you want a single process with no proxy, and you will handle renewal yourself |
234
+
235
+ **Never both.** If a proxy terminates TLS and the relay also expects
236
+ `--tls-cert`, the proxy's plain-HTTP request hits a TLS listener and every
237
+ request fails.
238
+
239
+ ### 3.2 DNS and firewall
240
+
241
+ ```bash
242
+ # Point the name at the VPS (replace with your own values)
243
+ # A w2m.<your-domain> -> <vps-ipv4>
244
+ # AAAA w2m.<your-domain> -> <vps-ipv6> (optional)
245
+ dig +short w2m.<your-domain>
246
+
247
+ # Open 443 (and 80 only if you use the HTTP-01 challenge for Let's Encrypt)
248
+ sudo ufw allow 443/tcp
249
+ sudo ufw allow 80/tcp
250
+ # The relay port itself must NOT be exposed: keep it on 127.0.0.1.
251
+ ```
252
+
253
+ Confirm from outside that only 443 answers:
254
+
255
+ ```bash
256
+ nc -zv w2m.<your-domain> 443
257
+ nc -zv w2m.<your-domain> 8787 # must fail/refuse
258
+ ```
259
+
260
+ ### 3.3 Certificate
261
+
262
+ **Let's Encrypt (recommended, needs a public name and port 80 or DNS-01):**
263
+
264
+ ```bash
265
+ sudo apt install certbot
266
+ sudo certbot certonly --standalone -d w2m.<your-domain>
267
+ # or, with nginx already installed:
268
+ sudo certbot --nginx -d w2m.<your-domain>
269
+ # renewal is automatic via the certbot timer; verify with:
270
+ sudo certbot renew --dry-run
271
+ ```
272
+
273
+ **Self-signed (only when every client machine is under your control):**
274
+
275
+ ```bash
276
+ sudo openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
277
+ -keyout /etc/w2m/w2m.key -out /etc/w2m/w2m.crt \
278
+ -subj "/CN=w2m.<your-domain>" \
279
+ -addext "subjectAltName=DNS:w2m.<your-domain>"
280
+ chmod 600 /etc/w2m/w2m.key
281
+ ```
282
+
283
+ A self-signed certificate is not trusted by clients. Either distribute the CA
284
+ to every machine, or accept that the local-side HTTP client will refuse the
285
+ connection. **Do not disable verification** — fix the trust instead.
286
+
287
+ ### 3.4 Start the relay
288
+
289
+ **B1 — behind a proxy:**
290
+
291
+ ```bash
292
+ w2m-rabbit \
293
+ --host 127.0.0.1 --port 8787 \
294
+ --state /var/lib/w2m \
295
+ --trust-proxy
296
+ ```
297
+
298
+ `--trust-proxy` is required here: without it the relay sees every connection as
299
+ coming from `127.0.0.1`, so the pairing rate limit becomes one shared budget for
300
+ the whole internet, and `effective_scheme` in `/healthz` stays `http`.
301
+
302
+ **B2 — self-terminating TLS:**
303
+
304
+ ```bash
305
+ w2m-rabbit \
306
+ --host 0.0.0.0 --port 8443 \
307
+ --state /var/lib/w2m \
308
+ --tls-cert /etc/letsencrypt/live/w2m.<your-domain>/fullchain.pem \
309
+ --tls-key /etc/letsencrypt/live/w2m.<your-domain>/privkey.pem
310
+ ```
311
+
312
+ `--tls-cert` and `--tls-key` must be given together; giving one alone is a
313
+ usage error and the relay exits with code 2.
314
+
315
+ ### 3.5 Install the reverse proxy
316
+
317
+ Copy `deploy/nginx/w2m.conf.example` to `/etc/nginx/conf.d/w2m.conf` (or
318
+ `deploy/caddy/Caddyfile.example` to `/etc/caddy/Caddyfile`), replace
319
+ `<your-domain>`, then **validate before reloading**:
320
+
321
+ ```bash
322
+ sudo nginx -t && sudo systemctl reload nginx
323
+ # or
324
+ sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy
325
+ ```
326
+
327
+ The nginx file carries the three SSE settings this deployment cannot work
328
+ without — see §6.
329
+
330
+ ### 3.6 Point the model side at the relay, and pair machines
331
+
332
+ ```bash
333
+ w2m-localside --rabbit https://w2m.<your-domain> --pair PAIR-XXXXXXXX \
334
+ --project /path/to/project --name <machine-name> \
335
+ --allowed-commands '["node --test","git status"]'
336
+ ```
337
+
338
+ To send work from the model side, the plugin needs the operator token — on the
339
+ **dispatching** host only:
340
+
341
+ ```bash
342
+ # On the host that runs w2m_run (not on every executor):
343
+ export W2M_OPERATOR_TOKEN=<read it from <state>/operator-token.txt>
344
+ # or set `operatorToken` in the plugin configuration
345
+ ```
346
+
347
+ Do **not** copy this to machines that only execute work; see §8.1.
348
+
349
+ ### 3.7 Verify
350
+
351
+ ```bash
352
+ # From outside the VPS
353
+ curl https://w2m.<your-domain>/healthz
354
+ # => {"ok":true,...,"effective_scheme":"https","base_path":"/",...}
355
+
356
+ # The SSE route must deliver a frame immediately, not hang:
357
+ curl -N -H "Authorization: Bearer <device_token>" \
358
+ -H "Accept: text/event-stream" \
359
+ https://w2m.<your-domain>/v1/stream
360
+ # => a ready frame within a second, then ": keepalive" comments every ~15s
361
+ ```
362
+
363
+ ### 3.8 Risks and boundaries
364
+
365
+ - **The relay is now reachable by the whole internet.** Your fences are the
366
+ pairing code, the per-IP pairing rate limit, and the two tokens. Keep
367
+ `--pair-rate-limit` at its default or lower; never set it to `0` on a public
368
+ host.
369
+ - **`--trust-proxy` is only correct behind a proxy you control.** With it on
370
+ and the relay directly reachable, a client can forge `X-Forwarded-For` and
371
+ get a fresh rate-limit budget per request. See §6.3.
372
+ - **HTTP, not just HTTPS, must be considered.** If port 80 stays open with a
373
+ redirect, that is fine; if the relay itself is reachable on 8787, close it.
374
+ - **Single point of failure.** There is no relay HA and no multi-relay
375
+ federation in 0.1.2. Back up `<state>/` so a rebuilt relay resumes the same
376
+ device table.
377
+
378
+ ---
379
+
380
+ ## 4. Shape C — Cloudflare Tunnel / ngrok
381
+
382
+ This shape needs no public IP, no inbound firewall rule and no DNS record of
383
+ your own. The relay stays on `127.0.0.1:8787`; the tunnel client dials out.
384
+
385
+ ### 4.1 Cloudflare Tunnel
386
+
387
+ ```bash
388
+ # Install cloudflared (Debian/Ubuntu shown)
389
+ curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
390
+ | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
391
+ echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] \
392
+ https://pkg.cloudflare.com/cloudflared any main" \
393
+ | sudo tee /etc/apt/sources.list.d/cloudflared.list
394
+ sudo apt update && sudo apt install cloudflared
395
+
396
+ # Authenticate once (opens a browser; needs a Cloudflare account)
397
+ cloudflared tunnel login
398
+
399
+ # Create a named tunnel and route a hostname to it
400
+ cloudflared tunnel create w2m
401
+ cloudflared tunnel route dns w2m w2m.<your-domain>
402
+
403
+ # Run it in the foreground first, to see errors directly
404
+ cloudflared tunnel run --url http://127.0.0.1:8787 w2m
405
+ ```
406
+
407
+ > **Not verified on the authoring machine** — `cloudflared` is not installed
408
+ > there, so the commands above are written from Cloudflare's documented CLI and
409
+ > were not executed. Verify each one on the server before trusting it.
410
+
411
+ **Quick alternative, no account and no DNS** (a random `trycloudflare.com`
412
+ hostname, for a smoke test only — it changes on every restart):
413
+
414
+ ```bash
415
+ cloudflared tunnel --url http://127.0.0.1:8787
416
+ # prints e.g. https://<random-words>.trycloudflare.com
417
+ ```
418
+
419
+ ### 4.2 ngrok
420
+
421
+ ```bash
422
+ # Install
423
+ curl -sSL https://ngrok.com/download | sudo tar -xz -C /usr/local/bin ngrok
424
+
425
+ # Authenticate with the token from your ngrok dashboard
426
+ ngrok config add-authtoken <your-ngrok-authtoken>
427
+
428
+ # Expose the relay
429
+ ngrok http 127.0.0.1:8787
430
+ # prints e.g. Forwarding https://<random-subdomain>.ngrok-free.app -> http://127.0.0.1:8787
431
+ ```
432
+
433
+ > **Not verified on the authoring machine** — `ngrok` is not installed there.
434
+ > The `ngrok config add-authtoken` line above also needs **your own** token;
435
+ > this document deliberately does not contain one.
436
+
437
+ ### 4.3 Which `rabbitUrl` to use
438
+
439
+ Read the hostname the tunnel printed and use **exactly** that:
440
+
441
+ ```bash
442
+ # Cloudflare quick tunnel
443
+ w2m-localside --rabbit https://<random-words>.trycloudflare.com --pair ...
444
+
445
+ # ngrok
446
+ w2m-localside --rabbit https://<random-subdomain>.ngrok-free.app --pair ...
447
+ ```
448
+
449
+ ### 4.4 Sub-path deployments — the easiest thing to get wrong
450
+
451
+ Both tunnel products can publish the relay under a **prefix** instead of at the
452
+ root of a hostname, for example `https://<host>/w2m/`. When they do, **two
453
+ places must agree**:
454
+
455
+ | Place | Value |
456
+ |---|---|
457
+ | Relay | `--base-path /w2m` |
458
+ | Client | `--rabbit https://<host>/w2m` |
459
+
460
+ If the relay is started without `--base-path` while the tunnel strips or
461
+ forwards a prefix, **every `/v1/*` call fails** — usually with `404 NOT_FOUND`,
462
+ or with a redirect loop if the tunnel adds one. The relay is explicit about
463
+ this: with `--base-path /w2m`, a request to `/v1/devices` is answered
464
+
465
+ ```
466
+ 404 {"error":{"code":"NOT_FOUND","message":"path is outside the configured base path /w2m"}}
467
+ ```
468
+
469
+ while `/w2m/v1/devices` proceeds to authentication. That message is the
470
+ signature of this misconfiguration — see `TROUBLESHOOTING.md` §2.
471
+
472
+ Notes:
473
+
474
+ - `--base-path /w2m` also keeps `/healthz` answering at **both** `/healthz` and
475
+ `/w2m/healthz`, because monitoring probes are often pointed at the bare path.
476
+ - A trailing slash in `rabbitUrl` is harmless: the client normalises
477
+ `https://<host>/w2m/` and `https://<host>/w2m` to the same base.
478
+ - Do **not** configure the tunnel to strip the prefix while the relay also
479
+ expects it (nginx `proxy_pass http://relay/;`, Caddy `handle_path`). Pick one
480
+ side and be consistent.
481
+
482
+ ### 4.5 `--trust-proxy` with a tunnel
483
+
484
+ Set it **only if the tunnel sets `X-Forwarded-For` and you control the tunnel**:
485
+
486
+ ```bash
487
+ w2m-rabbit --host 127.0.0.1 --port 8787 --state /var/lib/w2m --trust-proxy
488
+ ```
489
+
490
+ Without it, every client appears to come from `127.0.0.1`, so the pairing rate
491
+ limit is a single shared budget (and `/healthz` reports
492
+ `"effective_scheme":"http"` even though clients use `https`). With it on a
493
+ tunnel that does **not** set the header, the relay falls back to the socket
494
+ address, which is also fine.
495
+
496
+ ### 4.6 The free-tier reality
497
+
498
+ - **Free tunnels disconnect.** Cloudflare quick tunnels and free ngrok tunnels
499
+ are not uptime products: expect the connection to drop and the hostname to
500
+ change across restarts. ngrok's free tier in particular shows an interstitial
501
+ page on first browser visit and enforces session limits.
502
+ - **A rotating hostname means re-pairing is not required, but re-pointing is.**
503
+ The device token survives, so a machine that reconnects to a *new* hostname
504
+ still holds its credentials; what breaks is the configured `--rabbit` value.
505
+ Pin a stable hostname (a named Cloudflare tunnel with your own DNS) before
506
+ relying on this in daily use.
507
+ - **The tunnel provider sees your traffic.** TLS terminates at the tunnel edge,
508
+ so the provider can read the relay's HTTP content. For project task payloads
509
+ that may be acceptable; decide deliberately rather than by default.
510
+ - **Idle timeouts.** Both products close idle connections. The relay's SSE
511
+ stream is idle apart from a `: keepalive` comment every 15s, which is usually
512
+ enough to keep it open; if you see repeated reconnects, see
513
+ `TROUBLESHOOTING.md` §6.
514
+
515
+ ### 4.7 Risks and boundaries
516
+
517
+ - **A quick tunnel is a public URL** with no authentication beyond the relay's
518
+ own tokens, and anyone who learns the hostname can reach `/v1/pair`. Keep the
519
+ pairing rate limit on, and close the tunnel when you are done.
520
+ - **No inbound firewall change is needed** — that is the point — but the tunnel
521
+ client itself becomes a process you must keep running and updated.
522
+ - **Do not use shape C as the permanent answer for a machine you can simply put
523
+ on a tailnet.** Shape A has fewer moving parts and no third party in the
524
+ data path.
525
+
526
+ ---
527
+
528
+ ## 5. `rabbitUrl` reference — what to put in each shape
529
+
530
+ The client takes a **base address**. It may include a sub-path; it must not
531
+ include a query string or a fragment.
532
+
533
+ | Shape | Relay started with | `rabbitUrl` (client `--rabbit`) | Notes |
534
+ |---|---|---|---|
535
+ | **A. Tailscale** | `--host 100.x.y.z --port 8787` | `http://100.x.y.z:8787` | plain HTTP on purpose (§2.3) |
536
+ | **B1. VPS, proxy terminates TLS** | `--host 127.0.0.1 --port 8787 --trust-proxy` | `https://w2m.<your-domain>` | no port: it is 443 |
537
+ | **B1′. VPS, proxy on a non-standard port** | same | `https://w2m.<your-domain>:8443` | port must match what clients dial |
538
+ | **B2. VPS, relay terminates TLS** | `--host 0.0.0.0 --port 8443 --tls-cert … --tls-key …` | `https://w2m.<your-domain>:8443` | `https`, and the port is required |
539
+ | **C1. Cloudflare named tunnel** | `--host 127.0.0.1 --port 8787` (no `--trust-proxy` needed) | `https://w2m.<your-domain>` | the tunnel's public hostname |
540
+ | **C2. Cloudflare quick tunnel** | same | `https://<random-words>.trycloudflare.com` | changes on every restart |
541
+ | **C3. ngrok** | same | `https://<random-subdomain>.ngrok-free.app` | changes unless you have a reserved domain |
542
+ | **C4. Any tunnel with a prefix** | `--host 127.0.0.1 --port 8787 --base-path /w2m` | `https://<host>/w2m` | **both sides must carry `/w2m`** |
543
+ | **Local development** | `--port 8787` (defaults to `127.0.0.1`) | `http://127.0.0.1:8787` | the default; nothing else to configure |
544
+
545
+ **Illegal and rejected at startup** (the client fails loudly, naming
546
+ `rabbitUrl`, rather than silently degrading):
547
+
548
+ ```
549
+ https://w2m.<your-domain>?token=x # query string
550
+ https://w2m.<your-domain>#frag # fragment
551
+ ftp://w2m.<your-domain> # not http/https
552
+ ```
553
+
554
+ **Rule of thumb:** the value you pass must be the exact prefix under which
555
+ `/healthz` answers. Verify with:
556
+
557
+ ```bash
558
+ curl <rabbitUrl>/healthz
559
+ ```
560
+
561
+ If that returns `ok: true`, the base address is right, and the client will
562
+ build `<rabbitUrl>/v1/stream` correctly.
563
+
564
+ ---
565
+
566
+ ## 6. SSE and reverse proxies
567
+
568
+ The event stream (`GET /v1/stream`) is the part of W2M that reverse proxies
569
+ break most often, because it is a long-lived, chunked, mostly-idle HTTP
570
+ response, and proxies are optimised for the opposite.
571
+
572
+ The relay already helps: every SSE response carries
573
+
574
+ ```
575
+ Content-Type: text/event-stream; charset=utf-8
576
+ Cache-Control: no-cache, no-transform
577
+ X-Accel-Buffering: no
578
+ ```
579
+
580
+ verified by reading the live response headers. `X-Accel-Buffering: no` is
581
+ honoured by nginx automatically. **Caddy does not honour it** (it is an
582
+ nginx-specific convention), so Caddy needs `flush_interval -1` explicitly.
583
+
584
+ ### 6.1 nginx — the three settings
585
+
586
+ From `deploy/nginx/w2m.conf.example`:
587
+
588
+ ```nginx
589
+ location = /v1/stream {
590
+ proxy_pass http://w2m_relay/v1/stream;
591
+ proxy_buffering off; # 1. send frames immediately
592
+ proxy_read_timeout 86400s; # 2. do not time out a long-lived response
593
+ proxy_http_version 1.1; # 3. chunked transfer, required for SSE
594
+ proxy_set_header Connection "";
595
+ }
596
+ ```
597
+
598
+ Dropping any one produces a different symptom:
599
+
600
+ | Missing setting | What the operator sees |
601
+ |---|---|
602
+ | `proxy_buffering off` | connects, HTTP 200, **no events ever arrive** (the classic "connected but dead") |
603
+ | `proxy_read_timeout` long enough | stream dies after the default 60s of quiet |
604
+ | `proxy_http_version 1.1` | stream ends immediately after the first frame |
605
+
606
+ Also set `gzip off` for the stream location: compressing an event stream makes
607
+ nginx buffer it again.
608
+
609
+ ### 6.2 Caddy
610
+
611
+ ```caddy
612
+ handle /v1/stream {
613
+ reverse_proxy 127.0.0.1:8787 {
614
+ flush_interval -1
615
+ transport http {
616
+ read_timeout 0
617
+ write_timeout 0
618
+ }
619
+ }
620
+ }
621
+ ```
622
+
623
+ Caddy handles HTTP/2 and TLS correctly by itself; the only SSE-specific
624
+ requirement is `flush_interval -1`.
625
+
626
+ ### 6.3 `--trust-proxy`, both sides of it
627
+
628
+ **Turn it on only when the relay is behind a proxy or tunnel you control.**
629
+
630
+ | | `--trust-proxy` off (default) | `--trust-proxy` on |
631
+ |---|---|---|
632
+ | Client IP used for `/v1/pair` rate limiting | the socket address | first entry of `X-Forwarded-For` |
633
+ | `/healthz` `effective_scheme` | the actual listener scheme | `X-Forwarded-Proto` |
634
+ | Behind a proxy | **every client shares one rate-limit budget** (`127.0.0.1`) | correct per-client budgets |
635
+ | Exposed directly | safe | **a client can forge `X-Forwarded-For` and get unlimited pairing attempts** |
636
+
637
+ So the two failure modes are symmetric, and both are visible in `/healthz`:
638
+
639
+ - Rate limiting that never triggers on a public deployment → you forgot
640
+ `--trust-proxy`.
641
+ - `429 RATE_LIMITED` for all clients at once → `--trust-proxy` is off, so they
642
+ share the loopback budget.
643
+
644
+ A rate-limit rejection is now a proper `429` with a `Retry-After: 60` header and
645
+ `detail.retry_after_seconds`, so ordinary retry logic works without special
646
+ cases.
647
+
648
+ ---
649
+
650
+ ## 7. What was verified, and what was not
651
+
652
+ This section exists so you can tell an untested claim from a tested one.
653
+
654
+ ### Verified on the authoring machine (Windows, Node 24.21.0)
655
+
656
+ Each item was executed against the real relay process, not read from source.
657
+
658
+ | Check | Result |
659
+ |---|---|
660
+ | Runtime layout the Dockerfile installs (`package.json`, `bin/`, `src/`, `lib/`, `cordis.patch.yml` copied into a clean dir) | relay starts and serves |
661
+ | `GET /healthz` field set | all 0.1.2 fields present, incl. `relay_id`, `started_at`, `effective_scheme`, `base_path`, `operator_token_required`, `pair_rate_limit`, `persistence{...}` |
662
+ | `POST /v1/pair` with an auto-generated code | `200` + `device_token` |
663
+ | `w2m-localside --pair <code>` (the real client) | pairs, stores the device token, opens the stream, `/healthz` `devices` becomes 1 |
664
+ | `--base-path /w2m` | `/w2m/healthz` **and** `/healthz` → `200`; `/w2m/v1/devices` → `401` (correct: auth required); `/v1/devices` → `404 NOT_FOUND` with "path is outside the configured base path" |
665
+ | `--operator-token <t>` on `POST /v1/task` | operator token → `200` + `task_id`; device token / none / wrong → `401 OPERATOR_REQUIRED` |
666
+ | `--pair-rate-limit 2` | 3rd attempt → `429 RATE_LIMITED` with `retry_after_seconds: 60`, `limit: 2`, and a `Retry-After: 60` header |
667
+ | `--trust-proxy` | `/healthz` `effective_scheme` follows `X-Forwarded-Proto` (`https` → `https`) |
668
+ | `--no-persist` | `persistence.enabled: false`, no state directory created |
669
+ | Persistence across restart | `devices.json`, `ledger.jsonl`, `operator-token.txt` written; after a restart `revived_devices: 1` and the **same** device token still authenticates (`200`) |
670
+ | `--tls-cert` without `--tls-key` | usage error, exit code `2` |
671
+ | `--json` stdout purity | exactly **one** JSON line, `JSON.parse` succeeds, diagnostics on stderr, and the token value appears **nowhere** on stdout |
672
+ | `--json` after a pairing-code rotation | still exactly one line, still parseable (the rotation notice goes to stderr) |
673
+ | `--json` field set | `operatorTokenPresent: true` and **no** `operatorToken` key |
674
+ | `w2m-localside --operator-token` | accepted; `--help` documents it as needed only when the host also submits tasks |
675
+ | SSE response headers | `text/event-stream`, `Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no` all observed on a live response |
676
+ | Structural checks on all four deploy files | braces/quotes/directive termination balanced; the required SSE directives present; placeholder discipline clean (script: `check-configs.mjs`, kept in the task work directory) |
677
+
678
+ ### NOT verified here — verify on your server
679
+
680
+ | Claim | Why it was not verified | How to verify |
681
+ |---|---|---|
682
+ | `nginx -t` accepts `deploy/nginx/w2m.conf.example` | nginx is not installed on the authoring machine | `sudo nginx -t` after installing the file |
683
+ | `caddy validate` accepts `deploy/caddy/Caddyfile.example` | caddy is not installed | `caddy validate --config Caddyfile` |
684
+ | `systemd-analyze verify` accepts `deploy/systemd/w2m-rabbit.service` | systemd is not available on Windows | `sudo systemd-analyze verify /etc/systemd/system/w2m-rabbit.service` |
685
+ | `docker build` / `docker compose config` | docker is not installed | `docker build -f deploy/docker/Dockerfile -t w2m-relay:0.1.2 .` then `docker compose -f deploy/docker/docker-compose.yml config` |
686
+ | `cloudflared` and `ngrok` command sequences (§4.1, §4.2) | neither tool is installed | run them on the server; both are quick to smoke-test with `--url`/`http` |
687
+ | TLS termination end-to-end (B2) | no certificate was available for a routable name | `curl -v https://w2m.<your-domain>/healthz` |
688
+ | Tailscale reachability (§2) | Tailscale is not installed | `tailscale ping <relay-host>`, then `curl http://100.x.y.z:8787/healthz` |
689
+
690
+ ---
691
+
692
+ ## 8. Current implementation status (0.1.2, re-measured 2026-10-07)
693
+
694
+ The deployment material in this directory targets the 0.1.2 contract in
695
+ `PROTOCOL-v0.1.2.md`. Every behaviour this document relies on was re-measured
696
+ against a live relay process, and at the time of writing **all of it is
697
+ implemented**:
698
+
699
+ | Behaviour | Measured result |
700
+ |---|---|
701
+ | `--base-path /w2m` | `/w2m/healthz` **and** `/healthz` → `200`; `/w2m/v1/devices` → `401`; `/v1/devices` → `404 NOT_FOUND` naming the base path |
702
+ | `--trust-proxy` | `/healthz` `effective_scheme` follows `X-Forwarded-Proto` |
703
+ | `--operator-token` | `POST /v1/task`: operator token → `200`; device token / none / wrong → **`401`** `OPERATOR_REQUIRED` |
704
+ | `--pair-rate-limit` | over-limit → **`429`** `RATE_LIMITED`, `retry_after_seconds: 60`, and a `Retry-After: 60` header |
705
+ | `--pairing-code <fixed>` | fixed code registered (`pairing_codes: 1`) and accepted (`200`) — use this for unattended/automated pairing |
706
+ | `--no-persist` | `persistence.enabled: false`, no state directory created |
707
+ | Persistence across restart | `devices.json` / `ledger.jsonl` / `operator-token.txt` written; after a restart `revived_devices: 1` and the **same** device token still authenticates |
708
+ | `/healthz` v0.1.2 fields | all present, including a non-null `relay_id` |
709
+ | `--tls-cert` without `--tls-key` | usage error, exit code `2` |
710
+ | SSE headers | `text/event-stream`, `Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no` |
711
+
712
+ ### `--json` output contract
713
+
714
+ `--json` is meant to be piped into a log or a parser, so the contract is
715
+ narrow and worth relying on:
716
+
717
+ - **stdout is exactly one line of JSON** — no banner, no diagnostics, and no
718
+ extra line even after a pairing-code rotation. `JSON.parse(stdout.trim())`
719
+ works; `ConvertFrom-Json` in PowerShell works.
720
+ - **every diagnostic goes to stderr**, including the listening banner, the
721
+ `relay_id`/`base_path`/`pair_rate_limit` lines, the pairing-code rotation
722
+ notice, and the operator token.
723
+ - **the operator token never appears on stdout.** The JSON reports
724
+ `"operatorTokenPresent": true|false` and nothing more.
725
+
726
+ Measured fields (live relay, 0.1.2):
727
+
728
+ ```json
729
+ {"event":"listening","url":"http://127.0.0.1:8787","publicUrl":"http://127.0.0.1:8787",
730
+ "host":"127.0.0.1","port":8787,"scheme":"http","basePath":"/","stateDir":"/var/lib/w2m",
731
+ "persist":true,"trustProxy":false,"pairRateLimit":5,
732
+ "operatorTokenRequired":true,"operatorTokenPresent":true,
733
+ "pairingCode":"PAIR-XXXXXXXX","relayId":"<hex>","protocolVersion":1}
734
+ ```
735
+
736
+ `operatorToken` deliberately has **no** value field: `--json` is exactly what
737
+ people redirect into a file (`> relay.log`), and a credential in a log is a
738
+ leak. To obtain the token, read `<state>/operator-token.txt` (mode 0600) or
739
+ watch stderr — both are outside the machine-readable stream. If you need the
740
+ token in an automated flow, read the file, not stdout.
741
+
742
+ ### Known rough edges
743
+
744
+ 1. **`POST /v1/pair` does not echo `machine_id`.** A successful pairing returns
745
+ exactly `{device_token, protocol_version, rabbit_time, next_pairing_code}` —
746
+ verified live — even though the `machine_id` you sent is stored and the
747
+ device works. A script that wants the id must call `/v1/devices` or read it
748
+ from the client's stored identity. This is accepted behaviour, not a
749
+ pending fix: the client already knows its own `machine_id`.
750
+ 2. **`w2m-localside --state <dir>` does not move `device.json`** — intentional,
751
+ see below.
752
+
753
+ ### Three behaviours that look like bugs but are intentional
754
+
755
+ - **`w2m-localside --state <dir>` does not move `device.json`.** Machine
756
+ identity and `device_token` always live at `$DSH_HOME/xclient/device.json`;
757
+ `--state` controls only spool/runtime files. This is deliberate: identity
758
+ must follow `DSH_HOME` so that two profiles on one machine cannot share a
759
+ `machine_id` (early versions did, and the second pairing silently evicted the
760
+ first machine's token). **To simulate two machines on one host, give each a
761
+ different `DSH_HOME`:**
762
+ ```bash
763
+ DSH_HOME=/tmp/w2m-machine-a w2m-localside --rabbit ... --name machine-a ...
764
+ DSH_HOME=/tmp/w2m-machine-b w2m-localside --rabbit ... --name machine-b ...
765
+ ```
766
+ - **The operator token is not in the `--json` stream.** See the contract above:
767
+ `operatorTokenPresent` is a boolean, and the value lives in
768
+ `<state>/operator-token.txt` (0600) and on stderr. That is a deliberate
769
+ credential-hygiene choice, not a missing field.
770
+ - **Only a machine that dispatches needs the operator token.** A machine that
771
+ only executes work needs a device token, which pairing gives it. Configure the
772
+ operator token on the dispatching host only; putting it on every executor
773
+ would widen the blast radius of one compromised machine for no benefit. See
774
+ §8.1 for the client-side flag that carries it.
775
+
776
+ ### 8.1 Client side: where the operator token goes
777
+
778
+ `w2m-localside` accepts `--operator-token <t>` (or `$W2M_OPERATOR_TOKEN`). Read
779
+ its own `--help` wording, because it states the boundary precisely:
780
+
781
+ ```
782
+ --operator-token <t> Operator token (or $W2M_OPERATOR_TOKEN). Only needed
783
+ when this host also submits tasks; the Localside
784
+ agent itself never sends it.
785
+ ```
786
+
787
+ That is exactly the rule:
788
+
789
+ | Machine's role | Operator token needed? |
790
+ |---|---|
791
+ | Executes work only (takes offers, returns results) | **No** — pairing gives it a device token, which is all it uses |
792
+ | Also dispatches tasks (the host running `w2m_run`) | **Yes** — via `--operator-token`, `$W2M_OPERATOR_TOKEN`, or the plugin's `operatorToken` config |
793
+ | The relay host itself | Only if you also dispatch from there |
794
+
795
+ Two things worth knowing about the implementation:
796
+
797
+ - The agent **never sends** the operator token on its own requests. It is made
798
+ available to the host's submit-capable side; the relay still sees only device
799
+ tokens on machine traffic. Configuring it does not widen what the machine can
800
+ do on the wire by itself.
801
+ - The startup log records only that it is configured and its **length**, never
802
+ its value — so a localside log is safe to share.
803
+
804
+ **Do not** put `--operator-token` on every machine. The whole point of the
805
+ device/operator split (protocol §5) is that one compromised executor cannot
806
+ command the group; handing the operator token to every executor removes that
807
+ property.
808
+
809
+ ### Automated (unattended) pairing
810
+
811
+ `--pairing-code <fixed>` registers the code you supply, so a provisioning script
812
+ can pair machines without scraping the log for a rotating code:
813
+
814
+ ```bash
815
+ # Relay: pin the code for an automated rollout
816
+ w2m-rabbit --host 127.0.0.1 --port 8787 --state /var/lib/w2m \
817
+ --pairing-code "$W2M_PAIRING_CODE"
818
+
819
+ # Client: pair with the pinned code
820
+ w2m-localside --rabbit https://w2m.<your-domain> --pair "$W2M_PAIRING_CODE" \
821
+ --project /srv/project --name "$(hostname)" \
822
+ --allowed-commands '["node --test"]'
823
+ ```
824
+
825
+ Verified: the pinned code is registered (`/healthz` → `pairing_codes: 1`), a
826
+ pairing request with it returns `200`, and the response carries the next
827
+ rotated code. Keep the pinned code out of shell history and out of the
828
+ repository — pass it through an environment variable as above.
829
+
830
+ ---
831
+
832
+ ## 9. Where to go next
833
+
834
+ - `TROUBLESHOOTING.md` — cross-region symptoms, causes and fixes (including the
835
+ P2P fallback reasons and the allow-list trap, §9 and §10 there).
836
+ - `PROTOCOL-v0.4.0.md` — the current wire additions: path selection, the shared
837
+ server's constants, the announce/punch/offer flow and the `transport` fields.
838
+ - `PROTOCOL-v0.1.2.md` — the wire contract shape B and C were written against.
839
+ - `README.md` — what W2M is and how the eight tools are used.
840
+
841
+ ---
842
+
843
+ ## 10. Shape 0 — the shared server
844
+
845
+ v0.4.0 ships a default rendezvous so that "install it and try it" does not begin
846
+ with provisioning a host. With an empty `rabbitUrl` the plugin and the agent use:
847
+
848
+ | Constant | Value |
849
+ |---|---|
850
+ | `SHARED_SERVER.rabbitUrl` | `http://202.182.123.154:8787` |
851
+ | `SHARED_SERVER.stun` | `202.182.123.154:3478` (first entry of the default STUN list) |
852
+
853
+ Precedence: `rabbitUrl` in the profile patch → `W2M_RABBIT_URL` → the shared
854
+ default. `w2m_status` reports which one was used (`rabbit_source`).
855
+
856
+ **What is running there.** The relay (`:8787`, and `:80` through nginx), an
857
+ RFC 5389 STUN responder (`:3478/udp`), and — for this project's own verification —
858
+ a Localside agent, so the host is also a machine in the fleet. `deploy/shared-server/`
859
+ builds exactly this on a fresh host:
860
+
861
+ ```bash
862
+ scp twinsearth-w2m-dsh-plugin-0.4.0.tgz root@<host>:/tmp/w2m-pkg.tgz
863
+ scp deploy/shared-server/install.sh root@<host>:/tmp/
864
+ ssh root@<host> 'bash /tmp/install.sh /tmp/w2m-pkg.tgz [PAIR-XXXXXXXX]'
865
+ ```
866
+
867
+ **TLS: there is none, and this document will not pretend otherwise.** Without a
868
+ hostname there is nothing to issue a certificate for. Pairing codes, device
869
+ tokens, the operator token and every result cross the wire in clear text. What
870
+ limits the damage:
871
+
872
+ * the payload path is the direct one when the network allows it, so the relay sees
873
+ the ledger rather than the traffic (`transport` says which happened);
874
+ * the relay holds no project code and no model credentials — it can forge tasks,
875
+ and the machine-side allow-list is the load-bearing control;
876
+ * the operator token is a bearer credential: treat it as compromised if the path is
877
+ hostile, and rotate it by deleting `operator-token.txt` and restarting the relay.
878
+
879
+ To add TLS, point a subdomain at the host, issue a certificate (the nginx block the
880
+ installer writes already serves `/` on :80 for the HTTP-01 challenge), and add the
881
+ 443 server from `deploy/nginx/w2m.conf.example`. Only `rabbitUrl` changes on the
882
+ machines.
883
+
884
+ **Verify it, rather than trusting it:**
885
+
886
+ ```bash
887
+ # the relay answers, with the shared server's identity in the reply
888
+ curl -fsS http://202.182.123.154:8787/healthz | head -c 200
889
+ curl -fsS http://202.182.123.154/healthz | head -c 120 # the :80 path
890
+
891
+ # the STUN responder answers, and this number is what the punch will aim at
892
+ node -e "import('./src/agent/stun.mjs').then(async (s) => {
893
+ const { socket } = await s.bindUdpSocket();
894
+ console.log(await s.stunQuery(socket, '202.182.123.154:3478')); socket.close(); })"
895
+ ```
896
+
897
+ If the second command reports an address, the responder is reachable from where
898
+ you are. If you then run `discoverReflexive` against it **and two public STUN
899
+ servers** and the three mapped ports differ, your network is symmetric and the
900
+ punch cannot succeed from it — see `TROUBLESHOOTING.md` §9. That is a property of
901
+ your network, not of this deployment.
902
+