@takodotid/circuit 0.43.1 → 1.0.0-rc.2

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 (256) hide show
  1. package/README.md +115 -37
  2. package/package.json +21 -58
  3. package/src/checks/across.ts +402 -0
  4. package/src/checks/built-in.ts +256 -0
  5. package/src/checks/device.ts +436 -0
  6. package/src/checks/index.ts +53 -0
  7. package/src/cli/advisories.ts +108 -0
  8. package/src/cli/app.ts +71 -0
  9. package/src/cli/editor-types.generated.ts +30 -0
  10. package/src/cli/editor-types.ts +69 -0
  11. package/src/cli/format.ts +124 -0
  12. package/src/cli/git-hooks.ts +79 -0
  13. package/src/cli/index.ts +350 -1170
  14. package/src/cli/migrate.ts +554 -0
  15. package/src/cli/project-commands.ts +656 -0
  16. package/src/cli/releases.ts +374 -0
  17. package/src/cli/remember.ts +339 -0
  18. package/src/cli/rename.ts +27 -33
  19. package/src/cli/secrets-password.ts +60 -0
  20. package/src/cli/secrets.ts +258 -0
  21. package/src/cli/setup.ts +300 -0
  22. package/src/cli/signing.ts +23 -0
  23. package/src/cli/text-editor.ts +268 -0
  24. package/src/cli/ui.ts +298 -0
  25. package/src/cli/version.ts +2 -0
  26. package/src/cli/versions.ts +158 -0
  27. package/src/config/address.ts +102 -0
  28. package/src/config/circuit.d.ts +30 -0
  29. package/src/config/common.ts +65 -0
  30. package/src/config/communities.ts +479 -0
  31. package/src/config/define.ts +684 -0
  32. package/src/config/device.ts +164 -0
  33. package/src/config/firewall.ts +150 -0
  34. package/src/config/index.ts +22 -0
  35. package/src/{schema → config}/interfaces.ts +121 -85
  36. package/src/config/keys.ts +434 -0
  37. package/src/config/management.ts +61 -0
  38. package/src/config/merge.ts +335 -0
  39. package/src/config/models.ts +70 -0
  40. package/src/config/monitoring.ts +64 -0
  41. package/src/config/network.ts +180 -0
  42. package/src/config/presets.ts +149 -0
  43. package/src/config/routing.ts +401 -0
  44. package/src/config/services.ts +51 -0
  45. package/src/config/switching.ts +84 -0
  46. package/src/config/system.ts +103 -0
  47. package/src/{schema → config}/time-zones.ts +1 -1
  48. package/src/core/address.ts +1 -1
  49. package/src/core/cabling.ts +8 -43
  50. package/src/core/graph.ts +40 -0
  51. package/src/{integrations → core}/registries.ts +5 -3
  52. package/src/core/secrets-file.ts +539 -0
  53. package/src/core/secrets.ts +83 -64
  54. package/src/core/topology.ts +103 -146
  55. package/src/core/utils.ts +12 -1
  56. package/src/model.ts +83 -0
  57. package/src/platforms/derive.ts +64 -37
  58. package/src/platforms/finding.ts +9 -0
  59. package/src/platforms/fortios/firewall.ts +76 -64
  60. package/src/platforms/fortios/index.ts +189 -125
  61. package/src/platforms/fortios/names.ts +51 -18
  62. package/src/platforms/fortios/render.ts +69 -62
  63. package/src/platforms/fortios/routing.ts +89 -58
  64. package/src/platforms/fortios/vdoms.ts +47 -16
  65. package/src/platforms/junos/TRAPS.md +2 -2
  66. package/src/platforms/junos/bgp.ts +63 -42
  67. package/src/platforms/junos/index.ts +122 -89
  68. package/src/platforms/junos/render.ts +139 -104
  69. package/src/platforms/platform.ts +10 -8
  70. package/src/platforms/raisecom-ros/TRAPS.md +1 -1
  71. package/src/platforms/raisecom-ros/index.ts +89 -70
  72. package/src/platforms/raisecom-ros/render.ts +49 -45
  73. package/src/platforms/routeros/TRAPS.md +3 -3
  74. package/src/platforms/routeros/index.ts +121 -60
  75. package/src/platforms/routeros/render.ts +214 -195
  76. package/src/platforms/vrp/TRAPS.md +1 -1
  77. package/src/platforms/vrp/index.ts +74 -50
  78. package/src/platforms/vrp/render.ts +67 -56
  79. package/src/project/project.ts +725 -0
  80. package/src/studio/app/env.d.ts +9 -0
  81. package/src/studio/app/index.html +18 -0
  82. package/src/studio/app/src/App.vue +95 -0
  83. package/src/studio/app/src/components/AddressesView.vue +38 -0
  84. package/src/studio/app/src/components/CablesView.vue +29 -0
  85. package/src/studio/app/src/components/CompareView.vue +160 -0
  86. package/src/studio/app/src/components/DataTable.vue +49 -0
  87. package/src/studio/app/src/components/DeviceView.vue +237 -0
  88. package/src/studio/app/src/components/DevicesView.vue +32 -0
  89. package/src/studio/app/src/components/Faceplate.vue +75 -0
  90. package/src/studio/app/src/components/GraphView.vue +282 -0
  91. package/src/studio/app/src/components/LineDiff.vue +24 -0
  92. package/src/studio/app/src/components/ProblemsView.vue +42 -0
  93. package/src/studio/app/src/components/SearchView.vue +72 -0
  94. package/src/studio/app/src/components/SecretsView.vue +112 -0
  95. package/src/studio/app/src/components/SessionsView.vue +35 -0
  96. package/src/studio/app/src/components/Settings.vue +95 -0
  97. package/src/studio/app/src/components/ToolsView.vue +118 -0
  98. package/src/studio/app/src/components/VlansView.vue +50 -0
  99. package/src/studio/app/src/lib/csv.ts +19 -0
  100. package/src/studio/{public/diff.js → app/src/lib/diff.ts} +40 -35
  101. package/src/studio/app/src/lib/format.ts +38 -0
  102. package/src/studio/app/src/lib/net.ts +68 -0
  103. package/src/studio/app/src/lib/secrets-crypto.ts +35 -0
  104. package/src/studio/app/src/lib/store.ts +71 -0
  105. package/src/studio/app/src/lib/tools.ts +217 -0
  106. package/src/studio/app/src/main.ts +9 -0
  107. package/src/studio/{public → app/src}/style.css +14 -4
  108. package/src/studio/app/tsconfig.json +17 -0
  109. package/src/studio/app/vite.config.ts +16 -0
  110. package/src/studio/assets.generated.ts +3 -0
  111. package/src/studio/data.ts +96 -188
  112. package/src/studio/git.ts +27 -43
  113. package/src/studio/server.ts +196 -107
  114. package/src/studio/types.ts +141 -0
  115. package/src/transport/console.ts +3 -3
  116. package/src/transport/ssh.ts +1 -1
  117. package/dist/chunks/index-3b343xva.js +0 -177
  118. package/dist/chunks/index-6wb41q67.js +0 -62
  119. package/dist/chunks/index-n7w3tg5y.js +0 -149
  120. package/dist/chunks/index-yzw86rc8.js +0 -921
  121. package/dist/chunks/new-w3zys5y4.js +0 -172
  122. package/dist/chunks/server-cxr9ynxc.js +0 -259
  123. package/dist/cli/index.js +0 -12922
  124. package/dist/helpers/index.js +0 -881
  125. package/dist/index.js +0 -28
  126. package/dist/types/core/address-space.d.ts +0 -62
  127. package/dist/types/core/address.d.ts +0 -44
  128. package/dist/types/core/cabling.d.ts +0 -41
  129. package/dist/types/core/cross-checks.d.ts +0 -28
  130. package/dist/types/core/define.d.ts +0 -79
  131. package/dist/types/core/project-files.d.ts +0 -12
  132. package/dist/types/core/schema-docs.d.ts +0 -25
  133. package/dist/types/core/secrets.d.ts +0 -62
  134. package/dist/types/core/topology.d.ts +0 -96
  135. package/dist/types/core/utils.d.ts +0 -10
  136. package/dist/types/core/validator.d.ts +0 -23
  137. package/dist/types/helpers/bgp.d.ts +0 -195
  138. package/dist/types/helpers/cables.d.ts +0 -69
  139. package/dist/types/helpers/checks.d.ts +0 -36
  140. package/dist/types/helpers/exchange.d.ts +0 -55
  141. package/dist/types/helpers/filters.d.ts +0 -51
  142. package/dist/types/helpers/index.d.ts +0 -14
  143. package/dist/types/helpers/route-server.d.ts +0 -52
  144. package/dist/types/helpers/upstreams.d.ts +0 -192
  145. package/dist/types/index.d.ts +0 -10
  146. package/dist/types/integrations/librenms.d.ts +0 -60
  147. package/dist/types/integrations/netbox.d.ts +0 -89
  148. package/dist/types/integrations/peeringdb.d.ts +0 -24
  149. package/dist/types/integrations/prometheus.d.ts +0 -24
  150. package/dist/types/integrations/registries.d.ts +0 -8
  151. package/dist/types/platforms/bgp-wait.d.ts +0 -24
  152. package/dist/types/platforms/catalog.d.ts +0 -109
  153. package/dist/types/platforms/derive.d.ts +0 -17
  154. package/dist/types/platforms/fortios/firewall.d.ts +0 -47
  155. package/dist/types/platforms/fortios/index.d.ts +0 -140
  156. package/dist/types/platforms/fortios/models.d.ts +0 -14
  157. package/dist/types/platforms/fortios/names.d.ts +0 -52
  158. package/dist/types/platforms/fortios/plan.d.ts +0 -114
  159. package/dist/types/platforms/fortios/render.d.ts +0 -77
  160. package/dist/types/platforms/fortios/routing.d.ts +0 -56
  161. package/dist/types/platforms/fortios/tree.d.ts +0 -37
  162. package/dist/types/platforms/fortios/vdoms.d.ts +0 -50
  163. package/dist/types/platforms/index.d.ts +0 -13
  164. package/dist/types/platforms/junos/bgp.d.ts +0 -44
  165. package/dist/types/platforms/junos/index.d.ts +0 -114
  166. package/dist/types/platforms/junos/models.d.ts +0 -14
  167. package/dist/types/platforms/junos/render.d.ts +0 -154
  168. package/dist/types/platforms/line-diff.d.ts +0 -67
  169. package/dist/types/platforms/platform.d.ts +0 -135
  170. package/dist/types/platforms/raisecom-ros/index.d.ts +0 -71
  171. package/dist/types/platforms/raisecom-ros/models.d.ts +0 -14
  172. package/dist/types/platforms/raisecom-ros/render.d.ts +0 -41
  173. package/dist/types/platforms/routeros/export.d.ts +0 -59
  174. package/dist/types/platforms/routeros/index.d.ts +0 -116
  175. package/dist/types/platforms/routeros/models.d.ts +0 -18
  176. package/dist/types/platforms/routeros/plan.d.ts +0 -58
  177. package/dist/types/platforms/routeros/render.d.ts +0 -250
  178. package/dist/types/platforms/vrp/index.d.ts +0 -83
  179. package/dist/types/platforms/vrp/models.d.ts +0 -12
  180. package/dist/types/platforms/vrp/render.d.ts +0 -96
  181. package/dist/types/schema/common.d.ts +0 -30
  182. package/dist/types/schema/device.d.ts +0 -126
  183. package/dist/types/schema/firewall.d.ts +0 -131
  184. package/dist/types/schema/index.d.ts +0 -8
  185. package/dist/types/schema/interfaces.d.ts +0 -205
  186. package/dist/types/schema/policy.d.ts +0 -107
  187. package/dist/types/schema/routing.d.ts +0 -161
  188. package/dist/types/schema/system.d.ts +0 -198
  189. package/dist/types/schema/time-zones.d.ts +0 -2
  190. package/dist/types/transport/console.d.ts +0 -115
  191. package/dist/types/transport/path-check.d.ts +0 -32
  192. package/dist/types/transport/ssh.d.ts +0 -27
  193. package/src/cli/graph-format.ts +0 -91
  194. package/src/cli/new.ts +0 -234
  195. package/src/core/address-space.ts +0 -320
  196. package/src/core/cross-checks.ts +0 -369
  197. package/src/core/define.ts +0 -124
  198. package/src/core/project-files.ts +0 -35
  199. package/src/core/schema-docs.ts +0 -174
  200. package/src/core/validator.ts +0 -508
  201. package/src/helpers/bgp.ts +0 -473
  202. package/src/helpers/cables.ts +0 -74
  203. package/src/helpers/checks.ts +0 -201
  204. package/src/helpers/exchange.ts +0 -123
  205. package/src/helpers/filters.ts +0 -107
  206. package/src/helpers/index.ts +0 -25
  207. package/src/helpers/route-server.ts +0 -212
  208. package/src/helpers/upstreams.ts +0 -369
  209. package/src/index.ts +0 -13
  210. package/src/integrations/librenms.ts +0 -291
  211. package/src/integrations/netbox.ts +0 -620
  212. package/src/integrations/peeringdb.ts +0 -173
  213. package/src/integrations/prometheus.ts +0 -90
  214. package/src/schema/common.ts +0 -41
  215. package/src/schema/device.ts +0 -145
  216. package/src/schema/firewall.ts +0 -140
  217. package/src/schema/index.ts +0 -8
  218. package/src/schema/policy.ts +0 -115
  219. package/src/schema/routing.ts +0 -173
  220. package/src/schema/system.ts +0 -203
  221. package/src/studio/public/app.js +0 -186
  222. package/src/studio/public/compare.js +0 -250
  223. package/src/studio/public/dom.js +0 -138
  224. package/src/studio/public/evaluate.js +0 -687
  225. package/src/studio/public/export.js +0 -130
  226. package/src/studio/public/graph.js +0 -1541
  227. package/src/studio/public/index.html +0 -83
  228. package/src/studio/public/net.js +0 -134
  229. package/src/studio/public/tables.js +0 -1289
  230. package/src/studio/public/tools.js +0 -610
  231. package/templates/colocation/checks.ts +0 -53
  232. package/templates/colocation/circuit.config.ts +0 -9
  233. package/templates/colocation/edge-01.ts +0 -133
  234. package/templates/colocation/routing.ts +0 -13
  235. package/templates/colocation/tenancy.ts +0 -76
  236. package/templates/colocation/tenants/acme.ts +0 -19
  237. package/templates/colocation/tenants/index.ts +0 -6
  238. package/templates/colocation/tor-01.ts +0 -33
  239. package/templates/edge-router/checks.ts +0 -20
  240. package/templates/edge-router/circuit.config.ts +0 -8
  241. package/templates/edge-router/edge-01.ts +0 -110
  242. package/templates/edge-router/routing.ts +0 -13
  243. package/templates/project/AGENTS.md +0 -51
  244. package/templates/project/CLAUDE.md +0 -1
  245. package/templates/project/README.md +0 -16
  246. package/templates/project/gitignore +0 -5
  247. package/templates/project/tsconfig.json +0 -13
  248. package/templates/single-site/checks.ts +0 -26
  249. package/templates/single-site/circuit.config.ts +0 -8
  250. package/templates/single-site/router.ts +0 -86
  251. package/templates/single-site/site.ts +0 -18
  252. package/templates/single-site/switch.ts +0 -34
  253. /package/src/studio/{public → app/public}/favicon.svg +0 -0
  254. /package/src/studio/{public → app/public}/special-gothic-latin-ext.woff2 +0 -0
  255. /package/src/studio/{public → app/public}/special-gothic-latin.woff2 +0 -0
  256. /package/src/studio/{public → app/public}/special-gothic-license.txt +0 -0
