@takodotid/circuit 0.26.2 → 0.27.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/dist/cli/index.js CHANGED
@@ -110,6 +110,11 @@ var MARKER = /<secret:([A-Za-z0-9_./:-]+)>/g;
110
110
  var substitute = (text) => text.replace(MARKER, (_, reference) => valueOf(reference));
111
111
  var valueOfReference = valueOf;
112
112
 
113
+ // src/transport/console.ts
114
+ import { spawn } from "node:child_process";
115
+ import { Duplex } from "node:stream";
116
+ import { Client as Client2 } from "ssh2";
117
+
113
118
  // src/transport/ssh.ts
114
119
  import { Client } from "ssh2";
115
120
  function connect(login) {
@@ -202,6 +207,107 @@ function upload(client, path, content) {
202
207
  });
203
208
  }
204
209
 
210
+ // src/transport/console.ts
211
+ var RELAY = `import fcntl, os, select, sys, termios
212
+
213
+ port, baud = sys.argv[1], int(sys.argv[2])
214
+ fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
215
+
216
+ try:
217
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
218
+ except OSError:
219
+ sys.stderr.write("busy: another session holds " + port + "\\n")
220
+ sys.exit(3)
221
+
222
+ attrs = termios.tcgetattr(fd)
223
+ attrs[0] = attrs[1] = attrs[3] = 0
224
+ attrs[2] = termios.CS8 | termios.CREAD | termios.CLOCAL
225
+ attrs[4] = attrs[5] = getattr(termios, "B%d" % baud)
226
+ termios.tcsetattr(fd, termios.TCSANOW, attrs)
227
+ termios.tcflush(fd, termios.TCIOFLUSH)
228
+
229
+ sys.stderr.write("ready\\n")
230
+ sys.stderr.flush()
231
+
232
+ while True:
233
+ ready, _, _ = select.select([0, fd], [], [])
234
+
235
+ if 0 in ready:
236
+ data = os.read(0, 4096)
237
+ if not data:
238
+ break
239
+ while data:
240
+ try:
241
+ data = data[os.write(fd, data):]
242
+ except BlockingIOError:
243
+ select.select([], [fd], [])
244
+
245
+ if fd in ready:
246
+ try:
247
+ data = os.read(fd, 4096)
248
+ except BlockingIOError:
249
+ continue
250
+ if data:
251
+ os.write(1, data)
252
+ `;
253
+ var RELAY_PATH = "/tmp/circuit-console.py";
254
+ var PORT = /^\/dev\/[\w./:+-]+$/;
255
+ function connectJump(jump) {
256
+ return new Promise((done, fail) => {
257
+ const client = new Client2;
258
+ const login = { username: jump.user, password: resolve(jump.password), readyTimeout: 60000 };
259
+ client.on("ready", () => done(client)).on("error", fail);
260
+ if (!jump.cloudflared) {
261
+ client.connect({ ...login, host: jump.host, port: jump.port ?? 22 });
262
+ return;
263
+ }
264
+ const tunnel = spawn("cloudflared", ["access", "ssh", "--hostname", jump.host], { stdio: ["pipe", "pipe", "ignore"] });
265
+ tunnel.on("error", (error) => fail(new Error(`cloudflared did not start: ${error.message}`)));
266
+ client.on("close", () => tunnel.kill());
267
+ client.connect({ ...login, sock: Duplex.from({ readable: tunnel.stdout, writable: tunnel.stdin }) });
268
+ });
269
+ }
270
+ async function openConsole(console2) {
271
+ if (!PORT.test(console2.port))
272
+ throw new Error(`console port ${console2.port} is not a path under /dev`);
273
+ if (!Number.isInteger(console2.baud) || console2.baud <= 0)
274
+ throw new Error(`console baud ${console2.baud} is not a line speed`);
275
+ const client = await connectJump(console2.jump);
276
+ try {
277
+ const encoded = Buffer.from(RELAY).toString("base64");
278
+ await exec(client, `sh -c 'printf %s ${encoded} | base64 -d > ${RELAY_PATH}'`, 30000);
279
+ const channel = await new Promise((done, fail) => {
280
+ client.exec(`python3 ${RELAY_PATH} ${console2.port} ${console2.baud}`, (error, stream) => error ? fail(error) : done(stream));
281
+ });
282
+ await new Promise((done, fail) => {
283
+ let said = "";
284
+ const limit = setTimeout(() => fail(new Error(`the console relay did not start: ${said.trim()}`)), 30000);
285
+ channel.stderr.on("data", (chunk) => {
286
+ said += chunk.toString("utf8");
287
+ if (said.includes(`ready
288
+ `)) {
289
+ clearTimeout(limit);
290
+ done();
291
+ }
292
+ });
293
+ channel.on("close", () => {
294
+ clearTimeout(limit);
295
+ fail(new Error(`the console relay stopped: ${said.trim() || "no reason given"}`));
296
+ });
297
+ });
298
+ return {
299
+ channel,
300
+ close: () => {
301
+ channel.end();
302
+ client.end();
303
+ }
304
+ };
305
+ } catch (error) {
306
+ client.end();
307
+ throw error;
308
+ }
309
+ }
310
+
205
311
  // src/adapters/devices/fortios/names.ts
