@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.
- package/dist/cli/index.js +299 -27
- package/dist/types/core/validator.d.ts +2 -0
- package/dist/types/helpers/upstreams.d.ts +2 -2
- 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 +1 -1
- package/package.json +1 -1
- package/src/core/validator.ts +25 -0
- package/src/helpers/upstreams.ts +4 -1
- 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 +1 -1
|
@@ -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
|
*
|
package/src/schema/device.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { IP, Secret } from "./common";
|
|
|
2
2
|
import type { AclRule, Firewall } from "./firewall";
|
|
3
3
|
import type { Interface, Port, Vlan } from "./interfaces";
|
|
4
4
|
import type { PrefixSet, RoutePolicy } from "./policy";
|
|
5
|
-
import type { Routing } from "./routing";
|
|
5
|
+
import type { Routing, StaticRoute } from "./routing";
|
|
6
6
|
import type { Certificate, DhcpRelay, DhcpServer, FlowExport, Management, System, User } from "./system";
|
|
7
7
|
|
|
8
8
|
/** How the framework reaches the device. */
|
|
@@ -43,6 +43,25 @@ export type Console = {
|
|
|
43
43
|
passwordOnly?: true;
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Virtual domains, on FortiOS: the device's own traffic kept apart from the traffic it routes. Everything is in `root` when absent.
|
|
48
|
+
*
|
|
49
|
+
* Multi-VDOM mode is turned on by hand, not by a plan: it moves the whole configuration into `root` and ends every session.
|
|
50
|
+
*/
|
|
51
|
+
export type Vdoms<I extends string> = {
|
|
52
|
+
/**
|
|
53
|
+
* 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`.
|
|
54
|
+
*/
|
|
55
|
+
management: {
|
|
56
|
+
/** Its name, such as `mgmt`. Not `root`. */
|
|
57
|
+
name: string;
|
|
58
|
+
/** The ports and interfaces in it, by name. A LAG's member ports go with the LAG. */
|
|
59
|
+
interfaces: readonly I[];
|
|
60
|
+
/** Its static routes, such as its own default route. */
|
|
61
|
+
static?: readonly StaticRoute<I, never>[];
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
46
65
|
/** A separate routing table, with the interfaces that route in it. */
|
|
47
66
|
export type Vrf = {
|
|
48
67
|
/** What the VRF is for. */
|
|
@@ -120,4 +139,6 @@ export type Device<
|
|
|
120
139
|
};
|
|
121
140
|
/** Forward in hardware where the platform can. An interface may override it. */
|
|
122
141
|
hardware_offload?: boolean;
|
|
142
|
+
/** Virtual domains, on FortiOS only: a management VDOM apart from the routed traffic. */
|
|
143
|
+
vdoms?: Vdoms<NoInfer<I | N>>;
|
|
123
144
|
};
|
package/src/schema/firewall.ts
CHANGED
|
@@ -104,7 +104,7 @@ export type Firewall<I extends string, AS extends string> = {
|
|
|
104
104
|
};
|
|
105
105
|
/** Helpers that run. None when absent. */
|
|
106
106
|
helpers?: readonly Helper[];
|
|
107
|
-
/** 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. */
|
|
107
|
+
/** 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. */
|
|
108
108
|
untracked?: readonly Prefix[];
|
|
109
109
|
};
|
|
110
110
|
|