@@ -6,11 +6,13 @@ import type { Client, ClientChannel } from "ssh2";
6
6
  import { Address } from "../../core/address";
7
7
  import type { Neighbor } from "../../core/cabling";
8
8
  import { Secrets } from "../../core/secrets";
9
- import type { AclRule, Device, Family, FilterRule } from "../../schema";
9
+ import type { AclRule, Device, Family, FilterRule, Gre, Lag, VlanInterface, Vxlan } from "../../model";
10
10
  import { SerialConsole, type ConsoleDialect } from "../../transport/console";
11
11
  import { Ssh } from "../../transport/ssh";
12
12
  import { Catalog } from "../catalog";
13
13
  import { Derive } from "../derive";
14
+ import type { Finding } from "../finding";
15
+
14
16
  import { type ApplyOptions, type OsSupport, type Plan, type ReadOptions, type ReadSession, Platform } from "../platform";
15
17
  import { FortiOsFirewall } from "./firewall";
16
18
  import { FortiOsNames } from "./names";
@@ -129,6 +131,16 @@ export class FortiOsCli {
129
131
  }
130
132
  }
131
133
 
134
+ /** The fields of a match that may hold a list or an interface set before Circuit writes them out. */
135
+ type ListedMatch = {
136
+ src?: unknown;
137
+ dst?: unknown;
138
+ in_interface?: unknown;
139
+ out_interface?: unknown;
140
+ in_interface_set?: string;
141
+ out_interface_set?: string;
142
+ };
143
+
132
144
  /** A failure after which the device reloads its saved configuration, which still says to revert. */
