@twinsearth/w2m-dsh-plugin 0.6.3 → 0.8.2

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 (42) hide show
  1. package/CHANGELOG.md +1176 -0
  2. package/HANDOFF.md +173 -0
  3. package/README.md +9 -2
  4. package/bin/w2m-localside.mjs +325 -308
  5. package/bin/w2m-rabbit.mjs +6 -0
  6. package/bin/w2m-stun.mjs +0 -0
  7. package/deploy/mac/mac-self-update.mjs +257 -0
  8. package/deploy/server/README.md +109 -0
  9. package/deploy/server/deploy-w2m.sh +161 -0
  10. package/deploy/server/w2m-localside.service +31 -0
  11. package/deploy/server/w2m-rabbit.service +26 -0
  12. package/deploy/server/w2m-stun.service +26 -0
  13. package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
  14. package/package.json +34 -22
  15. package/src/agent/agent.mjs +24 -2
  16. package/src/chatops/account.mjs +401 -0
  17. package/src/chatops/actions.mjs +227 -0
  18. package/src/chatops/approval-broker.mjs +320 -0
  19. package/src/chatops/auth.mjs +376 -0
  20. package/src/chatops/bridge.mjs +344 -0
  21. package/src/chatops/channel.mjs +192 -0
  22. package/src/chatops/commands.mjs +409 -0
  23. package/src/chatops/config-route.mjs +332 -0
  24. package/src/chatops/device-signing.mjs +218 -0
  25. package/src/chatops/durability.mjs +202 -0
  26. package/src/chatops/file-safety.mjs +282 -0
  27. package/src/chatops/fleet-join.mjs +164 -0
  28. package/src/chatops/fleet-scope.mjs +176 -0
  29. package/src/chatops/fleet-wiring.mjs +258 -0
  30. package/src/chatops/fleet.mjs +272 -0
  31. package/src/chatops/handoff.mjs +258 -0
  32. package/src/chatops/ilink-fleet-bridge.mjs +197 -0
  33. package/src/chatops/ilink-protocol.mjs +165 -0
  34. package/src/chatops/ilink-transport.mjs +168 -0
  35. package/src/chatops/ilink.mjs +423 -0
  36. package/src/chatops/index.mjs +440 -0
  37. package/src/chatops/outbound-queue.mjs +282 -0
  38. package/src/chatops/policy.mjs +356 -0
  39. package/src/chatops/run-command.mjs +308 -0
  40. package/src/plugin/tools.mjs +1 -1
  41. package/src/relay/server.mjs +96 -0
  42. package/src/relay/state.mjs +2279 -2011
@@ -53,6 +53,9 @@ Credentials and state:
53
53
  --no-persist Keep everything in memory; forget devices and tasks on
54
54
  restart.
55
55
  --pairing-code <c> Use a fixed pairing code instead of a generated one.
56
+ --fleet <id> Bind this relay to one account's fleet. Omitted, the relay serves
57
+ every device (the pre-v0.7.7 behaviour). Set, a device with no
58
+ fleet reaches nothing and a device in another fleet is invisible.
56
59
 
57
60
  Request signing (v0.3.0):
58
61
  --signing-secret <s> Shared secret for HMAC-signed requests. Unset means the relay does
