dsh-foreman-node 0.1.1 → 0.2.1
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 +69 -0
- package/LICENSE +21 -21
- package/README.md +29 -0
- package/cordis.patch.yml +0 -0
- package/lib/client.js +611 -0
- package/lib/index.d.ts +246 -32
- package/lib/index.js +662 -23
- package/package.json +27 -3
- package/src/client/index.tsx +211 -0
- package/src/client/locales.ts +95 -0
- package/src/client/page-state.ts +99 -0
- package/src/client.ts +0 -0
- package/src/config.ts +100 -0
- package/src/index.ts +112 -34
- package/src/keys.ts +49 -0
- package/src/protocol.ts +0 -0
- package/src/report-tool.ts +0 -0
- package/src/routes.ts +157 -0
- package/src/session.ts +57 -1
- package/src/tunnel.ts +267 -0
package/lib/index.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { FrameDedupe, encodeFrame, parseFrame } from "./protocol.js";
|
|
2
2
|
import s from "@deepseek-ai/schemastery";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
7
|
+
import { connect } from "node:net";
|
|
3
8
|
import { MessageId, freezeMessage } from "@deepseek-ai/dsh-llm";
|
|
4
9
|
// src/client.ts
|
|
5
10
|
/**
|
|
@@ -187,6 +192,531 @@ var NodeClient = class {
|
|
|
187
192
|
this.log(`dsh-foreman-node reconnecting in ${wait} ms`);
|
|
188
193
|
}
|
|
189
194
|
};
|
|
195
|
+
// src/config.ts
|
|
196
|
+
/** The SSH defaults, spread over whatever the host hands in, so a partial block is still complete. */
|
|
197
|
+
const SSH_DEFAULTS = {
|
|
198
|
+
enabled: false,
|
|
199
|
+
host: "",
|
|
200
|
+
port: 22,
|
|
201
|
+
user: "",
|
|
202
|
+
keyPath: "",
|
|
203
|
+
remotePort: 8790,
|
|
204
|
+
localPort: 8790
|
|
205
|
+
};
|
|
206
|
+
/** A volatile field arrives wrapped; this reads it either way, so callers do not have to know which. */
|
|
207
|
+
const valueOf = (field, fallback) => {
|
|
208
|
+
if (field === void 0) return fallback;
|
|
209
|
+
if (typeof field === "object" && field !== null && "get" in field && typeof field.get === "function") return field.get();
|
|
210
|
+
return field;
|
|
211
|
+
};
|
|
212
|
+
/**
|
|
213
|
+
* Unwrap a whole configuration: every field may be a volatile wrapper, a plain value, or absent.
|
|
214
|
+
*
|
|
215
|
+
* `Config` already applies defaults, so `apply` sees a complete object. This is for the paths that do not go through
|
|
216
|
+
* the schema: the configuration page's read, and a `cordis.patch.yml` written before a field existed.
|
|
217
|
+
*/
|
|
218
|
+
/**
|
|
219
|
+
* Unwrap the `ssh` block.
|
|
220
|
+
*
|
|
221
|
+
* Its fields are volatile for the same reason the top level is, so each may arrive wrapped. Reading them all through
|
|
222
|
+
* `valueOf` keeps one unwrapping rule instead of two, and it means a plain partial block (an older patch file) works
|
|
223
|
+
* just as well as a wrapped one.
|
|
224
|
+
*/
|
|
225
|
+
function unwrapSsh(block) {
|
|
226
|
+
if (block === null || typeof block !== "object") return {};
|
|
227
|
+
const source = block;
|
|
228
|
+
return {
|
|
229
|
+
enabled: valueOf(source["enabled"], SSH_DEFAULTS.enabled),
|
|
230
|
+
host: valueOf(source["host"], SSH_DEFAULTS.host),
|
|
231
|
+
port: valueOf(source["port"], SSH_DEFAULTS.port),
|
|
232
|
+
user: valueOf(source["user"], SSH_DEFAULTS.user),
|
|
233
|
+
keyPath: valueOf(source["keyPath"], SSH_DEFAULTS.keyPath),
|
|
234
|
+
remotePort: valueOf(source["remotePort"], SSH_DEFAULTS.remotePort),
|
|
235
|
+
localPort: valueOf(source["localPort"], SSH_DEFAULTS.localPort)
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function configured(config) {
|
|
239
|
+
const source = config ?? {};
|
|
240
|
+
return {
|
|
241
|
+
url: valueOf(source["url"], ""),
|
|
242
|
+
deviceId: valueOf(source["deviceId"], ""),
|
|
243
|
+
token: valueOf(source["token"], ""),
|
|
244
|
+
roots: valueOf(source["roots"], []),
|
|
245
|
+
heartbeatMs: valueOf(source["heartbeatMs"], 15e3),
|
|
246
|
+
reconnectMs: valueOf(source["reconnectMs"], 1e3),
|
|
247
|
+
ssh: {
|
|
248
|
+
...SSH_DEFAULTS,
|
|
249
|
+
...unwrapSsh(source["ssh"])
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
// src/keys.ts
|
|
254
|
+
/**
|
|
255
|
+
* The SSH keys this machine already has, named so a person can pick one.
|
|
256
|
+
*
|
|
257
|
+
* A browser cannot list `~/.ssh`: the page runs in the DSH web UI, which has no filesystem. The device process does,
|
|
258
|
+
* so the listing happens here and the page renders it. Nothing on this path reads a private key: the answer is a
|
|
259
|
+
* filename, a path, and the comment from the public half, which is enough to tell one key from another and is all a
|
|
260
|
+
* page needs.
|
|
261
|
+
*/
|
|
262
|
+
/** The `.pub` file that sits beside a private key. */
|
|
263
|
+
const publicOf = (path) => `${path}.pub`;
|
|
264
|
+
/**
|
|
265
|
+
* Lists the usable private keys in a directory.
|
|
266
|
+
*
|
|
267
|
+
* A key counts as usable only when its public half is beside it: that is how `ssh-keygen` writes them, and a private
|
|
268
|
+
* key without one is either half-deleted or was never finished, so offering it would only fail later at the tunnel.
|
|
269
|
+
* Anything unreadable is skipped rather than thrown, because a permissions problem on one file must not empty the list.
|
|
270
|
+
*/
|
|
271
|
+
function listKeys(directory) {
|
|
272
|
+
let names;
|
|
273
|
+
try {
|
|
274
|
+
names = readdirSync(directory);
|
|
275
|
+
} catch {
|
|
276
|
+
return [];
|
|
277
|
+
}
|
|
278
|
+
const keys = [];
|
|
279
|
+
for (const name of names.sort()) {
|
|
280
|
+
if (!name.startsWith("id_") || name.endsWith(".pub")) continue;
|
|
281
|
+
const path = join(directory, name);
|
|
282
|
+
if (!existsSync(publicOf(path))) continue;
|
|
283
|
+
let comment = "";
|
|
284
|
+
try {
|
|
285
|
+
comment = readFileSync(publicOf(path), "utf8").trim().split(/\s+/).slice(2).join(" ");
|
|
286
|
+
} catch {
|
|
287
|
+
comment = "";
|
|
288
|
+
}
|
|
289
|
+
keys.push({
|
|
290
|
+
name: basename(name),
|
|
291
|
+
path,
|
|
292
|
+
comment
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return keys;
|
|
296
|
+
}
|
|
297
|
+
// src/routes.ts
|
|
298
|
+
/** How long one authentication test may take before it is reported as a timeout. */
|
|
299
|
+
const TEST_TIMEOUT_MS = 15e3;
|
|
300
|
+
/**
|
|
301
|
+
* The argv of an authentication test.
|
|
302
|
+
*
|
|
303
|
+
* It asks for a command that does nothing (`true`) rather than opening a forward, because the question is only
|
|
304
|
+
* "does this key authenticate", and `BatchMode=yes` keeps it from stopping to ask for a passphrase nobody can type.
|
|
305
|
+
*/
|
|
306
|
+
function testArgs({ host, port, user, keyPath }) {
|
|
307
|
+
return [
|
|
308
|
+
"ssh",
|
|
309
|
+
"-o",
|
|
310
|
+
"BatchMode=yes",
|
|
311
|
+
"-o",
|
|
312
|
+
"ConnectTimeout=8",
|
|
313
|
+
"-o",
|
|
314
|
+
"StrictHostKeyChecking=accept-new",
|
|
315
|
+
"-p",
|
|
316
|
+
String(port),
|
|
317
|
+
"-i",
|
|
318
|
+
keyPath,
|
|
319
|
+
`${user}@${host}`,
|
|
320
|
+
"true"
|
|
321
|
+
];
|
|
322
|
+
}
|
|
323
|
+
/** The default runner: a real ssh, resolving to its exit status either way. */
|
|
324
|
+
const realRun = (argv) => new Promise((resolve) => {
|
|
325
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
326
|
+
stdio: [
|
|
327
|
+
"ignore",
|
|
328
|
+
"pipe",
|
|
329
|
+
"pipe"
|
|
330
|
+
],
|
|
331
|
+
windowsHide: true
|
|
332
|
+
});
|
|
333
|
+
let output = "";
|
|
334
|
+
const collect = (chunk) => {
|
|
335
|
+
output = (output + chunk.toString("utf8")).slice(-2048);
|
|
336
|
+
};
|
|
337
|
+
child.stdout.on("data", collect);
|
|
338
|
+
child.stderr.on("data", collect);
|
|
339
|
+
const timer = setTimeout(() => {
|
|
340
|
+
output = `${output}\n(timed out after ${TEST_TIMEOUT_MS / 1e3}s)`.trim();
|
|
341
|
+
child.kill("SIGKILL");
|
|
342
|
+
}, TEST_TIMEOUT_MS);
|
|
343
|
+
child.on("error", (error) => {
|
|
344
|
+
clearTimeout(timer);
|
|
345
|
+
resolve({
|
|
346
|
+
code: -1,
|
|
347
|
+
output: `could not start ssh: ${error.message}`
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
child.on("exit", (code) => {
|
|
351
|
+
clearTimeout(timer);
|
|
352
|
+
resolve({
|
|
353
|
+
code,
|
|
354
|
+
output: output.trim()
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
/** Writes one JSON answer. */
|
|
359
|
+
function json(res, status, value) {
|
|
360
|
+
res.writeHead(status, {
|
|
361
|
+
"content-type": "application/json; charset=utf-8",
|
|
362
|
+
"cache-control": "no-store",
|
|
363
|
+
"x-content-type-options": "nosniff"
|
|
364
|
+
});
|
|
365
|
+
res.end(JSON.stringify(value));
|
|
366
|
+
}
|
|
367
|
+
/** Reads a JSON body, refusing anything larger than a form would send. */
|
|
368
|
+
async function body(req, maxBytes = 16384) {
|
|
369
|
+
const chunks = [];
|
|
370
|
+
let size = 0;
|
|
371
|
+
for await (const chunk of req) {
|
|
372
|
+
const bytes = Buffer.from(chunk);
|
|
373
|
+
size += bytes.length;
|
|
374
|
+
if (size > maxBytes) throw new Error(`request exceeds ${maxBytes} bytes`);
|
|
375
|
+
chunks.push(bytes);
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
379
|
+
} catch {
|
|
380
|
+
throw new Error("the request body is not JSON");
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* The same-origin guard the hub uses, for the same reason.
|
|
385
|
+
*
|
|
386
|
+
* The marker header alone proves nothing: any local process can send it. What the browser will not let another origin
|
|
387
|
+
* do is send it *with* its own `Origin` intact, so an `Origin` that is present must be this page's. A request with no
|
|
388
|
+
* `Origin` at all (a local tool, a test) is allowed and the marker still has to be there.
|
|
389
|
+
*/
|
|
390
|
+
/** The loopback names a host header may carry; DSH's desktop build normalizes`::1` and `localhost` to each other. */
|
|
391
|
+
const LOOPBACK = [
|
|
392
|
+
"127.0.0.1",
|
|
393
|
+
"localhost",
|
|
394
|
+
"::1",
|
|
395
|
+
"[::1]"
|
|
396
|
+
];
|
|
397
|
+
function sameOrigin(req, port, protocol = "http:") {
|
|
398
|
+
const host = req.headers.host;
|
|
399
|
+
if (host === void 0) return false;
|
|
400
|
+
const match = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(host);
|
|
401
|
+
if (match === null) return false;
|
|
402
|
+
const [, name, seen] = match;
|
|
403
|
+
if (!LOOPBACK.includes(name) || Number(seen) !== port) return false;
|
|
404
|
+
return req.headers["x-orchestrator-request"] === "1" && (req.headers.origin === void 0 || req.headers.origin === `${protocol}//${host}`);
|
|
405
|
+
}
|
|
406
|
+
/** The prefix both routes live under; it never collides with the hub's `/orchestrator`. */
|
|
407
|
+
const NODE_PREFIX = "/foreman-node";
|
|
408
|
+
/**
|
|
409
|
+
* Registers the routes and returns their disposer.
|
|
410
|
+
*
|
|
411
|
+
* `listKeys` and `run` are injectable so tests can answer without a filesystem or a child process; production passes
|
|
412
|
+
* a directory and the real runner.
|
|
413
|
+
*/
|
|
414
|
+
function registerNodeRoutes(host, directory, run = realRun) {
|
|
415
|
+
return host.register({
|
|
416
|
+
kind: "prefix",
|
|
417
|
+
path: NODE_PREFIX,
|
|
418
|
+
handler: async (req, res) => {
|
|
419
|
+
if (!sameOrigin(req, host.port, host.protocol)) {
|
|
420
|
+
json(res, 403, { error: "Same-origin request required" });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
424
|
+
const method = req.method ?? "GET";
|
|
425
|
+
try {
|
|
426
|
+
if (url.pathname === `/foreman-node/api/keys` && method === "GET") {
|
|
427
|
+
json(res, 200, {
|
|
428
|
+
keys: listKeys(directory),
|
|
429
|
+
directory
|
|
430
|
+
});
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (url.pathname === `/foreman-node/api/test` && method === "POST") {
|
|
434
|
+
const input = await body(req);
|
|
435
|
+
if (typeof input.host !== "string" || input.host.trim() === "" || typeof input.user !== "string" || input.user.trim() === "" || typeof input.keyPath !== "string" || input.keyPath.trim() === "") {
|
|
436
|
+
json(res, 400, { error: "host, user and keyPath are required" });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const port = typeof input.port === "number" && Number.isInteger(input.port) && input.port > 0 && input.port <= 65535 ? input.port : 22;
|
|
440
|
+
const keyPath = input.keyPath.trim();
|
|
441
|
+
if (listKeys(directory).find((key) => key.path === keyPath) === void 0) {
|
|
442
|
+
json(res, 400, { error: `no key at ${keyPath}; the page lists the ones this machine has` });
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const result = await run(testArgs({
|
|
446
|
+
host: input.host.trim(),
|
|
447
|
+
port,
|
|
448
|
+
user: input.user.trim(),
|
|
449
|
+
keyPath
|
|
450
|
+
}));
|
|
451
|
+
json(res, 200, {
|
|
452
|
+
ok: result.code === 0,
|
|
453
|
+
output: result.output
|
|
454
|
+
});
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
json(res, 404, { error: "Unknown operation" });
|
|
458
|
+
} catch (error) {
|
|
459
|
+
json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
// src/tunnel.ts
|
|
465
|
+
/**
|
|
466
|
+
* The device's own tunnel to a hub it cannot reach directly.
|
|
467
|
+
*
|
|
468
|
+
* The hub listens on loopback only, on purpose: its ledger is the one thing worth protecting, and a publicly bound
|
|
469
|
+
* port would make the device token the only thing between it and the internet. So a device on another machine
|
|
470
|
+
* reaches it by opening a local port that `ssh -L` forwards to the hub's own loopback port.
|
|
471
|
+
*
|
|
472
|
+
* This class owns exactly one child process and nothing else. It does not know what the hub is for, does not build
|
|
473
|
+
* frames, and does not retry the connection inside the tunnel — the NodeClient above it does that. Its whole job is
|
|
474
|
+
* "there is a port on localhost that reaches the hub, and it stays that way", reported as a state a page can show.
|
|
475
|
+
*/
|
|
476
|
+
/** How long to wait for the local port to start answering before calling the attempt a failure. */
|
|
477
|
+
const READY_TIMEOUT_MS = 1e4;
|
|
478
|
+
/** How often to ask whether the port answers yet. */
|
|
479
|
+
const READY_POLL_MS = 200;
|
|
480
|
+
/** How long a stopped process may take to honour SIGTERM before it is killed. */
|
|
481
|
+
const STOP_GRACE_MS = 2e3;
|
|
482
|
+
/** `-N` forks no command, `-T` allocates no terminal: this process exists only to carry the forward. */
|
|
483
|
+
const TUNNEL_FLAGS = ["-N", "-T"];
|
|
484
|
+
/**
|
|
485
|
+
* The options that make a failure a failure.
|
|
486
|
+
*
|
|
487
|
+
* `BatchMode=yes` refuses to prompt, because nothing is there to answer and a prompt would hang forever.
|
|
488
|
+
* `ExitOnForwardFailure=yes` makes a forward that cannot be set up kill the process instead of leaving a live ssh
|
|
489
|
+
* that forwards nothing — the failure that would otherwise only show up as "the hub is offline".
|
|
490
|
+
* The keepalives let a dead link be noticed rather than waiting for a TCP timeout nobody is watching.
|
|
491
|
+
*/
|
|
492
|
+
const TUNNEL_OPTIONS = [
|
|
493
|
+
"-o",
|
|
494
|
+
"BatchMode=yes",
|
|
495
|
+
"-o",
|
|
496
|
+
"ExitOnForwardFailure=yes",
|
|
497
|
+
"-o",
|
|
498
|
+
"ServerAliveInterval=30",
|
|
499
|
+
"-o",
|
|
500
|
+
"ServerAliveCountMax=3"
|
|
501
|
+
];
|
|
502
|
+
/** The argv of the forwarding process, in order, exactly as the tests pin it. */
|
|
503
|
+
function tunnelArgs(ssh) {
|
|
504
|
+
return [
|
|
505
|
+
"ssh",
|
|
506
|
+
...TUNNEL_FLAGS,
|
|
507
|
+
...TUNNEL_OPTIONS,
|
|
508
|
+
"-p",
|
|
509
|
+
String(ssh.port),
|
|
510
|
+
"-i",
|
|
511
|
+
ssh.keyPath,
|
|
512
|
+
"-L",
|
|
513
|
+
`${ssh.localPort}:127.0.0.1:${ssh.remotePort}`,
|
|
514
|
+
`${ssh.user}@${ssh.host}`
|
|
515
|
+
];
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Turns ssh's own words into something a person can act on.
|
|
519
|
+
*
|
|
520
|
+
* Both of these would otherwise read as a bare "the tunnel stopped", and both have a specific fix. Reprinting the
|
|
521
|
+
* ssh text is not enough: it names the mechanism, not the move.
|
|
522
|
+
*/
|
|
523
|
+
function explain(ssh, stderr) {
|
|
524
|
+
const said = stderr.trim().split("\n").slice(-3).join(" ").trim();
|
|
525
|
+
const tail = said === "" ? "" : ` (ssh said: ${said})`;
|
|
526
|
+
if (/Permission denied \(publickey/.test(stderr)) return `the hub refused this key; if it has a passphrase, add it to ssh-agent or pick a key without one${tail}`;
|
|
527
|
+
if (/Address already in use|bind \[.*\]:\d+: Address already in use|Could not request local forwarding/.test(stderr)) return `local port ${ssh.localPort} is already in use; close whatever holds it, or set a different localPort${tail}`;
|
|
528
|
+
if (/Could not resolve hostname/.test(stderr)) return `the hub machine ${ssh.host} could not be resolved; check the host name${tail}`;
|
|
529
|
+
if (/Host key verification failed/.test(stderr)) return `the hub machine's host key is not in this machine's known_hosts; connect once from a terminal to accept it${tail}`;
|
|
530
|
+
return `the tunnel to ${ssh.host} stopped${said === "" ? "" : `: ${said}`}`;
|
|
531
|
+
}
|
|
532
|
+
/** The default spawn: a real ssh, with stderr piped because that is where the reason lives. */
|
|
533
|
+
const realSpawn = (argv) => spawn(argv[0], argv.slice(1), {
|
|
534
|
+
stdio: [
|
|
535
|
+
"ignore",
|
|
536
|
+
"ignore",
|
|
537
|
+
"pipe"
|
|
538
|
+
],
|
|
539
|
+
windowsHide: true
|
|
540
|
+
});
|
|
541
|
+
/** The default probe: can something be connected to on this local port right now? */
|
|
542
|
+
const realProbe = (port) => new Promise((resolve) => {
|
|
543
|
+
const socket = connect({
|
|
544
|
+
host: "127.0.0.1",
|
|
545
|
+
port
|
|
546
|
+
});
|
|
547
|
+
const done = (result) => {
|
|
548
|
+
socket.destroy();
|
|
549
|
+
resolve(result);
|
|
550
|
+
};
|
|
551
|
+
socket.setTimeout(2e3, () => done(false));
|
|
552
|
+
socket.once("connect", () => done(true));
|
|
553
|
+
socket.once("error", () => done(false));
|
|
554
|
+
});
|
|
555
|
+
/**
|
|
556
|
+
* Keeps one forwarding process alive, reporting what it is doing.
|
|
557
|
+
*
|
|
558
|
+
* A retry is always a *new* process: an ssh that exited cannot be revived, and reusing the object would report a
|
|
559
|
+
* state no process is in. `stop()` is idempotent and leaves nothing behind, including a pending retry timer.
|
|
560
|
+
*/
|
|
561
|
+
var Tunnel = class {
|
|
562
|
+
input;
|
|
563
|
+
current = "idle";
|
|
564
|
+
lastFailure;
|
|
565
|
+
child;
|
|
566
|
+
timer;
|
|
567
|
+
/** The pending escalation from SIGTERM to SIGKILL after `stop()`. */
|
|
568
|
+
killTimer;
|
|
569
|
+
/** Whether the local port was free when the current attempt spawned; only then can an answer be our own forward. */
|
|
570
|
+
portWasFree = false;
|
|
571
|
+
/**
|
|
572
|
+
* The child that has actually exited, if any.
|
|
573
|
+
*
|
|
574
|
+
* `ChildProcess.killed` means "a signal was sent", not "the process is gone", so it cannot answer "is it still
|
|
575
|
+
* running" — and using it that way makes the SIGKILL escalation unreachable. The exit event is the only truth.
|
|
576
|
+
*/
|
|
577
|
+
exitedChild;
|
|
578
|
+
/** A generation counter, so a late callback from a superseded attempt cannot overwrite the current state. */
|
|
579
|
+
generation = 0;
|
|
580
|
+
retryMs;
|
|
581
|
+
maxRetryMs;
|
|
582
|
+
spawn;
|
|
583
|
+
probe;
|
|
584
|
+
constructor(input) {
|
|
585
|
+
this.input = input;
|
|
586
|
+
this.spawn = input.spawn ?? realSpawn;
|
|
587
|
+
this.probe = input.probe ?? realProbe;
|
|
588
|
+
this.retryMs = input.retryMs ?? 1e3;
|
|
589
|
+
this.maxRetryMs = input.maxRetryMs ?? 6e4;
|
|
590
|
+
}
|
|
591
|
+
state() {
|
|
592
|
+
return this.current;
|
|
593
|
+
}
|
|
594
|
+
/** Why the last attempt failed, already rewritten into something a person can act on. */
|
|
595
|
+
failure() {
|
|
596
|
+
return this.lastFailure;
|
|
597
|
+
}
|
|
598
|
+
/** Start the first attempt, or do nothing if one is already running. */
|
|
599
|
+
start() {
|
|
600
|
+
if (this.current === "connecting" || this.current === "connected") return;
|
|
601
|
+
this.attempt();
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* End the process and any pending retry; safe to call more than once.
|
|
605
|
+
*
|
|
606
|
+
* SIGTERM first, then SIGKILL: an ssh that ignores or is slow to handle the polite signal would otherwise outlive
|
|
607
|
+
* the plugin, keep `localPort` bound, and make the next activation's first attempt fail on a port it still owns.
|
|
608
|
+
*/
|
|
609
|
+
stop() {
|
|
610
|
+
this.generation++;
|
|
611
|
+
if (this.timer !== void 0) {
|
|
612
|
+
clearTimeout(this.timer);
|
|
613
|
+
this.timer = void 0;
|
|
614
|
+
}
|
|
615
|
+
if (this.killTimer !== void 0) {
|
|
616
|
+
clearTimeout(this.killTimer);
|
|
617
|
+
this.killTimer = void 0;
|
|
618
|
+
}
|
|
619
|
+
const child = this.child;
|
|
620
|
+
this.child = void 0;
|
|
621
|
+
this.set("idle");
|
|
622
|
+
if (child === void 0 || this.exitedChild === child) return;
|
|
623
|
+
child.kill("SIGTERM");
|
|
624
|
+
this.killTimer = setTimeout(() => {
|
|
625
|
+
if (this.exitedChild !== child) child.kill("SIGKILL");
|
|
626
|
+
}, STOP_GRACE_MS);
|
|
627
|
+
this.killTimer.unref?.();
|
|
628
|
+
}
|
|
629
|
+
/** One attempt: check the port is free, spawn, wait for the local port, then either stay connected or fail. */
|
|
630
|
+
async attempt() {
|
|
631
|
+
const generation = ++this.generation;
|
|
632
|
+
this.set("connecting");
|
|
633
|
+
const startedFree = !await this.probe(this.input.ssh.localPort);
|
|
634
|
+
if (generation !== this.generation) return;
|
|
635
|
+
this.portWasFree = startedFree;
|
|
636
|
+
const argv = tunnelArgs(this.input.ssh);
|
|
637
|
+
let stderr = "";
|
|
638
|
+
let settled = false;
|
|
639
|
+
let child;
|
|
640
|
+
try {
|
|
641
|
+
child = this.spawn(argv);
|
|
642
|
+
} catch (error) {
|
|
643
|
+
this.fail(generation, `could not start ssh: ${String(error)}`);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
this.child = child;
|
|
647
|
+
const collect = (chunk) => {
|
|
648
|
+
stderr = (stderr + chunk.toString("utf8")).slice(-2048);
|
|
649
|
+
};
|
|
650
|
+
child.stderr?.on("data", collect);
|
|
651
|
+
child.on("error", (error) => {
|
|
652
|
+
if (!settled) {
|
|
653
|
+
settled = true;
|
|
654
|
+
this.fail(generation, `could not start ssh: ${error.message}`);
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
child.on("exit", (code) => {
|
|
658
|
+
this.exitedChild = child;
|
|
659
|
+
if (settled || generation !== this.generation) return;
|
|
660
|
+
settled = true;
|
|
661
|
+
this.fail(generation, code === null || code === 0 ? `the tunnel to ${this.input.ssh.host} closed` : explain(this.input.ssh, stderr));
|
|
662
|
+
});
|
|
663
|
+
this.waitReady(generation, startedFree, () => settled, () => {
|
|
664
|
+
settled = true;
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Poll the local port until it answers or the attempt is superseded; a first answer means the forward works.
|
|
669
|
+
*
|
|
670
|
+
* Reaching `connected` deliberately does NOT mark the attempt settled. The process is still running, and it is
|
|
671
|
+
* exactly the exit that follows a working tunnel — a dropped link, a restarted hub — that has to reach `fail` and
|
|
672
|
+
* schedule a retry. Marking it settled here would leave a device that connects once and then silently stops
|
|
673
|
+
* taking work, which is the hardest failure to notice.
|
|
674
|
+
*/
|
|
675
|
+
async waitReady(generation, startedFree, settled, markSettled) {
|
|
676
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
677
|
+
while (Date.now() < deadline) {
|
|
678
|
+
if (generation !== this.generation || settled()) return;
|
|
679
|
+
if (await this.probe(this.input.ssh.localPort)) {
|
|
680
|
+
if (generation !== this.generation || settled()) return;
|
|
681
|
+
if (!startedFree) {
|
|
682
|
+
markSettled();
|
|
683
|
+
this.fail(generation, `local port ${this.input.ssh.localPort} was already in use before this attempt, so the device cannot tell its own tunnel from whatever owns that port; close it or set a different localPort`);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
this.retryMs = this.input.retryMs ?? 1e3;
|
|
687
|
+
this.lastFailure = void 0;
|
|
688
|
+
this.set("connected");
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
await new Promise((resolve) => setTimeout(resolve, READY_POLL_MS));
|
|
692
|
+
}
|
|
693
|
+
if (generation !== this.generation || settled()) return;
|
|
694
|
+
markSettled();
|
|
695
|
+
this.fail(generation, `the tunnel to ${this.input.ssh.host} did not start listening on port ${this.input.ssh.localPort} within ${READY_TIMEOUT_MS / 1e3} seconds`);
|
|
696
|
+
}
|
|
697
|
+
/** Record a failure, release the process, and schedule the next attempt with a growing delay. */
|
|
698
|
+
fail(generation, message) {
|
|
699
|
+
if (generation !== this.generation) return;
|
|
700
|
+
this.lastFailure = message;
|
|
701
|
+
const child = this.child;
|
|
702
|
+
this.child = void 0;
|
|
703
|
+
if (child !== void 0 && !child.killed) child.kill("SIGKILL");
|
|
704
|
+
this.set("failed", message);
|
|
705
|
+
this.input.log?.(`dsh-foreman-node tunnel: ${message}; retrying in ${Math.round(this.retryMs / 1e3)}s`);
|
|
706
|
+
const delay = this.retryMs;
|
|
707
|
+
this.retryMs = Math.min(this.retryMs * 2, this.maxRetryMs);
|
|
708
|
+
this.timer = setTimeout(() => {
|
|
709
|
+
this.timer = void 0;
|
|
710
|
+
if (generation !== this.generation) return;
|
|
711
|
+
this.attempt();
|
|
712
|
+
}, delay);
|
|
713
|
+
this.timer.unref?.();
|
|
714
|
+
}
|
|
715
|
+
set(state, failure) {
|
|
716
|
+
this.current = state;
|
|
717
|
+
this.input.onState?.(state, failure);
|
|
718
|
+
}
|
|
719
|
+
};
|
|
190
720
|
// src/report-tool.ts
|
|
191
721
|
/**
|
|
192
722
|
* The `foreman_report` tool a device registers on a job Session.
|
|
@@ -347,6 +877,8 @@ var JobSessions = class {
|
|
|
347
877
|
jobs = /* @__PURE__ */ new Map();
|
|
348
878
|
/** Assignments whose Session already carries the report tool, so a redelivered assign registers it only once. */
|
|
349
879
|
reported = /* @__PURE__ */ new Set();
|
|
880
|
+
/** Assignments that have actually reported; a turn ending without one is closed out by `watchTurnEnd`. */
|
|
881
|
+
reports = /* @__PURE__ */ new Set();
|
|
350
882
|
constructor(ctx) {
|
|
351
883
|
this.ctx = ctx;
|
|
352
884
|
}
|
|
@@ -452,7 +984,12 @@ var JobSessions = class {
|
|
|
452
984
|
/** Attach the `foreman_report` tool to a job Session, disposed with it. */
|
|
453
985
|
reportTools(agent, assignmentId, onReport) {
|
|
454
986
|
const registrar = agent.ctx.tools;
|
|
455
|
-
const
|
|
987
|
+
const reported = this.reports;
|
|
988
|
+
const report = (id, deviceReport) => {
|
|
989
|
+
reported.add(id);
|
|
990
|
+
onReport(id, deviceReport);
|
|
991
|
+
};
|
|
992
|
+
const tool = foremanReportTool({ report }, assignmentId);
|
|
456
993
|
registrar.register({
|
|
457
994
|
name: tool.name,
|
|
458
995
|
description: tool.description,
|
|
@@ -469,32 +1006,101 @@ var JobSessions = class {
|
|
|
469
1006
|
},
|
|
470
1007
|
execute: (args, execution) => tool.execute(args, execution)
|
|
471
1008
|
});
|
|
1009
|
+
this.watchTurnEnd(agent, assignmentId, report);
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Report for a job that ended without calling the tool.
|
|
1013
|
+
*
|
|
1014
|
+
* Telling the model to report is not a guarantee: on a real deployment a device did the work, wrote a complete
|
|
1015
|
+
* answer, and never called `foreman_report`, so the hub waited at `running` forever. The tool alone leaves that
|
|
1016
|
+
* failure open, so a turn that ends with no report is closed out here — `ready`, with the last thing the model said
|
|
1017
|
+
* as the summary. A person still gets the result; the ledger still learns the job ended.
|
|
1018
|
+
*/
|
|
1019
|
+
watchTurnEnd(agent, assignmentId, report) {
|
|
1020
|
+
if (typeof agent.ctx.effect !== "function") return;
|
|
1021
|
+
agent.ctx.effect(() => {
|
|
1022
|
+
agent.ctx.on("session/event", (session, event) => {
|
|
1023
|
+
if (session.id !== agent.session.id || event.type !== "turn/end") return;
|
|
1024
|
+
if (this.reports.has(assignmentId)) return;
|
|
1025
|
+
this.lastText(agent).then((text) => {
|
|
1026
|
+
if (this.reports.has(assignmentId)) return;
|
|
1027
|
+
report(assignmentId, {
|
|
1028
|
+
state: "ready",
|
|
1029
|
+
summary: text === "" ? "the job finished without a report" : text.slice(0, 600)
|
|
1030
|
+
});
|
|
1031
|
+
}, () => {
|
|
1032
|
+
if (this.reports.has(assignmentId)) return;
|
|
1033
|
+
report(assignmentId, {
|
|
1034
|
+
state: "ready",
|
|
1035
|
+
summary: "the job finished without a report"
|
|
1036
|
+
});
|
|
1037
|
+
});
|
|
1038
|
+
});
|
|
1039
|
+
return () => {};
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
/** The last text the assistant said in a Session, read from its stored history. */
|
|
1043
|
+
async lastText(agent) {
|
|
1044
|
+
try {
|
|
1045
|
+
var _usingCtx3 = _usingCtx();
|
|
1046
|
+
await this.ctx.sessionPersistence.flush();
|
|
1047
|
+
const handle = _usingCtx3.a(await this.ctx.sessionPersistence.open(agent.id, "read"));
|
|
1048
|
+
let found = "";
|
|
1049
|
+
for (let offset = 0;;) {
|
|
1050
|
+
const { events } = await handle.read(offset, 512);
|
|
1051
|
+
if (events.length === 0) break;
|
|
1052
|
+
for (const event of events) {
|
|
1053
|
+
if (event.type !== "assistant/message") continue;
|
|
1054
|
+
const text = (event.data?.message?.content ?? []).filter((part) => part?.type === "text").map((part) => part.text ?? "").join(" ").trim();
|
|
1055
|
+
if (text !== "") found = text;
|
|
1056
|
+
}
|
|
1057
|
+
offset += events.length;
|
|
1058
|
+
}
|
|
1059
|
+
return found;
|
|
1060
|
+
} catch (_) {
|
|
1061
|
+
_usingCtx3.e = _;
|
|
1062
|
+
} finally {
|
|
1063
|
+
await _usingCtx3.d();
|
|
1064
|
+
}
|
|
472
1065
|
}
|
|
473
1066
|
};
|
|
474
1067
|
// src/index.ts
|
|
475
1068
|
/** Version reported in the hello, so the hub can tell which device build is talking. */
|
|
476
|
-
const VERSION = "0.
|
|
1069
|
+
const VERSION = "0.2.0";
|
|
477
1070
|
/**
|
|
478
|
-
*
|
|
479
|
-
*
|
|
480
|
-
* Everything is optional on purpose. A bundle patch inserts this row the moment the package is added to a profile,
|
|
481
|
-
* so `apply` runs before anyone has filled the values in — and a schema that demanded them would turn the whole
|
|
482
|
-
* profile red with "invalid config" on a fresh install. `apply` treats a blank address as "not configured yet":
|
|
483
|
-
* it says what is missing and stops, instead of dialling nowhere or refusing to activate.
|
|
1071
|
+
* The schema, asserted rather than annotated.
|
|
484
1072
|
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
1073
|
+
* An annotation makes the declaration portable but cannot express what a volatile field does to a field's type, so the
|
|
1074
|
+
* mismatch moves into the annotation. The assertion keeps the published `.d.ts` free of `schemastery` internals and
|
|
1075
|
+
* puts the shape in one place; `configured()` is what re-establishes the real types at every read.
|
|
487
1076
|
*/
|
|
488
|
-
const
|
|
489
|
-
url: s.string().default("").description("The hub's address, such as wss://foreman.example.com. The device dials out to it."),
|
|
490
|
-
deviceId: s.string().default("").description("This device's id, as registered on the hub with `Devices.add`."),
|
|
491
|
-
token: s.string().default("").role("secret").description("This device's token; it is sent in the hello frame and nowhere else."),
|
|
1077
|
+
const Typed = s.object({
|
|
1078
|
+
url: s.string().default("").volatile().description("The hub's address, such as wss://foreman.example.com. The device dials out to it."),
|
|
1079
|
+
deviceId: s.string().default("").volatile().description("This device's id, as registered on the hub with `Devices.add`."),
|
|
1080
|
+
token: s.string().default("").role("secret").volatile().description("This device's token; it is sent in the hello frame and nowhere else."),
|
|
492
1081
|
roots: s.array(s.object({
|
|
493
1082
|
project: s.string().required(),
|
|
494
1083
|
path: s.string().required()
|
|
495
|
-
})).default([]).description("Projects this device can work on: a logical name and this machine's own absolute path, kept verbatim."),
|
|
496
|
-
heartbeatMs: s.natural().default(15e3).description("How often to tell the hub this device is alive, in milliseconds."),
|
|
497
|
-
reconnectMs: s.natural().default(1e3).description("Delay before the first reconnect attempt, in milliseconds.")
|
|
1084
|
+
})).default([]).volatile().description("Projects this device can work on: a logical name and this machine's own absolute path, kept verbatim."),
|
|
1085
|
+
heartbeatMs: s.natural().default(15e3).volatile().description("How often to tell the hub this device is alive, in milliseconds."),
|
|
1086
|
+
reconnectMs: s.natural().default(1e3).volatile().description("Delay before the first reconnect attempt, in milliseconds."),
|
|
1087
|
+
ssh: s.object({
|
|
1088
|
+
enabled: s.boolean().default(false).volatile().description("Reach the hub through an SSH tunnel this device starts itself."),
|
|
1089
|
+
host: s.string().default("").volatile().description("The hub machine, such as 203.0.113.10 or its ~/.ssh/config alias."),
|
|
1090
|
+
port: s.natural().min(1).max(65535).default(22).volatile().description("The hub machine's SSH port."),
|
|
1091
|
+
user: s.string().default("").volatile().description("The SSH user on the hub machine."),
|
|
1092
|
+
keyPath: s.string().default("").volatile().description("The private key this device authenticates with; the page lists these from ~/.ssh."),
|
|
1093
|
+
remotePort: s.natural().min(1).max(65535).default(8790).volatile().description("The port the hub listens on, on the hub machine."),
|
|
1094
|
+
localPort: s.natural().min(1).max(65535).default(8790).volatile().description("The local port the tunnel listens on; the device dials this.")
|
|
1095
|
+
}).default({
|
|
1096
|
+
enabled: false,
|
|
1097
|
+
host: "",
|
|
1098
|
+
port: 22,
|
|
1099
|
+
user: "",
|
|
1100
|
+
keyPath: "",
|
|
1101
|
+
remotePort: 8790,
|
|
1102
|
+
localPort: 8790
|
|
1103
|
+
}).description("How to reach the hub when it is not directly reachable: a tunnel this device opens and keeps open.")
|
|
498
1104
|
});
|
|
499
1105
|
/**
|
|
500
1106
|
* What this configuration still needs before the device can dial its hub, or undefined when it is complete.
|
|
@@ -502,9 +1108,23 @@ const Config = s.object({
|
|
|
502
1108
|
* A missing value is reported by name so an operator can fill it in without reading the source; a value that is only
|
|
503
1109
|
* whitespace counts as missing, because a device that dials a blank address retries forever and looks merely offline.
|
|
504
1110
|
*/
|
|
1111
|
+
/**
|
|
1112
|
+
* The address this device dials.
|
|
1113
|
+
*
|
|
1114
|
+
* With the tunnel on, that is the local end of it: `url` names the hub at its own address, which the tunnel exists to
|
|
1115
|
+
* make reachable, so dialling `url` would bypass the tunnel entirely and look like an offline device. Keeping the two
|
|
1116
|
+
* cases in one function is what stops `apply` from picking the wrong one.
|
|
1117
|
+
*/
|
|
1118
|
+
function hubUrlOf(config) {
|
|
1119
|
+
return config.ssh.enabled ? `ws://127.0.0.1:${config.ssh.localPort}` : config.url;
|
|
1120
|
+
}
|
|
505
1121
|
function missingConfiguration(config) {
|
|
506
1122
|
const missing = [];
|
|
507
|
-
if (config.
|
|
1123
|
+
if (config.ssh.enabled) {
|
|
1124
|
+
if (config.ssh.host.trim() === "") missing.push("ssh.host");
|
|
1125
|
+
if (config.ssh.user.trim() === "") missing.push("ssh.user");
|
|
1126
|
+
if (config.ssh.keyPath.trim() === "") missing.push("ssh.keyPath");
|
|
1127
|
+
} else if (config.url.trim() === "") missing.push("url");
|
|
508
1128
|
if (config.deviceId.trim() === "") missing.push("deviceId");
|
|
509
1129
|
if (config.token.trim() === "") missing.push("token");
|
|
510
1130
|
if (missing.length === 0) return void 0;
|
|
@@ -530,7 +1150,12 @@ const inject = [
|
|
|
530
1150
|
* The plugin returns once the connection is attempted, not once it succeeds: a device must boot and stay usable
|
|
531
1151
|
* while its hub is unreachable, and the client keeps retrying in the background.
|
|
532
1152
|
*/
|
|
533
|
-
async function apply(ctx,
|
|
1153
|
+
async function apply(ctx, raw) {
|
|
1154
|
+
const config = configured(raw);
|
|
1155
|
+
ctx.inject(["webServer"], (child) => {
|
|
1156
|
+
const host = child.webServer;
|
|
1157
|
+
child.effect(() => registerNodeRoutes(host, sshDirectory()));
|
|
1158
|
+
});
|
|
534
1159
|
const missing = missingConfiguration(config);
|
|
535
1160
|
if (missing !== void 0) {
|
|
536
1161
|
ctx.logger.info("%s", missing);
|
|
@@ -538,7 +1163,7 @@ async function apply(ctx, config) {
|
|
|
538
1163
|
}
|
|
539
1164
|
const jobs = new JobSessions(ctx);
|
|
540
1165
|
const client = new NodeClient({
|
|
541
|
-
url: config
|
|
1166
|
+
url: hubUrlOf(config),
|
|
542
1167
|
deviceId: config.deviceId,
|
|
543
1168
|
token: config.token,
|
|
544
1169
|
version: VERSION,
|
|
@@ -554,11 +1179,25 @@ async function apply(ctx, config) {
|
|
|
554
1179
|
receive(ctx, jobs, client, frame);
|
|
555
1180
|
}
|
|
556
1181
|
});
|
|
557
|
-
|
|
1182
|
+
const tunnel = config.ssh.enabled ? new Tunnel({
|
|
1183
|
+
ssh: config.ssh,
|
|
1184
|
+
log: (text) => ctx.logger.warn("%s", text),
|
|
1185
|
+
onState: (state, failure) => {
|
|
1186
|
+
ctx.logger.info("dsh-foreman-node tunnel: %s", failure === void 0 ? state : `${state} (${failure})`);
|
|
1187
|
+
if (state === "connected") client.start();
|
|
1188
|
+
}
|
|
1189
|
+
}) : void 0;
|
|
1190
|
+
if (tunnel === void 0) client.start();
|
|
1191
|
+
else tunnel.start();
|
|
558
1192
|
ctx.effect(() => () => {
|
|
1193
|
+
tunnel?.stop();
|
|
559
1194
|
client.stop();
|
|
560
1195
|
});
|
|
561
|
-
ctx.logger.info("dsh-foreman-node %s is dialling %s as %s", VERSION, config
|
|
1196
|
+
ctx.logger.info("dsh-foreman-node %s is dialling %s as %s", VERSION, hubUrlOf(config), config.deviceId);
|
|
1197
|
+
}
|
|
1198
|
+
/** The directory the key list reads; `~/.ssh` on a machine where the current user is the one configuring it. */
|
|
1199
|
+
function sshDirectory() {
|
|
1200
|
+
return join(homedir(), ".ssh");
|
|
562
1201
|
}
|
|
563
1202
|
/**
|
|
564
1203
|
* Handle one frame from the hub.
|
|
@@ -598,4 +1237,4 @@ async function receive(ctx, jobs, client, frame) {
|
|
|
598
1237
|
});
|
|
599
1238
|
}
|
|
600
1239
|
}
|
|
601
|
-
export { Config, JobSessions, NodeClient, REPORT_STATES, VERSION, apply, foremanReportTool, inject, missingConfiguration, name };
|
|
1240
|
+
export { Typed as Config, JobSessions, NodeClient, REPORT_STATES, SSH_DEFAULTS, Tunnel, VERSION, apply, configured, foremanReportTool, hubUrlOf, inject, listKeys, missingConfiguration, name, registerNodeRoutes, sshDirectory, testArgs, tunnelArgs };
|