@takodotid/circuit 0.41.0 → 0.42.1
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/chunks/index-6wb41q67.js +62 -0
- package/dist/chunks/{index-k38bby43.js → index-yzw86rc8.js} +4 -62
- package/dist/chunks/{server-ggertnce.js → server-cxr9ynxc.js} +1 -1
- package/dist/cli/index.js +226 -55
- package/dist/helpers/index.js +14 -9
- package/dist/types/core/cabling.d.ts +0 -6
- package/dist/types/core/define.d.ts +1 -1
- package/dist/types/core/validator.d.ts +2 -0
- package/dist/types/helpers/cables.d.ts +14 -11
- package/dist/types/helpers/exchange.d.ts +7 -1
- package/dist/types/helpers/upstreams.d.ts +11 -3
- package/dist/types/platforms/bgp-wait.d.ts +2 -2
- package/dist/types/platforms/derive.d.ts +1 -1
- package/dist/types/platforms/fortios/routing.d.ts +2 -2
- package/dist/types/platforms/junos/bgp.d.ts +2 -2
- package/dist/types/platforms/platform.d.ts +3 -0
- package/dist/types/platforms/routeros/index.d.ts +14 -1
- package/dist/types/platforms/routeros/plan.d.ts +4 -0
- package/dist/types/platforms/routeros/render.d.ts +4 -0
- package/dist/types/schema/device.d.ts +4 -4
- package/dist/types/schema/interfaces.d.ts +1 -1
- package/dist/types/schema/routing.d.ts +43 -10
- package/package.json +1 -1
- package/src/cli/index.ts +26 -29
- package/src/core/cabling.ts +0 -30
- package/src/core/define.ts +3 -1
- package/src/core/validator.ts +64 -1
- package/src/helpers/cables.ts +20 -15
- package/src/helpers/exchange.ts +9 -3
- package/src/helpers/upstreams.ts +9 -4
- package/src/platforms/bgp-wait.ts +11 -11
- package/src/platforms/derive.ts +23 -1
- package/src/platforms/fortios/routing.ts +2 -2
- package/src/platforms/junos/bgp.ts +2 -2
- package/src/platforms/platform.ts +3 -0
- package/src/platforms/routeros/TRAPS.md +6 -0
- package/src/platforms/routeros/export.ts +1 -0
- package/src/platforms/routeros/index.ts +133 -4
- package/src/platforms/routeros/plan.ts +20 -1
- package/src/platforms/routeros/render.ts +28 -3
- package/src/schema/device.ts +4 -3
- package/src/schema/interfaces.ts +1 -1
- package/src/schema/routing.ts +46 -10
- package/templates/colocation/checks.ts +14 -11
- package/templates/colocation/edge-01.ts +2 -2
- package/templates/colocation/tenancy.ts +50 -71
- package/templates/colocation/tenants/acme.ts +10 -10
- package/templates/colocation/tenants/index.ts +2 -1
- package/templates/colocation/tor-01.ts +2 -2
- package/templates/project/AGENTS.md +15 -15
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { Client } from "ssh2";
|
|
6
6
|
import { Address } from "../../core/address";
|
|
7
7
|
import type { Neighbor } from "../../core/cabling";
|
|
8
|
+
import type { Finding } from "../../core/define";
|
|
8
9
|
import { Secrets } from "../../core/secrets";
|
|
9
10
|
import type { Device, Family } from "../../schema";
|
|
10
11
|
import { SerialConsole, type ConsoleDialect, ConsoleShell } from "../../transport/console";
|
|
@@ -84,6 +85,12 @@ export class RouterOs extends Platform {
|
|
|
84
85
|
].join(" ");
|
|
85
86
|
|
|
86
87
|
/** Open again the session from one address, only while it is closed on its limit. */
|
|
88
|
+
/** Each advertisement of these prefixes, one line each: `ADV|<session>|<prefix>`. One line, because a script sent over exec runs as one. */
|
|
89
|
+
private static listAdvertisements(prefixes: readonly string[]): string {
|
|
90
|
+
const where = prefixes.map((prefix) => `dst=${prefix}`).join(" or ");
|
|
91
|
+
return `:foreach a in=[/routing bgp advertisements print as-value where ${where}] do={ :put ("ADV|" . ($a->"peer") . "|" . ($a->"dst")) }`;
|
|
92
|
+
}
|
|
93
|
+
|
|
87
94
|
private static clearLimit(address: string): string {
|
|
88
95
|
return `/routing bgp session clear [find remote.address=${address} and limit-exceeded] flag=limit-exceeded`;
|
|
89
96
|
}
|
|
@@ -135,6 +142,51 @@ export class RouterOs extends Platform {
|
|
|
135
142
|
|
|
136
143
|
override readonly console = RouterOs.CONSOLE;
|
|
137
144
|
|
|
145
|
+
/**
|
|
146
|
+
* A BGP session with a `table` announces from that table, not from main: each prefix of `networks` it announces needs a route in the table too, or it is not announced (TRAPS.md). A session left with nothing to announce is an error; a prefix missing from its table is a warning, since its export may not announce it anyway.
|
|
147
|
+
*/
|
|
148
|
+
checks(device: Device): Finding[] {
|
|
149
|
+
const bgp = device.routing?.bgp;
|
|
150
|
+
const statics = device.routing?.static ?? [];
|
|
151
|
+
const findings: Finding[] = [];
|
|
152
|
+
|
|
153
|
+
for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
|
|
154
|
+
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
155
|
+
const table = neighbor.table ?? group?.table;
|
|
156
|
+
if (!table) continue;
|
|
157
|
+
|
|
158
|
+
const families = neighbor.families ?? group?.families ?? [Address.family(neighbor.address)];
|
|
159
|
+
const networks = (bgp?.networks ?? []).filter((prefix) => families.includes(Address.family(Address.host(prefix))));
|
|
160
|
+
const inTable = (prefix: string) =>
|
|
161
|
+
statics.some((route) => route.table === table && Address.canonical(route.prefix) === Address.canonical(prefix));
|
|
162
|
+
const missing = networks.filter((prefix) => !inTable(prefix));
|
|
163
|
+
if (!missing.length) continue;
|
|
164
|
+
|
|
165
|
+
const where = `routing.bgp.neighbors.${name}`;
|
|
166
|
+
const route = (prefix: string) => `{ prefix: "${prefix}", blackhole: true, table: "${table}" }`;
|
|
167
|
+
const fix = `On RouterOS a session announces from its own table, ${table}`;
|
|
168
|
+
|
|
169
|
+
if (missing.length === networks.length) {
|
|
170
|
+
findings.push({
|
|
171
|
+
level: "error",
|
|
172
|
+
device: device.name,
|
|
173
|
+
message: `${where} would announce nothing: ${fix}, which has no route for any of networks. Add the routes it announces to ${table} too, such as ${route(missing[0]!)} in routing.static`,
|
|
174
|
+
});
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const prefix of missing) {
|
|
179
|
+
findings.push({
|
|
180
|
+
level: "warning",
|
|
181
|
+
device: device.name,
|
|
182
|
+
message: `${where} does not announce ${prefix}: ${fix}, which has no route for it. If its export should announce it, add ${route(prefix)} to routing.static`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return findings;
|
|
188
|
+
}
|
|
189
|
+
|
|
138
190
|
unsupported(device: Device): string[] {
|
|
139
191
|
const found: string[] = [];
|
|
140
192
|
|
|
@@ -207,7 +259,16 @@ export class RouterOs extends Platform {
|
|
|
207
259
|
}
|
|
208
260
|
|
|
209
261
|
// BFD on a static route and a MAC limit are refused above, for the reason the device gave, and an ethertype filter on a LAG as unproved.
|
|
210
|
-
found.push(
|
|
262
|
+
found.push(
|
|
263
|
+
...Derive.unproved(device, [
|
|
264
|
+
"bfd-timers",
|
|
265
|
+
"multihop-bfd",
|
|
266
|
+
"static-route-bfd",
|
|
267
|
+
"mac-limit",
|
|
268
|
+
"ethertype-filter",
|
|
269
|
+
"routing-tables",
|
|
270
|
+
])
|
|
271
|
+
);
|
|
211
272
|
|
|
212
273
|
return found;
|
|
213
274
|
}
|
|
@@ -418,13 +479,14 @@ export class RouterOs extends Platform {
|
|
|
418
479
|
}
|
|
419
480
|
|
|
420
481
|
/**
|
|
421
|
-
*
|
|
482
|
+
* Three checks after an apply. When the plan touched switch rules, none may be invalid. BGP sessions established before the apply must be established again, and announce again each of the device's `networks` they announced before, within `BgpWait.AFTER_APPLY`. A session can stay up and stop announcing (TRAPS.md), which the session check alone would not see.
|
|
422
483
|
*/
|
|
423
484
|
async health(device: Device, plan: Plan, options: HealthOptions = {}): Promise<HealthCheck> {
|
|
424
485
|
const switchRules = plan.steps.some((step) => step.send.some((command) => command.startsWith(RouterOs.SWITCH_RULES)));
|
|
425
486
|
const limits = options.bgp ?? BgpWait.AFTER_APPLY;
|
|
426
487
|
|
|
427
488
|
const establishedBefore = await this.establishedSessions(device, options);
|
|
489
|
+
const announcedBefore = establishedBefore.length ? await this.announcedNow(device, options) : [];
|
|
428
490
|
|
|
429
491
|
return {
|
|
430
492
|
run: async () => {
|
|
@@ -438,7 +500,8 @@ export class RouterOs extends Platform {
|
|
|
438
500
|
try {
|
|
439
501
|
const switchProblems = switchRules ? await RouterOs.invalidSwitchRules(session, device) : [];
|
|
440
502
|
const bgpProblems = await this.bgpBack(device, session, establishedBefore, limits);
|
|
441
|
-
|
|
503
|
+
const announceProblems = await this.announcedBack(device, session, announcedBefore, limits);
|
|
504
|
+
return [...switchProblems, ...bgpProblems, ...announceProblems];
|
|
442
505
|
} finally {
|
|
443
506
|
session.close();
|
|
444
507
|
}
|
|
@@ -549,11 +612,77 @@ export class RouterOs extends Platform {
|
|
|
549
612
|
|
|
550
613
|
return down.map((bgp) =>
|
|
551
614
|
bgp.limit
|
|
552
|
-
? `BGP session ${bgp.neighbor} was established before the apply and closed on its limit${bgp.limit.max !== undefined ? ` of ${bgp.limit.max} prefixes` : ""}: the neighbor sends more than it may. Find out why, then: circuit bgp ${device.name}
|
|
615
|
+
? `BGP session ${bgp.neighbor} was established before the apply and closed on its limit${bgp.limit.max !== undefined ? ` of ${bgp.limit.max} prefixes` : ""}: the neighbor sends more than it may. Find out why, then: circuit bgp ${device.name} --neighbors '^${bgp.neighbor}$' --clear-limit`
|
|
553
616
|
: `BGP session ${bgp.neighbor} was established before the apply and is not ${limits.seconds} s after it (${bgp.reason ?? (bgp.uptime === "no session" ? "no session" : "not established")}). Look with: circuit bgp ${device.name} --log`
|
|
554
617
|
);
|
|
555
618
|
}
|
|
556
619
|
|
|
620
|
+
/** What each session announces of the device's own `networks`, as `neighbor prefix`. None, without logging in, on a device without networks. */
|
|
621
|
+
private async announcedNow(device: Device, options: ReadOptions): Promise<string[]> {
|
|
622
|
+
if (!device.routing?.bgp?.networks?.length) return [];
|
|
623
|
+
|
|
624
|
+
const session = await this.session(device, options);
|
|
625
|
+
|
|
626
|
+
try {
|
|
627
|
+
return await this.announced(device, session);
|
|
628
|
+
} finally {
|
|
629
|
+
session.close();
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** The `networks` each session announces, as `neighbor prefix`, sorted. An advertisement names its session, which is matched to its neighbor by address first, as in `bgpSessions`. */
|
|
634
|
+
private async announced(device: Device, session: ReadSession): Promise<string[]> {
|
|
635
|
+
const bgp = device.routing?.bgp;
|
|
636
|
+
const networks = bgp?.networks ?? [];
|
|
637
|
+
if (!networks.length) return [];
|
|
638
|
+
|
|
639
|
+
const sessions = (await session.run(RouterOs.LIST_BGP_SESSIONS, 60_000))
|
|
640
|
+
.split(/\r?\n/)
|
|
641
|
+
.map((line) => line.trim().split("|"))
|
|
642
|
+
.filter((fields) => fields.length > 1 && fields[0] !== "disabled");
|
|
643
|
+
const neighbors = Object.entries(bgp?.neighbors ?? {});
|
|
644
|
+
const neighborOf = (sessionName: string) => {
|
|
645
|
+
const remote = sessions.find(([name]) => name === sessionName)?.[1];
|
|
646
|
+
const byAddress = neighbors.find(([, neighbor]) => neighbor.address === remote);
|
|
647
|
+
const byName = neighbors.find(([name]) => name === sessionName.replace(/-\d+$/, ""));
|
|
648
|
+
return (byAddress ?? byName)?.[0];
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const output = await session.run(RouterOs.listAdvertisements(networks), 60_000);
|
|
652
|
+
return output
|
|
653
|
+
.split(/\r?\n/)
|
|
654
|
+
.map((line) => line.trim().split("|"))
|
|
655
|
+
.flatMap(([marker, peer, prefix]) => {
|
|
656
|
+
const neighbor = marker === "ADV" && peer ? neighborOf(peer) : undefined;
|
|
657
|
+
return neighbor && prefix ? [`${neighbor} ${prefix}`] : [];
|
|
658
|
+
})
|
|
659
|
+
.sort();
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Wait until each session announces again what it announced before, and give a problem for each prefix a session no longer announces when time runs out. */
|
|
663
|
+
private async announcedBack(device: Device, session: ReadSession, before: readonly string[], limits: WaitLimits): Promise<string[]> {
|
|
664
|
+
if (!before.length) return [];
|
|
665
|
+
|
|
666
|
+
const missing = (now: string[]) => before.filter((pair) => !now.includes(pair));
|
|
667
|
+
const after = await BgpWait.until(
|
|
668
|
+
() => this.announced(device, session),
|
|
669
|
+
(now) => !missing(now).length,
|
|
670
|
+
limits,
|
|
671
|
+
(now) => console.log(` BGP: ${missing(now).length} announcement(s) not back yet, checking again in ${limits.every} s`)
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
const gone = missing(after);
|
|
675
|
+
if (!gone.length) {
|
|
676
|
+
console.log(` BGP: every session announces what it announced before`);
|
|
677
|
+
return [];
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return gone.map((pair) => {
|
|
681
|
+
const [neighbor, prefix] = pair.split(" ");
|
|
682
|
+
return `BGP session ${neighbor} announced ${prefix} before the apply and does not ${limits.seconds} s after it, though it may still be established. A RouterOS session announces from its routing table: that table needs a route for ${prefix}, and a session whose table changed must restart. Look with: circuit show ${device.name} '/routing bgp advertisements print'`;
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
557
686
|
/** A problem when any switch rule is invalid, saying what to do. */
|
|
558
687
|
private static async invalidSwitchRules(session: ReadSession, device: Device): Promise<string[]> {
|
|
559
688
|
const answer = (await session.run(RouterOs.COUNT_INVALID_SWITCH_RULES)).trim();
|
|
@@ -30,6 +30,9 @@ export class RouterOsPlan {
|
|
|
30
30
|
"/interface bridge filter",
|
|
31
31
|
"/interface list",
|
|
32
32
|
"/ip vrf",
|
|
33
|
+
// Routes, rules and BGP connections name a table, so it exists first.
|
|
34
|
+
"/routing table",
|
|
35
|
+
"/routing rule",
|
|
33
36
|
"/ip address",
|
|
34
37
|
"/ipv6 address",
|
|
35
38
|
"/ip pool",
|
|
@@ -80,6 +83,11 @@ export class RouterOsPlan {
|
|
|
80
83
|
/** Menus where an entry that keeps its name but changes what it is known by is changed with a `set`, so what points at it by name, such as an address on a VLAN interface, stays attached. In every other menu it is removed and added again. */
|
|
81
84
|
private static readonly CHANGED_IN_PLACE = new Set(["/interface vlan"]);
|
|
82
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Properties a running entry takes only when it restarts. A BGP session given a new `routing-table` stops announcing at once and keeps its routes in the old table until it restarts (TRAPS.md), so the plan disables the connection, changes it and enables it again: one restart, written in the plan.
|
|
88
|
+
*/
|
|
89
|
+
private static readonly RESTART_ON: Record<string, string[]> = { "/routing bgp connection": ["routing-table"] };
|
|
90
|
+
|
|
83
91
|
/** The variable holding the old rules of an ordered menu while the new ones are added. Removed when the rewrite ends. */
|
|
84
92
|
private static readonly OLD_RULES = "frameworkOldRules";
|
|
85
93
|
|
|
@@ -311,8 +319,19 @@ export class RouterOsPlan {
|
|
|
311
319
|
const update = this.changes(entry, current, `${menu} ${entryKey}`);
|
|
312
320
|
if (!Object.keys(update).length) continue;
|
|
313
321
|
|
|
322
|
+
const selector = RouterOsPlan.find(menu, RouterOsExport.identity(current));
|
|
323
|
+
const restartsOn = (RouterOsPlan.RESTART_ON[menu] ?? []).filter((property) => property in update);
|
|
324
|
+
if (restartsOn.length) {
|
|
325
|
+
changeStep.show.push(
|
|
326
|
+
`~ ${entryKey} ${RouterOsPlan.describe(update)}, restarting the session: it takes a new ${restartsOn.join(" and ")} only when it restarts`
|
|
327
|
+
);
|
|
328
|
+
changeStep.send.push(RouterOsPlan.line(menu, "set", selector, [], { disabled: "yes", ...update }));
|
|
329
|
+
changeStep.send.push(RouterOsPlan.line(menu, "set", selector, [], { disabled: "no" }));
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
314
333
|
changeStep.show.push(`~ ${entryKey} ${RouterOsPlan.describe(update)}`);
|
|
315
|
-
changeStep.send.push(RouterOsPlan.line(menu, "set",
|
|
334
|
+
changeStep.send.push(RouterOsPlan.line(menu, "set", selector, [], update));
|
|
316
335
|
}
|
|
317
336
|
|
|
318
337
|
const removed = existingEntries.filter((entry) => !kept.has(key(entry)));
|
|
@@ -32,7 +32,7 @@ export type AnyInterface = Interface<string, string>;
|
|
|
32
32
|
|
|
33
33
|
type Speed = NonNullable<AnyPort["speed"]>;
|
|
34
34
|
type AnyRule = PolicyRule<string, string>;
|
|
35
|
-
type AnyNeighbor = Neighbor<string, string>;
|
|
35
|
+
type AnyNeighbor = Neighbor<string, string, string>;
|
|
36
36
|
|
|
37
37
|
/** One device's render: the device, and the commands collected under their menu. */
|
|
38
38
|
export class RouterOsRender {
|
|
@@ -1101,6 +1101,8 @@ export class RouterOsRender {
|
|
|
1101
1101
|
}
|
|
1102
1102
|
|
|
1103
1103
|
private routing(): void {
|
|
1104
|
+
this.routingTables();
|
|
1105
|
+
this.routingRules();
|
|
1104
1106
|
this.staticRoutes();
|
|
1105
1107
|
this.rpki();
|
|
1106
1108
|
if (this.device.routing?.bgp) {
|
|
@@ -1113,6 +1115,28 @@ export class RouterOsRender {
|
|
|
1113
1115
|
this.bfd();
|
|
1114
1116
|
}
|
|
1115
1117
|
|
|
1118
|
+
/** A table beside the main one. `fib` puts its routes in the forwarding table, so traffic can be routed by them. */
|
|
1119
|
+
private routingTables(): void {
|
|
1120
|
+
for (const [name, table] of Object.entries(this.device.routing?.tables ?? {})) {
|
|
1121
|
+
this.add("/routing table", "add fib", { comment: table.description, name });
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/** Rules in the order written. `lookup-only-in-table` drops what the table has no route for, and `lookup` falls back to the main table. */
|
|
1126
|
+
private routingRules(): void {
|
|
1127
|
+
for (const rule of this.device.routing?.rules ?? []) {
|
|
1128
|
+
const onlyInTable = rule.table !== "main" && (rule.fallback ?? "none") === "none";
|
|
1129
|
+
this.add("/routing rule", "add", {
|
|
1130
|
+
action: onlyInTable ? "lookup-only-in-table" : "lookup",
|
|
1131
|
+
comment: rule.description,
|
|
1132
|
+
"dst-address": rule.match?.dst,
|
|
1133
|
+
interface: rule.match?.in_interface && this.name(rule.match.in_interface),
|
|
1134
|
+
"src-address": rule.match?.src,
|
|
1135
|
+
table: rule.table,
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1116
1140
|
private staticRoutes(): void {
|
|
1117
1141
|
for (const route of this.device.routing?.static ?? []) {
|
|
1118
1142
|
const menu = Address.family(Address.host(route.prefix)) === "ipv4" ? "/ip route" : "/ipv6 route";
|
|
@@ -1123,7 +1147,7 @@ export class RouterOsRender {
|
|
|
1123
1147
|
distance: route.distance,
|
|
1124
1148
|
"dst-address": route.prefix,
|
|
1125
1149
|
gateway,
|
|
1126
|
-
"routing-table": route.vrf,
|
|
1150
|
+
"routing-table": route.vrf ?? route.table,
|
|
1127
1151
|
});
|
|
1128
1152
|
}
|
|
1129
1153
|
}
|
|
@@ -1146,7 +1170,7 @@ export class RouterOsRender {
|
|
|
1146
1170
|
}
|
|
1147
1171
|
|
|
1148
1172
|
/** A neighbor's settings with its group's beneath them. */
|
|
1149
|
-
private settingsOf(neighbor: AnyNeighbor): NeighborSettings<string> {
|
|
1173
|
+
private settingsOf(neighbor: AnyNeighbor): NeighborSettings<string, string> {
|
|
1150
1174
|
const group = neighbor.group ? this.device.routing?.bgp?.groups?.[neighbor.group] : undefined;
|
|
1151
1175
|
return { ...group, ...neighbor };
|
|
1152
1176
|
}
|
|
@@ -1268,6 +1292,7 @@ export class RouterOsRender {
|
|
|
1268
1292
|
"output.network": originates ? RouterOsRender.NETWORKS[family] : undefined,
|
|
1269
1293
|
"remote.address": `${neighbor.address}/${addressFamily === "ipv4" ? 32 : 128}`,
|
|
1270
1294
|
"remote.as": settings.remote_as,
|
|
1295
|
+
"routing-table": settings.table,
|
|
1271
1296
|
"tcp-md5-key": settings.password && Secrets.marker(settings.password),
|
|
1272
1297
|
"use-bfd": settings.bfd ? "yes" : undefined,
|
|
1273
1298
|
});
|
package/src/schema/device.ts
CHANGED
|
@@ -71,7 +71,7 @@ export type Vrf = {
|
|
|
71
71
|
/**
|
|
72
72
|
* Everything one device runs. What is not written here is removed from the device or returned to its default.
|
|
73
73
|
*
|
|
74
|
-
* Type parameters are the names the device declares, so a reference to a name that does not exist fails to compile: N ports, V VLANs, I interfaces, P policies, S prefix sets, G BGP groups, A ACLs, AS address sets, U users, F VRFs, C certificates.
|
|
74
|
+
* Type parameters are the names the device declares, so a reference to a name that does not exist fails to compile: N ports, V VLANs, I interfaces, P policies, S prefix sets, G BGP groups, A ACLs, AS address sets, U users, F VRFs, C certificates, T routing tables.
|
|
75
75
|
*/
|
|
76
76
|
export type Device<
|
|
77
77
|
Pl extends string = string,
|
|
@@ -87,6 +87,7 @@ export type Device<
|
|
|
87
87
|
U extends string = string,
|
|
88
88
|
F extends string = string,
|
|
89
89
|
C extends string = string,
|
|
90
|
+
T extends string = string,
|
|
90
91
|
> = {
|
|
91
92
|
/** Hostname. */
|
|
92
93
|
name: string;
|
|
@@ -112,8 +113,8 @@ export type Device<
|
|
|
112
113
|
interfaces?: Record<I, Interface<NoInfer<V>, NoInfer<F>>>;
|
|
113
114
|
/** Separate routing tables, keyed by name. */
|
|
114
115
|
vrfs?: Record<F, Vrf>;
|
|
115
|
-
/**
|
|
116
|
-
routing?: Routing<NoInfer<P>, G, NoInfer<I | N>, NoInfer<F
|
|
116
|
+
/** Routing tables and rules, static routes, BGP, OSPF and route validation. */
|
|
117
|
+
routing?: Routing<NoInfer<P>, G, NoInfer<I | N>, NoInfer<F>, T>;
|
|
117
118
|
/** Route policies, keyed by name. */
|
|
118
119
|
policies?: Record<P, RoutePolicy<NoInfer<P>, NoInfer<S>>>;
|
|
119
120
|
/** Prefix sets that policies match on, keyed by name. */
|
package/src/schema/interfaces.ts
CHANGED
|
@@ -113,7 +113,7 @@ export type Port<V extends string, A extends string, L extends string, F extends
|
|
|
113
113
|
acl?: A;
|
|
114
114
|
/** The LAG this port is in. Every other setting goes on the LAG. */
|
|
115
115
|
lag?: L;
|
|
116
|
-
/** Another of our devices at the other end of the cable.
|
|
116
|
+
/** Another of our devices at the other end of the cable. */
|
|
117
117
|
link?: Link;
|
|
118
118
|
};
|
|
119
119
|
|
package/src/schema/routing.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Family, IP, Prefix, Secret } from "./common";
|
|
2
2
|
|
|
3
3
|
/** A route the device has without learning it. */
|
|
4
|
-
export type StaticRoute<I extends string, F extends string> = {
|
|
4
|
+
export type StaticRoute<I extends string, F extends string, T extends string = never> = {
|
|
5
5
|
/** Destination. */
|
|
6
6
|
prefix: Prefix;
|
|
7
7
|
/** Next-hop address. */
|
|
@@ -14,8 +14,10 @@ export type StaticRoute<I extends string, F extends string> = {
|
|
|
14
14
|
bfd?: true;
|
|
15
15
|
/** Preference against other routes to the same prefix. Lower wins. 1 when absent. */
|
|
16
16
|
distance?: number;
|
|
17
|
-
/** The VRF the route is in. The
|
|
17
|
+
/** The VRF the route is in. The main table when absent. */
|
|
18
18
|
vrf?: F;
|
|
19
|
+
/** The table the route is in, one of `routing.tables`. The main table when absent. A route has a `vrf` or a `table`, not both. */
|
|
20
|
+
table?: T;
|
|
19
21
|
/** Why the route exists. */
|
|
20
22
|
description?: string;
|
|
21
23
|
};
|
|
@@ -26,7 +28,7 @@ export type StaticRoute<I extends string, F extends string> = {
|
|
|
26
28
|
export type BgpRole = "provider" | "customer" | "peer" | "rs" | "rs-client";
|
|
27
29
|
|
|
28
30
|
/** Settings a BGP neighbor can set itself or take from its group. */
|
|
29
|
-
export type NeighborSettings<P extends string> = {
|
|
31
|
+
export type NeighborSettings<P extends string, T extends string = never> = {
|
|
30
32
|
/** The neighbor's AS. Our own AS makes the session internal. */
|
|
31
33
|
remote_as?: number;
|
|
32
34
|
/** Who the neighbor is. */
|
|
@@ -55,10 +57,12 @@ export type NeighborSettings<P extends string> = {
|
|
|
55
57
|
passive?: boolean;
|
|
56
58
|
/** Detect a dead link in under a second with BFD. */
|
|
57
59
|
bfd?: boolean;
|
|
60
|
+
/** The table the routes learned are installed in, one of `routing.tables`. The main table when absent. On RouterOS the session also announces from that table, so each prefix of `networks` it announces needs a route in the table too, and a running session takes a new table only when it restarts. */
|
|
61
|
+
table?: T;
|
|
58
62
|
};
|
|
59
63
|
|
|
60
64
|
/** One BGP session. Settings it does not set come from its group. */
|
|
61
|
-
export type Neighbor<P extends string, G extends string> = NeighborSettings<P> & {
|
|
65
|
+
export type Neighbor<P extends string, G extends string, T extends string = never> = NeighborSettings<P, T> & {
|
|
62
66
|
/** The neighbor's address. */
|
|
63
67
|
address: IP;
|
|
64
68
|
/** The group this neighbor takes a setting from when it does not set that setting itself. */
|
|
@@ -66,15 +70,15 @@ export type Neighbor<P extends string, G extends string> = NeighborSettings<P> &
|
|
|
66
70
|
};
|
|
67
71
|
|
|
68
72
|
/** BGP on this device. */
|
|
69
|
-
export type Bgp<P extends string, G extends string> = {
|
|
73
|
+
export type Bgp<P extends string, G extends string, T extends string = never> = {
|
|
70
74
|
/** Our AS number. */
|
|
71
75
|
asn: number;
|
|
72
76
|
/** Prefixes this device originates. Each needs a route, for example a static blackhole. Export policy still decides who receives them. */
|
|
73
77
|
networks?: readonly Prefix[];
|
|
74
78
|
/** Settings shared by several neighbors, keyed by group name. */
|
|
75
|
-
groups?: Record<G, NeighborSettings<P>>;
|
|
79
|
+
groups?: Record<G, NeighborSettings<P, T>>;
|
|
76
80
|
/** Sessions keyed by name. */
|
|
77
|
-
neighbors?: Record<string, Neighbor<P, G>>;
|
|
81
|
+
neighbors?: Record<string, Neighbor<P, G, T>>;
|
|
78
82
|
};
|
|
79
83
|
|
|
80
84
|
/** How fast BFD notices a dead neighbor: after `multiplier` packets in a row are missed, so in `interval` times `multiplier` milliseconds. */
|
|
@@ -120,14 +124,46 @@ export type Ospf<I extends string> = {
|
|
|
120
124
|
redistribute?: readonly ("connected" | "static")[];
|
|
121
125
|
};
|
|
122
126
|
|
|
127
|
+
/** A routing table beside the main one. It holds no interfaces: routes go in from static routes and BGP neighbors that name it in `table`, and `routing.rules` send traffic to it. */
|
|
128
|
+
export type RoutingTable = {
|
|
129
|
+
/** What the table is for. */
|
|
130
|
+
description?: string;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Traffic a routing rule matches: all of what is given. Every packet when absent. */
|
|
134
|
+
export type RoutingRuleMatch<I extends string> = {
|
|
135
|
+
/** Source prefix. */
|
|
136
|
+
src?: Prefix;
|
|
137
|
+
/** Destination prefix. */
|
|
138
|
+
dst?: Prefix;
|
|
139
|
+
/** The interface the traffic arrives on. */
|
|
140
|
+
in_interface?: I;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** One routing rule: traffic it matches is routed by `table` instead of the main table. */
|
|
144
|
+
export type RoutingRule<I extends string, T extends string> = {
|
|
145
|
+
/** Why the rule exists. */
|
|
146
|
+
description?: string;
|
|
147
|
+
/** What the traffic must match. Every packet when absent, so such a rule is the last one. */
|
|
148
|
+
match?: RoutingRuleMatch<I>;
|
|
149
|
+
/** The table that routes the traffic: one of `routing.tables`, or `main`. */
|
|
150
|
+
table: T | "main";
|
|
151
|
+
/** When `table` has no route for a packet: `none` drops it as unroutable, `main` routes it by the main table instead. `none` when absent. Not for a rule whose table is `main`. */
|
|
152
|
+
fallback?: "none" | "main";
|
|
153
|
+
};
|
|
154
|
+
|
|
123
155
|
/** Routing on this device. */
|
|
124
|
-
export type Routing<P extends string, G extends string, I extends string, F extends string> = {
|
|
156
|
+
export type Routing<P extends string, G extends string, I extends string, F extends string, T extends string = never> = {
|
|
125
157
|
/** Identifies this router to its neighbors. */
|
|
126
158
|
router_id?: IP;
|
|
159
|
+
/** Routing tables beside the main one, keyed by name. Not `main`, and not the name of a VRF. */
|
|
160
|
+
tables?: Record<T, RoutingTable>;
|
|
161
|
+
/** Rules that route matching traffic by another table, such as traffic from one block by its own transits' default. In order: the first that matches decides, and traffic no rule matches is routed by the main table. */
|
|
162
|
+
rules?: readonly RoutingRule<I, NoInfer<T>>[];
|
|
127
163
|
/** Routes the device has without learning them. */
|
|
128
|
-
static?: readonly StaticRoute<I, F
|
|
164
|
+
static?: readonly StaticRoute<I, F, NoInfer<T>>[];
|
|
129
165
|
/** BGP sessions and what they exchange. */
|
|
130
|
-
bgp?: Bgp<P, G
|
|
166
|
+
bgp?: Bgp<P, G, NoInfer<T>>;
|
|
131
167
|
/** OSPF areas and interfaces. */
|
|
132
168
|
ospf?: Ospf<I>;
|
|
133
169
|
/** Validators answering route origin validation. */
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { Check, Finding } from "@takodotid/circuit";
|
|
4
4
|
import { Address } from "@takodotid/circuit";
|
|
5
5
|
import { Checks } from "@takodotid/circuit/helpers";
|
|
6
|
+
import { blocksOf, type Tenant } from "./tenancy";
|
|
6
7
|
import { TENANTS } from "./tenants";
|
|
7
8
|
|
|
8
9
|
export default [
|
|
@@ -15,21 +16,27 @@ export default [
|
|
|
15
16
|
// A native VLAN stays on one trunk and nowhere else, or it joins every trunk it is native on.
|
|
16
17
|
Checks.nativeVlansAreDeadEnds,
|
|
17
18
|
|
|
18
|
-
// Tenants never share a name, a
|
|
19
|
+
// Tenants never share a name, a VLAN, an address or a port. A port given twice would go to the last tenant alone.
|
|
19
20
|
() => {
|
|
20
21
|
const findings: Finding[] = [];
|
|
21
22
|
const error = (message: string) => findings.push({ level: "error", message });
|
|
23
|
+
const vlansOf = (t: Tenant) => [t.internet.vlan, ...(t.private ? [t.private.vlan] : [])];
|
|
22
24
|
|
|
23
25
|
TENANTS.forEach((t, index) => {
|
|
24
26
|
for (const other of TENANTS.slice(index + 1)) {
|
|
25
27
|
if (other.name === t.name) error(`tenant ${t.name} is declared twice`);
|
|
26
|
-
if (other.number === t.number) error(`tenants ${t.name} and ${other.name} share number ${t.number}`);
|
|
27
28
|
|
|
28
|
-
for (const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
for (const vlan of vlansOf(t)) {
|
|
30
|
+
for (const theirs of vlansOf(other)) {
|
|
31
|
+
if (theirs.id === vlan.id)
|
|
32
|
+
error(`VLAN ${vlan.id} is both ${vlan.name} of ${t.name} and ${theirs.name} of ${other.name}`);
|
|
33
|
+
if (theirs.name === vlan.name) error(`VLAN ${vlan.name} is declared by both ${t.name} and ${other.name}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const block of blocksOf(t)) {
|
|
38
|
+
const clash = blocksOf(other).find((prefix) => Address.contains(prefix, block) || Address.contains(block, prefix));
|
|
39
|
+
if (clash) error(`${block} of ${t.name} overlaps ${clash} of ${other.name}`);
|
|
33
40
|
}
|
|
34
41
|
}
|
|
35
42
|
|
|
@@ -39,10 +46,6 @@ export default [
|
|
|
39
46
|
if (sharer) error(`port ${port} on ${device} is given to both ${t.name} and ${sharer.name}`);
|
|
40
47
|
}
|
|
41
48
|
}
|
|
42
|
-
|
|
43
|
-
for (const { prefix, gateway } of t.blocks) {
|
|
44
|
-
if (!Address.contains(prefix, gateway)) error(`the gateway ${gateway} of ${t.name} is outside ${prefix}`);
|
|
45
|
-
}
|
|
46
49
|
});
|
|
47
50
|
|
|
48
51
|
return findings;
|
|
@@ -6,7 +6,7 @@ import type { Prefix } from "@takodotid/circuit";
|
|
|
6
6
|
import { defineDevice, secret, Utils } from "@takodotid/circuit";
|
|
7
7
|
import { Bgp, Filters } from "@takodotid/circuit/helpers";
|
|
8
8
|
import { ASN, communities } from "./routing";
|
|
9
|
-
import { edgeRules, gateways, internetVlanNames,
|
|
9
|
+
import { edgeRules, gateways, internetVlanNames, routerVlans } from "./tenancy";
|
|
10
10
|
import { TENANTS } from "./tenants";
|
|
11
11
|
|
|
12
12
|
/** The address space this router announces. */
|
|
@@ -34,7 +34,7 @@ export default defineDevice({
|
|
|
34
34
|
transit: { id: 300, description: "IP transit from Hurricane Electric, AS6939" },
|
|
35
35
|
ix: { id: 200, description: "Peering LAN of SGIX" },
|
|
36
36
|
},
|
|
37
|
-
|
|
37
|
+
routerVlans(TENANTS)
|
|
38
38
|
),
|
|
39
39
|
|
|
40
40
|
ports: {
|