@takodotid/circuit 0.28.1 → 0.30.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.
Files changed (35) hide show
  1. package/dist/chunks/{index-22sbskdx.js → index-47debt16.js} +5 -1
  2. package/dist/chunks/{server-1ftkqkx5.js → server-tjbb3rqc.js} +1 -1
  3. package/dist/cli/index.js +151 -20
  4. package/dist/types/adapters/devices/fortios/firewall.d.ts +2 -0
  5. package/dist/types/adapters/devices/fortios/index.d.ts +1 -1
  6. package/dist/types/adapters/devices/fortios/names.d.ts +9 -0
  7. package/dist/types/adapters/devices/fortios/render.d.ts +2 -0
  8. package/dist/types/adapters/devices/routeros/render.d.ts +2 -0
  9. package/dist/types/schema/device.d.ts +2 -2
  10. package/dist/types/schema/interfaces.d.ts +13 -1
  11. package/dist/types/transport/ssh.d.ts +3 -0
  12. package/package.json +1 -1
  13. package/src/adapters/devices/fortios/TRAPS.md +3 -0
  14. package/src/adapters/devices/fortios/firewall.ts +11 -1
  15. package/src/adapters/devices/fortios/index.ts +52 -4
  16. package/src/adapters/devices/fortios/names.ts +16 -0
  17. package/src/adapters/devices/fortios/plan.ts +10 -1
  18. package/src/adapters/devices/fortios/render.ts +19 -1
  19. package/src/adapters/devices/junos/index.ts +7 -2
  20. package/src/adapters/devices/junos/render.ts +1 -1
  21. package/src/adapters/devices/raisecom-ros/index.ts +7 -2
  22. package/src/adapters/devices/routeros/TRAPS.md +2 -0
  23. package/src/adapters/devices/routeros/export.ts +3 -0
  24. package/src/adapters/devices/routeros/index.ts +6 -2
  25. package/src/adapters/devices/routeros/plan.ts +1 -0
  26. package/src/adapters/devices/routeros/render.ts +24 -2
  27. package/src/adapters/devices/vrp/index.ts +8 -3
  28. package/src/adapters/registries/librenms.ts +4 -2
  29. package/src/adapters/registries/prometheus.ts +2 -2
  30. package/src/cli/index.ts +11 -6
  31. package/src/core/graph.ts +5 -1
  32. package/src/core/validate.ts +17 -0
  33. package/src/schema/device.ts +2 -2
  34. package/src/schema/interfaces.ts +14 -1
  35. package/src/transport/ssh.ts +7 -0
@@ -117,7 +117,11 @@ function describeDevice(device) {
117
117
  const vrf = settings.vrf ? `, VRF ${settings.vrf}` : "";
118
118
  return `${name}: ${list.join(", ")}${vrf}`;
119
119
  });
120
- return [`${device.platform} ${device.model}`, `reached at ${device.connection.host}`, ...addresses];
120
+ return [
121
+ `${device.platform} ${device.model}`,
122
+ device.connection.host ? `reached at ${device.connection.host}` : "reached through its console",
123
+ ...addresses
124
+ ];
121
125
  }
122
126
  function carriedBy(device, settings) {
123
127
  const vlan = (name, untagged) => {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  packageRoot
3
- } from "./index-22sbskdx.js";
3
+ } from "./index-47debt16.js";
4
4
 
5
5
  // src/studio/server.ts
6
6
  import { execFile as execFile2, spawn } from "node:child_process";
package/dist/cli/index.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  addressesOf,
12
12
  freeOfLength,
13
13
  studioData
