@takodotid/circuit 0.28.0 → 0.29.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.
@@ -117,7 +117,11 @@ function describeDevice(device) {
117
117
  const vrf = settings.vrf ? `, VRF ${settings.vrf}` : "";
118
118
  return `${name}: ${list.join(", ")}${vrf}`;
119
119
  });
120
- return [`${device.platform} ${device.model}`, `reached at ${device.connection.host}`, ...addresses];
120
+ return [
121
+ `${device.platform} ${device.model}`,
122
+ device.connection.host ? `reached at ${device.connection.host}` : "reached through its console",
123
+ ...addresses
124
+ ];
121
125
  }
122
126
  function carriedBy(device, settings) {
123
127
  const vlan = (name, untagged) => {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  packageRoot
3
- } from "./index-22sbskdx.js";
3
+ } from "./index-47debt16.js";
4
4
 
5
5
  // src/studio/server.ts
6
6
  import { execFile as execFile2, spawn } from "node:child_process";
package/dist/cli/index.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  addressesOf,
12
12
  freeOfLength,
13
13
  studioData
14
- } from "../chunks/index-22sbskdx.js";
14
+ } from "../chunks/index-47debt16.js";
15
15
  import {
16
16
  familyOf2,
17
17
  split,
@@ -118,6 +118,11 @@ import { Client as Client2 } from "ssh2";
118
118
 
119
119
  // src/transport/ssh.ts
120
120
  import { Client } from "ssh2";
121
+ function managementHost(device) {
122
+ if (!device.connection.host)
123
+ throw new Error(`${device.name} has no management address; it is reached through its console`);
124
+ return device.connection.host;
125
+ }
121
126
  function connect(login) {
122
127
  return new Promise((resolve, reject) => {
123
128
  const client = new Client;
@@ -324,8 +329,10 @@ async function openOnJump(console2, jump) {
324
329
  return {
325
330
  channel: asTerminal(channel),
326
331
  close: () => {
327
- channel.end();
328
- client.end();
332
+ quiet(channel, 1000).then(() => {
333
+ channel.end();
334
+ client.end();
335
+ });
329
336
  }
330
337
  };
331
338
  } catch (error) {
@@ -355,8 +362,10 @@ async function openHere(console2) {
355
362
  return {
356
363
  channel: asTerminal(asChannel),
357
364
  close: () => {
358
- channel.end();
359
- setTimeout(() => child.kill(), 2000).unref();
365
+ quiet(asChannel, 1000).then(() => {
366
+ channel.end();
367
+ setTimeout(() => child.kill(), 2000).unref();
368
+ });
360
369
  }
361
370
  };
362
371
  }
@@ -428,6 +437,7 @@ async function consoleLogin(channel, dialect, user, password) {
428
437
  return `${dialect.logout}
429
438
  `;
430
439
  };
440
+ await quiet(channel, 1000);
431
441
  let state = (await expect(channel, `
432
442
  `, states)).index;
433
443
  for (let step = 0;state !== 0; step++) {
@@ -435,6 +445,7 @@ async function consoleLogin(channel, dialect, user, password) {
435
445
  throw new Error("the console never reached a login prompt");
436
446
  state = (await expect(channel, toward(state), states)).index;
437
447
  }
448
+ await quiet(channel, 1000);
438
449
  const name = dialect.user ? dialect.user(user) : user;
439
450
  if ((await expect(channel, `${name}
440
451
  `, [dialect.password, dialect.login, dialect.prompt])).index !== 0) {
@@ -2697,7 +2708,7 @@ async function login(device) {
2697
2708
  if (!user)
2698
2709
  throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
2699
2710
  return connect({
2700
- host: device.connection.host,
2711
+ host: managementHost(device),
2701
2712
  port: device.connection.port,
2702
2713
  user: device.connection.user,
2703
2714
  password: resolve(user.password)
@@ -3941,7 +3952,8 @@ function sflow(out, device, names) {
3941
3952
  const flow = device.flow_export;
3942
3953
  if (flow?.protocol !== "sflow")
3943
3954
  return;
3944
- out.set("protocols sflow agent-id", device.connection.host);
3955
+ if (device.connection.host)
3956
+ out.set("protocols sflow agent-id", device.connection.host);
3945
3957
  if (flow.sampling !== undefined)
3946
3958
  out.set("protocols sflow sample-rate ingress", flow.sampling);
3947
3959
  for (const collector of flow.collectors) {
@@ -4515,7 +4527,7 @@ async function login2(device) {
4515
4527
  if (!user)
4516
4528
  throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
4517
4529
  return connect({
4518
- host: device.connection.host,
4530
+ host: managementHost(device),
4519
4531
  port: device.connection.port,
4520
4532
  user: device.connection.user,
4521
4533
  password: resolve(user.password)
@@ -4566,7 +4578,7 @@ async function edit(channel, line, timeout = 60000) {
4566
4578
  return output.replace(/\r/g, "");
4567
4579
  }
4568
4580
  var JUNOS_CONSOLE = {
4569
- login: /login: $/,
4581
+ login: /(?:^|\n)login: $/,
4570
4582
  password: /Password:\s*$/,
4571
4583
  prompt: /[>%] $/,
4572
4584
  logout: "exit",
@@ -4613,7 +4625,7 @@ async function named(channel, line) {
4613
4625
  return [...names].map((name) => `delete ${[...words.slice(0, at), name, ...words.slice(at + 1)].join(" ")}`);
4614
4626
  }
4615
4627
  var REFUSED2 = /^terminal:\d+:\(\d+\) (?!warning)|^error: /m;
4616
- async function configure2(channel, plan, guard) {
4628
+ async function configure2(channel, plan, guard, paced = false) {
4617
4629
  const sent = plan.steps.flatMap((step) => step.send);
4618
4630
  const entered = await run(channel, "configure exclusive");
4619
4631
  if (!/Entering configuration mode/.test(entered) || /^error: /m.test(entered)) {
@@ -4639,9 +4651,13 @@ async function configure2(channel, plan, guard) {
4639
4651
  channel.write(`load set terminal
4640
4652
  `);
4641
4653
  await readUntil(channel, (text) => text.includes("[Type ^D at a new line to end input]"), 20000, 300);
4642
- for (const line of ordered)
4643
- channel.write(`${substitute(line)}
4654
+ for (const line of ordered) {
4655
+ const statement = substitute(line);
4656
+ channel.write(`${statement}
4644
4657
  `);
4658
+ if (paced)
4659
+ await readUntil(channel, (text) => text.replace(/\r/g, "").includes(statement.slice(-24)), 20000, 50);
4660
+ }
4645
4661
  const loaded = await edit(channel, "\x04", 300000);
4646
4662
  const refused = loaded.split(`
4647
4663
  `).filter((line) => REFUSED2.test(line));
@@ -4706,7 +4722,7 @@ async function apply2(device, _network, plan, options) {
4706
4722
  await relogin();
4707
4723
  await run(channel, "configure exclusive");
4708
4724
  }
4709
- });
4725
+ }, true);
4710
4726
  });
4711
4727
  return;
4712
4728
  }
@@ -5410,7 +5426,7 @@ async function sshShell(device) {
5410
5426
  if (!user)
5411
5427
  throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
5412
5428
  const client = await connect({
5413
- host: device.connection.host,
5429
+ host: managementHost(device),
5414
5430
  port: device.connection.port,
5415
5431
  user: device.connection.user,
5416
5432
  password: resolve(user.password),
@@ -7336,7 +7352,7 @@ async function login3(device) {
7336
7352
  if (!user)
7337
7353
  throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
7338
7354
  return connect({
7339
- host: device.connection.host,
7355
+ host: managementHost(device),
7340
7356
  port: device.connection.port,
7341
7357
  user: device.connection.user,
7342
7358
  password: resolve(user.password)
@@ -7975,7 +7991,8 @@ function flowExport3(config, device) {
7975
7991
  const flow = device.flow_export;
7976
7992
  if (flow?.protocol !== "sflow")
7977
7993
  return;
7978
- config.line(`sflow agent ip ${device.connection.host}`);
7994
+ if (device.connection.host)
7995
+ config.line(`sflow agent ip ${device.connection.host}`);
7979
7996
  flow.collectors.forEach((collector, index) => {
7980
7997
  const number = FIRST_COLLECTOR + index;
7981
7998
  config.line(`sflow collector ${number} ip ${collector.address} udp-port ${collector.port ?? 6343}`);
@@ -8041,7 +8058,7 @@ async function login4(device) {
8041
8058
  if (!user)
8042
8059
  throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
8043
8060
  return connect({
8044
- host: device.connection.host,
8061
+ host: managementHost(device),
8045
8062
  port: device.connection.port,
8046
8063
  user: device.connection.user,
8047
8064
  password: resolve(user.password),
@@ -8347,8 +8364,9 @@ async function planLibreNms(network) {
8347
8364
  if (!response.ok)
8348
8365
  throw new Error(`LibreNMS answered ${response.status} to the device list`);
8349
8366
  const monitored = new Set((await response.json()).devices.map((device) => device.hostname));
8350
- const ours = new Set(network.devices.map((device) => device.connection.host));
8351
- const missing = network.devices.filter((device) => !monitored.has(device.connection.host));
8367
+ const polled = network.devices.filter((device) => device.connection.host);
8368
+ const ours = new Set(polled.map((device) => device.connection.host));
8369
+ const missing = polled.filter((device) => !monitored.has(device.connection.host));
8352
8370
  return {
8353
8371
  additions: missing.filter((device) => device.management?.snmp),
8354
8372
  withoutSnmp: missing.filter((device) => !device.management?.snmp),
@@ -8471,7 +8489,7 @@ var AUTH2 = { sha1: "SHA", sha256: "SHA256" };
8471
8489
  var PRIVACY2 = { aes128: "AES", des: "DES" };
8472
8490
  function prometheusTargets(network) {
8473
8491
  return {
8474
- targets: network.devices.filter((device) => device.management?.snmp).map((device) => ({
8492
+ targets: network.devices.filter((device) => device.management?.snmp && device.connection.host).map((device) => ({
8475
8493
  targets: [device.connection.host],
8476
8494
  labels: { device: device.name, platform: device.platform, model: device.model, __param_auth: device.name }
8477
8495
  })),
@@ -8828,6 +8846,8 @@ function checkDevice(device, report) {
8828
8846
  };
8829
8847
  if (!device.users?.[device.connection.user])
8830
8848
  report(`connection user '${device.connection.user}' is not one of its users`);
8849
+ if (!device.connection.host && !device.connection.console)
8850
+ report("connection has neither a host nor a console to reach the device through");
8831
8851
  checkVlans(device, report);
8832
8852
  checkAddresses2(device, report, cidr, ip);
8833
8853
  checkPorts(device, names, report);
@@ -9333,9 +9353,12 @@ function one() {
9333
9353
  const [device] = selected(false);
9334
9354
  return device ?? fail("name a device");
9335
9355
  }
9356
+ function optionsFor(device) {
9357
+ return options.console || !device.connection.host ? { ...options, console: true } : options;
9358
+ }
9336
9359
  function adapterOf(device) {
9337
9360
  const adapter = adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
9338
- if (!options.console)
9361
+ if (!optionsFor(device).console)
9339
9362
  return adapter;
9340
9363
  if (!adapter.console)
9341
9364
  fail(`${device.name}: ${device.platform} cannot be reached through a console yet`);
@@ -9409,7 +9432,7 @@ function diffCommand() {
9409
9432
  }
9410
9433
  async function snapshotCommand() {
9411
9434
  for (const device of selected(true)) {
9412
- const text = await adapterOf(device).read(device, network3.devices, options);
9435
+ const text = await adapterOf(device).read(device, network3.devices, optionsFor(device));
9413
9436
  writeState(device, text);
9414
9437
  console.log(`${device.name}: ${text.split(`
9415
9438
  `).length} lines`);
@@ -9428,7 +9451,7 @@ async function applyCommand() {
9428
9451
  const device = one();
9429
9452
  guard([device]);
9430
9453
  const adapter = adapterOf(device);
9431
- const live = await adapter.read(device, network3.devices, options);
9454
+ const live = await adapter.read(device, network3.devices, optionsFor(device));
9432
9455
  writeState(device, live);
9433
9456
  const plan = adapter.plan(adapter.render(device), live, options);
9434
9457
  if (!show(device, plan))
@@ -9441,8 +9464,8 @@ Not sent: resolve the problems first.`);
9441
9464
  Nothing sent. Re-run with --confirm.`);
9442
9465
  return;
9443
9466
  }
9444
- await adapter.apply(device, network3.devices, plan, options);
9445
- writeState(device, await adapter.read(device, network3.devices, options));
9467
+ await adapter.apply(device, network3.devices, plan, optionsFor(device));
9468
+ writeState(device, await adapter.read(device, network3.devices, optionsFor(device)));
9446
9469
  console.log("snapshot updated");
9447
9470
  }
9448
9471
  async function refreshCommand() {
@@ -9731,7 +9754,7 @@ async function studioCommand() {
9731
9754
  const port = Number(flagValue("port") ?? 4600);
9732
9755
  if (!Number.isInteger(port) || port < 1 || port > 65535)
9733
9756
  fail("--port takes a number from 1 to 65535");
9734
- const { isLoopback, studio } = await import("../chunks/server-1ftkqkx5.js");
9757
+ const { isLoopback, studio } = await import("../chunks/server-tjbb3rqc.js");
9735
9758
  const host = flagValue("host") ?? "127.0.0.1";
9736
9759
  const allowHosts = (flagValue("allow-host") ?? "").split(",").filter(Boolean);
9737
9760
  if (!isLoopback(host) && !allowHosts.length) {
@@ -32,7 +32,7 @@ export type Guard = {
32
32
  *
33
33
  * Deletions go before the statements that are set, so a path deleted and set again in one plan ends up set.
34
34
  */
35
- export declare function configure(channel: ClientChannel, plan: Plan, guard?: Guard): Promise<void>;
35
+ export declare function configure(channel: ClientChannel, plan: Plan, guard?: Guard, paced?: boolean): Promise<void>;
36
36
  export declare function apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void>;
37
37
  export declare function unsupported(device: Device): string[];
38
38
  /** The plan that replaces a prefix list's entries with what a registry returned. */
@@ -6,8 +6,8 @@ import type { Routing } from "./routing";
6
6
  import type { Certificate, DhcpRelay, DhcpServer, FlowExport, Management, System, User } from "./system";
7
7
  /** How the framework reaches the device. */
8
8
  export type Connection<U extends string> = {
9
- /** Management address. */
10
- host: IP;
9
+ /** Management address. Absent for a device reached only through its console, `console`. */
10
+ host?: IP;
11
11
  /** One of the device's `users`. Its password is the login. */
12
12
  user: U;
13
13
  /** SSH port. 22 when absent. */
@@ -1,4 +1,5 @@
1
1
  import { Client, type ClientChannel } from "ssh2";
2
+ import type { Device } from "../schema";
2
3
  export type Login = {
3
4
  host: string;
4
5
  port?: number;
@@ -7,6 +8,8 @@ export type Login = {
7
8
  /** Host key algorithms to accept, for a device that negotiates one it then signs wrongly. */
8
9
  hostKeys?: string[];
9
10
  };
11
+ /** The address SSH reaches a device at. A device without one is reached only through its console. */
12
+ export declare function managementHost(device: Device): string;
10
13
  /** Open a session. */
11
14
  export declare function connect(login: Login): Promise<Client>;
12
15
  /** Run one command and return what it printed, stderr included. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.28.0",
3
+ "version": "0.29.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",
@@ -39,3 +39,4 @@ Behaviour that looks like something else. Each one has been hit on a FortiGate 3
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
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.
42
+ - A data port can stay `netdev status: down`, with `rx_carrier: 0` in `diagnose hardware deviceinfo nic`, while the far end reads link-ok and frames reach the port's switch chip. Fixing the speed does not change it, in a LAG or out of one; a reboot does. `link_status: Up` there says nothing: a port with no cable shows it too.
@@ -7,7 +7,7 @@ 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
9
  import { type ConsoleDialect, consoleLogin, consoleOf, openConsole } from "../../../transport/console";
10
- import { connect, shell } from "../../../transport/ssh";
10
+ import { connect, managementHost, shell } from "../../../transport/ssh";
11
11
  import { portsOf, spell } from "../catalog";
12
12
  import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
13
13
  import { acceptsBeforeDrop, familiesOf, isImplied, serviceOf } from "./firewall";
@@ -141,7 +141,7 @@ export async function login(device: Device): Promise<Client> {
141
141
  if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
142
142
 
143
143
  return connect({
144
- host: device.connection.host,
144
+ host: managementHost(device),
145
145
  port: device.connection.port,
146
146
  user: device.connection.user,
147
147
  password: resolve(user.password),
@@ -29,3 +29,6 @@ Behaviour that looks like something else. Each one has been hit on a QFX5100-48S
29
29
  - IPv4 routes over an IPv6 session need a local IPv4 address under the family: `Local IPv4 address must be configured`.
30
30
  - A DHCP server and a DHCP relay cannot share an interface: `Interface irb.10 already configured`.
31
31
  - A port's MTU counts the Ethernet header. `mtu 9018` carries 9000-byte packets with a VLAN tag, and a VLAN interface's 9000 then commits without a warning.
32
+ - The serial console has no flow control. Statements pasted into `load set terminal` at once overrun it: some arrive garbled, the load reports syntax errors, and the `^D` that ends it can be lost, leaving the console in the load. Circuit sends each statement after the echo of the one before.
33
+ - After a login the console prints `Last login: ...`, which ends in `login: ` like the login prompt; only a `login: ` on a line of its own is the prompt.
34
+ - A factory-default switch runs Auto Image Upgrade, which prints on the console every few seconds until `delete chassis auto-image-upgrade` is committed.
@@ -7,7 +7,7 @@ import { familyOf, host, isAddress, split } from "../../../core/addr";
7
7
  import { resolve, substitute } from "../../../core/secrets";
8
8
  import type { Device, Family } from "../../../schema";
9
9
  import { type ConsoleDialect, consoleLogin, consoleOf, openConsole } from "../../../transport/console";
10
- import { connect, exec, readUntil, shell } from "../../../transport/ssh";
10
+ import { connect, exec, managementHost, readUntil, shell } from "../../../transport/ssh";
11
11
  import { lagIds } from "../derive";
12
12
  import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions, Step } from "../types";
13
13
  import { GENERATED, settingsOf } from "./bgp";
@@ -296,7 +296,7 @@ export async function login(device: Device): Promise<Client> {
296
296
  if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
297
297
 
298
298
  return connect({
299
- host: device.connection.host,
299
+ host: managementHost(device),
300
300
  port: device.connection.port,
301
301
  user: device.connection.user,
302
302
  password: resolve(user.password),
@@ -360,7 +360,8 @@ async function edit(channel: ClientChannel, line: string, timeout = 60_000): Pro
360
360
 
361
361
  /** How the console asks for a login. Root lands in a shell, `%`, every other user in the CLI, `>`; a session left in configuration mode, `#`, leaves it first. Not yet proved on a device. */
362
362
  export const JUNOS_CONSOLE: ConsoleDialect = {
363
- login: /login: $/,
363
+ // On a line of its own: the banner after a login says `Last login: ...`, and a chunk can end right after it.
364
+ login: /(?:^|\n)login: $/,
364
365
  password: /Password:\s*$/,
365
366
  prompt: /[>%] $/,
366
367
  logout: "exit",
@@ -434,7 +435,7 @@ export type Guard = { minutes: number; prove: () => Promise<void> };
434
435
  *
435
436
  * Deletions go before the statements that are set, so a path deleted and set again in one plan ends up set.
436
437
  */
437
- export async function configure(channel: ClientChannel, plan: Plan, guard?: Guard): Promise<void> {
438
+ export async function configure(channel: ClientChannel, plan: Plan, guard?: Guard, paced = false): Promise<void> {
438
439
  const sent = plan.steps.flatMap((step) => step.send);
439
440
 
440
441
  const entered = await run(channel, "configure exclusive");
@@ -460,7 +461,12 @@ export async function configure(channel: ClientChannel, plan: Plan, guard?: Guar
460
461
  channel.write("load set terminal\n");
461
462
  await readUntil(channel, (text) => text.includes("[Type ^D at a new line to end input]"), 20_000, 300);
462
463
 
463
- for (const line of ordered) channel.write(`${substitute(line)}\n`);
464
+ // A serial console has no flow control: statements sent at once overrun it and arrive garbled. There, each waits for the echo of the one before.
465
+ for (const line of ordered) {
466
+ const statement = substitute(line);
467
+ channel.write(`${statement}\n`);
468
+ if (paced) await readUntil(channel, (text) => text.replace(/\r/g, "").includes(statement.slice(-24)), 20_000, 50);
469
+ }
464
470
 
465
471
  const loaded = await edit(channel, "\u0004", 300_000);
466
472
  const refused = loaded.split("\n").filter((line) => REFUSED.test(line));
@@ -518,14 +524,19 @@ export async function apply(device: Device, _network: readonly Device[], plan: P
518
524
  await checkVersion((command) => run(channel, command), device);
519
525
 
520
526
  // A fresh login on the console ends the session that holds the candidate, so the proof logs in again and takes the candidate back to confirm the commit.
521
- await configure(channel, plan, {
522
- minutes: options.rollback,
523
- prove: async () => {
524
- await run(channel, "exit configuration-mode");
525
- await relogin();
526
- await run(channel, "configure exclusive");
527
+ await configure(
528
+ channel,
529
+ plan,
530
+ {
531
+ minutes: options.rollback,
532
+ prove: async () => {
533
+ await run(channel, "exit configuration-mode");
534
+ await relogin();
535
+ await run(channel, "configure exclusive");
536
+ },
527
537
  },
528
- });
538
+ true
539
+ );
529
540
  });
530
541
  return;
531
542
  }
@@ -675,7 +675,7 @@ function sflow(out: Statements, device: Device, names: Names): void {
675
675
  const flow = device.flow_export;
676
676
  if (flow?.protocol !== "sflow") return;
677
677
 
678
- out.set("protocols sflow agent-id", device.connection.host);
678
+ if (device.connection.host) out.set("protocols sflow agent-id", device.connection.host);
679
679
  if (flow.sampling !== undefined) out.set("protocols sflow sample-rate ingress", flow.sampling);
680
680
 
681
681
  for (const collector of flow.collectors) {
@@ -8,7 +8,7 @@ import { marker, resolve, substitute } from "../../../core/secrets";
8
8
  import { offset } from "../../../core/time";
9
9
  import type { Device, Port, SnmpUser, Switched, Vrrp } from "../../../schema";
10
10
  import { type ConsoleDialect, consoleLogin, consoleOf, openConsole } from "../../../transport/console";
11
- import { connect, readUntil, send, shell } from "../../../transport/ssh";
11
+ import { connect, managementHost, readUntil, send, shell } from "../../../transport/ssh";
12
12
  import { portsOf, spell } from "../catalog";
13
13
  import { ipv4Networks, lagIds, switchUnsupported } from "../derive";
14
14
  import { compare, type Dialect, entries, type Group, scrub, steps, vlanListChange } from "../lines";
@@ -338,7 +338,7 @@ async function sshShell(device: Device): Promise<Session> {
338
338
  if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
339
339
 
340
340
  const client = await connect({
341
- host: device.connection.host,
341
+ host: managementHost(device),
342
342
  port: device.connection.port,
343
343
  user: device.connection.user,
344
344
  password: resolve(user.password),
@@ -7,7 +7,7 @@ import { familyOf } from "../../../core/addr";
7
7
  import { resolve, substitute } from "../../../core/secrets";
8
8
  import type { Device, Family } from "../../../schema";
9
9
  import { type ConsoleDialect, consoleLogin, consoleOf, ConsoleShell, openConsole } from "../../../transport/console";
10
- import { connect, exec, upload } from "../../../transport/ssh";
10
+ import { connect, exec, managementHost, upload } from "../../../transport/ssh";
11
11
  import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
12
12
  import { INSTALL_CERTIFICATE, plan } from "./plan";
13
13
  import { spell } from "../catalog";
@@ -21,7 +21,7 @@ export async function login(device: Device): Promise<Client> {
21
21
  if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
22
22
 
23
23
  return connect({
24
- host: device.connection.host,
24
+ host: managementHost(device),
25
25
  port: device.connection.port,
26
26
  user: device.connection.user,
27
27
  password: resolve(user.password),
@@ -8,7 +8,7 @@ import { marker, resolve, substitute, valueOfReference } from "../../../core/sec
8
8
  import { offset } from "../../../core/time";
9
9
  import type { Device, Interface, Port, Vrrp } from "../../../schema";
10
10
  import { type ConsoleDialect, consoleLogin, consoleOf, openConsole } from "../../../transport/console";
11
- import { connect, readUntil, send, shell } from "../../../transport/ssh";
11
+ import { connect, managementHost, readUntil, send, shell } from "../../../transport/ssh";
12
12
  import { portsOf, spell } from "../catalog";
13
13
  import { ipv4Networks, lagIds, switchUnsupported } from "../derive";
14
14
  import { compare, type Dialect, scrub, steps, vlanListChange } from "../lines";
@@ -465,7 +465,7 @@ function flowExport(config: Config, device: Device): void {
465
465
  const flow = device.flow_export;
466
466
  if (flow?.protocol !== "sflow") return;
467
467
 
468
- config.line(`sflow agent ip ${device.connection.host}`);
468
+ if (device.connection.host) config.line(`sflow agent ip ${device.connection.host}`);
469
469
 
470
470
  flow.collectors.forEach((collector, index) => {
471
471
  const number = FIRST_COLLECTOR + index;
@@ -550,7 +550,7 @@ export async function login(device: Device): Promise<Client> {
550
550
  const user = device.users?.[device.connection.user];
551
551
  if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
552
552
  return connect({
553
- host: device.connection.host,
553
+ host: managementHost(device),
554
554
  port: device.connection.port,
555
555
  user: device.connection.user,
556
556
  password: resolve(user.password),
@@ -57,8 +57,10 @@ export async function planLibreNms(network: Network): Promise<LibreNmsPlan> {
57
57
  if (!response.ok) throw new Error(`LibreNMS answered ${response.status} to the device list`);
58
58
 
59
59
  const monitored = new Set(((await response.json()) as { devices: { hostname: string }[] }).devices.map((device) => device.hostname));
60
- const ours = new Set(network.devices.map((device) => device.connection.host));
61
- const missing = network.devices.filter((device) => !monitored.has(device.connection.host));
60
+ // A device reached only through its console has no address to poll.
61
+ const polled = network.devices.filter((device) => device.connection.host);
62
+ const ours = new Set(polled.map((device) => device.connection.host));
63
+ const missing = polled.filter((device) => !monitored.has(device.connection.host!));
62
64
 
63
65
  return {
64
66
  additions: missing.filter((device) => device.management?.snmp),
@@ -15,9 +15,9 @@ const PRIVACY = { aes128: "AES", des: "DES" } as const;
15
15
  export function prometheusTargets(network: Network): { targets: PrometheusTarget[]; withoutSnmp: Device[] } {
16
16
  return {
17
17
  targets: network.devices
18
- .filter((device) => device.management?.snmp)
18
+ .filter((device) => device.management?.snmp && device.connection.host)
19
19
  .map((device) => ({
20
- targets: [device.connection.host],
20
+ targets: [device.connection.host!],
21
21
  // `__param_auth` picks the device's own auth in snmp_exporter.
22
22
  labels: { device: device.name, platform: device.platform, model: device.model, __param_auth: device.name },
23
23
  })),
package/src/cli/index.ts CHANGED
@@ -133,10 +133,15 @@ function one(): Device {
133
133
  return device ?? fail("name a device");
134
134
  }
135
135
 
136
- /** The adapter of a device, which must be able to use its console when `--console` asks for it. */
136
+ /** How a device is reached: through its console when `--console` asks, or when it has no management address. */
137
+ function optionsFor(device: Device): ApplyOptions {
138
+ return options.console || !device.connection.host ? { ...options, console: true } : options;
139
+ }
140
+
141
+ /** The adapter of a device, which must be able to use its console when the device is reached through it. */
137
142
  function adapterOf(device: Device): DeviceAdapter {
138
143
  const adapter = adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
139
- if (!options.console) return adapter;
144
+ if (!optionsFor(device).console) return adapter;
140
145
 
141
146
  if (!adapter.console) fail(`${device.name}: ${device.platform} cannot be reached through a console yet`);
142
147
  if (!device.connection.console) fail(`${device.name}: --console needs connection.console`);
@@ -225,7 +230,7 @@ function diffCommand(): void {
225
230
 
226
231
  async function snapshotCommand(): Promise<void> {
227
232
  for (const device of selected(true)) {
228
- const text = await adapterOf(device).read(device, network.devices, options);
233
+ const text = await adapterOf(device).read(device, network.devices, optionsFor(device));
229
234
  writeState(device, text);
230
235
  console.log(`${device.name}: ${text.split("\n").length} lines`);
231
236
  }
@@ -248,7 +253,7 @@ async function applyCommand(): Promise<void> {
248
253
  const adapter = adapterOf(device);
249
254
 
250
255
  // The plan is made against what the device runs now, never against an old snapshot.
251
- const live = await adapter.read(device, network.devices, options);
256
+ const live = await adapter.read(device, network.devices, optionsFor(device));
252
257
  writeState(device, live);
253
258
 
254
259
  const plan = adapter.plan(adapter.render(device), live, options);
@@ -261,9 +266,9 @@ async function applyCommand(): Promise<void> {
261
266
  return;
262
267
  }
263
268
 
264
- await adapter.apply(device, network.devices, plan, options);
269
+ await adapter.apply(device, network.devices, plan, optionsFor(device));
265
270
 
266
- writeState(device, await adapter.read(device, network.devices, options));
271
+ writeState(device, await adapter.read(device, network.devices, optionsFor(device)));
267
272
  console.log("snapshot updated");
268
273
  }
269
274
 
package/src/core/graph.ts CHANGED
@@ -143,7 +143,11 @@ function describeDevice(device: Device): string[] {
143
143
  return `${name}: ${list.join(", ")}${vrf}`;
144
144
  });
145
145
 
146
- return [`${device.platform} ${device.model}`, `reached at ${device.connection.host}`, ...addresses];
146
+ return [
147
+ `${device.platform} ${device.model}`,
148
+ device.connection.host ? `reached at ${device.connection.host}` : "reached through its console",
149
+ ...addresses,
150
+ ];
147
151
  }
148
152
 
149
153
  /** The VLANs a port or LAG carries, by id. */
@@ -79,6 +79,8 @@ function checkDevice(device: Device, report: Report): void {
79
79
  };
80
80
 
81
81
  if (!device.users?.[device.connection.user]) report(`connection user '${device.connection.user}' is not one of its users`);
82
+ if (!device.connection.host && !device.connection.console)
83
+ report("connection has neither a host nor a console to reach the device through");
82
84
 
83
85
  checkVlans(device, report);
84
86
  checkAddresses(device, report, cidr, ip);
@@ -7,8 +7,8 @@ import type { Certificate, DhcpRelay, DhcpServer, FlowExport, Management, System
7
7
 
8
8
  /** How the framework reaches the device. */
9
9
  export type Connection<U extends string> = {
10
- /** Management address. */
11
- host: IP;
10
+ /** Management address. Absent for a device reached only through its console, `console`. */
11
+ host?: IP;
12
12
  /** One of the device's `users`. Its password is the login. */
13
13
  user: U;
14
14
  /** SSH port. 22 when absent. */
@@ -144,9 +144,12 @@ async function openOnJump(console: Console, jump: Jump): Promise<ConsoleSession>
144
144
 
145
145
  return {
146
146
  channel: asTerminal(channel),
147
+ // What was written last, such as a logout, is still on its way to the device; the relay ends once the console goes quiet.
147
148
  close: () => {
148
- channel.end();
149
- client.end();
149
+ void quiet(channel, 1_000).then(() => {
150
+ channel.end();
151
+ client.end();
152
+ });
150
153
  },
151
154
  };
152
155
  } catch (error) {
@@ -186,8 +189,10 @@ async function openHere(console: Console): Promise<ConsoleSession> {
186
189
  return {
187
190
  channel: asTerminal(asChannel),
188
191
  close: () => {
189
- channel.end();
190
- setTimeout(() => child.kill(), 2_000).unref();
192
+ void quiet(asChannel, 1_000).then(() => {
193
+ channel.end();
194
+ setTimeout(() => child.kill(), 2_000).unref();
195
+ });
191
196
  },
192
197
  };
193
198
  }
@@ -301,12 +306,18 @@ export async function consoleLogin(channel: Channel, dialect: ConsoleDialect, us
301
306
  return `${dialect.logout}\n`;
302
307
  };
303
308
 
309
+ // What the last session printed as it left, such as a login prompt after its logout, arrives late; it must not be taken for an answer.
310
+ await quiet(channel, 1_000);
311
+
304
312
  let state = (await expect(channel, "\n", states)).index;
305
313
  for (let step = 0; state !== 0; step++) {
306
314
  if (step >= 10) throw new Error("the console never reached a login prompt");
307
315
  state = (await expect(channel, toward(state), states)).index;
308
316
  }
309
317
 
318
+ // A console can print the login prompt more than once; a name typed while it does is lost.
319
+ await quiet(channel, 1_000);
320
+
310
321
  const name = dialect.user ? dialect.user(user) : user;
311
322
  if ((await expect(channel, `${name}\n`, [dialect.password, dialect.login, dialect.prompt])).index !== 0) {
312
323
  throw new Error("the console did not ask for a password");
@@ -1,6 +1,7 @@
1
1
  // SSH sessions to devices. Adapters choose how to talk to their platform; this holds what they share.
2
2
 
3
3
  import { Client, type ClientChannel } from "ssh2";
4
+ import type { Device } from "../schema";
4
5
 
5
6
  export type Login = {
6
7
  host: string;
@@ -11,6 +12,12 @@ export type Login = {
11
12
  hostKeys?: string[];
12
13
  };
13
14
 
15
+ /** The address SSH reaches a device at. A device without one is reached only through its console. */
16
+ export function managementHost(device: Device): string {
17
+ if (!device.connection.host) throw new Error(`${device.name} has no management address; it is reached through its console`);
18
+ return device.connection.host;
19
+ }
20
+
14
21
  /** Open a session. */
15
22
  export function connect(login: Login): Promise<Client> {
16
23
  return new Promise((resolve, reject) => {