@twinsearth/w2m-dsh-plugin 0.8.10 → 0.8.11
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 +122 -0
- package/bin/w2m-localside.mjs +325 -325
- package/bin/w2m-rabbit.mjs +0 -0
- package/bin/w2m-stun.mjs +0 -0
- package/package.json +20 -19
- package/src/chatops/account.mjs +432 -412
- package/src/chatops/index.mjs +440 -440
- package/src/plugin/auto-update.mjs +31 -2
- package/src/plugin/tools.mjs +5207 -4878
- package/src/relay/state.mjs +2277 -2277
package/bin/w2m-localside.mjs
CHANGED
|
@@ -1,325 +1,325 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* `w2m-localside` — the Windows/macOS side of a W2M pair.
|
|
4
|
-
*
|
|
5
|
-
* node bin/w2m-localside.mjs \
|
|
6
|
-
* --rabbit http://127.0.0.1:8787 \
|
|
7
|
-
* --pair PAIR-7K2M9QX4 \
|
|
8
|
-
* --project E:/DS/w2m \
|
|
9
|
-
* --name win-desktop \
|
|
10
|
-
* --allowed-commands '["node --test","git status"]' \
|
|
11
|
-
* --state ~/.dsh/xclient/localside \
|
|
12
|
-
* --once
|
|
13
|
-
*
|
|
14
|
-
* The allow-list is **default-deny**: with no `--allowed-commands`, every offer
|
|
15
|
-
* is refused with `COMMAND_NOT_ALLOWED`. That is deliberate -- an agent that
|
|
16
|
-
* runs whatever it is told is not a thing this project ships.
|
|
17
|
-
*
|
|
18
|
-
* State (identity file excluded) lives under `--state`, never in the project,
|
|
19
|
-
* so `write: false` offers cannot dirty the worktree they are measuring.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { parseArgs as parseNodeArgs } from 'node:util';
|
|
23
|
-
import process from 'node:process';
|
|
24
|
-
|
|
25
|
-
import { formatError, resolveStateDir } from '../src/util/cli.mjs';
|
|
26
|
-
import { createAgent, parseAllowedCommands } from '../src/agent/agent.mjs';
|
|
27
|
-
import { probeCapsDetailed, detectPlatform } from '../src/agent/caps.mjs';
|
|
28
|
-
import { loadOrCreateIdentity, saveDeviceToken } from '../src/agent/identity.mjs';
|
|
29
|
-
import { normalizeP2PMode, normalizeP2PPort, stunServersWithShared } from '../src/agent/p2p-node.mjs';
|
|
30
|
-
import { resolveBaseUrl } from '../src/agent/url.mjs';
|
|
31
|
-
|
|
32
|
-
const USAGE = `w2m-localside — W2M Localside agent (protocol v1)
|
|
33
|
-
|
|
34
|
-
Usage:
|
|
35
|
-
w2m-localside --rabbit <url> [--pair <CODE>] [--project <dir>]
|
|
36
|
-
[--name <name>] [--allowed-commands <json>]
|
|
37
|
-
[--state <dir>] [--p2p-mode <mode>] [--p2p-port <n>]
|
|
38
|
-
[--stun-servers <list>]
|
|
39
|
-
[--once] [--once-idle-ms <ms>]
|
|
40
|
-
|
|
41
|
-
Options:
|
|
42
|
-
--rabbit <url> Rabbit base address (default: $W2M_RABBIT_URL).
|
|
43
|
-
A sub-path is supported: https://host/w2m
|
|
44
|
-
Query strings and fragments are rejected.
|
|
45
|
-
--pair <CODE> One-time pairing code, e.g. PAIR-7K2M9QX4
|
|
46
|
-
--project <dir> Project root to run in (default: current directory)
|
|
47
|
-
--name <name> machine_name (default: <os>-<hostname>)
|
|
48
|
-
--allowed-commands <json> Default-deny allow-list, JSON array of prefixes.
|
|
49
|
-
Example: '["node --test","git status"]'
|
|
50
|
-
Nothing is allowed when omitted.
|
|
51
|
-
--state <dir> State directory for spool/state
|
|
52
|
-
(default: <DSH_HOME>/xclient/localside)
|
|
53
|
-
--p2p-mode <mode> auto (default) | direct | relay
|
|
54
|
-
(default: $W2M_P2P_MODE)
|
|
55
|
-
auto: accept an offer over either path
|
|
56
|
-
direct: refuse an offer that did not arrive
|
|
57
|
-
directly (P2P_UNAVAILABLE)
|
|
58
|
-
relay: v0.3.9 - no UDP socket is bound at all
|
|
59
|
-
--p2p-port <n> Local UDP port for the punch socket, 1..65535
|
|
60
|
-
(default: $W2M_P2P_PORT, or an ephemeral port)
|
|
61
|
-
Pin this on a machine that must *accept* a punch:
|
|
62
|
-
an ephemeral port changes on every restart, so the
|
|
63
|
-
firewall rule has to be re-pointed each time and
|
|
64
|
-
every dispatch falls back to the relay until it is.
|
|
65
|
-
--stun-servers <list> Comma-separated STUN servers, queried in order,
|
|
66
|
-
after the shared server (default: $W2M_STUN_SERVERS)
|
|
67
|
-
Example: stun.internal:3478,stun.l.google.com:19302
|
|
68
|
-
--operator-token <t> Operator token (or $W2M_OPERATOR_TOKEN). Only needed
|
|
69
|
-
when this host also submits tasks; the Localside
|
|
70
|
-
agent itself never sends it.
|
|
71
|
-
--once Handle one offer, then exit
|
|
72
|
-
--once-idle-ms <ms> With --once: give up if no offer arrives in <ms>
|
|
73
|
-
--heartbeat-ms <ms> Lease heartbeat interval (default: 10000)
|
|
74
|
-
--help Show this message
|
|
75
|
-
|
|
76
|
-
Exit codes: 0 stopped cleanly, 1 runtime error, 2 usage error.`;
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Parse argv, tolerating `--flag=value` and `--flag value`.
|
|
80
|
-
*
|
|
81
|
-
* @param {string[]} argv
|
|
82
|
-
*/
|
|
83
|
-
function parseCli(argv) {
|
|
84
|
-
const { values, positionals } = parseNodeArgs({
|
|
85
|
-
args: argv,
|
|
86
|
-
allowPositionals: false,
|
|
87
|
-
strict: true,
|
|
88
|
-
options: {
|
|
89
|
-
rabbit: { type: 'string' },
|
|
90
|
-
pair: { type: 'string' },
|
|
91
|
-
// v0.8.1: the account-derived key, so a machine joins its account's fleet with no
|
|
92
|
-
// operator step. Supplied by the plugin, which is the only process that can read the
|
|
93
|
-
// signed-in account; the agent is standalone and cannot derive it itself.
|
|
94
|
-
'fleet-key': { type: 'string' },
|
|
95
|
-
project: { type: 'string' },
|
|
96
|
-
name: { type: 'string' },
|
|
97
|
-
'allowed-commands': { type: 'string' },
|
|
98
|
-
'operator-token': { type: 'string' },
|
|
99
|
-
state: { type: 'string' },
|
|
100
|
-
'p2p-mode': { type: 'string' },
|
|
101
|
-
'p2p-port': { type: 'string' },
|
|
102
|
-
'stun-servers': { type: 'string' },
|
|
103
|
-
once: { type: 'boolean', default: false },
|
|
104
|
-
'once-idle-ms': { type: 'string' },
|
|
105
|
-
'heartbeat-ms': { type: 'string' },
|
|
106
|
-
help: { type: 'boolean', default: false },
|
|
107
|
-
},
|
|
108
|
-
});
|
|
109
|
-
void positionals;
|
|
110
|
-
return values;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async function main() {
|
|
114
|
-
let flags;
|
|
115
|
-
try {
|
|
116
|
-
flags = parseCli(process.argv.slice(2));
|
|
117
|
-
} catch (error) {
|
|
118
|
-
process.stderr.write(`${formatError(error)}\n\n${USAGE}\n`);
|
|
119
|
-
return 2;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (flags.help) {
|
|
123
|
-
process.stdout.write(`${USAGE}\n`);
|
|
124
|
-
return 0;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const rabbitUrl = flags.rabbit ?? process.env.W2M_RABBIT_URL ?? '';
|
|
128
|
-
if (rabbitUrl.trim() === '') {
|
|
129
|
-
process.stderr.write('error: --rabbit <url> is required (or set W2M_RABBIT_URL)\n\n' + `${USAGE}\n`);
|
|
130
|
-
return 2;
|
|
131
|
-
}
|
|
132
|
-
// Validate the base address before doing anything else: a sub-path is fine,
|
|
133
|
-
// a query string / fragment / non-http scheme is a usage error (v0.1.2 §2).
|
|
134
|
-
let baseUrl;
|
|
135
|
-
try {
|
|
136
|
-
baseUrl = resolveBaseUrl(rabbitUrl);
|
|
137
|
-
} catch (error) {
|
|
138
|
-
process.stderr.write(`error: ${formatError(error)}\n`);
|
|
139
|
-
return 2;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const operatorToken = flags['operator-token'] ?? process.env.W2M_OPERATOR_TOKEN ?? null;
|
|
143
|
-
|
|
144
|
-
let allowedCommands;
|
|
145
|
-
try {
|
|
146
|
-
allowedCommands = parseAllowedCommands(flags['allowed-commands'] ?? '[]');
|
|
147
|
-
} catch (error) {
|
|
148
|
-
process.stderr.write(`error: ${formatError(error)}\n`);
|
|
149
|
-
return 2;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const project = flags.project ?? process.cwd();
|
|
153
|
-
const stateDir = resolveStateDir(flags.state, 'localside');
|
|
154
|
-
const onceIdleMs = flags['once-idle-ms'] ? Number.parseInt(flags['once-idle-ms'], 10) : 0;
|
|
155
|
-
const heartbeatMs = flags['heartbeat-ms']
|
|
156
|
-
? Number.parseInt(flags['heartbeat-ms'], 10)
|
|
157
|
-
: undefined;
|
|
158
|
-
|
|
159
|
-
if (Number.isNaN(onceIdleMs) || onceIdleMs < 0) {
|
|
160
|
-
process.stderr.write('error: --once-idle-ms must be a non-negative integer\n');
|
|
161
|
-
return 2;
|
|
162
|
-
}
|
|
163
|
-
if (heartbeatMs !== undefined && (Number.isNaN(heartbeatMs) || heartbeatMs <= 0)) {
|
|
164
|
-
process.stderr.write('error: --heartbeat-ms must be a positive integer\n');
|
|
165
|
-
return 2;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// v0.4.0. The flag wins over the environment, and both go through the same loud validator the
|
|
169
|
-
// node and the agent use: a typo is a usage error (exit 2) naming the setting, exactly like
|
|
170
|
-
// `--rabbit` or `--once-idle-ms`, and never a silent fallback to the default. A mode that
|
|
171
|
-
// silently became `auto` would turn `direct` into a promise the operator only discovers was
|
|
172
|
-
// broken when a punch failed.
|
|
173
|
-
const p2pModeInput = flags['p2p-mode'] ?? process.env.W2M_P2P_MODE ?? undefined;
|
|
174
|
-
const p2pModeResult = normalizeP2PMode(p2pModeInput === undefined ? 'auto' : p2pModeInput);
|
|
175
|
-
if (!p2pModeResult.ok) {
|
|
176
|
-
const source = flags['p2p-mode'] !== undefined ? '--p2p-mode' : 'W2M_P2P_MODE';
|
|
177
|
-
process.stderr.write(`error: ${source}: ${p2pModeResult.reason}\n`);
|
|
178
|
-
return 2;
|
|
179
|
-
}
|
|
180
|
-
const p2pMode = p2pModeResult.mode;
|
|
181
|
-
|
|
182
|
-
// Same precedence rule, same reason, and the same loud validator the node and the agent use.
|
|
183
|
-
// Omitting the setting keeps the ephemeral port, which is the right default for a machine that
|
|
184
|
-
// only dials. A value that *is* written must be a real port: `0` is refused by name rather than
|
|
185
|
-
// read as "let the OS choose", because an operator who writes `0` is asking for a stable port and
|
|
186
|
-
// would silently get a random one -- see `normalizeP2PPort`.
|
|
187
|
-
const p2pPortInput = flags['p2p-port'] ?? process.env.W2M_P2P_PORT ?? undefined;
|
|
188
|
-
const p2pPortResult = normalizeP2PPort(p2pPortInput);
|
|
189
|
-
if (!p2pPortResult.ok) {
|
|
190
|
-
const source = flags['p2p-port'] !== undefined ? '--p2p-port' : 'W2M_P2P_PORT';
|
|
191
|
-
process.stderr.write(`error: ${source}: ${p2pPortResult.reason}\n`);
|
|
192
|
-
return 2;
|
|
193
|
-
}
|
|
194
|
-
const p2pPort = p2pPortResult.port;
|
|
195
|
-
|
|
196
|
-
// Same precedence rule, same reason. An empty list means "the shared server and the public
|
|
197
|
-
// fallbacks", i.e. the default -- `stunServersWithShared()` accepts '' and drops it -- so an
|
|
198
|
-
// accidentally blank variable is harmless rather than a node that cannot discover anything.
|
|
199
|
-
const stunInput = flags['stun-servers'] ?? process.env.W2M_STUN_SERVERS ?? '';
|
|
200
|
-
let stunServers;
|
|
201
|
-
try {
|
|
202
|
-
stunServers = stunServersWithShared(stunInput);
|
|
203
|
-
} catch (error) {
|
|
204
|
-
const source = flags['stun-servers'] !== undefined ? '--stun-servers' : 'W2M_STUN_SERVERS';
|
|
205
|
-
process.stderr.write(`error: ${source}: ${formatError(error)}\n`);
|
|
206
|
-
return 2;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const log = (level, message, extra) => {
|
|
210
|
-
const suffix = extra && Object.keys(extra).length > 0 ? ` ${JSON.stringify(extra)}` : '';
|
|
211
|
-
process.stdout.write(`${new Date().toISOString()} ${level.padEnd(7)} ${message}${suffix}\n`);
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
/** One-shot guard for the resolved-P2P startup line (see `onP2PStatus` below). */
|
|
215
|
-
let p2pLogged = false;
|
|
216
|
-
|
|
217
|
-
try {
|
|
218
|
-
const { identity, path: identityPath, created } = loadOrCreateIdentity({
|
|
219
|
-
name: flags.name,
|
|
220
|
-
rabbitUrl: baseUrl,
|
|
221
|
-
});
|
|
222
|
-
log('info', `${created ? 'created' : 'loaded'} identity ${identity.machine_id} (${identityPath})`);
|
|
223
|
-
|
|
224
|
-
const [{ caps, detail }, platform] = await Promise.all([
|
|
225
|
-
probeCapsDetailed({}),
|
|
226
|
-
detectPlatform({}),
|
|
227
|
-
]);
|
|
228
|
-
log('info', `platform ${platform.os} ${platform.os_version} ${platform.arch} shell=${platform.shell}`, {});
|
|
229
|
-
log('info', 'caps', caps);
|
|
230
|
-
if (detail.notes.length > 0) log('warn', 'capability probe notes', { notes: detail.notes });
|
|
231
|
-
|
|
232
|
-
const agent = createAgent({
|
|
233
|
-
rabbitUrl: baseUrl,
|
|
234
|
-
project,
|
|
235
|
-
stateDir,
|
|
236
|
-
identity,
|
|
237
|
-
// v0.6.2: the device key belongs beside the `device.json` this process actually loaded, and this
|
|
238
|
-
// is the only place that knows where that is. Without it the agent fell back to `--state`, a
|
|
239
|
-
// different directory — so on one machine the plugin and the agent each minted their own keypair
|
|
240
|
-
// and then overwrote each other's announcement under the one shared machine_id.
|
|
241
|
-
identityPath,
|
|
242
|
-
caps,
|
|
243
|
-
platform,
|
|
244
|
-
allowedCommands,
|
|
245
|
-
once: flags.once === true,
|
|
246
|
-
onceIdleMs,
|
|
247
|
-
heartbeatIntervalMs: heartbeatMs,
|
|
248
|
-
operatorToken,
|
|
249
|
-
p2pMode,
|
|
250
|
-
p2pPort,
|
|
251
|
-
stunServers,
|
|
252
|
-
log,
|
|
253
|
-
// The startup line has to name the *resolved* mode, the STUN list and whether the node
|
|
254
|
-
// actually started. `p2p_mode` is what was asked for; `started` is what happened, and a
|
|
255
|
-
// machine that cannot punch reports `started: false` with the reason right there -- which is
|
|
256
|
-
// the whole point of "a failure to start the node is never fatal".
|
|
257
|
-
onP2PStatus: (status) => {
|
|
258
|
-
// One line, for every mode including `relay`. It is emitted when the fact is final --
|
|
259
|
-
// the node started, the node failed to start, or there is no node to start -- so the line
|
|
260
|
-
// never says "starting" and never repeats as the status is refreshed on each heartbeat.
|
|
261
|
-
const final = status.running || !status.enabled || status.start_error !== null;
|
|
262
|
-
if (p2pLogged || !final) return;
|
|
263
|
-
p2pLogged = true;
|
|
264
|
-
log(status.start_error === null ? 'info' : 'warn', `p2p mode=${status.mode} node ${status.running ? 'started' : 'not started'}`, {
|
|
265
|
-
stun_servers: stunServers,
|
|
266
|
-
...(status.start_error === null ? {} : { error: status.start_error }),
|
|
267
|
-
});
|
|
268
|
-
},
|
|
269
|
-
});
|
|
270
|
-
if (operatorToken) {
|
|
271
|
-
log('info', 'operator token configured (submit-capable host); the agent itself never sends it', {
|
|
272
|
-
length: operatorToken.length,
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Pair by a code, by an account fleet key, or not at all (v0.8.1).
|
|
278
|
-
*
|
|
279
|
-
* ## The bug a first version of this had
|
|
280
|
-
*
|
|
281
|
-
* The fleet-key handling was inserted **inside `if (flags.pair)`**, so the key path could only
|
|
282
|
-
* run when a pairing code was also supplied — which is precisely the case it exists to avoid.
|
|
283
|
-
* It worked whenever anyone tested it with a code, and did nothing in the case that mattered.
|
|
284
|
-
*
|
|
285
|
-
* The two are therefore peers rather than nested: either credential alone is enough.
|
|
286
|
-
*/
|
|
287
|
-
const fleetKey = typeof flags['fleet-key'] === 'string' && flags['fleet-key'] !== '' ? flags['fleet-key'] : null;
|
|
288
|
-
|
|
289
|
-
if (flags.pair || fleetKey !== null) {
|
|
290
|
-
const paired = await agent.pair(flags.pair ?? null, undefined, fleetKey);
|
|
291
|
-
saveDeviceToken(paired.device_token, {});
|
|
292
|
-
log('info', `device_token stored (paired by ${flags.pair ? 'pairing code' : 'fleet key'})`);
|
|
293
|
-
} else if (!identity.device_token) {
|
|
294
|
-
log('warn', 'no device_token and no --pair/--fleet-key: offers cannot be fetched until paired');
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
let stopping = false;
|
|
298
|
-
const onSignal = (signal) => {
|
|
299
|
-
if (stopping) return;
|
|
300
|
-
stopping = true;
|
|
301
|
-
log('warn', `received ${signal}, stopping`);
|
|
302
|
-
// Awaited so the node's socket is really gone before the process leaves; an unawaited
|
|
303
|
-
// `stop()` would let the event loop drain with a dgram handle still open.
|
|
304
|
-
void agent.stop();
|
|
305
|
-
};
|
|
306
|
-
process.on('SIGINT', () => onSignal('SIGINT'));
|
|
307
|
-
process.on('SIGTERM', () => onSignal('SIGTERM'));
|
|
308
|
-
|
|
309
|
-
await agent.start();
|
|
310
|
-
return 0;
|
|
311
|
-
} catch (error) {
|
|
312
|
-
process.stderr.write(`error: ${formatError(error)}\n`);
|
|
313
|
-
return 1;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
main().then(
|
|
318
|
-
(code) => {
|
|
319
|
-
process.exitCode = code;
|
|
320
|
-
},
|
|
321
|
-
(error) => {
|
|
322
|
-
process.stderr.write(`fatal: ${formatError(error)}\n`);
|
|
323
|
-
process.exitCode = 1;
|
|
324
|
-
},
|
|
325
|
-
);
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `w2m-localside` — the Windows/macOS side of a W2M pair.
|
|
4
|
+
*
|
|
5
|
+
* node bin/w2m-localside.mjs \
|
|
6
|
+
* --rabbit http://127.0.0.1:8787 \
|
|
7
|
+
* --pair PAIR-7K2M9QX4 \
|
|
8
|
+
* --project E:/DS/w2m \
|
|
9
|
+
* --name win-desktop \
|
|
10
|
+
* --allowed-commands '["node --test","git status"]' \
|
|
11
|
+
* --state ~/.dsh/xclient/localside \
|
|
12
|
+
* --once
|
|
13
|
+
*
|
|
14
|
+
* The allow-list is **default-deny**: with no `--allowed-commands`, every offer
|
|
15
|
+
* is refused with `COMMAND_NOT_ALLOWED`. That is deliberate -- an agent that
|
|
16
|
+
* runs whatever it is told is not a thing this project ships.
|
|
17
|
+
*
|
|
18
|
+
* State (identity file excluded) lives under `--state`, never in the project,
|
|
19
|
+
* so `write: false` offers cannot dirty the worktree they are measuring.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { parseArgs as parseNodeArgs } from 'node:util';
|
|
23
|
+
import process from 'node:process';
|
|
24
|
+
|
|
25
|
+
import { formatError, resolveStateDir } from '../src/util/cli.mjs';
|
|
26
|
+
import { createAgent, parseAllowedCommands } from '../src/agent/agent.mjs';
|
|
27
|
+
import { probeCapsDetailed, detectPlatform } from '../src/agent/caps.mjs';
|
|
28
|
+
import { loadOrCreateIdentity, saveDeviceToken } from '../src/agent/identity.mjs';
|
|
29
|
+
import { normalizeP2PMode, normalizeP2PPort, stunServersWithShared } from '../src/agent/p2p-node.mjs';
|
|
30
|
+
import { resolveBaseUrl } from '../src/agent/url.mjs';
|
|
31
|
+
|
|
32
|
+
const USAGE = `w2m-localside — W2M Localside agent (protocol v1)
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
w2m-localside --rabbit <url> [--pair <CODE>] [--project <dir>]
|
|
36
|
+
[--name <name>] [--allowed-commands <json>]
|
|
37
|
+
[--state <dir>] [--p2p-mode <mode>] [--p2p-port <n>]
|
|
38
|
+
[--stun-servers <list>]
|
|
39
|
+
[--once] [--once-idle-ms <ms>]
|
|
40
|
+
|
|
41
|
+
Options:
|
|
42
|
+
--rabbit <url> Rabbit base address (default: $W2M_RABBIT_URL).
|
|
43
|
+
A sub-path is supported: https://host/w2m
|
|
44
|
+
Query strings and fragments are rejected.
|
|
45
|
+
--pair <CODE> One-time pairing code, e.g. PAIR-7K2M9QX4
|
|
46
|
+
--project <dir> Project root to run in (default: current directory)
|
|
47
|
+
--name <name> machine_name (default: <os>-<hostname>)
|
|
48
|
+
--allowed-commands <json> Default-deny allow-list, JSON array of prefixes.
|
|
49
|
+
Example: '["node --test","git status"]'
|
|
50
|
+
Nothing is allowed when omitted.
|
|
51
|
+
--state <dir> State directory for spool/state
|
|
52
|
+
(default: <DSH_HOME>/xclient/localside)
|
|
53
|
+
--p2p-mode <mode> auto (default) | direct | relay
|
|
54
|
+
(default: $W2M_P2P_MODE)
|
|
55
|
+
auto: accept an offer over either path
|
|
56
|
+
direct: refuse an offer that did not arrive
|
|
57
|
+
directly (P2P_UNAVAILABLE)
|
|
58
|
+
relay: v0.3.9 - no UDP socket is bound at all
|
|
59
|
+
--p2p-port <n> Local UDP port for the punch socket, 1..65535
|
|
60
|
+
(default: $W2M_P2P_PORT, or an ephemeral port)
|
|
61
|
+
Pin this on a machine that must *accept* a punch:
|
|
62
|
+
an ephemeral port changes on every restart, so the
|
|
63
|
+
firewall rule has to be re-pointed each time and
|
|
64
|
+
every dispatch falls back to the relay until it is.
|
|
65
|
+
--stun-servers <list> Comma-separated STUN servers, queried in order,
|
|
66
|
+
after the shared server (default: $W2M_STUN_SERVERS)
|
|
67
|
+
Example: stun.internal:3478,stun.l.google.com:19302
|
|
68
|
+
--operator-token <t> Operator token (or $W2M_OPERATOR_TOKEN). Only needed
|
|
69
|
+
when this host also submits tasks; the Localside
|
|
70
|
+
agent itself never sends it.
|
|
71
|
+
--once Handle one offer, then exit
|
|
72
|
+
--once-idle-ms <ms> With --once: give up if no offer arrives in <ms>
|
|
73
|
+
--heartbeat-ms <ms> Lease heartbeat interval (default: 10000)
|
|
74
|
+
--help Show this message
|
|
75
|
+
|
|
76
|
+
Exit codes: 0 stopped cleanly, 1 runtime error, 2 usage error.`;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse argv, tolerating `--flag=value` and `--flag value`.
|
|
80
|
+
*
|
|
81
|
+
* @param {string[]} argv
|
|
82
|
+
*/
|
|
83
|
+
function parseCli(argv) {
|
|
84
|
+
const { values, positionals } = parseNodeArgs({
|
|
85
|
+
args: argv,
|
|
86
|
+
allowPositionals: false,
|
|
87
|
+
strict: true,
|
|
88
|
+
options: {
|
|
89
|
+
rabbit: { type: 'string' },
|
|
90
|
+
pair: { type: 'string' },
|
|
91
|
+
// v0.8.1: the account-derived key, so a machine joins its account's fleet with no
|
|
92
|
+
// operator step. Supplied by the plugin, which is the only process that can read the
|
|
93
|
+
// signed-in account; the agent is standalone and cannot derive it itself.
|
|
94
|
+
'fleet-key': { type: 'string' },
|
|
95
|
+
project: { type: 'string' },
|
|
96
|
+
name: { type: 'string' },
|
|
97
|
+
'allowed-commands': { type: 'string' },
|
|
98
|
+
'operator-token': { type: 'string' },
|
|
99
|
+
state: { type: 'string' },
|
|
100
|
+
'p2p-mode': { type: 'string' },
|
|
101
|
+
'p2p-port': { type: 'string' },
|
|
102
|
+
'stun-servers': { type: 'string' },
|
|
103
|
+
once: { type: 'boolean', default: false },
|
|
104
|
+
'once-idle-ms': { type: 'string' },
|
|
105
|
+
'heartbeat-ms': { type: 'string' },
|
|
106
|
+
help: { type: 'boolean', default: false },
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
void positionals;
|
|
110
|
+
return values;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function main() {
|
|
114
|
+
let flags;
|
|
115
|
+
try {
|
|
116
|
+
flags = parseCli(process.argv.slice(2));
|
|
117
|
+
} catch (error) {
|
|
118
|
+
process.stderr.write(`${formatError(error)}\n\n${USAGE}\n`);
|
|
119
|
+
return 2;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (flags.help) {
|
|
123
|
+
process.stdout.write(`${USAGE}\n`);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const rabbitUrl = flags.rabbit ?? process.env.W2M_RABBIT_URL ?? '';
|
|
128
|
+
if (rabbitUrl.trim() === '') {
|
|
129
|
+
process.stderr.write('error: --rabbit <url> is required (or set W2M_RABBIT_URL)\n\n' + `${USAGE}\n`);
|
|
130
|
+
return 2;
|
|
131
|
+
}
|
|
132
|
+
// Validate the base address before doing anything else: a sub-path is fine,
|
|
133
|
+
// a query string / fragment / non-http scheme is a usage error (v0.1.2 §2).
|
|
134
|
+
let baseUrl;
|
|
135
|
+
try {
|
|
136
|
+
baseUrl = resolveBaseUrl(rabbitUrl);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
process.stderr.write(`error: ${formatError(error)}\n`);
|
|
139
|
+
return 2;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const operatorToken = flags['operator-token'] ?? process.env.W2M_OPERATOR_TOKEN ?? null;
|
|
143
|
+
|
|
144
|
+
let allowedCommands;
|
|
145
|
+
try {
|
|
146
|
+
allowedCommands = parseAllowedCommands(flags['allowed-commands'] ?? '[]');
|
|
147
|
+
} catch (error) {
|
|
148
|
+
process.stderr.write(`error: ${formatError(error)}\n`);
|
|
149
|
+
return 2;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const project = flags.project ?? process.cwd();
|
|
153
|
+
const stateDir = resolveStateDir(flags.state, 'localside');
|
|
154
|
+
const onceIdleMs = flags['once-idle-ms'] ? Number.parseInt(flags['once-idle-ms'], 10) : 0;
|
|
155
|
+
const heartbeatMs = flags['heartbeat-ms']
|
|
156
|
+
? Number.parseInt(flags['heartbeat-ms'], 10)
|
|
157
|
+
: undefined;
|
|
158
|
+
|
|
159
|
+
if (Number.isNaN(onceIdleMs) || onceIdleMs < 0) {
|
|
160
|
+
process.stderr.write('error: --once-idle-ms must be a non-negative integer\n');
|
|
161
|
+
return 2;
|
|
162
|
+
}
|
|
163
|
+
if (heartbeatMs !== undefined && (Number.isNaN(heartbeatMs) || heartbeatMs <= 0)) {
|
|
164
|
+
process.stderr.write('error: --heartbeat-ms must be a positive integer\n');
|
|
165
|
+
return 2;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// v0.4.0. The flag wins over the environment, and both go through the same loud validator the
|
|
169
|
+
// node and the agent use: a typo is a usage error (exit 2) naming the setting, exactly like
|
|
170
|
+
// `--rabbit` or `--once-idle-ms`, and never a silent fallback to the default. A mode that
|
|
171
|
+
// silently became `auto` would turn `direct` into a promise the operator only discovers was
|
|
172
|
+
// broken when a punch failed.
|
|
173
|
+
const p2pModeInput = flags['p2p-mode'] ?? process.env.W2M_P2P_MODE ?? undefined;
|
|
174
|
+
const p2pModeResult = normalizeP2PMode(p2pModeInput === undefined ? 'auto' : p2pModeInput);
|
|
175
|
+
if (!p2pModeResult.ok) {
|
|
176
|
+
const source = flags['p2p-mode'] !== undefined ? '--p2p-mode' : 'W2M_P2P_MODE';
|
|
177
|
+
process.stderr.write(`error: ${source}: ${p2pModeResult.reason}\n`);
|
|
178
|
+
return 2;
|
|
179
|
+
}
|
|
180
|
+
const p2pMode = p2pModeResult.mode;
|
|
181
|
+
|
|
182
|
+
// Same precedence rule, same reason, and the same loud validator the node and the agent use.
|
|
183
|
+
// Omitting the setting keeps the ephemeral port, which is the right default for a machine that
|
|
184
|
+
// only dials. A value that *is* written must be a real port: `0` is refused by name rather than
|
|
185
|
+
// read as "let the OS choose", because an operator who writes `0` is asking for a stable port and
|
|
186
|
+
// would silently get a random one -- see `normalizeP2PPort`.
|
|
187
|
+
const p2pPortInput = flags['p2p-port'] ?? process.env.W2M_P2P_PORT ?? undefined;
|
|
188
|
+
const p2pPortResult = normalizeP2PPort(p2pPortInput);
|
|
189
|
+
if (!p2pPortResult.ok) {
|
|
190
|
+
const source = flags['p2p-port'] !== undefined ? '--p2p-port' : 'W2M_P2P_PORT';
|
|
191
|
+
process.stderr.write(`error: ${source}: ${p2pPortResult.reason}\n`);
|
|
192
|
+
return 2;
|
|
193
|
+
}
|
|
194
|
+
const p2pPort = p2pPortResult.port;
|
|
195
|
+
|
|
196
|
+
// Same precedence rule, same reason. An empty list means "the shared server and the public
|
|
197
|
+
// fallbacks", i.e. the default -- `stunServersWithShared()` accepts '' and drops it -- so an
|
|
198
|
+
// accidentally blank variable is harmless rather than a node that cannot discover anything.
|
|
199
|
+
const stunInput = flags['stun-servers'] ?? process.env.W2M_STUN_SERVERS ?? '';
|
|
200
|
+
let stunServers;
|
|
201
|
+
try {
|
|
202
|
+
stunServers = stunServersWithShared(stunInput);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
const source = flags['stun-servers'] !== undefined ? '--stun-servers' : 'W2M_STUN_SERVERS';
|
|
205
|
+
process.stderr.write(`error: ${source}: ${formatError(error)}\n`);
|
|
206
|
+
return 2;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const log = (level, message, extra) => {
|
|
210
|
+
const suffix = extra && Object.keys(extra).length > 0 ? ` ${JSON.stringify(extra)}` : '';
|
|
211
|
+
process.stdout.write(`${new Date().toISOString()} ${level.padEnd(7)} ${message}${suffix}\n`);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** One-shot guard for the resolved-P2P startup line (see `onP2PStatus` below). */
|
|
215
|
+
let p2pLogged = false;
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const { identity, path: identityPath, created } = loadOrCreateIdentity({
|
|
219
|
+
name: flags.name,
|
|
220
|
+
rabbitUrl: baseUrl,
|
|
221
|
+
});
|
|
222
|
+
log('info', `${created ? 'created' : 'loaded'} identity ${identity.machine_id} (${identityPath})`);
|
|
223
|
+
|
|
224
|
+
const [{ caps, detail }, platform] = await Promise.all([
|
|
225
|
+
probeCapsDetailed({}),
|
|
226
|
+
detectPlatform({}),
|
|
227
|
+
]);
|
|
228
|
+
log('info', `platform ${platform.os} ${platform.os_version} ${platform.arch} shell=${platform.shell}`, {});
|
|
229
|
+
log('info', 'caps', caps);
|
|
230
|
+
if (detail.notes.length > 0) log('warn', 'capability probe notes', { notes: detail.notes });
|
|
231
|
+
|
|
232
|
+
const agent = createAgent({
|
|
233
|
+
rabbitUrl: baseUrl,
|
|
234
|
+
project,
|
|
235
|
+
stateDir,
|
|
236
|
+
identity,
|
|
237
|
+
// v0.6.2: the device key belongs beside the `device.json` this process actually loaded, and this
|
|
238
|
+
// is the only place that knows where that is. Without it the agent fell back to `--state`, a
|
|
239
|
+
// different directory — so on one machine the plugin and the agent each minted their own keypair
|
|
240
|
+
// and then overwrote each other's announcement under the one shared machine_id.
|
|
241
|
+
identityPath,
|
|
242
|
+
caps,
|
|
243
|
+
platform,
|
|
244
|
+
allowedCommands,
|
|
245
|
+
once: flags.once === true,
|
|
246
|
+
onceIdleMs,
|
|
247
|
+
heartbeatIntervalMs: heartbeatMs,
|
|
248
|
+
operatorToken,
|
|
249
|
+
p2pMode,
|
|
250
|
+
p2pPort,
|
|
251
|
+
stunServers,
|
|
252
|
+
log,
|
|
253
|
+
// The startup line has to name the *resolved* mode, the STUN list and whether the node
|
|
254
|
+
// actually started. `p2p_mode` is what was asked for; `started` is what happened, and a
|
|
255
|
+
// machine that cannot punch reports `started: false` with the reason right there -- which is
|
|
256
|
+
// the whole point of "a failure to start the node is never fatal".
|
|
257
|
+
onP2PStatus: (status) => {
|
|
258
|
+
// One line, for every mode including `relay`. It is emitted when the fact is final --
|
|
259
|
+
// the node started, the node failed to start, or there is no node to start -- so the line
|
|
260
|
+
// never says "starting" and never repeats as the status is refreshed on each heartbeat.
|
|
261
|
+
const final = status.running || !status.enabled || status.start_error !== null;
|
|
262
|
+
if (p2pLogged || !final) return;
|
|
263
|
+
p2pLogged = true;
|
|
264
|
+
log(status.start_error === null ? 'info' : 'warn', `p2p mode=${status.mode} node ${status.running ? 'started' : 'not started'}`, {
|
|
265
|
+
stun_servers: stunServers,
|
|
266
|
+
...(status.start_error === null ? {} : { error: status.start_error }),
|
|
267
|
+
});
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
if (operatorToken) {
|
|
271
|
+
log('info', 'operator token configured (submit-capable host); the agent itself never sends it', {
|
|
272
|
+
length: operatorToken.length,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Pair by a code, by an account fleet key, or not at all (v0.8.1).
|
|
278
|
+
*
|
|
279
|
+
* ## The bug a first version of this had
|
|
280
|
+
*
|
|
281
|
+
* The fleet-key handling was inserted **inside `if (flags.pair)`**, so the key path could only
|
|
282
|
+
* run when a pairing code was also supplied — which is precisely the case it exists to avoid.
|
|
283
|
+
* It worked whenever anyone tested it with a code, and did nothing in the case that mattered.
|
|
284
|
+
*
|
|
285
|
+
* The two are therefore peers rather than nested: either credential alone is enough.
|
|
286
|
+
*/
|
|
287
|
+
const fleetKey = typeof flags['fleet-key'] === 'string' && flags['fleet-key'] !== '' ? flags['fleet-key'] : null;
|
|
288
|
+
|
|
289
|
+
if (flags.pair || fleetKey !== null) {
|
|
290
|
+
const paired = await agent.pair(flags.pair ?? null, undefined, fleetKey);
|
|
291
|
+
saveDeviceToken(paired.device_token, {});
|
|
292
|
+
log('info', `device_token stored (paired by ${flags.pair ? 'pairing code' : 'fleet key'})`);
|
|
293
|
+
} else if (!identity.device_token) {
|
|
294
|
+
log('warn', 'no device_token and no --pair/--fleet-key: offers cannot be fetched until paired');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let stopping = false;
|
|
298
|
+
const onSignal = (signal) => {
|
|
299
|
+
if (stopping) return;
|
|
300
|
+
stopping = true;
|
|
301
|
+
log('warn', `received ${signal}, stopping`);
|
|
302
|
+
// Awaited so the node's socket is really gone before the process leaves; an unawaited
|
|
303
|
+
// `stop()` would let the event loop drain with a dgram handle still open.
|
|
304
|
+
void agent.stop();
|
|
305
|
+
};
|
|
306
|
+
process.on('SIGINT', () => onSignal('SIGINT'));
|
|
307
|
+
process.on('SIGTERM', () => onSignal('SIGTERM'));
|
|
308
|
+
|
|
309
|
+
await agent.start();
|
|
310
|
+
return 0;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
process.stderr.write(`error: ${formatError(error)}\n`);
|
|
313
|
+
return 1;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
main().then(
|
|
318
|
+
(code) => {
|
|
319
|
+
process.exitCode = code;
|
|
320
|
+
},
|
|
321
|
+
(error) => {
|
|
322
|
+
process.stderr.write(`fatal: ${formatError(error)}\n`);
|
|
323
|
+
process.exitCode = 1;
|
|
324
|
+
},
|
|
325
|
+
);
|
package/bin/w2m-rabbit.mjs
CHANGED
|
File without changes
|