206
312
  function nameOf(device, name) {
207
313
  if (device.interfaces && name in device.interfaces)
@@ -1134,7 +1240,9 @@ var TABLES = {
1134
1240
  "firewall policy6": ORDERED,
1135
1241
  "firewall local-in-policy": ORDERED,
1136
1242
  "firewall local-in-policy6": ORDERED,
1137
- "firewall central-snat-map": ORDERED
1243
+ "firewall central-snat-map": ORDERED,
1244
+ "router static": { distinct: true },
1245
+ "router static6": { distinct: true }
1138
1246
  };
1139
1247
  var IGNORED = new Set(["uuid", "snmp-index"]);
1140
1248
  var IGNORED_IN = { "system global": ["cfg-save", "cfg-revert-timeout", "alias"] };
@@ -1271,7 +1379,7 @@ function compare(path, want, have, table, top, context) {
1271
1379
  }
1272
1380
  for (const [header, child] of want.blocks)
1273
1381
  nested(header, child, have.blocks.get(header) ?? new Block);
1274
- const entries = table?.byContent ? byContent(path, want, have) : byId(path, want, have, table, context);
1382
+ const entries = table?.byContent ? byContent(path, want, have) : table?.distinct ? distinctById(path, want, have, table, context) : byId(path, want, have, table, context);
1275
1383
  send.push(...entries.send);
1276
1384
  show.push(...entries.show);
1277
1385
  if (top) {
@@ -1320,6 +1428,30 @@ function byId(path, want, have, table, context) {
1320
1428
  }
1321
1429
  return { send, show, removals, removed };
1322
1430
  }
1431
+ function distinctById(path, want, have, table, context) {
1432
+ const deletes = [];
1433
+ const creates = [];
1434
+ const show = [];
1435
+ for (const [key, entry] of want.entries) {
1436
+ const old = have.entries.get(key);
1437
+ const where = [...path, bare(entry.id)];
1438
+ if (old) {
1439
+ if (!compare(where, entry.block, old.block, undefined, false, context).send.length)
1440
+ continue;
1441
+ deletes.push(`delete ${old.id}`);
1442
+ show.push(...shown("-", where, old.block));
1443
+ }
1444
+ creates.push(...creation(entry, table));
1445
+ show.push(...shown("+", where, entry.block));
1446
+ }
1447
+ for (const [key, entry] of have.entries) {
1448
+ if (want.entries.has(key) || isFactory(table, entry))
1449
+ continue;
1450
+ deletes.push(`delete ${entry.id}`);
1451
+ show.push(...shown("-", [...path, bare(entry.id)], entry.block));
1452
+ }
1453
+ return { send: [...deletes, ...creates], show, removals: [], removed: [] };
1454
+ }
1323
1455
  function moves(want, have) {
1324
1456
  const wanted = [...want.entries.keys()];
1325
1457
  const order = [...have.entries.keys()].filter((key) => want.entries.has(key));
@@ -2356,7 +2488,93 @@ async function login(device) {
2356
2488
  password: resolve(user.password)
2357
2489
  });
2358
2490
  }
