@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.
- package/dist/chunks/{index-22sbskdx.js → index-47debt16.js} +5 -1
- package/dist/chunks/{server-1ftkqkx5.js → server-tjbb3rqc.js} +1 -1
- package/dist/cli/index.js +49 -26
- package/dist/types/adapters/devices/junos/index.d.ts +1 -1
- package/dist/types/schema/device.d.ts +2 -2
- package/dist/types/transport/ssh.d.ts +3 -0
- package/package.json +1 -1
- package/src/adapters/devices/fortios/TRAPS.md +1 -0
- package/src/adapters/devices/fortios/index.ts +2 -2
- package/src/adapters/devices/junos/TRAPS.md +3 -0
- package/src/adapters/devices/junos/index.ts +23 -12
- package/src/adapters/devices/junos/render.ts +1 -1
- package/src/adapters/devices/raisecom-ros/index.ts +2 -2
- package/src/adapters/devices/routeros/index.ts +2 -2
- package/src/adapters/devices/vrp/index.ts +3 -3
- package/src/adapters/registries/librenms.ts +4 -2
- package/src/adapters/registries/prometheus.ts +2 -2
- package/src/cli/index.ts +11 -6
- package/src/core/graph.ts +5 -1
- package/src/core/validate.ts +2 -0
- package/src/schema/device.ts +2 -2
- package/src/transport/console.ts +15 -4
- package/src/transport/ssh.ts +7 -0
|
@@ -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 [
|
|
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) => {
|
package/dist/cli/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
addressesOf,
|
|
12
12
|
freeOfLength,
|
|
13
13
|
studioData
|
|
14
|
-
} from "../chunks/index-
|
|
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.
|
|
328
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
8351
|
-
const
|
|
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 (!
|
|
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,
|
|
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,
|
|
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,
|
|
9445
|
-
writeState(device, await adapter.read(device, network3.devices,
|
|
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-
|
|
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
|
|
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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
61
|
-
const
|
|
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
|
-
/**
|
|
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 (!
|
|
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,
|
|
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,
|
|
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,
|
|
269
|
+
await adapter.apply(device, network.devices, plan, optionsFor(device));
|
|
265
270
|
|
|
266
|
-
writeState(device, await adapter.read(device, network.devices,
|
|
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 [
|
|
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. */
|
package/src/core/validate.ts
CHANGED
|
@@ -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);
|
package/src/schema/device.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/src/transport/console.ts
CHANGED
|
@@ -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.
|
|
149
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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");
|
package/src/transport/ssh.ts
CHANGED
|
@@ -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) => {
|