@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,196 @@
1
+ # ============================================================================
2
+ # W2M relay behind nginx -- example reverse-proxy configuration
3
+ # ============================================================================
4
+ #
5
+ # This file is a TEMPLATE. Replace every <placeholder> before using it.
6
+ # Nothing here contains a real domain, IP or token.
7
+ #
8
+ # Validated how: nginx is not installed on the authoring machine, so this
9
+ # file was NOT checked with `nginx -t`. It was written against the nginx
10
+ # directives' documented semantics, and the three Server-Sent-Events settings
11
+ # it depends on were verified against the relay's actual response headers
12
+ # (see docs/DEPLOY.md, "SSE and reverse proxies").
13
+ #
14
+ # ---------------------------------------------------------------------------
15
+ # THE THREE THINGS THAT MAKE SSE WORK (drop any one and events silently stop)
16
+ # ---------------------------------------------------------------------------
17
+ # 1. `proxy_buffering off;` nginx otherwise buffers the event stream and
18
+ # holds frames until its buffer fills, so the
19
+ # client connects, receives nothing, and looks
20
+ # "connected but dead".
21
+ # 2. `proxy_read_timeout <long>;` the stream is a long-lived response with no
22
+ # data between keepalives (the relay sends
23
+ # `: keepalive` every 15s). The nginx default
24
+ # of 60s is survivable, but any shorter value
25
+ # kills idle connections.
26
+ # 3. `proxy_http_version 1.1;` SSE is an HTTP/1.1 chunked response. With the
27
+ # nginx default (1.0) the connection is closed
28
+ # at the end of the first response, so the
29
+ # stream ends immediately after it starts.
30
+ # The relay also sends `X-Accel-Buffering: no` and
31
+ # `Cache-Control: no-cache, no-transform`, which handles (1) for nginx
32
+ # automatically -- but keep `proxy_buffering off` anyway, because that header
33
+ # is nginx-specific and you do not want correctness to depend on one vendor.
34
+ #
35
+ # ---------------------------------------------------------------------------
36
+ # TWO DEPLOYMENT SHAPES -- pick ONE
37
+ # ---------------------------------------------------------------------------
38
+ # Shape 1 (recommended, simplest): the relay is mounted at the ROOT of a
39
+ # dedicated hostname. Everything below marked [ROOT] applies; delete the
40
+ # sub-path block at the bottom. Relay flags: no --base-path.
41
+ #
42
+ # Shape 2: the relay shares a hostname and lives under a prefix, e.g.
43
+ # https://<your-domain>/w2m/. Everything marked [ROOT] applies, PLUS the
44
+ # sub-path block at the bottom, and the relay MUST be started with
45
+ # `--base-path /w2m`. See docs/DEPLOY.md, "Sub-path deployments".
46
+ # ============================================================================
47
+
48
+ # Upstream: the relay listening on loopback. Keep it on loopback and let nginx
49
+ # be the only thing exposed; the relay then needs no TLS of its own.
50
+ upstream w2m_relay {
51
+ server 127.0.0.1:8787;
52
+ keepalive 16;
53
+ }
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # HTTP -> HTTPS redirect.
57
+ # Remove this block if something else (CDN, load balancer) already redirects.
58
+ # ---------------------------------------------------------------------------
59
+ server {
60
+ listen 80;
61
+ listen [::]:80;
62
+ server_name <your-domain>;
63
+
64
+ # Let's Encrypt HTTP-01 challenge. Keep this even after issuance: renewal
65
+ # needs it every ~60 days.
66
+ location /.well-known/acme-challenge/ {
67
+ root /var/www/certbot;
68
+ }
69
+
70
+ location / {
71
+ return 301 https://$host$request_uri;
72
+ }
73
+ }
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # The relay itself.
77
+ # ---------------------------------------------------------------------------
78
+ server {
79
+ listen 443 ssl;
80
+ listen [::]:443 ssl;
81
+ http2 on;
82
+ server_name <your-domain>;
83
+
84
+ # --- certificates -------------------------------------------------------
85
+ # Paths are what certbot writes by default; adjust to your own layout.
86
+ ssl_certificate /etc/letsencrypt/live/<your-domain>/fullchain.pem;
87
+ ssl_certificate_key /etc/letsencrypt/live/<your-domain>/privkey.pem;
88
+ ssl_protocols TLSv1.2 TLSv1.3;
89
+ ssl_session_cache shared:SSL:10m;
90
+ ssl_session_timeout 1d;
91
+
92
+ # --- request size -------------------------------------------------------
93
+ # Task results carry command output inline, so the default 1m is too small
94
+ # for a build log. Match `max_frame_bytes` on the relay side; 16m is a
95
+ # comfortable default for text output.
96
+ client_max_body_size 16m;
97
+
98
+ # --- SSE-friendly defaults ---------------------------------------------
99
+ # HTTP/1.1 is required for chunked event streams (reason 3 above). gzip
100
+ # must stay OFF here: compressing an event stream makes nginx buffer it,
101
+ # which reintroduces the exact failure this file exists to prevent.
102
+ proxy_http_version 1.1;
103
+ proxy_set_header Connection "";
104
+ gzip off;
105
+
106
+ # --- identity headers ---------------------------------------------------
107
+ # Host is what the relay (and its rate limiter) sees. X-Forwarded-* are
108
+ # only believed by the relay when it is started with --trust-proxy, which
109
+ # is exactly the case here, so these three must be set correctly.
110
+ proxy_set_header Host $host;
111
+ proxy_set_header X-Real-IP $remote_addr;
112
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
113
+ proxy_set_header X-Forwarded-Proto $scheme;
114
+ proxy_set_header X-Forwarded-Host $host;
115
+ # The port the client actually used, needed when it is not the scheme
116
+ # default (the relay uses it to build its public URL hints).
117
+ proxy_set_header X-Forwarded-Port $server_port;
118
+
119
+ # ======================= [ROOT] health check ==========================
120
+ # Kept unauthenticated so `curl https://<your-domain>/healthz` from a
121
+ # laptop answers "is the relay alive, and has it restarted?".
122
+ location = /healthz {
123
+ proxy_pass http://w2m_relay/healthz;
124
+
125
+ # --- the three SSE settings also apply to this endpoint's server block
126
+ # but buffering is harmless for a small JSON body, so only the timeout
127
+ # matters here.
128
+ proxy_read_timeout 30s;
129
+ }
130
+
131
+ # ======================= [ROOT] event stream ==========================
132
+ # The SSE endpoint gets its own location so the long timeout and disabled
133
+ # buffering cannot be forgotten for the one route that needs them.
134
+ location = /v1/stream {
135
+ proxy_pass http://w2m_relay/v1/stream;
136
+
137
+ # 1. no buffering: frames must leave immediately
138
+ proxy_buffering off;
139
+ proxy_cache off;
140
+ # 2. long-lived response: keepalives arrive every 15s; allow a wide
141
+ # margin (this is 24h, i.e. effectively "do not time out")
142
+ proxy_read_timeout 86400s;
143
+ proxy_send_timeout 86400s;
144
+ # 3. HTTP/1.1 chunked (set for the whole server block above; repeated
145
+ # here so this file is safe to copy location-by-location)
146
+ proxy_http_version 1.1;
147
+ proxy_set_header Connection "";
148
+ }
149
+
150
+ # ======================= [ROOT] everything else =======================
151
+ location / {
152
+ proxy_pass http://w2m_relay;
153
+
154
+ proxy_buffering off;
155
+ proxy_read_timeout 120s;
156
+ proxy_send_timeout 120s;
157
+ }
158
+
159
+ # ======================= Shape 2: sub-path ============================
160
+ # Uncomment this block ONLY if the relay is mounted under a prefix.
161
+ #
162
+ # nginx passes the prefix through unchanged (`proxy_pass` with no URI),
163
+ # and the relay is started with `--base-path /w2m`, which is the
164
+ # combination PROTOCOL-v0.1.2 §3 describes as "反代原样透传".
165
+ #
166
+ # Client `rabbitUrl` must then include the same prefix:
167
+ # https://<your-domain>/w2m
168
+ #
169
+ # Do NOT use a trailing-slash rewrite (`proxy_pass http://w2m_relay/;`)
170
+ # here: stripping the prefix in nginx while the relay also expects it is
171
+ # the single most common misconfiguration, and it produces 404 on every
172
+ # /v1/* call.
173
+ #
174
+ # location /w2m/ {
175
+ # proxy_pass http://w2m_relay;
176
+ #
177
+ # proxy_http_version 1.1;
178
+ # proxy_set_header Connection "";
179
+ # proxy_set_header Host $host;
180
+ # proxy_set_header X-Real-IP $remote_addr;
181
+ # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
182
+ # proxy_set_header X-Forwarded-Proto $scheme;
183
+ # proxy_set_header X-Forwarded-Port $server_port;
184
+ #
185
+ # proxy_buffering off;
186
+ # proxy_read_timeout 86400s;
187
+ # proxy_send_timeout 86400s;
188
+ # }
189
+ #
190
+ # # The relay answers /healthz both with and without the prefix, so a
191
+ # # monitoring probe pointed at the bare path keeps working:
192
+ # location = /healthz {
193
+ # proxy_pass http://w2m_relay/healthz;
194
+ # proxy_read_timeout 30s;
195
+ # }
196
+ }
@@ -0,0 +1,118 @@
1
+ # The shared server
2
+
3
+ One always-on host that gives every machine in the fleet the two things a machine behind
4
+ NAT cannot provide for itself: **a place to meet** (rendezvous + signalling + the task
5
+ ledger) and **an honest answer about its own address** (a STUN responder, so a hole punch
6
+ has somewhere to aim).
7
+
8
+ This directory deploys that host in one command. It is the same sequence the public
9
+ instance was built with.
10
+
11
+ ## What runs where
12
+
13
+ | Component | Listens | Why it is there |
14
+ |---|---|---|
15
+ | `w2m-rabbit` (relay) | `0.0.0.0:8787` tcp | Pairing, the SSE downlink, task offers, the result ledger, `POST /v1/peer/announce` and `GET /v1/peer/{machine_id}` — the rendezvous the punch needs |
16
+ | `w2m-stun` | `0.0.0.0:3478` udp | Answers "what address and port does the world see me as", which is the address a peer must aim at |
17
+ | nginx | `:80` tcp | A second path to the same relay on a port that is open on most hosts, with SSE-safe settings (`proxy_buffering off`, 24 h read timeout on `/v1/stream`) |
18
+ | (optional) `w2m-localside` | outbound only | Makes the server itself a machine in the fleet — useful for a cross-region check that does not involve a second laptop |
19
+
20
+ Everything is outbound from the machines' point of view. The server never dials a machine;
21
+ it answers, and it introduces peers to each other.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ scp twinsearth-w2m-dsh-plugin-<version>.tgz root@<host>:/tmp/w2m-pkg.tgz
27
+ scp deploy/shared-server/install.sh root@<host>:/tmp/
28
+ ssh root@<host> 'bash /tmp/install.sh /tmp/w2m-pkg.tgz' # relay + STUN + proxy
29
+ ssh root@<host> 'bash /tmp/install.sh /tmp/w2m-pkg.tgz PAIR-XXXXXXXX' # ... and join the fleet
30
+ ```
31
+
32
+ The pairing code comes from the relay's first start (`journalctl -u w2m-rabbit` or
33
+ `/var/log/w2m/rabbit.log`); it rotates after each successful pairing.
34
+
35
+ Then point every machine at it — in v0.4.0 that is the **default**, so an empty `rabbitUrl`
36
+ already means "the shared server":
37
+
38
+ ```yaml
39
+ - id: w2m
40
+ config:
41
+ rabbitUrl: 'http://202.182.123.154:8787' # optional: this is the default anyway
42
+ operatorToken: '<from /var/lib/w2m/rabbit/operator-token.txt>'
43
+ p2pMode: auto
44
+ ```
45
+
46
+ ## The two decisions worth knowing
47
+
48
+ **1. The relay binds `0.0.0.0`, unlike `deploy/systemd/w2m-rabbit.service`.** That template
49
+ binds loopback on purpose, because on a private host something else terminates TLS or the
50
+ network is already encrypted. A *shared* server has no such thing in front of it: it is the
51
+ public endpoint. Binding loopback would mean the machines cannot reach it at all, so the
52
+ public port is the point rather than an oversight. Port 80 through nginx reaches exactly the
53
+ same relay; the two differ in nothing but the port number.
54
+
55
+ **2. There is no TLS on the reference instance, and that is a real, stated cost.** Without a
56
+ hostname there is nothing to issue a certificate for. Everything the relay sees — pairing
57
+ codes, device tokens, the operator token, task offers and every result — crosses the wire in
58
+ clear text. What can be said honestly about the exposure:
59
+
60
+ * the two sockets that matter most for a fleet are still the machines' own: with the direct
61
+ path up, the payload does not traverse the relay at all (`transport` says which path a task
62
+ actually took);
63
+ * the relay is not a shell: it can forge tasks, and the machine-side allow-list is the
64
+ load-bearing control;
65
+ * the operator token is a bearer credential in clear text. Treat it as compromised if the
66
+ path is hostile, and rotate it by deleting `operator-token.txt` and restarting the relay.
67
+
68
+ **To add TLS**, point a subdomain at the host (`w2m.example.com`), then run certbot with the
69
+ webroot inside the nginx block this script installs, and add the `listen 443 ssl` server from
70
+ `deploy/nginx/w2m.conf.example`. Nothing on the machines has to change except `rabbitUrl`.
71
+
72
+ ## Verify the deployment
73
+
74
+ ```bash
75
+ # 1. the relay answers
76
+ curl -fsS http://127.0.0.1:8787/healthz | head -c 200
77
+
78
+ # 2. the relaying is real: pair a machine and watch it appear
79
+ curl -fsS -H "Authorization: Bearer $(cat /var/lib/w2m/rabbit/operator-token.txt)" ... # needs a device token, not the operator token
80
+
81
+ # 3. STUN answers through the responder (the number it returns is the NAT's opinion of you)
82
+ node -e "import('/opt/w2m/app/src/agent/stun.mjs').then(async (s) => {
83
+ const { socket } = await s.bindUdpSocket();
84
+ console.log(await s.stunQuery(socket, '<host>:3478')); socket.close(); })"
85
+ ```
86
+
87
+ A healthy instance answers all three. From a machine **behind another network**, run
88
+ `src/agent/stun.mjs`'s `discoverReflexive` against two or three servers: if every server
89
+ reports the same mapped port, the network is `endpoint-independent` and punching can work; if
90
+ each reports a different one, the network is `endpoint-dependent` (symmetric) and the punch
91
+ cannot succeed **from that network** whatever the server does. Measured, and written down
92
+ rather than discovered later: the reference client network (China Mobile) is
93
+ `endpoint-dependent`, so it falls back to the relay — which is exactly why `auto` and not
94
+ `direct` is the default.
95
+
96
+ ## Operating it
97
+
98
+ ```bash
99
+ systemctl status w2m-rabbit w2m-stun
100
+ journalctl -u w2m-rabbit -f
101
+ tail -f /var/log/w2m/rabbit.log /var/log/w2m/stun.log
102
+ ```
103
+
104
+ * State lives in `/var/lib/w2m/rabbit` (`devices.json` + `ledger.jsonl`), so a restart keeps
105
+ every paired machine and the task history. Losing that directory means every machine pairs
106
+ again — see `docs/TROUBLESHOOTING.md`.
107
+ * `ufw` needs `8787/tcp` and `3478/udp`; port 80 only if you use the proxy path.
108
+ * **If this host must *accept* a punch** (it runs a Localside agent and a peer dials it), its punch
109
+ socket needs a reachable UDP port *and* the port has to survive a restart. Pin it — until v0.4.1
110
+ the node bound an ephemeral one, so the rule had to be re-pointed after every agent start, and a
111
+ stale rule showed up only as a punch that timed out and tasks that quietly used the relay:
112
+
113
+ ```bash
114
+ # add to the agent's ExecStart, then: ufw allow 41234/udp
115
+ --p2p-port 41234
116
+ ```
117
+ * Upgrades are the same command: fetch the new tarball, run `install.sh` again. The relay
118
+ restarts; machines reconnect on their own (the agent's stream reconnects with backoff).
@@ -0,0 +1,255 @@
1
+ #!/bin/bash
2
+ # ============================================================================
3
+ # W2M shared server -- relay + STUN + reverse proxy, one command.
4
+ # ============================================================================
5
+ #
6
+ # This is the script the shared server at 202.182.123.154 was deployed with (the
7
+ # same steps, parameterised). It is idempotent: re-running it re-deploys the
8
+ # package and restarts the services, which is how an upgrade is done.
9
+ #
10
+ # Usage, on a fresh Debian/Ubuntu host as root:
11
+ #
12
+ # curl -fsSL https://nodejs.org/... # not needed: this script installs Node itself
13
+ # scp twinsearth-w2m-dsh-plugin-<version>.tgz root@<host>:/tmp/w2m-pkg.tgz
14
+ # bash install.sh /tmp/w2m-pkg.tgz [PAIR-XXXXXXXX]
15
+ #
16
+ # What it creates:
17
+ # /opt/w2m/app the package (bin/, src/, lib/)
18
+ # /var/lib/w2m/rabbit relay state: devices.json, ledger, operator token
19
+ # /etc/systemd/system/w2m-rabbit.service relay on 0.0.0.0:8787
20
+ # /etc/systemd/system/w2m-stun.service STUN responder on 0.0.0.0:3478/udp
21
+ # /etc/nginx/conf.d/w2m.conf port 80 -> 127.0.0.1:8787, SSE-safe
22
+ # ufw: 8787/tcp, 3478/udp
23
+ #
24
+ # With a package tarball the archive root is `package/` (npm pack layout); a git
25
+ # checkout tarball has no root directory. Both are handled below.
26
+ #
27
+ # WHY THE RELAY BINDS 0.0.0.0 HERE, when deploy/systemd/w2m-rabbit.service
28
+ # deliberately binds 127.0.0.1: on a *shared* server the relay IS the public
29
+ # endpoint -- there is no private network in front of it and no TLS terminator
30
+ # unless one is added. Port 80 through nginx is the same relay, so the two paths
31
+ # differ in nothing but the port. See deploy/shared-server/README.md for the
32
+ # security consequences, which are real: without TLS the operator token and every
33
+ # result travel in clear text.
34
+ # ============================================================================
35
+ set -euo pipefail
36
+
37
+ PKG="${1:?usage: install.sh <package.tgz|checkout.tar.gz> [PAIR-XXXXXXXX]}"
38
+ PAIR_CODE="${2:-}"
39
+ RABBIT_PORT="${RABBIT_PORT:-8787}"
40
+ STUN_PORT="${STUN_PORT:-3478}"
41
+ NODE_DIR=/opt/node22
42
+ APP_DIR=/opt/w2m/app
43
+ STATE_DIR=/var/lib/w2m/rabbit
44
+ LOG_DIR=/var/log/w2m
45
+
46
+ say() { printf '\n== %s ==\n' "$1"; }
47
+
48
+ say "node"
49
+ if [ ! -x "${NODE_DIR}/bin/node" ]; then
50
+ case "$(uname -m)" in
51
+ x86_64) NODE_ARCH=linux-x64 ;;
52
+ aarch64) NODE_ARCH=linux-arm64 ;;
53
+ *) echo "unsupported architecture $(uname -m)"; exit 1 ;;
54
+ esac
55
+ NODE_VERSION=v22.14.0
56
+ ( cd /tmp
57
+ curl -fsSL -o "node.tar.xz" "https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-${NODE_ARCH}.tar.xz"
58
+ mkdir -p "${NODE_DIR}"
59
+ tar -xJf node.tar.xz -C "${NODE_DIR}" --strip-components=1
60
+ rm -f node.tar.xz )
61
+ fi
62
+ ln -sf "${NODE_DIR}/bin/node" /usr/local/bin/node
63
+ ln -sf "${NODE_DIR}/bin/npm" /usr/local/bin/npm
64
+ node -v
65
+
66
+ say "package"
67
+ mkdir -p "${APP_DIR}" "${STATE_DIR}" "${LOG_DIR}"
68
+ STAGE="$(mktemp -d)"
69
+ tar -xzf "${PKG}" -C "${STAGE}"
70
+ if [ -d "${STAGE}/package/bin" ]; then APP_SRC="${STAGE}/package"; else APP_SRC="${STAGE}"; fi
71
+ [ -f "${APP_SRC}/bin/w2m-rabbit.mjs" ] || { echo "not a W2M package: ${PKG}"; exit 1; }
72
+ tar -C "${APP_SRC}" -cf - . | tar -C "${APP_DIR}" -xf -
73
+ rm -rf "${STAGE}"
74
+
75
+ if ! id w2m >/dev/null 2>&1; then useradd --system --home /var/lib/w2m --shell /usr/sbin/nologin w2m; fi
76
+ chown -R w2m:w2m /opt/w2m /var/lib/w2m "${LOG_DIR}"
77
+ node --check "${APP_DIR}/bin/w2m-rabbit.mjs"
78
+ node --check "${APP_DIR}/bin/w2m-stun.mjs"
79
+ echo "version: $(node -p "require('${APP_DIR}/package.json').version")"
80
+
81
+ say "services"
82
+ cat > /etc/systemd/system/w2m-rabbit.service <<UNIT
83
+ [Unit]
84
+ Description=W2M Rabbit relay (P2P rendezvous, signalling and task ledger)
85
+ After=network-online.target
86
+ Wants=network-online.target
87
+
88
+ [Service]
89
+ Type=simple
90
+ User=w2m
91
+ Group=w2m
92
+ WorkingDirectory=${APP_DIR}
93
+ ExecStart=/usr/local/bin/node ${APP_DIR}/bin/w2m-rabbit.mjs --host 0.0.0.0 --port ${RABBIT_PORT} --state ${STATE_DIR}
94
+ Restart=always
95
+ RestartSec=2
96
+ NoNewPrivileges=true
97
+ PrivateTmp=true
98
+ ProtectSystem=full
99
+ ProtectHome=true
100
+ ReadWritePaths=/var/lib/w2m
101
+ StandardOutput=append:${LOG_DIR}/rabbit.log
102
+ StandardError=append:${LOG_DIR}/rabbit.log
103
+
104
+ [Install]
105
+ WantedBy=multi-user.target
106
+ UNIT
107
+
108
+ cat > /etc/systemd/system/w2m-stun.service <<UNIT
109
+ [Unit]
110
+ Description=W2M STUN responder - reflexive address discovery for P2P hole punching
111
+ After=network-online.target
112
+ Wants=network-online.target
113
+
114
+ [Service]
115
+ Type=simple
116
+ User=w2m
117
+ Group=w2m
118
+ ExecStart=/usr/local/bin/node ${APP_DIR}/bin/w2m-stun.mjs --host 0.0.0.0 --port ${STUN_PORT} --log-level info
119
+ Restart=always
120
+ RestartSec=2
121
+ NoNewPrivileges=true
122
+ PrivateTmp=true
123
+ ProtectSystem=strict
124
+ ProtectHome=true
125
+ ProtectKernelTunables=true
126
+ ProtectControlGroups=true
127
+ RestrictAddressFamilies=AF_INET AF_INET6
128
+ StandardOutput=append:${LOG_DIR}/stun.log
129
+ StandardError=append:${LOG_DIR}/stun.log
130
+
131
+ [Install]
132
+ WantedBy=multi-user.target
133
+ UNIT
134
+
135
+ systemctl daemon-reload
136
+ systemctl enable w2m-rabbit w2m-stun >/dev/null
137
+ systemctl restart w2m-stun
138
+ systemctl restart w2m-rabbit
139
+ sleep 2
140
+ systemctl is-active w2m-rabbit w2m-stun
141
+
142
+ say "firewall"
143
+ if command -v ufw >/dev/null 2>&1; then
144
+ ufw allow "${RABBIT_PORT}/tcp" >/dev/null
145
+ ufw allow "${STUN_PORT}/udp" >/dev/null
146
+ ufw status | grep -E "${RABBIT_PORT}|${STUN_PORT}" || true
147
+ else
148
+ echo "ufw not installed; open ${RABBIT_PORT}/tcp and ${STUN_PORT}/udp yourself"
149
+ fi
150
+
151
+ say "reverse proxy on :80"
152
+ if command -v nginx >/dev/null 2>&1; then
153
+ cat > /etc/nginx/conf.d/w2m.conf <<NGINX
154
+ # Port 80 in front of the relay. \`proxy_pass\` with no URI passes the path through
155
+ # unchanged, so the relay needs no --base-path here. /v1/stream is SSE: buffering must
156
+ # be off and the read timeout must exceed the stream's lifetime.
157
+ upstream w2m_relay {
158
+ server 127.0.0.1:${RABBIT_PORT};
159
+ keepalive 16;
160
+ }
161
+
162
+ server {
163
+ listen 80 default_server;
164
+ listen [::]:80 default_server;
165
+ server_name _;
166
+ client_max_body_size 8m;
167
+
168
+ location = /healthz {
169
+ proxy_pass http://w2m_relay/healthz;
170
+ proxy_set_header Host \$host;
171
+ proxy_read_timeout 30s;
172
+ access_log off;
173
+ }
174
+
175
+ location = /v1/stream {
176
+ proxy_pass http://w2m_relay/v1/stream;
177
+ proxy_http_version 1.1;
178
+ proxy_set_header Connection "";
179
+ proxy_set_header Host \$host;
180
+ proxy_set_header X-Real-IP \$remote_addr;
181
+ proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
182
+ proxy_set_header X-Forwarded-Proto \$scheme;
183
+ proxy_buffering off;
184
+ proxy_cache off;
185
+ proxy_read_timeout 86400s;
186
+ proxy_send_timeout 86400s;
187
+ }
188
+
189
+ location / {
190
+ proxy_pass http://w2m_relay;
191
+ proxy_http_version 1.1;
192
+ proxy_set_header Connection "";
193
+ proxy_set_header Host \$host;
194
+ proxy_set_header X-Real-IP \$remote_addr;
195
+ proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
196
+ proxy_set_header X-Forwarded-Proto \$scheme;
197
+ proxy_buffering off;
198
+ proxy_read_timeout 120s;
199
+ proxy_send_timeout 120s;
200
+ }
201
+ }
202
+ NGINX
203
+ # Transactional: this host may already front something else.
204
+ if nginx -t 2>/tmp/w2m-nginx-test.log; then
205
+ systemctl reload nginx
206
+ echo "nginx reloaded"
207
+ else
208
+ echo "nginx config test failed:"
209
+ cat /tmp/w2m-nginx-test.log
210
+ rm -f /etc/nginx/conf.d/w2m.conf
211
+ exit 1
212
+ fi
213
+ else
214
+ echo "nginx not installed; the relay is still reachable directly on :${RABBIT_PORT}"
215
+ fi
216
+
217
+ say "health"
218
+ curl -fsS "http://127.0.0.1:${RABBIT_PORT}/healthz" >/dev/null && echo "relay: ok"
219
+ node -e "
220
+ import('${APP_DIR}/src/agent/stun.mjs').then(async (stun) => {
221
+ const { socket } = await stun.bindUdpSocket();
222
+ const one = await stun.stunQuery(socket, '127.0.0.1:${STUN_PORT}');
223
+ console.log('stun :', one.ok ? 'ok reflexive=' + JSON.stringify(one.reflexive) : 'FAILED ' + one.error);
224
+ socket.close();
225
+ });
226
+ "
227
+ echo "operator token: ${STATE_DIR}/operator-token.txt"
228
+
229
+ say "optional: this host as a fleet machine"
230
+ if [ -n "${PAIR_CODE}" ]; then
231
+ DEMO=/opt/w2m/demo
232
+ mkdir -p "${DEMO}"
233
+ printf '* text=auto eol=lf\n' > "${DEMO}/.gitattributes"
234
+ printf 'console.log("w2m-wan-ok");\n' > "${DEMO}/demo.mjs"
235
+ ( cd "${DEMO}"
236
+ git config --global --add safe.directory "${DEMO}" >/dev/null 2>&1 || true
237
+ [ -d .git ] || git init -q -b main
238
+ git -c user.email=w2m@localhost -c user.name=w2m add -A
239
+ git -c user.email=w2m@localhost -c user.name=w2m commit -qm "w2m demo project" || true )
240
+ chown -R w2m:w2m "${DEMO}" /var/lib/w2m
241
+ # `--once` streams after pairing, so the pair run is bounded by timeout on purpose.
242
+ timeout 25 sudo -u w2m env HOME=/var/lib/w2m DSH_HOME=/var/lib/w2m/.dsh \
243
+ /usr/local/bin/node "${APP_DIR}/bin/w2m-localside.mjs" \
244
+ --rabbit "http://127.0.0.1:${RABBIT_PORT}" --pair "${PAIR_CODE}" \
245
+ --project "${DEMO}" --name "$(hostname)" --state /var/lib/w2m/localside \
246
+ --allowed-commands '["node --version","node -e","git status --porcelain"]' >/tmp/w2m-pair.log 2>&1 || true
247
+ grep -E 'paired|error' /tmp/w2m-pair.log | head -3 || true
248
+ chown -R w2m:w2m /var/lib/w2m
249
+ echo "pairing done; start the agent with:"
250
+ echo " sudo -u w2m env HOME=/var/lib/w2m node ${APP_DIR}/bin/w2m-localside.mjs --rabbit http://127.0.0.1:${RABBIT_PORT} --project ${DEMO} --name $(hostname) --state /var/lib/w2m/localside --allowed-commands '[\"node --version\"]'"
251
+ fi
252
+
253
+ say "done"
254
+ echo "relay : http://<host>:${RABBIT_PORT} (also http://<host>/ through nginx)"
255
+ echo "stun : <host>:${STUN_PORT}/udp"