@takodotid/circuit 0.38.0 → 0.40.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 (36) hide show
  1. package/dist/chunks/{index-yzw86rc8.js → index-k38bby43.js} +62 -4
  2. package/dist/chunks/{server-cxr9ynxc.js → server-ggertnce.js} +1 -1
  3. package/dist/cli/index.js +332 -36
  4. package/dist/helpers/index.js +71 -29
  5. package/dist/types/core/cabling.d.ts +6 -0
  6. package/dist/types/core/validator.d.ts +2 -0
  7. package/dist/types/helpers/cables.d.ts +16 -20
  8. package/dist/types/helpers/index.d.ts +1 -1
  9. package/dist/types/helpers/upstreams.d.ts +102 -19
  10. package/dist/types/platforms/derive.d.ts +1 -1
  11. package/dist/types/platforms/fortios/firewall.d.ts +2 -0
  12. package/dist/types/platforms/fortios/index.d.ts +7 -1
  13. package/dist/types/platforms/fortios/render.d.ts +2 -0
  14. package/dist/types/platforms/fortios/vdoms.d.ts +50 -0
  15. package/dist/types/platforms/routeros/render.d.ts +7 -1
  16. package/dist/types/schema/device.d.ts +21 -1
  17. package/dist/types/schema/firewall.d.ts +1 -1
  18. package/dist/types/schema/interfaces.d.ts +1 -1
  19. package/package.json +1 -1
  20. package/src/cli/index.ts +12 -4
  21. package/src/core/cabling.ts +30 -0
  22. package/src/core/validator.ts +25 -0
  23. package/src/helpers/cables.ts +21 -43
  24. package/src/helpers/index.ts +1 -1
  25. package/src/helpers/upstreams.ts +175 -39
  26. package/src/platforms/derive.ts +3 -1
  27. package/src/platforms/fortios/firewall.ts +9 -0
  28. package/src/platforms/fortios/index.ts +90 -28
  29. package/src/platforms/fortios/render.ts +27 -5
  30. package/src/platforms/fortios/vdoms.ts +217 -0
  31. package/src/platforms/routeros/TRAPS.md +1 -0
  32. package/src/platforms/routeros/render.ts +36 -3
  33. package/src/schema/device.ts +22 -1
  34. package/src/schema/firewall.ts +1 -1
  35. package/src/schema/interfaces.ts +1 -1
  36. package/dist/chunks/index-6wb41q67.js +0 -62
