@takodotid/circuit 0.32.0 → 0.33.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
@@ -545,6 +545,20 @@ function bridgesOf(device) {
545
545
  return Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) => iface.type === "bridge" ? [{ name, members: iface.members }] : []);
546
546
  }
547
547
  var isTransparent = (device) => bridgesOf(device).length > 0;
548
+ function transparentManagement(device) {
549
+ if (!isTransparent(device))
550
+ return;
551
+ const members = new Set(bridgesOf(device).flatMap((bridge) => bridge.members));
552
+ const holders = [...Object.entries(device.ports ?? {}), ...Object.entries(device.interfaces ?? {})];
553
+ for (const [name, settings] of holders) {
554
+ if (members.has(name))
555
+ continue;
556
+ const address = addressesOf2(settings).find((each) => familyOf2(host2(each)) === "ipv4");
557
+ if (address)
558
+ return { name, address };
559
+ }
560
+ return;
561
+ }
548
562
  function forwardDomainOf(device, name) {
549
563
  const index = bridgesOf(device).findIndex((bridge) => bridge.members.includes(name));
550
564
  return index < 0 ? undefined : index + 1;
@@ -2232,8 +2246,11 @@ function render(device) {
2232
2246
  global(section("system global"), device);
2233
2247
  if (device.system?.banner)
2234
2248
  section(BANNER).set("buffer", quote(device.system.banner));
2235
- if (isTransparent(device))
2236
- section("system settings").set("opmode", "transparent").set("manageip", TRANSPARENT_MANAGEIP);
2249
+ if (isTransparent(device)) {
2250
+ const management = transparentManagement(device);
2251
+ const manageip = management ? `${host2(management.address)}/${netmask(split(management.address).len)}` : TRANSPARENT_MANAGEIP;
2252
+ section("system settings").set("opmode", "transparent").set("manageip", manageip);
2253
+ }
2237
2254
  if (natRules(device).length)
2238
2255
  section("system settings").set("central-nat", "enable");
2239
2256
  section("system settings").set("default-voip-alg-mode", "kernel-helper-based");
@@ -2428,6 +2445,11 @@ function vlanParent(device, vlan) {
2428
2445
  return carriers.length === 1 ? carriers[0][0] : undefined;
2429
2446
  }
2430
2447
  function routed(entry, device, name, settings, access) {
2448
+ if (isTransparent(device)) {
2449
+ if (transparentManagement(device)?.name === name)
2450
+ entry.set("allowaccess", access);
2451
+ return;
2452
+ }
2431
2453
  const addresses = addressesOf2(settings);
2432
2454
  const ipv4 = addresses.filter((address) => familyOf2(host2(address)) === "ipv4");
2433
2455
  const ipv6 = addresses.filter((address) => familyOf2(host2(address)) === "ipv6");
@@ -2634,7 +2656,7 @@ function staticRoutes(ipv4, ipv6, device) {
2634
2656
  if (route.via)
2635
2657
  entry.set("gateway", route.via);
2636
2658
  const outgoing = route.interface ?? (route.via ? interfaceFor(device, route.via, route.vrf) : undefined);
2637
- if (outgoing && !route.blackhole)
2659
+ if (outgoing && !route.blackhole && !isTransparent(device))
2638
2660
  entry.set("device", quote(nameOf(device, outgoing)));
2639
2661
  if (route.distance !== undefined && route.distance !== 10)
2640
2662
  entry.set("distance", route.distance);
@@ -3027,27 +3049,38 @@ function transparentGaps(device) {
3027
3049
  return [];
3028
3050
  const found = [];
3029
3051
  const where = "in transparent mode, which a bridge needs";
3030
- if (device.connection.host)
3031
- found.push(`connection.host, ${where}: the device is reached through its console`);
3032
- for (const [name, port] of Object.entries(device.ports ?? {})) {
3033
- if (port?.addresses?.length)
3034
- found.push(`ports.${name}.addresses, ${where}`);
3035
- }
3036
- for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
3037
- if (iface.addresses?.length)
3038
- found.push(`interfaces.${name}.addresses, ${where}`);
3052
+ const management = transparentManagement(device);
3053
+ if (device.connection.host && device.connection.host !== (management && host2(management.address)))
3054
+ found.push(`connection.host, ${where}: only the management address, one IPv4 address outside the bridges`);
3055
+ const holders = [
3056
+ ...Object.entries(device.ports ?? {}).map(([name, port]) => [`ports.${name}`, name, port?.addresses]),
3057
+ ...Object.entries(device.interfaces ?? {}).map(([name, iface]) => [`interfaces.${name}`, name, iface.addresses])
3058
+ ];
3059
+ for (const [path, name, addresses] of holders) {
3060
+ const extra = (addresses ?? []).filter((entry) => !(typeof entry === "string" && management && name === management.name && entry === management.address));
3061
+ if (extra.length)
3062
+ found.push(`${path}.addresses, ${where}: one IPv4 address in all, outside the bridges, for management`);
3039
3063
  }
3040
3064
  const routing = device.routing;
3041
- if (routing?.static?.length || routing?.bgp || routing?.ospf)
3042
- found.push(`routing, ${where}`);
3065
+ if (routing?.bgp || routing?.ospf)
3066
+ found.push(`routing, ${where}: static routes only`);
3067
+ for (const [index, route] of (routing?.static ?? []).entries()) {
3068
+ const at = `routing.static[${index}]`;
3069
+ if (!management)
3070
+ found.push(`${at}, ${where}: a route needs the management address`);
3071
+ if (!route.via || familyOf2(route.via) !== "ipv4" || route.interface || route.blackhole || route.vrf)
3072
+ found.push(`${at}, ${where}: only an IPv4 next hop, for the management address`);
3073
+ }
3043
3074
  if (device.firewall?.nat)
3044
3075
  found.push(`firewall.nat, ${where}`);
3045
3076
  if (device.dhcp && Object.keys(device.dhcp).length)
3046
3077
  found.push(`dhcp, ${where}`);
3047
- const management = device.management ?? {};
3048
- for (const service of ["ssh", "telnet", "http", "https", "api", "api_tls", "snmp", "native"]) {
3049
- if (management[service])
3050
- found.push(`management.${service}, ${where}: the device has no address to answer on`);
3078
+ if (!management) {
3079
+ const services = device.management ?? {};
3080
+ for (const service of ["ssh", "telnet", "http", "https", "api", "api_tls", "snmp", "native"]) {
3081
+ if (services[service])
3082
+ found.push(`management.${service}, ${where}: the device has no management address to answer on`);
3083
+ }
3051
3084
  }
3052
3085
  return found;
3053
3086
  }
@@ -14,6 +14,13 @@ export declare function bridgesOf(device: Device): {
14
14
  }[];
15
15
  /** Whether the device runs in transparent mode: it has a bridge. */
16
16
  export declare const isTransparent: (device: Device) => boolean;
17
+ /**
18
+ * The management address of a device in transparent mode: the one IPv4 address on a port or an interface outside every bridge. The device answers on it, the platform's `manageip`, through that interface alone. Undefined when there is none, and the device is reached only through its console.
19
+ */
20
+ export declare function transparentManagement(device: Device): {
21
+ name: string;
22
+ address: string;
23
+ } | undefined;
17
24
  /** The forward domain of a VLAN interface in a bridge, or undefined. */
18
25
  export declare function forwardDomainOf(device: Device, name: string): number | undefined;
19
26
  /** The name the platform gives a port or an interface: a port by its spelling, any other interface by its key. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.32.0",
3
+ "version": "0.33.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",
@@ -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, isTransparent, network, settingsOf, subnet } from "./names";
14
+ import { holderOf, interfaceFor, isTransparent, network, settingsOf, subnet, transparentManagement } 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";
@@ -482,30 +482,49 @@ export function unsupported(device: Device): string[] {
482
482
  return found;
483
483
  }
484
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. */
485
+ /**
486
+ * What transparent mode, which a bridge needs, leaves out: every address but one IPv4 management address outside the bridges, routing beyond static routes for it, NAT and DHCP. Without a management address the device is reached through its console and answers no service.
487
+ */
486
488
  function transparentGaps(device: Device): string[] {
487
489
  if (!isTransparent(device)) return [];
488
490
 
489
491
  const found: string[] = [];
490
492
  const where = "in transparent mode, which a bridge needs";
493
+ const management = transparentManagement(device);
491
494
 
492
- if (device.connection.host) found.push(`connection.host, ${where}: the device is reached through its console`);
495
+ if (device.connection.host && device.connection.host !== (management && host(management.address)))
496
+ found.push(`connection.host, ${where}: only the management address, one IPv4 address outside the bridges`);
493
497
 
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}`);
498
+ const holders = [
499
+ ...Object.entries(device.ports ?? {}).map(([name, port]) => [`ports.${name}`, name, port?.addresses] as const),
500
+ ...Object.entries(device.interfaces ?? {}).map(([name, iface]) => [`interfaces.${name}`, name, iface.addresses] as const),
501
+ ];
502
+
503
+ for (const [path, name, addresses] of holders) {
504
+ const extra = (addresses ?? []).filter(
505
+ (entry) => !(typeof entry === "string" && management && name === management.name && entry === management.address)
506
+ );
507
+ if (extra.length) found.push(`${path}.addresses, ${where}: one IPv4 address in all, outside the bridges, for management`);
499
508
  }
500
509
 
501
510
  const routing = device.routing;
502
- if (routing?.static?.length || routing?.bgp || routing?.ospf) found.push(`routing, ${where}`);
511
+ if (routing?.bgp || routing?.ospf) found.push(`routing, ${where}: static routes only`);
512
+
513
+ for (const [index, route] of (routing?.static ?? []).entries()) {
514
+ const at = `routing.static[${index}]`;
515
+ if (!management) found.push(`${at}, ${where}: a route needs the management address`);
516
+ if (!route.via || familyOf(route.via) !== "ipv4" || route.interface || route.blackhole || route.vrf)
517
+ found.push(`${at}, ${where}: only an IPv4 next hop, for the management address`);
518
+ }
519
+
503
520
  if (device.firewall?.nat) found.push(`firewall.nat, ${where}`);
504
521
  if (device.dhcp && Object.keys(device.dhcp).length) found.push(`dhcp, ${where}`);
505
522
 
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`);
523
+ if (!management) {
524
+ const services = device.management ?? {};
525
+ for (const service of ["ssh", "telnet", "http", "https", "api", "api_tls", "snmp", "native"] as const) {
526
+ if (services[service]) found.push(`management.${service}, ${where}: the device has no management address to answer on`);
527
+ }
509
528
  }
510
529
 
511
530
  return found;
@@ -16,6 +16,27 @@ export function bridgesOf(device: Device): { name: string; members: readonly str
16
16
  /** Whether the device runs in transparent mode: it has a bridge. */
17
17
  export const isTransparent = (device: Device) => bridgesOf(device).length > 0;
18
18
 
19
+ /**
20
+ * The management address of a device in transparent mode: the one IPv4 address on a port or an interface outside every bridge. The device answers on it, the platform's `manageip`, through that interface alone. Undefined when there is none, and the device is reached only through its console.
21
+ */
22
+ export function transparentManagement(device: Device): { name: string; address: string } | undefined {
23
+ if (!isTransparent(device)) return undefined;
24
+
25
+ const members = new Set(bridgesOf(device).flatMap((bridge) => bridge.members));
26
+ const holders = [...Object.entries(device.ports ?? {}), ...Object.entries(device.interfaces ?? {})] as [
27
+ string,
28
+ Addressed | undefined,
29
+ ][];
30
+
31
+ for (const [name, settings] of holders) {
32
+ if (members.has(name)) continue;
33
+ const address = addressesOf(settings).find((each) => familyOf(host(each)) === "ipv4");
34
+ if (address) return { name, address };
35
+ }
36
+
37
+ return undefined;
38
+ }
39
+
19
40
  /** The forward domain of a VLAN interface in a bridge, or undefined. */
20
41
  export function forwardDomainOf(device: Device, name: string): number | undefined {
21
42
  const index = bridgesOf(device).findIndex((bridge) => bridge.members.includes(name));
@@ -15,6 +15,7 @@ import {
15
15
  nameOf,
16
16
  network,
17
17
  subnet,
18
+ transparentManagement,
18
19
  vrfNumber,
19
20
  } from "./names";
20
21
  import { routing, usesBfd } from "./routing";
@@ -269,8 +270,12 @@ export function render(device: Device): string {
269
270
  certificates(section("vpn certificate local"), section("vpn certificate ca"), device);
270
271
  global(section("system global"), device);
271
272
  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);
273
+ // A bridge needs transparent mode, and transparent mode a management address: the config's, or one on no network the device can reach, which keeps it reached only through its console.
274
+ if (isTransparent(device)) {
275
+ const management = transparentManagement(device);
276
+ const manageip = management ? `${host(management.address)}/${netmask(split(management.address).len)}` : TRANSPARENT_MANAGEIP;
277
+ section("system settings").set("opmode", "transparent").set("manageip", manageip);
278
+ }
274
279
  if (natRules(device).length) section("system settings").set("central-nat", "enable");
275
280
  // SIP is otherwise rewritten by its proxy whatever the helpers say; this way only the `sip` helper does.
276
281
  section("system settings").set("default-voip-alg-mode", "kernel-helper-based");
@@ -484,6 +489,12 @@ type RoutedSettings = {
484
489
 
485
490
  /** Addresses and their routing table, the services answered on them, VRRP, DHCP relay, router advertisements, LLDP and flow sampling of a port or an interface. */
486
491
  function routed(entry: Block, device: Device, name: string, settings: RoutedSettings, access: string): void {
492
+ // In transparent mode no interface holds an address: the management address is the device's, and answers on its interface alone.
493
+ if (isTransparent(device)) {
494
+ if (transparentManagement(device)?.name === name) entry.set("allowaccess", access);
495
+ return;
496
+ }
497
+
487
498
  const addresses = addressesOf(settings);
488
499
  const ipv4 = addresses.filter((address) => familyOf(host(address)) === "ipv4");
489
500
  const ipv6 = addresses.filter((address) => familyOf(host(address)) === "ipv6");
@@ -732,8 +743,9 @@ function staticRoutes(ipv4: Block, ipv6: Block, device: Device): void {
732
743
 
733
744
  if (route.via) entry.set("gateway", route.via);
734
745
 
746
+ // In transparent mode a route names no interface: it carries only the management address's own traffic.
735
747
  const outgoing = route.interface ?? (route.via ? interfaceFor(device, route.via, route.vrf) : undefined);
736
- if (outgoing && !route.blackhole) entry.set("device", quote(nameOf(device, outgoing)));
748
+ if (outgoing && !route.blackhole && !isTransparent(device)) entry.set("device", quote(nameOf(device, outgoing)));
737
749
 
738
750
  if (route.distance !== undefined && route.distance !== 10) entry.set("distance", route.distance);
739
751
  if (route.description) entry.set("comment", quote(route.description));