@takodotid/circuit 0.39.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.
@@ -2,7 +2,7 @@ import type { IP, Secret } from "./common.js";
2
2
  import type { AclRule, Firewall } from "./firewall.js";
3
3
  import type { Interface, Port, Vlan } from "./interfaces.js";
4
4
  import type { PrefixSet, RoutePolicy } from "./policy.js";
5
- import type { Routing } from "./routing.js";
5
+ import type { Routing, StaticRoute } from "./routing.js";
6
6
  import type { Certificate, DhcpRelay, DhcpServer, FlowExport, Management, System, User } from "./system.js";
7
7
  /** How the framework reaches the device. */
8
8
  export type Connection<U extends string> = {
@@ -39,6 +39,24 @@ export type Console = {
39
39
  /** The console asks only for a password, with no name first. Circuit then types no name; `connection.user` still picks the password. */
40
40
  passwordOnly?: true;
41
41
  };
42
+ /**
43
+ * Virtual domains, on FortiOS: the device's own traffic kept apart from the traffic it routes. Everything is in `root` when absent.
44
+ *
45
+ * Multi-VDOM mode is turned on by hand, not by a plan: it moves the whole configuration into `root` and ends every session.
46
+ */
47
+ export type Vdoms<I extends string> = {
48
+ /**
49
+ * A VDOM for the device's own traffic: FortiGuard, NTP, DNS and logging leave from it, by its own routes, so they never follow the routed traffic's default route. Everything not listed here stays in `root`.
50
+ */
51
+ management: {
52
+ /** Its name, such as `mgmt`. Not `root`. */
53
+ name: string;
54
+ /** The ports and interfaces in it, by name. A LAG's member ports go with the LAG. */
55
+ interfaces: readonly I[];
56
+ /** Its static routes, such as its own default route. */
57
+ static?: readonly StaticRoute<I, never>[];
58
+ };
59
+ };
42
60
  /** A separate routing table, with the interfaces that route in it. */
43
61
  export type Vrf = {
44
62
  /** What the VRF is for. */
@@ -103,4 +121,6 @@ export type Device<Pl extends string = string, M extends string = string, N exte
103
121
  };
104
122
  /** Forward in hardware where the platform can. An interface may override it. */
105
123
  hardware_offload?: boolean;
124
+ /** Virtual domains, on FortiOS only: a management VDOM apart from the routed traffic. */
125
+ vdoms?: Vdoms<NoInfer<I | N>>;
106
126
  };
@@ -96,7 +96,7 @@ export type Firewall<I extends string, AS extends string> = {
96
96
  };
97
97
  /** Helpers that run. None when absent. */
98
98
  helpers?: readonly Helper[];
99
- /** Traffic to and from these prefixes passes without connection tracking. A flood toward them cannot fill the connection table, and rules see its packets in the state `untracked`. Everything is tracked when absent. */
99
+ /** Traffic to and from these prefixes passes without connection tracking. A flood toward them cannot fill the connection table, and rules see its packets in the state `untracked`. An address NAT uses inside one stays tracked, or its replies would never be translated back. Everything is tracked when absent. */
100
100
  untracked?: readonly Prefix[];
101
101
  };
102
102
  /** A stateless rule evaluated in hardware where traffic arrives, before routing. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "Declarative, vendor-neutral network configuration: describe each device in TypeScript, and Circuit makes the device match. Alpha.",
5
5
  "keywords": [
6
6
  "network",
@@ -93,6 +93,31 @@ export class Validator {
93
93
  Validator.checkFirewall(device, names, cidr);
94
94
  Validator.checkServices(device, names, report, cidr);
95
95
  Validator.checkCertificates(device, names, report);
96
+ Validator.checkVdoms(device, names, report, cidr, ip);
97
+ }
98
+
99
+ /** A management VDOM: FortiOS only, not `root`, with declared interfaces, and routes that leave through its own. */
100
+ private static checkVdoms(device: Device, names: Names, report: Report, cidr: Checker, ip: Checker): void {
101
+ const management = device.vdoms?.management;
102
+ if (!management) return;
103
+
104
+ if (device.platform !== "fortios") report("vdoms are a FortiOS setting");
105
+ if (management.name === "root") report("vdoms.management.name is root, the VDOM everything else is in; give it a name of its own");
106
+
107
+ for (const name of management.interfaces) {
108
+ names.expectInterface("vdoms.management interface", name);
109
+ const port = device.ports?.[name as keyof typeof device.ports];
110
+ if (port?.lag) report(`vdoms.management interface ${name} is in LAG ${port.lag}; list the LAG`);
111
+ }
112
+
113
+ for (const route of management.static ?? []) {
114
+ const where = `vdoms.management static route ${route.prefix}`;
115
+ cidr(where, route.prefix);
116
+ if (route.via) ip(where, route.via);
117
+ if (!route.via && !route.interface && !route.blackhole) report(`${where} has no next hop`);
118
+ if (route.interface && !management.interfaces.includes(route.interface))
119
+ report(`${where} leaves through ${route.interface}, which is not in the VDOM`);
120
+ }
96
121
  }
97
122
 
98
123
  /** What the model's hardware holds, from the catalog. A LAG with too many members is an error; too many switch rules is a warning, since the limit is only roughly known. */
