@takodotid/circuit 0.26.2 → 0.28.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 +694 -90
- package/dist/types/adapters/devices/fortios/index.d.ts +5 -2
- package/dist/types/adapters/devices/fortios/plan.d.ts +2 -0
- package/dist/types/adapters/devices/junos/index.d.ts +5 -2
- package/dist/types/adapters/devices/raisecom-ros/index.d.ts +10 -5
- package/dist/types/adapters/devices/routeros/index.d.ts +16 -3
- package/dist/types/adapters/devices/types.d.ts +7 -1
- package/dist/types/adapters/devices/vrp/index.d.ts +6 -3
- package/dist/types/schema/device.d.ts +25 -1
- package/dist/types/transport/console.d.ts +57 -0
- package/package.json +1 -1
- package/src/adapters/devices/fortios/TRAPS.md +2 -1
- package/src/adapters/devices/fortios/index.ts +69 -13
- package/src/adapters/devices/fortios/plan.ts +40 -1
- package/src/adapters/devices/junos/index.ts +68 -7
- package/src/adapters/devices/raisecom-ros/index.ts +72 -16
- package/src/adapters/devices/routeros/TRAPS.md +1 -0
- package/src/adapters/devices/routeros/index.ts +105 -35
- package/src/adapters/devices/types.ts +8 -1
- package/src/adapters/devices/vrp/index.ts +91 -26
- package/src/cli/index.ts +14 -4
- package/src/schema/device.ts +27 -1
- package/src/transport/console.ts +409 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Client } from "ssh2";
|
|
2
2
|
import type { Device, Family } from "../../../schema";
|
|
3
|
-
import
|
|
3
|
+
import { type ConsoleDialect } from "../../../transport/console";
|
|
4
|
+
import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
|
|
4
5
|
/** What a session reads and writes: an SSH shell, or anything else that streams the CLI. */
|
|
5
6
|
export type Channel = {
|
|
6
7
|
write(data: string): unknown;
|
|
@@ -26,13 +27,15 @@ export declare class Cli {
|
|
|
26
27
|
private collect;
|
|
27
28
|
}
|
|
28
29
|
export declare function login(device: Device): Promise<Client>;
|
|
30
|
+
/** How the FortiOS console asks for a login, and what moves it on from where the last session left it. */
|
|
31
|
+
export declare const FORTIOS_CONSOLE: ConsoleDialect;
|
|
29
32
|
/** Refuse a device on a release this adapter was not proved on, before anything is read or sent. */
|
|
30
33
|
export declare function checkVersion(cli: Cli, device: Device): Promise<void>;
|
|
31
34
|
/** 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
35
|
export declare function scrub(text: string): string;
|
|
33
36
|
/** Every section Circuit manages, as the device holds it, credentials scrubbed. */
|
|
34
37
|
export declare function readConfig(cli: Cli, device: Device): Promise<string>;
|
|
35
|
-
export declare function read(device: Device): Promise<string>;
|
|
38
|
+
export declare function read(device: Device, _network?: readonly Device[], options?: ReadOptions): Promise<string>;
|
|
36
39
|
/**
|
|
37
40
|
* 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
41
|
*/
|
|
@@ -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>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Client, ClientChannel } from "ssh2";
|
|
2
2
|
import type { Device, Family } from "../../../schema";
|
|
3
|
-
import
|
|
3
|
+
import { type ConsoleDialect } from "../../../transport/console";
|
|
4
|
+
import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
|
|
4
5
|
/** A statement's words; a quoted value is one word with its quotes. */
|
|
5
6
|
export declare function wordsOf(statement: string): string[];
|
|
6
7
|
/** The device prints a password as a hash, an SNMP key as a reversible cipher, and a community as itself. None leave the read. */
|
|
@@ -16,7 +17,9 @@ export declare function clean(output: string): string;
|
|
|
16
17
|
*/
|
|
17
18
|
export declare function plan(desired: string, current: string, options: Pick<ApplyOptions, "secrets">): Plan;
|
|
18
19
|
export declare function login(device: Device): Promise<Client>;
|
|
19
|
-
export declare function read(device: Device): Promise<string>;
|
|
20
|
+
export declare function read(device: Device, _network?: readonly Device[], options?: ReadOptions): Promise<string>;
|
|
21
|
+
/** 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. */
|
|
22
|
+
export declare const JUNOS_CONSOLE: ConsoleDialect;
|
|
20
23
|
/** An interactive CLI with paging off. Root lands in a shell and starts the CLI from there. */
|
|
21
24
|
export declare function openCli(channel: ClientChannel): Promise<void>;
|
|
22
25
|
/** How a commit that could cut access is guarded: it rolls back after `minutes` unless `prove` shows access survived. */
|
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
import type { ClientChannel } from "ssh2";
|
|
2
2
|
import type { Device } from "../../../schema";
|
|
3
|
+
import { type ConsoleDialect } from "../../../transport/console";
|
|
3
4
|
import { type Dialect } from "../lines";
|
|
4
|
-
import type { DeviceAdapter, Plan } from "../types";
|
|
5
|
+
import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
|
|
5
6
|
export declare const dialect: Dialect;
|
|
6
7
|
/** The access-list that guards every management service. */
|
|
7
8
|
export declare const MANAGEMENT_ACL = 1000;
|
|
8
9
|
/** `99,200-201,999`, the platform's compression of a VLAN list. */
|
|
9
10
|
export declare function vlanList(ids: number[]): string;
|
|
10
11
|
export declare function render(device: Device): string;
|
|
12
|
+
/** A session, and how to prove access in a fresh login: a new SSH session, or logging out and in again on the console. */
|
|
11
13
|
export type Session = {
|
|
12
14
|
channel: ClientChannel;
|
|
13
15
|
close: () => void;
|
|
16
|
+
fresh: () => Promise<Session>;
|
|
14
17
|
};
|
|
15
|
-
/**
|
|
16
|
-
export declare
|
|
18
|
+
/** How the console asks for a login. Not yet proved on a device: the RAX721's console was never cabled to a jump host. */
|
|
19
|
+
export declare const RAISECOM_CONSOLE: ConsoleDialect;
|
|
20
|
+
/** An interactive shell, paging off, over SSH or on the console. A session that does not answer as ROS is refused, so nothing meant for it can reach another shell. */
|
|
21
|
+
export declare function open(device: Device, options?: ReadOptions): Promise<Session>;
|
|
17
22
|
/** What the device runs, credentials scrubbed. */
|
|
18
|
-
export declare function read(device: Device): Promise<string>;
|
|
23
|
+
export declare function read(device: Device, _network?: readonly Device[], options?: ReadOptions): Promise<string>;
|
|
19
24
|
export declare function unsupported(device: Device): string[];
|
|
20
|
-
export declare function apply(device: Device, _network: readonly Device[], plan: Plan): Promise<void>;
|
|
25
|
+
export declare function apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void>;
|
|
21
26
|
/** The VLAN IDs of a `create vlan 10,20-30 active` line. */
|
|
22
27
|
export declare function createdIds(line: string): number[];
|
|
23
28
|
export declare const raisecom: DeviceAdapter;
|
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
import type { Client } from "ssh2";
|
|
2
2
|
import type { Device, Family } from "../../../schema";
|
|
3
|
-
import
|
|
3
|
+
import { type ConsoleDialect } from "../../../transport/console";
|
|
4
|
+
import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
|
|
4
5
|
/** The major version this adapter renders for. Version 6 has a different BGP and filter language altogether. */
|
|
5
6
|
export declare const SUPPORTED_MAJOR = 7;
|
|
6
7
|
export declare function login(device: Device): Promise<Client>;
|
|
8
|
+
/** How the RouterOS console asks for a login. The flags after the user turn off colours and terminal detection, without which the console redraws every line. */
|
|
9
|
+
export declare const ROUTEROS_CONSOLE: ConsoleDialect;
|
|
10
|
+
/** Where commands run, one at a time, each returning what it printed: an SSH session, or the console. */
|
|
11
|
+
type Session = {
|
|
12
|
+
run(command: string, timeout?: number): Promise<string>;
|
|
13
|
+
/** Write a file to the device. Only SSH can. */
|
|
14
|
+
upload?(path: string, content: string): Promise<void>;
|
|
15
|
+
/** A fresh login, which proves access survived: this session ends and a new SSH session opens, or the console logs out and in again. */
|
|
16
|
+
fresh(): Promise<Session>;
|
|
17
|
+
end(): void;
|
|
18
|
+
};
|
|
7
19
|
/** Refuse a device on a version this adapter does not render for, before anything is read or sent. */
|
|
8
|
-
export declare function checkVersion(
|
|
20
|
+
export declare function checkVersion(session: Session, device: Device): Promise<void>;
|
|
9
21
|
/**
|
|
10
22
|
* What the device runs: its export, its users, and its certificates. Secrets are not part of an export.
|
|
11
23
|
*/
|
|
12
|
-
export declare function read(device: Device): Promise<string>;
|
|
24
|
+
export declare function read(device: Device, _network?: readonly Device[], options?: ReadOptions): Promise<string>;
|
|
13
25
|
export declare function unsupported(device: Device): string[];
|
|
14
26
|
export declare function apply(device: Device, _network: readonly Device[], changes: Plan, options: ApplyOptions): Promise<void>;
|
|
15
27
|
/** Replace a list's entries in batches, so each command stays a sane length. */
|
|
16
28
|
export declare function fill(device: Device, _network: readonly Device[], set: string, family: Family, prefixes: string[]): Promise<void>;
|
|
17
29
|
export declare const routeros: DeviceAdapter;
|
|
30
|
+
export {};
|
|
@@ -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,7 +1,8 @@
|
|
|
1
1
|
import type { Client, ClientChannel } from "ssh2";
|
|
2
2
|
import type { Device } from "../../../schema";
|
|
3
|
+
import { type ConsoleDialect } from "../../../transport/console";
|
|
3
4
|
import { type Dialect } from "../lines";
|
|
4
|
-
import type { DeviceAdapter, Plan } from "../types";
|
|
5
|
+
import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
|
|
5
6
|
export declare const dialect: Dialect;
|
|
6
7
|
/** The ACL that guards every management service. */
|
|
7
8
|
export declare const MANAGEMENT_ACL = 2000;
|
|
@@ -17,8 +18,10 @@ export declare function login(device: Device): Promise<Client>;
|
|
|
17
18
|
* Every login asks whether to change the password. Answering yes replaces it at once and every stored credential with it, so the answer is always no. Anything sent before the question is answered is taken as the answer.
|
|
18
19
|
*/
|
|
19
20
|
export declare function openShell(client: Client): Promise<ClientChannel>;
|
|
21
|
+
/** How the console asks for a login: the same question to change the password, always answered no, and a system view left with `return`. Not yet proved on a device. */
|
|
22
|
+
export declare const VRP_CONSOLE: ConsoleDialect;
|
|
20
23
|
/** What the device runs, credentials scrubbed. */
|
|
21
|
-
export declare function read(device: Device): Promise<string>;
|
|
24
|
+
export declare function read(device: Device, _network?: readonly Device[], options?: ReadOptions): Promise<string>;
|
|
22
25
|
export declare function unsupported(device: Device): string[];
|
|
23
|
-
export declare function apply(device: Device, _network: readonly Device[], plan: Plan): Promise<void>;
|
|
26
|
+
export declare function apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void>;
|
|
24
27
|
export declare const vrp: DeviceAdapter;
|
|
@@ -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, on a jump host or on the computer Circuit runs on. */
|
|
32
|
+
export type Console = {
|
|
33
|
+
/** The jump host the console cable is plugged into. When absent, the cable is plugged into the computer Circuit runs on. */
|
|
34
|
+
jump?: Jump;
|
|
35
|
+
/** The serial port: on Linux or macOS a path under `/dev`, where a path under `/dev/serial/by-path/` stays the same when two adapters swap names; on Windows a COM port, such as `COM3`. */
|
|
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,57 @@
|
|
|
1
|
+
import { type ClientChannel } from "ssh2";
|
|
2
|
+
import type { Console, Device } 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 or on this computer. Fails when another session holds the port. */
|
|
9
|
+
export declare function openConsole(console: Console): Promise<ConsoleSession>;
|
|
10
|
+
/** A device's console and its login, for `--console`. */
|
|
11
|
+
export declare function consoleOf(device: Device): {
|
|
12
|
+
console: Console;
|
|
13
|
+
user: string;
|
|
14
|
+
password: string;
|
|
15
|
+
};
|
|
16
|
+
/** How a platform's console asks for a login and shows its prompt, and what moves it on from anywhere else. */
|
|
17
|
+
export type ConsoleDialect = {
|
|
18
|
+
/** The login prompt. */
|
|
19
|
+
login: RegExp;
|
|
20
|
+
/** The password prompt. */
|
|
21
|
+
password: RegExp;
|
|
22
|
+
/** The CLI's prompt once logged in. */
|
|
23
|
+
prompt: RegExp;
|
|
24
|
+
/** What logs out from the prompt. */
|
|
25
|
+
logout: string;
|
|
26
|
+
/** Other places a console can be left, each with what moves it toward a login prompt: a pager, a config level, a question. Checked before the prompt. */
|
|
27
|
+
others?: readonly (readonly [RegExp, string])[];
|
|
28
|
+
/** The name typed at the login prompt, when the platform adds to it. */
|
|
29
|
+
user?: (user: string) => string;
|
|
30
|
+
};
|
|
31
|
+
/** The channel a console speaks on: an SSH stream from the relay, or anything shaped like one. */
|
|
32
|
+
type Channel = Pick<ClientChannel, "write"> & {
|
|
33
|
+
on(event: "data", listener: (chunk: Buffer) => void): unknown;
|
|
34
|
+
removeListener(event: "data", listener: (chunk: Buffer) => void): unknown;
|
|
35
|
+
};
|
|
36
|
+
/** Write, then wait until what the console printed since ends with one of the patterns. Returns which, and what was printed. */
|
|
37
|
+
export declare function expect(channel: Channel, write: string, patterns: readonly RegExp[], timeout?: number): Promise<{
|
|
38
|
+
index: number;
|
|
39
|
+
text: string;
|
|
40
|
+
}>;
|
|
41
|
+
/**
|
|
42
|
+
* Log in on a console, whatever state the last session left it in: what `others` names is moved on, a session left open is logged out, so every session starts from a fresh login. Returns once the CLI's prompt shows.
|
|
43
|
+
*/
|
|
44
|
+
export declare function consoleLogin(channel: Channel, dialect: ConsoleDialect, user: string, password: string): Promise<void>;
|
|
45
|
+
/** The text after a command's echo. A terminal wraps a long echo where it likes, so the echo is matched character by character, line breaks it added skipped. */
|
|
46
|
+
export declare function afterEcho(text: string, command: string): string;
|
|
47
|
+
/** A CLI on a console that runs one command at a time and returns what it printed, as an SSH exec would: no echo, no prompt. */
|
|
48
|
+
export declare class ConsoleShell {
|
|
49
|
+
private readonly channel;
|
|
50
|
+
private readonly dialect;
|
|
51
|
+
constructor(channel: Channel, dialect: ConsoleDialect);
|
|
52
|
+
/** Run a command and wait for the prompt. A pager in `others` is answered as it shows. */
|
|
53
|
+
run(command: string, timeout?: number): Promise<string>;
|
|
54
|
+
/** Log out, so no session is left open on the console. */
|
|
55
|
+
logout(): void;
|
|
56
|
+
}
|
|
57
|
+
export {};
|
package/package.json
CHANGED
|
@@ -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
|
|
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 { type ConsoleDialect, consoleLogin, consoleOf, 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,47 @@ export async function login(device: Device): Promise<Client> {
|
|
|
147
148
|
});
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
/**
|
|
151
|
-
|
|
151
|
+
/** How the FortiOS console asks for a login, and what moves it on from where the last session left it. */
|
|
152
|
+
export const FORTIOS_CONSOLE: ConsoleDialect = {
|
|
153
|
+
login: /login: $/,
|
|
154
|
+
password: /Password: $/,
|
|
155
|
+
prompt: /[#$] $/,
|
|
156
|
+
logout: "exit",
|
|
157
|
+
others: [
|
|
158
|
+
[BANNER, "a"],
|
|
159
|
+
[/--More-- $/, "q"],
|
|
160
|
+
[/\([^)\n]*\) [#$] $/, "end\n"],
|
|
161
|
+
],
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Log out of a console, so no session is left open on it. */
|
|
165
|
+
async function consoleLogout(cli: Cli): Promise<void> {
|
|
166
|
+
await leave(cli).catch(() => undefined);
|
|
167
|
+
await cli.run("exit", 10_000).catch(() => undefined);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 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. */
|
|
171
|
+
async function consoleSession<T>(device: Device, work: (cli: Cli, relogin: () => Promise<void>) => Promise<T>): Promise<T> {
|
|
172
|
+
const { console, user, password } = consoleOf(device);
|
|
173
|
+
const opened = await openConsole(console);
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
await consoleLogin(opened.channel, FORTIOS_CONSOLE, user, password);
|
|
177
|
+
const cli = new Cli(opened.channel);
|
|
178
|
+
const relogin = () => consoleLogin(opened.channel, FORTIOS_CONSOLE, user, password);
|
|
179
|
+
|
|
180
|
+
const result = await work(cli, relogin);
|
|
181
|
+
await consoleLogout(cli);
|
|
182
|
+
return result;
|
|
183
|
+
} finally {
|
|
184
|
+
opened.close();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Log in, open the CLI, and run `work` in it: through the console when asked, else over SSH. */
|
|
189
|
+
async function session<T>(device: Device, work: (cli: Cli) => Promise<T>, options: ReadOptions = {}): Promise<T> {
|
|
190
|
+
if (options.console) return consoleSession(device, (cli) => work(cli));
|
|
191
|
+
|
|
152
192
|
const client = await login(device);
|
|
153
193
|
|
|
154
194
|
try {
|
|
@@ -216,8 +256,8 @@ export async function readConfig(cli: Cli, device: Device): Promise<string> {
|
|
|
216
256
|
return scrub(clean(text));
|
|
217
257
|
}
|
|
218
258
|
|
|
219
|
-
export function read(device: Device): Promise<string> {
|
|
220
|
-
return session(device, (cli) => readConfig(cli, device));
|
|
259
|
+
export function read(device: Device, _network: readonly Device[] = [], options: ReadOptions = {}): Promise<string> {
|
|
260
|
+
return session(device, (cli) => readConfig(cli, device), options);
|
|
221
261
|
}
|
|
222
262
|
|
|
223
263
|
/**
|
|
@@ -317,15 +357,19 @@ const RELOAD = { attempts: 20, interval: 15_000 } as const;
|
|
|
317
357
|
/**
|
|
318
358
|
* 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
359
|
*/
|
|
320
|
-
async function settle(device: Device): Promise<string> {
|
|
360
|
+
async function settle(device: Device, options: ReadOptions): Promise<string> {
|
|
321
361
|
for (let attempt = 0; attempt < RELOAD.attempts; attempt++) {
|
|
322
362
|
await new Promise((done) => setTimeout(done, RELOAD.interval));
|
|
323
363
|
|
|
324
|
-
const settled = await session(
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
364
|
+
const settled = await session(
|
|
365
|
+
device,
|
|
366
|
+
async (cli) => {
|
|
367
|
+
if (!/no config to be reloaded/.test(await cli.run("execute cfg reload"))) return false;
|
|
368
|
+
await saving(cli, "automatic");
|
|
369
|
+
return true;
|
|
370
|
+
},
|
|
371
|
+
options
|
|
372
|
+
).catch(() => false);
|
|
329
373
|
if (settled) return "the device is back and saves changes on its own again";
|
|
330
374
|
}
|
|
331
375
|
|
|
@@ -333,12 +377,23 @@ async function settle(device: Device): Promise<string> {
|
|
|
333
377
|
}
|
|
334
378
|
|
|
335
379
|
export async function apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void> {
|
|
380
|
+
const seconds = options.rollback * 60;
|
|
381
|
+
|
|
336
382
|
try {
|
|
383
|
+
// 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.
|
|
384
|
+
if (options.console) {
|
|
385
|
+
await consoleSession(device, async (cli, relogin) => {
|
|
386
|
+
await checkVersion(cli, device);
|
|
387
|
+
await configure(cli, plan, { seconds, prove: relogin });
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
337
392
|
await session(device, async (cli) => {
|
|
338
393
|
await checkVersion(cli, device);
|
|
339
394
|
|
|
340
395
|
await configure(cli, plan, {
|
|
341
|
-
seconds
|
|
396
|
+
seconds,
|
|
342
397
|
prove: async () => {
|
|
343
398
|
const proof = await login(device);
|
|
344
399
|
proof.end();
|
|
@@ -347,7 +402,7 @@ export async function apply(device: Device, _network: readonly Device[], plan: P
|
|
|
347
402
|
});
|
|
348
403
|
} catch (error) {
|
|
349
404
|
if (!(error instanceof Reloaded)) throw error;
|
|
350
|
-
throw new Error(`${error.message}\n ${await settle(device)}`);
|
|
405
|
+
throw new Error(`${error.message}\n ${await settle(device, options)}`);
|
|
351
406
|
}
|
|
352
407
|
}
|
|
353
408
|
|
|
@@ -791,6 +846,7 @@ function serviceGaps(device: Device): string[] {
|
|
|
791
846
|
|
|
792
847
|
export const fortios: DeviceAdapter = {
|
|
793
848
|
extension: ".conf",
|
|
849
|
+
console: true,
|
|
794
850
|
unsupported,
|
|
795
851
|
render,
|
|
796
852
|
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
|
|
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()];
|
|
@@ -6,9 +6,10 @@ import type { Client, ClientChannel } from "ssh2";
|
|
|
6
6
|
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
|
+
import { type ConsoleDialect, consoleLogin, consoleOf, openConsole } from "../../../transport/console";
|
|
9
10
|
import { connect, exec, readUntil, shell } from "../../../transport/ssh";
|
|
10
11
|
import { lagIds } from "../derive";
|
|
11
|
-
import type { ApplyOptions, DeviceAdapter, Plan, Step } from "../types";
|
|
12
|
+
import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions, Step } from "../types";
|
|
12
13
|
import { GENERATED, settingsOf } from "./bgp";
|
|
13
14
|
import { KEY_STATEMENT, Names, render, stormProfile, vrrpAddress } from "./render";
|
|
14
15
|
|
|
@@ -303,8 +304,8 @@ export async function login(device: Device): Promise<Client> {
|
|
|
303
304
|
}
|
|
304
305
|
|
|
305
306
|
/** Refuse a device on a release this adapter was not proved on, before anything is read or sent. */
|
|
306
|
-
async function checkVersion(
|
|
307
|
-
const version = await
|
|
307
|
+
async function checkVersion(ask: (command: string) => Promise<string>, device: Device): Promise<void> {
|
|
308
|
+
const version = await ask("show version | match Junos:");
|
|
308
309
|
const release = /Junos: (\d+)\.(\S+)/.exec(version);
|
|
309
310
|
|
|
310
311
|
if (!release) throw new Error(`${device.name} did not say which Junos it runs: ${version.slice(0, 200)}`);
|
|
@@ -313,11 +314,21 @@ async function checkVersion(client: Client, device: Device): Promise<void> {
|
|
|
313
314
|
}
|
|
314
315
|
}
|
|
315
316
|
|
|
316
|
-
export async function read(device: Device): Promise<string> {
|
|
317
|
+
export async function read(device: Device, _network: readonly Device[] = [], options: ReadOptions = {}): Promise<string> {
|
|
318
|
+
if (options.console) {
|
|
319
|
+
return onConsole(device, async (channel) => {
|
|
320
|
+
await checkVersion((command) => run(channel, command), device);
|
|
321
|
+
const output = await run(channel, "show configuration | display set | no-more", 180_000);
|
|
322
|
+
if (!/^set /m.test(output)) throw new Error(`${device.name} returned no configuration`);
|
|
323
|
+
|
|
324
|
+
return clean(output);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
317
328
|
const client = await login(device);
|
|
318
329
|
|
|
319
330
|
try {
|
|
320
|
-
await checkVersion(client, device);
|
|
331
|
+
await checkVersion((command) => exec(client, command), device);
|
|
321
332
|
const output = await exec(client, "show configuration | display set | no-more", 180_000);
|
|
322
333
|
if (!/^set /m.test(output)) throw new Error(`${device.name} returned no configuration`);
|
|
323
334
|
|
|
@@ -347,6 +358,38 @@ async function edit(channel: ClientChannel, line: string, timeout = 60_000): Pro
|
|
|
347
358
|
return output.replace(/\r/g, "");
|
|
348
359
|
}
|
|
349
360
|
|
|
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
|
+
export const JUNOS_CONSOLE: ConsoleDialect = {
|
|
363
|
+
login: /login: $/,
|
|
364
|
+
password: /Password:\s*$/,
|
|
365
|
+
prompt: /[>%] $/,
|
|
366
|
+
logout: "exit",
|
|
367
|
+
others: [[/# $/, "exit configuration-mode\n"]],
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/** Log in on the console, open the CLI, and run `work` in it with a way to log in afresh. Logs out after. */
|
|
371
|
+
async function onConsole<T>(device: Device, work: (channel: ClientChannel, relogin: () => Promise<void>) => Promise<T>): Promise<T> {
|
|
372
|
+
const { console, user, password } = consoleOf(device);
|
|
373
|
+
const opened = await openConsole(console);
|
|
374
|
+
|
|
375
|
+
// The CLI is opened on a prompt asked for after the login, since the login's own prompt is already read.
|
|
376
|
+
const login = async () => {
|
|
377
|
+
await consoleLogin(opened.channel, JUNOS_CONSOLE, user, password);
|
|
378
|
+
const ready = openCli(opened.channel);
|
|
379
|
+
opened.channel.write("\n");
|
|
380
|
+
await ready;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
await login();
|
|
385
|
+
const result = await work(opened.channel, login);
|
|
386
|
+
opened.channel.write(`${JUNOS_CONSOLE.logout}\n`);
|
|
387
|
+
return result;
|
|
388
|
+
} finally {
|
|
389
|
+
opened.close();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
350
393
|
/** An interactive CLI with paging off. Root lands in a shell and starts the CLI from there. */
|
|
351
394
|
export async function openCli(channel: ClientChannel): Promise<void> {
|
|
352
395
|
const banner = await readUntil(channel, (text) => PROMPT.test(text.replace(/\r/g, "")), 20_000, 500);
|
|
@@ -470,10 +513,27 @@ export async function configure(channel: ClientChannel, plan: Plan, guard?: Guar
|
|
|
470
513
|
}
|
|
471
514
|
|
|
472
515
|
export async function apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void> {
|
|
516
|
+
if (options.console) {
|
|
517
|
+
await onConsole(device, async (channel, relogin) => {
|
|
518
|
+
await checkVersion((command) => run(channel, command), device);
|
|
519
|
+
|
|
520
|
+
// 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
|
+
},
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
473
533
|
const client = await login(device);
|
|
474
534
|
|
|
475
535
|
try {
|
|
476
|
-
await checkVersion(client, device);
|
|
536
|
+
await checkVersion((command) => exec(client, command), device);
|
|
477
537
|
|
|
478
538
|
const channel = await shell(client);
|
|
479
539
|
await openCli(channel);
|
|
@@ -760,7 +820,7 @@ export async function fill(device: Device, _network: readonly Device[], set: str
|
|
|
760
820
|
const client = await login(device);
|
|
761
821
|
|
|
762
822
|
try {
|
|
763
|
-
await checkVersion(client, device);
|
|
823
|
+
await checkVersion((command) => exec(client, command), device);
|
|
764
824
|
|
|
765
825
|
const channel = await shell(client);
|
|
766
826
|
await openCli(channel);
|
|
@@ -775,6 +835,7 @@ export async function fill(device: Device, _network: readonly Device[], set: str
|
|
|
775
835
|
|
|
776
836
|
export const junos: DeviceAdapter = {
|
|
777
837
|
extension: ".conf",
|
|
838
|
+
console: true,
|
|
778
839
|
unsupported,
|
|
779
840
|
render,
|
|
780
841
|
read,
|