@takodotid/circuit 0.38.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/chunks/{index-yzw86rc8.js → index-k38bby43.js} +62 -4
  2. package/dist/chunks/{server-cxr9ynxc.js → server-ggertnce.js} +1 -1
  3. package/dist/cli/index.js +332 -36
  4. package/dist/helpers/index.js +71 -29
  5. package/dist/types/core/cabling.d.ts +6 -0
  6. package/dist/types/core/validator.d.ts +2 -0
  7. package/dist/types/helpers/cables.d.ts +16 -20
  8. package/dist/types/helpers/index.d.ts +1 -1
  9. package/dist/types/helpers/upstreams.d.ts +102 -19
  10. package/dist/types/platforms/derive.d.ts +1 -1
  11. package/dist/types/platforms/fortios/firewall.d.ts +2 -0
  12. package/dist/types/platforms/fortios/index.d.ts +7 -1
  13. package/dist/types/platforms/fortios/render.d.ts +2 -0
  14. package/dist/types/platforms/fortios/vdoms.d.ts +50 -0
  15. package/dist/types/platforms/routeros/render.d.ts +7 -1
  16. package/dist/types/schema/device.d.ts +21 -1
  17. package/dist/types/schema/firewall.d.ts +1 -1
  18. package/dist/types/schema/interfaces.d.ts +1 -1
  19. package/package.json +1 -1
  20. package/src/cli/index.ts +12 -4
  21. package/src/core/cabling.ts +30 -0
  22. package/src/core/validator.ts +25 -0
  23. package/src/helpers/cables.ts +21 -43
  24. package/src/helpers/index.ts +1 -1
  25. package/src/helpers/upstreams.ts +175 -39
  26. package/src/platforms/derive.ts +3 -1
  27. package/src/platforms/fortios/firewall.ts +9 -0
  28. package/src/platforms/fortios/index.ts +90 -28
  29. package/src/platforms/fortios/render.ts +27 -5
  30. package/src/platforms/fortios/vdoms.ts +217 -0
  31. package/src/platforms/routeros/TRAPS.md +1 -0
  32. package/src/platforms/routeros/render.ts +36 -3
  33. package/src/schema/device.ts +22 -1
  34. package/src/schema/firewall.ts +1 -1
  35. package/src/schema/interfaces.ts +1 -1
  36. package/dist/chunks/index-6wb41q67.js +0 -62
@@ -2,7 +2,7 @@ import type { IP, Secret } from "./common.js";
2
2
  import type { AclRule, Firewall } from "./firewall.js";
3
3
  import type { Interface, Port, Vlan } from "./interfaces.js";
4
4
  import type { PrefixSet, RoutePolicy } from "./policy.js";
5
- import type { Routing } from "./routing.js";
5
+ import type { Routing, StaticRoute } from "./routing.js";
6
6
  import type { Certificate, DhcpRelay, DhcpServer, FlowExport, Management, System, User } from "./system.js";
7
7
  /** How the framework reaches the device. */
8
8
  export type Connection<U extends string> = {
@@ -39,6 +39,24 @@ export type Console = {
39
39
  /** The console asks only for a password, with no name first. Circuit then types no name; `connection.user` still picks the password. */
40
40
  passwordOnly?: true;
41
41
  };
42
+ /**
43
+ * Virtual domains, on FortiOS: the device's own traffic kept apart from the traffic it routes. Everything is in `root` when absent.
44
+ *
45
+ * Multi-VDOM mode is turned on by hand, not by a plan: it moves the whole configuration into `root` and ends every session.
46
+ */
47
+ export type Vdoms<I extends string> = {
48
+ /**
49
+ * A VDOM for the device's own traffic: FortiGuard, NTP, DNS and logging leave from it, by its own routes, so they never follow the routed traffic's default route. Everything not listed here stays in `root`.
50
+ */
51
+ management: {
52
+ /** Its name, such as `mgmt`. Not `root`. */
53
+ name: string;
54
+ /** The ports and interfaces in it, by name. A LAG's member ports go with the LAG. */
55
+ interfaces: readonly I[];
56
+ /** Its static routes, such as its own default route. */
57
+ static?: readonly StaticRoute<I, never>[];
58
+ };
59
+ };
42
60
  /** A separate routing table, with the interfaces that route in it. */
43
61
  export type Vrf = {
44
62
  /** What the VRF is for. */
@@ -103,4 +121,6 @@ export type Device<Pl extends string = string, M extends string = string, N exte
103
121
  };
104
122
  /** Forward in hardware where the platform can. An interface may override it. */
105
123
  hardware_offload?: boolean;
124
+ /** Virtual domains, on FortiOS only: a management VDOM apart from the routed traffic. */
125
+ vdoms?: Vdoms<NoInfer<I | N>>;
106
126
  };