@@ -163,7 +163,10 @@ type Names<C, N, F extends Field, Ks extends Key<C>> = { [K in Ks]: Named<C, N,
163
163
  /** The catalog as written, with any key the shape does not have refused, at every level. */
164
164
  type Exact<S, T> = T extends readonly unknown[] | string | number | boolean | Secret | ((...args: never[]) => unknown)
165
165
  ? T
166
- : { [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;
167
170
 
168
171
  /** The names as written, with a name or a role Upstreams does not make refused. */
169
172
  type ExactNames<N> = {
@@ -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;
@@ -8,6 +8,7 @@ import { FortiOsFirewall } from "./firewall";
8
8
  import { FortiOsNames } from "./names";
9
9
  import { FortiOsRouting } from "./routing";
10
10
  import { FortiOsBlock, FortiOsTree } from "./tree";
11
+ import { FortiOsVdoms } from "./vdoms";
11
12
 
12
13
  export type Section = (typeof FortiOsRender.SECTIONS)[number];
13
14
 
@@ -292,10 +293,28 @@ export class FortiOsRender {
292
293
  FortiOsRouting.render(root, device);
293
294
  FortiOsRender.helpers(section("system session-helper"), device);
294
295
  FortiOsFirewall.render(root, device);
296
+ FortiOsRender.managementVdom(root, device);
295
297
 
296
298
  return FortiOsTree.print(root);
297
299
  }
298
300
 
301
+ /** A management VDOM's routes, and who reaches the device's own services through it, as sections of their own after everything else. */
302
+ private static managementVdom(root: FortiOsBlock, device: Device): void {
303
+ const management = device.vdoms?.management;
304
+ if (!management) return;
305
+
306
+ const vdom = new FortiOsBlock();
307
+ for (const section of FortiOsVdoms.MANAGEMENT_SECTIONS) vdom.block(section);
308
+
309
+ FortiOsRender.staticRoutes(vdom.block("router static"), vdom.block("router static6"), {
310
+ ...device,
311
+ routing: { static: management.static ?? [] },
312
+ });
313
+ FortiOsFirewall.managementOnly(vdom, device);
314
+
315
+ for (const [section, block] of vdom.blocks) root.blocks.set(FortiOsVdoms.key(management.name, section), block);
316
+ }
317
+
299
318
  /** A certificate with its key is the device's own; one without is only trusted. */
300
319
  private static certificates(local: FortiOsBlock, trusted: FortiOsBlock, device: Device): void {
301
320
  for (const [name, certificate] of Object.entries(device.certificates ?? {})) {
@@ -330,6 +349,9 @@ export class FortiOsRender {
330
349
  if (device.system?.banner) block.set("pre-login-banner", "enable");
331
350
 
332
351
  if (device.lldp === true) block.set("lldp-transmission", "enable").set("lldp-reception", "enable");
352
+
353
+ const vdom = FortiOsVdoms.managementOf(device);
354
+ if (vdom) block.set("vdom-mode", "multi-vdom").set("management-vdom", FortiOsTree.quote(vdom));
333
355
  }
334
356
 
335
357
  /** Each user, with the profile its role takes. */
@@ -365,7 +387,7 @@ export class FortiOsRender {
365
387
  const settings = device.ports?.[port as keyof typeof device.ports];
366
388
  const entry = block.entry(Catalog.spell(device.platform, device.model, port));
367
389
 
368
- entry.set("vdom", FortiOsTree.quote("root"));
390
+ entry.set("vdom", FortiOsTree.quote(FortiOsVdoms.vdomOf(device, port)));
369
391
  entry.set("type", "physical");
370
392
 
371
393
  if (!settings) {
@@ -395,7 +417,7 @@ export class FortiOsRender {
395
417
  .map(([port]) => Catalog.spell(device.platform, device.model, port));
396
418
 
397
419
  const entry = block.entry(name);
398
- entry.set("vdom", FortiOsTree.quote("root"));
420
+ entry.set("vdom", FortiOsTree.quote(FortiOsVdoms.vdomOf(device, name)));
399
421
  entry.set("type", "aggregate");
400
422
  if (members.length) entry.set("member", FortiOsTree.quoteAll(members));
401
423
  if (iface.mode === "static") entry.set("lacp-mode", "static");
@@ -410,7 +432,7 @@ export class FortiOsRender {
410
432
 
411
433
  const parent = FortiOsRender.vlanParent(device, iface.vlan);
412
434
  const entry = block.entry(name);
413
- entry.set("vdom", FortiOsTree.quote("root"));
435
+ entry.set("vdom", FortiOsTree.quote(FortiOsVdoms.vdomOf(device, name)));
414
436
  if (parent) entry.set("interface", FortiOsTree.quote(FortiOsNames.nameOf(device, parent)));
415
437
  entry.set("vlanid", device.vlans?.[iface.vlan]?.id ?? 0);
416
438
  const domain = FortiOsNames.forwardDomainOf(device, name);
@@ -425,7 +447,7 @@ export class FortiOsRender {
425
447
  if (iface.type !== "loopback") continue;
426
448
 
427
449
  const entry = block.entry(name);
428
- entry.set("vdom", FortiOsTree.quote("root"));
450
+ entry.set("vdom", FortiOsTree.quote(FortiOsVdoms.vdomOf(device, name)));
429
451
  entry.set("type", "loopback");
430
452
  if (iface.description) entry.set("description", FortiOsTree.quote(iface.description));
431
453
  FortiOsRender.redirects(entry, device);
@@ -454,7 +476,7 @@ export class FortiOsRender {
454
476
  }
455
477
 
456
478
  const entry = block.entry(name);
457
- entry.set("vdom", FortiOsTree.quote("root"));
479
+ entry.set("vdom", FortiOsTree.quote(FortiOsVdoms.vdomOf(device, name)));
458
480
  entry.set("type", iface.type === "gre" ? "tunnel" : "vxlan");
459
481
  entry.set("interface", via);
460
482
  if (iface.type === "vxlan" && iface.mac) entry.set("macaddr", iface.mac.toLowerCase());