@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +1288 -0
  2. package/LICENSE +21 -0
  3. package/PROTOCOL-v0.1.2.md +165 -0
  4. package/PROTOCOL-v0.3.0.md +454 -0
  5. package/PROTOCOL-v0.3.9.md +207 -0
  6. package/PROTOCOL-v0.4.0.md +255 -0
  7. package/PROTOCOL.md +339 -0
  8. package/README.md +665 -2
  9. package/bin/w2m-localside.mjs +303 -0
  10. package/bin/w2m-rabbit.mjs +396 -0
  11. package/bin/w2m-stun.mjs +231 -0
  12. package/cordis.patch.yml +94 -0
  13. package/deploy/caddy/Caddyfile.example +118 -0
  14. package/deploy/docker/Dockerfile +144 -0
  15. package/deploy/docker/README.md +338 -0
  16. package/deploy/docker/docker-compose.yml +179 -0
  17. package/deploy/nginx/w2m.conf.example +196 -0
  18. package/deploy/shared-server/README.md +118 -0
  19. package/deploy/shared-server/install.sh +255 -0
  20. package/deploy/systemd/w2m-rabbit.service +150 -0
  21. package/deploy/systemd/w2m-stun.service +69 -0
  22. package/deploy/windows/README.md +115 -0
  23. package/deploy/windows/install-service.ps1 +195 -0
  24. package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
  25. package/deploy/windows/winsw/w2m-localside.xml +50 -0
  26. package/docs/DEPLOY.md +902 -0
  27. package/docs/LINT-AUDIT.md +492 -0
  28. package/docs/PIPELINE-DESIGN.md +95 -0
  29. package/docs/TROUBLESHOOTING.md +560 -0
  30. package/docs/UI-CARDS.md +81 -0
  31. package/lib/index.js +13 -0
  32. package/lib/tools.js +11 -0
  33. package/package.json +133 -4
  34. package/src/agent/agent.mjs +3317 -0
  35. package/src/agent/allowed-commands.mjs +116 -0
  36. package/src/agent/caps.mjs +437 -0
  37. package/src/agent/exec.mjs +356 -0
  38. package/src/agent/git.mjs +616 -0
  39. package/src/agent/identity.mjs +253 -0
  40. package/src/agent/p2p-node.mjs +1243 -0
  41. package/src/agent/p2p.mjs +605 -0
  42. package/src/agent/resolve.mjs +148 -0
  43. package/src/agent/spool.mjs +252 -0
  44. package/src/agent/stun.mjs +397 -0
  45. package/src/agent/url.mjs +127 -0
  46. package/src/plugin/auto-update.mjs +574 -0
  47. package/src/plugin/schedule.mjs +452 -0
  48. package/src/plugin/shared-config.mjs +264 -0
  49. package/src/plugin/tools.mjs +3891 -0
  50. package/src/plugin/update-install.mjs +764 -0
  51. package/src/plugin/update-source.mjs +686 -0
  52. package/src/relay/metrics.mjs +284 -0
  53. package/src/relay/peers.mjs +180 -0
  54. package/src/relay/persistence.mjs +207 -0
  55. package/src/relay/report.mjs +591 -0
  56. package/src/relay/server.mjs +1187 -0
  57. package/src/relay/state.mjs +2004 -0
  58. package/src/relay/stun-server.mjs +733 -0
  59. package/src/signing.mjs +359 -0
  60. package/src/util/cli.mjs +70 -0
