dsh-foreman-node 0.1.1 → 0.2.0

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/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.
@@ -473,28 +1003,41 @@ var JobSessions = class {
473
1003
  };
474
1004
  // src/index.ts
475
1005
  /** Version reported in the hello, so the hub can tell which device build is talking. */
476
- const VERSION = "0.1.0";
1006
+ const VERSION = "0.2.0";
477
1007
  /**
478
- * Configuration of one device, from the `dsh-foreman-node` entry in the profile's patch.
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.
1008
+ * The schema, asserted rather than annotated.
484
1009
  *
485
- * The annotation names the schema type through the global `Schemastery` namespace rather than letting it be inferred:
486
- * an inferred type here names a `cosmokit` symbol the emitted declaration cannot reference portably.
1010
+ * An annotation makes the declaration portable but cannot express what a volatile field does to a field's type, so the
1011
+ * mismatch moves into the annotation. The assertion keeps the published `.d.ts` free of `schemastery` internals and
1012
+ * puts the shape in one place; `configured()` is what re-establishes the real types at every read.
487
1013
  */
488
- const Config = s.object({
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."),
1014
+ const Typed = s.object({
1015
+ url: s.string().default("").volatile().description("The hub's address, such as wss://foreman.example.com. The device dials out to it."),
1016
+ deviceId: s.string().default("").volatile().description("This device's id, as registered on the hub with `Devices.add`."),
1017
+ token: s.string().default("").role("secret").volatile().description("This device's token; it is sent in the hello frame and nowhere else."),
492
1018
  roots: s.array(s.object({
493
1019
  project: s.string().required(),
494
1020
  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.")
1021
+ })).default([]).volatile().description("Projects this device can work on: a logical name and this machine's own absolute path, kept verbatim."),
1022
+ heartbeatMs: s.natural().default(15e3).volatile().description("How often to tell the hub this device is alive, in milliseconds."),
1023
+ reconnectMs: s.natural().default(1e3).volatile().description("Delay before the first reconnect attempt, in milliseconds."),
1024
+ ssh: s.object({
1025
+ enabled: s.boolean().default(false).volatile().description("Reach the hub through an SSH tunnel this device starts itself."),
1026
+ host: s.string().default("").volatile().description("The hub machine, such as 203.0.113.10 or its ~/.ssh/config alias."),
1027
+ port: s.natural().min(1).max(65535).default(22).volatile().description("The hub machine's SSH port."),
1028
+ user: s.string().default("").volatile().description("The SSH user on the hub machine."),
1029
+ keyPath: s.string().default("").volatile().description("The private key this device authenticates with; the page lists these from ~/.ssh."),
1030
+ remotePort: s.natural().min(1).max(65535).default(8790).volatile().description("The port the hub listens on, on the hub machine."),
1031
+ localPort: s.natural().min(1).max(65535).default(8790).volatile().description("The local port the tunnel listens on; the device dials this.")
1032
+ }).default({
1033
+ enabled: false,
1034
+ host: "",
1035
+ port: 22,
1036
+ user: "",
1037
+ keyPath: "",
1038
+ remotePort: 8790,
1039
+ localPort: 8790
1040
+ }).description("How to reach the hub when it is not directly reachable: a tunnel this device opens and keeps open.")
498
1041
  });
499
1042
  /**
500
1043
  * What this configuration still needs before the device can dial its hub, or undefined when it is complete.
@@ -502,9 +1045,23 @@ const Config = s.object({
502
1045
  * A missing value is reported by name so an operator can fill it in without reading the source; a value that is only
503
1046
  * whitespace counts as missing, because a device that dials a blank address retries forever and looks merely offline.
504
1047
  */
1048
+ /**
1049
+ * The address this device dials.
1050
+ *
1051
+ * With the tunnel on, that is the local end of it: `url` names the hub at its own address, which the tunnel exists to
1052
+ * make reachable, so dialling `url` would bypass the tunnel entirely and look like an offline device. Keeping the two
1053
+ * cases in one function is what stops `apply` from picking the wrong one.
1054
+ */
1055
+ function hubUrlOf(config) {
1056
+ return config.ssh.enabled ? `ws://127.0.0.1:${config.ssh.localPort}` : config.url;
1057
+ }
505
1058
  function missingConfiguration(config) {
506
1059
  const missing = [];
507
- if (config.url.trim() === "") missing.push("url");
1060
+ if (config.ssh.enabled) {
1061
+ if (config.ssh.host.trim() === "") missing.push("ssh.host");
1062
+ if (config.ssh.user.trim() === "") missing.push("ssh.user");
1063
+ if (config.ssh.keyPath.trim() === "") missing.push("ssh.keyPath");
1064
+ } else if (config.url.trim() === "") missing.push("url");
508
1065
  if (config.deviceId.trim() === "") missing.push("deviceId");
509
1066
  if (config.token.trim() === "") missing.push("token");
510
1067
  if (missing.length === 0) return void 0;
@@ -530,7 +1087,12 @@ const inject = [
530
1087
  * The plugin returns once the connection is attempted, not once it succeeds: a device must boot and stay usable
531
1088
  * while its hub is unreachable, and the client keeps retrying in the background.
532
1089
  */
533
- async function apply(ctx, config) {
1090
+ async function apply(ctx, raw) {
1091
+ const config = configured(raw);
1092
+ ctx.inject(["webServer"], (child) => {
1093
+ const host = child.webServer;
1094
+ child.effect(() => registerNodeRoutes(host, sshDirectory()));
1095
+ });
534
1096
  const missing = missingConfiguration(config);
535
1097
  if (missing !== void 0) {
536
1098
  ctx.logger.info("%s", missing);
@@ -538,7 +1100,7 @@ async function apply(ctx, config) {
538
1100
  }
539
1101
  const jobs = new JobSessions(ctx);
540
1102
  const client = new NodeClient({
541
- url: config.url,
1103
+ url: hubUrlOf(config),
542
1104
  deviceId: config.deviceId,
543
1105
  token: config.token,
544
1106
  version: VERSION,
@@ -554,11 +1116,25 @@ async function apply(ctx, config) {
554
1116
  receive(ctx, jobs, client, frame);
555
1117
  }
556
1118
  });
557
- client.start();
1119
+ const tunnel = config.ssh.enabled ? new Tunnel({
1120
+ ssh: config.ssh,
1121
+ log: (text) => ctx.logger.warn("%s", text),
1122
+ onState: (state, failure) => {
1123
+ ctx.logger.info("dsh-foreman-node tunnel: %s", failure === void 0 ? state : `${state} (${failure})`);
1124
+ if (state === "connected") client.start();
1125
+ }
1126
+ }) : void 0;
1127
+ if (tunnel === void 0) client.start();
1128
+ else tunnel.start();
558
1129
  ctx.effect(() => () => {
1130
+ tunnel?.stop();
559
1131
  client.stop();
560
1132
  });
561
- ctx.logger.info("dsh-foreman-node %s is dialling %s as %s", VERSION, config.url, config.deviceId);
1133
+ ctx.logger.info("dsh-foreman-node %s is dialling %s as %s", VERSION, hubUrlOf(config), config.deviceId);
1134
+ }
1135
+ /** The directory the key list reads; `~/.ssh` on a machine where the current user is the one configuring it. */
1136
+ function sshDirectory() {
1137
+ return join(homedir(), ".ssh");
562
1138
  }
563
1139
  /**
564
1140
  * Handle one frame from the hub.
@@ -598,4 +1174,4 @@ async function receive(ctx, jobs, client, frame) {
598
1174
  });
599
1175
  }
600
1176
  }
601
- export { Config, JobSessions, NodeClient, REPORT_STATES, VERSION, apply, foremanReportTool, inject, missingConfiguration, name };
1177
+ export { Typed as Config, JobSessions, NodeClient, REPORT_STATES, SSH_DEFAULTS, Tunnel, VERSION, apply, configured, foremanReportTool, hubUrlOf, inject, listKeys, missingConfiguration, name, registerNodeRoutes, sshDirectory, testArgs, tunnelArgs };