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