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