@takodotid/circuit 0.34.2 → 0.35.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 +13 -14
- 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 +11347 -9018
- 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 +17 -11
- 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 -25
- package/src/adapters/devices/raisecom-ros/index.ts +0 -693
- 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,1060 @@
|
|
|
1
|
+
// Fortinet FortiOS 6.2, as on the FortiGate 3700D.
|
|
2
|
+
//
|
|
3
|
+
// The configuration is read and written as `config` blocks, the form `show` prints. A change applies as it is sent. Before a plan is sent the device is set to revert: a change not saved within the rollback time is undone by reloading the saved configuration, and Circuit reloads it at once when a line is refused. A fresh login proves access before the change is saved. Configuration transactions are not used: on 6.2 one keeps references its own deletions dropped, and one whose session is cut off keeps what it touched locked.
|
|
4
|
+
|
|
5
|
+
import type { Client, ClientChannel } from "ssh2";
|
|
6
|
+
import { Address } from "../../core/address";
|
|
7
|
+
import type { Neighbor } from "../../core/cabling";
|
|
8
|
+
import { Secrets } from "../../core/secrets";
|
|
9
|
+
import type { AclRule, Device, Family, FilterRule } from "../../schema";
|
|
10
|
+
import { SerialConsole, type ConsoleDialect } from "../../transport/console";
|
|
11
|
+
import { Ssh } from "../../transport/ssh";
|
|
12
|
+
import { Catalog } from "../catalog";
|
|
13
|
+
import { Derive } from "../derive";
|
|
14
|
+
import { type ApplyOptions, type OsSupport, type Plan, type ReadOptions, type ReadSession, Platform } from "../platform";
|
|
15
|
+
import { FortiOsFirewall } from "./firewall";
|
|
16
|
+
import { FortiOsNames } from "./names";
|
|
17
|
+
import { FortiOsPlan } from "./plan";
|
|
18
|
+
import { FortiOsRender } from "./render";
|
|
19
|
+
import { FortiOsRouting } from "./routing";
|
|
20
|
+
import { FortiOsBlock, FortiOsTree } from "./tree";
|
|
21
|
+
|
|
22
|
+
/** What a session reads and writes: an SSH shell, or anything else that streams the CLI. */
|
|
23
|
+
export type Channel = {
|
|
24
|
+
write(data: string): unknown;
|
|
25
|
+
on(event: "data", listener: (chunk: Buffer) => void): unknown;
|
|
26
|
+
on(event: "close", listener: () => void): unknown;
|
|
27
|
+
removeListener(event: "data", listener: (chunk: Buffer) => void): unknown;
|
|
28
|
+
removeListener(event: "close", listener: () => void): unknown;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** How a change that could cut access is guarded: the device reloads its saved configuration after `seconds` unless `prove` shows access survived. */
|
|
32
|
+
export type Guard = { seconds: number; prove: () => Promise<void> };
|
|
33
|
+
|
|
34
|
+
/** A CLI over a channel: one line at a time, and what the device printed until its prompt came back. */
|
|
35
|
+
export class FortiOsCli {
|
|
36
|
+
/** The prompt at the end of what the device printed, `host # ` or `host (context) # `, or a question it waits on. */
|
|
37
|
+
private static readonly PROMPT = /(?:^|\n)[^\n]*[#$] $/;
|
|
38
|
+
static readonly QUESTION = /\(y\/n\)\s*$/;
|
|
39
|
+
|
|
40
|
+
/** The pre-login banner, which waits for the reader to accept it. */
|
|
41
|
+
static readonly BANNER = /\(Press 'a' to accept\):\s*$/;
|
|
42
|
+
|
|
43
|
+
/** The device ended the session and asks for a login again. */
|
|
44
|
+
private static readonly LOGGED_OUT = /(?:^|\n)[^\n]*login: $/;
|
|
45
|
+
|
|
46
|
+
/** The pager the device stops at, and how it erases itself once answered. */
|
|
47
|
+
private static readonly PAGER = /--More-- \r? *\r/g;
|
|
48
|
+
|
|
49
|
+
/** The prompt that ended the last answer, which shows where the session is: `(port1)` inside an entry. */
|
|
50
|
+
prompt = "";
|
|
51
|
+
|
|
52
|
+
constructor(
|
|
53
|
+
private readonly channel: Channel,
|
|
54
|
+
private readonly newline = "\n"
|
|
55
|
+
) {}
|
|
56
|
+
|
|
57
|
+
/** Wait for the prompt, after logging in. */
|
|
58
|
+
async ready(timeout = 30_000): Promise<void> {
|
|
59
|
+
await this.collect(timeout);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Send one line and return what the device printed after it, without the echo or the prompt. */
|
|
63
|
+
async run(line: string, timeout = 60_000): Promise<string> {
|
|
64
|
+
this.channel.write(line + this.newline);
|
|
65
|
+
const output = await this.collect(timeout);
|
|
66
|
+
|
|
67
|
+
const lines = output.split("\n");
|
|
68
|
+
return lines.slice(1, -1).join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Answer a question the device asked. */
|
|
72
|
+
answer(reply: "y" | "n", timeout = 60_000): Promise<string> {
|
|
73
|
+
this.channel.write(reply);
|
|
74
|
+
return this.collect(timeout);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What the device prints until its prompt or a question. A session the device ended, or that went silent, fails. */
|
|
78
|
+
private collect(timeout: number): Promise<string> {
|
|
79
|
+
return new Promise((done, fail) => {
|
|
80
|
+
let raw = "";
|
|
81
|
+
let settle: ReturnType<typeof setTimeout> | undefined;
|
|
82
|
+
|
|
83
|
+
const text = () => raw.replace(FortiOsCli.PAGER, "").replace(/\r/g, "");
|
|
84
|
+
|
|
85
|
+
const stop = () => {
|
|
86
|
+
clearTimeout(limit);
|
|
87
|
+
clearTimeout(settle);
|
|
88
|
+
this.channel.removeListener("data", onData);
|
|
89
|
+
this.channel.removeListener("close", onClose);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const finish = () => {
|
|
93
|
+
stop();
|
|
94
|
+
|
|
95
|
+
const output = text();
|
|
96
|
+
this.prompt = output.split("\n").at(-1) ?? "";
|
|
97
|
+
done(output);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const onData = (chunk: Buffer) => {
|
|
101
|
+
raw += chunk.toString("utf8");
|
|
102
|
+
if (raw.endsWith("--More-- ")) this.channel.write(" ");
|
|
103
|
+
if (FortiOsCli.BANNER.test(raw)) this.channel.write("a");
|
|
104
|
+
|
|
105
|
+
clearTimeout(settle);
|
|
106
|
+
|
|
107
|
+
if (FortiOsCli.LOGGED_OUT.test(text())) {
|
|
108
|
+
stop();
|
|
109
|
+
fail(new Error("the device ended the session"));
|
|
110
|
+
} else if (FortiOsCli.PROMPT.test(text()) || FortiOsCli.QUESTION.test(text())) {
|
|
111
|
+
settle = setTimeout(finish, 300);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const onClose = () => {
|
|
116
|
+
stop();
|
|
117
|
+
fail(new Error("the device ended the session"));
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const limit = setTimeout(() => {
|
|
121
|
+
stop();
|
|
122
|
+
fail(new Error(`no prompt within ${timeout / 1000} seconds: ${text().slice(-200)}`));
|
|
123
|
+
}, timeout);
|
|
124
|
+
|
|
125
|
+
this.channel.on("data", onData);
|
|
126
|
+
this.channel.on("close", onClose);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A failure after which the device reloads its saved configuration, which still says to revert. */
|
|
132
|
+
class Reloaded extends Error {}
|
|
133
|
+
|
|
134
|
+
export class FortiOs extends Platform {
|
|
135
|
+
/** How the FortiOS console asks for a login, and what moves it on from where the last session left it. */
|
|
136
|
+
static readonly CONSOLE: ConsoleDialect = {
|
|
137
|
+
login: /login: $/,
|
|
138
|
+
password: /Password: $/,
|
|
139
|
+
prompt: /[#$] $/,
|
|
140
|
+
logout: "exit",
|
|
141
|
+
others: [
|
|
142
|
+
[FortiOsCli.BANNER, "a"],
|
|
143
|
+
[/--More-- $/, "q"],
|
|
144
|
+
[/\([^)\n]*\) [#$] $/, "end\n"],
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** The release this adapter renders for, and was proved on. */
|
|
149
|
+
private static readonly SUPPORTED = "6.2";
|
|
150
|
+
|
|
151
|
+
/** The longest name the platform takes for an interface, and for an address or service. */
|
|
152
|
+
private static readonly NAME_LENGTH = { interface: 15, object: 79 } as const;
|
|
153
|
+
|
|
154
|
+
/** Names the platform keeps for interfaces of its own. */
|
|
155
|
+
private static readonly RESERVED_INTERFACES = new Set(["any", "modem", "ssl.root"]);
|
|
156
|
+
|
|
157
|
+
/** What the device prints when it refuses a line. */
|
|
158
|
+
private static readonly REFUSED =
|
|
159
|
+
/Command fail\. Return code|Unknown action|parse error|discard the setting|MUST be set|Can not config|isn't supported|(?:^|\n)Invalid /;
|
|
160
|
+
|
|
161
|
+
/** How long a device may take to come back from a reload of its configuration, and how often to try it. */
|
|
162
|
+
private static readonly RELOAD = { attempts: 20, interval: 15_000 } as const;
|
|
163
|
+
|
|
164
|
+
/** Commands that only read: `show`, `get`, a set of `diagnose` and `execute` commands. `diagnose sys session filter` sets only this session's filter; `diagnose sys session clear`, which ends sessions, is not here. `diagnose lldprx` shows what LLDP received. */
|
|
165
|
+
private static readonly READS =
|
|
166
|
+
/^(?:(?:show|get)\s|diagnose (?:sys session (?:filter|list|stat|full-stat)|sys top|hardware deviceinfo nic|npu np6 (?:dce|synproxy-stats|port-list|sse-stats)|netlink aggregate|debug crashlog read|lldprx (?:neighbor|port) (?:summary|details))\b|execute (?:ping|ping6|traceroute|sensor list)\b)/;
|
|
167
|
+
|
|
168
|
+
/** A session filter, which holds only in the shell that set it. */
|
|
169
|
+
private static readonly SESSION_FILTER = /^diagnose sys session filter\b/;
|
|
170
|
+
|
|
171
|
+
readonly name = "fortios";
|
|
172
|
+
|
|
173
|
+
readonly os: OsSupport = {
|
|
174
|
+
name: "Fortinet FortiOS",
|
|
175
|
+
versions: "6.2",
|
|
176
|
+
provedOn: "FG-3700D, FortiOS 6.2.2",
|
|
177
|
+
checked: true,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
readonly extension = ".conf";
|
|
181
|
+
|
|
182
|
+
override readonly console = FortiOs.CONSOLE;
|
|
183
|
+
|
|
184
|
+
unsupported(device: Device): string[] {
|
|
185
|
+
const found: string[] = [];
|
|
186
|
+
|
|
187
|
+
found.push(...FortiOs.systemGaps(device));
|
|
188
|
+
found.push(...FortiOs.managementGaps(device));
|
|
189
|
+
found.push(...FortiOs.interfaceGaps(device));
|
|
190
|
+
found.push(...FortiOs.staticGaps(device));
|
|
191
|
+
found.push(...FortiOsRouting.gaps(device));
|
|
192
|
+
found.push(...FortiOs.firewallGaps(device));
|
|
193
|
+
found.push(...FortiOs.serviceGaps(device));
|
|
194
|
+
|
|
195
|
+
if (device.stp) found.push("stp, ports are routed");
|
|
196
|
+
found.push(...FortiOs.transparentGaps(device));
|
|
197
|
+
|
|
198
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
199
|
+
if (iface.type === "vlan" && iface.mac) found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
|
|
200
|
+
}
|
|
201
|
+
if (Object.keys(device.vrfs ?? {}).length > FortiOsRender.VRFS) found.push(`vrfs, at most ${FortiOsRender.VRFS}`);
|
|
202
|
+
|
|
203
|
+
for (const [name, vrf] of Object.entries(device.vrfs ?? {})) {
|
|
204
|
+
if (vrf.description) found.push(`vrfs.${name}.description, the platform numbers a VRF and keeps no description`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Proved on a FG-3700D on FortiOS 6.2.2, in NAT mode. BFD on a multihop BGP session is refused in FortiOsRouting.gaps, for the reason the device showed.
|
|
208
|
+
found.push(...Derive.unproved(device, ["bfd-timers", "static-route-bfd", "multihop-bfd"]));
|
|
209
|
+
|
|
210
|
+
return found;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
render(device: Device): string {
|
|
214
|
+
return FortiOsRender.of(device);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
read(device: Device, _network: readonly Device[] = [], options: ReadOptions = {}): Promise<string> {
|
|
218
|
+
return FortiOs.withCli(device, (cli) => FortiOs.readConfig(cli, device), options);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The LLDP neighbors the device received, read with `diagnose lldprx neighbor details`. A port hears LLDP only while it, or the aggregate it is in, has `lldp-reception` on. */
|
|
222
|
+
neighbors(device: Device, _network: readonly Device[] = [], options: ReadOptions = {}): Promise<Neighbor[]> {
|
|
223
|
+
return FortiOs.withCli(
|
|
224
|
+
device,
|
|
225
|
+
async (cli) => {
|
|
226
|
+
await FortiOs.checkVersion(cli, device);
|
|
227
|
+
return FortiOs.neighborsOf(await cli.run("diagnose lldprx neighbor details"), device);
|
|
228
|
+
},
|
|
229
|
+
options
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The neighbors in what `diagnose lldprx neighbor details` prints, one `lldprx.neighbor.<n>.<field>: <value>` line per field: `port.txt` is our port, `port.id.data` the far port and `system.name.data` the far device. Our port is given Circuit's name. A neighbor that sent no system name or port is left out.
|
|
235
|
+
*/
|
|
236
|
+
static neighborsOf(text: string, device: Device): Neighbor[] {
|
|
237
|
+
const fields = new Map<string, Map<string, string>>();
|
|
238
|
+
|
|
239
|
+
for (const [, number, field, value] of text.matchAll(/^lldprx\.neighbor\.(\d+)\.([\w.]+): ?(.*)$/gm)) {
|
|
240
|
+
if (!fields.has(number!)) fields.set(number!, new Map());
|
|
241
|
+
fields.get(number!)!.set(field!, value!.trim());
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const circuitName = new Map(
|
|
245
|
+
Catalog.ports(device.platform, device.model).map((port) => [Catalog.spell(device.platform, device.model, port), port])
|
|
246
|
+
);
|
|
247
|
+
const found: Neighbor[] = [];
|
|
248
|
+
|
|
249
|
+
for (const neighbor of fields.values()) {
|
|
250
|
+
const port = neighbor.get("port.txt");
|
|
251
|
+
const system = neighbor.get("system.name.data");
|
|
252
|
+
const remote = neighbor.get("port.id.data");
|
|
253
|
+
if (!port || !system || !remote) continue;
|
|
254
|
+
|
|
255
|
+
found.push({ port: circuitName.get(port) ?? port, system, remote_port: remote });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return found;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
plan(desired: string, current: string, options: ApplyOptions): Plan {
|
|
262
|
+
return FortiOsPlan.of(desired, current, options);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async apply(device: Device, network: readonly Device[], changes: Plan, options: ApplyOptions): Promise<void> {
|
|
266
|
+
await FortiOs.applyOnce(device, changes, options);
|
|
267
|
+
|
|
268
|
+
// Switching the operation mode makes changes of its own, such as a static route in each family and memory logging on, which a plan made before it could not see. One more pass, planned against what the device now runs, takes them back.
|
|
269
|
+
const switched = changes.steps.some((step) => step.send.some((line) => line.startsWith("set opmode ")));
|
|
270
|
+
if (!switched) return;
|
|
271
|
+
|
|
272
|
+
const again = FortiOsPlan.of(FortiOsRender.of(device), await this.read(device, network, options), options);
|
|
273
|
+
if (!again.steps.length) return;
|
|
274
|
+
|
|
275
|
+
console.log(" the operation mode changed; applying what it added on its own");
|
|
276
|
+
await FortiOs.applyOnce(device, again, options);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Replace a prefix list `refresh` fetched. It cannot cut access, so it is sent without a guard, and saved as it goes. `purge` asks to confirm, and is answered yes. */
|
|
280
|
+
async fill(device: Device, _network: readonly Device[], set: string, family: Family, prefixes: string[]): Promise<void> {
|
|
281
|
+
await FortiOs.withCli(device, async (cli) => {
|
|
282
|
+
await FortiOs.checkVersion(cli, device);
|
|
283
|
+
|
|
284
|
+
for (const line of FortiOs.fillLines(set, family, prefixes)) {
|
|
285
|
+
let answer = await cli.run(line);
|
|
286
|
+
if (FortiOsCli.QUESTION.test(cli.prompt) && line === "purge") answer = await cli.answer("y");
|
|
287
|
+
if (FortiOs.REFUSED.test(answer)) throw new Error(`${set}: ${line}\n device said: ${answer.trim().slice(0, 300)}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
console.log(` ${set}: ${prefixes.length} prefixes on the device`);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
readOnly(command: string): boolean {
|
|
295
|
+
return FortiOs.READS.test(command);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Over SSH, each command runs on its own until a session filter is set. The filter holds only in the shell that set it, so it and every command after it run in one shell. */
|
|
299
|
+
async session(device: Device, options: ReadOptions = {}): Promise<ReadSession> {
|
|
300
|
+
if (options.console) {
|
|
301
|
+
const opened = await FortiOs.openConsole(device);
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
run: (command, timeout) => opened.cli.run(command, timeout),
|
|
305
|
+
close: () => void FortiOs.consoleLogout(opened.cli).finally(opened.close),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const client = await FortiOs.login(device);
|
|
310
|
+
let shell: { channel: ClientChannel; cli: FortiOsCli } | undefined;
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
run: async (command, timeout) => {
|
|
314
|
+
if (!shell && !FortiOs.SESSION_FILTER.test(command)) return Ssh.exec(client, command, timeout);
|
|
315
|
+
|
|
316
|
+
if (!shell) {
|
|
317
|
+
const channel = await Ssh.shell(client, 4000);
|
|
318
|
+
shell = { channel, cli: new FortiOsCli(channel) };
|
|
319
|
+
await shell.cli.ready();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return shell.cli.run(command, timeout);
|
|
323
|
+
},
|
|
324
|
+
close: () => {
|
|
325
|
+
shell?.channel.end();
|
|
326
|
+
client.end();
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The lines that replace a prefix list's entries with what a registry returned: every entry is purged, then each prefix added, matching it and anything more specific. */
|
|
332
|
+
static fillLines(set: string, family: Family, prefixes: string[]): string[] {
|
|
333
|
+
const list = new FortiOsBlock();
|
|
334
|
+
prefixes.forEach((prefix, index) => {
|
|
335
|
+
const rule = list.entry(index + 1);
|
|
336
|
+
const network = FortiOsNames.network(prefix);
|
|
337
|
+
rule.set(family === "ipv4" ? "prefix" : "prefix6", family === "ipv4" ? FortiOsNames.subnet(network) : network);
|
|
338
|
+
if (Address.split(network).len < FortiOsRouting.BITS[family]) rule.set("le", FortiOsRouting.BITS[family]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
return [
|
|
342
|
+
`config ${FortiOsRouting.PREFIX_LIST[family]}`,
|
|
343
|
+
`edit ${FortiOsTree.quote(set)}`,
|
|
344
|
+
"config rule",
|
|
345
|
+
"purge",
|
|
346
|
+
...FortiOsTree.lines(list).map((line) => line.trim()),
|
|
347
|
+
"end",
|
|
348
|
+
"next",
|
|
349
|
+
"end",
|
|
350
|
+
];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 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.
|
|
355
|
+
*/
|
|
356
|
+
static withSecrets(line: string): string {
|
|
357
|
+
const key = line.startsWith("set private-key ");
|
|
358
|
+
|
|
359
|
+
const filled = line.replace(Secrets.MARKER, (_, reference: string) => {
|
|
360
|
+
const value = Secrets.valueOf(reference);
|
|
361
|
+
const text = key && !value.includes("-----BEGIN") ? Buffer.from(value, "base64").toString("utf8").trim() : value;
|
|
362
|
+
return text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
return filled.replace(/\r\n?/g, "\n").replace(/\?/g, "\u0016?");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private static async login(device: Device): Promise<Client> {
|
|
369
|
+
const { port, user, password } = Secrets.loginOf(device);
|
|
370
|
+
|
|
371
|
+
return Ssh.connect({
|
|
372
|
+
host: Ssh.hostOf(device),
|
|
373
|
+
port,
|
|
374
|
+
user,
|
|
375
|
+
password,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Log out of a console, so no session is left open on it. */
|
|
380
|
+
private static async consoleLogout(cli: FortiOsCli): Promise<void> {
|
|
381
|
+
await FortiOs.leave(cli).catch(() => undefined);
|
|
382
|
+
await cli.run("exit", 10_000).catch(() => undefined);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Log in on the console and open the CLI. Returns the CLI, a way to log in afresh on the same console, and how to let the console go. */
|
|
386
|
+
private static async openConsole(device: Device) {
|
|
387
|
+
const { console, user, password } = SerialConsole.of(device);
|
|
388
|
+
const opened = await SerialConsole.open(console);
|
|
389
|
+
const login = () => SerialConsole.login(opened.channel, FortiOs.CONSOLE, user, password, console.passwordOnly);
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
await login();
|
|
393
|
+
} catch (error) {
|
|
394
|
+
opened.close();
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return { cli: new FortiOsCli(opened.channel), relogin: login, close: opened.close };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** 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. */
|
|
402
|
+
private static async consoleSession<T>(
|
|
403
|
+
device: Device,
|
|
404
|
+
work: (cli: FortiOsCli, relogin: () => Promise<void>) => Promise<T>
|
|
405
|
+
): Promise<T> {
|
|
406
|
+
const opened = await FortiOs.openConsole(device);
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
const result = await work(opened.cli, opened.relogin);
|
|
410
|
+
await FortiOs.consoleLogout(opened.cli);
|
|
411
|
+
return result;
|
|
412
|
+
} finally {
|
|
413
|
+
opened.close();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Log in, open the CLI, and run `work` in it: through the console when asked, else over SSH. */
|
|
418
|
+
private static async withCli<T>(device: Device, work: (cli: FortiOsCli) => Promise<T>, options: ReadOptions = {}): Promise<T> {
|
|
419
|
+
if (options.console) return FortiOs.consoleSession(device, (cli) => work(cli));
|
|
420
|
+
|
|
421
|
+
const client = await FortiOs.login(device);
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
const channel = await Ssh.shell(client, 4000);
|
|
425
|
+
const cli = new FortiOsCli(channel);
|
|
426
|
+
await cli.ready();
|
|
427
|
+
|
|
428
|
+
const result = await work(cli);
|
|
429
|
+
channel.end();
|
|
430
|
+
return result;
|
|
431
|
+
} finally {
|
|
432
|
+
client.end();
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Refuse a device on a release this adapter was not proved on, before anything is read or sent. */
|
|
437
|
+
private static async checkVersion(cli: FortiOsCli, device: Device): Promise<void> {
|
|
438
|
+
const status = await cli.run("get system status");
|
|
439
|
+
const version = /^Version: \S+ v(\d+)\.(\d+)\.(\d+)/m.exec(status);
|
|
440
|
+
|
|
441
|
+
if (!version) throw new Error(`${device.name} did not say which FortiOS it runs: ${status.slice(0, 200)}`);
|
|
442
|
+
if (`${version[1]}.${version[2]}` !== FortiOs.SUPPORTED) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`${device.name} runs FortiOS ${version[1]}.${version[2]}.${version[3]}; this adapter renders for FortiOS ${FortiOs.SUPPORTED}`
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** 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. */
|
|
450
|
+
private static scrub(text: string): string {
|
|
451
|
+
const root = FortiOsTree.parse(text);
|
|
452
|
+
|
|
453
|
+
for (const { block } of root.blocks.get("system snmp community")?.entries.values() ?? []) {
|
|
454
|
+
if (block.settings.has("name")) block.settings.set("name", '"<redacted>"');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const hide = (block: FortiOsBlock) => {
|
|
458
|
+
for (const [key, value] of block.settings) {
|
|
459
|
+
if (value.startsWith("ENC ")) block.settings.set(key, "ENC <redacted>");
|
|
460
|
+
if (key === "private-key") block.settings.set(key, '"<redacted>"');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
for (const child of block.blocks.values()) hide(child);
|
|
464
|
+
for (const entry of block.entries.values()) hide(entry.block);
|
|
465
|
+
};
|
|
466
|
+
hide(root);
|
|
467
|
+
|
|
468
|
+
return FortiOsTree.print(root);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Every section Circuit manages, as the device holds it, credentials scrubbed. */
|
|
472
|
+
private static async readConfig(cli: FortiOsCli, device: Device): Promise<string> {
|
|
473
|
+
await FortiOs.checkVersion(cli, device);
|
|
474
|
+
|
|
475
|
+
const parts: string[] = [];
|
|
476
|
+
|
|
477
|
+
for (const section of FortiOsRender.SECTIONS) {
|
|
478
|
+
const show = FortiOsRender.FULL.includes(section) ? "show full-configuration" : "show";
|
|
479
|
+
parts.push(await cli.run(`${show} ${section}`, 120_000));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const text = parts.join("\n");
|
|
483
|
+
if (!/^config system global$/m.test(text)) throw new Error(`${device.name} returned no configuration`);
|
|
484
|
+
|
|
485
|
+
return FortiOs.scrub(FortiOsPlan.clean(text));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Leave every `config` and `edit` level, back to the top prompt. */
|
|
489
|
+
private static async leave(cli: FortiOsCli): Promise<void> {
|
|
490
|
+
for (let level = 0; level < 10 && /\(.*\) [#$] $/.test(cli.prompt); level++) await cli.run("end");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Send the lines that set how the device saves changes. */
|
|
494
|
+
private static async saving(cli: FortiOsCli, mode: "revert" | "automatic", seconds?: number): Promise<void> {
|
|
495
|
+
const lines = ["config system global", `set cfg-save ${mode}`, ...(seconds ? [`set cfg-revert-timeout ${seconds}`] : []), "end"];
|
|
496
|
+
|
|
497
|
+
for (const line of lines) {
|
|
498
|
+
const answer = await cli.run(line);
|
|
499
|
+
if (FortiOs.REFUSED.test(answer)) throw new Error(`the device refused ${line}: ${answer.trim().slice(0, 300)}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Send a plan line by line, each applied as it arrives. The device is first set to revert what is not saved, so a refused line or a fresh login that fails reloads its saved configuration. Once access is proved the change is saved, and changes save themselves again.
|
|
505
|
+
*/
|
|
506
|
+
private static async configure(cli: FortiOsCli, plan: Plan, guard: Guard): Promise<void> {
|
|
507
|
+
const minutes = guard.seconds / 60;
|
|
508
|
+
await FortiOs.saving(cli, "revert", guard.seconds);
|
|
509
|
+
|
|
510
|
+
// The session still open reloads the saved configuration at once; if it cannot, the device does when the time runs out.
|
|
511
|
+
const restore = async (reason: string) => {
|
|
512
|
+
await FortiOs.leave(cli).catch(() => undefined);
|
|
513
|
+
await cli.run("execute cfg reload").catch(() => undefined);
|
|
514
|
+
return new Reloaded(
|
|
515
|
+
`${reason}\n the device reloads its saved configuration, which undoes what was sent, at the latest in ${minutes} minutes`
|
|
516
|
+
);
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
for (const step of plan.steps) {
|
|
520
|
+
for (const line of step.send) {
|
|
521
|
+
const answer = await cli.run(FortiOs.withSecrets(line)).catch((error: Error) => error);
|
|
522
|
+
if (answer instanceof Error) throw await restore(`${step.title}: ${line}\n ${answer.message}`);
|
|
523
|
+
if (/System will reload/.test(answer)) {
|
|
524
|
+
throw new Reloaded(
|
|
525
|
+
`${step.title}: ${line}\n the rollback time ran out while the plan was sent, and the device is reloading its saved configuration`
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (FortiOs.REFUSED.test(answer))
|
|
530
|
+
throw await restore(`${step.title}: ${line}\n device said: ${answer.trim().slice(0, 300)}`);
|
|
531
|
+
|
|
532
|
+
// No line of a plan should ask; one that does is answered no and treated as refused.
|
|
533
|
+
if (FortiOsCli.QUESTION.test(cli.prompt)) {
|
|
534
|
+
await cli.answer("n").catch(() => undefined);
|
|
535
|
+
throw await restore(`${step.title}: ${line}\n device asked: ${cli.prompt.trim()}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
console.log(` sent; the device reloads its saved configuration in ${minutes} minutes unless access is proved`);
|
|
541
|
+
|
|
542
|
+
try {
|
|
543
|
+
await guard.prove();
|
|
544
|
+
} catch (error) {
|
|
545
|
+
throw await restore(`a fresh login failed: ${(error as Error).message}`);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const saved = await cli.run("execute cfg save", 120_000);
|
|
549
|
+
if (!/config saved|no config to be saved/.test(saved)) {
|
|
550
|
+
throw new Error(
|
|
551
|
+
`access was proved but the save failed; the device reverts in ${minutes} minutes:\n ${saved.trim().slice(0, 300)}`
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
await FortiOs.saving(cli, "automatic");
|
|
556
|
+
console.log(" logged in again, saved");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* 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.
|
|
561
|
+
*/
|
|
562
|
+
private static async settle(device: Device, options: ReadOptions): Promise<string> {
|
|
563
|
+
for (let attempt = 0; attempt < FortiOs.RELOAD.attempts; attempt++) {
|
|
564
|
+
await new Promise((done) => setTimeout(done, FortiOs.RELOAD.interval));
|
|
565
|
+
|
|
566
|
+
const settled = await FortiOs.withCli(
|
|
567
|
+
device,
|
|
568
|
+
async (cli) => {
|
|
569
|
+
if (!/no config to be reloaded/.test(await cli.run("execute cfg reload"))) return false;
|
|
570
|
+
await FortiOs.saving(cli, "automatic");
|
|
571
|
+
return true;
|
|
572
|
+
},
|
|
573
|
+
options
|
|
574
|
+
).catch(() => false);
|
|
575
|
+
if (settled) return "the device is back and saves changes on its own again";
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return "the device did not come back in time and still reverts every change; set `cfg-save automatic` under `config system global`";
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private static async applyOnce(device: Device, plan: Plan, options: ApplyOptions): Promise<void> {
|
|
582
|
+
const seconds = options.rollback * 60;
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
// 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.
|
|
586
|
+
if (options.console) {
|
|
587
|
+
await FortiOs.consoleSession(device, async (cli, relogin) => {
|
|
588
|
+
await FortiOs.checkVersion(cli, device);
|
|
589
|
+
await FortiOs.configure(cli, plan, { seconds, prove: relogin });
|
|
590
|
+
});
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
await FortiOs.withCli(device, async (cli) => {
|
|
595
|
+
await FortiOs.checkVersion(cli, device);
|
|
596
|
+
|
|
597
|
+
await FortiOs.configure(cli, plan, {
|
|
598
|
+
seconds,
|
|
599
|
+
prove: async () => {
|
|
600
|
+
const proof = await FortiOs.login(device);
|
|
601
|
+
proof.end();
|
|
602
|
+
},
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
} catch (error) {
|
|
606
|
+
if (!(error instanceof Reloaded)) throw error;
|
|
607
|
+
throw new Error(`${error.message}\n ${await FortiOs.settle(device, options)}`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* What transparent mode, which a bridge needs, leaves out: every address but one IPv4 management address outside the bridges, routing beyond static routes for it, NAT and DHCP. Without a management address the device is reached through its console and answers no service.
|
|
613
|
+
*/
|
|
614
|
+
private static transparentGaps(device: Device): string[] {
|
|
615
|
+
if (!FortiOsNames.isTransparent(device)) return [];
|
|
616
|
+
|
|
617
|
+
const found: string[] = [];
|
|
618
|
+
const where = "in transparent mode, which a bridge needs";
|
|
619
|
+
const management = FortiOsNames.transparentManagement(device);
|
|
620
|
+
|
|
621
|
+
if (device.connection.host && device.connection.host !== (management && Address.host(management.address)))
|
|
622
|
+
found.push(`connection.host, ${where}: only the management address, one IPv4 address outside the bridges`);
|
|
623
|
+
|
|
624
|
+
const holders = [
|
|
625
|
+
...Object.entries(device.ports ?? {}).map(([name, port]) => [`ports.${name}`, name, port?.addresses] as const),
|
|
626
|
+
...Object.entries(device.interfaces ?? {}).map(([name, iface]) => [`interfaces.${name}`, name, iface.addresses] as const),
|
|
627
|
+
];
|
|
628
|
+
|
|
629
|
+
for (const [path, name, addresses] of holders) {
|
|
630
|
+
const extra = (addresses ?? []).filter(
|
|
631
|
+
(entry) => !(typeof entry === "string" && management && name === management.name && entry === management.address)
|
|
632
|
+
);
|
|
633
|
+
if (extra.length) found.push(`${path}.addresses, ${where}: one IPv4 address in all, outside the bridges, for management`);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const routing = device.routing;
|
|
637
|
+
if (routing?.bgp || routing?.ospf) found.push(`routing, ${where}: static routes only`);
|
|
638
|
+
if (routing?.bfd) found.push(`routing.bfd, ${where}: BFD is proved in NAT mode only`);
|
|
639
|
+
|
|
640
|
+
for (const [index, route] of (routing?.static ?? []).entries()) {
|
|
641
|
+
const at = `routing.static[${index}]`;
|
|
642
|
+
if (!management) found.push(`${at}, ${where}: a route needs the management address`);
|
|
643
|
+
if (!route.via || Address.family(route.via) !== "ipv4" || route.interface || route.blackhole || route.vrf)
|
|
644
|
+
found.push(`${at}, ${where}: only an IPv4 next hop, for the management address`);
|
|
645
|
+
if (route.bfd) found.push(`${at}.bfd, ${where}: BFD is proved in NAT mode only`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (device.firewall?.nat) found.push(`firewall.nat, ${where}`);
|
|
649
|
+
if (device.dhcp && Object.keys(device.dhcp).length) found.push(`dhcp, ${where}`);
|
|
650
|
+
|
|
651
|
+
if (!management) {
|
|
652
|
+
const services = device.management ?? {};
|
|
653
|
+
for (const service of ["ssh", "telnet", "http", "https", "api", "api_tls", "snmp", "native"] as const) {
|
|
654
|
+
if (services[service]) found.push(`management.${service}, ${where}: the device has no management address to answer on`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return found;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private static systemGaps(device: Device): string[] {
|
|
662
|
+
const found: string[] = [];
|
|
663
|
+
const system = device.system ?? {};
|
|
664
|
+
|
|
665
|
+
if (system.timezone && !FortiOsRender.zoneOf(system.timezone))
|
|
666
|
+
found.push(`system.timezone ${system.timezone}, the platform has no zone for it`);
|
|
667
|
+
if (system.release_channel) found.push("system.release_channel, the platform's firmware has no release channel to follow");
|
|
668
|
+
if (system.transceiver_monitoring !== undefined)
|
|
669
|
+
found.push("system.transceiver_monitoring, the platform reads optics without being told");
|
|
670
|
+
if (system.ip?.syn_cookies) found.push("system.ip.syn_cookies, the platform has a SYN proxy per DoS policy, not SYN cookies");
|
|
671
|
+
|
|
672
|
+
const local = system.logging?.local;
|
|
673
|
+
if (local?.files !== undefined || local?.lines_per_file !== undefined) {
|
|
674
|
+
found.push("system.logging.local.files and lines_per_file, logs kept in memory do not rotate into files");
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if ((system.logging?.remote?.length ?? 0) > FortiOsRender.COLLECTORS)
|
|
678
|
+
found.push(`system.logging.remote, at most ${FortiOsRender.COLLECTORS} collectors`);
|
|
679
|
+
|
|
680
|
+
const servers = system.dns?.servers ?? [];
|
|
681
|
+
for (const family of ["ipv4", "ipv6"] as const) {
|
|
682
|
+
if (servers.filter((server) => Address.family(server) === family).length > FortiOsRender.RESOLVERS.device) {
|
|
683
|
+
found.push(`system.dns.servers, at most ${FortiOsRender.RESOLVERS.device} of each family`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return found;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
private static managementGaps(device: Device): string[] {
|
|
691
|
+
const found: string[] = [];
|
|
692
|
+
const management = device.management ?? {};
|
|
693
|
+
|
|
694
|
+
if (management.api || management.api_tls) found.push("management.api and api_tls, the API is served by management.https");
|
|
695
|
+
if (management.ftp) found.push("management.ftp, the platform has no FTP service");
|
|
696
|
+
if (management.console) found.push("management.console, the console logs in with the device's users");
|
|
697
|
+
if (management.privilege_password) found.push("management.privilege_password, the platform has no privileged mode");
|
|
698
|
+
if (management.native?.interfaces?.length) found.push("management.native.interfaces, FortiManager access runs over IP only");
|
|
699
|
+
if (management.native?.port) found.push("management.native.port, FortiManager access listens on 541");
|
|
700
|
+
if (management.ssh?.auth_retries)
|
|
701
|
+
found.push("management.ssh.auth_retries, the platform counts failed logins per source, not per connection");
|
|
702
|
+
|
|
703
|
+
for (const [name, user] of Object.entries(management.snmp?.users ?? {})) {
|
|
704
|
+
if (user.auth === "sha256")
|
|
705
|
+
found.push(`management.snmp.users.${name}.auth sha256, the platform authenticates with SHA1 or MD5`);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
for (const [name, user] of Object.entries(device.users ?? {})) {
|
|
709
|
+
if ((user.ssh_keys?.length ?? 0) > FortiOsRender.SSH_KEYS)
|
|
710
|
+
found.push(`users.${name}.ssh_keys, at most ${FortiOsRender.SSH_KEYS}`);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return found;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Every name the platform knows a port by. */
|
|
717
|
+
private static portNames(device: Device): Set<string> {
|
|
718
|
+
return new Set(Catalog.ports(device.platform, device.model).map((port) => Catalog.spell(device.platform, device.model, port)));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
private static interfaceGaps(device: Device): string[] {
|
|
722
|
+
const found: string[] = [];
|
|
723
|
+
const vlanInterfaces = new Set(
|
|
724
|
+
Object.values(device.interfaces ?? {}).flatMap((iface) => (iface.type === "vlan" ? [iface.vlan as string] : []))
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
for (const [name, vlan] of Object.entries(device.vlans ?? {})) {
|
|
728
|
+
if (vlan.description) found.push(`vlans.${name}.description, a VLAN interface holds its own`);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
732
|
+
if (!port) continue;
|
|
733
|
+
|
|
734
|
+
const where = `ports.${name}`;
|
|
735
|
+
const speedClass = name.split("-")[0]!;
|
|
736
|
+
if (port.speed && !FortiOsRender.SPEEDS[speedClass]?.[port.speed])
|
|
737
|
+
found.push(`${where}.speed ${port.speed}, not in a ${speedClass} cage`);
|
|
738
|
+
|
|
739
|
+
for (const field of ["access_vlan", "native_vlan", "stp", "storm_control"] as const) {
|
|
740
|
+
if (port[field] !== undefined) found.push(`${where}.${field}, ports are routed`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (port.hardware_offload !== undefined)
|
|
744
|
+
found.push(`${where}.hardware_offload, offload is decided per policy, which can match many interfaces`);
|
|
745
|
+
|
|
746
|
+
if (port.lag) {
|
|
747
|
+
for (const field of ["addresses", "mtu", "vrrp", "vrf", "lldp", "trunk_vlans", "ipv6_ra", "acl"] as const) {
|
|
748
|
+
if (port[field] !== undefined) found.push(`${where}.${field}, a LAG member takes its settings from the LAG`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const acl = port.acl ? device.acls?.[port.acl] : undefined;
|
|
753
|
+
if (acl) found.push(...FortiOs.aclGaps(`acls.${port.acl}`, acl));
|
|
754
|
+
|
|
755
|
+
found.push(...FortiOs.trunkGaps(where, name, port.trunk_vlans, vlanInterfaces, device));
|
|
756
|
+
found.push(...FortiOs.routedGaps(where, port, false));
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const ports = FortiOs.portNames(device);
|
|
760
|
+
|
|
761
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
762
|
+
const where = `interfaces.${name}`;
|
|
763
|
+
|
|
764
|
+
if (iface.type === "wireguard") {
|
|
765
|
+
found.push(`${where}, a wireguard: the platform has no WireGuard`);
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (name.length > FortiOs.NAME_LENGTH.interface)
|
|
770
|
+
found.push(`${where}, a name longer than ${FortiOs.NAME_LENGTH.interface} characters`);
|
|
771
|
+
if (ports.has(name) || FortiOs.RESERVED_INTERFACES.has(name))
|
|
772
|
+
found.push(`${where}, a name the platform gives a port of its own`);
|
|
773
|
+
if (iface.hardware_offload !== undefined)
|
|
774
|
+
found.push(`${where}.hardware_offload, offload is decided per policy, which can match many interfaces`);
|
|
775
|
+
|
|
776
|
+
if (iface.type === "lag") {
|
|
777
|
+
if (iface.id !== undefined) found.push(`${where}.id, the platform names a LAG by its key`);
|
|
778
|
+
for (const field of ["access_vlan", "native_vlan", "stp", "storm_control"] as const) {
|
|
779
|
+
if (iface[field] !== undefined) found.push(`${where}.${field}, LAGs are routed`);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
found.push(...FortiOs.trunkGaps(where, name, iface.trunk_vlans, vlanInterfaces, device));
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (iface.type === "vlan" && !FortiOsRender.vlanParent(device, iface.vlan)) {
|
|
786
|
+
found.push(`${where}, VLAN ${iface.vlan} must be carried tagged by exactly one port or LAG`);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (iface.type === "loopback") {
|
|
790
|
+
if (iface.mtu !== undefined) found.push(`${where}.mtu, a loopback has no MTU of its own`);
|
|
791
|
+
if (iface.vrrp?.length) found.push(`${where}.vrrp, a loopback runs no VRRP`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (iface.type === "gre" || iface.type === "vxlan") {
|
|
795
|
+
if (!FortiOsNames.holderOf(device, iface.local)) found.push(`${where}.local, no interface holds ${iface.local}`);
|
|
796
|
+
if (Address.family(iface.local) !== Address.family(iface.remote)) found.push(`${where}, endpoints of two families`);
|
|
797
|
+
if (iface.vrrp?.length) found.push(`${where}.vrrp, on a tunnel`);
|
|
798
|
+
if (iface.ipv6_ra) found.push(`${where}.ipv6_ra, on a tunnel`);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (iface.type === "gre") {
|
|
802
|
+
for (const entry of iface.addresses ?? []) {
|
|
803
|
+
if (typeof entry !== "string" && Address.family(Address.host(entry.address)) === "ipv6")
|
|
804
|
+
found.push(`${where}.addresses, a peer on an IPv6 address`);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const ipv4 = (iface.addresses ?? []).filter(
|
|
808
|
+
(entry) => Address.family(Address.host(typeof entry === "string" ? entry : entry.address)) === "ipv4"
|
|
809
|
+
);
|
|
810
|
+
if (ipv4.some((entry) => typeof entry === "string"))
|
|
811
|
+
found.push(`${where}.addresses, an IPv4 address on a tunnel takes its peer`);
|
|
812
|
+
if (ipv4.length > 1) found.push(`${where}.addresses, one IPv4 address`);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
found.push(...FortiOs.routedGaps(where, iface, iface.type === "gre"));
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// LLDP runs on ports and aggregates; a VLAN interface has no LLDP settings.
|
|
819
|
+
if (device.lldp && typeof device.lldp === "object") {
|
|
820
|
+
for (const name of device.lldp.interfaces) {
|
|
821
|
+
const settings = device.interfaces?.[name];
|
|
822
|
+
if (settings && settings.type !== "lag") found.push(`lldp.interfaces ${name}, a ${settings.type}`);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return found;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** A port or LAG carries tagged only VLANs that have an interface here, and is the only one carrying each. */
|
|
830
|
+
private static trunkGaps(
|
|
831
|
+
where: string,
|
|
832
|
+
name: string,
|
|
833
|
+
vlans: readonly string[] | undefined,
|
|
834
|
+
withInterface: Set<string>,
|
|
835
|
+
device: Device
|
|
836
|
+
): string[] {
|
|
837
|
+
const found: string[] = [];
|
|
838
|
+
|
|
839
|
+
for (const vlan of vlans ?? []) {
|
|
840
|
+
if (!withInterface.has(vlan)) {
|
|
841
|
+
found.push(`${where}.trunk_vlans ${vlan}, a VLAN with no interface here: the platform routes and does not switch`);
|
|
842
|
+
} else if (FortiOsRender.vlanParent(device, vlan) !== name) {
|
|
843
|
+
found.push(`${where}.trunk_vlans ${vlan}, which another port or LAG also carries`);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return found;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Layer 3 settings the platform cannot express on a port or an interface. Only a tunnel takes an address with a peer. */
|
|
851
|
+
private static routedGaps(
|
|
852
|
+
where: string,
|
|
853
|
+
settings: {
|
|
854
|
+
addresses?: readonly (string | { address: string; peer: string })[];
|
|
855
|
+
vrrp?: readonly { id: number; address: string }[];
|
|
856
|
+
},
|
|
857
|
+
tunnel: boolean
|
|
858
|
+
): string[] {
|
|
859
|
+
const found: string[] = [];
|
|
860
|
+
|
|
861
|
+
if (!tunnel && settings.addresses?.some((entry) => typeof entry !== "string"))
|
|
862
|
+
found.push(`${where}.addresses with a peer, which only a tunnel takes`);
|
|
863
|
+
|
|
864
|
+
for (const group of settings.vrrp ?? []) {
|
|
865
|
+
if (Address.family(group.address) === "ipv6") found.push(`${where}.vrrp ${group.id}, IPv6: the platform runs VRRP for IPv4`);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
return found;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** An ACL as the platform's, which only drop: every drop comes before any accept, and no rule limits a rate or matches a VLAN. */
|
|
872
|
+
private static aclGaps(where: string, rules: readonly AclRule<string>[]): string[] {
|
|
873
|
+
const found: string[] = [];
|
|
874
|
+
|
|
875
|
+
if (FortiOsFirewall.acceptsBeforeDrop(rules)) found.push(`${where}, an accept before a drop: the platform's ACLs only drop`);
|
|
876
|
+
|
|
877
|
+
rules.forEach((rule, index) => {
|
|
878
|
+
if (rule.rate) found.push(`${where}[${index}].rate`);
|
|
879
|
+
if (rule.match?.vlan) found.push(`${where}[${index}].match.vlan, a port's ACL sees no VLAN`);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
return found;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
private static staticGaps(device: Device): string[] {
|
|
886
|
+
const found: string[] = [];
|
|
887
|
+
const seen = new Set<string>();
|
|
888
|
+
|
|
889
|
+
for (const route of device.routing?.static ?? []) {
|
|
890
|
+
const where = `routing.static ${route.prefix}`;
|
|
891
|
+
|
|
892
|
+
if (route.vrf && route.bfd) found.push(`${where}.bfd, BFD in a VRF is not proved on a device yet`);
|
|
893
|
+
if (route.vrf && route.blackhole)
|
|
894
|
+
found.push(`${where}, a blackhole in a VRF: a route takes the VRF of the interface it leaves through`);
|
|
895
|
+
if (route.vrf && route.interface && FortiOsNames.settingsOf(device, route.interface)?.vrf !== route.vrf) {
|
|
896
|
+
found.push(`${where}.interface, which is not in VRF ${route.vrf}`);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (route.via && !route.interface && !route.blackhole && !FortiOsNames.interfaceFor(device, route.via, route.vrf)) {
|
|
900
|
+
found.push(`${where} via ${route.via}, no interface's own network in its table holds the next hop`);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// The device refuses a second route identical to one it has: `Duplicated entry`.
|
|
904
|
+
const key = [
|
|
905
|
+
Address.split(route.prefix).addr,
|
|
906
|
+
Address.split(route.prefix).len,
|
|
907
|
+
route.via,
|
|
908
|
+
route.interface,
|
|
909
|
+
route.blackhole,
|
|
910
|
+
route.distance,
|
|
911
|
+
route.vrf,
|
|
912
|
+
].join(" ");
|
|
913
|
+
if (seen.has(key)) found.push(`${where}, the same route twice`);
|
|
914
|
+
seen.add(key);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
return found;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
private static firewallGaps(device: Device): string[] {
|
|
921
|
+
const found: string[] = [];
|
|
922
|
+
const fw = device.firewall;
|
|
923
|
+
if (!fw) return found;
|
|
924
|
+
|
|
925
|
+
if (fw.filter?.output) found.push("firewall.filter.output, the platform filters no traffic it sends itself");
|
|
926
|
+
if (fw.untracked) found.push("firewall.untracked, every session is tracked");
|
|
927
|
+
|
|
928
|
+
for (const helper of fw.helpers ?? []) {
|
|
929
|
+
if (!FortiOsRender.HELPERS[helper]) found.push(`firewall.helpers ${helper}, the platform has no helper for it`);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
for (const chain of ["forward", "input"] as const) {
|
|
933
|
+
(fw.filter?.[chain]?.rules ?? []).forEach((rule, index) =>
|
|
934
|
+
found.push(...FortiOs.ruleGaps(`firewall.filter.${chain}[${index}]`, rule, chain, device))
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
(fw.nat?.source ?? []).forEach((rule, index) => {
|
|
939
|
+
const where = `firewall.nat.source[${index}]`;
|
|
940
|
+
const match = rule.match;
|
|
941
|
+
|
|
942
|
+
if (FortiOsFirewall.familiesOf(device, match).includes("ipv6") && (match?.family === "ipv6" || match?.src || match?.dst)) {
|
|
943
|
+
found.push(`${where}, IPv6: central NAT translates IPv4`);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (rule.action === "dnat") found.push(`${where}.action dnat, in source NAT`);
|
|
947
|
+
if (rule.action === "snat" && Address.family(rule.to) === "ipv6") found.push(`${where}.to, IPv6: central NAT translates IPv4`);
|
|
948
|
+
if (match?.dst_port !== undefined) found.push(`${where}.match.dst_port, central NAT matches the source port only`);
|
|
949
|
+
if (Array.isArray(match?.src_port)) found.push(`${where}.match.src_port, one port or range`);
|
|
950
|
+
if (rule.action === "snat" && rule.to_port !== undefined && match?.src_port === undefined)
|
|
951
|
+
found.push(`${where}.to_port, without match.src_port`);
|
|
952
|
+
if ((match?.src && match.src_set) || (match?.dst && match.dst_set))
|
|
953
|
+
found.push(`${where}.match, a prefix and a set on one side`);
|
|
954
|
+
if (match?.state || match?.tcp_flags) found.push(`${where}.match.state and tcp_flags`);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
(fw.nat?.destination ?? []).forEach((rule, index) => {
|
|
958
|
+
const where = `firewall.nat.destination[${index}]`;
|
|
959
|
+
const match = rule.match;
|
|
960
|
+
|
|
961
|
+
if (rule.action !== "dnat") {
|
|
962
|
+
found.push(`${where}.action ${rule.action}, in destination NAT`);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
if (!match?.dst || Address.split(match.dst.includes("/") ? match.dst : `${match.dst}/32`).len !== 32) {
|
|
967
|
+
found.push(`${where}.match.dst, one IPv4 address the virtual IP answers on`);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const sources = match?.src ? [match.src] : match?.src_set ? (fw.address_sets?.[match.src_set] ?? []) : [];
|
|
971
|
+
if (Address.family(rule.to) === "ipv6" || sources.some((source) => Address.family(Address.host(source)) === "ipv6"))
|
|
972
|
+
found.push(`${where}, IPv6`);
|
|
973
|
+
if (match?.dst_port !== undefined && Array.isArray(match.dst_port)) found.push(`${where}.match.dst_port, one port or range`);
|
|
974
|
+
if (match?.dst_port !== undefined && match.protocol !== "tcp" && match.protocol !== "udp") {
|
|
975
|
+
found.push(`${where}.match.protocol, tcp or udp with a port`);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (match?.dst_port === undefined && (match?.protocol || rule.to_port)) {
|
|
979
|
+
found.push(`${where}.match.dst_port, a protocol or port is forwarded by its port`);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (match?.src && match.src_set) found.push(`${where}.match, a prefix and a set on one side`);
|
|
983
|
+
|
|
984
|
+
for (const field of ["dst_set", "src_port", "out_interface", "state", "tcp_flags", "family"] as const) {
|
|
985
|
+
if (match?.[field] !== undefined) found.push(`${where}.match.${field}`);
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
for (const name of Object.keys(fw.address_sets ?? {})) {
|
|
990
|
+
if (name.length > FortiOs.NAME_LENGTH.object)
|
|
991
|
+
found.push(`firewall.address_sets.${name}, a name longer than ${FortiOs.NAME_LENGTH.object} characters`);
|
|
992
|
+
if (name.startsWith("circuit-"))
|
|
993
|
+
found.push(`firewall.address_sets.${name}, a name Circuit keeps for the sources of management`);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
return found;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** A filter rule the platform cannot express. */
|
|
1000
|
+
private static ruleGaps(where: string, rule: FilterRule<string, string>, chain: "forward" | "input", device: Device): string[] {
|
|
1001
|
+
const found: string[] = [];
|
|
1002
|
+
const match = rule.match;
|
|
1003
|
+
const state = match?.state ?? [];
|
|
1004
|
+
|
|
1005
|
+
// The platform is stateful: replies pass on their own, and a rule matches a session's first packet.
|
|
1006
|
+
if (state.some((one) => one === "invalid" || one === "untracked")) found.push(`${where}.match.state ${state.join(", ")}`);
|
|
1007
|
+
else if (state.length && rule.action !== "accept") found.push(`${where}.match.state, on a rule that does not accept`);
|
|
1008
|
+
|
|
1009
|
+
if (match?.tcp_flags) found.push(`${where}.match.tcp_flags, the platform matches no TCP flags`);
|
|
1010
|
+
if ((match?.src && match.src_set) || (match?.dst && match.dst_set)) found.push(`${where}.match, a prefix and a set on one side`);
|
|
1011
|
+
if (FortiOsFirewall.isImplied(rule)) return found;
|
|
1012
|
+
|
|
1013
|
+
const families = FortiOsFirewall.familiesOf(device, match);
|
|
1014
|
+
if (!families.length) found.push(`${where}, its addresses share no family`);
|
|
1015
|
+
|
|
1016
|
+
if (match?.src && match.dst && Address.family(Address.host(match.src)) !== Address.family(Address.host(match.dst))) {
|
|
1017
|
+
found.push(`${where}, a source and destination of two families`);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
for (const family of families) {
|
|
1021
|
+
if (FortiOsFirewall.serviceOf(match, family).name.length > FortiOs.NAME_LENGTH.object)
|
|
1022
|
+
found.push(`${where}.match ports, too many for one service`);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
if (chain === "input") {
|
|
1026
|
+
if (match?.out_interface) found.push(`${where}.match.out_interface, on traffic to the device`);
|
|
1027
|
+
if (rule.action === "reject") found.push(`${where}.action reject, the platform drops traffic to itself silently`);
|
|
1028
|
+
if (rule.offload) found.push(`${where}.offload, on traffic to the device`);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
return found;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
private static serviceGaps(device: Device): string[] {
|
|
1035
|
+
const found: string[] = [];
|
|
1036
|
+
|
|
1037
|
+
for (const [name, server] of Object.entries(device.dhcp ?? {})) {
|
|
1038
|
+
if (Address.family(Address.host(server.network)) === "ipv6")
|
|
1039
|
+
found.push(`dhcp.${name}.network, IPv6: DHCPv6 hands out no gateway`);
|
|
1040
|
+
if ((server.dns?.length ?? 0) > FortiOsRender.RESOLVERS.dhcp)
|
|
1041
|
+
found.push(`dhcp.${name}.dns, at most ${FortiOsRender.RESOLVERS.dhcp}`);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const flow = device.flow_export;
|
|
1045
|
+
if (flow?.protocol === "ipfix") found.push("flow_export.protocol ipfix, the platform exports sFlow and NetFlow 9");
|
|
1046
|
+
if (flow && flow.collectors.length > 1) found.push("flow_export.collectors, one collector");
|
|
1047
|
+
for (const name of flow?.interfaces ?? []) {
|
|
1048
|
+
const type = device.interfaces?.[name]?.type;
|
|
1049
|
+
if (type === "gre" || type === "vxlan")
|
|
1050
|
+
found.push(`flow_export.interfaces ${name}, a tunnel: the platform samples the interface it leaves through`);
|
|
1051
|
+
}
|
|
1052
|
+
if (flow?.protocol === "netflow-v9" && flow.sampling !== undefined)
|
|
1053
|
+
found.push("flow_export.sampling, NetFlow here exports every flow");
|
|
1054
|
+
if (flow?.protocol === "sflow" && (flow.sampling === undefined || flow.sampling < 10 || flow.sampling > 99999)) {
|
|
1055
|
+
found.push("flow_export.sampling, required and from 10 to 99999: the platform samples");
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
return found;
|
|
1059
|
+
}
|
|
1060
|
+
}
|