14
- } from "../chunks/index-22sbskdx.js";
14
+ } from "../chunks/index-47debt16.js";
15
15
  import {
16
16
  familyOf2,
17
17
  split,
@@ -118,6 +118,11 @@ import { Client as Client2 } from "ssh2";
118
118
 
119
119
  // src/transport/ssh.ts
120
120
  import { Client } from "ssh2";
121
+ function managementHost(device) {
122
+ if (!device.connection.host)
123
+ throw new Error(`${device.name} has no management address; it is reached through its console`);
124
+ return device.connection.host;
125
+ }
121
126
  function connect(login) {
122
127
  return new Promise((resolve, reject) => {
123
128
  const client = new Client;
@@ -530,6 +535,14 @@ class ConsoleShell {
530
535
  }
531
536
 
532
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
+ }
533
546
  function nameOf(device, name) {
534
547
  if (device.interfaces && name in device.interfaces)
535
548
  return name;
@@ -710,6 +723,7 @@ function portList(ports) {
710
723
  const list = Array.isArray(ports) ? ports : [ports];
711
724
  return list.map((port) => typeof port === "number" ? String(port) : `${port.min}-${port.max}`);
712
725
  }
726
+ var ALL_IN_TRANSPARENT = "circuit-all";
713
727
  function serviceOf(match, family) {
714
728
  const protocol = match?.protocol;
715
729
  const destinations = portList(match?.dst_port);
@@ -770,6 +784,10 @@ class Objects {
770
784
  }
771
785
  service(match, family) {
772
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
+ }
773
791
  if (!block)
774
792
  return name;
775
793
  const entry = this.root.block("firewall service custom").entry(name);
@@ -1922,7 +1940,11 @@ function plan(desired, current, options) {
1922
1940
  removals.unshift({ title: `${section}, removed`, show: diff.removed, send: [`config ${header}`, ...diff.removals, "end"] });
1923
1941
  }
1924
1942
  }
1925
- 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 };
1926
1948
  }
1927
1949
  var TUNNELS = "system interface, tunnels";
1928
1950
  function separateTunnels(root) {
@@ -2194,6 +2216,7 @@ function allowAccess(device) {
2194
2216
  services.push("fgfm");
2195
2217
  return services.join(" ");
2196
2218
  }
2219
+ var TRANSPARENT_MANAGEIP = "169.254.0.1/255.255.255.255";
2197
2220
  function render(device) {
2198
2221
  const root = new Block;
2199
2222
  for (const section of SECTIONS)
@@ -2203,6 +2226,8 @@ function render(device) {
2203
2226
  global(section("system global"), device);
2204
2227
  if (device.system?.banner)
2205
2228
  section(BANNER).set("buffer", quote(device.system.banner));
2229
+ if (isTransparent(device))
2230
+ section("system settings").set("opmode", "transparent").set("manageip", TRANSPARENT_MANAGEIP);
2206
2231
  if (natRules(device).length)
2207
2232
  section("system settings").set("central-nat", "enable");
2208
2233
  section("system settings").set("default-voip-alg-mode", "kernel-helper-based");
@@ -2329,6 +2354,9 @@ function interfaces(root, device) {
2329
2354
  if (parent)
2330
2355
  entry.set("interface", quote(nameOf(device, parent)));
2331
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);
2332
2360
  if (iface.description)
2333
2361
  entry.set("description", quote(iface.description));
2334
2362
  if (iface.mtu)
@@ -2703,7 +2731,7 @@ async function login(device) {
2703
2731
  if (!user)
2704
2732
  throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
2705
2733
  return connect({
2706
- host: device.connection.host,
2734
+ host: managementHost(device),
2707
2735
  port: device.connection.port,
2708
2736
  user: device.connection.user,
2709
2737
  password: resolve(user.password)
@@ -2893,7 +2921,18 @@ async function settle(device, options) {
2893
2921
  }
2894
2922
  return "the device did not come back in time and still reverts every change; set `cfg-save automatic` under `config system global`";
2895
2923
  }
2896
- 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) {
2897
2936
  const seconds = options.rollback * 60;
2898
2937
  try {
2899
2938
  if (options.console) {
@@ -2964,6 +3003,11 @@ function unsupported(device) {
2964
3003
  found.push(...serviceGaps(device));
2965
3004
  if (device.stp)
2966
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
+ }
2967
3011
  if (Object.keys(device.vrfs ?? {}).length > VRFS)
2968
3012
  found.push(`vrfs, at most ${VRFS}`);
2969
3013
  for (const [name, vrf] of Object.entries(device.vrfs ?? {})) {
@@ -2972,6 +3016,35 @@ function unsupported(device) {
2972
3016
  }
2973
3017
  return found;
2974
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
+ }
2975
3048
  function systemGaps(device) {
2976
3049
  const found = [];
2977
3050
  const system = device.system ?? {};
@@ -3947,7 +4020,8 @@ function sflow(out, device, names) {
3947
4020
  const flow = device.flow_export;
3948
4021
  if (flow?.protocol !== "sflow")
3949
4022
  return;
3950
- out.set("protocols sflow agent-id", device.connection.host);
4023
+ if (device.connection.host)
4024
+ out.set("protocols sflow agent-id", device.connection.host);
3951
4025
  if (flow.sampling !== undefined)
3952
4026
  out.set("protocols sflow sample-rate ingress", flow.sampling);
3953
4027
  for (const collector of flow.collectors) {
@@ -4521,7 +4595,7 @@ async function login2(device) {
4521
4595
  if (!user)
4522
4596
  throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
4523
4597
  return connect({
4524
- host: device.connection.host,
4598
+ host: managementHost(device),
4525
4599
  port: device.connection.port,
4526
4600
  user: device.connection.user,
4527
4601
  password: resolve(user.password)
@@ -4739,6 +4813,12 @@ async function apply2(device, _network, plan, options) {
4739
4813
  }
4740
4814
  function unsupported2(device) {
4741
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
+ }
4742
4822
  const names = new Names(device);
4743
4823
  const users = device.users ?? {};
4744
4824
  const root = users.root;
@@ -5420,7 +5500,7 @@ async function sshShell(device) {
5420
5500
  if (!user)
5421
5501
  throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
5422
5502
  const client = await connect({
5423
- host: device.connection.host,
5503
+ host: managementHost(device),
5424
5504
  port: device.connection.port,
5425
5505
  user: device.connection.user,
5426
5506
  password: resolve(user.password),
@@ -5489,6 +5569,12 @@ async function read3(device, _network = [], options = {}) {
5489
5569
  }
5490
5570
  function unsupported3(device) {
5491
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
+ }
5492
5578
  for (const [name, port] of Object.entries(device.ports ?? {})) {
5493
5579
  if (!port)
5494
5580
  continue;
@@ -5813,6 +5899,7 @@ var DEFAULTS2 = {
5813
5899
  "limit-unknown-unicasts": "no"
5814
5900
  },
5815
5901
  "/interface vlan": { "l3-hw-offloading": "yes", mtu: "1500" },
5902
+ "/interface macvlan": { mode: "bridge", mtu: "1500" },
5816
5903
  "/interface vrrp": { priority: "100", "preemption-mode": "yes", interval: "1s", version: "3", "v3-protocol": "ipv4" },
5817
5904
  "/interface vxlan": { port: "4789" },
5818
5905
  "/interface wireguard peers": {
@@ -5913,6 +6000,7 @@ var IDENTITY = {
5913
6000
  "/ipv6 firewall address-list": ["list", "address"],
5914
6001
  "/interface bridge port": ["interface"],
5915
6002
  "/interface bridge vlan": ["vlan-ids"],
6003
+ "/interface vlan": ["interface", "vlan-id"],
5916
6004
  "/interface vxlan vteps": ["interface", "remote-ip"],
5917
6005
  "/routing bgp connection": ["remote.address"],
5918
6006
  "/routing rpki": ["address"],
@@ -5968,6 +6056,7 @@ var ORDER = [
5968
6056
  "/interface ethernet",
5969
6057
  "/interface bonding",
5970
6058
  "/interface vlan",
6059
+ "/interface macvlan",
5971
6060
  "/interface gre",
5972
6061
  "/interface vxlan",
5973
6062
  "/interface wireguard",
@@ -6486,17 +6575,28 @@ function lags2(out, ctx) {
6486
6575
  }));
6487
6576
  }
6488
6577
  }
6578
+ var underMacvlan = (name) => `${name}-vlan`;
6489
6579
  function vlanInterfaces(out, ctx) {
6490
6580
  for (const [name, iface] of ctx.vlanInterfaces()) {
6491
6581
  const softwareOnly = ctx.device.hardware_offload && iface.hardware_offload === false;
6582
+ const vlanName = iface.mac ? underMacvlan(name) : name;
6492
6583
  out.add("/interface vlan", command("add", {
6493
6584
  comment: iface.description,
6494
6585
  interface: BRIDGE,
6495
6586
  "l3-hw-offloading": softwareOnly ? "no" : undefined,
6496
6587
  mtu: iface.mtu,
6497
- name,
6588
+ name: vlanName,
6498
6589
  "vlan-id": ctx.vlanId(iface.vlan)
6499
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
+ }
6500
6600
  }
6501
6601
  }
6502
6602
  function tunnels(out, ctx) {
@@ -7346,7 +7446,7 @@ async function login3(device) {
7346
7446
  if (!user)
7347
7447
  throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
7348
7448
  return connect({
7349
- host: device.connection.host,
7449
+ host: managementHost(device),
7350
7450
  port: device.connection.port,
7351
7451
  user: device.connection.user,
7352
7452
  password: resolve(user.password)
@@ -7442,6 +7542,10 @@ ${certificates}` : "";
7442
7542
  }
7443
7543
  function unsupported4(device) {
7444
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
+ }
7445
7549
  for (const [name, port] of Object.entries(device.ports ?? {})) {
7446
7550
  if (!port)
7447
7551
  continue;
@@ -7985,7 +8089,8 @@ function flowExport3(config, device) {
7985
8089
  const flow = device.flow_export;
7986
8090
  if (flow?.protocol !== "sflow")
7987
8091
  return;
7988
- config.line(`sflow agent ip ${device.connection.host}`);
8092
+ if (device.connection.host)
8093
+ config.line(`sflow agent ip ${device.connection.host}`);
7989
8094
  flow.collectors.forEach((collector, index) => {
7990
8095
  const number = FIRST_COLLECTOR + index;
7991
8096
  config.line(`sflow collector ${number} ip ${collector.address} udp-port ${collector.port ?? 6343}`);
@@ -8051,7 +8156,7 @@ async function login4(device) {
8051
8156
  if (!user)
8052
8157
  throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
8053
8158
  return connect({
8054
- host: device.connection.host,
8159
+ host: managementHost(device),
8055
8160
  port: device.connection.port,
8056
8161
  user: device.connection.user,
8057
8162
  password: resolve(user.password),
@@ -8122,6 +8227,12 @@ function configurationOf(device, dump) {
8122
8227
  }
8123
8228
  function unsupported5(device) {
8124
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
+ }
8125
8236
  for (const [name, port] of Object.entries(device.ports ?? {})) {
8126
8237
  if (!port)
8127
8238
  continue;
@@ -8357,8 +8468,9 @@ async function planLibreNms(network) {
8357
8468
  if (!response.ok)
8358
8469
  throw new Error(`LibreNMS answered ${response.status} to the device list`);
8359
8470
  const monitored = new Set((await response.json()).devices.map((device) => device.hostname));
8360
- const ours = new Set(network.devices.map((device) => device.connection.host));
8361
- const missing = network.devices.filter((device) => !monitored.has(device.connection.host));
8471
+ const polled = network.devices.filter((device) => device.connection.host);
8472
+ const ours = new Set(polled.map((device) => device.connection.host));
8473
+ const missing = polled.filter((device) => !monitored.has(device.connection.host));
8362
8474
  return {
8363
8475
  additions: missing.filter((device) => device.management?.snmp),
8364
8476
  withoutSnmp: missing.filter((device) => !device.management?.snmp),
@@ -8481,7 +8593,7 @@ var AUTH2 = { sha1: "SHA", sha256: "SHA256" };
8481
8593
  var PRIVACY2 = { aes128: "AES", des: "DES" };
8482
8594
  function prometheusTargets(network) {
8483
8595
  return {
8484
- targets: network.devices.filter((device) => device.management?.snmp).map((device) => ({
8596
+ targets: network.devices.filter((device) => device.management?.snmp && device.connection.host).map((device) => ({
8485
8597
  targets: [device.connection.host],
8486
8598
  labels: { device: device.name, platform: device.platform, model: device.model, __param_auth: device.name }
8487
8599
  })),
@@ -8838,6 +8950,8 @@ function checkDevice(device, report) {
8838
8950
  };
8839
8951
  if (!device.users?.[device.connection.user])
8840
8952
  report(`connection user '${device.connection.user}' is not one of its users`);
8953
+ if (!device.connection.host && !device.connection.console)
8954
+ report("connection has neither a host nor a console to reach the device through");
8841
8955
  checkVlans(device, report);
8842
8956
  checkAddresses2(device, report, cidr, ip);
8843
8957
  checkPorts(device, names, report);
@@ -8925,6 +9039,20 @@ function checkInterfaces(device, names, report, ip) {
8925
9039
  if (!members.length)
8926
9040
  report(`LAG ${name} has no member ports`);
8927
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
+ }
8928
9056
  if (iface.type === "wireguard") {
8929
9057
  for (const peer of iface.peers ?? []) {
8930
9058
  for (const address of peer.allowed_addresses) {
@@ -9343,9 +9471,12 @@ function one() {
9343
9471
  const [device] = selected(false);
9344
9472
  return device ?? fail("name a device");
9345
9473
  }
9474
+ function optionsFor(device) {
9475
+ return options.console || !device.connection.host ? { ...options, console: true } : options;
9476
+ }
9346
9477
  function adapterOf(device) {
9347
9478
  const adapter = adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
9348
- if (!options.console)
9479
+ if (!optionsFor(device).console)
9349
9480
  return adapter;
9350
9481
  if (!adapter.console)
9351
9482
  fail(`${device.name}: ${device.platform} cannot be reached through a console yet`);
@@ -9419,7 +9550,7 @@ function diffCommand() {
9419
9550
  }
9420
9551
  async function snapshotCommand() {
9421
9552
  for (const device of selected(true)) {
9422
- const text = await adapterOf(device).read(device, network3.devices, options);
9553
+ const text = await adapterOf(device).read(device, network3.devices, optionsFor(device));
9423
9554
  writeState(device, text);
9424
9555
  console.log(`${device.name}: ${text.split(`
9425
9556
  `).length} lines`);
@@ -9438,7 +9569,7 @@ async function applyCommand() {
9438
9569
  const device = one();
9439
9570
  guard([device]);
9440
9571
  const adapter = adapterOf(device);
9441
- const live = await adapter.read(device, network3.devices, options);
9572
+ const live = await adapter.read(device, network3.devices, optionsFor(device));
9442
9573
  writeState(device, live);
9443
9574
  const plan = adapter.plan(adapter.render(device), live, options);
9444
9575
  if (!show(device, plan))
@@ -9451,8 +9582,8 @@ Not sent: resolve the problems first.`);
9451
9582
  Nothing sent. Re-run with --confirm.`);
9452
9583
  return;
9453
9584
  }
9454
- await adapter.apply(device, network3.devices, plan, options);
9455
- writeState(device, await adapter.read(device, network3.devices, options));
9585
+ await adapter.apply(device, network3.devices, plan, optionsFor(device));
9586
+ writeState(device, await adapter.read(device, network3.devices, optionsFor(device)));
9456
9587
  console.log("snapshot updated");
9457
9588
  }
9458
9589
  async function refreshCommand() {
@@ -9741,7 +9872,7 @@ async function studioCommand() {
9741
9872
  const port = Number(flagValue("port") ?? 4600);
9742
9873
  if (!Number.isInteger(port) || port < 1 || port > 65535)
9743
9874
  fail("--port takes a number from 1 to 65535");
9744
- const { isLoopback, studio } = await import("../chunks/server-1ftkqkx5.js");
9875
+ const { isLoopback, studio } = await import("../chunks/server-tjbb3rqc.js");
9745
9876
  const host = flagValue("host") ?? "127.0.0.1";
9746
9877
  const allowHosts = (flagValue("allow-host") ?? "").split(",").filter(Boolean);
9747
9878
  if (!isLoopback(host) && !allowHosts.length) {
@@ -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
  /**
@@ -6,8 +6,8 @@ import type { Routing } from "./routing";
6
6
  import type { Certificate, DhcpRelay, DhcpServer, FlowExport, Management, System, User } from "./system";
7
7
  /** How the framework reaches the device. */
8
8
  export type Connection<U extends string> = {
9
- /** Management address. */
10
- host: IP;
9
+ /** Management address. Absent for a device reached only through its console, `console`. */
10
+ host?: IP;
11
11
  /** One of the device's `users`. Its password is the login. */
12
12
  user: U;
13
13
  /** SSH port. 22 when absent. */
@@ -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 {};
@@ -1,4 +1,5 @@
1
1
  import { Client, type ClientChannel } from "ssh2";
2
+ import type { Device } from "../schema";
2
3
  export type Login = {
3
4
  host: string;
4
5
  port?: number;
@@ -7,6 +8,8 @@ export type Login = {
7
8
  /** Host key algorithms to accept, for a device that negotiates one it then signs wrongly. */
8
9
  hostKeys?: string[];
9
10
  };
11
+ /** The address SSH reaches a device at. A device without one is reached only through its console. */
12
+ export declare function managementHost(device: Device): string;
10
13
  /** Open a session. */
11
14
  export declare function connect(login: Login): Promise<Client>;
12
15
  /** Run one command and return what it printed, stderr included. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.28.1",
3
+ "version": "0.30.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);
@@ -7,11 +7,11 @@ import { familyOf, host, split } from "../../../core/addr";
7
7
  import { MARKER, resolve, valueOfReference } from "../../../core/secrets";
8
8
  import type { AclRule, Device, Family, FilterRule } from "../../../schema";
9
9
  import { type ConsoleDialect, consoleLogin, consoleOf, openConsole } from "../../../transport/console";
10
- import { connect, shell } from "../../../transport/ssh";
10
+ 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";
@@ -141,7 +141,7 @@ export async function login(device: Device): Promise<Client> {
141
141
  if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
142
142
 
143
143
  return connect({
144
- host: device.connection.host,
144
+ host: managementHost(device),
145
145
  port: device.connection.port,
146
146
  user: device.connection.user,
147
147
  password: resolve(user.password),
@@ -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);
@@ -7,7 +7,7 @@ import { familyOf, host, isAddress, split } from "../../../core/addr";
7
7
  import { resolve, substitute } from "../../../core/secrets";
8
8
  import type { Device, Family } from "../../../schema";
9
9
  import { type ConsoleDialect, consoleLogin, consoleOf, openConsole } from "../../../transport/console";
10
- import { connect, exec, readUntil, shell } from "../../../transport/ssh";
10
+ import { connect, exec, managementHost, readUntil, shell } from "../../../transport/ssh";
11
11
  import { lagIds } from "../derive";
12
12
  import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions, Step } from "../types";
13
13
  import { GENERATED, settingsOf } from "./bgp";
@@ -296,7 +296,7 @@ export async function login(device: Device): Promise<Client> {
296
296
  if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
297
297
 
298
298
  return connect({
299
- host: device.connection.host,
299
+ host: managementHost(device),
300
300
  port: device.connection.port,
301
301
  user: device.connection.user,
302
302
  password: resolve(user.password),
@@ -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
 
@@ -675,7 +675,7 @@ function sflow(out: Statements, device: Device, names: Names): void {
675
675
  const flow = device.flow_export;
676
676
  if (flow?.protocol !== "sflow") return;
677
677
 
678
- out.set("protocols sflow agent-id", device.connection.host);
678
+ if (device.connection.host) out.set("protocols sflow agent-id", device.connection.host);
679
679
  if (flow.sampling !== undefined) out.set("protocols sflow sample-rate ingress", flow.sampling);
680
680
 
681
681
  for (const collector of flow.collectors) {
@@ -8,7 +8,7 @@ import { marker, resolve, substitute } from "../../../core/secrets";
8
8
  import { offset } from "../../../core/time";
9
9
  import type { Device, Port, SnmpUser, Switched, Vrrp } from "../../../schema";
10
10
  import { type ConsoleDialect, consoleLogin, consoleOf, openConsole } from "../../../transport/console";
11
- import { connect, readUntil, send, shell } from "../../../transport/ssh";
11
+ import { connect, managementHost, readUntil, send, shell } from "../../../transport/ssh";
12
12
  import { portsOf, spell } from "../catalog";
13
13
  import { ipv4Networks, lagIds, switchUnsupported } from "../derive";
14
14
  import { compare, type Dialect, entries, type Group, scrub, steps, vlanListChange } from "../lines";
@@ -338,7 +338,7 @@ async function sshShell(device: Device): Promise<Session> {
338
338
  if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
339
339
 
340
340
  const client = await connect({
341
- host: device.connection.host,
341
+ host: managementHost(device),
342
342
  port: device.connection.port,
343
343
  user: device.connection.user,
344
344
  password: resolve(user.password),
@@ -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"],
@@ -7,7 +7,7 @@ import { familyOf } from "../../../core/addr";
7
7
  import { resolve, substitute } from "../../../core/secrets";
8
8
  import type { Device, Family } from "../../../schema";
9
9
  import { type ConsoleDialect, consoleLogin, consoleOf, ConsoleShell, openConsole } from "../../../transport/console";
10
- import { connect, exec, upload } from "../../../transport/ssh";
10
+ import { connect, exec, managementHost, upload } from "../../../transport/ssh";
11
11
  import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
12
12
  import { INSTALL_CERTIFICATE, plan } from "./plan";
13
13
  import { spell } from "../catalog";
@@ -21,7 +21,7 @@ export async function login(device: Device): Promise<Client> {
21
21
  if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
22
22
 
23
23
  return connect({
24
- host: device.connection.host,
24
+ host: managementHost(device),
25
25
  port: device.connection.port,
26
26
  user: device.connection.user,
27
27
  password: resolve(user.password),
@@ -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
 
@@ -8,7 +8,7 @@ import { marker, resolve, substitute, valueOfReference } from "../../../core/sec
8
8
  import { offset } from "../../../core/time";
9
9
  import type { Device, Interface, Port, Vrrp } from "../../../schema";
10
10
  import { type ConsoleDialect, consoleLogin, consoleOf, openConsole } from "../../../transport/console";
11
- import { connect, readUntil, send, shell } from "../../../transport/ssh";
11
+ import { connect, managementHost, readUntil, send, shell } from "../../../transport/ssh";
12
12
  import { portsOf, spell } from "../catalog";
13
13
  import { ipv4Networks, lagIds, switchUnsupported } from "../derive";
14
14
  import { compare, type Dialect, scrub, steps, vlanListChange } from "../lines";
@@ -465,7 +465,7 @@ function flowExport(config: Config, device: Device): void {
465
465
  const flow = device.flow_export;
466
466
  if (flow?.protocol !== "sflow") return;
467
467
 
468
- config.line(`sflow agent ip ${device.connection.host}`);
468
+ if (device.connection.host) config.line(`sflow agent ip ${device.connection.host}`);
469
469
 
470
470
  flow.collectors.forEach((collector, index) => {
471
471
  const number = FIRST_COLLECTOR + index;
@@ -550,7 +550,7 @@ export async function login(device: Device): Promise<Client> {
550
550
  const user = device.users?.[device.connection.user];
551
551
  if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
552
552
  return connect({
553
- host: device.connection.host,
553
+ host: managementHost(device),
554
554
  port: device.connection.port,
555
555
  user: device.connection.user,
556
556
  password: resolve(user.password),
@@ -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
 
@@ -57,8 +57,10 @@ export async function planLibreNms(network: Network): Promise<LibreNmsPlan> {
57
57
  if (!response.ok) throw new Error(`LibreNMS answered ${response.status} to the device list`);
58
58
 
59
59
  const monitored = new Set(((await response.json()) as { devices: { hostname: string }[] }).devices.map((device) => device.hostname));
60
- const ours = new Set(network.devices.map((device) => device.connection.host));
61
- const missing = network.devices.filter((device) => !monitored.has(device.connection.host));
60
+ // A device reached only through its console has no address to poll.
61
+ const polled = network.devices.filter((device) => device.connection.host);
62
+ const ours = new Set(polled.map((device) => device.connection.host));
63
+ const missing = polled.filter((device) => !monitored.has(device.connection.host!));
62
64
 
63
65
  return {
64
66
  additions: missing.filter((device) => device.management?.snmp),
@@ -15,9 +15,9 @@ const PRIVACY = { aes128: "AES", des: "DES" } as const;
15
15
  export function prometheusTargets(network: Network): { targets: PrometheusTarget[]; withoutSnmp: Device[] } {
16
16
  return {
17
17
  targets: network.devices
18
- .filter((device) => device.management?.snmp)
18
+ .filter((device) => device.management?.snmp && device.connection.host)
19
19
  .map((device) => ({
20
- targets: [device.connection.host],
20
+ targets: [device.connection.host!],
21
21
  // `__param_auth` picks the device's own auth in snmp_exporter.
22
22
  labels: { device: device.name, platform: device.platform, model: device.model, __param_auth: device.name },
23
23
  })),
package/src/cli/index.ts CHANGED
@@ -133,10 +133,15 @@ function one(): Device {
133
133
  return device ?? fail("name a device");
134
134
  }
135
135
 
136
- /** The adapter of a device, which must be able to use its console when `--console` asks for it. */
136
+ /** How a device is reached: through its console when `--console` asks, or when it has no management address. */
137
+ function optionsFor(device: Device): ApplyOptions {
138
+ return options.console || !device.connection.host ? { ...options, console: true } : options;
139
+ }
140
+
141
+ /** The adapter of a device, which must be able to use its console when the device is reached through it. */
137
142
  function adapterOf(device: Device): DeviceAdapter {
138
143
  const adapter = adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
139
- if (!options.console) return adapter;
144
+ if (!optionsFor(device).console) return adapter;
140
145
 
141
146
  if (!adapter.console) fail(`${device.name}: ${device.platform} cannot be reached through a console yet`);
142
147
  if (!device.connection.console) fail(`${device.name}: --console needs connection.console`);
@@ -225,7 +230,7 @@ function diffCommand(): void {
225
230
 
226
231
  async function snapshotCommand(): Promise<void> {
227
232
  for (const device of selected(true)) {
228
- const text = await adapterOf(device).read(device, network.devices, options);
233
+ const text = await adapterOf(device).read(device, network.devices, optionsFor(device));
229
234
  writeState(device, text);
230
235
  console.log(`${device.name}: ${text.split("\n").length} lines`);
231
236
  }
@@ -248,7 +253,7 @@ async function applyCommand(): Promise<void> {
248
253
  const adapter = adapterOf(device);
249
254
 
250
255
  // The plan is made against what the device runs now, never against an old snapshot.
251
- const live = await adapter.read(device, network.devices, options);
256
+ const live = await adapter.read(device, network.devices, optionsFor(device));
252
257
  writeState(device, live);
253
258
 
254
259
  const plan = adapter.plan(adapter.render(device), live, options);
@@ -261,9 +266,9 @@ async function applyCommand(): Promise<void> {
261
266
  return;
262
267
  }
263
268
 
264
- await adapter.apply(device, network.devices, plan, options);
269
+ await adapter.apply(device, network.devices, plan, optionsFor(device));
265
270
 
266
- writeState(device, await adapter.read(device, network.devices, options));
271
+ writeState(device, await adapter.read(device, network.devices, optionsFor(device)));
267
272
  console.log("snapshot updated");
268
273
  }
269
274
 
package/src/core/graph.ts CHANGED
@@ -143,7 +143,11 @@ function describeDevice(device: Device): string[] {
143
143
  return `${name}: ${list.join(", ")}${vrf}`;
144
144
  });
145
145
 
146
- return [`${device.platform} ${device.model}`, `reached at ${device.connection.host}`, ...addresses];
146
+ return [
147
+ `${device.platform} ${device.model}`,
148
+ device.connection.host ? `reached at ${device.connection.host}` : "reached through its console",
149
+ ...addresses,
150
+ ];
147
151
  }
148
152
 
149
153
  /** The VLANs a port or LAG carries, by id. */
@@ -79,6 +79,8 @@ function checkDevice(device: Device, report: Report): void {
79
79
  };
80
80
 
81
81
  if (!device.users?.[device.connection.user]) report(`connection user '${device.connection.user}' is not one of its users`);
82
+ if (!device.connection.host && !device.connection.console)
83
+ report("connection has neither a host nor a console to reach the device through");
82
84
 
83
85
  checkVlans(device, report);
84
86
  checkAddresses(device, report, cidr, ip);
@@ -173,6 +175,21 @@ function checkInterfaces(device: Device, names: Names, report: Report, ip: Check
173
175
  if (!members.length) report(`LAG ${name} has no member ports`);
174
176
  }
175
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
+
176
193
  if (iface.type === "wireguard") {
177
194
  for (const peer of iface.peers ?? []) {
178
195
  for (const address of peer.allowed_addresses) {
@@ -7,8 +7,8 @@ import type { Certificate, DhcpRelay, DhcpServer, FlowExport, Management, System
7
7
 
8
8
  /** How the framework reaches the device. */
9
9
  export type Connection<U extends string> = {
10
- /** Management address. */
11
- host: IP;
10
+ /** Management address. Absent for a device reached only through its console, `console`. */
11
+ host?: IP;
12
12
  /** One of the device's `users`. Its password is the login. */
13
13
  user: U;
14
14
  /** SSH port. 22 when absent. */
@@ -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>;
@@ -1,6 +1,7 @@
1
1
  // SSH sessions to devices. Adapters choose how to talk to their platform; this holds what they share.
2
2
 
3
3
  import { Client, type ClientChannel } from "ssh2";
4
+ import type { Device } from "../schema";
4
5
 
5
6
  export type Login = {
6
7
  host: string;
@@ -11,6 +12,12 @@ export type Login = {
11
12
  hostKeys?: string[];
12
13
  };
13
14
 
15
+ /** The address SSH reaches a device at. A device without one is reached only through its console. */
16
+ export function managementHost(device: Device): string {
17
+ if (!device.connection.host) throw new Error(`${device.name} has no management address; it is reached through its console`);
18
+ return device.connection.host;
19
+ }
20
+
14
21
  /** Open a session. */
15
22
  export function connect(login: Login): Promise<Client> {
16
23
  return new Promise((resolve, reject) => {