@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.6
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 +1288 -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 +13 -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
|
@@ -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 .
|