@takodotid/circuit 0.23.0 → 0.25.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.
@@ -332,6 +332,7 @@ function internetExchange(exchange, members) {
332
332
  const findings = [];
333
333
  const error = (message, device) => findings.push({ level: "error", ...device ? { device } : {}, message });
334
334
  const taken = new Map((exchange.reserved ?? []).map((address) => [address, "reserved"]));
335
+ const portHolders = new Map;
335
336
  members.forEach((member, index) => {
336
337
  for (const other of members.slice(index + 1)) {
337
338
  if (other.name === member.name)
@@ -353,6 +354,10 @@ function internetExchange(exchange, members) {
353
354
  taken.set(address, member.name);
354
355
  }
355
356
  for (const [deviceName, portName] of Object.entries(member.ports)) {
357
+ const holder = portHolders.get(`${deviceName} ${portName}`);
358
+ if (holder)
359
+ error(`port ${portName} is given to both ${holder} and ${member.name}`, deviceName);
360
+ portHolders.set(`${deviceName} ${portName}`, member.name);
356
361
  const device = devices.find((candidate) => candidate.name === deviceName);
357
362
  const port = device?.ports?.[portName];
358
363
  const carried = port ? vlansOf(port) : [];
@@ -39,6 +39,20 @@ export declare const catalog: {
39
39
  };
40
40
  };
41
41
  };
42
+ readonly junos: {
43
+ readonly "QFX5100-48S-6Q": {
44
+ readonly ports: {
45
+ readonly "40g": 6;
46
+ readonly "10g": 48;
47
+ readonly "1g": 2;
48
+ };
49
+ readonly spelling: {
50
+ readonly "40g": "et-0/0/{n+47}";
51
+ readonly "10g": "xe-0/0/{n-1}";
52
+ readonly "1g": "em{n-1}";
53
+ };
54
+ };
55
+ };
42
56
  };
43
57
  export type Catalog = typeof catalog;
44
58
  export type PlatformName = keyof Catalog;
@@ -0,0 +1,11 @@
1
+ import type { Device, Neighbor, NeighborSettings } from "../../../schema";
2
+ import { type Statements } from "./render";
3
+ type AnyNeighbor = Neighbor<string, string>;
4
+ /** The policy a session uses when it states none, so that it learns or announces nothing. */
5
+ export declare const REJECT_ALL = "circuit-reject-all";
6
+ /** A comment in the render naming a path whose statements `refresh` fills, so the plan keeps what the device holds there. */
7
+ export declare const GENERATED = "# generated ";
8
+ /** A neighbor's settings with its group's beneath them. */
9
+ export declare function settingsOf(device: Device, neighbor: AnyNeighbor): NeighborSettings<string>;
10
+ export declare function renderBgp(out: Statements, device: Device): void;
11
+ export {};
@@ -0,0 +1,39 @@
1
+ import type { Client, ClientChannel } from "ssh2";
2
+ import type { Device, Family } from "../../../schema";
3
+ import type { ApplyOptions, DeviceAdapter, Plan } from "../types";
4
+ /** A statement's words; a quoted value is one word with its quotes. */
5
+ export declare function wordsOf(statement: string): string[];
6
+ /** The device prints a password as a hash, an SNMP key as a reversible cipher, and a community as itself. None leave the read. */
7
+ export declare function scrub(text: string): string;
8
+ /** The configuration's statements from what `show configuration | display set` printed. */
9
+ export declare function clean(output: string): string;
10
+ /**
11
+ * What changes, by section.
12
+ *
13
+ * A statement the render holds and the device does not is set. One the device holds and the render does not is deleted at the shortest path that holds nothing the render wants, so a removed port goes as a whole and a changed value goes alone. A hidden value ends a path by its name alone; a hidden name in a list, such as a community, stays `<redacted>` and the session names each entry when it sends the plan. A statement the device holds deactivated is activated when the render wants it.
14
+ *
15
+ * A policy, a filter or an import or export chain is ordered, so when anything in one changes it is deleted and set again whole, in the render's order. A path the render marks as generated, such as a prefix list `refresh` fills, keeps what the device holds.
16
+ */
17
+ export declare function plan(desired: string, current: string, options: Pick<ApplyOptions, "secrets">): Plan;
18
+ export declare function login(device: Device): Promise<Client>;
19
+ export declare function read(device: Device): Promise<string>;
20
+ /** An interactive CLI with paging off. Root lands in a shell and starts the CLI from there. */
21
+ export declare function openCli(channel: ClientChannel): Promise<void>;
22
+ /** How a commit that could cut access is guarded: it rolls back after `minutes` unless `prove` shows access survived. */
23
+ export type Guard = {
24
+ minutes: number;
25
+ prove: () => Promise<void>;
26
+ };
27
+ /**
28
+ * Load the plan into an exclusive candidate and check it. With a guard, commit it to roll back after `minutes`, prove access, then confirm; without one, commit it outright.
29
+ *
30
+ * Deletions go before the statements that are set, so a path deleted and set again in one plan ends up set.
31
+ */
32
+ export declare function configure(channel: ClientChannel, plan: Plan, guard?: Guard): Promise<void>;
33
+ export declare function apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void>;
34
+ export declare function unsupported(device: Device): string[];
35
+ /** The plan that replaces a prefix list's entries with what a registry returned. */
36
+ export declare function fillPlan(set: string, prefixes: string[]): Plan;
37
+ /** Replace a prefix list `refresh` fetched. It cannot cut access, so it is committed without a rollback. */
38
+ export declare function fill(device: Device, _network: readonly Device[], set: string, _family: Family, prefixes: string[]): Promise<void>;
39
+ export declare const junos: DeviceAdapter;
@@ -0,0 +1,14 @@
1
+ export declare const models: {
2
+ readonly "QFX5100-48S-6Q": {
3
+ readonly ports: {
4
+ readonly "40g": 6;
5
+ readonly "10g": 48;
6
+ readonly "1g": 2;
7
+ };
8
+ readonly spelling: {
9
+ readonly "40g": "et-0/0/{n+47}";
10
+ readonly "10g": "xe-0/0/{n-1}";
11
+ readonly "1g": "em{n-1}";
12
+ };
13
+ };
14
+ };
@@ -0,0 +1,52 @@
1
+ import type { Device, Interface, Port, Vrrp } from "../../../schema";
2
+ type AnyPort = Port<string, string, string, string>;
3
+ type AnyInterface = Interface<string, string>;
4
+ type LagInterface = Extract<AnyInterface, {
5
+ type: "lag";
6
+ }>;
7
+ type Switched = AnyPort | LagInterface;
8
+ /** The statement each kind of public key is written under, by the key's own prefix. */
9
+ export declare const KEY_STATEMENT: Record<string, string>;
10
+ /** Detection under a second: three missed packets 300 ms apart. */
11
+ export declare const BFD_INTERVAL = 300;
12
+ /** Quote a value unless it is a plain word. Quoting more than the platform would is harmless: the comparison ignores quotes. */
13
+ export declare function quote(value: string): string;
14
+ /** Collects `set` statements, and comments the plan reads. */
15
+ export declare class Statements {
16
+ private readonly lines;
17
+ private readonly notes;
18
+ set(...words: (string | number)[]): void;
19
+ note(comment: string): void;
20
+ /** The statements as the device prints them: each once, and none that another one extends, because the platform prints only the longest. */
21
+ text(): string;
22
+ }
23
+ /** What the platform calls a port, a LAG, a VLAN interface or a loopback. */
24
+ export declare class Names {
25
+ private readonly device;
26
+ readonly lags: Map<string, number>;
27
+ readonly loopbacks: Map<string, number>;
28
+ readonly tunnels: Map<string, number>;
29
+ constructor(device: Device);
30
+ /** A physical port. A 10G cage holding a 1G optic is `ge-`, not `xe-`. */
31
+ port(port: string): string;
32
+ /** The physical interface a port or a LAG is: `xe-0/0/1`, `ae1`. */
33
+ physical(name: string): string;
34
+ /** The logical interface that holds addresses: `xe-0/0/1.0`, `ae1.0`, `irb.10`, `lo0.0`. */
35
+ unit(name: string): string;
36
+ /** The physical ports behind a port or a LAG. */
37
+ members(name: string): string[];
38
+ }
39
+ /** The address a VRRP group sits on: the one whose network holds the virtual address. */
40
+ export declare function vrrpAddress(addresses: readonly string[], group: Vrrp): string | undefined;
41
+ /**
42
+ * The storm-control profile a port or LAG names. A profile holds one threshold for every kind it limits, so a port's kinds must share one in percent; `unsupported()` refuses any other.
43
+ */
44
+ export declare function stormProfile(settings: Switched): {
45
+ name: string;
46
+ percent: number;
47
+ left: string[];
48
+ } | undefined;
49
+ export declare function render(device: Device): string;
50
+ /** The size a port's frames may reach for an IP MTU: the Ethernet header, and a VLAN tag when the port switches. */
51
+ export declare const frameSize: (mtu: number, switched: boolean) => number;
52
+ export {};
@@ -60,6 +60,8 @@ export type Holder = {
60
60
  /** The far end of a point-to-point address. */
61
61
  peer?: string;
62
62
  vrrp: NonNullable<Routed<string>["vrrp"]>;
63
+ /** The VRF the address is in. The default table when absent. */
64
+ vrf?: string;
63
65
  };
64
66
  export declare const deviceId: (name: string) => string;
65
67
  /** An address as a key, so two spellings of one address compare equal. Undefined when it is not an address. */
@@ -171,7 +171,7 @@ export type InternetExchange<V extends string> = {
171
171
  * Member ports and a check for an internet exchange's fabric.
172
172
  *
173
173
  * - `ports(device)`: every member's port on one switch. A member port is in the peering VLAN and nothing else, takes no part in spanning tree or LLDP, and lets only a trickle of broadcast, multicast and unknown unicast through.
174
- * - `check`: members never share a name, an AS or an address; every address is inside the peering LAN and not reserved; every member port carries the peering VLAN alone.
174
+ * - `check`: members never share a name, an AS, an address or a port; every address is inside the peering LAN and not reserved; every member port carries the peering VLAN alone.
175
175
  */
176
176
  export declare function internetExchange<const V extends string>(exchange: InternetExchange<V>, members: readonly ExchangeMember[]): {
177
177
  ports: (device: string) => Record<string, Port<V, string, string, string>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.23.0",
3
+ "version": "0.25.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",
@@ -13,7 +13,9 @@
13
13
  "mikrotik",
14
14
  "vrp",
15
15
  "huawei",
16
- "raisecom"
16
+ "raisecom",
17
+ "junos",
18
+ "juniper"
17
19
  ],
18
20
  "homepage": "https://circuit.tako.id",
19
21
  "bugs": {
@@ -1,10 +1,11 @@
1
1
  // Every model each platform supports. Types read from here, so a model or port name that does not exist fails to compile.
2
2
 
3
+ import { models as junos } from "./junos/models";
3
4
  import { models as raisecom } from "./raisecom-ros/models";
4
5
  import { models as routeros } from "./routeros/models";
5
6
  import { models as vrp } from "./vrp/models";
6
7
 
7
- export const catalog = { routeros, vrp, "raisecom-ros": raisecom } as const;
8
+ export const catalog = { routeros, vrp, "raisecom-ros": raisecom, junos } as const;
8
9
 
9
10
  export type Catalog = typeof catalog;
10
11
  export type PlatformName = keyof Catalog;
@@ -34,5 +35,6 @@ export function spell(platform: string, model: string, port: string): string {
34
35
  const template = parts && spec?.spelling[parts[1]!];
35
36
  if (!template) throw new Error(`${model} has no port ${port}`);
36
37
 
37
- return template.replace("{n}", parts[2]!);
38
+ // A platform that counts from 0, or numbers one class after another, shifts the position: `{n-1}`, `{n+47}`.
39
+ return template.replace(/\{n([+-]\d+)?\}/, (_, shift?: string) => String(Number(parts[2]) + Number(shift ?? 0)));
38
40
  }
@@ -1,7 +1,8 @@
1
+ import { junos } from "./junos";
1
2
  import { raisecom } from "./raisecom-ros";
2
3
  import { routeros } from "./routeros";
3
4
  import type { DeviceAdapter } from "./types";
4
5
  import { vrp } from "./vrp";
5
6
 
6
7
  /** Every platform the framework can render and apply, by name. */
7
- export const adapters: Record<string, DeviceAdapter> = { routeros, vrp, "raisecom-ros": raisecom };
8
+ export const adapters: Record<string, DeviceAdapter> = { routeros, vrp, "raisecom-ros": raisecom, junos };
@@ -0,0 +1,31 @@
1
+ # Junos traps
2
+
3
+ Behaviour that looks like something else. Each one has been hit on a QFX5100-48S-6Q running Junos 21.4R3.
4
+
5
+ - Changes stage in a candidate and apply at `commit`. `commit confirmed N` applies them and rolls them back after N minutes unless another `commit` confirms them. `commit` also saves: there is no separate save.
6
+ - The device counts ports from 0, and the 40G ports follow the 10G ones: `10g-1` is `xe-0/0/0`, `10g-48` is `xe-0/0/47`, `40g-1` is `et-0/0/48`. The two management ports, `em0` and `em1`, are `1g-1` and `1g-2`.
7
+ - `load set terminal` reports a refused statement as `terminal:12:(8) syntax error: ...`, then says `load complete` and keeps every statement it accepted. Circuit discards the whole candidate on any refusal.
8
+ - A value is cut at `(`, `&` or `|` unless it is quoted: `description a|b` is stored as `a`, with an error. The render quotes every value that is not a plain word.
9
+ - The device decides for itself which values it prints in quotes: `noc@example.com` comes back quoted, `"Router"` comes back plain. The plan compares values without their quotes.
10
+ - The device prints only the longest form of a statement. `set protocols rstp interface xe-0/0/0` disappears once `... xe-0/0/0 edge` is set, so the render leaves it out too.
11
+ - A delete cannot stop at a list whose entries are named: `delete interfaces xe-0/0/1 unit` is refused with `syntax error, expecting <identifier>`, while `unit 0` works. `show` refuses the same way. A list of plain values, such as `vlan members` or `ciphers`, can go whole.
12
+ - `delete` with a value removes the statement only when the value matches; otherwise it says `warning: statement not found` and changes nothing.
13
+ - A `?` shows help and is not executed, but the Enter after it sends the line typed so far, which may be a complete command.
14
+ - `operator`, `daemon`, `bind`, `kmem`, `nobody`, `sshd`, `tty` and `uucp` are system accounts. A user with one of those names fails the commit check: `user name is used by a system account`.
15
+ - Root logs in to a shell, `root@:RE:0%`, and reaches the CLI with `cli`. Every other user lands in the CLI.
16
+ - A password set with `plain-text-password-value` prints as a `$6$` hash. SNMPv3 passwords print as `$9$` keys, which can be turned back into the password. The read redacts both.
17
+ - An SNMP community prints in clear, and its value is also its name, so a new value is a second community. A new value is sent after the old communities are deleted, each by its name, which Circuit looks up during the apply and never prints.
18
+ - A firewall filter name is unique across families. The filter guarding the device's services is `circuit-services` for IPv4 and `circuit-services-v6` for IPv6.
19
+ - A VLAN interface's MTU above what its ports carry commits with only a warning, `mtu 9000 is bigger than member link`.
20
+ - There is no `protocols stp`. Plain STP is RSTP held back with `force-version stp`.
21
+ - sFlow interfaces print with their unit, `xe-0/0/0.0`.
22
+ - The factory VLAN `default` can be deleted.
23
+ - BGP without a licence commits with only a warning, `requires 'bgp' license`, and sessions establish. The same holds for the other licensed features.
24
+ - Junos accepts and announces every route over eBGP unless a policy says otherwise: `Default eBGP mode: advertise - accept, receive - accept`. A session without a policy is given `circuit-reject-all`.
25
+ - An export policy sees every active route, not only BGP's. Every export starts with `circuit-bgp-origin`, which passes on BGP's routes and the prefixes in `routing.bgp.networks` and rejects the rest.
26
+ - A policy's or a filter's terms are kept in order, and `set` adds a new term at the end. A change to one rewrites it whole.
27
+ - One policy term cannot filter IPv4 and IPv6 prefixes together: `invalid prefix ::/0 for family inet`. A rule for both families becomes a term for each.
28
+ - A port filter cannot match the VLAN a frame is switched in: `learn-vlan-id not supported on ingress interface`.
29
+ - IPv4 routes over an IPv6 session need a local IPv4 address under the family: `Local IPv4 address must be configured`.
30
+ - A DHCP server and a DHCP relay cannot share an interface: `Interface irb.10 already configured`.
31
+ - A port's MTU counts the Ethernet header. `mtu 9018` carries 9000-byte packets with a VLAN tag, and a VLAN interface's 9000 then commits without a warning.
@@ -0,0 +1,263 @@
1
+ // BGP, route policies, prefix sets and route origin validation, as Junos `set` statements.
2
+
3
+ import { familyOf, host } from "../../../core/addr";
4
+ import { marker } from "../../../core/secrets";
5
+ import type { Device, Family, Neighbor, NeighborSettings, PolicyRule } from "../../../schema";
6
+ import { BFD_INTERVAL, quote, type Statements } from "./render";
7
+
8
+ type AnyRule = PolicyRule<string, string>;
9
+ type AnyNeighbor = Neighbor<string, string>;
10
+
11
+ /** The policy a session uses when it states none, so that it learns or announces nothing. */
12
+ export const REJECT_ALL = "circuit-reject-all";
13
+
14
+ /** The policy every export starts with. BGP's own routes and the prefixes the device originates go on to the session's policy; the device's other routes never reach it. */
15
+ const ORIGIN = "circuit-bgp-origin";
16
+
17
+ /** The validator group every RPKI server joins. */
18
+ const RPKI_GROUP = "circuit";
19
+
20
+ /** A comment in the render naming a path whose statements `refresh` fills, so the plan keeps what the device holds there. */
21
+ export const GENERATED = "# generated ";
22
+
23
+ const FAMILY = { ipv4: "inet", ipv6: "inet6" } as const;
24
+
25
+ const VALIDATION = { valid: "valid", invalid: "invalid", "not-found": "unknown" } as const;
26
+
27
+ /** A neighbor's settings with its group's beneath them. */
28
+ export function settingsOf(device: Device, neighbor: AnyNeighbor): NeighborSettings<string> {
29
+ const group = neighbor.group ? device.routing?.bgp?.groups?.[neighbor.group] : undefined;
30
+ return { ...group, ...neighbor };
31
+ }
32
+
33
+ /** A name made of a value's words, for a community or a path-length list: `64500:*` becomes `64500-any`. */
34
+ const slug = (value: string) => value.replace(/\*/g, "any").replace(/[^A-Za-z0-9]+/g, "-");
35
+
36
+ /** A community as the platform writes a member: a large community carries the `large:` prefix. */
37
+ const member = (community: string, large: boolean) => (large ? `large:${community}` : community);
38
+
39
+ export function renderBgp(out: Statements, device: Device): void {
40
+ prefixSets(out, device);
41
+ rpki(out, device);
42
+
43
+ const bgp = device.routing?.bgp;
44
+ if (!bgp) return;
45
+
46
+ out.set("routing-options autonomous-system", bgp.asn);
47
+
48
+ const used = new Set<string>();
49
+ let rejectAll = false;
50
+ let exports = false;
51
+
52
+ // One group per session: a group holds one type, internal or external, and the config's groups are only shared settings.
53
+ for (const [name, neighbor] of Object.entries(bgp.neighbors ?? {})) {
54
+ const settings = settingsOf(device, neighbor);
55
+ const group = `protocols bgp group ${name}`;
56
+
57
+ out.set(group, "type", settings.remote_as === bgp.asn ? "internal" : "external");
58
+ out.set(group, "peer-as", settings.remote_as!);
59
+ if (settings.description) out.set(group, "description", quote(settings.description));
60
+ if (settings.local_address) out.set(group, "local-address", settings.local_address);
61
+ if (settings.multihop) out.set(group, "multihop");
62
+ if (settings.passive) out.set(group, "passive");
63
+ if (settings.hold_time !== undefined) out.set(group, "hold-time", settings.hold_time);
64
+ if (settings.password) out.set(group, "authentication-key", marker(settings.password));
65
+ if (settings.bfd) out.set(group, "bfd-liveness-detection minimum-interval", BFD_INTERVAL);
66
+
67
+ out.set(group, "import", settings.import ?? REJECT_ALL);
68
+
69
+ if (settings.export) {
70
+ exports = true;
71
+ out.set(group, "export", ORIGIN);
72
+ out.set(group, "export", settings.export);
73
+ } else out.set(group, "export", REJECT_ALL);
74
+
75
+ for (const policy of [settings.import, settings.export]) {
76
+ if (policy) used.add(policy);
77
+ else rejectAll = true;
78
+ }
79
+
80
+ for (const family of settings.families ?? [familyOf(neighbor.address)]) {
81
+ const nlri = `${group} family ${FAMILY[family]} unicast`;
82
+ out.set(nlri);
83
+
84
+ // A session over its limit stays down until it is cleared by hand.
85
+ if (settings.max_prefixes !== undefined) {
86
+ out.set(nlri, "prefix-limit maximum", settings.max_prefixes);
87
+ out.set(nlri, "prefix-limit teardown idle-timeout forever");
88
+ }
89
+ }
90
+
91
+ out.set(group, "neighbor", neighbor.address);
92
+ }
93
+
94
+ if (rejectAll) out.set(`policy-options policy-statement ${REJECT_ALL} then reject`);
95
+
96
+ if (exports) origin(out, device);
97
+
98
+ for (const policy of [...used].sort()) policyStatement(out, device, policy);
99
+ }
100
+
101
+ /** BGP's routes and the originated prefixes go on to the session's policy; anything else is rejected. */
102
+ function origin(out: Statements, device: Device): void {
103
+ const statement = `policy-options policy-statement ${ORIGIN}`;
104
+ out.set(statement, "term bgp from protocol bgp");
105
+ out.set(statement, "term bgp then next policy");
106
+
107
+ const networks = device.routing?.bgp?.networks ?? [];
108
+ for (const prefix of networks) out.set(statement, "term networks from route-filter", prefix, "exact");
109
+ if (networks.length) out.set(statement, "term networks then next policy");
110
+
111
+ out.set(statement, "term others then reject");
112
+ }
113
+
114
+ function prefixSets(out: Statements, device: Device): void {
115
+ for (const [name, set] of Object.entries(device.prefix_sets ?? {})) {
116
+ const list = `policy-options prefix-list ${name}`;
117
+
118
+ if ("prefixes" in set && set.prefixes.length) for (const prefix of set.prefixes) out.set(list, prefix);
119
+ else out.set(list);
120
+
121
+ if ("source" in set) out.note(`${GENERATED}${list}`);
122
+ }
123
+ }
124
+
125
+ function rpki(out: Statements, device: Device): void {
126
+ for (const server of device.routing?.rpki ?? []) {
127
+ out.set(`routing-options validation group ${RPKI_GROUP} session`, server.address, "port", server.port);
128
+ }
129
+ }
130
+
131
+ /** The family a rule is limited to, by its match: a family, a prefix, or a prefix set of one family. */
132
+ function familyOfRule(device: Device, rule: AnyRule): Family | undefined {
133
+ const match = rule.match;
134
+ if (match?.family) return match.family;
135
+
136
+ if (match?.prefix) return familyOf(host(match.prefix));
137
+
138
+ const set = match?.prefix_set ? device.prefix_sets?.[match.prefix_set] : undefined;
139
+ if (set && "source" in set) return set.source.family;
140
+
141
+ if (set?.prefixes[0]) return familyOf(host(set.prefixes[0]));
142
+
143
+ return undefined;
144
+ }
145
+
146
+ /** A policy's rules with each call replaced by the rules it calls, in order. A called policy that decides nothing hands back to the caller, as it does on every platform. */
147
+ function flatten(device: Device, policy: string, label: string, seen: string[]): { label: string; rule: AnyRule }[] {
148
+ const rules = device.policies?.[policy] ?? [];
149
+ const flat: { label: string; rule: AnyRule }[] = [];
150
+
151
+ rules.forEach((rule, index) => {
152
+ const here = label ? `${label}.${index + 1}` : `${index + 1}`;
153
+
154
+ if (rule.call && !seen.includes(rule.call)) flat.push(...flatten(device, rule.call, here, [...seen, rule.call]));
155
+
156
+ if (rule.match || rule.set || rule.action) flat.push({ label: here, rule });
157
+ });
158
+
159
+ return flat;
160
+ }
161
+
162
+ /** A term's name: its place, then the rule's description so a reader sees it on the device. */
163
+ const termName = (label: string, rule: AnyRule) => quote(rule.description ? `${label} ${rule.description}` : label);
164
+
165
+ /** One policy as a policy statement: a term per rule, then a reject when the last rule does not decide on every route. */
166
+ function policyStatement(out: Statements, device: Device, policy: string): void {
167
+ const statement = `policy-options policy-statement ${policy}`;
168
+ const flat = flatten(device, policy, "", [policy]);
169
+
170
+ for (const { label, rule } of flat) {
171
+ const family = familyOfRule(device, rule);
172
+ // A next hop only means something to routes of its own family, and one term cannot filter prefixes of both, so a rule for both families splits into a term for each.
173
+ const split = !family && (rule.set?.next_hop || rule.match?.prefix_length) ? (["ipv4", "ipv6"] as const) : [family];
174
+
175
+ for (const part of split) {
176
+ const name = split.length > 1 ? termName(`${label}-${part === "ipv4" ? "v4" : "v6"}`, rule) : termName(label, rule);
177
+ term(out, device, `${statement} term ${name}`, rule, part, split.length > 1);
178
+ }
179
+ }
180
+
181
+ const last = flat.at(-1)?.rule;
182
+ const decides = last !== undefined && !last.match && last.action !== undefined;
183
+ if (!decides) out.set(statement, "term circuit-default then reject");
184
+ }
185
+
186
+ function term(out: Statements, device: Device, term: string, rule: AnyRule, family: Family | undefined, splitFamily: boolean): void {
187
+ const match = rule.match ?? {};
188
+ const set = rule.set ?? {};
189
+ const from: (string | number)[][] = [];
190
+ const then: (string | number)[][] = [];
191
+
192
+ if (match.family || splitFamily) from.push(["family", FAMILY[(match.family ?? family)!]]);
193
+ if (match.prefix) from.push(["route-filter", match.prefix, "exact"]);
194
+ if (match.prefix_set) from.push(["prefix-list-filter", match.prefix_set, "orlonger"]);
195
+
196
+ if (match.prefix_length) {
197
+ const families = family ? [family] : (["ipv4", "ipv6"] as const);
198
+
199
+ for (const each of families) {
200
+ const longest = each === "ipv4" ? 32 : 128;
201
+ const range = `/${match.prefix_length.min ?? 0}-/${match.prefix_length.max ?? longest}`;
202
+ from.push(["route-filter", each === "ipv4" ? "0.0.0.0/0" : "::/0", "prefix-length-range", range]);
203
+ }
204
+ }
205
+
206
+ if (match.as_path_length) {
207
+ const { min = 0, max } = match.as_path_length;
208
+ const name = `circuit-path-length-${min}-${max ?? "any"}`;
209
+ out.set(`policy-options as-path ${name}`, quote(`.{${min},${max ?? ""}}`));
210
+ from.push(["as-path", name]);
211
+ }
212
+
213
+ if (match.rpki) from.push(["validation-database", VALIDATION[match.rpki]]);
214
+
215
+ // Members of one named community must all be present, so a rule matching both kinds names them together.
216
+ const wanted = [
217
+ ...(match.community ? [member(match.community, false)] : []),
218
+ ...(match.large_community ? [member(match.large_community, true)] : []),
219
+ ];
220
+
221
+ if (wanted.length) from.push(["community", community(out, "match", wanted)]);
222
+
223
+ if (set.local_pref !== undefined) then.push(["local-preference", set.local_pref]);
224
+ if (set.med !== undefined) then.push(["metric", set.med]);
225
+
226
+ if (set.prepend !== undefined) {
227
+ const asn = device.routing!.bgp!.asn;
228
+ then.push(["as-path-prepend", quote(Array(set.prepend).fill(asn).join(" "))]);
229
+ }
230
+
231
+ // Replacing communities of one kind is removing every one of that kind, then adding the new ones.
232
+ const removed = [
233
+ ...(set.communities ? ["*:*"] : (set.remove_communities ?? [])).map((pattern) => member(pattern, false)),
234
+ ...(set.large_communities ? ["*:*:*"] : (set.remove_large_communities ?? [])).map((pattern) => member(pattern, true)),
235
+ ];
236
+
237
+ const added = [
238
+ ...(set.communities ?? set.add_communities ?? []).map((value) => member(value, false)),
239
+ ...(set.large_communities ?? set.add_large_communities ?? []).map((value) => member(value, true)),
240
+ ];
241
+
242
+ if (set.communities && set.add_communities) added.push(...set.add_communities.map((value) => member(value, false)));
243
+ if (set.large_communities && set.add_large_communities) added.push(...set.add_large_communities.map((value) => member(value, true)));
244
+
245
+ for (const pattern of removed) then.push(["community delete", community(out, "remove", [pattern])]);
246
+ if (added.length) then.push(["community add", community(out, "add", added)]);
247
+
248
+ if (set.next_hop && (!family || familyOf(set.next_hop) === family)) then.push(["next-hop", set.next_hop]);
249
+ if (set.blackhole) then.push(["next-hop discard"]);
250
+ if (rule.action) then.push([rule.action]);
251
+
252
+ for (const words of from) out.set(term, "from", ...words);
253
+ for (const words of then) out.set(term, "then", ...words);
254
+
255
+ if (!from.length && !then.length) out.set(term);
256
+ }
257
+
258
+ /** A named community holding these members, named after them. */
259
+ function community(out: Statements, purpose: string, members: string[]): string {
260
+ const name = `circuit-${purpose}-${slug(members.join(" "))}`;
261
+ for (const value of members) out.set(`policy-options community ${name} members`, quote(value));
262
+ return name;
263
+ }