@@ -98,6 +101,9 @@ try {
98
101
  port: { type: 'string' },
99
102
  state: { type: 'string' },
100
103
  'pairing-code': { type: 'string' },
104
+ // v0.7.7: bind this relay to a fleet. Omitted means every device, which is the
105
+ // pre-v0.7.7 behaviour and therefore the migration-safe default.
106
+ fleet: { type: 'string' },
101
107
  'heartbeat-ms': { type: 'string' },
102
108
  'grace-ms': { type: 'string' },
103
109
  'base-path': { type: 'string' },
package/bin/w2m-stun.mjs CHANGED
File without changes
@@ -0,0 +1,257 @@
1
+ /**
2
+ * The Mac's self-updating supervisor (v0.8.0).
3
+ *
4
+ * ## Why this is detached rather than run inline
5
+ *
6
+ * It has to **survive the thing it is restarting**. DSH spawns the Localside agent, so a script
7
+ * running inside that agent dies the moment DSH stops — which is exactly when the work begins. So
8
+ * this is launched detached with `stdio: 'ignore'` and `unref()`, and it is the *only* process that
9
+ * outlives the restart.
10
+ *
11
+ * ## The failure this is built around
12
+ *
13
+ * A blind remote update has one catastrophic outcome: DSH stops, the plugin does not load, and the
14
+ * machine never comes back — leaving no channel to fix it with. So every step is ordered so that
15
+ * **the previous state is restorable without the network**:
16
+ *
17
+ * 1. back up the installed plugin tree to a timestamped directory
18
+ * 2. stage the new tarball and verify it EXTRACTED (a truncated download upacks to nothing)
19
+ * 3. swap, then restart
20
+ * 4. **self-check**: is DSH running, and is the plugin loaded?
21
+ * 5. **on failure, roll back and restart again** — automatically, because nobody is watching
22
+ *
23
+ * ## How success is reported when nothing can be read back
24
+ *
25
+ * The machine cannot tell us anything directly: its task results are `unverifiable` and its stdout
26
+ * never reaches the relay. So the report is written to a file **and** the outcome is made visible
27
+ * indirectly — a successful restart puts the machine back in the fleet, and the caller watches for
28
+ * it. `--verify-fleet` does that check from inside this script as well, against the relay's
29
+ * `/healthz`, so the rollback decision does not depend on an outside observer.
30
+ *
31
+ * Usage: node mac-self-update.mjs --tarball <path> --sha256 <hex> [--verify-fleet <url>]
32
+ */
33
+
34
+ import { execFileSync, spawn } from 'node:child_process';
35
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync, rmSync, readdirSync, statSync } from 'node:fs';
36
+ import { createHash } from 'node:crypto';
37
+ import { homedir, tmpdir } from 'node:os';
38
+ import { join } from 'node:path';
39
+
40
+ const argv = process.argv.slice(2);
41
+ const opt = (name, dflt) => (argv.includes(name) ? argv[argv.indexOf(name) + 1] : dflt);
42
+
43
+ const TARBALL = opt('--tarball', '');
44
+ const EXPECTED = opt('--sha256', '');
45
+ const VERIFY_FLEET = opt('--verify-fleet', '');
46
+ const PACKAGE = '@twinsearth/w2m-dsh-plugin';
47
+ const STAMP = new Date().toISOString().replace(/[:.]/g, '-');
48
+ const REPORT = join(homedir(), 'w2m-self-update-report.json');
49
+
50
+ const steps = [];
51
+ const record = (step, ok, detail) => {
52
+ steps.push({ step, ok, detail, at: new Date().toISOString() });
53
+ // Written after every step: if the process is killed mid-way, the report says where it stopped.
54
+ try { writeFileSync(REPORT, JSON.stringify({ stamp: STAMP, steps, done: false }, null, 2)); } catch { /* the report must never break the update */ }
55
+ };
56
+ const finish = (ok, extra = {}) => {
57
+ try { writeFileSync(REPORT, JSON.stringify({ stamp: STAMP, ok, steps, done: true, ...extra }, null, 2)); } catch { /* as above */ }
58
+ process.exit(ok ? 0 : 1);
59
+ };
60
+
61
+ /** Run a command, never throwing: a missing tool is a finding, not a crash. */
62
+ function run(cmd, args, opts = {}) {
63
+ try {
64
+ return { ok: true, out: execFileSync(cmd, args, { encoding: 'utf8', timeout: opts.timeout ?? 60_000, stdio: ['ignore', 'pipe', 'pipe'] }).trim() };
65
+ } catch (e) {
66
+ return { ok: false, out: `${e.status ?? ''} ${String(e.stderr ?? e.message).trim()}`.trim() };
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Find the DSH home and the installed plugin.
72
+ *
73
+ * Discovered rather than assumed: the paths differ between a packaged app, a profile install, and a
74
+ * development checkout, and this script has exactly one chance to be right.
75
+ */
76
+ function findInstall() {
77
+ const home = homedir();
78
+ const candidates = [
79
+ join(home, '.dsh'),
80
+ join(home, 'Library', 'Application Support', 'dsh'),
81
+ join(home, 'Library', 'Application Support', 'DeepSeek Harness'),
82
+ ];
83
+ for (const dshHome of candidates) {
84
+ if (!existsSync(dshHome)) continue;
85
+ const profiles = join(dshHome, 'profiles');
86
+ if (!existsSync(profiles)) continue;
87
+ for (const profile of readdirSync(profiles)) {
88
+ const mod = join(profiles, profile, 'node_modules', ...PACKAGE.split('/'));
89
+ if (existsSync(join(mod, 'package.json'))) {
90
+ let version = '?';
91
+ try { version = JSON.parse(readFileSync(join(mod, 'package.json'), 'utf8')).version; } catch { /* reported as ? */ }
92
+ return { dshHome, profiles, profile, moduleDir: mod, version, profileDir: join(profiles, profile) };
93
+ }
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /** Is DSH itself running, and what launched it? */
100
+ function findDshProcess() {
101
+ const ps = run('/bin/ps', ['-Ao', 'pid=,command='], { timeout: 20_000 });
102
+ if (!ps.ok) return { ok: false, reason: `ps failed: ${ps.out}` };
103
+ const lines = ps.out.split('\n').filter((l) => l.trim() !== '');
104
+ const self = String(process.pid);
105
+ const matches = lines
106
+ .map((l) => {
107
+ const m = /^\s*(\d+)\s+(.*)$/.exec(l);
108
+ return m ? { pid: m[1], command: m[2] } : null;
109
+ })
110
+ .filter(Boolean)
111
+ .filter((p) => p.pid !== self)
112
+ .filter((p) => /DeepSeek Harness|\.dsh|dsh\b/i.test(p.command))
113
+ .filter((p) => !/mac-self-update|node -e/i.test(p.command));
114
+ return { ok: true, matches };
115
+ }
116
+
117
+ /**
118
+ * The fleet check: does this machine show up at the relay again?
119
+ *
120
+ * `machineName` is read from the environment the agent was started with, falling back to the
121
+ * hostname — the relay lists machines by the name they paired under.
122
+ */
123
+ async function fleetHasMachine(url, name) {
124
+ try {
125
+ const res = await fetch(new URL('/healthz', url).toString(), { signal: AbortSignal.timeout(10_000) });
126
+ const body = await res.json();
127
+ if (body?.ok !== true) return { ok: false, why: `healthz ok=${body?.ok}` };
128
+ // `/healthz` gives a count; the device list needs a device token, so the count is the signal
129
+ // available here. A drop to zero means the fleet lost everyone and the rollback should fire.
130
+ return { ok: (body.devices ?? 0) > 0, why: `devices=${body.devices}`, devices: body.devices ?? 0 };
131
+ } catch (e) {
132
+ return { ok: false, why: `healthz threw: ${e.message}` };
133
+ }
134
+ }
135
+
136
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
137
+
138
+ async function main() {
139
+ const install = findInstall();
140
+ if (install === null) {
141
+ record('locate the installed plugin', false, `no ${PACKAGE} under any known DSH home`);
142
+ return finish(false, { reason: 'not_installed' });
143
+ }
144
+ record('locate the installed plugin', true, `v${install.version} at ${install.moduleDir}`);
145
+
146
+ const before = findDshProcess();
147
+ record('find the DSH process', before.ok, before.ok ? `${before.matches.length} match(es)` : before.reason);
148
+
149
+ // ---- 1. verify the artefact BEFORE touching anything -------------------------------
150
+ if (!existsSync(TARBALL)) { record('verify the artefact', false, `no tarball at ${TARBALL}`); return finish(false, { reason: 'no_tarball' }); }
151
+ const actual = createHash('sha256').update(readFileSync(TARBALL)).digest('hex');
152
+ if (EXPECTED !== '' && actual !== EXPECTED) {
153
+ record('verify the artefact', false, `checksum ${actual} != ${EXPECTED}`);
154
+ return finish(false, { reason: 'checksum_mismatch' });
155
+ }
156
+ record('verify the artefact', true, `sha256 ${actual.slice(0, 16)}…`);
157
+
158
+ // ---- 2. back up, so rollback needs nothing from the network -------------------------
159
+ const backup = `${install.moduleDir}.backup-${STAMP}`;
160
+ try {
161
+ cpSync(install.moduleDir, backup, { recursive: true });
162
+ record('back up the installed plugin', true, `${backup}`);
163
+ } catch (e) {
164
+ record('back up the installed plugin', false, e.message);
165
+ return finish(false, { reason: 'backup_failed' });
166
+ }
167
+
168
+ // ---- 3. stage and check it EXTRACTED -------------------------------------------------
169
+ const staging = join(tmpdir(), `w2m-stage-${STAMP}`);
170
+ mkdirSync(staging, { recursive: true });
171
+ const untar = run('/usr/bin/tar', ['-xzf', TARBALL, '-C', staging], { timeout: 180_000 });
172
+ if (!untar.ok) { record('unpack to staging', false, untar.out); return finish(false, { reason: 'untar_failed' }); }
173
+ const staged = join(staging, 'package');
174
+ const src = existsSync(staged) ? staged : staging;
175
+ const entry = join(src, 'bin', 'w2m-localside.mjs');
176
+ if (!existsSync(entry)) {
177
+ record('check the staged tree', false, `no bin/w2m-localside.mjs in ${src}`);
178
+ return finish(false, { reason: 'no_entry_point' });
179
+ }
180
+ let stagedVersion = '?';
181
+ try { stagedVersion = JSON.parse(readFileSync(join(src, 'package.json'), 'utf8')).version; } catch { /* reported as ? */ }
182
+ record('check the staged tree', true, `v${stagedVersion}`);
183
+
184
+ // ---- 4. swap -------------------------------------------------------------------------
185
+ try {
186
+ rmSync(install.moduleDir, { recursive: true, force: true });
187
+ cpSync(src, install.moduleDir, { recursive: true });
188
+ record('swap into place', true, `v${install.version} -> v${stagedVersion}`);
189
+ } catch (e) {
190
+ record('swap into place', false, e.message);
191
+ // The backup is right there; restore without asking.
192
+ try { rmSync(install.moduleDir, { recursive: true, force: true }); cpSync(backup, install.moduleDir, { recursive: true }); record('restore after a failed swap', true, 'reverted'); } catch (e2) { record('restore after a failed swap', false, e2.message); }
193
+ return finish(false, { reason: 'swap_failed' });
194
+ }
195
+
196
+ // ---- 5. restart ----------------------------------------------------------------------
197
+ /**
198
+ * DSH is restarted by signalling it, and **the detached supervisor is what makes that survivable**:
199
+ * this process is not a child of DSH, so it keeps running to do the self-check and the rollback.
200
+ */
201
+ const targets = before.ok ? before.matches : [];
202
+ for (const p of targets) run('/bin/kill', ['-TERM', p.pid], { timeout: 10_000 });
203
+ record('signal DSH to stop', targets.length > 0, `${targets.length} process(es)`);
204
+ await sleep(5000);
205
+
206
+ // Bring it back the way it was launched, if a launcher is discoverable.
207
+ const launchd = run('/bin/launchctl', ['list'], { timeout: 15_000 });
208
+ const launchdHit = launchd.ok ? launchd.out.split('\n').find((l) => /dsh|harness/i.test(l)) : undefined;
209
+ if (launchdHit) {
210
+ record('restart via launchd', true, launchdHit.trim());
211
+ } else {
212
+ // No launchd job: try `open`, which is how a packaged app is started. A failure here is the
213
+ // case the rollback exists for.
214
+ const opened = run('/usr/bin/open', ['-a', 'DeepSeek Harness'], { timeout: 30_000 });
215
+ record('restart via open -a', opened.ok, opened.ok ? 'launched' : opened.out);
216
+ }
217
+
218
+ // ---- 6. self-check, with the rollback as the answer to a failure ----------------------
219
+ let healthy = false;
220
+ let why = '';
221
+ for (let i = 1; i <= 6; i += 1) {
222
+ await sleep(10_000);
223
+ const proc = findDshProcess();
224
+ if (!proc.ok || proc.matches.length === 0) { why = 'DSH is not running'; continue; }
225
+ if (VERIFY_FLEET !== '') {
226
+ const fleet = await fleetHasMachine(VERIFY_FLEET, '');
227
+ if (!fleet.ok) { why = `fleet check: ${fleet.why}`; continue; }
228
+ record(`self-check ${i}`, true, `DSH up, ${fleet.why}`);
229
+ } else {
230
+ record(`self-check ${i}`, true, `DSH up (${proc.matches.length} process(es))`);
231
+ }
232
+ healthy = true;
233
+ break;
234
+ }
235
+
236
+ if (healthy) return finish(true, { from: install.version, to: stagedVersion, backup });
237
+
238
+ record('self-check', false, why || 'DSH did not come back');
239
+ // ---- 7. roll back, automatically ------------------------------------------------------
240
+ for (const p of (findDshProcess().matches ?? [])) run('/bin/kill', ['-TERM', p.pid], { timeout: 10_000 });
241
+ await sleep(3000);
242
+ try {
243
+ rmSync(install.moduleDir, { recursive: true, force: true });
244
+ cpSync(backup, install.moduleDir, { recursive: true });
245
+ record('roll back', true, `restored v${install.version}`);
246
+ } catch (e) {
247
+ record('roll back', false, e.message);
248
+ return finish(false, { reason: 'rollback_failed', backup });
249
+ }
250
+ run('/usr/bin/open', ['-a', 'DeepSeek Harness'], { timeout: 30_000 });
251
+ await sleep(20_000);
252
+ const after = findDshProcess();
253
+ record('verify the rollback', after.ok && after.matches.length > 0, `${after.matches?.length ?? 0} process(es)`);
254
+ return finish(false, { reason: 'rolled_back', restored: install.version, backup });
255
+ }
256
+
257
+ main().catch((e) => { record('unhandled', false, String(e?.stack ?? e)); finish(false, { reason: 'threw' }); });
@@ -0,0 +1,109 @@
1
+ # Running the relay on a server
2
+
3
+ These are the units the shared relay **actually runs**, copied from
4
+ `/etc/systemd/system/` on the live host rather than written from the documentation. They are in the
5
+ repository because "how the server runs" is part of the thing being open-sourced — and because a
6
+ unit that exists only on one machine is a unit nobody can review, reproduce, or audit.
7
+
8
+ ## The three services
9
+
10
+ | Unit | What it is | Port |
11
+ |---|---|---|
12
+ | `w2m-rabbit.service` | The relay: P2P rendezvous, signalling, and the task ledger | 8787 |
13
+ | `w2m-stun.service` | STUN responder, for reflexive address discovery during hole punching | 3478 |
14
+ | `w2m-localside.service` | A Localside agent, so the server is itself a fleet machine | — |
15
+
16
+ `w2m-localside` is deliberately the same agent every other machine runs. The server is not
17
+ privileged in the fleet's protocol — it is a peer that happens to be reachable — and running it
18
+ through the normal agent is what keeps that true.
19
+
20
+ ## ⚠️ What is NOT here, and why
21
+
22
+ **`/var/lib/w2m/rabbit/` is not in this repository and must never be.**
23
+
24
+ It holds:
25
+
26
+ | File | Why it must not be published |
27
+ |---|---|
28
+ | `operator-token.txt` | **Full control of the fleet.** Anyone holding it can dispatch any allowed command to every machine. |
29
+ | `ledger` | The task history: every command run, on which machine, with which results. |
30
+
31
+ Two mechanisms keep this true, because one is not enough:
32
+
33
+ 1. **`.gitignore`** refuses the paths, so an accidental `git add -A` cannot stage them.
34
+ 2. **`scripts/scan-for-secrets.mjs`** reads the **content** of every tracked file and fails the
35
+ release on a credential shape. A `.gitignore` refuses a *path*; the ways a credential actually
36
+ escapes are pasting one into a source file, a fixture, or an example — so tracked content is
37
+ what gets read.
38
+
39
+ Run it before a release:
40
+
41
+ ```bash
42
+ node scripts/scan-for-secrets.mjs .
43
+ # 0 blocking, N for review -> exit 0
44
+ ```
45
+
46
+ `REVIEW` findings (long hex strings, high-entropy assignments) are **not fatal by design**: a
47
+ scanner whose false positives block releases is a scanner someone deletes. Only real credential
48
+ shapes stop anything.
49
+
50
+ ## Installing
51
+
52
+ ```bash
53
+ # 1. A user that owns the state and nothing else.
54
+ sudo useradd --system --home /var/lib/w2m --shell /usr/sbin/nologin w2m
55
+
56
+ # 2. The paths the units reference.
57
+ sudo install -d -o w2m -g w2m /opt/w2m /var/lib/w2m /var/log/w2m
58
+
59
+ # 3. The code, at the tag being deployed.
60
+ sudo -u w2m git clone https://github.com/TwinsEarth/dsh-windows2macos /opt/w2m/app
61
+ sudo -u w2m git -C /opt/w2m/app checkout v0.7.10
62
+
63
+ # 4. The units.
64
+ sudo cp deploy/server/*.service /etc/systemd/system/
65
+ sudo systemctl daemon-reload
66
+ sudo systemctl enable --now w2m-rabbit w2m-stun
67
+
68
+ # 5. Pair the server as a fleet machine, then enable the agent.
69
+ sudo systemctl enable --now w2m-localside
70
+ ```
71
+
72
+ ## Notes on the hardening, since it is easy to weaken by accident
73
+
74
+ Every unit sets `NoNewPrivileges`, `PrivateTmp`, `ProtectHome`, and a `ProtectSystem` level, and
75
+ writes only inside `/var/lib/w2m` via `ReadWritePaths`. That last one matters: `ProtectSystem=full`
76
+ makes `/usr` and `/boot` read-only, so an agent that tried to write anywhere else would fail
77
+ loudly rather than silently succeed on a machine that should not be writable.
78
+
79
+ `w2m-stun` is stricter (`ProtectSystem=strict`, `RestrictAddressFamilies=AF_INET AF_INET6`) because
80
+ it needs a socket and nothing else.
81
+
82
+ **If you add a capability to an agent, check `ReadWritePaths` first.** The failure is a permission
83
+ error at runtime, which looks like a bug in the agent rather than a hardening setting.
84
+
85
+ ## The `--fleet` flag, and why it is absent here
86
+
87
+ `w2m-rabbit` accepts `--fleet <account code>`, which binds the relay to one account's fleet. It is
88
+ **absent from this unit on purpose**, and the relay therefore runs unscoped: every paired device
89
+ reaches every other.
90
+
91
+ Turning it on is not a configuration change — it is a **fleet migration**:
92
+
93
+ 1. the account code must be read on a machine that is **signed in** (a headless server cannot
94
+ produce one; it has no DeepSeek account),
95
+ 2. the relay restarts scoped,
96
+ 3. **every device must be re-paired**, because a device receives its fleet *from the pairing code*,
97
+ and on a scoped relay a device with no fleet reaches **nothing**.
98
+
99
+ Step 3 is the risk: the fleet goes silent until each machine is re-paired. That is the intended
100
+ fail-safe direction and it is still an outage while it lasts.
101
+
102
+ ## Deliberately not included
103
+
104
+ * **TLS termination.** The live relay is behind a proxy that handles it; `--base-path` and
105
+ `--trust-proxy` exist for that arrangement and the certificate is the operator's business.
106
+ * **Backups.** The state directory is small (about 1 MB) and is the operator's to schedule. The
107
+ deployment script below does back it up before a swap, which is not the same as a backup policy.
108
+ * **Log rotation.** `StandardOutput=append:` grows without bound; the live host rotates
109
+ `/var/log/w2m/*.log` outside these units.
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Deploy one release tarball to a running relay, with a rollback that is automatic rather than
4
+ # remembered.
5
+ #
6
+ # This is the script the live server was updated with (v0.6.3 -> v0.7.7 -> v0.7.10). It is in the
7
+ # repository because a deployment that exists only in someone's shell history is a deployment nobody
8
+ # can review — and because the order of these steps is the whole safety argument:
9
+ #
10
+ # 1. verify the checksum BEFORE unpacking, so a truncated upload cannot become a running service
11
+ # 2. back up the tree in place, so rollback does not depend on the network
12
+ # 3. stage and check for entry points BEFORE swapping, so a bad tarball never reaches the
13
+ # live path
14
+ # 4. restart and health-check, and roll back automatically if the health check fails
15
+ #
16
+ # Step 3 is the one that matters most: `git archive` of a wrong subdirectory produces a tarball that
17
+ # unpacks cleanly and contains no `bin/`, and without the entry-point check that becomes a service
18
+ # that starts, finds nothing to run, and reports a confusing error.
19
+ #
20
+ # Usage: deploy-w2m.sh <tarball> <expected-sha256> [--fleet <account-code>]
21
+ #
22
+ set -euo pipefail
23
+
24
+ APP_DIR=/opt/w2m/app
25
+ BACKUP_ROOT=/opt/w2m
26
+ STATE_DIR=/var/lib/w2m
27
+ SERVICES=(w2m-rabbit w2m-stun w2m-localside)
28
+ STAMP=$(date -u +%Y%m%dT%H%M%SZ)
29
+
30
+ TARBALL="${1:-}"
31
+ EXPECTED="${2:-}"
32
+ FLEET=""
33
+ if [ "${3:-}" = "--fleet" ]; then FLEET="${4:-}"; fi
34
+
35
+ if [ -z "$TARBALL" ] || [ -z "$EXPECTED" ]; then
36
+ echo "usage: $0 <tarball> <expected-sha256> [--fleet <account-code>]" >&2
37
+ exit 2
38
+ fi
39
+ if [ ! -f "$TARBALL" ]; then echo "no such tarball: $TARBALL" >&2; exit 2; fi
40
+
41
+ echo "=== 1. verify the artefact ==="
42
+ ACTUAL=$(sha256sum "$TARBALL" | awk '{print $1}')
43
+ echo " expected $EXPECTED"
44
+ echo " actual $ACTUAL"
45
+ if [ "$ACTUAL" != "$EXPECTED" ]; then
46
+ echo " CHECKSUM MISMATCH -- refusing to deploy. A truncated upload must never become a service." >&2
47
+ exit 1
48
+ fi
49
+ echo " checksum OK"
50
+
51
+ echo
52
+ echo "=== 2. what is running now ==="
53
+ if [ -f "$APP_DIR/package.json" ]; then
54
+ echo " $APP_DIR is version $(node -e "process.stdout.write(require('$APP_DIR/package.json').version)")"
55
+ else
56
+ echo " $APP_DIR has no package.json (first deploy?)"
57
+ fi
58
+ for s in "${SERVICES[@]}"; do
59
+ printf ' %-16s %s\n' "$s" "$(systemctl is-active "$s" 2>/dev/null || echo inactive)"
60
+ done
61
+
62
+ echo
63
+ echo "=== 3. back up the current tree ==="
64
+ BACKUP="$BACKUP_ROOT/backup-$STAMP"
65
+ if [ -d "$APP_DIR" ]; then
66
+ cp -a "$APP_DIR" "$BACKUP"
67
+ echo " $APP_DIR -> $BACKUP"
68
+ else
69
+ echo " nothing to back up"
70
+ fi
71
+
72
+ echo
73
+ echo "=== 4. unpack to staging and check it ==="
74
+ STAGING=$(mktemp -d)
75
+ trap 'rm -rf "$STAGING"' EXIT
76
+ tar -xzf "$TARBALL" -C "$STAGING"
77
+ # The tarball contains a single `package/` root, by npm convention.
78
+ SRC="$STAGING/package"
79
+ if [ ! -d "$SRC" ]; then SRC="$STAGING"; fi
80
+
81
+ STAGED_VERSION=$(node -e "process.stdout.write(require('$SRC/package.json').version)" 2>/dev/null || echo "?")
82
+ echo " staged version: $STAGED_VERSION"
83
+
84
+ # The check that turns a confusing runtime failure into a clear deployment failure.
85
+ MISSING=""
86
+ for f in bin/w2m-rabbit.mjs bin/w2m-stun.mjs bin/w2m-localside.mjs package.json; do
87
+ [ -e "$SRC/$f" ] || MISSING="$MISSING $f"
88
+ done
89
+ if [ -n "$MISSING" ]; then
90
+ echo " MISSING ENTRY POINTS:$MISSING" >&2
91
+ echo " refusing to swap: this tarball would install a service that cannot start." >&2
92
+ exit 1
93
+ fi
94
+ echo " entry points present"
95
+
96
+ echo
97
+ echo "=== 5. swap into place ==="
98
+ rm -rf "$APP_DIR.old"
99
+ if [ -d "$APP_DIR" ]; then mv "$APP_DIR" "$APP_DIR.old"; fi
100
+ cp -a "$SRC" "$APP_DIR"
101
+ chown -R w2m:w2m "$APP_DIR" 2>/dev/null || true
102
+ echo " swapped; previous tree kept at $APP_DIR.old"
103
+
104
+ echo
105
+ echo "=== 6. restart ==="
106
+ # The relay's fleet binding: omitted means unscoped, which is the pre-v0.7.8 behaviour.
107
+ if [ -n "$FLEET" ]; then
108
+ echo " --fleet $FLEET requested"
109
+ if grep -q -- '--fleet' /etc/systemd/system/w2m-rabbit.service; then
110
+ sed -i "s|--fleet [^ ]*|--fleet $FLEET|" /etc/systemd/system/w2m-rabbit.service
111
+ else
112
+ sed -i "s|\(--state [^ ]*\)|\1 --fleet $FLEET|" /etc/systemd/system/w2m-rabbit.service
113
+ fi
114
+ systemctl daemon-reload
115
+ echo " WARNING: the relay is now scoped. A device with no fleet reaches NOTHING."
116
+ echo " Every device must be re-paired with a code issued by this relay."
117
+ fi
118
+ systemctl restart "${SERVICES[@]}"
119
+
120
+ echo
121
+ echo "=== 7. health check ==="
122
+ sleep 4
123
+ HEALTHY=1
124
+ for s in "${SERVICES[@]}"; do
125
+ STATE=$(systemctl is-active "$s" 2>/dev/null || echo inactive)
126
+ printf ' %-16s %s\n' "$s" "$STATE"
127
+ [ "$STATE" = "active" ] || HEALTHY=0
128
+ done
129
+
130
+ if command -v curl >/dev/null 2>&1; then
131
+ HEALTH=$(curl -fsS --max-time 10 http://127.0.0.1:8787/healthz 2>/dev/null || echo "")
132
+ if [ -n "$HEALTH" ]; then
133
+ echo " healthz: $HEALTH"
134
+ else
135
+ echo " healthz: NO ANSWER"
136
+ HEALTHY=0
137
+ fi
138
+ fi
139
+
140
+ if [ "$HEALTHY" != "1" ]; then
141
+ echo
142
+ echo "=== 8. ROLLBACK ==="
143
+ if [ -d "$BACKUP" ]; then
144
+ rm -rf "$APP_DIR"
145
+ mv "$BACKUP" "$APP_DIR"
146
+ systemctl restart "${SERVICES[@]}"
147
+ echo " rolled back to the previous tree and restarted" >&2
148
+ else
149
+ echo " no backup to roll back to" >&2
150
+ fi
151
+ exit 1
152
+ fi
153
+
154
+ echo
155
+ echo "=== deployed ==="
156
+ echo " version: $STAGED_VERSION"
157
+ echo " backup: $BACKUP"
158
+ echo " previous: $APP_DIR.old (removable once this is proven)"
159
+ echo
160
+ echo " NOTE: relay state lives in $STATE_DIR and was NOT touched by this script."
161
+ echo " The ledger and the operator token survive a deploy; that is deliberate."
@@ -0,0 +1,31 @@
1
+ [Unit]
2
+ Description=W2M Localside agent (shared server as a fleet machine)
3
+ After=network-online.target w2m-rabbit.service
4
+ Wants=network-online.target
5
+ Wants=w2m-rabbit.service
6
+
7
+ [Service]
8
+ Type=simple
9
+ User=w2m
10
+ Group=w2m
11
+ WorkingDirectory=/opt/w2m/demo
12
+ ExecStart=/usr/local/bin/node /opt/w2m/app/bin/w2m-localside.mjs \
13
+ --rabbit http://127.0.0.1:8787 \
14
+ --project /opt/w2m/shared \
15
+ --name jp-shared \
16
+ --state /var/lib/w2m/localside-jp \
17
+ --p2p-mode auto \
18
+ --p2p-port 41234 \
19
+ --allowed-commands '["node --version","node --test","node -e","git status --porcelain","git rev-parse","git log","git diff","npm test","npm run"]'
20
+ Restart=always
21
+ RestartSec=3
22
+ NoNewPrivileges=true
23
+ PrivateTmp=true
24
+ ProtectSystem=full
25
+ ProtectHome=true
26
+ ReadWritePaths=/var/lib/w2m
27
+ StandardOutput=append:/var/log/w2m/localside.log
28
+ StandardError=append:/var/log/w2m/localside.log
29
+
30
+ [Install]
31
+ WantedBy=multi-user.target
@@ -0,0 +1,26 @@
1
+ [Unit]
2
+ Description=W2M Rabbit relay (P2P rendezvous, signalling and task ledger)
3
+ Documentation=https://github.com/TwinsEarth/dsh-windows2macos
4
+ After=network-online.target
5
+ Wants=network-online.target
6
+
7
+ [Service]
8
+ Type=simple
9
+ User=w2m
10
+ Group=w2m
11
+ WorkingDirectory=/opt/w2m/app
12
+ ExecStart=/usr/local/bin/node /opt/w2m/app/bin/w2m-rabbit.mjs --host 0.0.0.0 --port 8787 --state /var/lib/w2m/rabbit
13
+ Restart=always
14
+ RestartSec=2
15
+ # The relay holds no model credentials and no working copy; it needs its state dir and
16
+ # nothing else. NoNewPrivileges + ProtectSystem close the obvious escalation paths.
17
+ NoNewPrivileges=true
18
+ PrivateTmp=true
19
+ ProtectSystem=full
20
+ ProtectHome=true
21
+ ReadWritePaths=/var/lib/w2m
22
+ StandardOutput=append:/var/log/w2m/rabbit.log
23
+ StandardError=append:/var/log/w2m/rabbit.log
24
+
25
+ [Install]
26
+ WantedBy=multi-user.target
@@ -0,0 +1,26 @@
1
+ [Unit]
2
+ Description=W2M STUN responder - reflexive address discovery for P2P hole punching
3
+ After=network-online.target
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ Type=simple
8
+ User=w2m
9
+ Group=w2m
10
+ ExecStart=/usr/local/bin/node /opt/w2m/app/bin/w2m-stun.mjs --host 0.0.0.0 --port 3478 --log-level info
11
+ Restart=always
12
+ RestartSec=2
13
+ StartLimitIntervalSec=60
14
+ StartLimitBurst=5
15
+ NoNewPrivileges=true
16
+ PrivateTmp=true
17
+ ProtectSystem=strict
18
+ ProtectHome=true
19
+ ProtectKernelTunables=true
20
+ ProtectControlGroups=true
21
+ RestrictAddressFamilies=AF_INET AF_INET6
22
+ StandardOutput=append:/var/log/w2m/stun.log
23
+ StandardError=append:/var/log/w2m/stun.log
24
+
25
+ [Install]
26
+ WantedBy=multi-user.target