@@ -32,6 +32,8 @@ export type Upstream = {
32
32
  vlan: number;
33
33
  /** It sends its VLAN tagged: its port gets a native VLAN of its own with this ID, which nothing else is in. Untagged when absent. */
34
34
  native?: number;
35
+ /** Its number in names with `{number}`, such as `core-ipt01-transita`. Written here, so removing another upstream renumbers nothing. */
36
+ number?: number;
35
37
  /** Our addresses on it. `{ address, peer }` for a point-to-point address whose far end is outside its own prefix. */
36
38
  addresses: readonly (InterfaceAddress | { address: InterfaceAddress; peer: IP })[];
37
39
  /** The switch port it arrives on. */
@@ -39,8 +41,8 @@ export type Upstream = {
39
41
  /** An exchange's LAN, for `circuit peeringdb`. */
40
42
  exchange?: ExchangeLan;
41
43
  };
42
- /** A GRE tunnel to it. */
43
- tunnel?: { local: IP; remote: IP; addresses: readonly InterfaceAddress[]; mtu?: number };
44
+ /** A GRE tunnel to it. `number` is its number in names with `{number}`. */
45
+ tunnel?: { local: IP; remote: IP; addresses: readonly InterfaceAddress[]; mtu?: number; number?: number };
44
46
  /** Our BGP sessions with it, by name: its address, and ours when it is not the interface's. */
45
47
  sessions: Readonly<Record<string, { address: IP; local_address?: IP }>>;
46
48
  /** The sessions are more than one hop away, from this address of ours. They share a group. */
@@ -65,6 +67,29 @@ export type Upstream = {
65
67
  /** Upstreams by key. The key names everything built from it. */
66
68
  export type UpstreamCatalog = Readonly<Record<string, Upstream>>;
67
69
 
70
+ /** A name made from an upstream: `{key}` is its key, `{KEY}` the key in capitals, `{number}` its `handoff.number` or `tunnel.number`. */
71
+ type Pattern = string;
72
+ /** A pattern for every upstream, or one per role. A role left out takes the default. */
73
+ type ByRole = Pattern | { readonly provider?: Pattern; readonly rs?: Pattern; readonly peer?: Pattern };
74
+
75
+ /** How the names built from each upstream are made. Each one left out takes its default. */
76
+ export type UpstreamNames = {
77
+ /** The VLAN a handoff is on. `{key}` when absent. */
78
+ vlan?: ByRole;
79
+ /** The native VLAN of a handoff sent tagged. `{key}-native` when absent. */
80
+ native?: Pattern;
81
+ /** The router's interface: per role, and `tunnel` for one reached over a tunnel. `{key}` when absent. */
82
+ interface?: Pattern | { readonly provider?: Pattern; readonly rs?: Pattern; readonly peer?: Pattern; readonly tunnel?: Pattern };
83
+ /** The import policy. `{KEY}-IN` when absent. */
84
+ import?: Pattern;
85
+ /** The export policy. `{KEY}-OUT` when absent. */
86
+ export?: Pattern;
87
+ /** The BGP group of an exchange's route servers or of multihop sessions. `{key}` when absent. */
88
+ group?: Pattern;
89
+ /** The description of a handoff's switch port. Its `name` when absent. No device names it, so it may be any function. */
90
+ port?: (key: string, upstream: Upstream) => string;
91
+ };
92
+
68
93
  /** How each upstream's policies are written: shared by every upstream of a catalog. */
69
94
  export type UpstreamPolicies<P extends string> = {
70
95
  /** A policy holding `Bgp.sanity()` that each import calls. The sanity rules are written in each import when absent. */
@@ -77,6 +102,19 @@ export type UpstreamPolicies<P extends string> = {
77
102
  sources?: readonly IP[];
78
103
  };
79
104
 
105
+ /** The names a device can refer to, and their patterns when the project gives none. */
106
+ const DEFAULTS = {
107
+ vlan: "{key}",
108
+ native: "{key}-native",
109
+ interface: "{key}",
110
+ import: "{KEY}-IN",
111
+ export: "{KEY}-OUT",
112
+ group: "{key}",
113
+ } as const;
114
+
115
+ type Defaults = typeof DEFAULTS;
116
+ type Field = keyof Defaults;
117
+
80
118
  type Key<C> = keyof C & string;
81
119
  /** The upstreams that land on a VLAN of ours. */
82
120
  type Landed<C> = { [K in Key<C>]: C[K] extends { handoff: object } ? K : never }[Key<C>];
@@ -89,35 +127,123 @@ type Grouped<C> = { [K in Key<C>]: C[K] extends { role: "rs" } | { multihop: obj
89
127
  /** The upstreams we take routes from. */
90
128
  type Importing<C> = { [K in Key<C>]: C[K] extends { import: object } ? K : never }[Key<C>];
91
129
 
92
- /** A native VLAN's name. */
93
- type Native<K extends string> = `${K}-native`;
94
- /** Each upstream's policies: `TRANSIT_A-IN` and `TRANSIT_A-OUT`. */
95
- type PolicyName<C> = `${Uppercase<Importing<C>>}-IN` | `${Uppercase<Key<C>>}-OUT`;
130
+ /** The project's pattern for a name, or the default. */
131
+ type PatternOf<N, F extends Field> = N extends { [X in F]: infer P } ? P : Defaults[F];
132
+ /** The pattern an upstream takes: the only one, its tunnel's, its role's, or the default. */
133
+ type Chosen<P, U, F extends Field> = P extends string
134
+ ? P
135
+ : U extends { tunnel: object }
136
+ ? P extends { tunnel: infer T extends string }
137
+ ? T
138
+ : Defaults[F]
139
+ : U extends { role: infer R extends string }
140
+ ? P extends { [X in R]: infer T extends string }
141
+ ? T
142
+ : Defaults[F]
143
+ : Defaults[F];
144
+ /** Every `From` in `S` replaced. */
145
+ type Replace<S extends string, From extends string, To extends string> = S extends `${infer A}${From}${infer B}`
146
+ ? `${A}${To}${Replace<B, From, To>}`
147
+ : S;
148
+ /** An upstream's `handoff.number` or `tunnel.number`. */
149
+ type NumberOf<U> = U extends { handoff: { number: infer N extends number } }
150
+ ? N
151
+ : U extends { tunnel: { number: infer N extends number } }
152
+ ? N
153
+ : number;
154
+ /** The name built for upstream `K`: its pattern with the key, the key in capitals and its number filled in. */
155
+ type Named<C, N, F extends Field, K extends Key<C>> = Replace<
156
+ Replace<Replace<Chosen<PatternOf<N, F>, C[K], F>, "{key}", K>, "{KEY}", Uppercase<K>>,
157
+ "{number}",
158
+ `${NumberOf<C[K]>}`
159
+ >;
160
+ /** Each of these upstreams' names. */
161
+ type Names<C, N, F extends Field, Ks extends Key<C>> = { [K in Ks]: Named<C, N, F, K> }[Ks];
96
162
 
97
163
  /** The catalog as written, with any key the shape does not have refused, at every level. */
98
- type Exact<S, T> = T extends readonly unknown[] | string | number | boolean | Secret
164
+ type Exact<S, T> = T extends readonly unknown[] | string | number | boolean | Secret | ((...args: never[]) => unknown)
99
165
  ? T
100
- : { [K in keyof T]: K extends keyof S ? Exact<NonNullable<S[K]>, T[K]> : never };
166
+ : // A shape that is a union, such as a storm-control threshold, is each of its members in turn: its keys are any member's.
167
+ S extends unknown
168
+ ? { [K in keyof T]: K extends keyof S ? Exact<NonNullable<S[K]>, T[K]> : never }
169
+ : never;
170
+
171
+ /** The names as written, with a name or a role Upstreams does not make refused. */
172
+ type ExactNames<N> = {
173
+ [F in keyof N]: F extends keyof UpstreamNames
174
+ ? N[F] extends string | ((...args: never[]) => unknown)
175
+ ? N[F]
176
+ : { [R in keyof N[F]]: R extends "provider" | "rs" | "peer" | "tunnel" ? N[F][R] : never }
177
+ : never;
178
+ };
101
179
 
102
180
  /** A network's upstreams written once, and each device's part built from them. */
103
181
  export class Upstreams {
104
182
  /**
105
- * Each upstream written once: its AS, where it lands, its sessions, limits and policy. Everything a device needs is built from it, under names made from each key, so a device that names one is checked:
183
+ * Each upstream written once: its AS, where it lands, its sessions, limits and policy. Everything a device needs is built from it, under names made from each key, so a device that names one is checked. By default:
106
184
  *
107
185
  * - VLAN `<key>`, and `<key>-native` for one that sends its VLAN tagged. The router's interface is `<key>` too, a VLAN interface or a GRE tunnel.
108
186
  * - Policies `<KEY>-IN` and `<KEY>-OUT`; a BGP group `<key>` for an exchange's route servers or multihop sessions.
109
187
  *
110
- * On the router: `vlans()`, `interfaces()`, `groups()`, `neighbors()`, `policies({ ... })`, `tunnelRules()`, `addresses()`. On a switch: `vlans(device)`, `ports(device)`, `vlansOn(device)`. For checks and ACLs: `untrusted()`, `natives()`, `privateDrops()`. Lists follow the catalog's order, where order counts on a device.
188
+ * `names` gives patterns of your own, such as `{ interface: { provider: "core-ipt0{number}-{key}" } }`; see `UpstreamNames`.
189
+ *
190
+ * On the router: `vlans()`, `interfaces()`, `uplinks()`, `groups()`, `neighbors()`, `policies({ ... })`, `tunnelRules()`, `addresses()`. On a switch: `vlans(device)`, `ports(device)`, `vlansOn(device)`. For checks and ACLs: `untrusted()`, `natives()`, `privateDrops()`. Lists follow the catalog's order, where order counts on a device.
111
191
  */
112
- static define<const C extends UpstreamCatalog>(catalog: C & { [K in keyof C]: Exact<Upstream, C[K]> }) {
113
- type V = Landed<C>;
114
- type T = Tagged<C>;
115
-
192
+ static define<const C extends UpstreamCatalog, const N extends UpstreamNames = {}>(
193
+ catalog: C & { [K in keyof C]: Exact<Upstream, C[K]> },
194
+ options: { names?: N & ExactNames<N> } = {}
195
+ ) {
196
+ type V = Names<C, N, "vlan", Landed<C>>;
197
+ type T = Names<C, N, "native", Tagged<C>>;
198
+ type I = Names<C, N, "interface", Landed<C>>;
199
+ type G = Names<C, N, "interface", Tunneled<C>>;
200
+ type PolicyName = Names<C, N, "import", Importing<C>> | Names<C, N, "export", Key<C>>;
201
+ type Group = Names<C, N, "group", Grouped<C>>;
202
+
203
+ const names: UpstreamNames = options.names ?? {};
116
204
  const all = Object.entries(catalog) as [Key<C>, Upstream][];
117
205
  const nameOf = (key: string, upstream: Upstream) => upstream.name ?? key;
118
- const policy = (key: string) => key.toUpperCase();
119
206
  const grouped = (upstream: Upstream) => upstream.role === "rs" || Boolean(upstream.multihop);
120
207
 
208
+ /** The name built for one upstream: its pattern with the key, the key in capitals and its number filled in. */
209
+ const named = (field: Field, key: string, upstream: Upstream): string => {
210
+ const given = names[field];
211
+ const pattern =
212
+ given === undefined || typeof given === "string"
213
+ ? (given ?? DEFAULTS[field])
214
+ : ((upstream.tunnel && "tunnel" in given ? given.tunnel : given[upstream.role]) ?? DEFAULTS[field]);
215
+
216
+ return pattern
217
+ .replaceAll("{key}", key)
218
+ .replaceAll("{KEY}", key.toUpperCase())
219
+ .replaceAll("{number}", () => {
220
+ const number = upstream.handoff?.number ?? upstream.tunnel?.number;
221
+ if (number === undefined) throw new Error(`Upstreams: ${key} needs a number for ${field} name ${pattern}`);
222
+ return String(number);
223
+ });
224
+ };
225
+
226
+ // Two upstreams under one name would replace each other on the device.
227
+ for (const field of Object.keys(DEFAULTS) as Field[]) {
228
+ const taken = new Map<string, string>();
229
+
230
+ for (const [key, upstream] of all) {
231
+ if (field === "native" && upstream.handoff?.native === undefined) continue;
232
+ if (field === "import" && !upstream.import) continue;
233
+ if (field === "group" && !grouped(upstream)) continue;
234
+ if ((field === "vlan" && !upstream.handoff) || (field === "interface" && !upstream.handoff && !upstream.tunnel)) continue;
235
+
236
+ const name = named(field, key, upstream);
237
+ const other = taken.get(name);
238
+ if (other) throw new Error(`Upstreams: ${other} and ${key} both have the ${field} name ${name}`);
239
+ taken.set(name, key);
240
+ }
241
+ }
242
+
243
+ const vlan = (key: string, upstream: Upstream) => named("vlan", key, upstream) as V;
244
+ const native = (key: string, upstream: Upstream) => named("native", key, upstream) as T;
245
+ const policy = (direction: "import" | "export", key: string, upstream: Upstream) => named(direction, key, upstream) as PolicyName;
246
+
121
247
  /** Each landed upstream's VLAN; with a device, the VLANs and natives of those arriving on its ports. */
122
248
  const vlans = <const D extends string | undefined = undefined>(device?: D) => {
123
249
  const result: Record<string, Vlan> = {};
@@ -126,33 +252,33 @@ export class Upstreams {
126
252
  const handoff = upstream.handoff;
127
253
  if (!handoff || (device !== undefined && handoff.port?.device !== device)) continue;
128
254
 
129
- result[key] = { id: handoff.vlan, description: nameOf(key, upstream) };
255
+ result[vlan(key, upstream)] = { id: handoff.vlan, description: nameOf(key, upstream) };
130
256
  if (device !== undefined && handoff.native !== undefined) {
131
- result[`${key}-native`] = {
257
+ result[native(key, upstream)] = {
132
258
  id: handoff.native,
133
259
  description: `Native toward ${nameOf(key, upstream)}, nothing else in it`,
134
260
  };
135
261
  }
136
262
  }
137
263
 
138
- return result as D extends string ? { [K in V]: Vlan } & { [K in T as Native<K>]: Vlan } : { [K in V]: Vlan };
264
+ return result as D extends string ? { [K in V]: Vlan } & { [K in T]: Vlan } : { [K in V]: Vlan };
139
265
  };
140
266
 
141
267
  const vlansOn = (device: string) =>
142
- all.filter(([, upstream]) => upstream.handoff?.port?.device === device).map(([key]) => key as V);
268
+ all.filter(([, upstream]) => upstream.handoff?.port?.device === device).map(([key, upstream]) => vlan(key, upstream));
143
269
 
144
270
  const ports = (device: string) => {
145
- const result: Record<string, Port<V | Native<T>, never, never, never>> = {};
271
+ const result: Record<string, Port<V | T, never, never, never>> = {};
146
272
 
147
273
  for (const [key, upstream] of all) {
148
274
  const handoff = upstream.handoff;
149
275
  if (!handoff?.port || handoff.port.device !== device) continue;
150
276
 
151
277
  result[handoff.port.port] = {
152
- description: nameOf(key, upstream),
278
+ description: names.port ? names.port(key, upstream) : nameOf(key, upstream),
153
279
  ...(handoff.native !== undefined
154
- ? { trunk_vlans: [key as V], native_vlan: `${key}-native` as Native<T> }
155
- : { access_vlan: key as V }),
280
+ ? { trunk_vlans: [vlan(key, upstream)], native_vlan: native(key, upstream) }
281
+ : { access_vlan: vlan(key, upstream) }),
156
282
  lldp: false,
157
283
  stp: false,
158
284
  ...(handoff.port.storm_control ? { storm_control: handoff.port.storm_control } : {}),
@@ -167,26 +293,31 @@ export class Upstreams {
167
293
 
168
294
  for (const [key, upstream] of all) {
169
295
  const description = nameOf(key, upstream);
296
+ const name = named("interface", key, upstream);
170
297
 
171
298
  if (upstream.tunnel) {
172
299
  const { local, remote, addresses, mtu } = upstream.tunnel;
173
- result[key] = { type: "gre", description, local, remote, addresses, ...(mtu ? { mtu } : {}) };
300
+ result[name] = { type: "gre", description, local, remote, addresses, ...(mtu ? { mtu } : {}) };
174
301
  } else if (upstream.handoff) {
175
302
  const { addresses, exchange } = upstream.handoff;
176
- result[key] = { type: "vlan", vlan: key as V, description, addresses, ...(exchange ? { exchange } : {}) };
303
+ result[name] = { type: "vlan", vlan: vlan(key, upstream), description, addresses, ...(exchange ? { exchange } : {}) };
177
304
  }
178
305
  }
179
306
 
180
- return result as { [K in V]: VlanInterface<V, never> } & { [K in Tunneled<C>]: Gre<never> };
307
+ return result as { [K in I]: VlanInterface<V, never> } & { [K in G]: Gre<never> };
181
308
  };
182
309
 
310
+ /** The router's interfaces toward the internet: every landed upstream's, for NAT and lists like it. */
311
+ const uplinks = () =>
312
+ all.filter(([, upstream]) => upstream.handoff).map(([key, upstream]) => named("interface", key, upstream) as I);
313
+
183
314
  /** What a session or its group takes from its upstream. */
184
- const settings = (key: string, upstream: Upstream): NeighborSettings<PolicyName<C>> => ({
315
+ const settings = (key: string, upstream: Upstream): NeighborSettings<PolicyName> => ({
185
316
  remote_as: upstream.asn,
186
317
  description: nameOf(key, upstream),
187
318
  role: upstream.role,
188
- ...(upstream.import ? { import: `${policy(key)}-IN` as PolicyName<C> } : {}),
189
- export: `${policy(key)}-OUT` as PolicyName<C>,
319
+ ...(upstream.import ? { import: policy("import", key, upstream) } : {}),
320
+ export: policy("export", key, upstream),
190
321
  });
191
322
 
192
323
  const groups = () =>
@@ -196,7 +327,7 @@ export class Upstreams {
196
327
  .map(([key, upstream]) => {
197
328
  const multihop = upstream.multihop;
198
329
  return [
199
- key,
330
+ named("group", key, upstream),
200
331
  {
201
332
  ...settings(key, upstream),
202
333
  ...(multihop
@@ -210,7 +341,7 @@ export class Upstreams {
210
341
  },
211
342
  ];
212
343
  })
213
- ) as Record<Grouped<C>, NeighborSettings<PolicyName<C>>>;
344
+ ) as Record<Group, NeighborSettings<PolicyName>>;
214
345
 
215
346
  const neighbors = () =>
216
347
  Object.fromEntries(
@@ -221,19 +352,19 @@ export class Upstreams {
221
352
  name,
222
353
  {
223
354
  ...session,
224
- ...(grouped(upstream) ? { group: key } : settings(key, upstream)),
355
+ ...(grouped(upstream) ? { group: named("group", key, upstream) } : settings(key, upstream)),
225
356
  ...(limit ? { max_prefixes: limit } : {}),
226
357
  },
227
358
  ];
228
359
  })
229
360
  )
230
- ) as Record<string, Neighbor<PolicyName<C>, Grouped<C>>>;
361
+ ) as Record<string, Neighbor<PolicyName, Group>>;
231
362
 
232
363
  const policies = <const P extends string = never>(options: UpstreamPolicies<P> = {}) => {
233
364
  const result: Record<string, PolicyRule<P, never>[]> = {};
234
365
 
235
366
  for (const [key, upstream] of all) {
236
- result[`${policy(key)}-OUT`] = [
367
+ result[policy("export", key, upstream)] = [
237
368
  ...(options.actions?.(key, upstream) ?? []),
238
369
  ...(upstream.export ?? []),
239
370
  ...Bgp.announce(upstream.announce),
@@ -243,7 +374,7 @@ export class Upstreams {
243
374
  if (!taken) continue;
244
375
 
245
376
  const tag = options.tag?.(key, upstream);
246
- result[`${policy(key)}-IN`] = [
377
+ result[policy("import", key, upstream)] = [
247
378
  ...(taken.default
248
379
  ? Bgp.defaultOnly({ ...taken.default, ...(options.sources ? { sources: options.sources } : {}) })
249
380
  : []),
@@ -259,16 +390,20 @@ export class Upstreams {
259
390
  ];
260
391
  }
261
392
 
262
- return result as Record<PolicyName<C>, PolicyRule<P, never>[]>;
393
+ return result as Record<PolicyName, PolicyRule<P, never>[]>;
263
394
  };
264
395
 
265
396
  /** The VLANs straight off an upstream, natives included: `untrusted` for `Checks.trustBoundary`. */
266
- const untrusted = () => [...all.filter(([, upstream]) => upstream.handoff).map(([key]) => key as V), ...natives()];
397
+ const untrusted = () => [
398
+ ...all.filter(([, upstream]) => upstream.handoff).map(([key, upstream]) => vlan(key, upstream)),
399
+ ...natives(),
400
+ ];
267
401
 
268
402
  const natives = () =>
269
- all.filter(([, upstream]) => upstream.handoff?.native !== undefined).map(([key]) => `${key}-native` as Native<T>);
403
+ all.filter(([, upstream]) => upstream.handoff?.native !== undefined).map(([key, upstream]) => native(key, upstream));
270
404
 
271
- const privateDrops = () => all.filter(([, upstream]) => upstream.handoff && upstream.drop_private).map(([key]) => key as V);
405
+ const privateDrops = () =>
406
+ all.filter(([, upstream]) => upstream.handoff && upstream.drop_private).map(([key, upstream]) => vlan(key, upstream));
272
407
 
273
408
  const tunnelRules = (): FilterRule<never, never>[] =>
274
409
  all.flatMap(([key, upstream]) =>
@@ -293,6 +428,7 @@ export class Upstreams {
293
428
  vlansOn,
294
429
  ports,
295
430
  interfaces,
431
+ uplinks,
296
432
  groups,
297
433
  neighbors,
298
434
  policies,
@@ -4,7 +4,8 @@ import { Address } from "../core/address";
4
4
  import type { Device } from "../schema";
5
5
 
6
6
  /** A feature a platform renders once proved on one of its devices. */
7
- export type ProvedFeature = "bfd-timers" | "static-route-bfd" | "multihop-bfd" | "alarm-severity" | "mac-limit" | "ethertype-filter";
7
+ export type ProvedFeature =
8
+ "bfd-timers" | "static-route-bfd" | "multihop-bfd" | "alarm-severity" | "mac-limit" | "ethertype-filter" | "management-vdom";
8
9
 
9
10
  export class Derive {
10
11
  /** Each LAG's number: its own `id`, or its position among the device's LAGs. */
@@ -41,6 +42,7 @@ export class Derive {
41
42
  const has = (feature: ProvedFeature) => proved.includes(feature);
42
43
 
43
44
  if (routing?.bfd && !has("bfd-timers")) found.push(`routing.bfd, BFD timers are ${unproved}`);
45
+ if (device.vdoms && !has("management-vdom")) found.push(`vdoms, a management VDOM is ${unproved}`);
44
46
 
45
47
  for (const route of routing?.static ?? []) {
46
48
  if (route.bfd && !has("static-route-bfd"))
@@ -155,6 +155,15 @@ export class FortiOsFirewall {
155
155
  );
156
156
  }
157
157
 
158
+ /** Who reaches the device's own services, alone: for a management VDOM, which routes no traffic of its own. */
159
+ static managementOnly(root: FortiOsBlock, device: Device): void {
160
+ const localIns = { ipv4: 0, ipv6: 0 };
161
+ const localInEntry = (family: Family) =>
162
+ root.block(family === "ipv4" ? "firewall local-in-policy" : "firewall local-in-policy6").entry(++localIns[family]);
163
+
164
+ FortiOsFirewall.management(localInEntry, new Objects(root, device), device);
165
+ }
166
+
158
167
  /** Packet filtering, who reaches the device, NAT and stateless ACLs. */
159
168
  static render(root: FortiOsBlock, device: Device): void {
160
169
  const fw = device.firewall;
@@ -18,6 +18,7 @@ import { FortiOsPlan } from "./plan";
18
18
  import { FortiOsRender } from "./render";
19
19
  import { FortiOsRouting } from "./routing";
20
20
  import { FortiOsBlock, FortiOsTree } from "./tree";
21
+ import { FortiOsVdoms } from "./vdoms";
21
22
 
22
23
  /** What a session reads and writes: an SSH shell, or anything else that streams the CLI. */
23
24
  export type Channel = {
@@ -194,6 +195,7 @@ export class FortiOs extends Platform {
194
195
 
195
196
  if (device.stp) found.push("stp, ports are routed");
196
197
  found.push(...FortiOs.transparentGaps(device));
198
+ found.push(...FortiOsVdoms.gaps(device));
197
199
 
198
200
  for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
199
201
  if (iface.type === "vlan" && iface.mac) found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
@@ -224,7 +226,8 @@ export class FortiOs extends Platform {
224
226
  device,
225
227
  async (cli) => {
226
228
  await FortiOs.checkVersion(cli, device);
227
- return FortiOs.neighborsOf(await cli.run("diagnose lldprx neighbor details"), device);
229
+ const details = await FortiOs.inGlobal(cli, device, () => cli.run("diagnose lldprx neighbor details"));
230
+ return FortiOs.neighborsOf(details, device);
228
231
  },
229
232
  options
230
233
  );
@@ -259,7 +262,7 @@ export class FortiOs extends Platform {
259
262
  }
260
263
 
261
264
  plan(desired: string, current: string, options: ApplyOptions): Plan {
262
- return FortiOsPlan.of(desired, current, options);
265
+ return FortiOsVdoms.plan(desired, current, (want, have) => FortiOsPlan.of(want, have, options));
263
266
  }
264
267
 
265
268
  async apply(device: Device, network: readonly Device[], changes: Plan, options: ApplyOptions): Promise<void> {
@@ -269,7 +272,7 @@ export class FortiOs extends Platform {
269
272
  const switched = changes.steps.some((step) => step.send.some((line) => line.startsWith("set opmode ")));
270
273
  if (!switched) return;
271
274
 
272
- const again = FortiOsPlan.of(FortiOsRender.of(device), await this.read(device, network, options), options);
275
+ const again = this.plan(FortiOsRender.of(device), await this.read(device, network, options), options);
273
276
  if (!again.steps.length) return;
274
277
 
275
278
  console.log(" the operation mode changed; applying what it added on its own");
@@ -281,7 +284,8 @@ export class FortiOs extends Platform {
281
284
  await FortiOs.withCli(device, async (cli) => {
282
285
  await FortiOs.checkVersion(cli, device);
283
286
 
284
- for (const line of FortiOs.fillLines(set, family, prefixes)) {
287
+ const lines = FortiOs.fillLines(set, family, prefixes);
288
+ for (const line of device.vdoms ? FortiOsVdoms.wrap({ kind: "vdom", vdom: FortiOsVdoms.ROOT }, lines) : lines) {
285
289
  let answer = await cli.run(line);
286
290
  if (FortiOsCli.QUESTION.test(cli.prompt) && line === "purge") answer = await cli.answer("y");
287
291
  if (FortiOs.REFUSED.test(answer)) throw new Error(`${set}: ${line}\n device said: ${answer.trim().slice(0, 300)}`);
@@ -433,8 +437,8 @@ export class FortiOs extends Platform {
433
437
  }
434
438
  }
435
439
 
436
- /** Refuse a device on a release this adapter was not proved on, before anything is read or sent. */
437
- private static async checkVersion(cli: FortiOsCli, device: Device): Promise<void> {
440
+ /** Refuse a device on a release this adapter was not proved on, before anything is read or sent. Gives what `get system status` printed. */
441
+ private static async checkVersion(cli: FortiOsCli, device: Device): Promise<string> {
438
442
  const status = await cli.run("get system status");
439
443
  const version = /^Version: \S+ v(\d+)\.(\d+)\.(\d+)/m.exec(status);
440
444
 
@@ -444,6 +448,21 @@ export class FortiOs extends Platform {
444
448
  `${device.name} runs FortiOS ${version[1]}.${version[2]}.${version[3]}; this adapter renders for FortiOS ${FortiOs.SUPPORTED}`
445
449
  );
446
450
  }
451
+
452
+ return status;
453
+ }
454
+
455
+ /** Run `work` among the device's own settings: inside `config global` on a device with VDOMs, as it is on one without. */
456
+ 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);
458
+ if (!multi) return work();
459
+
460
+ await cli.run("config global");
461
+ try {
462
+ return await work();
463
+ } finally {
464
+ await FortiOs.leave(cli);
465
+ }
447
466
  }
448
467
 
449
468
  /** 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. */
@@ -470,13 +489,15 @@ export class FortiOs extends Platform {
470
489
 
471
490
  /** Every section Circuit manages, as the device holds it, credentials scrubbed. */
472
491
  private static async readConfig(cli: FortiOsCli, device: Device): Promise<string> {
473
- await FortiOs.checkVersion(cli, device);
474
-
492
+ const status = await FortiOs.checkVersion(cli, device);
493
+ const show = (section: string) =>
494
+ cli.run(`${FortiOsRender.FULL.includes(section) ? "show full-configuration" : "show"} ${section}`, 120_000);
475
495
  const parts: string[] = [];
476
496
 
477
- for (const section of FortiOsRender.SECTIONS) {
478
- const show = FortiOsRender.FULL.includes(section) ? "show full-configuration" : "show";
479
- parts.push(await cli.run(`${show} ${section}`, 120_000));
497
+ if (/^Virtual domain configuration: (multiple|split-task)/m.test(status)) {
498
+ parts.push(...(await FortiOs.readVdoms(cli, device, show)));
499
+ } else {
500
+ for (const section of FortiOsRender.SECTIONS) parts.push(await show(section));
480
501
  }
481
502
 
482
503
  const text = parts.join("\n");
@@ -485,16 +506,51 @@ export class FortiOs extends Platform {
485
506
  return FortiOs.scrub(FortiOsPlan.clean(text));
486
507
  }
487
508
 
509
+ /**
510
+ * A device in multi-VDOM mode, read where each section lives: the device's own under `config global`, the rest in `root`, and a management VDOM's sections named as the render names them. A VDOM that does not exist yet is not entered, since `edit` would make it.
511
+ */
512
+ private static async readVdoms(cli: FortiOsCli, device: Device, show: (section: string) => Promise<string>): Promise<string[]> {
513
+ 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`);
515
+
516
+ const parts: string[] = [];
517
+ const inVdom = async (vdom: string, sections: readonly string[]) => {
518
+ await cli.run("config vdom");
519
+ await cli.run(`edit ${vdom}`);
520
+ try {
521
+ for (const section of sections) {
522
+ const text = await show(section);
523
+ parts.push(vdom === FortiOsVdoms.ROOT ? text : FortiOsVdoms.named(vdom, text));
524
+ }
525
+ } finally {
526
+ await FortiOs.leave(cli);
527
+ }
528
+ };
529
+
530
+ const listed = await FortiOs.inGlobal(cli, true, async () => {
531
+ for (const section of FortiOsRender.SECTIONS.filter((one) => FortiOsVdoms.GLOBAL.has(one))) parts.push(await show(section));
532
+ return FortiOsVdoms.listed(await cli.run("diagnose sys vd list"));
533
+ });
534
+
535
+ await inVdom(
536
+ FortiOsVdoms.ROOT,
537
+ FortiOsRender.SECTIONS.filter((one) => !FortiOsVdoms.GLOBAL.has(one))
538
+ );
539
+ if (listed.includes(management)) await inVdom(management, FortiOsVdoms.MANAGEMENT_SECTIONS);
540
+
541
+ return parts;
542
+ }
543
+
488
544
  /** Leave every `config` and `edit` level, back to the top prompt. */
489
545
  private static async leave(cli: FortiOsCli): Promise<void> {
490
546
  for (let level = 0; level < 10 && /\(.*\) [#$] $/.test(cli.prompt); level++) await cli.run("end");
491
547
  }
492
548
 
493
549
  /** Send the lines that set how the device saves changes. */
494
- private static async saving(cli: FortiOsCli, mode: "revert" | "automatic", seconds?: number): Promise<void> {
550
+ private static async saving(cli: FortiOsCli, mode: "revert" | "automatic", multi: boolean, seconds?: number): Promise<void> {
495
551
  const lines = ["config system global", `set cfg-save ${mode}`, ...(seconds ? [`set cfg-revert-timeout ${seconds}`] : []), "end"];
496
552
 
497
- for (const line of lines) {
553
+ for (const line of multi ? FortiOsVdoms.wrap({ kind: "global" }, lines) : lines) {
498
554
  const answer = await cli.run(line);
499
555
  if (FortiOs.REFUSED.test(answer)) throw new Error(`the device refused ${line}: ${answer.trim().slice(0, 300)}`);
500
556
  }
@@ -503,14 +559,14 @@ export class FortiOs extends Platform {
503
559
  /**
504
560
  * Send a plan line by line, each applied as it arrives. The device is first set to revert what is not saved, so a refused line or a fresh login that fails reloads its saved configuration. Once access is proved the change is saved, and changes save themselves again.
505
561
  */
506
- private static async configure(cli: FortiOsCli, plan: Plan, guard: Guard): Promise<void> {
562
+ private static async configure(cli: FortiOsCli, plan: Plan, guard: Guard, multi: boolean): Promise<void> {
507
563
  const minutes = guard.seconds / 60;
508
- await FortiOs.saving(cli, "revert", guard.seconds);
564
+ await FortiOs.saving(cli, "revert", multi, guard.seconds);
509
565
 
510
566
  // The session still open reloads the saved configuration at once; if it cannot, the device does when the time runs out.
511
567
  const restore = async (reason: string) => {
512
568
  await FortiOs.leave(cli).catch(() => undefined);
513
- await cli.run("execute cfg reload").catch(() => undefined);
569
+ await FortiOs.inGlobal(cli, multi, () => cli.run("execute cfg reload")).catch(() => undefined);
514
570
  return new Reloaded(
515
571
  `${reason}\n the device reloads its saved configuration, which undoes what was sent, at the latest in ${minutes} minutes`
516
572
  );
@@ -545,14 +601,14 @@ export class FortiOs extends Platform {
545
601
  throw await restore(`a fresh login failed: ${(error as Error).message}`);
546
602
  }
547
603
 
548
- const saved = await cli.run("execute cfg save", 120_000);
604
+ const saved = await FortiOs.inGlobal(cli, multi, () => cli.run("execute cfg save", 120_000));
549
605
  if (!/config saved|no config to be saved/.test(saved)) {
550
606
  throw new Error(
551
607
  `access was proved but the save failed; the device reverts in ${minutes} minutes:\n ${saved.trim().slice(0, 300)}`
552
608
  );
553
609
  }
554
610
 
555
- await FortiOs.saving(cli, "automatic");
611
+ await FortiOs.saving(cli, "automatic", multi);
556
612
  console.log(" logged in again, saved");
557
613
  }
558
614
 
@@ -566,8 +622,9 @@ export class FortiOs extends Platform {
566
622
  const settled = await FortiOs.withCli(
567
623
  device,
568
624
  async (cli) => {
569
- if (!/no config to be reloaded/.test(await cli.run("execute cfg reload"))) return false;
570
- await FortiOs.saving(cli, "automatic");
625
+ const reloaded = await FortiOs.inGlobal(cli, device, () => cli.run("execute cfg reload"));
626
+ if (!/no config to be reloaded/.test(reloaded)) return false;
627
+ await FortiOs.saving(cli, "automatic", Boolean(device.vdoms));
571
628
  return true;
572
629
  },
573
630
  options
@@ -575,7 +632,7 @@ export class FortiOs extends Platform {
575
632
  if (settled) return "the device is back and saves changes on its own again";
576
633
  }
577
634
 
578
- return "the device did not come back in time and still reverts every change; set `cfg-save automatic` under `config system global`";
635
+ return "the device did not come back in time and still reverts every change; set `cfg-save automatic` under `config system global` (in `config global` on a device with VDOMs)";
579
636
  }
580
637
 
581
638
  private static async applyOnce(device: Device, plan: Plan, options: ApplyOptions): Promise<void> {
@@ -586,7 +643,7 @@ export class FortiOs extends Platform {
586
643
  if (options.console) {
587
644
  await FortiOs.consoleSession(device, async (cli, relogin) => {
588
645
  await FortiOs.checkVersion(cli, device);
589
- await FortiOs.configure(cli, plan, { seconds, prove: relogin });
646
+ await FortiOs.configure(cli, plan, { seconds, prove: relogin }, Boolean(device.vdoms));
590
647
  });
591
648
  return;
592
649
  }
@@ -594,13 +651,18 @@ export class FortiOs extends Platform {
594
651
  await FortiOs.withCli(device, async (cli) => {
595
652
  await FortiOs.checkVersion(cli, device);
596
653
 
597
- await FortiOs.configure(cli, plan, {
598
- seconds,
599
- prove: async () => {
600
- const proof = await FortiOs.login(device);
601
- proof.end();
654
+ await FortiOs.configure(
655
+ cli,
656
+ plan,
657
+ {
658
+ seconds,
659
+ prove: async () => {
660
+ const proof = await FortiOs.login(device);
661
+ proof.end();
662
+ },
602
663
  },
603
- });
664
+ Boolean(device.vdoms)
665
+ );
604
666
  });
605
667
  } catch (error) {
606
668
  if (!(error instanceof Reloaded)) throw error;