133
145
  class Reloaded extends Error {}
134
146
 
@@ -182,6 +194,11 @@ export class FortiOs extends Platform {
182
194
 
183
195
  override readonly console = FortiOs.CONSOLE;
184
196
 
197
+ /** A management VDOM that cannot work: named `root`, listing a LAG's port instead of the LAG, or with a route that leaves through an interface outside it or goes nowhere. */
198
+ checks(device: Device): Finding[] {
199
+ return FortiOsVdoms.problems(device).map((message) => ({ level: "error", device: device.name, message }));
200
+ }
201
+
185
202
  unsupported(device: Device): string[] {
186
203
  const found: string[] = [];
187
204
 
@@ -193,21 +210,34 @@ export class FortiOs extends Platform {
193
210
  found.push(...FortiOs.firewallGaps(device));
194
211
  found.push(...FortiOs.serviceGaps(device));
195
212
 
196
- if (device.stp) found.push("stp, ports are routed");
213
+ if (device.switching?.stp) found.push("switching.stp, ports are routed");
197
214
  found.push(...FortiOs.transparentGaps(device));
198
215
  found.push(...FortiOsVdoms.gaps(device));
199
216
 
200
- for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
201
- if (iface.type === "vlan" && iface.mac) found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
217
+ for (const [name, iface] of Object.entries(device.interfaces?.vlan_interface ?? {})) {
218
+ if (iface?.mac) found.push(`interfaces.vlan_interface.${name}.mac, which only RouterOS sets for now`);
202
219
  }
203
- if (Object.keys(device.vrfs ?? {}).length > FortiOsRender.VRFS) found.push(`vrfs, at most ${FortiOsRender.VRFS}`);
220
+ const vrfs = device.routing?.vrfs ?? {};
221
+ if (Object.keys(vrfs).length > FortiOsRender.VRFS) found.push(`routing.vrfs, at most ${FortiOsRender.VRFS}`);
204
222
 
205
- for (const [name, vrf] of Object.entries(device.vrfs ?? {})) {
206
- if (vrf.description) found.push(`vrfs.${name}.description, the platform numbers a VRF and keeps no description`);
223
+ for (const [name, vrf] of Object.entries(vrfs)) {
224
+ if (vrf?.description) found.push(`routing.vrfs.${name}.description, the platform numbers a VRF and keeps no description`);
207
225
  }
208
226
 
209
227
  // 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.
210
- found.push(...Derive.unproved(device, ["bfd-timers", "static-route-bfd", "multihop-bfd"]));
228
+ // A management VDOM is rendered but not proved: `experimental()` gives it.
229
+ found.push(...Derive.unproved(device, ["bfd-timers", "static-route-bfd", "multihop-bfd", "management-vdom"]));
230
+
231
+ return found;
232
+ }
233
+
234
+ override experimental(device: Device): string[] {
235
+ const found: string[] = [];
236
+
237
+ if (device.system?.vdoms) found.push("system.vdoms, a management VDOM");
238
+ for (const route of device.routing?.static_routes ?? []) {
239
+ if (route.vrf && route.bfd) found.push(`routing.static_routes ${route.prefix}.bfd, BFD in a VRF`);
240
+ }
211
241
 
212
242
  return found;
213
243
  }
@@ -285,7 +315,7 @@ export class FortiOs extends Platform {
285
315
  await FortiOs.checkVersion(cli, device);
286
316
 
287
317
  const lines = FortiOs.fillLines(set, family, prefixes);
288
- for (const line of device.vdoms ? FortiOsVdoms.wrap({ kind: "vdom", vdom: FortiOsVdoms.ROOT }, lines) : lines) {
318
+ for (const line of device.system?.vdoms ? FortiOsVdoms.wrap({ kind: "vdom", vdom: FortiOsVdoms.ROOT }, lines) : lines) {
289
319
  let answer = await cli.run(line);
290
320
  if (FortiOsCli.QUESTION.test(cli.prompt) && line === "purge") answer = await cli.answer("y");
291
321
  if (FortiOs.REFUSED.test(answer)) throw new Error(`${set}: ${line}\n device said: ${answer.trim().slice(0, 300)}`);
@@ -390,7 +420,7 @@ export class FortiOs extends Platform {
390
420
  private static async openConsole(device: Device) {
391
421
  const { console, user, password } = SerialConsole.of(device);
392
422
  const opened = await SerialConsole.open(console);
393
- const login = () => SerialConsole.login(opened.channel, FortiOs.CONSOLE, user, password, console.passwordOnly);
423
+ const login = () => SerialConsole.login(opened.channel, FortiOs.CONSOLE, user, password, console.password_only);
394
424
 
395
425
  try {
396
426
  await login();
@@ -454,7 +484,7 @@ export class FortiOs extends Platform {
454
484
 
455
485
  /** Run `work` among the device's own settings: inside `config global` on a device with VDOMs, as it is on one without. */
456
486
  private static async inGlobal<T>(cli: FortiOsCli, device: Device | boolean, work: () => Promise<T>): Promise<T> {
457
- const multi = typeof device === "boolean" ? device : Boolean(device.vdoms);
487
+ const multi = typeof device === "boolean" ? device : Boolean(device.system?.vdoms);
458
488
  if (!multi) return work();
459
489
 
460
490
  await cli.run("config global");
@@ -511,7 +541,7 @@ export class FortiOs extends Platform {
511
541
  */
512
542
  private static async readVdoms(cli: FortiOsCli, device: Device, show: (section: string) => Promise<string>): Promise<string[]> {
513
543
  const management = FortiOsVdoms.managementOf(device);
514
- if (!management) throw new Error(`${device.name} runs several VDOMs and its config has no vdoms; write vdoms.management`);
544
+ if (!management) throw new Error(`${device.name} runs several VDOMs and its config has no vdoms; write system.vdoms.management`);
515
545
 
516
546
  const parts: string[] = [];
517
547
  const inVdom = async (vdom: string, sections: readonly string[]) => {
@@ -624,7 +654,7 @@ export class FortiOs extends Platform {
624
654
  async (cli) => {
625
655
  const reloaded = await FortiOs.inGlobal(cli, device, () => cli.run("execute cfg reload"));
626
656
  if (!/no config to be reloaded/.test(reloaded)) return false;
627
- await FortiOs.saving(cli, "automatic", Boolean(device.vdoms));
657
+ await FortiOs.saving(cli, "automatic", Boolean(device.system?.vdoms));
628
658
  return true;
629
659
  },
630
660
  options
@@ -643,7 +673,7 @@ export class FortiOs extends Platform {
643
673
  if (options.console) {
644
674
  await FortiOs.consoleSession(device, async (cli, relogin) => {
645
675
  await FortiOs.checkVersion(cli, device);
646
- await FortiOs.configure(cli, plan, { seconds, prove: relogin }, Boolean(device.vdoms));
676
+ await FortiOs.configure(cli, plan, { seconds, prove: relogin }, Boolean(device.system?.vdoms));
647
677
  });
648
678
  return;
649
679
  }
@@ -661,7 +691,7 @@ export class FortiOs extends Platform {
661
691
  proof.end();
662
692
  },
663
693
  },
664
- Boolean(device.vdoms)
694
+ Boolean(device.system?.vdoms)
665
695
  );
666
696
  });
667
697
  } catch (error) {
@@ -684,8 +714,10 @@ export class FortiOs extends Platform {
684
714
  found.push(`connection.host, ${where}: only the management address, one IPv4 address outside the bridges`);
685
715
 
686
716
  const holders = [
687
- ...Object.entries(device.ports ?? {}).map(([name, port]) => [`ports.${name}`, name, port?.addresses] as const),
688
- ...Object.entries(device.interfaces ?? {}).map(([name, iface]) => [`interfaces.${name}`, name, iface.addresses] as const),
717
+ ...Derive.ports(device).map(([name, port]) => [`interfaces.ethernet.${name}`, name, port.addresses] as const),
718
+ ...FortiOsNames.interfacesOf(device).map(
719
+ ({ name, kind, settings }) => [`interfaces.${kind}.${name}`, name, settings.addresses] as const
720
+ ),
689
721
  ];
690
722
 
691
723
  for (const [path, name, addresses] of holders) {
@@ -699,22 +731,25 @@ export class FortiOs extends Platform {
699
731
  if (routing?.bgp || routing?.ospf) found.push(`routing, ${where}: static routes only`);
700
732
  if (routing?.bfd) found.push(`routing.bfd, ${where}: BFD is proved in NAT mode only`);
701
733
 
702
- for (const [index, route] of (routing?.static ?? []).entries()) {
703
- const at = `routing.static[${index}]`;
734
+ for (const [index, route] of (routing?.static_routes ?? []).entries()) {
735
+ const at = `routing.static_routes[${index}]`;
704
736
  if (!management) found.push(`${at}, ${where}: a route needs the management address`);
705
- if (!route.via || Address.family(route.via) !== "ipv4" || route.interface || route.blackhole || route.vrf)
737
+ if (!route.next_hop || Address.family(route.next_hop) !== "ipv4" || route.interface || route.blackhole || route.vrf)
706
738
  found.push(`${at}, ${where}: only an IPv4 next hop, for the management address`);
707
739
  if (route.bfd) found.push(`${at}.bfd, ${where}: BFD is proved in NAT mode only`);
708
740
  }
709
741
 
710
742
  if (device.firewall?.nat) found.push(`firewall.nat, ${where}`);
711
- if (device.dhcp && Object.keys(device.dhcp).length) found.push(`dhcp, ${where}`);
743
+ if (device.services?.dhcp_server && Object.keys(device.services.dhcp_server).length) found.push(`services.dhcp_server, ${where}`);
712
744
 
713
745
  if (!management) {
714
746
  const services = device.management ?? {};
715
- for (const service of ["ssh", "telnet", "http", "https", "api", "api_tls", "snmp", "native"] as const) {
747
+ for (const service of ["ssh", "telnet", "http", "https", "api", "api_tls"] as const) {
716
748
  if (services[service]) found.push(`management.${service}, ${where}: the device has no management address to answer on`);
717
749
  }
750
+ if (device.monitoring?.snmp) found.push(`monitoring.snmp, ${where}: the device has no management address to answer on`);
751
+ if (services.native_protocol)
752
+ found.push(`management.native_protocol, ${where}: the device has no management address to answer on`);
718
753
  }
719
754
 
720
755
  return found;
@@ -727,17 +762,18 @@ export class FortiOs extends Platform {
727
762
  if (system.timezone && !FortiOsRender.zoneOf(system.timezone))
728
763
  found.push(`system.timezone ${system.timezone}, the platform has no zone for it`);
729
764
  if (system.release_channel) found.push("system.release_channel, the platform's firmware has no release channel to follow");
730
- if (system.transceiver_monitoring !== undefined)
731
- found.push("system.transceiver_monitoring, the platform reads optics without being told");
732
- if (system.ip?.syn_cookies) found.push("system.ip.syn_cookies, the platform has a SYN proxy per DoS policy, not SYN cookies");
765
+ if (device.monitoring?.transceivers !== undefined)
766
+ found.push("monitoring.transceivers, the platform reads optics without being told");
767
+ if (system.ip_stack?.syn_cookies)
768
+ found.push("system.ip_stack.syn_cookies, the platform has a SYN proxy per DoS policy, not SYN cookies");
733
769
 
734
770
  const local = system.logging?.local;
735
771
  if (local?.files !== undefined || local?.lines_per_file !== undefined) {
736
772
  found.push("system.logging.local.files and lines_per_file, logs kept in memory do not rotate into files");
737
773
  }
738
774
 
739
- if ((system.logging?.remote?.length ?? 0) > FortiOsRender.COLLECTORS)
740
- found.push(`system.logging.remote, at most ${FortiOsRender.COLLECTORS} collectors`);
775
+ if ((device.monitoring?.syslog?.length ?? 0) > FortiOsRender.COLLECTORS)
776
+ found.push(`monitoring.syslog, at most ${FortiOsRender.COLLECTORS} collectors`);
741
777
 
742
778
  const servers = system.dns?.servers ?? [];
743
779
  for (const family of ["ipv4", "ipv6"] as const) {
@@ -757,19 +793,20 @@ export class FortiOs extends Platform {
757
793
  if (management.ftp) found.push("management.ftp, the platform has no FTP service");
758
794
  if (management.console) found.push("management.console, the console logs in with the device's users");
759
795
  if (management.privilege_password) found.push("management.privilege_password, the platform has no privileged mode");
760
- if (management.native?.interfaces?.length) found.push("management.native.interfaces, FortiManager access runs over IP only");
761
- if (management.native?.port) found.push("management.native.port, FortiManager access listens on 541");
796
+ if (management.native_protocol?.interfaces?.length)
797
+ found.push("management.native_protocol.interfaces, FortiManager access runs over IP only");
798
+ if (management.native_protocol?.port) found.push("management.native_protocol.port, FortiManager access listens on 541");
762
799
  if (management.ssh?.auth_retries)
763
800
  found.push("management.ssh.auth_retries, the platform counts failed logins per source, not per connection");
764
801
 
765
- for (const [name, user] of Object.entries(management.snmp?.users ?? {})) {
802
+ for (const [name, user] of Object.entries(device.monitoring?.snmp?.users ?? {})) {
766
803
  if (user.auth === "sha256")
767
- found.push(`management.snmp.users.${name}.auth sha256, the platform authenticates with SHA1 or MD5`);
804
+ found.push(`monitoring.snmp.users.${name}.auth sha256, the platform authenticates with SHA1 or MD5`);
768
805
  }
769
806
 
770
- for (const [name, user] of Object.entries(device.users ?? {})) {
771
- if ((user.ssh_keys?.length ?? 0) > FortiOsRender.SSH_KEYS)
772
- found.push(`users.${name}.ssh_keys, at most ${FortiOsRender.SSH_KEYS}`);
807
+ for (const [name, user] of Object.entries(device.system?.users ?? {})) {
808
+ if ((user?.ssh_keys?.length ?? 0) > FortiOsRender.SSH_KEYS)
809
+ found.push(`system.users.${name}.ssh_keys, at most ${FortiOsRender.SSH_KEYS}`);
773
810
  }
774
811
 
775
812
  return found;
@@ -783,17 +820,13 @@ export class FortiOs extends Platform {
783
820
  private static interfaceGaps(device: Device): string[] {
784
821
  const found: string[] = [];
785
822
  const vlanInterfaces = new Set(
786
- Object.values(device.interfaces ?? {}).flatMap((iface) => (iface.type === "vlan" ? [iface.vlan as string] : []))
823
+ Object.values(device.interfaces?.vlan_interface ?? {}).flatMap((iface) => (iface ? [iface.vlan] : []))
787
824
  );
788
825
 
789
- for (const [name, vlan] of Object.entries(device.vlans ?? {})) {
790
- if (vlan.description) found.push(`vlans.${name}.description, a VLAN interface holds its own`);
791
- }
792
-
793
- for (const [name, port] of Object.entries(device.ports ?? {})) {
794
- if (!port) continue;
826
+ // A VLAN's description is dropped, as `switching.vlans` says: the platform knows a VLAN by its number, and a VLAN interface holds a description of its own.
795
827
 
796
- const where = `ports.${name}`;
828
+ for (const [name, port] of Derive.ports(device)) {
829
+ const where = `interfaces.ethernet.${name}`;
797
830
  const speedClass = name.split("-")[0]!;
798
831
  if (port.speed && !FortiOsRender.SPEEDS[speedClass]?.[port.speed])
799
832
  found.push(`${where}.speed ${port.speed}, not in a ${speedClass} cage`);
@@ -805,14 +838,14 @@ export class FortiOs extends Platform {
805
838
  if (port.hardware_offload !== undefined)
806
839
  found.push(`${where}.hardware_offload, offload is decided per policy, which can match many interfaces`);
807
840
 
808
- if (port.lag) {
809
- for (const field of ["addresses", "mtu", "vrrp", "vrf", "lldp", "trunk_vlans", "ipv6_ra", "acl"] as const) {
841
+ if (port.in_lag) {
842
+ for (const field of ["addresses", "mtu", "vrrp", "vrf", "lldp", "trunk_vlans", "ipv6_ra", "ingress_acl"] as const) {
810
843
  if (port[field] !== undefined) found.push(`${where}.${field}, a LAG member takes its settings from the LAG`);
811
844
  }
812
845
  }
813
846
 
814
- const acl = port.acl ? device.acls?.[port.acl] : undefined;
815
- if (acl) found.push(...FortiOs.aclGaps(`acls.${port.acl}`, acl));
847
+ const acl = port.ingress_acl ? device.switching?.acls?.[port.ingress_acl] : undefined;
848
+ if (acl) found.push(...FortiOs.aclGaps(`switching.acls.${port.ingress_acl}`, acl));
816
849
 
817
850
  found.push(...FortiOs.trunkGaps(where, name, port.trunk_vlans, vlanInterfaces, device));
818
851
  found.push(...FortiOs.routedGaps(where, port, false));
@@ -820,10 +853,10 @@ export class FortiOs extends Platform {
820
853
 
821
854
  const ports = FortiOs.portNames(device);
822
855
 
823
- for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
824
- const where = `interfaces.${name}`;
856
+ for (const { name, kind, settings: iface } of FortiOsNames.interfacesOf(device)) {
857
+ const where = `interfaces.${kind}.${name}`;
825
858
 
826
- if (iface.type === "wireguard") {
859
+ if (kind === "wireguard") {
827
860
  found.push(`${where}, a wireguard: the platform has no WireGuard`);
828
861
  continue;
829
862
  }
@@ -835,32 +868,38 @@ export class FortiOs extends Platform {
835
868
  if (iface.hardware_offload !== undefined)
836
869
  found.push(`${where}.hardware_offload, offload is decided per policy, which can match many interfaces`);
837
870
 
838
- if (iface.type === "lag") {
839
- if (iface.id !== undefined) found.push(`${where}.id, the platform names a LAG by its key`);
871
+ if (kind === "lag") {
872
+ const lag = iface as Lag;
873
+ if (lag.id !== undefined) found.push(`${where}.id, the platform names a LAG by its key`);
840
874
  for (const field of ["access_vlan", "native_vlan", "stp", "storm_control"] as const) {
841
- if (iface[field] !== undefined) found.push(`${where}.${field}, LAGs are routed`);
875
+ if (lag[field] !== undefined) found.push(`${where}.${field}, LAGs are routed`);
842
876
  }
843
877
 
844
- found.push(...FortiOs.trunkGaps(where, name, iface.trunk_vlans, vlanInterfaces, device));
878
+ found.push(...FortiOs.trunkGaps(where, name, lag.trunk_vlans, vlanInterfaces, device));
845
879
  }
846
880
 
847
- if (iface.type === "vlan" && !FortiOsRender.vlanParent(device, iface.vlan)) {
848
- found.push(`${where}, VLAN ${iface.vlan} must be carried tagged by exactly one port or LAG`);
881
+ if (kind === "vlan_interface") {
882
+ const vlan = (iface as VlanInterface).vlan;
883
+ if (!FortiOsRender.vlanParent(device, vlan))
884
+ found.push(`${where}, VLAN ${vlan} must be carried tagged by exactly one port or LAG`);
849
885
  }
850
886
 
851
- if (iface.type === "loopback") {
887
+ if (kind === "loopback") {
852
888
  if (iface.mtu !== undefined) found.push(`${where}.mtu, a loopback has no MTU of its own`);
853
889
  if (iface.vrrp?.length) found.push(`${where}.vrrp, a loopback runs no VRRP`);
854
890
  }
855
891
 
856
- if (iface.type === "gre" || iface.type === "vxlan") {
857
- if (!FortiOsNames.holderOf(device, iface.local)) found.push(`${where}.local, no interface holds ${iface.local}`);
858
- if (Address.family(iface.local) !== Address.family(iface.remote)) found.push(`${where}, endpoints of two families`);
859
- if (iface.vrrp?.length) found.push(`${where}.vrrp, on a tunnel`);
860
- if (iface.ipv6_ra) found.push(`${where}.ipv6_ra, on a tunnel`);
892
+ if (kind === "gre" || kind === "vxlan") {
893
+ const tunnel = iface as Gre | Vxlan;
894
+ if (!FortiOsNames.holderOf(device, tunnel.local_address))
895
+ found.push(`${where}.local_address, no interface holds ${tunnel.local_address}`);
896
+ if (Address.family(tunnel.local_address) !== Address.family(tunnel.remote_address))
897
+ found.push(`${where}, endpoints of two families`);
898
+ if (tunnel.vrrp?.length) found.push(`${where}.vrrp, on a tunnel`);
899
+ if (tunnel.ipv6_ra) found.push(`${where}.ipv6_ra, on a tunnel`);
861
900
  }
862
901
 
863
- if (iface.type === "gre") {
902
+ if (kind === "gre") {
864
903
  for (const entry of iface.addresses ?? []) {
865
904
  if (typeof entry !== "string" && Address.family(Address.host(entry.address)) === "ipv6")
866
905
  found.push(`${where}.addresses, a peer on an IPv6 address`);
@@ -874,14 +913,15 @@ export class FortiOs extends Platform {
874
913
  if (ipv4.length > 1) found.push(`${where}.addresses, one IPv4 address`);
875
914
  }
876
915
 
877
- found.push(...FortiOs.routedGaps(where, iface, iface.type === "gre"));
916
+ found.push(...FortiOs.routedGaps(where, iface, kind === "gre"));
878
917
  }
879
918
 
880
919
  // LLDP runs on ports and aggregates; a VLAN interface has no LLDP settings.
881
- if (device.lldp && typeof device.lldp === "object") {
882
- for (const name of device.lldp.interfaces) {
883
- const settings = device.interfaces?.[name];
884
- if (settings && settings.type !== "lag") found.push(`lldp.interfaces ${name}, a ${settings.type}`);
920
+ const lldp = device.switching?.lldp;
921
+ if (lldp && typeof lldp === "object") {
922
+ for (const name of lldp.interfaces) {
923
+ const kind = FortiOsNames.interfacesOf(device).find((entry) => entry.name === name)?.kind;
924
+ if (kind && kind !== "lag") found.push(`switching.lldp.interfaces ${name}, a ${kind}`);
885
925
  }
886
926
  }
887
927
 
@@ -913,7 +953,7 @@ export class FortiOs extends Platform {
913
953
  private static routedGaps(
914
954
  where: string,
915
955
  settings: {
916
- addresses?: readonly (string | { address: string; peer: string })[];
956
+ addresses?: readonly (string | { address: string; peer_address: string })[];
917
957
  vrrp?: readonly { id: number; address: string }[];
918
958
  },
919
959
  tunnel: boolean
@@ -931,42 +971,57 @@ export class FortiOs extends Platform {
931
971
  }
932
972
 
933
973
  /** 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. */
934
- private static aclGaps(where: string, rules: readonly AclRule<string>[]): string[] {
974
+ private static aclGaps(where: string, rules: readonly AclRule[]): string[] {
935
975
  const found: string[] = [];
936
976
 
937
977
  if (FortiOsFirewall.acceptsBeforeDrop(rules)) found.push(`${where}, an accept before a drop: the platform's ACLs only drop`);
938
978
 
939
979
  rules.forEach((rule, index) => {
940
980
  if (rule.rate) found.push(`${where}[${index}].rate`);
941
- if (rule.match?.vlan) found.push(`${where}[${index}].match.vlan, a port's ACL sees no VLAN`);
981
+ if (rule.if?.vlan) found.push(`${where}[${index}].if.vlan, a port's ACL sees no VLAN`);
982
+ found.push(...FortiOs.listGaps(`${where}[${index}]`, rule.if));
942
983
  });
943
984
 
944
985
  return found;
945
986
  }
946
987
 
988
+ /** Lists in a rule's match where the platform takes one value, and interface sets: Circuit writes them out as one rule per value before the platform reads them. */
989
+ private static listGaps(where: string, match: ListedMatch | undefined): string[] {
990
+ const found: string[] = [];
991
+
992
+ for (const field of ["src", "dst", "in_interface", "out_interface"] as const) {
993
+ const value = match?.[field];
994
+ if (Array.isArray(value) && value.length > 1) found.push(`${where}.if.${field}, a list: the platform takes one value here`);
995
+ }
996
+ for (const field of ["in_interface_set", "out_interface_set"] as const) {
997
+ if (match?.[field] !== undefined) found.push(`${where}.if.${field}, which Circuit writes out as its interfaces`);
998
+ }
999
+
1000
+ return found;
1001
+ }
1002
+
947
1003
  private static staticGaps(device: Device): string[] {
948
1004
  const found: string[] = [];
949
1005
  const seen = new Set<string>();
950
1006
 
951
- for (const route of device.routing?.static ?? []) {
952
- const where = `routing.static ${route.prefix}`;
1007
+ for (const route of device.routing?.static_routes ?? []) {
1008
+ const where = `routing.static_routes ${route.prefix}`;
953
1009
 
954
- if (route.vrf && route.bfd) found.push(`${where}.bfd, BFD in a VRF is not proved on a device yet`);
955
1010
  if (route.vrf && route.blackhole)
956
1011
  found.push(`${where}, a blackhole in a VRF: a route takes the VRF of the interface it leaves through`);
957
1012
  if (route.vrf && route.interface && FortiOsNames.settingsOf(device, route.interface)?.vrf !== route.vrf) {
958
1013
  found.push(`${where}.interface, which is not in VRF ${route.vrf}`);
959
1014
  }
960
1015
 
961
- if (route.via && !route.interface && !route.blackhole && !FortiOsNames.interfaceFor(device, route.via, route.vrf)) {
962
- found.push(`${where} via ${route.via}, no interface's own network in its table holds the next hop`);
1016
+ if (route.next_hop && !route.interface && !route.blackhole && !FortiOsNames.interfaceFor(device, route.next_hop, route.vrf)) {
1017
+ found.push(`${where} next_hop ${route.next_hop}, no interface's own network in its table holds the next hop`);
963
1018
  }
964
1019
 
965
1020
  // The device refuses a second route identical to one it has: `Duplicated entry`.
966
1021
  const key = [
967
1022
  Address.split(route.prefix).addr,
968
1023
  Address.split(route.prefix).len,
969
- route.via,
1024
+ route.next_hop,
970
1025
  route.interface,
971
1026
  route.blackhole,
972
1027
  route.distance,
@@ -986,65 +1041,71 @@ export class FortiOs extends Platform {
986
1041
 
987
1042
  if (fw.filter?.output) found.push("firewall.filter.output, the platform filters no traffic it sends itself");
988
1043
  if (fw.untracked) found.push("firewall.untracked, every session is tracked");
1044
+ if (fw.interface_sets) found.push("firewall.interface_sets, which Circuit writes out as each rule's interfaces");
989
1045
 
990
- for (const helper of fw.helpers ?? []) {
991
- if (!FortiOsRender.HELPERS[helper]) found.push(`firewall.helpers ${helper}, the platform has no helper for it`);
1046
+ for (const helper of fw.conntrack_helpers ?? []) {
1047
+ if (!FortiOsRender.HELPERS[helper]) found.push(`firewall.conntrack_helpers ${helper}, the platform has no helper for it`);
992
1048
  }
993
1049
 
994
1050
  for (const chain of ["forward", "input"] as const) {
995
1051
  (fw.filter?.[chain]?.rules ?? []).forEach((rule, index) =>
996
- found.push(...FortiOs.ruleGaps(`firewall.filter.${chain}[${index}]`, rule, chain, device))
1052
+ found.push(...FortiOs.ruleGaps(`firewall.filter.${chain}.rules[${index}]`, rule, chain, device))
997
1053
  );
998
1054
  }
999
1055
 
1000
1056
  (fw.nat?.source ?? []).forEach((rule, index) => {
1001
1057
  const where = `firewall.nat.source[${index}]`;
1002
- const match = rule.match;
1058
+ const match = rule.if;
1059
+ found.push(...FortiOs.listGaps(where, match));
1003
1060
 
1004
1061
  if (FortiOsFirewall.familiesOf(device, match).includes("ipv6") && (match?.family === "ipv6" || match?.src || match?.dst)) {
1005
1062
  found.push(`${where}, IPv6: central NAT translates IPv4`);
1006
1063
  }
1007
1064
 
1008
- if (rule.action === "dnat") found.push(`${where}.action dnat, in source NAT`);
1009
- if (rule.action === "snat" && Address.family(rule.to) === "ipv6") found.push(`${where}.to, IPv6: central NAT translates IPv4`);
1010
- if (match?.dst_port !== undefined) found.push(`${where}.match.dst_port, central NAT matches the source port only`);
1011
- if (Array.isArray(match?.src_port)) found.push(`${where}.match.src_port, one port or range`);
1012
- if (rule.action === "snat" && rule.to_port !== undefined && match?.src_port === undefined)
1013
- found.push(`${where}.to_port, without match.src_port`);
1014
- if ((match?.src && match.src_set) || (match?.dst && match.dst_set))
1015
- found.push(`${where}.match, a prefix and a set on one side`);
1016
- if (match?.state || match?.tcp_flags) found.push(`${where}.match.state and tcp_flags`);
1065
+ if (rule.then === "dnat") found.push(`${where}.then dnat, in source NAT`);
1066
+ if (rule.then === "snat" && Address.family(rule.to_address) === "ipv6")
1067
+ found.push(`${where}.to_address, IPv6: central NAT translates IPv4`);
1068
+ if (match?.dst_port !== undefined) found.push(`${where}.if.dst_port, central NAT matches the source port only`);
1069
+ if (Array.isArray(match?.src_port)) found.push(`${where}.if.src_port, one port or range`);
1070
+ if (rule.then === "snat" && rule.to_port !== undefined && match?.src_port === undefined)
1071
+ found.push(`${where}.to_port, without if.src_port`);
1072
+ if ((match?.src && match.src_address_set) || (match?.dst && match.dst_address_set))
1073
+ found.push(`${where}.if, a prefix and a set on one side`);
1074
+ if (match?.connection_state || match?.tcp_flags) found.push(`${where}.if.connection_state and tcp_flags`);
1017
1075
  });
1018
1076
 
1019
1077
  (fw.nat?.destination ?? []).forEach((rule, index) => {
1020
1078
  const where = `firewall.nat.destination[${index}]`;
1021
- const match = rule.match;
1079
+ const match = rule.if;
1080
+ found.push(...FortiOs.listGaps(where, match));
1022
1081
 
1023
- if (rule.action !== "dnat") {
1024
- found.push(`${where}.action ${rule.action}, in destination NAT`);
1082
+ if (rule.then !== "dnat") {
1083
+ found.push(`${where}.then ${rule.then}, in destination NAT`);
1025
1084
  return;
1026
1085
  }
1027
1086
 
1028
- if (!match?.dst || Address.split(match.dst.includes("/") ? match.dst : `${match.dst}/32`).len !== 32) {
1029
- found.push(`${where}.match.dst, one IPv4 address the virtual IP answers on`);
1087
+ const dst = FortiOsNames.single(match?.dst);
1088
+ if (!dst || Address.split(dst.includes("/") ? dst : `${dst}/32`).len !== 32) {
1089
+ found.push(`${where}.if.dst, one IPv4 address the virtual IP answers on`);
1030
1090
  }
1031
1091
 
1032
- const sources = match?.src ? [match.src] : match?.src_set ? (fw.address_sets?.[match.src_set] ?? []) : [];
1033
- if (Address.family(rule.to) === "ipv6" || sources.some((source) => Address.family(Address.host(source)) === "ipv6"))
1092
+ const src = FortiOsNames.single(match?.src);
1093
+ const sources = src ? [src] : match?.src_address_set ? (fw.address_sets?.[match.src_address_set] ?? []) : [];
1094
+ if (Address.family(rule.to_address) === "ipv6" || sources.some((source) => Address.family(Address.host(source)) === "ipv6"))
1034
1095
  found.push(`${where}, IPv6`);
1035
- if (match?.dst_port !== undefined && Array.isArray(match.dst_port)) found.push(`${where}.match.dst_port, one port or range`);
1096
+ if (match?.dst_port !== undefined && Array.isArray(match.dst_port)) found.push(`${where}.if.dst_port, one port or range`);
1036
1097
  if (match?.dst_port !== undefined && match.protocol !== "tcp" && match.protocol !== "udp") {
1037
- found.push(`${where}.match.protocol, tcp or udp with a port`);
1098
+ found.push(`${where}.if.protocol, tcp or udp with a port`);
1038
1099
  }
1039
1100
 
1040
1101
  if (match?.dst_port === undefined && (match?.protocol || rule.to_port)) {
1041
- found.push(`${where}.match.dst_port, a protocol or port is forwarded by its port`);
1102
+ found.push(`${where}.if.dst_port, a protocol or port is forwarded by its port`);
1042
1103
  }
1043
1104
 
1044
- if (match?.src && match.src_set) found.push(`${where}.match, a prefix and a set on one side`);
1105
+ if (match?.src && match.src_address_set) found.push(`${where}.if, a prefix and a set on one side`);
1045
1106
 
1046
- for (const field of ["dst_set", "src_port", "out_interface", "state", "tcp_flags", "family"] as const) {
1047
- if (match?.[field] !== undefined) found.push(`${where}.match.${field}`);
1107
+ for (const field of ["dst_address_set", "src_port", "out_interface", "connection_state", "tcp_flags", "family"] as const) {
1108
+ if (match?.[field] !== undefined) found.push(`${where}.if.${field}`);
1048
1109
  }
1049
1110
  });
1050
1111
 
@@ -1059,35 +1120,39 @@ export class FortiOs extends Platform {
1059
1120
  }
1060
1121
 
1061
1122
  /** A filter rule the platform cannot express. */
1062
- private static ruleGaps(where: string, rule: FilterRule<string, string>, chain: "forward" | "input", device: Device): string[] {
1123
+ private static ruleGaps(where: string, rule: FilterRule, chain: "forward" | "input", device: Device): string[] {
1063
1124
  const found: string[] = [];
1064
- const match = rule.match;
1065
- const state = match?.state ?? [];
1125
+ const match = rule.if;
1126
+ const state = match?.connection_state ?? [];
1066
1127
 
1067
1128
  // The platform is stateful: replies pass on their own, and a rule matches a session's first packet.
1068
- if (state.some((one) => one === "invalid" || one === "untracked")) found.push(`${where}.match.state ${state.join(", ")}`);
1069
- else if (state.length && rule.action !== "accept") found.push(`${where}.match.state, on a rule that does not accept`);
1129
+ if (state.some((one) => one === "invalid" || one === "untracked")) found.push(`${where}.if.connection_state ${state.join(", ")}`);
1130
+ else if (state.length && rule.then !== "accept") found.push(`${where}.if.connection_state, on a rule that does not accept`);
1070
1131
 
1071
- if (match?.tcp_flags) found.push(`${where}.match.tcp_flags, the platform matches no TCP flags`);
1072
- if ((match?.src && match.src_set) || (match?.dst && match.dst_set)) found.push(`${where}.match, a prefix and a set on one side`);
1132
+ if (match?.tcp_flags) found.push(`${where}.if.tcp_flags, the platform matches no TCP flags`);
1133
+ if ((match?.src && match.src_address_set) || (match?.dst && match.dst_address_set))
1134
+ found.push(`${where}.if, a prefix and a set on one side`);
1135
+ found.push(...FortiOs.listGaps(where, match));
1073
1136
  if (FortiOsFirewall.isImplied(rule)) return found;
1074
1137
 
1075
1138
  const families = FortiOsFirewall.familiesOf(device, match);
1076
1139
  if (!families.length) found.push(`${where}, its addresses share no family`);
1077
1140
 
1078
- if (match?.src && match.dst && Address.family(Address.host(match.src)) !== Address.family(Address.host(match.dst))) {
1141
+ const src = FortiOsNames.single(match?.src);
1142
+ const dst = FortiOsNames.single(match?.dst);
1143
+ if (src && dst && Address.family(Address.host(src)) !== Address.family(Address.host(dst))) {
1079
1144
  found.push(`${where}, a source and destination of two families`);
1080
1145
  }
1081
1146
 
1082
1147
  for (const family of families) {
1083
1148
  if (FortiOsFirewall.serviceOf(match, family).name.length > FortiOs.NAME_LENGTH.object)
1084
- found.push(`${where}.match ports, too many for one service`);
1149
+ found.push(`${where}.if ports, too many for one service`);
1085
1150
  }
1086
1151
 
1087
1152
  if (chain === "input") {
1088
- if (match?.out_interface) found.push(`${where}.match.out_interface, on traffic to the device`);
1089
- if (rule.action === "reject") found.push(`${where}.action reject, the platform drops traffic to itself silently`);
1090
- if (rule.offload) found.push(`${where}.offload, on traffic to the device`);
1153
+ if (match?.out_interface) found.push(`${where}.if.out_interface, on traffic to the device`);
1154
+ if (rule.then === "reject") found.push(`${where}.then reject, the platform drops traffic to itself silently`);
1155
+ if (rule.fast_path) found.push(`${where}.fast_path, on traffic to the device`);
1091
1156
  }
1092
1157
 
1093
1158
  return found;
@@ -1096,25 +1161,24 @@ export class FortiOs extends Platform {
1096
1161
  private static serviceGaps(device: Device): string[] {
1097
1162
  const found: string[] = [];
1098
1163
 
1099
- for (const [name, server] of Object.entries(device.dhcp ?? {})) {
1164
+ for (const [name, server] of Object.entries(device.services?.dhcp_server ?? {})) {
1100
1165
  if (Address.family(Address.host(server.network)) === "ipv6")
1101
- found.push(`dhcp.${name}.network, IPv6: DHCPv6 hands out no gateway`);
1166
+ found.push(`services.dhcp_server.${name}.network, IPv6: DHCPv6 hands out no gateway`);
1102
1167
  if ((server.dns?.length ?? 0) > FortiOsRender.RESOLVERS.dhcp)
1103
- found.push(`dhcp.${name}.dns, at most ${FortiOsRender.RESOLVERS.dhcp}`);
1168
+ found.push(`services.dhcp_server.${name}.dns, at most ${FortiOsRender.RESOLVERS.dhcp}`);
1104
1169
  }
1105
1170
 
1106
- const flow = device.flow_export;
1107
- if (flow?.protocol === "ipfix") found.push("flow_export.protocol ipfix, the platform exports sFlow and NetFlow 9");
1108
- if (flow && flow.collectors.length > 1) found.push("flow_export.collectors, one collector");
1171
+ const flow = device.monitoring?.flow_export;
1172
+ if (flow?.protocol === "ipfix") found.push("monitoring.flow_export.protocol ipfix, the platform exports sFlow and NetFlow 9");
1173
+ if (flow && flow.collectors.length > 1) found.push("monitoring.flow_export.collectors, one collector");
1109
1174
  for (const name of flow?.interfaces ?? []) {
1110
- const type = device.interfaces?.[name]?.type;
1111
- if (type === "gre" || type === "vxlan")
1112
- found.push(`flow_export.interfaces ${name}, a tunnel: the platform samples the interface it leaves through`);
1175
+ if (FortiOsNames.isTunnel(device, name))
1176
+ found.push(`monitoring.flow_export.interfaces ${name}, a tunnel: the platform samples the interface it leaves through`);
1113
1177
  }
1114
1178
  if (flow?.protocol === "netflow-v9" && flow.sampling !== undefined)
1115
- found.push("flow_export.sampling, NetFlow here exports every flow");
1179
+ found.push("monitoring.flow_export.sampling, NetFlow here exports every flow");
1116
1180
  if (flow?.protocol === "sflow" && (flow.sampling === undefined || flow.sampling < 10 || flow.sampling > 99999)) {
1117
- found.push("flow_export.sampling, required and from 10 to 99999: the platform samples");
1181
+ found.push("monitoring.flow_export.sampling, required and from 10 to 99999: the platform samples");
1118
1182
  }
1119
1183
 
1120
1184
  return found;