@@ -96,7 +96,7 @@ export type Firewall<I extends string, AS extends string> = {
96
96
  };
97
97
  /** Helpers that run. None when absent. */
98
98
  helpers?: readonly Helper[];
99
- /** Traffic to and from these prefixes passes without connection tracking. A flood toward them cannot fill the connection table, and rules see its packets in the state `untracked`. Everything is tracked when absent. */
99
+ /** Traffic to and from these prefixes passes without connection tracking. A flood toward them cannot fill the connection table, and rules see its packets in the state `untracked`. An address NAT uses inside one stays tracked, or its replies would never be translated back. Everything is tracked when absent. */
100
100
  untracked?: readonly Prefix[];
101
101
  };
102
102
  /** A stateless rule evaluated in hardware where traffic arrives, before routing. */
@@ -109,7 +109,7 @@ export type Port<V extends string, A extends string, L extends string, F extends
109
109
  acl?: A;
110
110
  /** The LAG this port is in. Every other setting goes on the LAG. */
111
111
  lag?: L;
112
- /** Another of our devices at the other end of the cable. */
112
+ /** Another of our devices at the other end of the cable. Without a `description`, the port is described from it: the far device and the far port as printed on it, such as `firewall-1 port6`. */
113
113
  link?: Link;
114
114
  };
115
115
  /** Settings every interface that is not a physical port shares. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.38.0",
3
+ "version": "0.40.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",
package/src/cli/index.ts CHANGED
@@ -103,12 +103,20 @@ class Cli {
103
103
  return new Cli(configPath, network, options);
104
104
  }
105
105
 
106
- /** The config, a TypeScript file. Bun imports it as it is; Node needs jiti, which also resolves imports written without an extension. */
106
+ /**
107
+ * The config, a TypeScript file. Bun imports it as it is; Node needs jiti, which also resolves imports written without an extension. A linked port without a description is given one from its far end.
108
+ */
107
109
  private static async load(path: string): Promise<Network> {
108
- if (process.versions.bun) return (await import(path)).default;
110
+ let network: Network;
111
+
112
+ if (process.versions.bun) {
113
+ network = (await import(path)).default;
114
+ } else {
115
+ const { createJiti } = await import("jiti");
116
+ network = await createJiti(import.meta.url).import<Network>(path, { default: true });
117
+ }
109
118
 
110
- const { createJiti } = await import("jiti");
111
- return createJiti(import.meta.url).import(path, { default: true });
119
+ return { ...network, devices: Cabling.describeLinks(network.devices) };
112
120
  }
113
121
 
