@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
@@ -0,0 +1,338 @@
1
+ # Running the W2M relay in Docker
2
+
3
+ The relay is a single dependency-free `node:http` server. This directory builds
4
+ it into an image, and the image installs **nothing from npm** — that is not an
5
+ optimisation, it is the project's zero-runtime-dependency promise carried into
6
+ the container.
7
+
8
+ | File | What it is |
9
+ |---|---|
10
+ | `Dockerfile` | The image: `node:22.23.3-bookworm-slim` + 7 source files, non-root, state as a volume |
11
+ | `docker-compose.yml` | A runnable example with the state volume, port mapping, healthcheck and restart policy |
12
+ | `README.md` | This file |
13
+ | `../../.github/workflows/docker.yml` | CI job that builds `linux/amd64` **and** `linux/arm64` |
14
+
15
+ ---
16
+
17
+ ## 1. The image installs nothing — here is the proof
18
+
19
+ The relay's complete import closure is **7 files / ~175 KiB**, and every module
20
+ it reaches is a `node:` builtin:
21
+
22
+ ```
23
+ bin/w2m-rabbit.mjs
24
+ -> src/relay/server.mjs -> src/relay/state.mjs
25
+ -> src/relay/persistence.mjs
26
+ -> src/relay/report.mjs
27
+ -> src/signing.mjs
28
+ -> src/util/cli.mjs
29
+ builtins used: node:crypto node:fs node:http node:https node:os node:path node:util
30
+ ```
31
+
32
+ No module specifier in that closure is a bare package name. Consequently the
33
+ Dockerfile contains **no `npm install`, `npm ci`, `pnpm`, `yarn` or `corepack`**,
34
+ and there is no `node_modules` in the image at all. `package.json` still declares
35
+ `"dependencies": {}`.
36
+
37
+ You can re-derive this yourself at any time:
38
+
39
+ ```bash
40
+ # Closure + Dockerfile audit (kept outside the repo so it is not packed into
41
+ # the published tarball; path is the task work directory on the authoring machine)
42
+ node E:/DS/_work/xclient/task-29/verify-no-docker.mjs
43
+ ```
44
+
45
+ …or with nothing but grep, which is what the CI job does:
46
+
47
+ ```bash
48
+ grep -nE '^[[:space:]]*(RUN[[:space:]]+)?(npm|pnpm|yarn|corepack)[[:space:]]+(install|ci|add|i)\b' deploy/docker/Dockerfile \
49
+ && echo 'FAIL: a package manager appeared in the image' \
50
+ || echo 'no package-manager install in the Dockerfile'
51
+ ```
52
+
53
+ **Why `lib/` is not copied.** `package.json`'s `main` is `lib/index.js`, which is
54
+ the *DSH plugin* entry point for a host running `w2m_devices`/`w2m_run`. The relay
55
+ CLI never imports it, so it stays out. `cordis.patch.yml` is likewise plugin-host
56
+ configuration, not relay runtime.
57
+
58
+ ---
59
+
60
+ ## 2. Build
61
+
62
+ From the **repository root** — the COPY paths are relative to the build context:
63
+
64
+ ```bash
65
+ # Single architecture (your machine)
66
+ docker build -f deploy/docker/Dockerfile -t w2m-relay:0.3.4 .
67
+
68
+ # Both supported architectures, without needing the hardware
69
+ docker buildx build --platform linux/amd64,linux/arm64 \
70
+ -f deploy/docker/Dockerfile -t w2m-relay:0.3.4 .
71
+ ```
72
+
73
+ The base tag is pinned to an exact patch release (`node:22.23.3-bookworm-slim`),
74
+ not `22` and not `latest`, so two builds of the same commit produce the same
75
+ image. For byte-for-byte reproducibility, resolve the digest and pin that
76
+ instead:
77
+
78
+ ```bash
79
+ docker buildx imagetools inspect node:22.23.3-bookworm-slim
80
+ # then: FROM node:22.23.3-bookworm-slim@sha256:<digest>
81
+ ```
82
+
83
+ A digest names a single platform, so a **multi-arch** build needs the per-arch
84
+ digests from the manifest list rather than one digest on the `FROM` line.
85
+
86
+ ---
87
+
88
+ ## 3. Run
89
+
90
+ ### With compose (recommended)
91
+
92
+ ```bash
93
+ docker compose -f deploy/docker/docker-compose.yml up -d --build
94
+ docker compose -f deploy/docker/docker-compose.yml ps
95
+ ```
96
+
97
+ ### With plain docker
98
+
99
+ ```bash
100
+ docker volume create w2m-state
101
+ docker run -d --name w2m-relay \
102
+ -p 127.0.0.1:8787:8787 \
103
+ -v w2m-state:/var/lib/w2m \
104
+ --restart unless-stopped \
105
+ w2m-relay:0.3.4
106
+ ```
107
+
108
+ The container listens on **`0.0.0.0:8787`** unconditionally. That is correct
109
+ inside a container: a loopback bind would be unreachable from outside the
110
+ container's network namespace, making the published port useless. Exposure is
111
+ controlled by the port **mapping** — `127.0.0.1:8787:8787` means only this host
112
+ can reach it, which is what you want whenever a proxy or tunnel runs alongside.
113
+
114
+ > **Keep the internal port at 8787 in every deployment.** The image `HEALTHCHECK`
115
+ > probes `127.0.0.1:8787`. If you must change `--port`, override the healthcheck
116
+ > too, or the container will report unhealthy while serving fine.
117
+
118
+ ---
119
+
120
+ ## 4. Read the pairing code — the first thing an operator needs
121
+
122
+ The relay prints it at startup, so it is in the container log:
123
+
124
+ ```bash
125
+ # compose
126
+ docker compose -f deploy/docker/docker-compose.yml logs relay | grep -i 'pairing code'
127
+
128
+ # plain docker
129
+ docker logs w2m-relay 2>&1 | grep -i 'pairing code'
130
+ ```
131
+
132
+ Measured output, both streams shown:
133
+
134
+ ```
135
+ # stderr — the machine-readable line
136
+ [w2m-rabbit] listening on http://127.0.0.1:8787 pairing code: PAIR-AXNJSSQD
137
+
138
+ # stdout — the human banner, same code
139
+ ┌──────────────────────────────────────────────┐
140
+ │ 配对码 / PAIRING CODE: PAIR-AXNJSSQD │
141
+ └──────────────────────────────────────────────┘
142
+ w2m-localside --rabbit http://127.0.0.1:8787 --pair PAIR-AXNJSSQD \
143
+ --project <path-to-project> --name <machine-name>
144
+ ```
145
+
146
+ Then pair each machine (on the machine, not in the container):
147
+
148
+ ```bash
149
+ w2m-localside --rabbit http://<relay-host>:8787 --pair PAIR-AXNJSSQD \
150
+ --project /path/to/project --name <machine-name> \
151
+ --allowed-commands '["node --test"]'
152
+ ```
153
+
154
+ **The code rotates after every successful pairing**, and the relay prints the
155
+ next one, so you can onboard several machines without restarting.
156
+
157
+ **Two traps:**
158
+
159
+ - `docker logs w2m-relay 2>/dev/null` drops the stderr line that carries the
160
+ code. `docker logs` merges both streams by default; add `2>&1` when you redirect.
161
+ - The banner uses box-drawing characters and does not always render in a
162
+ non-UTF-8 terminal. Grep for `PAIR-` (or `pairing code`) instead of eyeballing
163
+ it.
164
+
165
+ ### Also in the log: the operator token
166
+
167
+ The operator token is what authorises **sending** work. It is printed once at
168
+ startup and written to `<state>/operator-token.txt` with mode `0600`:
169
+
170
+ ```bash
171
+ docker compose -f deploy/docker/docker-compose.yml exec relay \
172
+ cat /var/lib/w2m/operator-token.txt
173
+ ```
174
+
175
+ Only the machine that *dispatches* tasks needs it. A machine that only executes
176
+ work needs just its device token, which pairing gives it.
177
+
178
+ > The log therefore contains a credential. It is the fastest way to recover a
179
+ > pairing code after a restart, but treat the log file as sensitive: the compose
180
+ > example bounds it to 5 × 10 MB for that reason.
181
+
182
+ ---
183
+
184
+ ## 5. Verify
185
+
186
+ ### Is it alive?
187
+
188
+ ```bash
189
+ docker compose -f deploy/docker/docker-compose.yml ps # look for (healthy)
190
+ ```
191
+
192
+ `/healthz` requires **no token** — that is exactly why it is the probe:
193
+
194
+ ```bash
195
+ # from the host
196
+ curl -fsS http://127.0.0.1:8787/healthz | head -c 200
197
+
198
+ # from inside the container, which is what HEALTHCHECK runs
199
+ docker exec w2m-relay node -e \
200
+ "fetch('http://127.0.0.1:8787/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" \
201
+ && echo healthy || echo unhealthy
202
+ ```
203
+
204
+ Measured response shape (`200 application/json`):
205
+
206
+ ```json
207
+ {"ok":true,"protocol_version":1,"relay_id":"…","started_at":"…",
208
+ "effective_scheme":"http","base_path":"/","operator_token_required":true,
209
+ "pair_rate_limit":5,"persistence":{"enabled":true,"dir":"/var/lib/w2m",
210
+ "revived_devices":0,"revived_tasks":0},"signing":{…},"rtt":{…}}
211
+ ```
212
+
213
+ `relay_id` is the field to watch across restarts: a changed value means the
214
+ process restarted, which invalidates clients' sequence cursors (they resync
215
+ automatically).
216
+
217
+ ### Is the state volume being used?
218
+
219
+ ```bash
220
+ docker compose -f deploy/docker/docker-compose.yml exec relay ls -l /var/lib/w2m
221
+ # operator-token.txt immediately; devices.json and ledger.jsonl after first pairing
222
+
223
+ curl -fsS http://127.0.0.1:8787/healthz | grep -o '"persistence":{[^}]*}'
224
+ ```
225
+
226
+ ### Does it survive a restart?
227
+
228
+ ```bash
229
+ docker compose -f deploy/docker/docker-compose.yml restart relay
230
+ curl -fsS http://127.0.0.1:8787/healthz | grep -o '"revived_devices":[0-9]*'
231
+ ```
232
+
233
+ `revived_devices` greater than zero means the device table came back from the
234
+ volume and existing device tokens are still valid — no re-pairing.
235
+
236
+ ### Is the image actually multi-arch?
237
+
238
+ ```bash
239
+ docker buildx imagetools inspect w2m-relay:0.3.4
240
+ docker run --rm --platform linux/arm64 w2m-relay:0.3.4 --help
241
+ ```
242
+
243
+ (The second command needs QEMU registered on the host, e.g. via
244
+ `docker run --privileged --rm tonistiigi/binfmt --install arm64`.)
245
+
246
+ ---
247
+
248
+ ## 6. Deployment shapes
249
+
250
+ All three shapes run the same image. Only the port binding and the flag list
251
+ change. The compose file documents each one inline; the summary:
252
+
253
+ | Shape | Port binding | Extra relay flags | TLS terminates |
254
+ |---|---|---|---|
255
+ | **A. Tailscale / WireGuard** | `100.x.y.z:8787:8787` (tailnet only) | none — **do not** set `--trust-proxy` | the network (WireGuard); plain HTTP over 100.x is correct |
256
+ | **B. Public VPS + domain** | `127.0.0.1:8787:8787` (default) | **`--trust-proxy` required** | nginx/Caddy on the host, on 443 |
257
+ | **C. Cloudflare Tunnel / ngrok** | `127.0.0.1:8787:8787` (default) | `--trust-proxy` if the tunnel sets `X-Forwarded-*`; add `--base-path /w2m` if it publishes a prefix | the tunnel service |
258
+
259
+ **TLS terminates at the proxy, not here.** The image ships no certificate and no
260
+ `--tls-cert`; the hop from the proxy to the container is a loopback-only VPS
261
+ hop. That is also why the compose default binds `127.0.0.1` — the relay has no
262
+ TLS and no authentication beyond its tokens, so it must not be directly
263
+ reachable.
264
+
265
+ `--trust-proxy` is deliberately **not** in the default command. Turning it on
266
+ makes the relay believe `X-Forwarded-For` and `X-Forwarded-Proto`; with it on
267
+ and the relay directly reachable, a client can forge its address and get a fresh
268
+ pairing-rate-limit budget per request. Turn it on when a proxy you control is
269
+ genuinely in front — which is Shape B and usually Shape C.
270
+
271
+ ---
272
+
273
+ ## 7. What was verified
274
+
275
+ Docker is **not installed** on the machine these files were authored on
276
+ (`docker --version` → not found; likewise `podman`, `buildah`, `nerdctl`), and
277
+ Docker Hub is unreachable from it. Everything below is therefore split into what
278
+ was actually executed and what was not.
279
+
280
+ ### Verified by execution
281
+
282
+ | Check | Result |
283
+ |---|---|
284
+ | Every `COPY` source exists in the repo | 4/4 sources present |
285
+ | The Dockerfile's COPY set covers the relay's real import closure | 7/7 closure files covered — no missing transitive import |
286
+ | Nothing is copied that the relay never loads | 1 exception, reported below |
287
+ | No package manager anywhere in the Dockerfile | none; `package.json` still `"dependencies": {}` |
288
+ | Base image is an exact patch tag | `node:22.23.3-bookworm-slim`; **the Node version is verified** (v22.23.3 is the current 22.x LTS, released 2026-09-23, per `https://nodejs.org/dist/index.json`) |
289
+ | Non-root `USER`, `COPY --chown=<uid>:<gid>`, state `VOLUME`, `HEALTHCHECK` on `/healthz`, `--host 0.0.0.0` | all present (numeric `--chown` so the classic builder and BuildKit behave the same) |
290
+ | `docker-compose.yml` parses as YAML with `services.relay`, a named volume, healthcheck, `restart: unless-stopped`, port mapping, volume mount | all present |
291
+ | `.github/workflows/docker.yml` parses as YAML | one job; uses `setup-qemu` + `setup-buildx` + `build-push-action`; `platforms: linux/amd64,linux/arm64`; `push: false`; `cache-from/to: type=gha`; no registry named |
292
+ | No credential **value** in the compose file | only `${…}` references and commented examples |
293
+
294
+ **Ran the container's command for real.** The exact CMD was executed against a
295
+ copy of the exact COPY set:
296
+ `node /app/bin/w2m-rabbit.mjs --host 0.0.0.0 --port 8891 --state <dir>`
297
+
298
+ | Result | Value |
299
+ |---|---|
300
+ | Relay started | yes |
301
+ | `GET /healthz` with no token | `200 application/json` |
302
+ | The `HEALTHCHECK` command string | exit code `0` |
303
+ | Pairing code on stderr | `PAIR-FFBO727A` (also on stdout in the banner) |
304
+ | State directory after start | `operator-token.txt` |
305
+ | `GET /` and `GET /v1/devices` with no token | `401 UNAUTHORIZED` (correct) |
306
+
307
+ This is strong evidence the image would start, **but it is not a build**: it
308
+ exercises the file set, not the Docker layer stack, and it ran on Windows under
309
+ Node 24 rather than inside Linux under Node 22.
310
+
311
+ ### UNVERIFIED — needs Docker or a registry
312
+
313
+ | Item | Why it is unverified |
314
+ |---|---|
315
+ | `docker build` succeeds at all | Docker not installed |
316
+ | `docker buildx build --platform linux/amd64,linux/arm64` succeeds | Docker not installed; the arm64 leg additionally needs QEMU |
317
+ | `node:22.23.3-bookworm-slim` **exists in the registry** | Docker Hub is unreachable from this machine (`auth.docker.io`, `registry-1.docker.io`, `hub.docker.com` all time out). The version number is verified; the tag's presence is not |
318
+ | The `HEALTHCHECK` transitions a real container to `(healthy)` | needs the Docker health daemon |
319
+ | `docker compose config` accepts the compose file | Compose not installed — the file was validated structurally instead |
320
+ | `docker compose up` starts the service and the volume is writable as uid 10001 | needs Docker |
321
+ | The multi-arch manifest actually contains both platforms | needs a build or a registry push |
322
+ | The GitHub Actions workflow runs green | needs a runner; only its YAML and inputs were checked |
323
+ | Any **push** | there are no registry credentials in this repository, and the workflow sets `push: false` on purpose |
324
+
325
+ ### Reported, not fixed
326
+
327
+ `src/relay/metrics.mjs` is copied into the image but **nothing imports it**: the
328
+ relay's import closure does not include it, and `GET /metrics` returns `401`
329
+ (the auth gate, not a metrics route). It is an untracked work-in-progress file
330
+ for the v0.3.3 "metrics" item — `CHANGELOG.md` lists metrics as still untouched.
331
+
332
+ It is harmless today (a few KB of dead payload). The Dockerfile copies
333
+ `src/relay/` as a directory rather than four named files so that wiring metrics
334
+ up later cannot silently produce an image missing a module; the cost is that the
335
+ directory's contents are not individually reviewed. If you would rather the
336
+ image contain only what runs, replace the `src/relay/` COPY with the four
337
+ current files (`server.mjs`, `state.mjs`, `persistence.mjs`, `report.mjs`) — but
338
+ then any new relay module must be added there in the same change that imports it.
@@ -0,0 +1,179 @@
1
+ # ============================================================================
2
+ # W2M relay — docker compose example
3
+ # ============================================================================
4
+ #
5
+ # Run from the repository root:
6
+ #
7
+ # docker compose -f deploy/docker/docker-compose.yml up -d --build
8
+ # docker compose -f deploy/docker/docker-compose.yml ps
9
+ # docker compose -f deploy/docker/docker-compose.yml logs -f relay
10
+ #
11
+ # *** THE FIRST THING YOU NEED IS THE PAIRING CODE, AND IT IS ON STDERR. ***
12
+ # The relay prints it at startup, so it is in the container log:
13
+ #
14
+ # docker compose -f deploy/docker/docker-compose.yml logs relay \
15
+ # | grep -i 'pairing code'
16
+ #
17
+ # See deploy/docker/README.md for the full operator walkthrough.
18
+ #
19
+ # NOT VERIFIED: docker is not installed on the machine these files were
20
+ # authored on, so `docker compose config`, `up` and the healthcheck were never
21
+ # executed. The file was validated as YAML and against the Compose structure by
22
+ # script — see deploy/docker/README.md, "What was verified".
23
+ #
24
+ # ---------------------------------------------------------------------------
25
+ # WHICH DEPLOYMENT SHAPE — and what changes for each
26
+ # ---------------------------------------------------------------------------
27
+ # All three shapes run THIS service; only the port binding and the flag list
28
+ # differ. Pick the shape, apply the one-line change, leave the rest alone.
29
+ #
30
+ # ── Shape B — public VPS + domain + TLS (recommended for this file) ─────────
31
+ # TLS terminates in nginx/Caddy on the HOST, not here, and not in the relay.
32
+ # This file's default already matches: the relay is published only on
33
+ # 127.0.0.1, so the proxy is the sole way in.
34
+ # REQUIRED CHANGE: uncomment `--trust-proxy` in the command below, because
35
+ # the proxy is what knows the real client IP. Without it every client is
36
+ # rate-limited as 127.0.0.1 and /healthz reports scheme "http".
37
+ # Also required: listen on 443 in the proxy, never publish 8787 publicly.
38
+ # ports: ["127.0.0.1:8787:8787"] # default, correct for shape B
39
+ #
40
+ # ── Shape C — Cloudflare Tunnel / ngrok ────────────────────────────────────
41
+ # Identical to shape B: the tunnel client runs on the host and dials
42
+ # 127.0.0.1:8787, so the default binding already works.
43
+ # Set `--trust-proxy` only if the tunnel sets X-Forwarded-* and you control
44
+ # it (a tunnel you created counts as yours).
45
+ # If the tunnel publishes the relay under a prefix, ALSO set
46
+ # `--base-path /w2m` here *and* put the same prefix in the clients'
47
+ # `rabbitUrl` — mismatch is the single most common cause of 404 on /v1/*.
48
+ #
49
+ # ── Shape A — Tailscale / WireGuard ───────────────────────────────────────
50
+ # A container is usually the wrong tool, because the relay must be reachable
51
+ # on the tailnet address and WireGuard already encrypts the link (so plain
52
+ # HTTP over 100.x is correct, not sloppy). If you do containerise it, either
53
+ # run the container on the tailnet (Tailscale sidecar / host networking) or
54
+ # publish on the tailnet address only:
55
+ # ports: ["100.x.y.z:8787:8787"]
56
+ # Do NOT set --trust-proxy here: there is no proxy to trust, and leaving it
57
+ # off is what stops a client forging X-Forwarded-For past the rate limit.
58
+ #
59
+ # ---------------------------------------------------------------------------
60
+ # SECRETS
61
+ # ---------------------------------------------------------------------------
62
+ # Do NOT put the operator token in this file, and do not commit it to .env.
63
+ # Leave `--operator-token` out: the relay generates one, prints it to stderr
64
+ # and writes it to <state>/operator-token.txt (0600). Read it with:
65
+ #
66
+ # docker compose -f deploy/docker/docker-compose.yml exec relay \
67
+ # cat /var/lib/w2m/operator-token.txt
68
+ #
69
+ # If you must inject your own, reference an environment variable that fails
70
+ # loudly when unset, so a typo cannot silently start an unprotected relay:
71
+ #
72
+ # # in the command: --operator-token ${W2M_OPERATOR_TOKEN:?set it}
73
+ # # and keep the value in a git-ignored .env
74
+ #
75
+ # ---------------------------------------------------------------------------
76
+ # WHAT THIS FILE DOES NOT DO
77
+ # ---------------------------------------------------------------------------
78
+ # It does not terminate TLS, and it does not publish the relay to the internet.
79
+ # Both are deliberate: the relay ships no TLS of its own in this configuration
80
+ # and carries no authentication beyond its tokens, so it belongs behind a proxy
81
+ # or on a private network.
82
+ # ============================================================================
83
+
84
+ services:
85
+ relay:
86
+ build:
87
+ # Context is the repository root so the Dockerfile's COPY paths resolve.
88
+ # `docker compose` resolves this relative to THIS file, hence two levels up.
89
+ context: ../..
90
+ dockerfile: deploy/docker/Dockerfile
91
+ # Tag matches package.json version so an image is traceable to a release.
92
+ image: w2m-relay:0.3.4
93
+ container_name: w2m-relay
94
+ # Survive a crash and a host reboot; `unless-stopped` means a manual
95
+ # `docker compose stop` is respected and not undone on restart.
96
+ restart: unless-stopped
97
+
98
+ # --- the relay command --------------------------------------------------
99
+ # Keep this list in sync with the shape you chose above and with
100
+ # docs/DEPLOY.md's rabbitUrl table.
101
+ command:
102
+ - --host
103
+ - 0.0.0.0
104
+ - --port
105
+ - "8787"
106
+ - --state
107
+ - /var/lib/w2m
108
+ # --- Shape B (proxy on the host) and Shape C (tunnel): REQUIRED ---------
109
+ # - --trust-proxy
110
+ # --- only when the proxy/tunnel forwards a prefix unchanged ------------
111
+ # - --base-path
112
+ # - /w2m
113
+ # --- request signing (v0.3.0). Prefer the env var over the flag so the
114
+ # --- secret does not appear in `docker inspect` output:
115
+ # - --require-signature
116
+ environment:
117
+ # Read by the relay as a fallback when the matching flag is absent.
118
+ # Uncomment and source from a git-ignored .env:
119
+ # W2M_OPERATOR_TOKEN: ${W2M_OPERATOR_TOKEN:?set W2M_OPERATOR_TOKEN in .env}
120
+ # W2M_SIGNING_SECRET: ${W2M_SIGNING_SECRET:?set W2M_SIGNING_SECRET in .env}
121
+ NODE_ENV: production
122
+
123
+ # --- ports --------------------------------------------------------------
124
+ # 127.0.0.1:8787 on the host = "reachable only from this machine", which is
125
+ # exactly right when nginx/Caddy/cloudflared runs on the same host.
126
+ #
127
+ # Changing this to "8787:8787" publishes the relay directly, with no TLS and
128
+ # no proxy in front: do that only on a network you already trust (shape A).
129
+ #
130
+ # The container's internal port stays 8787 in every shape, which is why the
131
+ # image HEALTHCHECK never needs changing.
132
+ ports:
133
+ - "127.0.0.1:8787:8787"
134
+
135
+ # --- persistence --------------------------------------------------------
136
+ # Holds devices.json, ledger.jsonl and operator-token.txt. Deleting this
137
+ # volume forces every paired machine to pair again.
138
+ volumes:
139
+ - w2m-state:/var/lib/w2m
140
+
141
+ # --- runtime hygiene ----------------------------------------------------
142
+ security_opt:
143
+ - no-new-privileges:true
144
+ # The image already runs as uid 10001; this makes privilege escalation
145
+ # impossible even if a future base image misconfigures its user.
146
+ cap_drop:
147
+ - ALL
148
+ # `read_only` needs scratch space for Node itself; the state volume above is
149
+ # the only other writable path.
150
+ read_only: true
151
+ tmpfs:
152
+ - /tmp:size=16m,mode=1777
153
+
154
+ # --- logging ------------------------------------------------------------
155
+ # The startup banner carries the pairing code and the operator token, so the
156
+ # log is sensitive: keep it bounded and treat it like a credential store.
157
+ logging:
158
+ driver: json-file
159
+ options:
160
+ max-size: 10m
161
+ max-file: "5"
162
+
163
+ # --- health -------------------------------------------------------------
164
+ # Duplicated from the image so the intent is visible here and so a reader
165
+ # does not have to open the Dockerfile to know what "healthy" means.
166
+ # /healthz requires no token.
167
+ healthcheck:
168
+ test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8787/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
169
+ interval: 30s
170
+ timeout: 5s
171
+ retries: 3
172
+ start_period: 5s
173
+
174
+ volumes:
175
+ w2m-state:
176
+ # `docker compose down` keeps this; `docker compose down -v` deletes it and
177
+ # with it every paired device. Back it up before any destructive command:
178
+ # docker run --rm -v w2m_w2m-state:/data -v "$PWD:/backup" \
179
+ # alpine tar czf /backup/w2m-state.tgz -C /data .