@@ -0,0 +1,205 @@
1
+ // W2M Localside agent supervisor — the process a Windows service (or service-mode task) runs.
2
+ //
3
+ // WHY A SUPERVISOR AND NOT THE AGENT ITSELF
4
+ //
5
+ // Measured, twice: an agent started directly by a task or a service manager can be gone forty
6
+ // seconds later with `0xC000013A` (STATUS_CONTROL_C_EXIT — the console it was attached to went
7
+ // away) and **no log line anywhere**, because whatever was logging died with it. The fleet then
8
+ // reports the machine offline and nothing explains why. This loop owns the child instead: it
9
+ // respawns with a capped backoff and writes one line per run, so "silently gone" becomes "run 3
10
+ // exited code=3221225786 after 41s -- restarting".
11
+ //
12
+ // It also owns the log, which a service needs: stdout of a session-0 process goes nowhere.
13
+ //
14
+ // node w2m-agent-supervisor.mjs --config %USERPROFILE%\.dsh\w2m\agent.json
15
+ //
16
+ // # or, for a manual run, the flags directly:
17
+ // node w2m-agent-supervisor.mjs \
18
+ // --bin <path to the installed w2m-localside.mjs> \
19
+ // --rabbit http://202.182.123.154:8787 \
20
+ // --project E:\path\to\project \
21
+ // --name win-desktop \
22
+ // --state %USERPROFILE%\.dsh\w2m\localside \
23
+ // --allowed-commands '["node --test","git status --porcelain"]' \
24
+ // --log %USERPROFILE%\.dsh\w2m\agent.log \
25
+ // [--p2p-port 41235] [--p2p-mode auto]
26
+ //
27
+ // WHY `--config` EXISTS, AND WHY IT IS THE FORM A SERVICE USES
28
+ //
29
+ // Measured: a service manager and Task Scheduler both mangle a JSON argument. The task's stored
30
+ // command line came back as `--allowed-commands "[node --version,node --test]"` -- the escaped
31
+ // quotes were gone, so the agent read `[node` and exited 2 with ALLOWED_COMMANDS_INVALID. Passing
32
+ // JSON through another program's command line is the trap this project has now hit three times
33
+ // (cmd, PowerShell 5.1, Task Scheduler). A config file has no quoting rules at all, it is one more
34
+ // thing the installer can write atomically, and it stays readable for whoever debugs the service.
35
+ //
36
+ // Nothing here is Windows-specific except the defaults; the same supervisor works on POSIX hosts.
37
+ import { spawn } from 'node:child_process';
38
+ import { createWriteStream, existsSync, mkdirSync, readFileSync } from 'node:fs';
39
+ import { dirname, join } from 'node:path';
40
+
41
+ /** Minimal `--flag value` / `--flag` parser: no dependency, no surprises. */
42
+ function parseArgs(argv) {
43
+ const out = {};
44
+ for (let i = 0; i < argv.length; i += 1) {
45
+ const token = argv[i];
46
+ if (!token.startsWith('--')) continue;
47
+ const key = token.slice(2);
48
+ const next = argv[i + 1];
49
+ if (next === undefined || next.startsWith('--')) {
50
+ out[key] = true;
51
+ } else {
52
+ out[key] = next;
53
+ i += 1;
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ const args = parseArgs(process.argv.slice(2));
60
+ const home = process.env.USERPROFILE ?? process.env.HOME ?? '.';
61
+
62
+ /** The config file, when one was named. `--config` wins over the defaults, flags win over it. */
63
+ const configPath = typeof args.config === 'string' ? args.config : null;
64
+ let file = {};
65
+ if (configPath !== null) {
66
+ try {
67
+ // A BOM is stripped rather than rejected. Measured: PowerShell 5.1's `Set-Content -Encoding
68
+ // utf8` writes one, `JSON.parse` refuses it, and the failure looks like "the config is
69
+ // missing" -- the supervisor exited 78 with no log line because it had not opened the log yet.
70
+ // Notepad writes BOMs too, so a hand-edited config would hit the same wall.
71
+ const text = readFileSync(configPath, 'utf8').replace(/^\uFEFF/, '');
72
+ file = JSON.parse(text);
73
+ } catch (error) {
74
+ console.error(`w2m-supervisor: cannot read --config ${configPath}: ${error.message}`);
75
+ process.exit(78); // EX_CONFIG
76
+ }
77
+ }
78
+ /**
79
+ * One setting, from either source.
80
+ *
81
+ * The command line uses the agent's own kebab-case flags (`--allowed-commands`); the JSON config
82
+ * uses the camelCase name (`allowedCommands`), because it is JSON and reads better that way. Both
83
+ * are accepted in both places rather than documented as different namespaces -- the first version
84
+ * only looked for the kebab key in the file, so a perfectly correct config silently fell back to
85
+ * the four-entry default allow-list. Silent, because a missing key is not an error.
86
+ */
87
+ const camel = (key) => key.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase());
88
+ const pick = (key, fallback) => {
89
+ if (typeof args[key] === 'string') return args[key];
90
+ if (typeof args[camel(key)] === 'string') return args[camel(key)];
91
+ const value = file[key] ?? file[camel(key)];
92
+ return value === undefined ? fallback : value;
93
+ };
94
+ const pickList = (key, fallback) => {
95
+ if (typeof args[key] === 'string') return args[key];
96
+ const value = file[key] ?? file[camel(key)];
97
+ if (Array.isArray(value)) return JSON.stringify(value);
98
+ if (typeof value === 'string' && value !== '') return value;
99
+ return fallback;
100
+ };
101
+
102
+ // The installed package's bin is the default, because a service must run what the profile
103
+ // actually has: the plugin's updater replaces that copy and the next restart picks it up.
104
+ const defaultBin = join(
105
+ process.env.DSH_HOME ?? join(home, '.dsh'),
106
+ 'profiles',
107
+ process.env.DSH_PROFILE ?? 'desktop',
108
+ 'node_modules',
109
+ '@twinsearth',
110
+ 'w2m-dsh-plugin',
111
+ 'bin',
112
+ 'w2m-localside.mjs',
113
+ );
114
+
115
+ const bin = pick('bin', defaultBin);
116
+ const rabbit = pick('rabbit', 'http://202.182.123.154:8787');
117
+ const project = pick('project', process.cwd());
118
+ const name = pick('name', 'win-desktop');
119
+ const state = pick('state', join(home, '.dsh', 'w2m', 'localside'));
120
+ const logPath = pick('log', join(home, '.dsh', 'w2m', 'agent.log'));
121
+ const allowed = pickList('allowed-commands', '["node --version","node --test","git status --porcelain","git rev-parse"]');
122
+ const backoffRaw = pick('backoff-ms', 60_000);
123
+ const backoffMs = Number.isFinite(Number(backoffRaw)) ? Number(backoffRaw) : 60_000;
124
+
125
+ /** Extra agent arguments: `agentArgs` in the config, `--flag value` on the command line. */
126
+ const consumed = new Set([
127
+ 'config', 'bin', 'rabbit', 'project', 'name', 'state', 'log', 'allowed-commands', 'backoff-ms', 'agent-args',
128
+ ]);
129
+ const passthrough = [];
130
+ const fileAgentArgs = file.agentArgs ?? file['agent-args'];
131
+ if (Array.isArray(fileAgentArgs)) passthrough.push(...fileAgentArgs.map(String));
132
+ if (typeof args['agent-args'] === 'string') passthrough.push(...args['agent-args'].split(/\s+/).filter(Boolean));
133
+ for (const [key, value] of Object.entries(args)) {
134
+ if (consumed.has(key)) continue;
135
+ passthrough.push(`--${key}`);
136
+ if (value !== true) passthrough.push(String(value));
137
+ }
138
+
139
+ if (!existsSync(bin)) {
140
+ console.error(`w2m-supervisor: no agent at ${bin}`);
141
+ console.error('install the plugin first: dsh plugin --profile desktop add <release tarball>');
142
+ process.exit(78); // EX_CONFIG: a service manager should not keep retrying this
143
+ }
144
+
145
+ mkdirSync(dirname(logPath), { recursive: true });
146
+ const log = createWriteStream(logPath, { flags: 'a' });
147
+ const stamp = () => new Date().toISOString();
148
+ const say = (line) => {
149
+ console.log(line);
150
+ log.write(`${line}\n`);
151
+ };
152
+
153
+ const argv = [
154
+ bin,
155
+ '--rabbit', rabbit,
156
+ '--project', project,
157
+ '--name', name,
158
+ '--state', state,
159
+ '--allowed-commands', allowed,
160
+ ...passthrough,
161
+ ];
162
+
163
+ say(`[w2m-supervisor ${stamp()}] supervising ${process.execPath} ${argv.join(' ')}`);
164
+ let stopping = false;
165
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK']) {
166
+ process.on(signal, () => {
167
+ stopping = true;
168
+ });
169
+ }
170
+
171
+ let runs = 0;
172
+ while (!stopping) {
173
+ runs += 1;
174
+ const startedAt = Date.now();
175
+ const child = spawn(process.execPath, argv, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
176
+ child.stdout.on('data', (chunk) => {
177
+ process.stdout.write(chunk);
178
+ log.write(chunk);
179
+ });
180
+ child.stderr.on('data', (chunk) => {
181
+ process.stderr.write(chunk);
182
+ log.write(chunk);
183
+ });
184
+
185
+ const result = await new Promise((resolve) => {
186
+ child.on('exit', (exitCode, signal) => resolve({ exitCode, signal }));
187
+ child.on('error', (error) => {
188
+ say(`[w2m-supervisor] spawn failed: ${error.message}`);
189
+ resolve({ exitCode: null, signal: null });
190
+ });
191
+ });
192
+
193
+ const lived = Math.round((Date.now() - startedAt) / 1000);
194
+ say(
195
+ `[w2m-supervisor] run ${runs} exited code=${result.exitCode} signal=${result.signal} after ${lived}s` +
196
+ `${stopping ? ' (stopping)' : ' -- restarting'}`,
197
+ );
198
+ if (stopping) break;
199
+ // Deliberately NOT unref'ed: an unref'ed timer with nothing else pending lets Node exit during the
200
+ // backoff, which is the silent death this loop exists to prevent.
201
+ await new Promise((resolve) => {
202
+ setTimeout(resolve, backoffMs);
203
+ });
204
+ }
205
+ log.end();
@@ -0,0 +1,50 @@
1
+ <!--
2
+ WinSW service definition for the W2M Localside agent.
3
+
4
+ This file is a TEMPLATE. Replace the two @PLACEHOLDERS@, then -- from an elevated shell:
5
+
6
+ w2m-localside.exe install
7
+ w2m-localside.exe start
8
+
9
+ WHY IT RUNS THE SUPERVISOR AND NOT THE AGENT
10
+
11
+ A service manager restarts a process that exits; it cannot see anything about *why*, and a
12
+ process that dies with its console (0xC000013A -- measured twice on this project) leaves no
13
+ trace at all. The supervisor owns the child, respawns it with a capped backoff, and writes one
14
+ line per run to the log file below, so "the agent is not running" always has a preceding line
15
+ that says what happened.
16
+
17
+ WHY THE PORT IS PINNED
18
+
19
+ With `--p2p-port`, one inbound firewall rule survives every restart. Without it the node binds
20
+ an ephemeral port, the rule goes stale silently, and every task falls back to the relay with
21
+ `offer_path: "relay"` in the ledger.
22
+ -->
23
+ <service>
24
+ <id>w2mlocalside</id>
25
+ <name>W2M Localside agent</name>
26
+ <description>Runs the same project on this machine on behalf of a W2M fleet: relay stream plus a UDP punch socket. Executes only argv prefixes allowed by its allow-list.</description>
27
+
28
+ <executable>C:\Program Files\nodejs\node.exe</executable>
29
+ <!--
30
+ The allow-list is NOT on this command line. Task Scheduler and services both mangle a JSON
31
+ argument (measured: `--allowed-commands "[\\"node --version\\"]"` came back as
32
+ `"[node --version]"`, escapes gone, agent exiting 2 with ALLOWED_COMMANDS_INVALID). It lives in
33
+ agent.json, which install-service.ps1 writes and this command line merely points at.
34
+ -->
35
+ <arguments>@REPO@\deploy\windows\w2m-agent-supervisor.mjs --config "%USERPROFILE%\.dsh\w2m\agent.json"</arguments>
36
+ <workingdirectory>@REPO@\deploy\windows</workingdirectory>
37
+
38
+ <startmode>Automatic</startmode>
39
+ <delayedAutoStart>true</delayedAutoStart>
40
+ <onfailure action="restart" delay="60 sec" />
41
+ <resetfailure>1 hour</resetfailure>
42
+
43
+ <!-- The supervisor already logs; these two keep the wrapper's own output for the case where the
44
+ supervisor cannot start at all (a missing plugin, a wrong path). -->
45
+ <log mode="roll-by-size">
46
+ <logpath>%USERPROFILE%\.dsh\w2m</logpath>
47
+ <sizeThreshold>10240</sizeThreshold>
48
+ <keepFiles>4</keepFiles>
49
+ </log>
50
+ </service>