2359
- async function session(device, work) {
2491
+ var CONSOLE_STATES = [
2492
+ ["login", /login: $/],
2493
+ ["password", /Password: $/],
2494
+ ["banner", BANNER2],
2495
+ ["pager", /--More-- $/],
2496
+ ["context", /\([^)\n]*\) [#$] $/],
2497
+ ["prompt", /[#$] $/]
2498
+ ];
2499
+ function prompted(channel, write, timeout = 30000) {
2500
+ return new Promise((done, fail) => {
2501
+ let text = "";
2502
+ const stop = () => {
2503
+ clearTimeout(limit);
2504
+ channel.removeListener("data", onData);
2505
+ };
2506
+ const onData = (chunk) => {
2507
+ text += chunk.toString("utf8").replace(/\r/g, "");
2508
+ const state = CONSOLE_STATES.find(([, pattern]) => pattern.test(text));
2509
+ if (!state)
2510
+ return;
2511
+ stop();
2512
+ done(state[0]);
2513
+ };
2514
+ const limit = setTimeout(() => {
2515
+ stop();
2516
+ fail(new Error(`the console did not answer: ${text.slice(-200)}`));
2517
+ }, timeout);
2518
+ channel.on("data", onData);
2519
+ channel.write(write);
2520
+ });
2521
+ }
2522
+ async function consoleLogin(channel, user, password) {
2523
+ const toward = {
2524
+ password: "\r",
2525
+ banner: "a",
2526
+ pager: "q",
2527
+ context: "end\r",
2528
+ prompt: "exit\r"
2529
+ };
2530
+ let state = await prompted(channel, "\r");
2531
+ for (let step = 0;state !== "login"; step++) {
2532
+ if (step >= 10)
2533
+ throw new Error(`the console never reached a login prompt; it is at ${state}`);
2534
+ state = await prompted(channel, toward[state]);
2535
+ }
2536
+ if (await prompted(channel, `${user}\r`) !== "password")
2537
+ throw new Error("the console did not ask for a password");
2538
+ const answer = await prompted(channel, `${password}\r`, 60000);
2539
+ if (answer !== "prompt")
2540
+ throw new Error(`the console refused the login of ${user}`);
2541
+ return new Cli(channel, "\r");
2542
+ }
2543
+ async function consoleLogout(cli) {
2544
+ await leave(cli).catch(() => {
2545
+ return;
2546
+ });
2547
+ await cli.run("exit", 1e4).catch(() => {
2548
+ return;
2549
+ });
2550
+ }
2551
+ function consoleOf(device) {
2552
+ const console2 = device.connection.console;
2553
+ if (!console2)
2554
+ throw new Error(`${device.name}: no connection.console`);
2555
+ const user = device.users?.[device.connection.user];
2556
+ if (!user)
2557
+ throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
2558
+ return { console: console2, user: device.connection.user, password: resolve(user.password) };
2559
+ }
2560
+ async function consoleSession(device, work) {
2561
+ const { console: console2, user, password } = consoleOf(device);
2562
+ const opened = await openConsole(console2);
2563
+ try {
2564
+ const cli = await consoleLogin(opened.channel, user, password);
2565
+ const relogin = async () => {
2566
+ await consoleLogin(opened.channel, user, password);
2567
+ };
2568
+ const result = await work(cli, relogin);
2569
+ await consoleLogout(cli);
2570
+ return result;
2571
+ } finally {
2572
+ opened.close();
2573
+ }
2574
+ }
2575
+ async function session(device, work, options = {}) {
2576
+ if (options.console)
2577
+ return consoleSession(device, (cli) => work(cli));
2360
2578
  const client = await login(device);
2361
2579
  try {
2362
2580
  const channel = await shell(client, 4000);
@@ -2412,8 +2630,8 @@ async function readConfig(cli, device) {
2412
2630
  throw new Error(`${device.name} returned no configuration`);
2413
2631
  return scrub(clean(text));
2414
2632
  }
2415
- function read(device) {
2416
- return session(device, (cli) => readConfig(cli, device));
2633
+ function read(device, _network = [], options = {}) {
2634
+ return session(device, (cli) => readConfig(cli, device), options);
2417
2635
  }
2418
2636
  function withSecrets(line) {
2419
2637
  const key = line.startsWith("set private-key ");
@@ -2490,7 +2708,7 @@ async function configure(cli, plan, guard) {
2490
2708
  class Reloaded extends Error {
2491
2709
  }
2492
2710
  var RELOAD = { attempts: 20, interval: 15000 };
2493
- async function settle(device) {
2711
+ async function settle(device, options) {
2494
2712
  for (let attempt = 0;attempt < RELOAD.attempts; attempt++) {
2495
2713
  await new Promise((done) => setTimeout(done, RELOAD.interval));
2496
2714
  const settled = await session(device, async (cli) => {
@@ -2498,18 +2716,26 @@ async function settle(device) {
2498
2716
  return false;
2499
2717
  await saving(cli, "automatic");
2500
2718
  return true;
2501
- }).catch(() => false);
2719
+ }, options).catch(() => false);
2502
2720
  if (settled)
2503
2721
  return "the device is back and saves changes on its own again";
2504
2722
  }
2505
2723
  return "the device did not come back in time and still reverts every change; set `cfg-save automatic` under `config system global`";
2506
2724
  }
2507
2725
  async function apply(device, _network, plan, options) {
2726
+ const seconds = options.rollback * 60;
2508
2727
  try {
2728
+ if (options.console) {
2729
+ await consoleSession(device, async (cli, relogin) => {
2730
+ await checkVersion(cli, device);
2731
+ await configure(cli, plan, { seconds, prove: relogin });
2732
+ });
2733
+ return;
2734
+ }
2509
2735
  await session(device, async (cli) => {
2510
2736
  await checkVersion(cli, device);
2511
2737
  await configure(cli, plan, {
2512
- seconds: options.rollback * 60,
2738
+ seconds,
2513
2739
  prove: async () => {
2514
2740
  const proof = await login(device);
2515
2741
  proof.end();
@@ -2520,7 +2746,7 @@ async function apply(device, _network, plan, options) {
2520
2746
  if (!(error instanceof Reloaded))
2521
2747
  throw error;
2522
2748
  throw new Error(`${error.message}
2523
- ${await settle(device)}`);
2749
+ ${await settle(device, options)}`);
2524
2750
  }
2525
2751
  }
2526
2752
  function fillLines(set, family, prefixes) {
@@ -2920,6 +3146,7 @@ function serviceGaps(device) {
2920
3146
  }
2921
3147
  var fortios = {
2922
3148
  extension: ".conf",
3149
+ console: true,
2923
3150
  unsupported,
2924
3151
  render,
2925
3152
  read,
@@ -8661,6 +8888,7 @@ var HELP = `usage: circuit <command> [device...] [options]
8661
8888
  apply <device> read the device, plan against what it runs, and send with --confirm
8662
8889
  --secrets also send every secret, to rotate one
8663
8890
  --rollback=N minutes before a device that can restore itself does, default 10
8891
+ --console through the device's serial console on its jump host, instead of SSH; also for snapshot
8664
8892
  refresh <device> fetch prefix sets that come from a registry onto the device, with --confirm
8665
8893
  wireguard <device> <interface> <peer>
8666
8894
  a client config for one peer, its private key left for the peer to fill
@@ -8721,7 +8949,8 @@ var names = rest.filter((arg) => !arg.startsWith("--"));
8721
8949
  var confirmed = flags.includes("--confirm");
8722
8950
  var options = {
8723
8951
  secrets: flags.includes("--secrets"),
8724
- rollback: Number(flags.find((flag) => flag.startsWith("--rollback="))?.split("=")[1] ?? 10)
8952
+ rollback: Number(flags.find((flag) => flag.startsWith("--rollback="))?.split("=")[1] ?? 10),
8953
+ ...flags.includes("--console") ? { console: true } : {}
8725
8954
  };
8726
8955
  function fail(message) {
8727
8956
  console.error(message);
@@ -8738,7 +8967,16 @@ function one() {
8738
8967
  const [device] = selected(false);
8739
8968
  return device ?? fail("name a device");
8740
8969
  }
8741
- var adapterOf = (device) => adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
8970
+ function adapterOf(device) {
8971
+ const adapter = adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
8972
+ if (!options.console)
8973
+ return adapter;
8974
+ if (!adapter.console)
8975
+ fail(`${device.name}: ${device.platform} cannot be reached through a console yet`);
8976
+ if (!device.connection.console)
8977
+ fail(`${device.name}: --console needs connection.console`);
8978
+ return adapter;
8979
+ }
8742
8980
  var generated = join4(root2, ".circuit");
8743
8981
  var stateDirectory = join4(generated, "state");
8744
8982
  var statePath = (device) => join4(stateDirectory, device.name + adapterOf(device).extension);
@@ -8805,7 +9043,7 @@ function diffCommand() {
8805
9043
  }
8806
9044
  async function snapshotCommand() {
8807
9045
  for (const device of selected(true)) {
8808
- const text = await adapterOf(device).read(device, network3.devices);
9046
+ const text = await adapterOf(device).read(device, network3.devices, options);
8809
9047
  writeState(device, text);
8810
9048
  console.log(`${device.name}: ${text.split(`
8811
9049
  `).length} lines`);
@@ -8824,7 +9062,7 @@ async function applyCommand() {
8824
9062
  const device = one();
8825
9063
  guard([device]);
8826
9064
  const adapter = adapterOf(device);
8827
- const live = await adapter.read(device, network3.devices);
9065
+ const live = await adapter.read(device, network3.devices, options);
8828
9066
  writeState(device, live);
8829
9067
  const plan = adapter.plan(adapter.render(device), live, options);
8830
9068
  if (!show(device, plan))
@@ -8838,7 +9076,7 @@ Nothing sent. Re-run with --confirm.`);
8838
9076
  return;
8839
9077
  }
8840
9078
  await adapter.apply(device, network3.devices, plan, options);
8841
- writeState(device, await adapter.read(device, network3.devices));
9079
+ writeState(device, await adapter.read(device, network3.devices, options));
8842
9080
  console.log("snapshot updated");
8843
9081
  }
8844
9082
  async function refreshCommand() {
@@ -1,6 +1,6 @@
1
1
  import type { Client } from "ssh2";
2
2
  import type { Device, Family } from "../../../schema";
3
- import type { ApplyOptions, DeviceAdapter, Plan } from "../types";
3
+ import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
4
4
  /** What a session reads and writes: an SSH shell, or anything else that streams the CLI. */
5
5
  export type Channel = {
6
6
  write(data: string): unknown;
@@ -26,13 +26,17 @@ export declare class Cli {
26
26
  private collect;
27
27
  }
28
28
  export declare function login(device: Device): Promise<Client>;
29
+ /**
30
+ * Log in on a serial console, whatever state the last session left it in: a pager is quit, a `config` level left, a session someone left open logged out, so every session starts from a fresh login. The CLI ends a line at a carriage return.
31
+ */
32
+ export declare function consoleLogin(channel: Channel, user: string, password: string): Promise<Cli>;
29
33
  /** Refuse a device on a release this adapter was not proved on, before anything is read or sent. */
30
34
  export declare function checkVersion(cli: Cli, device: Device): Promise<void>;
31
35
  /** Secrets the device prints: a password or an SNMPv3 key as a cipher, a certificate's key encrypted, and the SNMP community in clear. None leave the read. */
32
36
  export declare function scrub(text: string): string;
33
37
  /** Every section Circuit manages, as the device holds it, credentials scrubbed. */
34
38
  export declare function readConfig(cli: Cli, device: Device): Promise<string>;
35
- export declare function read(device: Device): Promise<string>;
39
+ export declare function read(device: Device, _network?: readonly Device[], options?: ReadOptions): Promise<string>;
36
40
  /**
37
41
  * A line with each secret's value in place, escaped inside its quotes. A certificate's key from a variable is PEM encoded as one line of base64, and is sent as the PEM it holds. A file saved with Windows line endings is sent with plain ones: the CLI takes each carriage return as a line of its own, and refuses the key, `Invalid private key`. A `?`, which the CLI takes as a call for help, is typed literally.
38
42
  */
@@ -12,6 +12,8 @@ type Table = {
12
12
  created?: (entry: Block) => readonly string[];
13
13
  /** Settings an entry keeps for life. A change deletes the entry and creates it again. */
14
14
  fixed?: readonly string[];
15
+ /** The device refuses an entry that holds the same as another, `Duplicated entry`, even for a moment while entries are renumbered. */
16
+ distinct?: true;
15
17
  };
16
18
  /** Tables whose entries need more than a plain comparison, by section. */
17
19
  export declare const TABLES: Record<string, Table>;
@@ -15,16 +15,22 @@ export type ApplyOptions = {
15
15
  secrets: boolean;
16
16
  /** Minutes before a platform that can restore itself does so, if the run does not prove access survived. */
17
17
  rollback: number;
18
+ /** Reach the device through its serial console, `connection.console`, instead of SSH to its management address. */
19
+ console?: boolean;
18
20
  };
21
+ /** How a device is read. */
22
+ export type ReadOptions = Pick<ApplyOptions, "console">;
19
23
  export type DeviceAdapter = {
20
24
  /** File extension of a saved snapshot, for syntax highlighting. */
21
25
  extension: string;
22
26
  /** Fields this platform cannot express. Validation fails on any of them, so nothing is skipped silently. */
23
27
  unsupported(device: Device): string[];
28
+ /** The platform can be read and applied through a serial console, `--console`. */
29
+ console?: true;
24
30
  /** The whole configuration the device should run. Secrets appear as markers, never values. */
25
31
  render(device: Device): string;
26
32
  /** What the device runs now, secrets removed, in the form `render` produces where the platform allows. */
27
- read(device: Device, network: readonly Device[]): Promise<string>;
33
+ read(device: Device, network: readonly Device[], options?: ReadOptions): Promise<string>;
28
34
  /** What it takes to go from `current` to `desired`. */
29
35
  plan(desired: string, current: string, options: ApplyOptions): Plan;
30
36
  /** Replace the contents of a prefix set fetched from a registry. Absent on a platform that cannot hold one. */
@@ -1,4 +1,4 @@
1
- import type { IP } from "./common";
1
+ import type { IP, Secret } from "./common";
2
2
  import type { AclRule, Firewall } from "./firewall";
3
3
  import type { Interface, Port, Vlan } from "./interfaces";
4
4
  import type { PrefixSet, RoutePolicy } from "./policy";
@@ -12,6 +12,30 @@ export type Connection<U extends string> = {
12
12
  user: U;
13
13
  /** SSH port. 22 when absent. */
14
14
  port?: number;
15
+ /** The device's serial console, for `--console`: the way in before the device has a management address, or when its management network is down. */
16
+ console?: Console;
17
+ };
18
+ /** A computer with SSH that holds the serial consoles of devices, such as a laptop beside the rack. It runs Python 3. */
19
+ export type Jump = {
20
+ /** Its address or name. */
21
+ host: string;
22
+ /** The user Circuit logs in as. It must be allowed to open the serial ports. */
23
+ user: string;
24
+ /** The user's password. */
25
+ password: Secret;
26
+ /** SSH port. 22 when absent. */
27
+ port?: number;
28
+ /** The jump host is reached through a Cloudflare Tunnel, so Circuit connects with `cloudflared access ssh`, which must be installed. */
29
+ cloudflared?: true;
30
+ };
31
+ /** A serial console, reached through a jump host. */
32
+ export type Console = {
33
+ /** The jump host the console cable is plugged into. */
34
+ jump: Jump;
35
+ /** The serial port on the jump host. A path under `/dev/serial/by-path/` stays the same when two adapters swap names. */
36
+ port: string;
37
+ /** Line speed in bits per second, such as 9600 or 115200. */
38
+ baud: number;
15
39
  };
16
40
  /** A separate routing table, with the interfaces that route in it. */
17
41
  export type Vrf = {
@@ -0,0 +1,9 @@
1
+ import { type ClientChannel } from "ssh2";
2
+ import type { Console } from "../schema";
3
+ /** An open console: the stream to the device's CLI, and how to let it go. */
4
+ export type ConsoleSession = {
5
+ channel: ClientChannel;
6
+ close: () => void;
7
+ };
8
+ /** Open a device's serial console through its jump host. Fails when another session holds the port. */
9
+ export declare function openConsole(console: Console): Promise<ConsoleSession>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.26.2",
3
+ "version": "0.27.0",
4
4
  "description": "Declarative, vendor-neutral network configuration: describe each device in TypeScript, and Circuit makes the device match. Alpha.",
5
5
  "keywords": [
6
6
  "network",
@@ -19,7 +19,7 @@ Behaviour that looks like something else. Each one has been hit on a FortiGate 3
19
19
  - Global `alias` is set again at every start, whatever was saved.
20
20
  - `log memory setting` prints its `status` whether on or off, and `unset status` turns memory logging on.
21
21
  - Two interfaces that are up cannot have overlapping subnets, `Subnets overlap between ...`, so an address moving from one to another must leave the first before it joins the second.
22
- - A static route identical to another is refused, `Duplicated entry`. An IPv4 route's destination is not printed when it is the default route; neither is IPv6's.
22
+ - A static route identical to another is refused, `Duplicated entry`, even for a moment: editing route 1 into what route 2 holds fails before route 2 is changed. A plan deletes every route that changes or goes before it creates any. An IPv4 route's destination is not printed when it is the default route; neither is IPv6's.
23
23
  - A deny policy is given `logtraffic disable`, and its action is not printed, since deny is the default. `send-deny-packet` exists only while a policy denies: set before `action deny` or unset after `action accept`, it is `command parse error`. So the render writes `action deny` before `send-deny-packet`, and a plan removes the settings that go before it sets the ones that come: `send-deny-packet` is unset while the policy still denies, and set only once it does.
24
24
  - `show` prints `unset` in two places: an ICMP service's `unset icmptype`, and a prefix list rule's `unset ge` and `unset le` for a bound it does not have.
25
25
  - An SNMPv3 user authenticates with MD5 or SHA1 only, and has no list of sources: only a community has hosts. Circuit decides who reaches every service with local-in policies instead.
@@ -38,3 +38,4 @@ Behaviour that looks like something else. Each one has been hit on a FortiGate 3
38
38
  - There is no WireGuard and no RPKI, `command parse error`. A route map has no large community and a BGP neighbor no role: neither is among their settings. SNMPv3 offers no SHA256.
39
39
  - `execute backup config flash <comment>` keeps a revision on the device, and `execute restore config flash <id>` restores one.
40
40
  - The ports are `port1` to `port4` at 40G and `port5` to `port32` at 10G, then `mgmt1` and `mgmt2`. The factory dedicates both management ports to management and gives them addresses `192.168.1.99` and `192.168.2.99`.
41
+ - On the serial console a line ends at a carriage return. A session left open stays logged in, and one left inside `config` stays there, so the next login must first leave and log out. The console takes 9600 bits per second: reading the sections Circuit manages takes about two minutes.
@@ -6,9 +6,10 @@ import type { Client } from "ssh2";
6
6
  import { familyOf, host, split } from "../../../core/addr";
7
7
  import { MARKER, resolve, valueOfReference } from "../../../core/secrets";
8
8
  import type { AclRule, Device, Family, FilterRule } from "../../../schema";
9
+ import { openConsole } from "../../../transport/console";
9
10
  import { connect, shell } from "../../../transport/ssh";
10
11
  import { portsOf, spell } from "../catalog";
11
- import type { ApplyOptions, DeviceAdapter, Plan } from "../types";
12
+ import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
12
13
  import { acceptsBeforeDrop, familiesOf, isImplied, serviceOf } from "./firewall";
13
14
  import { holderOf, interfaceFor, network, settingsOf, subnet } from "./names";
14
15
  import { clean, plan } from "./plan";
@@ -147,8 +148,115 @@ export async function login(device: Device): Promise<Client> {
147
148
  });
148
149
  }
149
150
 
150
- /** Log in, open the CLI, and run `work` in it. */
151
- async function session<T>(device: Device, work: (cli: Cli) => Promise<T>): Promise<T> {
151
+ /** What a console shows when it waits for something, in the order they are checked. */
152
+ const CONSOLE_STATES = [
153
+ ["login", /login: $/],
154
+ ["password", /Password: $/],
155
+ ["banner", BANNER],
156
+ ["pager", /--More-- $/],
157
+ ["context", /\([^)\n]*\) [#$] $/],
158
+ ["prompt", /[#$] $/],
159
+ ] as const;
160
+
161
+ type ConsoleState = (typeof CONSOLE_STATES)[number][0];
162
+
163
+ /** Write to a console, then wait until it shows one of its states. */
164
+ function prompted(channel: Channel, write: string, timeout = 30_000): Promise<ConsoleState> {
165
+ return new Promise((done, fail) => {
166
+ let text = "";
167
+
168
+ const stop = () => {
169
+ clearTimeout(limit);
170
+ channel.removeListener("data", onData);
171
+ };
172
+
173
+ const onData = (chunk: Buffer) => {
174
+ text += chunk.toString("utf8").replace(/\r/g, "");
175
+
176
+ const state = CONSOLE_STATES.find(([, pattern]) => pattern.test(text));
177
+ if (!state) return;
178
+
179
+ stop();
180
+ done(state[0]);
181
+ };
182
+
183
+ const limit = setTimeout(() => {
184
+ stop();
185
+ fail(new Error(`the console did not answer: ${text.slice(-200)}`));
186
+ }, timeout);
187
+
188
+ channel.on("data", onData);
189
+ channel.write(write);
190
+ });
191
+ }
192
+
193
+ /**
194
+ * Log in on a serial console, whatever state the last session left it in: a pager is quit, a `config` level left, a session someone left open logged out, so every session starts from a fresh login. The CLI ends a line at a carriage return.
195
+ */
196
+ export async function consoleLogin(channel: Channel, user: string, password: string): Promise<Cli> {
197
+ // What to send in each state to get closer to a login prompt.
198
+ const toward: Record<Exclude<ConsoleState, "login">, string> = {
199
+ password: "\r",
200
+ banner: "a",
201
+ pager: "q",
202
+ context: "end\r",
203
+ prompt: "exit\r",
204
+ };
205
+
206
+ let state = await prompted(channel, "\r");
207
+ for (let step = 0; state !== "login"; step++) {
208
+ if (step >= 10) throw new Error(`the console never reached a login prompt; it is at ${state}`);
209
+ state = await prompted(channel, toward[state]);
210
+ }
211
+
212
+ if ((await prompted(channel, `${user}\r`)) !== "password") throw new Error("the console did not ask for a password");
213
+
214
+ const answer = await prompted(channel, `${password}\r`, 60_000);
215
+ if (answer !== "prompt") throw new Error(`the console refused the login of ${user}`);
216
+
217
+ return new Cli(channel, "\r");
218
+ }
219
+
220
+ /** Log out of a console, so no session is left open on it. */
221
+ async function consoleLogout(cli: Cli): Promise<void> {
222
+ await leave(cli).catch(() => undefined);
223
+ await cli.run("exit", 10_000).catch(() => undefined);
224
+ }
225
+
226
+ /** The device's console and its login. */
227
+ function consoleOf(device: Device) {
228
+ const console = device.connection.console;
229
+ if (!console) throw new Error(`${device.name}: no connection.console`);
230
+
231
+ const user = device.users?.[device.connection.user];
232
+ if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
233
+
234
+ return { console, user: device.connection.user as string, password: resolve(user.password) };
235
+ }
236
+
237
+ /** Log in on the console, open the CLI, and run `work` in it. `work` is also given a way to log in afresh on the same console. */
238
+ async function consoleSession<T>(device: Device, work: (cli: Cli, relogin: () => Promise<void>) => Promise<T>): Promise<T> {
239
+ const { console, user, password } = consoleOf(device);
240
+ const opened = await openConsole(console);
241
+
242
+ try {
243
+ const cli = await consoleLogin(opened.channel, user, password);
244
+ const relogin = async () => {
245
+ await consoleLogin(opened.channel, user, password);
246
+ };
247
+
248
+ const result = await work(cli, relogin);
249
+ await consoleLogout(cli);
250
+ return result;
251
+ } finally {
252
+ opened.close();
253
+ }
254
+ }
255
+
256
+ /** Log in, open the CLI, and run `work` in it: through the console when asked, else over SSH. */
257
+ async function session<T>(device: Device, work: (cli: Cli) => Promise<T>, options: ReadOptions = {}): Promise<T> {
258
+ if (options.console) return consoleSession(device, (cli) => work(cli));
259
+
152
260
  const client = await login(device);
153
261
 
154
262
  try {
@@ -216,8 +324,8 @@ export async function readConfig(cli: Cli, device: Device): Promise<string> {
216
324
  return scrub(clean(text));
217
325
  }
218
326
 
219
- export function read(device: Device): Promise<string> {
220
- return session(device, (cli) => readConfig(cli, device));
327
+ export function read(device: Device, _network: readonly Device[] = [], options: ReadOptions = {}): Promise<string> {
328
+ return session(device, (cli) => readConfig(cli, device), options);
221
329
  }
222
330
 
223
331
  /**
@@ -317,15 +425,19 @@ const RELOAD = { attempts: 20, interval: 15_000 } as const;
317
425
  /**
318
426
  * Once the device is back from a reload, set it to save changes on its own again: the saved configuration still says to revert. Setting it saves what runs, so it is set only once the device says nothing is left to reload; until then the reload is asked for again.
319
427
  */
320
- async function settle(device: Device): Promise<string> {
428
+ async function settle(device: Device, options: ReadOptions): Promise<string> {
321
429
  for (let attempt = 0; attempt < RELOAD.attempts; attempt++) {
322
430
  await new Promise((done) => setTimeout(done, RELOAD.interval));
323
431
 
324
- const settled = await session(device, async (cli) => {
325
- if (!/no config to be reloaded/.test(await cli.run("execute cfg reload"))) return false;
326
- await saving(cli, "automatic");
327
- return true;
328
- }).catch(() => false);
432
+ const settled = await session(
433
+ device,
434
+ async (cli) => {
435
+ if (!/no config to be reloaded/.test(await cli.run("execute cfg reload"))) return false;
436
+ await saving(cli, "automatic");
437
+ return true;
438
+ },
439
+ options
440
+ ).catch(() => false);
329
441
  if (settled) return "the device is back and saves changes on its own again";
330
442
  }
331
443
 
@@ -333,12 +445,23 @@ async function settle(device: Device): Promise<string> {
333
445
  }
334
446
 
335
447
  export async function apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void> {
448
+ const seconds = options.rollback * 60;
449
+
336
450
  try {
451
+ // Through the console, a fresh login on the same console proves the change left the device's logins working; over SSH, a fresh SSH session proves the management path too.
452
+ if (options.console) {
453
+ await consoleSession(device, async (cli, relogin) => {
454
+ await checkVersion(cli, device);
455
+ await configure(cli, plan, { seconds, prove: relogin });
456
+ });
457
+ return;
458
+ }
459
+
337
460
  await session(device, async (cli) => {
338
461
  await checkVersion(cli, device);
339
462
 
340
463
  await configure(cli, plan, {
341
- seconds: options.rollback * 60,
464
+ seconds,
342
465
  prove: async () => {
343
466
  const proof = await login(device);
344
467
  proof.end();
@@ -347,7 +470,7 @@ export async function apply(device: Device, _network: readonly Device[], plan: P
347
470
  });
348
471
  } catch (error) {
349
472
  if (!(error instanceof Reloaded)) throw error;
350
- throw new Error(`${error.message}\n ${await settle(device)}`);
473
+ throw new Error(`${error.message}\n ${await settle(device, options)}`);
351
474
  }
352
475
  }
353
476
 
@@ -791,6 +914,7 @@ function serviceGaps(device: Device): string[] {
791
914
 
792
915
  export const fortios: DeviceAdapter = {
793
916
  extension: ".conf",
917
+ console: true,
794
918
  unsupported,
795
919
  render,
796
920
  read,
@@ -18,6 +18,8 @@ type Table = {
18
18
  created?: (entry: Block) => readonly string[];
19
19
  /** Settings an entry keeps for life. A change deletes the entry and creates it again. */
20
20
  fixed?: readonly string[];
21
+ /** The device refuses an entry that holds the same as another, `Duplicated entry`, even for a moment while entries are renumbered. */
22
+ distinct?: true;
21
23
  };
22
24
 
23
25
  /** Objects every FortiGate running 6.2 is shipped with, which policies and profiles of the factory name. */
@@ -168,6 +170,8 @@ export const TABLES: Record<string, Table> = {
168
170
  "firewall local-in-policy": ORDERED,
169
171
  "firewall local-in-policy6": ORDERED,
170
172
  "firewall central-snat-map": ORDERED,
173
+ "router static": { distinct: true },
174
+ "router static6": { distinct: true },
171
175
  };
172
176
 
173
177
  /** Settings the device keeps for itself: an object's UUID and an interface's SNMP index. */
@@ -346,7 +350,11 @@ function compare(path: string[], want: Block, have: Block, table: Table | undefi
346
350
 
347
351
  for (const [header, child] of want.blocks) nested(header, child, have.blocks.get(header) ?? new Block());
348
352
 
349
- const entries = table?.byContent ? byContent(path, want, have) : byId(path, want, have, table, context);
353
+ const entries = table?.byContent
354
+ ? byContent(path, want, have)
355
+ : table?.distinct
356
+ ? distinctById(path, want, have, table, context)
357
+ : byId(path, want, have, table, context);
350
358
  send.push(...entries.send);
351
359
  show.push(...entries.show);
352
360
 
@@ -410,6 +418,37 @@ function byId(path: string[], want: Block, have: Block, table: Table | undefined
410
418
  return { send, show, removals, removed };
411
419
  }
412
420
 
421
+ /** Entries matched by name in a table that refuses two alike: every entry that changes or goes is deleted before any is created, so none is ever briefly the same as another. */
422
+ function distinctById(path: string[], want: Block, have: Block, table: Table, context: Context): Diff {
423
+ const deletes: string[] = [];
424
+ const creates: string[] = [];
425
+ const show: string[] = [];
426
+
427
+ for (const [key, entry] of want.entries) {
428
+ const old = have.entries.get(key);
429
+ const where = [...path, bare(entry.id)];
430
+
431
+ if (old) {
432
+ if (!compare(where, entry.block, old.block, undefined, false, context).send.length) continue;
433
+
434
+ deletes.push(`delete ${old.id}`);
435
+ show.push(...shown("-", where, old.block));
436
+ }
437
+
438
+ creates.push(...creation(entry, table));
439
+ show.push(...shown("+", where, entry.block));
440
+ }
441
+
442
+ for (const [key, entry] of have.entries) {
443
+ if (want.entries.has(key) || isFactory(table, entry)) continue;
444
+
445
+ deletes.push(`delete ${entry.id}`);
446
+ show.push(...shown("-", [...path, bare(entry.id)], entry.block));
447
+ }
448
+
449
+ return { send: [...deletes, ...creates], show, removals: [], removed: [] };
450
+ }
451
+
413
452
  /** The `move` commands that put a table's entries in the render's order. Kept entries hold their place on the device and new ones are added at the end. */
414
453
  function moves(want: Block, have: Block): string[] {
415
454
  const wanted = [...want.entries.keys()];
@@ -11,17 +11,24 @@ export type ApplyOptions = {
11
11
  secrets: boolean;
12
12
  /** Minutes before a platform that can restore itself does so, if the run does not prove access survived. */
13
13
  rollback: number;
14
+ /** Reach the device through its serial console, `connection.console`, instead of SSH to its management address. */
15
+ console?: boolean;
14
16
  };
15
17
 
18
+ /** How a device is read. */
19
+ export type ReadOptions = Pick<ApplyOptions, "console">;
20
+
16
21
  export type DeviceAdapter = {
17
22
  /** File extension of a saved snapshot, for syntax highlighting. */
18
23
  extension: string;
19
24
  /** Fields this platform cannot express. Validation fails on any of them, so nothing is skipped silently. */
20
25
  unsupported(device: Device): string[];
26
+ /** The platform can be read and applied through a serial console, `--console`. */
27
+ console?: true;
21
28
  /** The whole configuration the device should run. Secrets appear as markers, never values. */
22
29
  render(device: Device): string;
23
30
  /** What the device runs now, secrets removed, in the form `render` produces where the platform allows. */
24
- read(device: Device, network: readonly Device[]): Promise<string>;
31
+ read(device: Device, network: readonly Device[], options?: ReadOptions): Promise<string>;
25
32
  /** What it takes to go from `current` to `desired`. */
26
33
  plan(desired: string, current: string, options: ApplyOptions): Plan;
27
34
  /** Replace the contents of a prefix set fetched from a registry. Absent on a platform that cannot hold one. */
package/src/cli/index.ts CHANGED
@@ -32,6 +32,7 @@ const HELP = `usage: circuit <command> [device...] [options]
32
32
  apply <device> read the device, plan against what it runs, and send with --confirm
33
33
  --secrets also send every secret, to rotate one
34
34
  --rollback=N minutes before a device that can restore itself does, default 10
35
+ --console through the device's serial console on its jump host, instead of SSH; also for snapshot
35
36
  refresh <device> fetch prefix sets that come from a registry onto the device, with --confirm
36
37
  wireguard <device> <interface> <peer>
37
38
  a client config for one peer, its private key left for the peer to fill
@@ -109,6 +110,7 @@ const confirmed = flags.includes("--confirm");
109
110
  const options: ApplyOptions = {
110
111
  secrets: flags.includes("--secrets"),
111
112
  rollback: Number(flags.find((flag) => flag.startsWith("--rollback="))?.split("=")[1] ?? 10),
113
+ ...(flags.includes("--console") ? { console: true } : {}),
112
114
  };
113
115
 
114
116
  function fail(message: string): never {
@@ -131,7 +133,15 @@ function one(): Device {
131
133
  return device ?? fail("name a device");
132
134
  }
133
135
 
134
- const adapterOf = (device: Device): DeviceAdapter => adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
136
+ /** The adapter of a device, which must be able to use its console when `--console` asks for it. */
137
+ function adapterOf(device: Device): DeviceAdapter {
138
+ const adapter = adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
139
+ if (!options.console) return adapter;
140
+
141
+ if (!adapter.console) fail(`${device.name}: ${device.platform} cannot be reached through a console yet`);
142
+ if (!device.connection.console) fail(`${device.name}: --console needs connection.console`);
143
+ return adapter;
144
+ }
135
145
  /** What Circuit writes, beside the config. */
136
146
  const generated = join(root, ".circuit");
137
147
  const stateDirectory = join(generated, "state");
@@ -215,7 +225,7 @@ function diffCommand(): void {
215
225
 
216
226
  async function snapshotCommand(): Promise<void> {
217
227
  for (const device of selected(true)) {
218
- const text = await adapterOf(device).read(device, network.devices);
228
+ const text = await adapterOf(device).read(device, network.devices, options);
219
229
  writeState(device, text);
220
230
  console.log(`${device.name}: ${text.split("\n").length} lines`);
221
231
  }
@@ -238,7 +248,7 @@ async function applyCommand(): Promise<void> {
238
248
  const adapter = adapterOf(device);
239
249
 
240
250
  // The plan is made against what the device runs now, never against an old snapshot.
241
- const live = await adapter.read(device, network.devices);
251
+ const live = await adapter.read(device, network.devices, options);
242
252
  writeState(device, live);
243
253
 
244
254
  const plan = adapter.plan(adapter.render(device), live, options);
@@ -253,7 +263,7 @@ async function applyCommand(): Promise<void> {
253
263
 
254
264
  await adapter.apply(device, network.devices, plan, options);
255
265
 
256
- writeState(device, await adapter.read(device, network.devices));
266
+ writeState(device, await adapter.read(device, network.devices, options));
257
267
  console.log("snapshot updated");
258
268
  }
259
269
 
@@ -1,4 +1,4 @@
1
- import type { IP } from "./common";
1
+ import type { IP, Secret } from "./common";
2
2
  import type { AclRule, Firewall } from "./firewall";
3
3
  import type { Interface, Port, Vlan } from "./interfaces";
4
4
  import type { PrefixSet, RoutePolicy } from "./policy";
@@ -13,6 +13,32 @@ export type Connection<U extends string> = {
13
13
  user: U;
14
14
  /** SSH port. 22 when absent. */
15
15
  port?: number;
16
+ /** The device's serial console, for `--console`: the way in before the device has a management address, or when its management network is down. */
17
+ console?: Console;
18
+ };
19
+
20
+ /** A computer with SSH that holds the serial consoles of devices, such as a laptop beside the rack. It runs Python 3. */
21
+ export type Jump = {
22
+ /** Its address or name. */
23
+ host: string;
24
+ /** The user Circuit logs in as. It must be allowed to open the serial ports. */
25
+ user: string;
26
+ /** The user's password. */
27
+ password: Secret;
28
+ /** SSH port. 22 when absent. */
29
+ port?: number;
30
+ /** The jump host is reached through a Cloudflare Tunnel, so Circuit connects with `cloudflared access ssh`, which must be installed. */
31
+ cloudflared?: true;
32
+ };
33
+
34
+ /** A serial console, reached through a jump host. */
35
+ export type Console = {
36
+ /** The jump host the console cable is plugged into. */
37
+ jump: Jump;
38
+ /** The serial port on the jump host. A path under `/dev/serial/by-path/` stays the same when two adapters swap names. */
39
+ port: string;
40
+ /** Line speed in bits per second, such as 9600 or 115200. */
41
+ baud: number;
16
42
  };
17
43
 
18
44
  /** A separate routing table, with the interfaces that route in it. */
@@ -0,0 +1,127 @@
1
+ // Serial consoles on a jump host. Circuit logs in to the jump host over SSH and starts a small relay there, which joins the serial port to the session. The device's CLI then streams through it as through an SSH shell.
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { Duplex } from "node:stream";
5
+ import { Client, type ClientChannel } from "ssh2";
6
+ import { resolve } from "../core/secrets";
7
+ import type { Console, Jump } from "../schema";
8
+ import { exec } from "./ssh";
9
+
10
+ /** The relay, in Python's standard library only: the serial port in raw mode, locked against a second session, joined to standard input and output. */
11
+ const RELAY = `import fcntl, os, select, sys, termios
12
+
13
+ port, baud = sys.argv[1], int(sys.argv[2])
14
+ fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
15
+
16
+ try:
17
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
18
+ except OSError:
19
+ sys.stderr.write("busy: another session holds " + port + "\\n")
20
+ sys.exit(3)
21
+
22
+ attrs = termios.tcgetattr(fd)
23
+ attrs[0] = attrs[1] = attrs[3] = 0
24
+ attrs[2] = termios.CS8 | termios.CREAD | termios.CLOCAL
25
+ attrs[4] = attrs[5] = getattr(termios, "B%d" % baud)
26
+ termios.tcsetattr(fd, termios.TCSANOW, attrs)
27
+ termios.tcflush(fd, termios.TCIOFLUSH)
28
+
29
+ sys.stderr.write("ready\\n")
30
+ sys.stderr.flush()
31
+
32
+ while True:
33
+ ready, _, _ = select.select([0, fd], [], [])
34
+
35
+ if 0 in ready:
36
+ data = os.read(0, 4096)
37
+ if not data:
38
+ break
39
+ while data:
40
+ try:
41
+ data = data[os.write(fd, data):]
42
+ except BlockingIOError:
43
+ select.select([], [fd], [])
44
+
45
+ if fd in ready:
46
+ try:
47
+ data = os.read(fd, 4096)
48
+ except BlockingIOError:
49
+ continue
50
+ if data:
51
+ os.write(1, data)
52
+ `;
53
+
54
+ const RELAY_PATH = "/tmp/circuit-console.py";
55
+
56
+ /** A serial port path the relay is started with, which reaches the jump host's shell. */
57
+ const PORT = /^\/dev\/[\w./:+-]+$/;
58
+
59
+ /** A session to a jump host, through `cloudflared access ssh` when it sits behind a Cloudflare Tunnel. */
60
+ function connectJump(jump: Jump): Promise<Client> {
61
+ return new Promise((done, fail) => {
62
+ const client = new Client();
63
+ const login = { username: jump.user, password: resolve(jump.password), readyTimeout: 60_000 };
64
+
65
+ client.on("ready", () => done(client)).on("error", fail);
66
+
67
+ if (!jump.cloudflared) {
68
+ client.connect({ ...login, host: jump.host, port: jump.port ?? 22 });
69
+ return;
70
+ }
71
+
72
+ const tunnel = spawn("cloudflared", ["access", "ssh", "--hostname", jump.host], { stdio: ["pipe", "pipe", "ignore"] });
73
+ tunnel.on("error", (error) => fail(new Error(`cloudflared did not start: ${error.message}`)));
74
+ client.on("close", () => tunnel.kill());
75
+
76
+ client.connect({ ...login, sock: Duplex.from({ readable: tunnel.stdout, writable: tunnel.stdin }) });
77
+ });
78
+ }
79
+
80
+ /** An open console: the stream to the device's CLI, and how to let it go. */
81
+ export type ConsoleSession = { channel: ClientChannel; close: () => void };
82
+
83
+ /** Open a device's serial console through its jump host. Fails when another session holds the port. */
84
+ export async function openConsole(console: Console): Promise<ConsoleSession> {
85
+ if (!PORT.test(console.port)) throw new Error(`console port ${console.port} is not a path under /dev`);
86
+ if (!Number.isInteger(console.baud) || console.baud <= 0) throw new Error(`console baud ${console.baud} is not a line speed`);
87
+
88
+ const client = await connectJump(console.jump);
89
+
90
+ try {
91
+ const encoded = Buffer.from(RELAY).toString("base64");
92
+ await exec(client, `sh -c 'printf %s ${encoded} | base64 -d > ${RELAY_PATH}'`, 30_000);
93
+
94
+ const channel = await new Promise<ClientChannel>((done, fail) => {
95
+ client.exec(`python3 ${RELAY_PATH} ${console.port} ${console.baud}`, (error, stream) => (error ? fail(error) : done(stream)));
96
+ });
97
+
98
+ // The relay says it is ready once it holds the port, or why it cannot.
99
+ await new Promise<void>((done, fail) => {
100
+ let said = "";
101
+ const limit = setTimeout(() => fail(new Error(`the console relay did not start: ${said.trim()}`)), 30_000);
102
+
103
+ channel.stderr.on("data", (chunk: Buffer) => {
104
+ said += chunk.toString("utf8");
105
+ if (said.includes("ready\n")) {
106
+ clearTimeout(limit);
107
+ done();
108
+ }
109
+ });
110
+ channel.on("close", () => {
111
+ clearTimeout(limit);
112
+ fail(new Error(`the console relay stopped: ${said.trim() || "no reason given"}`));
113
+ });
114
+ });
115
+
116
+ return {
117
+ channel,
118
+ close: () => {
119
+ channel.end();
120
+ client.end();
121
+ },
122
+ };
123
+ } catch (error) {
124
+ client.end();
125
+ throw error;
126
+ }
127
+ }