@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 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
- return { steps: [...first, ...steps, ...removals, ...late], problems: [] };
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, _network, plan, options) {
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) {
@@ -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
- if (mixes && !facesRouter) {
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, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void>;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "Declarative, vendor-neutral network configuration: describe each device in TypeScript, and Circuit makes the device match. Alpha.",
5
5
  "keywords": [
6
6
  "network",
@@ -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, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void> {
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
- return { steps: [...first, ...steps, ...removals, ...late], problems: [] };
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 { addressed, addressesOf, holderOf, interfaceFor, nameOf, network, subnet, vrfNumber } from "./names";
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
 
@@ -16,6 +16,7 @@ const ORDER = [
16
16
  "/interface ethernet",
17
17
  "/interface bonding",
18
18
  "/interface vlan",
19
+ "/interface macvlan",
19
20
  "/interface gre",
20
21
  "/interface vxlan",
21
22
  "/interface wireguard",
@@ -344,10 +344,19 @@ function lags(out: Output, ctx: Context): void {
344
344
  }
345
345
  }
346
346
 
347
- /** A VLAN interface sits on the bridge. When the device offloads, one that opts out is routed in software so the firewall applies. */
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
 
@@ -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) {
@@ -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,
@@ -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>;