@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.
- package/CHANGELOG.md +1176 -0
- package/HANDOFF.md +173 -0
- package/README.md +9 -2
- package/bin/w2m-localside.mjs +325 -308
- package/bin/w2m-rabbit.mjs +6 -0
- package/bin/w2m-stun.mjs +0 -0
- package/deploy/mac/mac-self-update.mjs +257 -0
- package/deploy/server/README.md +109 -0
- package/deploy/server/deploy-w2m.sh +161 -0
- package/deploy/server/w2m-localside.service +31 -0
- package/deploy/server/w2m-rabbit.service +26 -0
- package/deploy/server/w2m-stun.service +26 -0
- package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
- package/package.json +34 -22
- package/src/agent/agent.mjs +24 -2
- package/src/chatops/account.mjs +401 -0
- package/src/chatops/actions.mjs +227 -0
- package/src/chatops/approval-broker.mjs +320 -0
- package/src/chatops/auth.mjs +376 -0
- package/src/chatops/bridge.mjs +344 -0
- package/src/chatops/channel.mjs +192 -0
- package/src/chatops/commands.mjs +409 -0
- package/src/chatops/config-route.mjs +332 -0
- package/src/chatops/device-signing.mjs +218 -0
- package/src/chatops/durability.mjs +202 -0
- package/src/chatops/file-safety.mjs +282 -0
- package/src/chatops/fleet-join.mjs +164 -0
- package/src/chatops/fleet-scope.mjs +176 -0
- package/src/chatops/fleet-wiring.mjs +258 -0
- package/src/chatops/fleet.mjs +272 -0
- package/src/chatops/handoff.mjs +258 -0
- package/src/chatops/ilink-fleet-bridge.mjs +197 -0
- package/src/chatops/ilink-protocol.mjs +165 -0
- package/src/chatops/ilink-transport.mjs +168 -0
- package/src/chatops/ilink.mjs +423 -0
- package/src/chatops/index.mjs +440 -0
- package/src/chatops/outbound-queue.mjs +282 -0
- package/src/chatops/policy.mjs +356 -0
- package/src/chatops/run-command.mjs +308 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/server.mjs +96 -0
- package/src/relay/state.mjs +2279 -2011
package/bin/w2m-rabbit.mjs
CHANGED
|
@@ -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
|