@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.
- package/CHANGELOG.md +1309 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +24 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- 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
|
+
|