114
122
  private static fail(message: string): never {
@@ -121,6 +121,36 @@ export class Cabling {
121
121
  return findings;
122
122
  }
123
123
 
124
+ /**
125
+ * Each linked port without a description gets one from its far end: the far device and the far port as printed on it, such as `firewall-1 port6`. The far device's own file has its platform and model, so the label is never written twice. A port whose far device is not in the network keeps none.
126
+ */
127
+ static describeLinks(devices: readonly Device[]): Device[] {
128
+ const byName = new Map(devices.map((device) => [device.name, device]));
129
+
130
+ return devices.map((device) => {
131
+ const ports = Object.entries(device.ports ?? {});
132
+ if (!ports.some(([, port]) => port?.link && port.description === undefined)) return device;
133
+
134
+ const described = ports.map(([name, port]) => {
135
+ const far = port?.link ? byName.get(port.link.device) : undefined;
136
+ if (!port?.link || port.description !== undefined || !far) return [name, port];
137
+
138
+ return [name, { ...port, description: `${far.name} ${Cabling.labelOf(far, port.link.port)}` }];
139
+ });
140
+
141
+ return { ...device, ports: Object.fromEntries(described) } as Device;
142
+ });
143
+ }
144
+
145
+ /** A port as printed on the device, or Circuit's name for it when the model has no spelling for it. */
146
+ private static labelOf(device: Device, port: string): string {
147
+ try {
148
+ return Catalog.spell(device.platform, device.model, port);
149
+ } catch {
150
+ return port;
151
+ }
152
+ }
153
+
124
154
  /** A system name is the device's name, alone or with a domain after it. */
125
155
  private static isDevice(system: string, name: string): boolean {
126
156
  return system === name || system.startsWith(`${name}.`);
@@ -93,6 +93,31 @@ export class Validator {
93
93
  Validator.checkFirewall(device, names, cidr);
94
94
  Validator.checkServices(device, names, report, cidr);
95
95
  Validator.checkCertificates(device, names, report);
96
+ Validator.checkVdoms(device, names, report, cidr, ip);
97
+ }
98
+
99
+ /** A management VDOM: FortiOS only, not `root`, with declared interfaces, and routes that leave through its own. */
100
+ private static checkVdoms(device: Device, names: Names, report: Report, cidr: Checker, ip: Checker): void {
101
+ const management = device.vdoms?.management;
102
+ if (!management) return;
103
+
104
+ if (device.platform !== "fortios") report("vdoms are a FortiOS setting");
105
+ if (management.name === "root") report("vdoms.management.name is root, the VDOM everything else is in; give it a name of its own");
106
+
107
+ for (const name of management.interfaces) {
108
+ names.expectInterface("vdoms.management interface", name);
109
+ const port = device.ports?.[name as keyof typeof device.ports];
110
+ if (port?.lag) report(`vdoms.management interface ${name} is in LAG ${port.lag}; list the LAG`);
111
+ }
112
+
113
+ for (const route of management.static ?? []) {
114
+ const where = `vdoms.management static route ${route.prefix}`;
115
+ cidr(where, route.prefix);
116
+ if (route.via) ip(where, route.via);
117
+ if (!route.via && !route.interface && !route.blackhole) report(`${where} has no next hop`);
118
+ if (route.interface && !management.interfaces.includes(route.interface))
119
+ report(`${where} leaves through ${route.interface}, which is not in the VDOM`);
120
+ }
96
121
  }
97
122
 
98
123
  /** What the model's hardware holds, from the catalog. A LAG with too many members is an error; too many switch rules is a warning, since the limit is only roughly known. */
@@ -1,28 +1,19 @@
1
- import { Catalog, type ModelName, type PlatformName, type PortName } from "../platforms/catalog";
2
-
3
- /** One end of a cable: the device, and its platform and model, which name its ports. */
4
- export type CableEnd<P extends PlatformName, M extends ModelName<P>> = {
5
- device: string;
6
- platform: P;
7
- model: M;
8
- };
9
-
10
- /** The far end of a port, for its description: the device, the port by Circuit's name, and by the name printed on the device. */
11
- export type FarEnd = { device: string; port: string; label: string };
1
+ /** The far end of a port, for a description of your own: the device and its port by Circuit's name. */
2
+ export type FarEnd = { device: string; port: string };
12
3
 
13
4
  /** What every cable of one entry shares. */
14
5
  type Shared = {
15
6
  /** What is between the two ends, such as a carrier's local loop. Both ends get it on their `link`. */
16
7
  through?: string;
17
- /** A port's description from its far end. The far device and the far port's label when absent, such as `firewall-1 port6`. */
8
+ /** A port's description from its far end. When absent, Circuit writes the far device and the far port as printed on it, such as `firewall-1 port6`, from the far device's own file. */
18
9
  describe?: (far: FarEnd) => string;
19
10
  };
20
11
 
21
- /** A port a cable builds: what it is, the LAG it is in, and the other end. */
12
+ /** A port a cable builds: the other end, the LAG it is in, and a description when `describe` gives one. */
22
13
  type CablePort<L extends string> = {
23
- description: string;
24
14
  link: { device: string; port: string; through?: string };
25
15
  lag?: L;
16
+ description?: string;
26
17
  };
27
18
 
28
19
  /** Ports for both ends of the cables between two of our devices, each cable written once. */
@@ -31,37 +22,26 @@ export class Cables {
31
22
  * One cable, both ends. Spread each end into its device's `ports`:
32
23
  *
33
24
  * ```ts
34
- * const uplink = Cables.link({ a: { device: "router-1", platform: "routeros", model: "CCR2216-1G-12XS-2XQ", port: "25g-1" }, b: { ... } });
25
+ * const uplink = Cables.link({ a: { device: "router-1", port: "25g-1" }, b: { device: "switch-1", port: "10g-48" } });
35
26
  * // router-1: ports: { ...uplink.a }
36
27
  * ```
37
28
  */
38
- static link<
39
- const PA extends PlatformName,
40
- const MA extends ModelName<PA>,
41
- const NA extends PortName<PA, MA>,
42
- const PB extends PlatformName,
43
- const MB extends ModelName<PB>,
44
- const NB extends PortName<PB, MB>,
45
- >(spec: Shared & { a: CableEnd<PA, MA> & { port: NA }; b: CableEnd<PB, MB> & { port: NB } }) {
29
+ static link<const NA extends string, const NB extends string>(
30
+ spec: Shared & { a: { device: string; port: NA }; b: { device: string; port: NB } }
31
+ ) {
46
32
  return {
47
- a: { [spec.a.port]: Cables.end(spec, spec.b, spec.b.port) } as Record<NA, CablePort<never>>,
48
- b: { [spec.b.port]: Cables.end(spec, spec.a, spec.a.port) } as Record<NB, CablePort<never>>,
33
+ a: { [spec.a.port]: Cables.end(spec, spec.b.device, spec.b.port) } as Record<NA, CablePort<never>>,
34
+ b: { [spec.b.port]: Cables.end(spec, spec.a.device, spec.a.port) } as Record<NB, CablePort<never>>,
49
35
  };
50
36
  }
51
37
 
52
38
  /**
53
39
  * The cables of a LAG, both ends, from one list read from end A: `{ "25g-1": "10g-2" }` is a cable from A's 25g-1 to B's 10g-2. Each port is a member of its end's LAG. Spread each end into its device's `ports`; the LAG itself stays on each device.
54
40
  */
55
- static lag<
56
- const PA extends PlatformName,
57
- const MA extends ModelName<PA>,
58
- const LA extends string,
59
- const PB extends PlatformName,
60
- const MB extends ModelName<PB>,
61
- const LB extends string,
62
- const C extends Partial<Record<PortName<PA, MA>, PortName<PB, MB>>>,
63
- >(spec: Shared & { a: CableEnd<PA, MA> & { lag: LA }; b: CableEnd<PB, MB> & { lag: LB }; cables: C }) {
64
- const pairs = Object.entries(spec.cables) as [string, string][];
41
+ static lag<const LA extends string, const LB extends string, const C extends Readonly<Record<string, string>>>(
42
+ spec: Shared & { a: { device: string; lag: LA }; b: { device: string; lag: LB }; cables: C }
43
+ ) {
44
+ const pairs = Object.entries(spec.cables);
65
45
 
66
46
  const taken = new Map<string, string>();
67
47
  for (const [near, far] of pairs) {
@@ -70,22 +50,20 @@ export class Cables {
70
50
  taken.set(far, near);
71
51
  }
72
52
 
73
- const a = Object.fromEntries(pairs.map(([near, far]) => [near, { ...Cables.end(spec, spec.b, far), lag: spec.a.lag }]));
74
- const b = Object.fromEntries(pairs.map(([near, far]) => [far, { ...Cables.end(spec, spec.a, near), lag: spec.b.lag }]));
53
+ const a = Object.fromEntries(pairs.map(([near, far]) => [near, { ...Cables.end(spec, spec.b.device, far), lag: spec.a.lag }]));
54
+ const b = Object.fromEntries(pairs.map(([near, far]) => [far, { ...Cables.end(spec, spec.a.device, near), lag: spec.b.lag }]));
75
55
 
76
56
  return {
77
57
  a: a as Record<keyof C & string, CablePort<LA>>,
78
- b: b as Record<NonNullable<C[keyof C]> & string, CablePort<LB>>,
58
+ b: b as Record<C[keyof C], CablePort<LB>>,
79
59
  };
80
60
  }
81
61
 
82
62
  /** A port facing `port` on the far device. */
83
- private static end(shared: Shared, far: { device: string; platform: string; model: string }, port: string): CablePort<never> {
84
- const farEnd = { device: far.device, port, label: Catalog.spell(far.platform, far.model, port) };
85
-
63
+ private static end(shared: Shared, device: string, port: string): CablePort<never> {
86
64
  return {
87
- description: shared.describe ? shared.describe(farEnd) : `${farEnd.device} ${farEnd.label}`,
88
- link: { device: far.device, port, ...(shared.through ? { through: shared.through } : {}) },
65
+ link: { device, port, ...(shared.through ? { through: shared.through } : {}) },
66
+ ...(shared.describe ? { description: shared.describe({ device, port }) } : {}),
89
67
  };
90
68
  }
91
69
  }
@@ -3,7 +3,7 @@
3
3
  export { Bgp } from "./bgp";
4
4
  export type { Announced, CommunityScheme, DefaultOnlyOptions, LearnOptions, SanityLengths } from "./bgp";
5
5
  export { Cables } from "./cables";
6
- export type { CableEnd, FarEnd } from "./cables";
6
+ export type { FarEnd } from "./cables";
7
7
  export { Checks } from "./checks";
8
8
  export type { TrustBoundary } from "./checks";
9
9
  export { Exchange } from "./exchange";