@takodotid/circuit 0.34.3 → 0.36.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/README.md +14 -15
- package/dist/chunks/index-20kcjxjg.js +174 -0
- package/dist/chunks/index-2r4bbhac.js +977 -0
- package/dist/chunks/index-n7w3tg5y.js +149 -0
- package/dist/chunks/new-w3zys5y4.js +172 -0
- package/dist/chunks/server-gzsf5me5.js +259 -0
- package/dist/cli/index.js +11345 -9024
- package/dist/helpers/index.js +594 -0
- package/dist/index.js +9 -28
- package/dist/types/core/{addresses.d.ts → address-space.d.ts} +23 -7
- package/dist/types/core/address.d.ts +44 -0
- package/dist/types/core/cabling.d.ts +41 -0
- package/dist/types/core/cross-checks.d.ts +28 -0
- package/dist/types/core/define.d.ts +25 -3
- package/dist/types/core/project-files.d.ts +12 -0
- package/dist/types/core/schema-docs.d.ts +13 -4
- package/dist/types/core/secrets.d.ts +62 -21
- package/dist/types/core/{graph.d.ts → topology.d.ts} +32 -12
- package/dist/types/core/utils.d.ts +8 -0
- package/dist/types/core/validator.d.ts +19 -0
- package/dist/types/helpers/bgp.d.ts +97 -0
- package/dist/types/helpers/checks.d.ts +32 -0
- package/dist/types/helpers/exchange.d.ts +49 -0
- package/dist/types/helpers/filters.d.ts +51 -0
- package/dist/types/helpers/index.d.ts +10 -0
- package/dist/types/helpers/route-server.d.ts +52 -0
- package/dist/types/index.d.ts +5 -3
- package/dist/types/integrations/librenms.d.ts +60 -0
- package/dist/types/integrations/netbox.d.ts +89 -0
- package/dist/types/integrations/peeringdb.d.ts +24 -0
- package/dist/types/integrations/prometheus.d.ts +24 -0
- package/dist/types/integrations/registries.d.ts +8 -0
- package/dist/types/platforms/bgp-wait.d.ts +18 -0
- package/dist/types/platforms/catalog.d.ts +109 -0
- package/dist/types/platforms/derive.d.ts +17 -0
- package/dist/types/platforms/fortios/firewall.d.ts +45 -0
- package/dist/types/platforms/fortios/index.d.ts +134 -0
- package/dist/types/platforms/fortios/names.d.ts +52 -0
- package/dist/types/platforms/fortios/plan.d.ts +104 -0
- package/dist/types/platforms/fortios/render.d.ts +75 -0
- package/dist/types/platforms/fortios/routing.d.ts +56 -0
- package/dist/types/platforms/fortios/tree.d.ts +37 -0
- package/dist/types/platforms/index.d.ts +13 -0
- package/dist/types/platforms/junos/bgp.d.ts +44 -0
- package/dist/types/platforms/junos/index.d.ts +114 -0
- package/dist/types/platforms/junos/render.d.ts +154 -0
- package/dist/types/platforms/line-diff.d.ts +67 -0
- package/dist/types/platforms/platform.d.ts +120 -0
- package/dist/types/platforms/raisecom-ros/index.d.ts +71 -0
- package/dist/types/platforms/raisecom-ros/render.d.ts +41 -0
- package/dist/types/platforms/routeros/export.d.ts +59 -0
- package/dist/types/platforms/routeros/index.d.ts +96 -0
- package/dist/types/{adapters/devices → platforms}/routeros/models.d.ts +4 -0
- package/dist/types/platforms/routeros/plan.d.ts +54 -0
- package/dist/types/platforms/routeros/render.d.ts +240 -0
- package/dist/types/platforms/vrp/index.d.ts +83 -0
- package/dist/types/platforms/vrp/render.d.ts +96 -0
- package/dist/types/schema/common.d.ts +12 -6
- package/dist/types/schema/index.d.ts +1 -0
- package/dist/types/schema/interfaces.d.ts +14 -6
- package/dist/types/schema/policy.d.ts +18 -10
- package/dist/types/schema/routing.d.ts +14 -1
- package/dist/types/schema/system.d.ts +16 -6
- package/dist/types/schema/time-zones.d.ts +2 -0
- package/dist/types/transport/console.d.ts +79 -23
- package/dist/types/transport/path-check.d.ts +32 -0
- package/dist/types/transport/ssh.d.ts +17 -15
- package/package.json +18 -12
- package/src/cli/graph-format.ts +91 -0
- package/src/cli/index.ts +1074 -484
- package/src/cli/new.ts +208 -175
- package/src/cli/rename.ts +97 -91
- package/src/core/address-space.ts +320 -0
- package/src/core/address.ts +202 -0
- package/src/core/cabling.ts +154 -0
- package/src/core/cross-checks.ts +364 -0
- package/src/core/define.ts +26 -3
- package/src/core/project-files.ts +35 -0
- package/src/core/schema-docs.ts +138 -130
- package/src/core/secrets.ts +170 -76
- package/src/core/topology.ts +656 -0
- package/src/core/utils.ts +37 -0
- package/src/core/validator.ts +407 -0
- package/src/helpers/bgp.ts +301 -0
- package/src/helpers/checks.ts +178 -0
- package/src/helpers/exchange.ts +117 -0
- package/src/helpers/filters.ts +107 -0
- package/src/helpers/index.ts +12 -0
- package/src/helpers/route-server.ts +212 -0
- package/src/index.ts +6 -3
- package/src/integrations/librenms.ts +291 -0
- package/src/integrations/netbox.ts +620 -0
- package/src/integrations/peeringdb.ts +173 -0
- package/src/integrations/prometheus.ts +90 -0
- package/src/integrations/registries.ts +25 -0
- package/src/platforms/bgp-wait.ts +41 -0
- package/src/platforms/catalog.ts +69 -0
- package/src/platforms/derive.ts +117 -0
- package/src/{adapters/devices → platforms}/fortios/TRAPS.md +6 -0
- package/src/platforms/fortios/firewall.ts +377 -0
- package/src/platforms/fortios/index.ts +1060 -0
- package/src/platforms/fortios/names.ts +130 -0
- package/src/platforms/fortios/plan.ts +929 -0
- package/src/platforms/fortios/render.ts +779 -0
- package/src/platforms/fortios/routing.ts +476 -0
- package/src/platforms/fortios/tree.ts +129 -0
- package/src/platforms/index.ts +36 -0
- package/src/{adapters/devices → platforms}/junos/TRAPS.md +8 -0
- package/src/platforms/junos/bgp.ts +293 -0
- package/src/platforms/junos/index.ts +1006 -0
- package/src/platforms/junos/render.ts +967 -0
- package/src/platforms/line-diff.ts +244 -0
- package/src/platforms/platform.ts +122 -0
- package/src/platforms/raisecom-ros/TRAPS.md +40 -0
- package/src/platforms/raisecom-ros/index.ts +731 -0
- package/src/platforms/raisecom-ros/render.ts +328 -0
- package/src/{adapters/devices → platforms}/routeros/TRAPS.md +16 -2
- package/src/platforms/routeros/export.ts +342 -0
- package/src/platforms/routeros/index.ts +636 -0
- package/src/{adapters/devices → platforms}/routeros/models.ts +2 -0
- package/src/platforms/routeros/plan.ts +444 -0
- package/src/platforms/routeros/render.ts +1483 -0
- package/src/platforms/vrp/TRAPS.md +45 -0
- package/src/platforms/vrp/index.ts +652 -0
- package/src/platforms/vrp/render.ts +615 -0
- package/src/schema/common.ts +15 -6
- package/src/schema/index.ts +1 -0
- package/src/schema/interfaces.ts +15 -5
- package/src/schema/policy.ts +22 -10
- package/src/schema/routing.ts +16 -1
- package/src/schema/system.ts +16 -6
- package/src/schema/time-zones.ts +482 -0
- package/src/studio/data.ts +172 -168
- package/src/studio/git.ts +95 -90
- package/src/studio/public/app.js +161 -150
- package/src/studio/public/compare.js +219 -212
- package/src/studio/public/diff.js +77 -75
- package/src/studio/public/dom.js +118 -108
- package/src/studio/public/evaluate.js +584 -545
- package/src/studio/public/export.js +120 -118
- package/src/studio/public/graph.js +1343 -1266
- package/src/studio/public/net.js +115 -109
- package/src/studio/public/tables.js +1135 -1083
- package/src/studio/public/tools.js +561 -529
- package/src/studio/server.ts +202 -196
- package/src/transport/console.ts +363 -271
- package/src/transport/path-check.ts +91 -0
- package/src/transport/ssh.ts +98 -94
- package/templates/colocation/checks.ts +11 -6
- package/templates/colocation/edge-01.ts +9 -9
- package/templates/colocation/routing.ts +2 -2
- package/templates/colocation/tenancy.ts +3 -3
- package/templates/colocation/tor-01.ts +3 -3
- package/templates/edge-router/checks.ts +4 -4
- package/templates/edge-router/edge-01.ts +5 -5
- package/templates/edge-router/routing.ts +2 -2
- package/templates/project/AGENTS.md +5 -1
- package/templates/single-site/checks.ts +2 -2
- package/dist/chunks/index-47debt16.js +0 -956
- package/dist/chunks/index-v8zgr7wm.js +0 -128
- package/dist/chunks/new-ny89s7y7.js +0 -166
- package/dist/chunks/server-tjbb3rqc.js +0 -251
- package/dist/presets/index.js +0 -394
- package/dist/types/adapters/devices/catalog.d.ts +0 -86
- package/dist/types/adapters/devices/derive.d.ts +0 -7
- package/dist/types/adapters/devices/fortios/firewall.d.ts +0 -23
- package/dist/types/adapters/devices/fortios/index.d.ts +0 -58
- package/dist/types/adapters/devices/fortios/names.d.ts +0 -48
- package/dist/types/adapters/devices/fortios/plan.d.ts +0 -28
- package/dist/types/adapters/devices/fortios/render.d.ts +0 -38
- package/dist/types/adapters/devices/fortios/routing.d.ts +0 -32
- package/dist/types/adapters/devices/fortios/tree.d.ts +0 -33
- package/dist/types/adapters/devices/index.d.ts +0 -3
- package/dist/types/adapters/devices/junos/bgp.d.ts +0 -11
- package/dist/types/adapters/devices/junos/index.d.ts +0 -42
- package/dist/types/adapters/devices/junos/render.d.ts +0 -52
- package/dist/types/adapters/devices/lines.d.ts +0 -56
- package/dist/types/adapters/devices/raisecom-ros/index.d.ts +0 -28
- package/dist/types/adapters/devices/routeros/export.d.ts +0 -39
- package/dist/types/adapters/devices/routeros/index.d.ts +0 -30
- package/dist/types/adapters/devices/routeros/plan.d.ts +0 -4
- package/dist/types/adapters/devices/routeros/render.d.ts +0 -100
- package/dist/types/adapters/devices/types.d.ts +0 -40
- package/dist/types/adapters/devices/vrp/index.d.ts +0 -27
- package/dist/types/adapters/registries/index.d.ts +0 -3
- package/dist/types/adapters/registries/librenms.d.ts +0 -15
- package/dist/types/adapters/registries/peeringdb.d.ts +0 -16
- package/dist/types/adapters/registries/prometheus.d.ts +0 -16
- package/dist/types/core/across.d.ts +0 -4
- package/dist/types/core/addr.d.ts +0 -34
- package/dist/types/core/files.d.ts +0 -6
- package/dist/types/core/merge.d.ts +0 -4
- package/dist/types/core/time.d.ts +0 -2
- package/dist/types/core/validate.d.ts +0 -2
- package/dist/types/presets/index.d.ts +0 -183
- package/src/adapters/devices/catalog.ts +0 -41
- package/src/adapters/devices/derive.ts +0 -59
- package/src/adapters/devices/fortios/firewall.ts +0 -362
- package/src/adapters/devices/fortios/index.ts +0 -923
- package/src/adapters/devices/fortios/names.ts +0 -115
- package/src/adapters/devices/fortios/plan.ts +0 -888
- package/src/adapters/devices/fortios/render.ts +0 -768
- package/src/adapters/devices/fortios/routing.ts +0 -444
- package/src/adapters/devices/fortios/tree.ts +0 -121
- package/src/adapters/devices/index.ts +0 -9
- package/src/adapters/devices/junos/bgp.ts +0 -269
- package/src/adapters/devices/junos/index.ts +0 -862
- package/src/adapters/devices/junos/render.ts +0 -872
- package/src/adapters/devices/lines.ts +0 -212
- package/src/adapters/devices/raisecom-ros/TRAPS.md +0 -26
- package/src/adapters/devices/raisecom-ros/index.ts +0 -700
- package/src/adapters/devices/routeros/export.ts +0 -321
- package/src/adapters/devices/routeros/index.ts +0 -346
- package/src/adapters/devices/routeros/plan.ts +0 -398
- package/src/adapters/devices/routeros/render.ts +0 -1490
- package/src/adapters/devices/types.ts +0 -38
- package/src/adapters/devices/vrp/TRAPS.md +0 -26
- package/src/adapters/devices/vrp/index.ts +0 -865
- package/src/adapters/registries/index.ts +0 -19
- package/src/adapters/registries/librenms.ts +0 -92
- package/src/adapters/registries/peeringdb.ts +0 -169
- package/src/adapters/registries/prometheus.ts +0 -71
- package/src/cli/graph.ts +0 -89
- package/src/core/across.ts +0 -350
- package/src/core/addr.ts +0 -174
- package/src/core/addresses.ts +0 -316
- package/src/core/files.ts +0 -32
- package/src/core/graph.ts +0 -640
- package/src/core/merge.ts +0 -19
- package/src/core/time.ts +0 -15
- package/src/core/validate.ts +0 -348
- package/src/presets/index.ts +0 -579
- /package/dist/types/{adapters/devices → platforms}/fortios/models.d.ts +0 -0
- /package/dist/types/{adapters/devices → platforms}/junos/models.d.ts +0 -0
- /package/dist/types/{adapters/devices → platforms}/raisecom-ros/models.d.ts +0 -0
- /package/dist/types/{adapters/devices → platforms}/vrp/models.d.ts +0 -0
- /package/src/{adapters/devices → platforms}/fortios/models.ts +0 -0
- /package/src/{adapters/devices → platforms}/junos/models.ts +0 -0
- /package/src/{adapters/devices → platforms}/raisecom-ros/models.ts +0 -0
- /package/src/{adapters/devices → platforms}/vrp/models.ts +0 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
// RouterOS 7: logging in, reading, the fields it cannot express, and sending a plan.
|
|
2
|
+
//
|
|
3
|
+
// The platform applies every command the moment it arrives and has no candidate configuration, so a run is guarded three ways. Every command is first compiled on the device without running, so a syntax error or an unknown property costs nothing. A backup is saved and a scheduler armed to restore it, and the run must log in again in a fresh session before the scheduler is removed. A command the device refuses restores the backup at once, rather than leaving the device half applied.
|
|
4
|
+
|
|
5
|
+
import type { Client } from "ssh2";
|
|
6
|
+
import { Address } from "../../core/address";
|
|
7
|
+
import type { Neighbor } from "../../core/cabling";
|
|
8
|
+
import { Secrets } from "../../core/secrets";
|
|
9
|
+
import type { Device, Family } from "../../schema";
|
|
10
|
+
import { SerialConsole, type ConsoleDialect, ConsoleShell } from "../../transport/console";
|
|
11
|
+
import { Ssh } from "../../transport/ssh";
|
|
12
|
+
import {
|
|
13
|
+
type ApplyOptions,
|
|
14
|
+
type BgpLogLine,
|
|
15
|
+
type BgpSession,
|
|
16
|
+
type HealthCheck,
|
|
17
|
+
type HealthOptions,
|
|
18
|
+
type OsSupport,
|
|
19
|
+
type Plan,
|
|
20
|
+
type ReadOptions,
|
|
21
|
+
type ReadSession,
|
|
22
|
+
type SwitchRules,
|
|
23
|
+
Platform,
|
|
24
|
+
} from "../platform";
|
|
25
|
+
import { BgpWait, type WaitLimits } from "../bgp-wait";
|
|
26
|
+
import { RouterOsPlan } from "./plan";
|
|
27
|
+
import { Catalog } from "../catalog";
|
|
28
|
+
import { Derive } from "../derive";
|
|
29
|
+
import { RouterOsRender } from "./render";
|
|
30
|
+
|
|
31
|
+
/** Where commands run, one at a time, each returning what it printed: an SSH session, or the console. */
|
|
32
|
+
type Session = {
|
|
33
|
+
run(command: string, timeout?: number): Promise<string>;
|
|
34
|
+
/** Write a file to the device. Only SSH can. */
|
|
35
|
+
upload?(path: string, content: string): Promise<void>;
|
|
36
|
+
/** A fresh login, which proves access survived: this session ends and a new SSH session opens, or the console logs out and in again. */
|
|
37
|
+
fresh(): Promise<Session>;
|
|
38
|
+
end(): void;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export class RouterOs extends Platform {
|
|
42
|
+
/** 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. */
|
|
43
|
+
static readonly CONSOLE: ConsoleDialect = {
|
|
44
|
+
login: /Login: $/,
|
|
45
|
+
password: /Password: $/,
|
|
46
|
+
prompt: /\] > $/,
|
|
47
|
+
logout: "/quit",
|
|
48
|
+
others: [
|
|
49
|
+
[/-- \[Q quit\|D dump\|(?:C-z pause\|)?down\]$/, "D"],
|
|
50
|
+
[/\[Y\/n\]:? ?$/, "n"],
|
|
51
|
+
],
|
|
52
|
+
user: (user) => `${user}+cwt`,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Why a MAC limit is refused: neither a bridge port nor a switch port has a learning limit on 7.23.5. */
|
|
56
|
+
private static readonly NO_MAC_LIMIT = "the platform has no MAC learning limit on a bridge port or a switch port on 7.23";
|
|
57
|
+
|
|
58
|
+
/** The major version this adapter renders for. Version 6 has a different BGP and filter language altogether. */
|
|
59
|
+
private static readonly SUPPORTED_MAJOR = 7;
|
|
60
|
+
|
|
61
|
+
/** Both dumps stamp the time they were taken. Without the stamp, an unchanged device reads the same twice. */
|
|
62
|
+
private static readonly EXPORT_TIMESTAMP = /^# \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} by RouterOS/m;
|
|
63
|
+
|
|
64
|
+
/** Lists certificates in the form the renderer writes them, since an export does not include them. One line, because a script sent over exec runs as one. */
|
|
65
|
+
private static readonly LIST_CERTIFICATES = [
|
|
66
|
+
":foreach c in=[/certificate find] do={",
|
|
67
|
+
':local key "no";',
|
|
68
|
+
':if ([/certificate get $c private-key]) do={ :set key "yes" };',
|
|
69
|
+
':put ("add fingerprint=" . [/certificate get $c fingerprint] . " name=" . [/certificate get $c name] . " private-key=" . $key)',
|
|
70
|
+
"}",
|
|
71
|
+
].join(" ");
|
|
72
|
+
|
|
73
|
+
/** Every BGP session, one line each: name, remote address, established, prefixes received, uptime. One line, because a script sent over exec runs as one. */
|
|
74
|
+
private static readonly LIST_BGP_SESSIONS = [
|
|
75
|
+
":foreach s in=[/routing bgp session find] do={",
|
|
76
|
+
":local g [/routing bgp session get $s];",
|
|
77
|
+
':put (($g->"name") . "|" . ($g->"remote.address") . "|" . ($g->"established") . "|" . ($g->"prefix-count") . "|" . ($g->"uptime"))',
|
|
78
|
+
"}",
|
|
79
|
+
].join(" ");
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Every LLDP neighbor, one line each: our interfaces, the port first and then the LAG and bridge it is in, the far system name, and the far port as LLDP reports it. `:tostr` keeps the interface list one value; joined to a string as it is, a list repeats the whole line for each of its items. One line, because a script sent over exec runs as one.
|
|
83
|
+
*/
|
|
84
|
+
private static readonly LIST_NEIGHBORS = [
|
|
85
|
+
':foreach n in=[/ip neighbor find where discovered-by~"lldp"] do={',
|
|
86
|
+
":local g [/ip neighbor get $n];",
|
|
87
|
+
':put ([:tostr ($g->"interface")] . "|" . ($g->"identity") . "|" . ($g->"interface-name"))',
|
|
88
|
+
"}",
|
|
89
|
+
].join(" ");
|
|
90
|
+
|
|
91
|
+
/** BGP log lines, without the `connection ...` lines a session that cannot connect repeats. */
|
|
92
|
+
private static readonly BGP_LOG = '/log print where topics~"bgp" and !(message~"^connection ")';
|
|
93
|
+
|
|
94
|
+
/** The device's date and time, in the form its log prints: `2026-10-24 03:12:44`. */
|
|
95
|
+
private static readonly CLOCK = ':put ([/system clock get date] . " " . [/system clock get time])';
|
|
96
|
+
|
|
97
|
+
/** The switch chip's rules. A rule it cannot hold is invalid, and so is every rule after it, with no log line (TRAPS.md). */
|
|
98
|
+
private static readonly SWITCH_RULES = "/interface ethernet switch rule";
|
|
99
|
+
|
|
100
|
+
private static readonly COUNT_INVALID_SWITCH_RULES = `${RouterOs.SWITCH_RULES} print count-only where invalid`;
|
|
101
|
+
|
|
102
|
+
private static readonly ROLLBACK_SCHEDULER = "apply-rollback";
|
|
103
|
+
|
|
104
|
+
/** Commands that only read: print, monitor, ping, traceroute, torch, profile, sniffer quick, an AS-path regex test, export, and `:put`, `:foreach` or `:delay` scripts. */
|
|
105
|
+
private static readonly READS =
|
|
106
|
+
/\b(print|monitor|monitor-traffic|ping|traceroute|torch|profile|test-as-path-regexp|export|get|find)\b|\bsniffer quick\b|^:(put|foreach|delay)\b/;
|
|
107
|
+
|
|
108
|
+
/** Words that change something, or print secrets. A command with any of them is refused, whatever else it holds. */
|
|
109
|
+
private static readonly WRITES =
|
|
110
|
+
/\b(set|add|remove|reset|reset-counters|enable|disable|unset|move|edit|import|reboot|shutdown|clear|run|upgrade|fetch|save|show-sensitive)\b|\bfile=/;
|
|
111
|
+
|
|
112
|
+
/** Flags after the user over SSH: no colours, no terminal detection, 300 columns, so output is plain and lines are not cut. */
|
|
113
|
+
private static readonly PLAIN_OUTPUT = "+ct300w";
|
|
114
|
+
|
|
115
|
+
readonly name = "routeros";
|
|
116
|
+
|
|
117
|
+
readonly os: OsSupport = {
|
|
118
|
+
name: "MikroTik RouterOS",
|
|
119
|
+
versions: "7",
|
|
120
|
+
provedOn: "CCR2216-1G-12XS-2XQ, RouterOS 7.23",
|
|
121
|
+
checked: true,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
readonly extension = ".rsc";
|
|
125
|
+
|
|
126
|
+
override readonly console = RouterOs.CONSOLE;
|
|
127
|
+
|
|
128
|
+
unsupported(device: Device): string[] {
|
|
129
|
+
const found: string[] = [];
|
|
130
|
+
|
|
131
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
132
|
+
if (iface.type === "bridge") found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
136
|
+
if (!port) continue;
|
|
137
|
+
|
|
138
|
+
if (port.speed && !RouterOsRender.medium(Catalog.spell(device.platform, device.model, name), port.speed)) {
|
|
139
|
+
found.push(`ports.${name}.speed ${port.speed}, the port has no fixed speed by that name`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const thresholds = Object.values(port.storm_control ?? {});
|
|
143
|
+
const percents = new Set(thresholds.map((threshold) => ("percent" in threshold ? threshold.percent : undefined)));
|
|
144
|
+
|
|
145
|
+
if (port.mac_limit !== undefined) found.push(`ports.${name}.mac_limit, ${RouterOs.NO_MAC_LIMIT}`);
|
|
146
|
+
|
|
147
|
+
if (thresholds.some((threshold) => "pps" in threshold)) {
|
|
148
|
+
found.push(`ports.${name}.storm_control in pps, the platform takes percent`);
|
|
149
|
+
} else if (percents.size > 1) {
|
|
150
|
+
found.push(`ports.${name}.storm_control with a different percent per kind, the platform takes one`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
155
|
+
if (iface.type !== "lag") continue;
|
|
156
|
+
|
|
157
|
+
if (iface.mac_limit !== undefined) found.push(`interfaces.${name}.mac_limit, ${RouterOs.NO_MAC_LIMIT}`);
|
|
158
|
+
if (iface.ethertypes !== undefined) found.push(`interfaces.${name}.ethertypes, on a LAG, which is not proved on a device yet`);
|
|
159
|
+
|
|
160
|
+
for (const field of ["storm_control", "stp"] as const) {
|
|
161
|
+
if (iface[field] !== undefined) found.push(`interfaces.${name}.${field}, on a LAG`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const bgp = device.routing?.bgp;
|
|
166
|
+
|
|
167
|
+
for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
|
|
168
|
+
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
169
|
+
const families = neighbor.families ?? group?.families ?? [Address.family(neighbor.address)];
|
|
170
|
+
|
|
171
|
+
if (families.length > 1) found.push(`routing.bgp.neighbors.${name}.families, one family per session`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const management = device.management;
|
|
175
|
+
|
|
176
|
+
if (management?.console) found.push("management.console, the console uses the device's users");
|
|
177
|
+
if (management?.interfaces) found.push("management.interfaces, which only FortiOS builds for now");
|
|
178
|
+
if (management?.privilege_password) found.push("management.privilege_password, the platform has no privileged mode");
|
|
179
|
+
|
|
180
|
+
if (management?.snmp?.port) found.push("management.snmp.port");
|
|
181
|
+
|
|
182
|
+
if (management?.ssh?.auth_timeout || management?.ssh?.auth_retries) {
|
|
183
|
+
found.push("management.ssh.auth_timeout and auth_retries");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const [name, user] of Object.entries(management?.snmp?.users ?? {})) {
|
|
187
|
+
if (user.auth !== "sha1") {
|
|
188
|
+
found.push(`management.snmp.users.${name}.auth ${user.auth}, the platform authenticates with SHA1 or MD5`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (device.system?.transceiver_monitoring === false) found.push("system.transceiver_monitoring false, it is always on");
|
|
193
|
+
if (device.flow_export?.protocol === "sflow") found.push("flow_export.protocol sflow, the platform exports NetFlow and IPFIX");
|
|
194
|
+
|
|
195
|
+
for (const route of device.routing?.static ?? []) {
|
|
196
|
+
if (route.bfd) found.push(`routing.static ${route.prefix} bfd, a static route's check-gateway takes arp, none or ping on 7.23`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// BFD on a static route and a MAC limit are refused above, for the reason the device gave, and an ethertype filter on a LAG as unproved.
|
|
200
|
+
found.push(...Derive.unproved(device, ["bfd-timers", "multihop-bfd", "static-route-bfd", "mac-limit", "ethertype-filter"]));
|
|
201
|
+
|
|
202
|
+
return found;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
render(device: Device): string {
|
|
206
|
+
return RouterOsRender.of(device);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
override switchRules(device: Device): SwitchRules {
|
|
210
|
+
const rules = RouterOsRender.switchRules(device);
|
|
211
|
+
const ranges = new Set(rules.flatMap(({ srcPort, dstPort }) => [srcPort, dstPort]).filter((port) => port?.includes("-")));
|
|
212
|
+
const filters = RouterOsRender.ethertypeFilters(device).reduce((count, { ethertypes }) => count + ethertypes.length + 1, 0);
|
|
213
|
+
|
|
214
|
+
return { count: rules.length + filters, ranges: ranges.size };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* What the device runs: its export, its users, and its certificates. Secrets are not part of an export.
|
|
219
|
+
*/
|
|
220
|
+
async read(device: Device, _network: readonly Device[] = [], options: ReadOptions = {}): Promise<string> {
|
|
221
|
+
const session = await RouterOs.open(device, options);
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
await RouterOs.checkVersion(session, device);
|
|
225
|
+
|
|
226
|
+
const config = await session.run("/export", 300_000);
|
|
227
|
+
const users = await session.run("/user export");
|
|
228
|
+
const certificates = (await session.run(RouterOs.LIST_CERTIFICATES)).trim();
|
|
229
|
+
|
|
230
|
+
const listed = certificates.split("\n").every((line) => line.trim().startsWith("add fingerprint="));
|
|
231
|
+
if (certificates && !listed) {
|
|
232
|
+
throw new Error(`${device.name}: certificates could not be listed: ${certificates.slice(0, 120)}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The user dump repeats the export's header; only its menu is kept.
|
|
236
|
+
const userMenu = users.slice(users.indexOf("\n/")).trim();
|
|
237
|
+
const certificateMenu = certificates ? `/certificate\n${certificates}` : "";
|
|
238
|
+
const text = [config.trim(), userMenu, certificateMenu].filter(Boolean).join("\n");
|
|
239
|
+
return RouterOs.withoutTimestamp(text) + "\n";
|
|
240
|
+
} finally {
|
|
241
|
+
session.end();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
plan(desired: string, current: string, options: ApplyOptions): Plan {
|
|
246
|
+
return RouterOsPlan.of(desired, current, options);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async apply(device: Device, _network: readonly Device[], changes: Plan, options: ApplyOptions): Promise<void> {
|
|
250
|
+
const session = await RouterOs.open(device, options);
|
|
251
|
+
await RouterOs.checkVersion(session, device);
|
|
252
|
+
|
|
253
|
+
const commands = changes.steps.flatMap((step) => step.send.map((command) => ({ title: step.title, command })));
|
|
254
|
+
|
|
255
|
+
for (const { title, command } of commands) {
|
|
256
|
+
if (command.startsWith(RouterOsPlan.INSTALL_CERTIFICATE)) continue;
|
|
257
|
+
|
|
258
|
+
const reason = await RouterOs.refusal(session, Secrets.substitute(command));
|
|
259
|
+
if (reason) {
|
|
260
|
+
session.end();
|
|
261
|
+
throw new Error(`${title}: the device will not parse ${command.slice(0, 200)}\n ${reason}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const stamp = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 12);
|
|
266
|
+
const backup = `pre-apply-${stamp}`;
|
|
267
|
+
|
|
268
|
+
// `interval` counts from now; `start-time=startup` would count from boot.
|
|
269
|
+
const armRollback = [
|
|
270
|
+
`/system scheduler add name="${RouterOs.ROLLBACK_SCHEDULER}"`,
|
|
271
|
+
`interval=${options.rollback}m`,
|
|
272
|
+
`on-event="/system backup load name=${backup} password=\\"\\""`,
|
|
273
|
+
"policy=read,write,policy,test,reboot,ftp",
|
|
274
|
+
].join(" ");
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
console.log(` saving ${backup}, arming a ${options.rollback} minute rollback`);
|
|
278
|
+
|
|
279
|
+
await session.run(`/system backup save name=${backup} dont-encrypt=yes`, 120_000);
|
|
280
|
+
await session.run(`/system scheduler remove [find name="${RouterOs.ROLLBACK_SCHEDULER}"]`);
|
|
281
|
+
await session.run(armRollback);
|
|
282
|
+
|
|
283
|
+
for (const { title, command } of commands) {
|
|
284
|
+
if (command.startsWith(RouterOsPlan.INSTALL_CERTIFICATE)) {
|
|
285
|
+
const certificateName = command.slice(RouterOsPlan.INSTALL_CERTIFICATE.length).trim();
|
|
286
|
+
await RouterOs.installCertificate(session, device, certificateName);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const answer = (await session.run(Secrets.substitute(command), 120_000)).trim();
|
|
291
|
+
if (answer) throw new Error(`${title}: ${command.slice(0, 160)}\n device said: ${answer.slice(0, 300)}`);
|
|
292
|
+
}
|
|
293
|
+
} catch (error) {
|
|
294
|
+
console.log(` FAILED: ${(error as Error).message}`);
|
|
295
|
+
console.log(` restoring ${backup} now; the rollback stays armed in case this does not get through`);
|
|
296
|
+
|
|
297
|
+
await session.run(`/system backup load name=${backup} password=""`, 30_000).catch(() => undefined);
|
|
298
|
+
session.end();
|
|
299
|
+
|
|
300
|
+
throw new Error("apply failed and the backup is being restored");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// A fresh login is the proof that access survived. Only then is the rollback removed, with the backup, which holds every credential.
|
|
304
|
+
const proof = await session.fresh().catch(() => undefined);
|
|
305
|
+
if (!proof) throw new Error(`could not log in again; the device restores ${backup} within ${options.rollback} minutes`);
|
|
306
|
+
|
|
307
|
+
await proof.run(`/system scheduler remove [find name="${RouterOs.ROLLBACK_SCHEDULER}"]`);
|
|
308
|
+
await proof.run(`/file remove [find name="${backup}.backup"]`);
|
|
309
|
+
proof.end();
|
|
310
|
+
console.log(" logged in again, rollback disarmed");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
readOnly(command: string): boolean {
|
|
314
|
+
return RouterOs.READS.test(command) && !RouterOs.WRITES.test(command);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Over SSH, each command runs on its own. */
|
|
318
|
+
async session(device: Device, options: ReadOptions = {}): Promise<ReadSession> {
|
|
319
|
+
const session = options.console
|
|
320
|
+
? await RouterOs.consoleSession(device, ConsoleShell.SETTLE)
|
|
321
|
+
: RouterOs.sshSession(await RouterOs.login(device, RouterOs.PLAIN_OUTPUT), device);
|
|
322
|
+
|
|
323
|
+
return { run: (command, timeout) => session.run(command, timeout), close: () => session.end() };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* A session is named `<connection>-<n>`, but a renamed connection keeps its session's old name until the session restarts. So a neighbor is matched by its address first, then by name.
|
|
328
|
+
*/
|
|
329
|
+
async bgpSessions(device: Device, session: ReadSession): Promise<BgpSession[]> {
|
|
330
|
+
const output = await session.run(RouterOs.LIST_BGP_SESSIONS, 60_000);
|
|
331
|
+
const live = output
|
|
332
|
+
.split(/\r?\n/)
|
|
333
|
+
.filter((line) => line.includes("|"))
|
|
334
|
+
.map((line) => line.trim().split("|"));
|
|
335
|
+
|
|
336
|
+
return Object.entries(device.routing?.bgp?.neighbors ?? {}).map(([name, neighbor]) => {
|
|
337
|
+
const found =
|
|
338
|
+
live.find(([, remote]) => remote === neighbor.address) ??
|
|
339
|
+
live.find(([sessionName]) => sessionName === name || sessionName?.replace(/-\d+$/, "") === name);
|
|
340
|
+
|
|
341
|
+
if (!found) return { neighbor: name, remote: "-", established: false, prefixes: "-", uptime: "no session" };
|
|
342
|
+
|
|
343
|
+
const [, remote, established, prefixes, uptime] = found;
|
|
344
|
+
return {
|
|
345
|
+
neighbor: name,
|
|
346
|
+
remote: remote || "-",
|
|
347
|
+
established: established === "true",
|
|
348
|
+
prefixes: prefixes || "-",
|
|
349
|
+
uptime: uptime || "-",
|
|
350
|
+
};
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** A `time>` filter in `/log print` does not filter, so the lines are kept here by comparing each one's time with the device's clock. */
|
|
355
|
+
async bgpLog(session: ReadSession, minutes: number): Promise<BgpLogLine[]> {
|
|
356
|
+
const now = (await session.run(RouterOs.CLOCK)).trim();
|
|
357
|
+
const nowTime = RouterOs.logTime(now);
|
|
358
|
+
if (Number.isNaN(nowTime)) throw new Error(`could not read the device's clock: ${now.slice(0, 120)}`);
|
|
359
|
+
|
|
360
|
+
const since = nowTime - minutes * 60_000;
|
|
361
|
+
const log = await session.run(RouterOs.BGP_LOG, 60_000);
|
|
362
|
+
|
|
363
|
+
return log
|
|
364
|
+
.split(/\r?\n/)
|
|
365
|
+
.map((line) => line.trim())
|
|
366
|
+
.filter(Boolean)
|
|
367
|
+
.flatMap((line) => {
|
|
368
|
+
const [date, time, ...rest] = line.split(" ");
|
|
369
|
+
const at = `${date} ${time}`;
|
|
370
|
+
return RouterOs.logTime(at) >= since ? [{ at, message: rest.join(" ") }] : [];
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Two checks after an apply. When the plan touched switch rules, none may be invalid. BGP sessions established before the apply must be established again, within `BgpWait.AFTER_APPLY`.
|
|
376
|
+
*/
|
|
377
|
+
async health(device: Device, plan: Plan, options: HealthOptions = {}): Promise<HealthCheck> {
|
|
378
|
+
const switchRules = plan.steps.some((step) => step.send.some((command) => command.startsWith(RouterOs.SWITCH_RULES)));
|
|
379
|
+
const limits = options.bgp ?? BgpWait.AFTER_APPLY;
|
|
380
|
+
|
|
381
|
+
const establishedBefore = await this.establishedSessions(device, options);
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
run: async () => {
|
|
385
|
+
if (!switchRules && !establishedBefore.length) {
|
|
386
|
+
console.log(" nothing to check: the plan changed no switch rule, and no BGP session was established before");
|
|
387
|
+
return [];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const session = await this.session(device, options);
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
const switchProblems = switchRules ? await RouterOs.invalidSwitchRules(session, device) : [];
|
|
394
|
+
const bgpProblems = await this.bgpBack(device, session, establishedBefore, limits);
|
|
395
|
+
return [...switchProblems, ...bgpProblems];
|
|
396
|
+
} finally {
|
|
397
|
+
session.close();
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** The LLDP neighbors the device sees, by Circuit's port names. */
|
|
404
|
+
override async neighbors(device: Device, network: readonly Device[], options: ReadOptions = {}): Promise<Neighbor[]> {
|
|
405
|
+
const session = await this.session(device, options);
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
const output = await session.run(RouterOs.LIST_NEIGHBORS, 60_000);
|
|
409
|
+
|
|
410
|
+
return output
|
|
411
|
+
.split(/\r?\n/)
|
|
412
|
+
.map((line) => line.trim())
|
|
413
|
+
.filter((line) => line.includes("|"))
|
|
414
|
+
.map((line) => RouterOs.neighbor(device, network, line));
|
|
415
|
+
} finally {
|
|
416
|
+
session.close();
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** One line of `LIST_NEIGHBORS` as a neighbor. Our port is the first interface, by Circuit's name when it is a port. */
|
|
421
|
+
private static neighbor(device: Device, network: readonly Device[], line: string): Neighbor {
|
|
422
|
+
const [interfaces = "", system = "", ...rest] = line.split("|");
|
|
423
|
+
const reported = rest.join("|");
|
|
424
|
+
const own = interfaces.split(";")[0] ?? "";
|
|
425
|
+
const port = Catalog.ports(device.platform, device.model).find(
|
|
426
|
+
(name) => Catalog.spell(device.platform, device.model, name) === own
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
return { port: port ?? own, system, remote_port: RouterOs.farPort(network, system, reported) };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* RouterOS reports the far port's description, where it has one, not its name (TRAPS.md). When it is the description of exactly one port of that device in the network, that port is the far port, by Circuit's name; otherwise the report stays as it is.
|
|
434
|
+
*/
|
|
435
|
+
private static farPort(network: readonly Device[], system: string, reported: string): string {
|
|
436
|
+
const far = network.find((candidate) => system === candidate.name || system.startsWith(`${candidate.name}.`));
|
|
437
|
+
const described = Object.entries(far?.ports ?? {})
|
|
438
|
+
.filter(([, port]) => port?.description === reported)
|
|
439
|
+
.map(([name]) => name);
|
|
440
|
+
|
|
441
|
+
return described.length === 1 ? described[0]! : reported;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Replace a list's entries in batches, so each command stays a sane length. */
|
|
445
|
+
async fill(device: Device, _network: readonly Device[], set: string, family: Family, prefixes: string[]): Promise<void> {
|
|
446
|
+
const menu = family === "ipv4" ? "/ip firewall address-list" : "/ipv6 firewall address-list";
|
|
447
|
+
const batchSize = 60;
|
|
448
|
+
const client = await RouterOs.login(device);
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
await Ssh.exec(client, `${menu} remove [find list="${set}" and !dynamic]`);
|
|
452
|
+
|
|
453
|
+
for (let start = 0; start < prefixes.length; start += batchSize) {
|
|
454
|
+
const batch = prefixes.slice(start, start + batchSize).map((prefix) => `${menu} add list="${set}" address=${prefix}`);
|
|
455
|
+
|
|
456
|
+
const answer = (await Ssh.exec(client, batch.join("; "), 90_000)).trim();
|
|
457
|
+
if (answer) throw new Error(`${set}: ${answer.slice(0, 200)}`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const count = (await Ssh.exec(client, `:put [:len [${menu} find list="${set}"]]`)).trim();
|
|
461
|
+
console.log(` ${set}: ${count} entries on the device`);
|
|
462
|
+
} finally {
|
|
463
|
+
client.end();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** The names of the configured neighbors whose session is established now. None, without logging in, on a device without BGP neighbors. */
|
|
468
|
+
private async establishedSessions(device: Device, options: ReadOptions): Promise<string[]> {
|
|
469
|
+
if (!Object.keys(device.routing?.bgp?.neighbors ?? {}).length) return [];
|
|
470
|
+
|
|
471
|
+
const session = await this.session(device, options);
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
const sessions = await this.bgpSessions(device, session);
|
|
475
|
+
return sessions.filter((bgp) => bgp.established).map((bgp) => bgp.neighbor);
|
|
476
|
+
} finally {
|
|
477
|
+
session.close();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Wait until every one of these neighbors is established again, and give a problem for each that is not when time runs out. */
|
|
482
|
+
private async bgpBack(device: Device, session: ReadSession, neighbors: readonly string[], limits: WaitLimits): Promise<string[]> {
|
|
483
|
+
if (!neighbors.length) return [];
|
|
484
|
+
|
|
485
|
+
console.log(` BGP: ${neighbors.length} session(s) were established before; waiting up to ${limits.seconds} s for them`);
|
|
486
|
+
|
|
487
|
+
const read = async () => (await this.bgpSessions(device, session)).filter((bgp) => neighbors.includes(bgp.neighbor));
|
|
488
|
+
const after = await BgpWait.until(
|
|
489
|
+
read,
|
|
490
|
+
(sessions) => BgpWait.upCount(sessions) === neighbors.length,
|
|
491
|
+
limits,
|
|
492
|
+
(sessions) =>
|
|
493
|
+
console.log(
|
|
494
|
+
` BGP: ${BgpWait.upCount(sessions)}/${neighbors.length} established again, checking again in ${limits.every} s`
|
|
495
|
+
)
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
const down = after.filter((bgp) => !bgp.established);
|
|
499
|
+
if (!down.length) {
|
|
500
|
+
console.log(` BGP: all ${neighbors.length} established again`);
|
|
501
|
+
return [];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return down.map(
|
|
505
|
+
(bgp) =>
|
|
506
|
+
`BGP session ${bgp.neighbor} was established before the apply and is not ${limits.seconds} s after it (${bgp.uptime === "no session" ? "no session" : "not established"}). Look with: circuit bgp ${device.name} --log`
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** A problem when any switch rule is invalid, saying what to do. */
|
|
511
|
+
private static async invalidSwitchRules(session: ReadSession, device: Device): Promise<string[]> {
|
|
512
|
+
const answer = (await session.run(RouterOs.COUNT_INVALID_SWITCH_RULES)).trim();
|
|
513
|
+
if (!/^\d+$/.test(answer)) return [`could not count invalid switch rules: ${answer.slice(0, 200)}`];
|
|
514
|
+
|
|
515
|
+
const invalid = Number(answer);
|
|
516
|
+
if (!invalid) {
|
|
517
|
+
console.log(" switch rules: none invalid");
|
|
518
|
+
return [];
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return [
|
|
522
|
+
`${invalid} switch rule(s) are invalid: the switch chip cannot hold them, and every rule after the first invalid one is invalid too, so those rules do nothing. Use fewer distinct port ranges in this device's ACLs, then apply again. See them with: circuit show ${device.name} '${RouterOs.SWITCH_RULES} print where invalid'`,
|
|
523
|
+
];
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Log in over SSH. `flags` go after the user, as RouterOS reads them: `+ct300w`. */
|
|
527
|
+
private static async login(device: Device, flags = ""): Promise<Client> {
|
|
528
|
+
const { port, user, password } = Secrets.loginOf(device);
|
|
529
|
+
|
|
530
|
+
return Ssh.connect({
|
|
531
|
+
host: Ssh.hostOf(device),
|
|
532
|
+
port,
|
|
533
|
+
user: `${user}${flags}`,
|
|
534
|
+
password,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private static sshSession(client: Client, device: Device): Session {
|
|
539
|
+
return {
|
|
540
|
+
run: (command, timeout) => Ssh.exec(client, command, timeout),
|
|
541
|
+
upload: (path, content) => Ssh.upload(client, path, content),
|
|
542
|
+
fresh: async () => {
|
|
543
|
+
client.end();
|
|
544
|
+
return RouterOs.sshSession(await RouterOs.login(device), device);
|
|
545
|
+
},
|
|
546
|
+
end: () => client.end(),
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Log in on the console. `settle` is the quiet after the prompt a command waits for, as `ConsoleShell` takes it. */
|
|
551
|
+
private static async consoleSession(device: Device, settle = 0): Promise<Session> {
|
|
552
|
+
const { console, user, password } = SerialConsole.of(device);
|
|
553
|
+
const opened = await SerialConsole.open(console);
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
await SerialConsole.login(opened.channel, RouterOs.CONSOLE, user, password, console.passwordOnly);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
opened.close();
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const shell = new ConsoleShell(opened.channel, RouterOs.CONSOLE, settle);
|
|
563
|
+
|
|
564
|
+
const session: Session = {
|
|
565
|
+
run: (command, timeout) => shell.run(command, timeout),
|
|
566
|
+
fresh: async () => {
|
|
567
|
+
await SerialConsole.login(opened.channel, RouterOs.CONSOLE, user, password, console.passwordOnly);
|
|
568
|
+
return session;
|
|
569
|
+
},
|
|
570
|
+
end: () => {
|
|
571
|
+
shell.logout();
|
|
572
|
+
opened.close();
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
return session;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Log in over SSH, or on the console when asked. */
|
|
580
|
+
private static async open(device: Device, options: ReadOptions = {}): Promise<Session> {
|
|
581
|
+
return options.console ? RouterOs.consoleSession(device) : RouterOs.sshSession(await RouterOs.login(device), device);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Refuse a device on a version this adapter does not render for, before anything is read or sent. */
|
|
585
|
+
private static async checkVersion(session: Session, device: Device): Promise<void> {
|
|
586
|
+
const version = (await session.run(":put [/system resource get version]")).trim();
|
|
587
|
+
const major = Number(version.split(".")[0]);
|
|
588
|
+
|
|
589
|
+
if (major !== RouterOs.SUPPORTED_MAJOR) {
|
|
590
|
+
throw new Error(`${device.name} runs RouterOS ${version}; this adapter renders for version ${RouterOs.SUPPORTED_MAJOR}`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** A time as the clock and the log print it, `2026-10-24 03:12:44`, in milliseconds. Both are read the same way, so the computer's time zone does not matter. Not a number when the text is not such a time. */
|
|
595
|
+
private static logTime(stamp: string): number {
|
|
596
|
+
return Date.parse(stamp.replace(" ", "T"));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private static withoutTimestamp(text: string): string {
|
|
600
|
+
return text.replace(/\r/g, "").replace(RouterOs.EXPORT_TIMESTAMP, "# by RouterOS");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Escape a command so it can sit inside a quoted string on the device. */
|
|
604
|
+
private static escapeForString(text: string): string {
|
|
605
|
+
return text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Compile a command on the device without running it. Returns why it was refused, or undefined. */
|
|
609
|
+
private static async refusal(session: Session, command: string): Promise<string | undefined> {
|
|
610
|
+
const compiled = (await session.run(`:put [:parse "${RouterOs.escapeForString(command)}"]`)).trim();
|
|
611
|
+
const refused = !compiled.startsWith("(") || /syntax error|bad parameter|no such|expected/.test(compiled);
|
|
612
|
+
return refused ? compiled.slice(0, 200) : undefined;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Upload a certificate and its key, import both under one name, and delete the files. */
|
|
616
|
+
private static async installCertificate(session: Session, device: Device, name: string): Promise<void> {
|
|
617
|
+
const certificate = device.certificates?.[name];
|
|
618
|
+
if (!certificate) throw new Error(`certificate ${name} is not declared`);
|
|
619
|
+
if (!session.upload)
|
|
620
|
+
throw new Error(`certificate ${name} is installed from a file, which only SSH can write; apply it without --console`);
|
|
621
|
+
|
|
622
|
+
await session.upload(`${name}.crt`, certificate.certificate);
|
|
623
|
+
await session.run(`/certificate import file-name=${name}.crt name=${name} passphrase=""`);
|
|
624
|
+
|
|
625
|
+
if (certificate.private_key) {
|
|
626
|
+
// A key from a file is PEM as it is; one from a variable is PEM encoded as one line of base64.
|
|
627
|
+
const value = Secrets.reveal(certificate.private_key);
|
|
628
|
+
const privateKey = value.includes("-----BEGIN") ? `${value}\n` : Buffer.from(value, "base64").toString("utf8");
|
|
629
|
+
|
|
630
|
+
await session.upload(`${name}.key`, privateKey);
|
|
631
|
+
await session.run(`/certificate import file-name=${name}.key name=${name} passphrase=""`);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
await session.run(`/file remove [find name="${name}.crt" or name="${name}.key"]`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
@@ -4,5 +4,7 @@ export const models = {
|
|
|
4
4
|
"CCR2216-1G-12XS-2XQ": {
|
|
5
5
|
ports: { "100g": 2, "25g": 12, "1g": 1 },
|
|
6
6
|
spelling: { "100g": "qsfp28-{n}-1", "25g": "sfp28-{n}", "1g": "ether{n}" },
|
|
7
|
+
// Seen on a real device: a LAG with 12 active members crash-looped it, and the switch chip failed at the sixth distinct port range. It held 70 rules with two ranges, so the rule count alone is not the limit.
|
|
8
|
+
limits: { lag_members: 8, switch_port_ranges: 5 },
|
|
7
9
|
},
|
|
8
10
|
} as const;
|