@takodotid/circuit 0.29.0 → 0.31.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 +121 -3
- package/dist/presets/index.js +3 -1
- package/dist/types/adapters/devices/fortios/firewall.d.ts +2 -0
- package/dist/types/adapters/devices/fortios/index.d.ts +1 -1
- package/dist/types/adapters/devices/fortios/names.d.ts +9 -0
- package/dist/types/adapters/devices/fortios/render.d.ts +2 -0
- package/dist/types/adapters/devices/routeros/render.d.ts +2 -0
- package/dist/types/presets/index.d.ts +4 -0
- package/dist/types/schema/interfaces.d.ts +13 -1
- package/package.json +1 -1
- package/src/adapters/devices/fortios/TRAPS.md +3 -0
- package/src/adapters/devices/fortios/firewall.ts +11 -1
- package/src/adapters/devices/fortios/index.ts +50 -2
- package/src/adapters/devices/fortios/names.ts +16 -0
- package/src/adapters/devices/fortios/plan.ts +10 -1
- package/src/adapters/devices/fortios/render.ts +19 -1
- package/src/adapters/devices/junos/index.ts +5 -0
- package/src/adapters/devices/raisecom-ros/index.ts +5 -0
- package/src/adapters/devices/routeros/TRAPS.md +2 -0
- package/src/adapters/devices/routeros/export.ts +3 -0
- package/src/adapters/devices/routeros/index.ts +4 -0
- package/src/adapters/devices/routeros/plan.ts +1 -0
- package/src/adapters/devices/routeros/render.ts +24 -2
- package/src/adapters/devices/vrp/index.ts +5 -0
- package/src/core/validate.ts +15 -0
- package/src/presets/index.ts +7 -1
- package/src/schema/interfaces.ts +14 -1
package/dist/cli/index.js
CHANGED
|
@@ -535,6 +535,14 @@ class ConsoleShell {
|
|
|
535
535
|
}
|
|
536
536
|
|
|
537
537
|
// src/adapters/devices/fortios/names.ts
|
|
538
|
+
function bridgesOf(device) {
|
|
539
|
+
return Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) => iface.type === "bridge" ? [{ name, members: iface.members }] : []);
|
|
540
|
+
}
|
|
541
|
+
var isTransparent = (device) => bridgesOf(device).length > 0;
|
|
542
|
+
function forwardDomainOf(device, name) {
|
|
543
|
+
const index = bridgesOf(device).findIndex((bridge) => bridge.members.includes(name));
|
|
544
|
+
return index < 0 ? undefined : index + 1;
|
|
545
|
+
}
|
|
538
546
|
function nameOf(device, name) {
|
|
539
547
|
if (device.interfaces && name in device.interfaces)
|
|
540
548
|
return name;
|
|
@@ -715,6 +723,7 @@ function portList(ports) {
|
|
|
715
723
|
const list = Array.isArray(ports) ? ports : [ports];
|
|
716
724
|
return list.map((port) => typeof port === "number" ? String(port) : `${port.min}-${port.max}`);
|
|
717
725
|
}
|
|
726
|
+
var ALL_IN_TRANSPARENT = "circuit-all";
|
|
718
727
|
function serviceOf(match, family) {
|
|
719
728
|
const protocol = match?.protocol;
|
|
720
729
|
const destinations = portList(match?.dst_port);
|
|
@@ -775,6 +784,10 @@ class Objects {
|
|
|
775
784
|
}
|
|
776
785
|
service(match, family) {
|
|
777
786
|
const { name, block } = serviceOf(match, family);
|
|
787
|
+
if (name === "ALL" && isTransparent(this.device)) {
|
|
788
|
+
this.root.block("firewall service custom").entry(ALL_IN_TRANSPARENT).set("protocol", "IP");
|
|
789
|
+
return ALL_IN_TRANSPARENT;
|
|
790
|
+
}
|
|
778
791
|
if (!block)
|
|
779
792
|
return name;
|
|
780
793
|
const entry = this.root.block("firewall service custom").entry(name);
|
|
@@ -1927,7 +1940,11 @@ function plan(desired, current, options) {
|
|
|
1927
1940
|
removals.unshift({ title: `${section}, removed`, show: diff.removed, send: [`config ${header}`, ...diff.removals, "end"] });
|
|
1928
1941
|
}
|
|
1929
1942
|
}
|
|
1930
|
-
|
|
1943
|
+
const opmode = (root) => root.blocks.get("system settings")?.settings.get("opmode");
|
|
1944
|
+
const problems = opmode(have) === "transparent" && opmode(want) !== "transparent" ? [
|
|
1945
|
+
"the device runs in transparent mode and the config has no bridge; leave transparent mode by hand, `set opmode nat` with an address and an interface under `config system settings`, then plan again"
|
|
1946
|
+
] : [];
|
|
1947
|
+
return { steps: [...first, ...steps, ...removals, ...late], problems };
|
|
1931
1948
|
}
|
|
1932
1949
|
var TUNNELS = "system interface, tunnels";
|
|
1933
1950
|
function separateTunnels(root) {
|
|
@@ -2199,6 +2216,7 @@ function allowAccess(device) {
|
|
|
2199
2216
|
services.push("fgfm");
|
|
2200
2217
|
return services.join(" ");
|
|
2201
2218
|
}
|
|
2219
|
+
var TRANSPARENT_MANAGEIP = "169.254.0.1/255.255.255.255";
|
|
2202
2220
|
function render(device) {
|
|
2203
2221
|
const root = new Block;
|
|
2204
2222
|
for (const section of SECTIONS)
|
|
@@ -2208,6 +2226,8 @@ function render(device) {
|
|
|
2208
2226
|
global(section("system global"), device);
|
|
2209
2227
|
if (device.system?.banner)
|
|
2210
2228
|
section(BANNER).set("buffer", quote(device.system.banner));
|
|
2229
|
+
if (isTransparent(device))
|
|
2230
|
+
section("system settings").set("opmode", "transparent").set("manageip", TRANSPARENT_MANAGEIP);
|
|
2211
2231
|
if (natRules(device).length)
|
|
2212
2232
|
section("system settings").set("central-nat", "enable");
|
|
2213
2233
|
section("system settings").set("default-voip-alg-mode", "kernel-helper-based");
|
|
@@ -2334,6 +2354,9 @@ function interfaces(root, device) {
|
|
|
2334
2354
|
if (parent)
|
|
2335
2355
|
entry.set("interface", quote(nameOf(device, parent)));
|
|
2336
2356
|
entry.set("vlanid", device.vlans?.[iface.vlan]?.id ?? 0);
|
|
2357
|
+
const domain = forwardDomainOf(device, name);
|
|
2358
|
+
if (domain)
|
|
2359
|
+
entry.set("forward-domain", domain);
|
|
2337
2360
|
if (iface.description)
|
|
2338
2361
|
entry.set("description", quote(iface.description));
|
|
2339
2362
|
if (iface.mtu)
|
|
@@ -2898,7 +2921,18 @@ async function settle(device, options) {
|
|
|
2898
2921
|
}
|
|
2899
2922
|
return "the device did not come back in time and still reverts every change; set `cfg-save automatic` under `config system global`";
|
|
2900
2923
|
}
|
|
2901
|
-
async function apply(device,
|
|
2924
|
+
async function apply(device, network, changes, options) {
|
|
2925
|
+
await applyOnce(device, changes, options);
|
|
2926
|
+
const switched = changes.steps.some((step) => step.send.some((line) => line.startsWith("set opmode ")));
|
|
2927
|
+
if (!switched)
|
|
2928
|
+
return;
|
|
2929
|
+
const again = plan(render(device), await read(device, network, options), options);
|
|
2930
|
+
if (!again.steps.length)
|
|
2931
|
+
return;
|
|
2932
|
+
console.log(" the operation mode changed; applying what it added on its own");
|
|
2933
|
+
await applyOnce(device, again, options);
|
|
2934
|
+
}
|
|
2935
|
+
async function applyOnce(device, plan, options) {
|
|
2902
2936
|
const seconds = options.rollback * 60;
|
|
2903
2937
|
try {
|
|
2904
2938
|
if (options.console) {
|
|
@@ -2969,6 +3003,11 @@ function unsupported(device) {
|
|
|
2969
3003
|
found.push(...serviceGaps(device));
|
|
2970
3004
|
if (device.stp)
|
|
2971
3005
|
found.push("stp, ports are routed");
|
|
3006
|
+
found.push(...transparentGaps(device));
|
|
3007
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
3008
|
+
if (iface.type === "vlan" && iface.mac)
|
|
3009
|
+
found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
|
|
3010
|
+
}
|
|
2972
3011
|
if (Object.keys(device.vrfs ?? {}).length > VRFS)
|
|
2973
3012
|
found.push(`vrfs, at most ${VRFS}`);
|
|
2974
3013
|
for (const [name, vrf] of Object.entries(device.vrfs ?? {})) {
|
|
@@ -2977,6 +3016,35 @@ function unsupported(device) {
|
|
|
2977
3016
|
}
|
|
2978
3017
|
return found;
|
|
2979
3018
|
}
|
|
3019
|
+
function transparentGaps(device) {
|
|
3020
|
+
if (!isTransparent(device))
|
|
3021
|
+
return [];
|
|
3022
|
+
const found = [];
|
|
3023
|
+
const where = "in transparent mode, which a bridge needs";
|
|
3024
|
+
if (device.connection.host)
|
|
3025
|
+
found.push(`connection.host, ${where}: the device is reached through its console`);
|
|
3026
|
+
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
3027
|
+
if (port?.addresses?.length)
|
|
3028
|
+
found.push(`ports.${name}.addresses, ${where}`);
|
|
3029
|
+
}
|
|
3030
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
3031
|
+
if (iface.addresses?.length)
|
|
3032
|
+
found.push(`interfaces.${name}.addresses, ${where}`);
|
|
3033
|
+
}
|
|
3034
|
+
const routing = device.routing;
|
|
3035
|
+
if (routing?.static?.length || routing?.bgp || routing?.ospf)
|
|
3036
|
+
found.push(`routing, ${where}`);
|
|
3037
|
+
if (device.firewall?.nat)
|
|
3038
|
+
found.push(`firewall.nat, ${where}`);
|
|
3039
|
+
if (device.dhcp && Object.keys(device.dhcp).length)
|
|
3040
|
+
found.push(`dhcp, ${where}`);
|
|
3041
|
+
const management = device.management ?? {};
|
|
3042
|
+
for (const service of ["ssh", "telnet", "http", "https", "api", "api_tls", "snmp", "native"]) {
|
|
3043
|
+
if (management[service])
|
|
3044
|
+
found.push(`management.${service}, ${where}: the device has no address to answer on`);
|
|
3045
|
+
}
|
|
3046
|
+
return found;
|
|
3047
|
+
}
|
|
2980
3048
|
function systemGaps(device) {
|
|
2981
3049
|
const found = [];
|
|
2982
3050
|
const system = device.system ?? {};
|
|
@@ -4745,6 +4813,12 @@ async function apply2(device, _network, plan, options) {
|
|
|
4745
4813
|
}
|
|
4746
4814
|
function unsupported2(device) {
|
|
4747
4815
|
const found = [];
|
|
4816
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
4817
|
+
if (iface.type === "bridge")
|
|
4818
|
+
found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
|
|
4819
|
+
if (iface.type === "vlan" && iface.mac)
|
|
4820
|
+
found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
|
|
4821
|
+
}
|
|
4748
4822
|
const names = new Names(device);
|
|
4749
4823
|
const users = device.users ?? {};
|
|
4750
4824
|
const root = users.root;
|
|
@@ -5495,6 +5569,12 @@ async function read3(device, _network = [], options = {}) {
|
|
|
5495
5569
|
}
|
|
5496
5570
|
function unsupported3(device) {
|
|
5497
5571
|
const found = [];
|
|
5572
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
5573
|
+
if (iface.type === "bridge")
|
|
5574
|
+
found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
|
|
5575
|
+
if (iface.type === "vlan" && iface.mac)
|
|
5576
|
+
found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
|
|
5577
|
+
}
|
|
5498
5578
|
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
5499
5579
|
if (!port)
|
|
5500
5580
|
continue;
|
|
@@ -5819,6 +5899,7 @@ var DEFAULTS2 = {
|
|
|
5819
5899
|
"limit-unknown-unicasts": "no"
|
|
5820
5900
|
},
|
|
5821
5901
|
"/interface vlan": { "l3-hw-offloading": "yes", mtu: "1500" },
|
|
5902
|
+
"/interface macvlan": { mode: "bridge", mtu: "1500" },
|
|
5822
5903
|
"/interface vrrp": { priority: "100", "preemption-mode": "yes", interval: "1s", version: "3", "v3-protocol": "ipv4" },
|
|
5823
5904
|
"/interface vxlan": { port: "4789" },
|
|
5824
5905
|
"/interface wireguard peers": {
|
|
@@ -5919,6 +6000,7 @@ var IDENTITY = {
|
|
|
5919
6000
|
"/ipv6 firewall address-list": ["list", "address"],
|
|
5920
6001
|
"/interface bridge port": ["interface"],
|
|
5921
6002
|
"/interface bridge vlan": ["vlan-ids"],
|
|
6003
|
+
"/interface vlan": ["interface", "vlan-id"],
|
|
5922
6004
|
"/interface vxlan vteps": ["interface", "remote-ip"],
|
|
5923
6005
|
"/routing bgp connection": ["remote.address"],
|
|
5924
6006
|
"/routing rpki": ["address"],
|
|
@@ -5974,6 +6056,7 @@ var ORDER = [
|
|
|
5974
6056
|
"/interface ethernet",
|
|
5975
6057
|
"/interface bonding",
|
|
5976
6058
|
"/interface vlan",
|
|
6059
|
+
"/interface macvlan",
|
|
5977
6060
|
"/interface gre",
|
|
5978
6061
|
"/interface vxlan",
|
|
5979
6062
|
"/interface wireguard",
|
|
@@ -6492,17 +6575,28 @@ function lags2(out, ctx) {
|
|
|
6492
6575
|
}));
|
|
6493
6576
|
}
|
|
6494
6577
|
}
|
|
6578
|
+
var underMacvlan = (name) => `${name}-vlan`;
|
|
6495
6579
|
function vlanInterfaces(out, ctx) {
|
|
6496
6580
|
for (const [name, iface] of ctx.vlanInterfaces()) {
|
|
6497
6581
|
const softwareOnly = ctx.device.hardware_offload && iface.hardware_offload === false;
|
|
6582
|
+
const vlanName = iface.mac ? underMacvlan(name) : name;
|
|
6498
6583
|
out.add("/interface vlan", command("add", {
|
|
6499
6584
|
comment: iface.description,
|
|
6500
6585
|
interface: BRIDGE,
|
|
6501
6586
|
"l3-hw-offloading": softwareOnly ? "no" : undefined,
|
|
6502
6587
|
mtu: iface.mtu,
|
|
6503
|
-
name,
|
|
6588
|
+
name: vlanName,
|
|
6504
6589
|
"vlan-id": ctx.vlanId(iface.vlan)
|
|
6505
6590
|
}));
|
|
6591
|
+
if (iface.mac) {
|
|
6592
|
+
out.add("/interface macvlan", command("add", {
|
|
6593
|
+
comment: iface.description,
|
|
6594
|
+
interface: vlanName,
|
|
6595
|
+
"mac-address": iface.mac.toUpperCase(),
|
|
6596
|
+
mtu: iface.mtu,
|
|
6597
|
+
name
|
|
6598
|
+
}));
|
|
6599
|
+
}
|
|
6506
6600
|
}
|
|
6507
6601
|
}
|
|
6508
6602
|
function tunnels(out, ctx) {
|
|
@@ -7448,6 +7542,10 @@ ${certificates}` : "";
|
|
|
7448
7542
|
}
|
|
7449
7543
|
function unsupported4(device) {
|
|
7450
7544
|
const found = [];
|
|
7545
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
7546
|
+
if (iface.type === "bridge")
|
|
7547
|
+
found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
|
|
7548
|
+
}
|
|
7451
7549
|
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
7452
7550
|
if (!port)
|
|
7453
7551
|
continue;
|
|
@@ -8129,6 +8227,12 @@ function configurationOf(device, dump) {
|
|
|
8129
8227
|
}
|
|
8130
8228
|
function unsupported5(device) {
|
|
8131
8229
|
const found = [];
|
|
8230
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
8231
|
+
if (iface.type === "bridge")
|
|
8232
|
+
found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
|
|
8233
|
+
if (iface.type === "vlan" && iface.mac)
|
|
8234
|
+
found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
|
|
8235
|
+
}
|
|
8132
8236
|
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
8133
8237
|
if (!port)
|
|
8134
8238
|
continue;
|
|
@@ -8935,6 +9039,20 @@ function checkInterfaces(device, names, report, ip) {
|
|
|
8935
9039
|
if (!members.length)
|
|
8936
9040
|
report(`LAG ${name} has no member ports`);
|
|
8937
9041
|
}
|
|
9042
|
+
if (iface.type === "bridge") {
|
|
9043
|
+
if (iface.members.length < 2)
|
|
9044
|
+
report(`bridge ${name} joins fewer than two interfaces`);
|
|
9045
|
+
for (const member of iface.members) {
|
|
9046
|
+
const joined = device.interfaces?.[member];
|
|
9047
|
+
if (joined?.type !== "vlan")
|
|
9048
|
+
report(`bridge ${name}: member ${member} is not a VLAN interface`);
|
|
9049
|
+
else if (joined.addresses?.length)
|
|
9050
|
+
report(`bridge ${name}: member ${member} has an address of its own`);
|
|
9051
|
+
const others = Object.entries(device.interfaces ?? {}).filter(([other, settings]) => other !== name && settings.type === "bridge" && settings.members.includes(member));
|
|
9052
|
+
if (others.length)
|
|
9053
|
+
report(`bridge ${name}: member ${member} is also in bridge ${others[0][0]}`);
|
|
9054
|
+
}
|
|
9055
|
+
}
|
|
8938
9056
|
if (iface.type === "wireguard") {
|
|
8939
9057
|
for (const peer of iface.peers ?? []) {
|
|
8940
9058
|
for (const address of peer.allowed_addresses) {
|
package/dist/presets/index.js
CHANGED
|
@@ -122,6 +122,7 @@ var vlansOf = (port) => [port.access_vlan, ...port.trunk_vlans ?? []].filter((vl
|
|
|
122
122
|
var trustBoundary = (boundary) => (devices) => {
|
|
123
123
|
const untrusted = new Set(boundary.untrusted);
|
|
124
124
|
const routers = new Set(boundary.routers);
|
|
125
|
+
const extended = (a, b) => (boundary.extended ?? []).some(([x, y]) => x === a && y === b || x === b && y === a);
|
|
125
126
|
const findings = [];
|
|
126
127
|
for (const device of devices) {
|
|
127
128
|
const isRouter = routers.has(device.name);
|
|
@@ -143,7 +144,8 @@ var trustBoundary = (boundary) => (devices) => {
|
|
|
143
144
|
for (const { what, carried, faces } of carriers) {
|
|
144
145
|
const mixes = carried.some((vlan) => untrusted.has(vlan)) && carried.some((vlan) => !untrusted.has(vlan));
|
|
145
146
|
const facesRouter = isRouter || faces.some((name) => routers.has(name));
|
|
146
|
-
|
|
147
|
+
const isExtension = faces.length > 0 && faces.every((name) => extended(device.name, name));
|
|
148
|
+
if (mixes && !facesRouter && !isExtension) {
|
|
147
149
|
findings.push({
|
|
148
150
|
level: "error",
|
|
149
151
|
device: device.name,
|
|
@@ -8,6 +8,8 @@ export declare function natRules(device: Device): NatRule<string, string>[];
|
|
|
8
8
|
export declare function familiesOf(device: Device, match: FilterMatch<string, string> | undefined): Family[];
|
|
9
9
|
/** A port or ports as the platform writes them in a service: `22`, `1000-2000`. */
|
|
10
10
|
export declare function portList(ports: Ports | undefined): string[];
|
|
11
|
+
/** Every protocol, as a service of Circuit's own, for a device in transparent mode. */
|
|
12
|
+
export declare const ALL_IN_TRANSPARENT = "circuit-all";
|
|
11
13
|
/** The service a rule matches, by name, with what it holds. `ALL` when the rule names no protocol and no port; ports without a protocol are TCP's and UDP's. */
|
|
12
14
|
export declare function serviceOf(match: FilterMatch<string, string> | undefined, family: Family): {
|
|
13
15
|
name: string;
|
|
@@ -49,7 +49,7 @@ export type Guard = {
|
|
|
49
49
|
* 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.
|
|
50
50
|
*/
|
|
51
51
|
export declare function configure(cli: Cli, plan: Plan, guard: Guard): Promise<void>;
|
|
52
|
-
export declare function apply(device: Device,
|
|
52
|
+
export declare function apply(device: Device, network: readonly Device[], changes: Plan, options: ApplyOptions): Promise<void>;
|
|
53
53
|
/** The lines that replace a prefix list's entries with what a registry returned: every entry is purged, then each prefix added, matching it and anything more specific. */
|
|
54
54
|
export declare function fillLines(set: string, family: Family, prefixes: string[]): string[];
|
|
55
55
|
/** Replace a prefix list `refresh` fetched. It cannot cut access, so it is sent without a guard, and saved as it goes. `purge` asks to confirm, and is answered yes. */
|
|
@@ -7,6 +7,15 @@ type Addressed = {
|
|
|
7
7
|
vrf?: string;
|
|
8
8
|
lag?: string;
|
|
9
9
|
};
|
|
10
|
+
/** The bridges a device has, in the config's order. A device with any runs in transparent mode, each bridge a forward domain numbered by its place. */
|
|
11
|
+
export declare function bridgesOf(device: Device): {
|
|
12
|
+
name: string;
|
|
13
|
+
members: readonly string[];
|
|
14
|
+
}[];
|
|
15
|
+
/** Whether the device runs in transparent mode: it has a bridge. */
|
|
16
|
+
export declare const isTransparent: (device: Device) => boolean;
|
|
17
|
+
/** The forward domain of a VLAN interface in a bridge, or undefined. */
|
|
18
|
+
export declare function forwardDomainOf(device: Device, name: string): number | undefined;
|
|
10
19
|
/** The name the platform gives a port or an interface: a port by its spelling, any other interface by its key. */
|
|
11
20
|
export declare function nameOf(device: Device, name: string): string;
|
|
12
21
|
/** A port's or an interface's settings, by its key. */
|
|
@@ -31,6 +31,8 @@ export declare const LEVEL: Record<Severity, string>;
|
|
|
31
31
|
export declare const HELPERS: Partial<Record<Helper, readonly [string, number, number][]>>;
|
|
32
32
|
/** Speed by the platform's spelling, for each class a port's cage takes. */
|
|
33
33
|
export declare const SPEEDS: Record<string, Partial<Record<string, string>>>;
|
|
34
|
+
/** The management address of a device in transparent mode, which is reached only through its console: link-local, on no network it can reach. */
|
|
35
|
+
export declare const TRANSPARENT_MANAGEIP = "169.254.0.1/255.255.255.255";
|
|
34
36
|
export declare function render(device: Device): string;
|
|
35
37
|
/** The one port or LAG that carries a VLAN tagged, which its VLAN interface sits on. `unsupported()` refuses any other case. */
|
|
36
38
|
export declare function vlanParent(device: Device, vlan: string): string | undefined;
|
|
@@ -64,6 +64,8 @@ export declare function renderInterfaces(out: Output, ctx: Context): void;
|
|
|
64
64
|
type Speed = NonNullable<AnyPort["speed"]>;
|
|
65
65
|
/** The platform's name for a fixed speed on a port, or undefined when it has none. */
|
|
66
66
|
export declare function mediumOf(platformPortName: string, speed: Speed): string | undefined;
|
|
67
|
+
/** The name of the VLAN interface under one with a MAC of its own, which takes the configured name itself. */
|
|
68
|
+
export declare const underMacvlan: (name: string) => string;
|
|
67
69
|
/** The interface holding one VRRP group. */
|
|
68
70
|
export declare const vrrpName: (iface: string, id: number) => string;
|
|
69
71
|
/**
|
|
@@ -83,6 +83,10 @@ export type TrustBoundary = {
|
|
|
83
83
|
untrusted: readonly string[];
|
|
84
84
|
/** The devices that filter: the only ones that may give an untrusted VLAN an address, and the only ones a port carrying both kinds may face. */
|
|
85
85
|
routers: readonly string[];
|
|
86
|
+
/**
|
|
87
|
+
* Links between two switches that may carry both kinds, each named by its two devices: a site's switch extended to another building, with an exchange's VLAN and its own management on one cable. Neither end gives an untrusted VLAN an address.
|
|
88
|
+
*/
|
|
89
|
+
extended?: readonly (readonly [string, string])[];
|
|
86
90
|
};
|
|
87
91
|
/** A check: traffic from outside reaches your own VLANs only through a router. A port or LAG carrying an untrusted VLAN beside any other is on a router or faces one, and only a router gives an untrusted VLAN an address. */
|
|
88
92
|
export declare const trustBoundary: (boundary: TrustBoundary) => Check;
|
|
@@ -114,6 +114,10 @@ export type VlanInterface<V extends string, F extends string> = Base<F> & {
|
|
|
114
114
|
type: "vlan";
|
|
115
115
|
/** The VLAN it is on. */
|
|
116
116
|
vlan: V;
|
|
117
|
+
/**
|
|
118
|
+
* A MAC address of its own, instead of the one it shares with the device's other VLAN interfaces. A gateway in front of a transparent firewall needs one: frames to it come back on another VLAN, where the bridge would otherwise take a shared MAC for its own and keep them.
|
|
119
|
+
*/
|
|
120
|
+
mac?: string;
|
|
117
121
|
};
|
|
118
122
|
/** An interface that is always up and is on no link, for addresses that must not depend on one. */
|
|
119
123
|
export type Loopback<F extends string> = Base<F> & {
|
|
@@ -176,6 +180,14 @@ export type WireGuard<F extends string> = Base<F> & {
|
|
|
176
180
|
/** Who may connect. */
|
|
177
181
|
peers?: readonly WireGuardPeer[];
|
|
178
182
|
};
|
|
183
|
+
/**
|
|
184
|
+
* A layer 2 bridge between VLAN interfaces, each keeping its own VLAN: a frame that comes in on one member leaves another with that member's tag. A firewall filters what crosses it, by the rules of its forward chain between the members, without routing it.
|
|
185
|
+
*/
|
|
186
|
+
export type Bridge<F extends string> = Base<F> & {
|
|
187
|
+
type: "bridge";
|
|
188
|
+
/** The VLAN interfaces it joins, by name. Each has no address of its own. */
|
|
189
|
+
members: readonly string[];
|
|
190
|
+
};
|
|
179
191
|
/** Any interface that is not a physical port. */
|
|
180
|
-
export type Interface<V extends string, F extends string> = VlanInterface<V, F> | Loopback<F> | Lag<V, F> | Gre<F> | Vxlan<F> | WireGuard<F>;
|
|
192
|
+
export type Interface<V extends string, F extends string> = VlanInterface<V, F> | Loopback<F> | Lag<V, F> | Gre<F> | Vxlan<F> | WireGuard<F> | Bridge<F>;
|
|
181
193
|
export {};
|
package/package.json
CHANGED
|
@@ -40,3 +40,6 @@ Behaviour that looks like something else. Each one has been hit on a FortiGate 3
|
|
|
40
40
|
- The ports are `port1` to `port4` at 40G and `port5` to `port32` at 10G, then `mgmt1` and `mgmt2`. The factory dedicates both management ports to management and gives them addresses `192.168.1.99` and `192.168.2.99`.
|
|
41
41
|
- On the serial console a line ends at a carriage return. A session left open stays logged in, and one left inside `config` stays there, so the next login must first leave and log out. The console takes 9600 bits per second: reading the sections Circuit manages takes about two minutes.
|
|
42
42
|
- A data port can stay `netdev status: down`, with `rx_carrier: 0` in `diagnose hardware deviceinfo nic`, while the far end reads link-ok and frames reach the port's switch chip. Fixing the speed does not change it, in a LAG or out of one; a reboot does. `link_status: Up` there says nothing: a port with no cable shows it too.
|
|
43
|
+
- Transparent mode, `set opmode transparent` under `config system settings`, refuses to start without a `manageip`: `Attribute 'manageip' MUST be set`. Switching to it takes every interface's address away, replaces the static routes with one default gateway, puts every port in one bridge, and deletes the factory's custom services, `ALL` among them, so a policy that names `ALL` is refused, `entry not found in datasource`. A link-local `manageip`, such as `169.254.0.1/255.255.255.255`, is accepted. It also adds an empty static route in each family and turns memory logging on, so an apply that switches the mode plans once more after it.
|
|
44
|
+
- `forward-domain` exists only in transparent mode: in NAT mode it is `command parse error`. VLAN interfaces in the same forward domain are bridged, each frame leaving with the tag of the interface it leaves by.
|
|
45
|
+
- Leaving transparent mode, `set opmode nat`, refuses `IP address must be a class A, B, or C ip` until an address is given with it.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { host } from "../../../core/addr";
|
|
4
4
|
import type { AclRule, Device, Family, FilterMatch, FilterRule, NatRule, Ports, Protocol } from "../../../schema";
|
|
5
|
-
import { familyOfPrefix, nameOf, network, subnet, withLength } from "./names";
|
|
5
|
+
import { familyOfPrefix, isTransparent, nameOf, network, subnet, withLength } from "./names";
|
|
6
6
|
import { Block, quote, quoteAll, UNSET } from "./tree";
|
|
7
7
|
|
|
8
8
|
/** IP protocol numbers, for a protocol the platform names only by number. */
|
|
@@ -51,6 +51,9 @@ export function portList(ports: Ports | undefined): string[] {
|
|
|
51
51
|
return list.map((port) => (typeof port === "number" ? String(port) : `${port.min}-${port.max}`));
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** Every protocol, as a service of Circuit's own, for a device in transparent mode. */
|
|
55
|
+
export const ALL_IN_TRANSPARENT = "circuit-all";
|
|
56
|
+
|
|
54
57
|
/** The service a rule matches, by name, with what it holds. `ALL` when the rule names no protocol and no port; ports without a protocol are TCP's and UDP's. */
|
|
55
58
|
export function serviceOf(match: FilterMatch<string, string> | undefined, family: Family): { name: string; block?: Block } {
|
|
56
59
|
const protocol = match?.protocol;
|
|
@@ -134,6 +137,13 @@ class Objects {
|
|
|
134
137
|
|
|
135
138
|
service(match: FilterMatch<string, string> | undefined, family: Family): string {
|
|
136
139
|
const { name, block } = serviceOf(match, family);
|
|
140
|
+
|
|
141
|
+
// Transparent mode deletes the factory's services, `ALL` with them, so every protocol is a service of Circuit's own there.
|
|
142
|
+
if (name === "ALL" && isTransparent(this.device)) {
|
|
143
|
+
this.root.block("firewall service custom").entry(ALL_IN_TRANSPARENT).set("protocol", "IP");
|
|
144
|
+
return ALL_IN_TRANSPARENT;
|
|
145
|
+
}
|
|
146
|
+
|
|
137
147
|
if (!block) return name;
|
|
138
148
|
|
|
139
149
|
const entry = this.root.block("firewall service custom").entry(name);
|
|
@@ -11,7 +11,7 @@ import { connect, managementHost, shell } from "../../../transport/ssh";
|
|
|
11
11
|
import { portsOf, spell } from "../catalog";
|
|
12
12
|
import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
|
|
13
13
|
import { acceptsBeforeDrop, familiesOf, isImplied, serviceOf } from "./firewall";
|
|
14
|
-
import { holderOf, interfaceFor, network, settingsOf, subnet } from "./names";
|
|
14
|
+
import { holderOf, interfaceFor, isTransparent, network, settingsOf, subnet } from "./names";
|
|
15
15
|
import { clean, plan } from "./plan";
|
|
16
16
|
import { COLLECTORS, FULL, HELPERS, RESOLVERS, render, SECTIONS, SPEEDS, SSH_KEYS, vlanParent, VRFS, zoneOf } from "./render";
|
|
17
17
|
import { BITS, PREFIX_LIST, routingGaps } from "./routing";
|
|
@@ -376,7 +376,21 @@ async function settle(device: Device, options: ReadOptions): Promise<string> {
|
|
|
376
376
|
return "the device did not come back in time and still reverts every change; set `cfg-save automatic` under `config system global`";
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
-
export async function apply(device: Device,
|
|
379
|
+
export async function apply(device: Device, network: readonly Device[], changes: Plan, options: ApplyOptions): Promise<void> {
|
|
380
|
+
await applyOnce(device, changes, options);
|
|
381
|
+
|
|
382
|
+
// Switching the operation mode makes changes of its own, such as a static route in each family and memory logging on, which a plan made before it could not see. One more pass, planned against what the device now runs, takes them back.
|
|
383
|
+
const switched = changes.steps.some((step) => step.send.some((line) => line.startsWith("set opmode ")));
|
|
384
|
+
if (!switched) return;
|
|
385
|
+
|
|
386
|
+
const again = plan(render(device), await read(device, network, options), options);
|
|
387
|
+
if (!again.steps.length) return;
|
|
388
|
+
|
|
389
|
+
console.log(" the operation mode changed; applying what it added on its own");
|
|
390
|
+
await applyOnce(device, again, options);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function applyOnce(device: Device, plan: Plan, options: ApplyOptions): Promise<void> {
|
|
380
394
|
const seconds = options.rollback * 60;
|
|
381
395
|
|
|
382
396
|
try {
|
|
@@ -454,6 +468,11 @@ export function unsupported(device: Device): string[] {
|
|
|
454
468
|
found.push(...serviceGaps(device));
|
|
455
469
|
|
|
456
470
|
if (device.stp) found.push("stp, ports are routed");
|
|
471
|
+
found.push(...transparentGaps(device));
|
|
472
|
+
|
|
473
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
474
|
+
if (iface.type === "vlan" && iface.mac) found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
|
|
475
|
+
}
|
|
457
476
|
if (Object.keys(device.vrfs ?? {}).length > VRFS) found.push(`vrfs, at most ${VRFS}`);
|
|
458
477
|
|
|
459
478
|
for (const [name, vrf] of Object.entries(device.vrfs ?? {})) {
|
|
@@ -463,6 +482,35 @@ export function unsupported(device: Device): string[] {
|
|
|
463
482
|
return found;
|
|
464
483
|
}
|
|
465
484
|
|
|
485
|
+
/** What transparent mode, which a bridge needs, leaves out: every address, routing, NAT and the services that answer on an address. The device is reached through its console. */
|
|
486
|
+
function transparentGaps(device: Device): string[] {
|
|
487
|
+
if (!isTransparent(device)) return [];
|
|
488
|
+
|
|
489
|
+
const found: string[] = [];
|
|
490
|
+
const where = "in transparent mode, which a bridge needs";
|
|
491
|
+
|
|
492
|
+
if (device.connection.host) found.push(`connection.host, ${where}: the device is reached through its console`);
|
|
493
|
+
|
|
494
|
+
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
495
|
+
if (port?.addresses?.length) found.push(`ports.${name}.addresses, ${where}`);
|
|
496
|
+
}
|
|
497
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
498
|
+
if (iface.addresses?.length) found.push(`interfaces.${name}.addresses, ${where}`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const routing = device.routing;
|
|
502
|
+
if (routing?.static?.length || routing?.bgp || routing?.ospf) found.push(`routing, ${where}`);
|
|
503
|
+
if (device.firewall?.nat) found.push(`firewall.nat, ${where}`);
|
|
504
|
+
if (device.dhcp && Object.keys(device.dhcp).length) found.push(`dhcp, ${where}`);
|
|
505
|
+
|
|
506
|
+
const management = device.management ?? {};
|
|
507
|
+
for (const service of ["ssh", "telnet", "http", "https", "api", "api_tls", "snmp", "native"] as const) {
|
|
508
|
+
if (management[service]) found.push(`management.${service}, ${where}: the device has no address to answer on`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return found;
|
|
512
|
+
}
|
|
513
|
+
|
|
466
514
|
function systemGaps(device: Device): string[] {
|
|
467
515
|
const found: string[] = [];
|
|
468
516
|
const system = device.system ?? {};
|
|
@@ -6,6 +6,22 @@ import { spell } from "../catalog";
|
|
|
6
6
|
|
|
7
7
|
type Addressed = { addresses?: readonly (string | { address: string; peer?: string })[]; vrf?: string; lag?: string };
|
|
8
8
|
|
|
9
|
+
/** The bridges a device has, in the config's order. A device with any runs in transparent mode, each bridge a forward domain numbered by its place. */
|
|
10
|
+
export function bridgesOf(device: Device): { name: string; members: readonly string[] }[] {
|
|
11
|
+
return Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) =>
|
|
12
|
+
iface.type === "bridge" ? [{ name, members: iface.members }] : []
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Whether the device runs in transparent mode: it has a bridge. */
|
|
17
|
+
export const isTransparent = (device: Device) => bridgesOf(device).length > 0;
|
|
18
|
+
|
|
19
|
+
/** The forward domain of a VLAN interface in a bridge, or undefined. */
|
|
20
|
+
export function forwardDomainOf(device: Device, name: string): number | undefined {
|
|
21
|
+
const index = bridgesOf(device).findIndex((bridge) => bridge.members.includes(name));
|
|
22
|
+
return index < 0 ? undefined : index + 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
9
25
|
/** The name the platform gives a port or an interface: a port by its spelling, any other interface by its key. */
|
|
10
26
|
export function nameOf(device: Device, name: string): string {
|
|
11
27
|
if (device.interfaces && name in device.interfaces) return name;
|
|
@@ -805,7 +805,16 @@ export function plan(desired: string, current: string, options: Pick<ApplyOption
|
|
|
805
805
|
}
|
|
806
806
|
}
|
|
807
807
|
|
|
808
|
-
|
|
808
|
+
// Leaving transparent mode needs an address and an interface for it, which the config of a device without a bridge does not say.
|
|
809
|
+
const opmode = (root: Block) => root.blocks.get("system settings")?.settings.get("opmode");
|
|
810
|
+
const problems =
|
|
811
|
+
opmode(have) === "transparent" && opmode(want) !== "transparent"
|
|
812
|
+
? [
|
|
813
|
+
"the device runs in transparent mode and the config has no bridge; leave transparent mode by hand, `set opmode nat` with an address and an interface under `config system settings`, then plan again",
|
|
814
|
+
]
|
|
815
|
+
: [];
|
|
816
|
+
|
|
817
|
+
return { steps: [...first, ...steps, ...removals, ...late], problems };
|
|
809
818
|
}
|
|
810
819
|
|
|
811
820
|
/** The interfaces of tunnels, which their own tables create, compared after those tables as a section of their own. */
|
|
@@ -5,7 +5,18 @@ import { marker } from "../../../core/secrets";
|
|
|
5
5
|
import type { Device, Helper, Severity } from "../../../schema";
|
|
6
6
|
import { portsOf, spell } from "../catalog";
|
|
7
7
|
import { firewall, natRules } from "./firewall";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
addressed,
|
|
10
|
+
addressesOf,
|
|
11
|
+
forwardDomainOf,
|
|
12
|
+
holderOf,
|
|
13
|
+
interfaceFor,
|
|
14
|
+
isTransparent,
|
|
15
|
+
nameOf,
|
|
16
|
+
network,
|
|
17
|
+
subnet,
|
|
18
|
+
vrfNumber,
|
|
19
|
+
} from "./names";
|
|
9
20
|
import { routing, usesBfd } from "./routing";
|
|
10
21
|
import { Block, print, quote, quoteAll } from "./tree";
|
|
11
22
|
|
|
@@ -246,6 +257,9 @@ function allowAccess(device: Device): string {
|
|
|
246
257
|
return services.join(" ");
|
|
247
258
|
}
|
|
248
259
|
|
|
260
|
+
/** The management address of a device in transparent mode, which is reached only through its console: link-local, on no network it can reach. */
|
|
261
|
+
export const TRANSPARENT_MANAGEIP = "169.254.0.1/255.255.255.255";
|
|
262
|
+
|
|
249
263
|
export function render(device: Device): string {
|
|
250
264
|
const root = new Block();
|
|
251
265
|
for (const section of SECTIONS) root.block(section);
|
|
@@ -255,6 +269,8 @@ export function render(device: Device): string {
|
|
|
255
269
|
certificates(section("vpn certificate local"), section("vpn certificate ca"), device);
|
|
256
270
|
global(section("system global"), device);
|
|
257
271
|
if (device.system?.banner) section(BANNER).set("buffer", quote(device.system.banner));
|
|
272
|
+
// A bridge needs transparent mode, and transparent mode a management address. One on no network the device can reach keeps it reached only through its console.
|
|
273
|
+
if (isTransparent(device)) section("system settings").set("opmode", "transparent").set("manageip", TRANSPARENT_MANAGEIP);
|
|
258
274
|
if (natRules(device).length) section("system settings").set("central-nat", "enable");
|
|
259
275
|
// SIP is otherwise rewritten by its proxy whatever the helpers say; this way only the `sip` helper does.
|
|
260
276
|
section("system settings").set("default-voip-alg-mode", "kernel-helper-based");
|
|
@@ -391,6 +407,8 @@ function interfaces(root: Block, device: Device): void {
|
|
|
391
407
|
entry.set("vdom", quote("root"));
|
|
392
408
|
if (parent) entry.set("interface", quote(nameOf(device, parent)));
|
|
393
409
|
entry.set("vlanid", device.vlans?.[iface.vlan]?.id ?? 0);
|
|
410
|
+
const domain = forwardDomainOf(device, name);
|
|
411
|
+
if (domain) entry.set("forward-domain", domain);
|
|
394
412
|
if (iface.description) entry.set("description", quote(iface.description));
|
|
395
413
|
if (iface.mtu) entry.set("mtu-override", "enable").set("mtu", iface.mtu);
|
|
396
414
|
redirects(entry, device);
|
|
@@ -565,6 +565,11 @@ export async function apply(device: Device, _network: readonly Device[], plan: P
|
|
|
565
565
|
|
|
566
566
|
export function unsupported(device: Device): string[] {
|
|
567
567
|
const found: string[] = [];
|
|
568
|
+
|
|
569
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
570
|
+
if (iface.type === "bridge") found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
|
|
571
|
+
if (iface.type === "vlan" && iface.mac) found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
|
|
572
|
+
}
|
|
568
573
|
const names = new Names(device);
|
|
569
574
|
const users = device.users ?? {};
|
|
570
575
|
|
|
@@ -420,6 +420,11 @@ export async function read(device: Device, _network: readonly Device[] = [], opt
|
|
|
420
420
|
export function unsupported(device: Device): string[] {
|
|
421
421
|
const found: string[] = [];
|
|
422
422
|
|
|
423
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
424
|
+
if (iface.type === "bridge") found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
|
|
425
|
+
if (iface.type === "vlan" && iface.mac) found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
|
|
426
|
+
}
|
|
427
|
+
|
|
423
428
|
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
424
429
|
if (!port) continue;
|
|
425
430
|
|
|
@@ -42,3 +42,5 @@ Behaviour that looks like something else. Each one has been hit on a real device
|
|
|
42
42
|
- `:parse` checks the command, not the text inside `rule=`. A routing filter rule is only checked when it is added.
|
|
43
43
|
- `/routing/filter/filter-wizard` adds the rule it builds; it is not a dry run. `set blackhole yes` was proved this way on 7.23, in an unused chain removed right after.
|
|
44
44
|
- On the serial console, the prompt can come twice after a login. The console echoes a long command wrapped at 80 columns, and a `print` that fills the screen stops at `-- [Q quit|D dump|down]`, where `D` prints the rest.
|
|
45
|
+
- Every VLAN interface on the bridge has the bridge's MAC, and the bridge keeps a frame to that MAC on whatever VLAN it arrives. A gateway in front of a transparent firewall then never gets its replies: they come back on the inside VLAN and stop at the bridge. A MACVLAN on the VLAN interface gives the gateway a MAC of its own, and its replies cross the firewall back; proved with a FortiGate 3700D in transparent mode, the session offloaded to its NP6.
|
|
46
|
+
- A second VLAN interface with the same VLAN ID on the same interface is refused, `already have such vlan`, so a VLAN interface is known by its interface and VLAN ID, and a new name is a rename.
|
|
@@ -131,6 +131,7 @@ export const DEFAULTS: Record<string, Record<string, string>> = {
|
|
|
131
131
|
"limit-unknown-unicasts": "no",
|
|
132
132
|
},
|
|
133
133
|
"/interface vlan": { "l3-hw-offloading": "yes", mtu: "1500" },
|
|
134
|
+
"/interface macvlan": { mode: "bridge", mtu: "1500" },
|
|
134
135
|
"/interface vrrp": { priority: "100", "preemption-mode": "yes", interval: "1s", version: "3", "v3-protocol": "ipv4" },
|
|
135
136
|
"/interface vxlan": { port: "4789" },
|
|
136
137
|
"/interface wireguard peers": {
|
|
@@ -259,6 +260,8 @@ const IDENTITY: Record<string, string[]> = {
|
|
|
259
260
|
"/ipv6 firewall address-list": ["list", "address"],
|
|
260
261
|
"/interface bridge port": ["interface"],
|
|
261
262
|
"/interface bridge vlan": ["vlan-ids"],
|
|
263
|
+
// One VLAN ID per interface: the device refuses a second, so a rename, such as one a MAC of its own brings, is a rename.
|
|
264
|
+
"/interface vlan": ["interface", "vlan-id"],
|
|
262
265
|
"/interface vxlan vteps": ["interface", "remote-ip"],
|
|
263
266
|
"/routing bgp connection": ["remote.address"],
|
|
264
267
|
"/routing rpki": ["address"],
|
|
@@ -151,6 +151,10 @@ export async function read(device: Device, _network: readonly Device[] = [], opt
|
|
|
151
151
|
export function unsupported(device: Device): string[] {
|
|
152
152
|
const found: string[] = [];
|
|
153
153
|
|
|
154
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
155
|
+
if (iface.type === "bridge") found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
|
|
156
|
+
}
|
|
157
|
+
|
|
154
158
|
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
155
159
|
if (!port) continue;
|
|
156
160
|
|
|
@@ -344,10 +344,19 @@ function lags(out: Output, ctx: Context): void {
|
|
|
344
344
|
}
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
-
/**
|
|
347
|
+
/** The name of the VLAN interface under one with a MAC of its own, which takes the configured name itself. */
|
|
348
|
+
export const underMacvlan = (name: string) => `${name}-vlan`;
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* A VLAN interface sits on the bridge. When the device offloads, one that opts out is routed in software so the firewall applies.
|
|
352
|
+
*
|
|
353
|
+
* Every VLAN interface on the bridge has the bridge's MAC. One with a MAC of its own is a MACVLAN on top of the VLAN interface: the MACVLAN takes the configured name, so its addresses and every rule naming it land on it.
|
|
354
|
+
*/
|
|
348
355
|
function vlanInterfaces(out: Output, ctx: Context): void {
|
|
349
356
|
for (const [name, iface] of ctx.vlanInterfaces()) {
|
|
350
357
|
const softwareOnly = ctx.device.hardware_offload && iface.hardware_offload === false;
|
|
358
|
+
const vlanName = iface.mac ? underMacvlan(name) : name;
|
|
359
|
+
|
|
351
360
|
out.add(
|
|
352
361
|
"/interface vlan",
|
|
353
362
|
command("add", {
|
|
@@ -355,10 +364,23 @@ function vlanInterfaces(out: Output, ctx: Context): void {
|
|
|
355
364
|
interface: BRIDGE,
|
|
356
365
|
"l3-hw-offloading": softwareOnly ? "no" : undefined,
|
|
357
366
|
mtu: iface.mtu,
|
|
358
|
-
name,
|
|
367
|
+
name: vlanName,
|
|
359
368
|
"vlan-id": ctx.vlanId(iface.vlan),
|
|
360
369
|
})
|
|
361
370
|
);
|
|
371
|
+
|
|
372
|
+
if (iface.mac) {
|
|
373
|
+
out.add(
|
|
374
|
+
"/interface macvlan",
|
|
375
|
+
command("add", {
|
|
376
|
+
comment: iface.description,
|
|
377
|
+
interface: vlanName,
|
|
378
|
+
"mac-address": iface.mac.toUpperCase(),
|
|
379
|
+
mtu: iface.mtu,
|
|
380
|
+
name,
|
|
381
|
+
})
|
|
382
|
+
);
|
|
383
|
+
}
|
|
362
384
|
}
|
|
363
385
|
}
|
|
364
386
|
|
|
@@ -637,6 +637,11 @@ function configurationOf(device: Device, dump: string): string {
|
|
|
637
637
|
export function unsupported(device: Device): string[] {
|
|
638
638
|
const found: string[] = [];
|
|
639
639
|
|
|
640
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
641
|
+
if (iface.type === "bridge") found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
|
|
642
|
+
if (iface.type === "vlan" && iface.mac) found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
|
|
643
|
+
}
|
|
644
|
+
|
|
640
645
|
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
641
646
|
if (!port) continue;
|
|
642
647
|
|
package/src/core/validate.ts
CHANGED
|
@@ -175,6 +175,21 @@ function checkInterfaces(device: Device, names: Names, report: Report, ip: Check
|
|
|
175
175
|
if (!members.length) report(`LAG ${name} has no member ports`);
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
if (iface.type === "bridge") {
|
|
179
|
+
if (iface.members.length < 2) report(`bridge ${name} joins fewer than two interfaces`);
|
|
180
|
+
|
|
181
|
+
for (const member of iface.members) {
|
|
182
|
+
const joined = device.interfaces?.[member];
|
|
183
|
+
if (joined?.type !== "vlan") report(`bridge ${name}: member ${member} is not a VLAN interface`);
|
|
184
|
+
else if (joined.addresses?.length) report(`bridge ${name}: member ${member} has an address of its own`);
|
|
185
|
+
|
|
186
|
+
const others = Object.entries(device.interfaces ?? {}).filter(
|
|
187
|
+
([other, settings]) => other !== name && settings.type === "bridge" && settings.members.includes(member)
|
|
188
|
+
);
|
|
189
|
+
if (others.length) report(`bridge ${name}: member ${member} is also in bridge ${others[0]![0]}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
178
193
|
if (iface.type === "wireguard") {
|
|
179
194
|
for (const peer of iface.peers ?? []) {
|
|
180
195
|
for (const address of peer.allowed_addresses) {
|
package/src/presets/index.ts
CHANGED
|
@@ -186,6 +186,10 @@ export type TrustBoundary = {
|
|
|
186
186
|
untrusted: readonly string[];
|
|
187
187
|
/** The devices that filter: the only ones that may give an untrusted VLAN an address, and the only ones a port carrying both kinds may face. */
|
|
188
188
|
routers: readonly string[];
|
|
189
|
+
/**
|
|
190
|
+
* Links between two switches that may carry both kinds, each named by its two devices: a site's switch extended to another building, with an exchange's VLAN and its own management on one cable. Neither end gives an untrusted VLAN an address.
|
|
191
|
+
*/
|
|
192
|
+
extended?: readonly (readonly [string, string])[];
|
|
189
193
|
};
|
|
190
194
|
|
|
191
195
|
/** A check: traffic from outside reaches your own VLANs only through a router. A port or LAG carrying an untrusted VLAN beside any other is on a router or faces one, and only a router gives an untrusted VLAN an address. */
|
|
@@ -194,6 +198,7 @@ export const trustBoundary =
|
|
|
194
198
|
(devices) => {
|
|
195
199
|
const untrusted = new Set(boundary.untrusted);
|
|
196
200
|
const routers = new Set(boundary.routers);
|
|
201
|
+
const extended = (a: string, b: string) => (boundary.extended ?? []).some(([x, y]) => (x === a && y === b) || (x === b && y === a));
|
|
197
202
|
const findings: Finding[] = [];
|
|
198
203
|
|
|
199
204
|
for (const device of devices) {
|
|
@@ -225,8 +230,9 @@ export const trustBoundary =
|
|
|
225
230
|
for (const { what, carried, faces } of carriers) {
|
|
226
231
|
const mixes = carried.some((vlan) => untrusted.has(vlan)) && carried.some((vlan) => !untrusted.has(vlan));
|
|
227
232
|
const facesRouter = isRouter || faces.some((name) => routers.has(name));
|
|
233
|
+
const isExtension = faces.length > 0 && faces.every((name) => extended(device.name, name));
|
|
228
234
|
|
|
229
|
-
if (mixes && !facesRouter) {
|
|
235
|
+
if (mixes && !facesRouter && !isExtension) {
|
|
230
236
|
findings.push({
|
|
231
237
|
level: "error",
|
|
232
238
|
device: device.name,
|
package/src/schema/interfaces.ts
CHANGED
|
@@ -119,6 +119,10 @@ export type VlanInterface<V extends string, F extends string> = Base<F> & {
|
|
|
119
119
|
type: "vlan";
|
|
120
120
|
/** The VLAN it is on. */
|
|
121
121
|
vlan: V;
|
|
122
|
+
/**
|
|
123
|
+
* A MAC address of its own, instead of the one it shares with the device's other VLAN interfaces. A gateway in front of a transparent firewall needs one: frames to it come back on another VLAN, where the bridge would otherwise take a shared MAC for its own and keep them.
|
|
124
|
+
*/
|
|
125
|
+
mac?: string;
|
|
122
126
|
};
|
|
123
127
|
|
|
124
128
|
/** An interface that is always up and is on no link, for addresses that must not depend on one. */
|
|
@@ -187,6 +191,15 @@ export type WireGuard<F extends string> = Base<F> & {
|
|
|
187
191
|
peers?: readonly WireGuardPeer[];
|
|
188
192
|
};
|
|
189
193
|
|
|
194
|
+
/**
|
|
195
|
+
* A layer 2 bridge between VLAN interfaces, each keeping its own VLAN: a frame that comes in on one member leaves another with that member's tag. A firewall filters what crosses it, by the rules of its forward chain between the members, without routing it.
|
|
196
|
+
*/
|
|
197
|
+
export type Bridge<F extends string> = Base<F> & {
|
|
198
|
+
type: "bridge";
|
|
199
|
+
/** The VLAN interfaces it joins, by name. Each has no address of its own. */
|
|
200
|
+
members: readonly string[];
|
|
201
|
+
};
|
|
202
|
+
|
|
190
203
|
/** Any interface that is not a physical port. */
|
|
191
204
|
export type Interface<V extends string, F extends string> =
|
|
192
|
-
VlanInterface<V, F> | Loopback<F> | Lag<V, F> | Gre<F> | Vxlan<F> | WireGuard<F>;
|
|
205
|
+
VlanInterface<V, F> | Loopback<F> | Lag<V, F> | Gre<F> | Vxlan<F> | WireGuard<F> | Bridge<F>;
|