@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,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"
|