@takodotid/circuit 0.39.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +305 -29
- package/dist/helpers/index.js +129 -147
- package/dist/types/core/validator.d.ts +2 -0
- package/dist/types/helpers/bgp.d.ts +53 -9
- package/dist/types/helpers/checks.d.ts +4 -0
- package/dist/types/helpers/index.d.ts +2 -2
- package/dist/types/helpers/upstreams.d.ts +115 -181
- package/dist/types/platforms/derive.d.ts +1 -1
- package/dist/types/platforms/fortios/firewall.d.ts +2 -0
- package/dist/types/platforms/fortios/index.d.ts +7 -1
- package/dist/types/platforms/fortios/render.d.ts +2 -0
- package/dist/types/platforms/fortios/vdoms.d.ts +50 -0
- package/dist/types/platforms/routeros/render.d.ts +7 -1
- package/dist/types/schema/device.d.ts +21 -1
- package/dist/types/schema/firewall.d.ts +5 -1
- package/dist/types/schema/policy.d.ts +15 -6
- package/package.json +1 -1
- package/src/core/validator.ts +35 -2
- package/src/helpers/bgp.ts +117 -67
- package/src/helpers/checks.ts +23 -0
- package/src/helpers/index.ts +2 -2
- package/src/helpers/upstreams.ts +157 -271
- package/src/platforms/derive.ts +3 -1
- package/src/platforms/fortios/firewall.ts +9 -0
- package/src/platforms/fortios/index.ts +90 -28
- package/src/platforms/fortios/render.ts +27 -5
- package/src/platforms/fortios/vdoms.ts +217 -0
- package/src/platforms/routeros/TRAPS.md +1 -0
- package/src/platforms/routeros/render.ts +36 -3
- package/src/schema/device.ts +22 -1
- package/src/schema/firewall.ts +5 -1
- package/src/schema/policy.ts +17 -6
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
570
|
-
|
|
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(
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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());
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Virtual domains: a management VDOM beside `root`. In multi-VDOM mode some sections are the device's own, under `config global`, and the rest belong to one VDOM, under `config vdom` and `edit <name>`. The render and the read write a management VDOM's sections as `@<name> <section>`, so both sides compare like any other section; a plan is made for each scope on its own, and each of its steps sent in that scope.
|
|
2
|
+
|
|
3
|
+
import type { Device } from "../../schema";
|
|
4
|
+
import type { Plan, Step } from "../platform";
|
|
5
|
+
import { FortiOsBlock, FortiOsTree } from "./tree";
|
|
6
|
+
|
|
7
|
+
/** Where a section lives in multi-VDOM mode: the device's own, or one VDOM's. */
|
|
8
|
+
type Scope = { kind: "global" } | { kind: "vdom"; vdom: string };
|
|
9
|
+
|
|
10
|
+
export class FortiOsVdoms {
|
|
11
|
+
/** The VDOM everything is in unless named otherwise. */
|
|
12
|
+
static readonly ROOT = "root";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sections under `config global` in multi-VDOM mode, from FortiOS 6.2's CLI reference: the device's identity, logins, interfaces, name resolution, time, SNMP, logging targets, flow export and session helpers. Every other section belongs to a VDOM. Not proved on a device yet.
|
|
16
|
+
*/
|
|
17
|
+
static readonly GLOBAL = new Set([
|
|
18
|
+
"vpn certificate local",
|
|
19
|
+
"vpn certificate ca",
|
|
20
|
+
"system global",
|
|
21
|
+
'system replacemsg admin "pre_admin-disclaimer-text"',
|
|
22
|
+
"system accprofile",
|
|
23
|
+
"system admin",
|
|
24
|
+
"system interface",
|
|
25
|
+
"system dns",
|
|
26
|
+
"system ntp",
|
|
27
|
+
"system snmp sysinfo",
|
|
28
|
+
"system snmp community",
|
|
29
|
+
"system snmp user",
|
|
30
|
+
"log disk setting",
|
|
31
|
+
...["", "2", "3", "4"].flatMap((number) => [`log syslogd${number} setting`, `log syslogd${number} filter`]),
|
|
32
|
+
"system sflow",
|
|
33
|
+
"system netflow",
|
|
34
|
+
"system session-helper",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** What a management VDOM holds: its routes, and who reaches the device's own services through it, with the objects those name. */
|
|
38
|
+
static readonly MANAGEMENT_SECTIONS = [
|
|
39
|
+
"firewall address",
|
|
40
|
+
"firewall address6",
|
|
41
|
+
"firewall addrgrp",
|
|
42
|
+
"firewall addrgrp6",
|
|
43
|
+
"firewall service custom",
|
|
44
|
+
"router static",
|
|
45
|
+
"router static6",
|
|
46
|
+
"firewall local-in-policy",
|
|
47
|
+
"firewall local-in-policy6",
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
/** The management VDOM's name, when the device has one. */
|
|
51
|
+
static managementOf(device: Device): string | undefined {
|
|
52
|
+
return device.vdoms?.management.name;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The VDOM a port or interface is in, by Circuit's name. A port in a LAG is where its LAG is. */
|
|
56
|
+
static vdomOf(device: Device, name: string): string {
|
|
57
|
+
const management = device.vdoms?.management;
|
|
58
|
+
if (!management) return FortiOsVdoms.ROOT;
|
|
59
|
+
|
|
60
|
+
const lag = device.ports?.[name as keyof typeof device.ports]?.lag;
|
|
61
|
+
const holder = lag ?? name;
|
|
62
|
+
return (management.interfaces as readonly string[]).includes(holder) ? management.name : FortiOsVdoms.ROOT;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A management VDOM's section as the render and the read write it. */
|
|
66
|
+
static key(vdom: string, section: string): string {
|
|
67
|
+
return `@${vdom} ${section}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A section's scope, and its name as the device writes it. */
|
|
71
|
+
static scopeOf(key: string): { scope: Scope; section: string } {
|
|
72
|
+
const named = /^@(\S+) (.+)$/.exec(key);
|
|
73
|
+
if (named) return { scope: { kind: "vdom", vdom: named[1]! }, section: named[2]! };
|
|
74
|
+
|
|
75
|
+
return FortiOsVdoms.GLOBAL.has(key)
|
|
76
|
+
? { scope: { kind: "global" }, section: key }
|
|
77
|
+
: { scope: { kind: "vdom", vdom: FortiOsVdoms.ROOT }, section: key };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether a configuration runs in multi-VDOM mode. */
|
|
81
|
+
static isMulti(tree: FortiOsBlock): boolean {
|
|
82
|
+
return tree.blocks.get("system global")?.settings.get("vdom-mode") === "multi-vdom";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Lines sent in a scope: inside `config global`, or inside one VDOM. */
|
|
86
|
+
static wrap(scope: Scope, lines: readonly string[]): string[] {
|
|
87
|
+
return scope.kind === "global"
|
|
88
|
+
? ["config global", ...lines, "end"]
|
|
89
|
+
: ["config vdom", `edit ${scope.vdom}`, ...lines, "next", "end"];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** What `show` printed in a management VDOM, each top-level section named as the render names it. */
|
|
93
|
+
static named(vdom: string, text: string): string {
|
|
94
|
+
return text.replace(/^config /gm, `config @${vdom} `);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The VDOMs `diagnose sys vd list` names, such as `name=mgmt/mgmt index=1`. */
|
|
98
|
+
static listed(text: string): string[] {
|
|
99
|
+
return [...text.matchAll(/^name=([^/\s]+)\//gm)].map((match) => match[1]!);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A plan for a device that may run in multi-VDOM mode. In one VDOM it is `planOne` as it is. In several, each scope is planned on its own and its steps sent in that scope: the global steps first, then each VDOM's, then the global removals, so an interface is gone only once no VDOM names it.
|
|
104
|
+
*
|
|
105
|
+
* Turning multi-VDOM mode on or off, and moving an interface into another VDOM, are left to an operator: the first moves everything into `root` and ends every session, and the second can cut the way in. A plan that needs either holds only the problem.
|
|
106
|
+
*/
|
|
107
|
+
static plan(desired: string, current: string, planOne: (desired: string, current: string) => Plan): Plan {
|
|
108
|
+
const want = FortiOsTree.parse(desired);
|
|
109
|
+
const have = FortiOsTree.parse(current);
|
|
110
|
+
const wantMulti = FortiOsVdoms.isMulti(want);
|
|
111
|
+
const haveMulti = FortiOsVdoms.isMulti(have);
|
|
112
|
+
|
|
113
|
+
const refused = (problem: string): Plan => ({ steps: [], problems: [problem] });
|
|
114
|
+
|
|
115
|
+
if (wantMulti && !haveMulti) {
|
|
116
|
+
return refused(
|
|
117
|
+
"the config has vdoms and the device runs one VDOM. Turn multi-VDOM mode on by hand, on the console: `config system global`, `set vdom-mode multi-vdom`, `end`. It moves everything into root and ends every session"
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (!wantMulti && haveMulti) {
|
|
121
|
+
return refused(
|
|
122
|
+
"the device runs several VDOMs and the config has no vdoms. Write vdoms.management, or turn multi-VDOM mode off by hand"
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (!wantMulti) return planOne(desired, current);
|
|
126
|
+
|
|
127
|
+
const moved = FortiOsVdoms.moved(want, have);
|
|
128
|
+
if (moved.length) {
|
|
129
|
+
return refused(
|
|
130
|
+
`${moved.map(({ name, from, to }) => `${name} from ${from} to ${to}`).join(", ")}: move an interface into another VDOM by hand, on the console, since it may carry the way in`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const scopes = new Map<string, { scope: Scope; want: FortiOsBlock; have: FortiOsBlock }>();
|
|
135
|
+
const scopeFor = (scope: Scope) => {
|
|
136
|
+
const name = scope.kind === "global" ? "" : scope.vdom;
|
|
137
|
+
if (!scopes.has(name)) scopes.set(name, { scope, want: new FortiOsBlock(), have: new FortiOsBlock() });
|
|
138
|
+
return scopes.get(name)!;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
for (const [side, tree] of [
|
|
142
|
+
["want", want],
|
|
143
|
+
["have", have],
|
|
144
|
+
] as const) {
|
|
145
|
+
for (const [key, block] of tree.blocks) {
|
|
146
|
+
const { scope, section } = FortiOsVdoms.scopeOf(key);
|
|
147
|
+
scopeFor(scope)[side].blocks.set(section, block);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const global = scopes.get("");
|
|
152
|
+
const globalPlan: Plan = global
|
|
153
|
+
? planOne(FortiOsTree.print(global.want), FortiOsTree.print(global.have))
|
|
154
|
+
: { steps: [], problems: [] };
|
|
155
|
+
const inScope = (scope: Scope, step: Step): Step => ({ ...step, send: FortiOsVdoms.wrap(scope, step.send) });
|
|
156
|
+
const removal = (step: Step) => step.title.endsWith(", removed");
|
|
157
|
+
|
|
158
|
+
const vdomPlans = [...scopes.values()]
|
|
159
|
+
.filter(({ scope }) => scope.kind === "vdom")
|
|
160
|
+
.map(({ scope, want: wanted, have: had }) => ({ scope, plan: planOne(FortiOsTree.print(wanted), FortiOsTree.print(had)) }));
|
|
161
|
+
const vdomSteps = vdomPlans.flatMap(({ scope, plan }) =>
|
|
162
|
+
plan.steps.map((step) => inScope(scope, { ...step, title: `${scope.kind === "vdom" ? scope.vdom : ""}: ${step.title}` }))
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
problems: [...globalPlan.problems, ...vdomPlans.flatMap(({ plan }) => plan.problems)],
|
|
167
|
+
steps: [
|
|
168
|
+
...globalPlan.steps.filter((step) => !removal(step)).map((step) => inScope({ kind: "global" }, step)),
|
|
169
|
+
...vdomSteps,
|
|
170
|
+
...globalPlan.steps.filter(removal).map((step) => inScope({ kind: "global" }, step)),
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Interfaces the config puts in another VDOM than the device has them in. */
|
|
176
|
+
private static moved(want: FortiOsBlock, have: FortiOsBlock): { name: string; from: string; to: string }[] {
|
|
177
|
+
const wanted = want.blocks.get("system interface");
|
|
178
|
+
const had = have.blocks.get("system interface");
|
|
179
|
+
if (!wanted || !had) return [];
|
|
180
|
+
|
|
181
|
+
return [...wanted.entries.entries()].flatMap(([name, { block }]) => {
|
|
182
|
+
const from = had.entries.get(name)?.block.settings.get("vdom");
|
|
183
|
+
const to = block.settings.get("vdom");
|
|
184
|
+
return from && to && FortiOsTree.bare(from) !== FortiOsTree.bare(to)
|
|
185
|
+
? [{ name, from: FortiOsTree.bare(from), to: FortiOsTree.bare(to) }]
|
|
186
|
+
: [];
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** What a management VDOM can't hold yet, or a config with one can't have. */
|
|
191
|
+
static gaps(device: Device): string[] {
|
|
192
|
+
if (!device.vdoms) return [];
|
|
193
|
+
|
|
194
|
+
const found: string[] = [];
|
|
195
|
+
const tunnels = Object.entries(device.interfaces ?? {}).filter(([, iface]) => iface.type === "gre" || iface.type === "vxlan");
|
|
196
|
+
if (tunnels.length)
|
|
197
|
+
found.push(`vdoms with GRE or VXLAN tunnels (${tunnels.map(([name]) => name).join(", ")}), whose tables belong to a VDOM`);
|
|
198
|
+
if (Object.values(device.interfaces ?? {}).some((iface) => iface.type === "bridge"))
|
|
199
|
+
found.push("vdoms with a bridge, which needs transparent mode");
|
|
200
|
+
|
|
201
|
+
const management = device.vdoms.management;
|
|
202
|
+
const inManagement = (name: string | undefined) =>
|
|
203
|
+
name !== undefined && (management.interfaces as readonly string[]).includes(name);
|
|
204
|
+
|
|
205
|
+
for (const route of device.routing?.static ?? []) {
|
|
206
|
+
if (inManagement(route.interface))
|
|
207
|
+
found.push(`routing.static ${route.prefix} leaves through ${route.interface}, in vdoms.management; write it there`);
|
|
208
|
+
}
|
|
209
|
+
for (const name of management.interfaces) {
|
|
210
|
+
const settings =
|
|
211
|
+
device.interfaces?.[name as keyof typeof device.interfaces] ?? device.ports?.[name as keyof typeof device.ports];
|
|
212
|
+
if (settings && "vrf" in settings && settings.vrf) found.push(`vdoms.management interface ${name} in VRF ${settings.vrf}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return found;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -25,6 +25,7 @@ Behaviour that looks like something else. Each one has been hit on a real device
|
|
|
25
25
|
- A switch-chip rule takes one port or a range, `1900-1901`; a list is refused. The render makes one rule for each port in a list.
|
|
26
26
|
- Any rule using fasttrack adds a dynamic "special dummy rule to show fasttrack counters" to the filter and the raw table. It cannot be removed, `cannot remove builtin`, so every selector over those menus skips dynamic entries.
|
|
27
27
|
- The connection table holds 1,048,576 entries on a CCR2216 with 16 GiB, and a full table refuses every new connection through the router, not only the target's. A SYN flood of 210,000 packets a second filled it in five seconds; a UDP flood from random sources fills it about six times faster, because an unanswered UDP entry lives 30 seconds. Traffic in `untracked` prefixes never enters it.
|
|
28
|
+
- A `notrack` in the raw table skips the reverse translation too, so a reply to a source NAT address inside an untracked block is never translated back (reported on a CCR2216, 7.23.5). A raw `accept` for the address in each direction, before the `notrack`, keeps it tracked; the router took both and counted packets on them.
|
|
28
29
|
- With hardware offload on, routed traffic bypasses the forward chain entirely. `hardware_offload: false` on an interface is what makes the firewall apply to it, at the cost of CPU. The only proof of offload is the `H` flag on a route.
|
|
29
30
|
- `/ip cloud ddns-enabled` cannot be turned off on 7.23.5.
|
|
30
31
|
- `mac-server` and `mac-winbox` work at layer 2 and ignore the IP firewall; they are limited by interface list.
|
|
@@ -874,13 +874,32 @@ export class RouterOsRender {
|
|
|
874
874
|
this.switchAcls();
|
|
875
875
|
}
|
|
876
876
|
|
|
877
|
-
/**
|
|
877
|
+
/**
|
|
878
|
+
* Untracked prefixes skip connection tracking in the raw table, both ways, before a connection is ever made.
|
|
879
|
+
*
|
|
880
|
+
* An address NAT uses inside one stays tracked, by an `accept` in each direction before every `notrack`: a reply to it that skipped tracking would never be translated back.
|
|
881
|
+
*/
|
|
878
882
|
private untracked(family: Family): void {
|
|
879
883
|
const menu = family === "ipv4" ? "/ip firewall raw" : "/ipv6 firewall raw";
|
|
884
|
+
const untracked = (this.device.firewall?.untracked ?? []).filter((prefix) => Address.family(prefix) === family);
|
|
880
885
|
|
|
881
|
-
for (const
|
|
882
|
-
if (Address.family(
|
|
886
|
+
for (const address of RouterOsRender.natAddresses(this.device)) {
|
|
887
|
+
if (Address.family(address) !== family || !untracked.some((prefix) => Address.contains(prefix, address))) continue;
|
|
883
888
|
|
|
889
|
+
for (const [direction, property] of [
|
|
890
|
+
["to", "dst-address"],
|
|
891
|
+
["from", "src-address"],
|
|
892
|
+
] as const) {
|
|
893
|
+
this.add(menu, "add", {
|
|
894
|
+
action: "accept",
|
|
895
|
+
chain: "prerouting",
|
|
896
|
+
comment: `Tracked for NAT, ${direction} ${address}`,
|
|
897
|
+
[property]: address,
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
for (const prefix of untracked) {
|
|
884
903
|
for (const [direction, property] of [
|
|
885
904
|
["to", "dst-address"],
|
|
886
905
|
["from", "src-address"],
|
|
@@ -895,6 +914,20 @@ export class RouterOsRender {
|
|
|
895
914
|
}
|
|
896
915
|
}
|
|
897
916
|
|
|
917
|
+
/** Every address NAT translates to or from: each translation's target, and the address a destination translation matches. Once each, in rule order. */
|
|
918
|
+
static natAddresses(device: Device): string[] {
|
|
919
|
+
const nat = device.firewall?.nat;
|
|
920
|
+
const addresses = [
|
|
921
|
+
...(nat?.source ?? []).flatMap((rule) => (rule.action === "masquerade" ? [] : [rule.to])),
|
|
922
|
+
...(nat?.destination ?? []).flatMap((rule) => [
|
|
923
|
+
...(rule.match?.dst ? [rule.match.dst] : []),
|
|
924
|
+
...(rule.action === "masquerade" ? [] : [rule.to]),
|
|
925
|
+
]),
|
|
926
|
+
];
|
|
927
|
+
|
|
928
|
+
return [...new Set(addresses)];
|
|
929
|
+
}
|
|
930
|
+
|
|
898
931
|
/**
|
|
899
932
|
* The families a rule applies to. An address in the rule decides it; otherwise it applies to both.
|
|
900
933
|
*
|