@takodotid/circuit 0.37.0 → 0.39.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 +129 -14
- package/dist/helpers/index.js +269 -1
- package/dist/types/core/cabling.d.ts +6 -0
- package/dist/types/helpers/bgp.d.ts +46 -3
- package/dist/types/helpers/cables.d.ts +66 -0
- package/dist/types/helpers/index.d.ts +5 -1
- package/dist/types/helpers/upstreams.d.ts +239 -0
- package/dist/types/platforms/bgp-wait.d.ts +6 -0
- package/dist/types/platforms/platform.d.ts +12 -0
- package/dist/types/platforms/routeros/index.d.ts +8 -1
- package/dist/types/schema/interfaces.d.ts +1 -1
- package/package.json +1 -1
- package/src/cli/index.ts +67 -8
- package/src/core/cabling.ts +30 -0
- package/src/helpers/bgp.ts +104 -2
- package/src/helpers/cables.ts +69 -0
- package/src/helpers/index.ts +6 -2
- package/src/helpers/upstreams.ts +439 -0
- package/src/platforms/bgp-wait.ts +24 -1
- package/src/platforms/platform.ts +9 -0
- package/src/platforms/routeros/TRAPS.md +1 -0
- package/src/platforms/routeros/index.ts +57 -10
- package/src/schema/interfaces.ts +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -3748,6 +3748,28 @@ class Cabling {
|
|
|
3748
3748
|
}
|
|
3749
3749
|
return findings;
|
|
3750
3750
|
}
|
|
3751
|
+
static describeLinks(devices) {
|
|
3752
|
+
const byName = new Map(devices.map((device) => [device.name, device]));
|
|
3753
|
+
return devices.map((device) => {
|
|
3754
|
+
const ports = Object.entries(device.ports ?? {});
|
|
3755
|
+
if (!ports.some(([, port]) => port?.link && port.description === undefined))
|
|
3756
|
+
return device;
|
|
3757
|
+
const described = ports.map(([name, port]) => {
|
|
3758
|
+
const far = port?.link ? byName.get(port.link.device) : undefined;
|
|
3759
|
+
if (!port?.link || port.description !== undefined || !far)
|
|
3760
|
+
return [name, port];
|
|
3761
|
+
return [name, { ...port, description: `${far.name} ${Cabling.labelOf(far, port.link.port)}` }];
|
|
3762
|
+
});
|
|
3763
|
+
return { ...device, ports: Object.fromEntries(described) };
|
|
3764
|
+
});
|
|
3765
|
+
}
|
|
3766
|
+
static labelOf(device, port) {
|
|
3767
|
+
try {
|
|
3768
|
+
return Catalog.spell(device.platform, device.model, port);
|
|
3769
|
+
} catch {
|
|
3770
|
+
return port;
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3751
3773
|
static isDevice(system, name) {
|
|
3752
3774
|
return system === name || system.startsWith(`${name}.`);
|
|
3753
3775
|
}
|
|
@@ -6596,6 +6618,20 @@ class BgpWait {
|
|
|
6596
6618
|
static upCount(sessions) {
|
|
6597
6619
|
return sessions.filter((session) => session.established).length;
|
|
6598
6620
|
}
|
|
6621
|
+
static BURST_MINUTES = 5;
|
|
6622
|
+
static limitBursts(sessions) {
|
|
6623
|
+
const closed = sessions.filter((session) => session.limit?.stoppedAt !== undefined && !Number.isNaN(session.limit.stoppedAt)).sort((a, b) => a.limit.stoppedAt - b.limit.stoppedAt);
|
|
6624
|
+
const bursts = [];
|
|
6625
|
+
for (const session of closed) {
|
|
6626
|
+
const last = bursts.at(-1);
|
|
6627
|
+
const previous = last?.at(-1);
|
|
6628
|
+
if (last && previous && session.limit.stoppedAt - previous.limit.stoppedAt <= BgpWait.BURST_MINUTES * 60000)
|
|
6629
|
+
last.push(session);
|
|
6630
|
+
else
|
|
6631
|
+
bursts.push([session]);
|
|
6632
|
+
}
|
|
6633
|
+
return bursts.filter((burst) => burst.length > 1);
|
|
6634
|
+
}
|
|
6599
6635
|
}
|
|
6600
6636
|
|
|
6601
6637
|
// src/platforms/routeros/export.ts
|
|
@@ -8381,9 +8417,15 @@ class RouterOs extends Platform {
|
|
|
8381
8417
|
static LIST_BGP_SESSIONS = [
|
|
8382
8418
|
":foreach s in=[/routing bgp session find] do={",
|
|
8383
8419
|
":local g [/routing bgp session get $s];",
|
|
8384
|
-
':put (($g->"name") . "|" . ($g->"remote.address") . "|" . ($g->"established") . "|" . ($g->"prefix-count") . "|" . ($g->"uptime"))',
|
|
8420
|
+
':put (($g->"name") . "|" . ($g->"remote.address") . "|" . ($g->"established") . "|" . ($g->"prefix-count") . "|" . ($g->"uptime") . "|" . ($g->"limit-exceeded") . "|" . ($g->"input.limit-process-routes") . "|" . ($g->"last-stopped"))',
|
|
8421
|
+
"};",
|
|
8422
|
+
":foreach c in=[/routing bgp connection find disabled] do={",
|
|
8423
|
+
':put ("disabled|" . [/routing bgp connection get $c name] . "|" . [/routing bgp connection get $c remote.address])',
|
|
8385
8424
|
"}"
|
|
8386
8425
|
].join(" ");
|
|
8426
|
+
static clearLimit(address) {
|
|
8427
|
+
return `/routing bgp session clear [find remote.address=${address} and limit-exceeded] flag=limit-exceeded`;
|
|
8428
|
+
}
|
|
8387
8429
|
static LIST_NEIGHBORS = [
|
|
8388
8430
|
':foreach n in=[/ip neighbor find where discovered-by~"lldp"] do={',
|
|
8389
8431
|
":local g [/ip neighbor get $n];",
|
|
@@ -8578,21 +8620,48 @@ ${certificates}` : "";
|
|
|
8578
8620
|
}
|
|
8579
8621
|
async bgpSessions(device, session) {
|
|
8580
8622
|
const output = await session.run(RouterOs.LIST_BGP_SESSIONS, 60000);
|
|
8581
|
-
const
|
|
8623
|
+
const lines = output.split(/\r?\n/).filter((line) => line.includes("|")).map((line) => line.trim().split("|"));
|
|
8624
|
+
const live = lines.filter(([first]) => first !== "disabled");
|
|
8625
|
+
const disabled = lines.filter(([first]) => first === "disabled").map(([, name, address]) => ({ name, address: address?.split("/")[0] }));
|
|
8582
8626
|
return Object.entries(device.routing?.bgp?.neighbors ?? {}).map(([name, neighbor]) => {
|
|
8583
8627
|
const found = live.find(([, remote]) => remote === neighbor.address) ?? live.find(([sessionName]) => sessionName === name || sessionName?.replace(/-\d+$/, "") === name);
|
|
8584
|
-
if (!found)
|
|
8585
|
-
|
|
8586
|
-
|
|
8628
|
+
if (!found) {
|
|
8629
|
+
const off = disabled.some((connection) => connection.address === neighbor.address || connection.name === name);
|
|
8630
|
+
return {
|
|
8631
|
+
neighbor: name,
|
|
8632
|
+
remote: "-",
|
|
8633
|
+
established: false,
|
|
8634
|
+
prefixes: "-",
|
|
8635
|
+
uptime: "no session",
|
|
8636
|
+
...off ? { reason: "admin down" } : {}
|
|
8637
|
+
};
|
|
8638
|
+
}
|
|
8639
|
+
const [, remote, established, prefixes, uptime, limitExceeded, max, stopped] = found;
|
|
8640
|
+
const up = established === "true";
|
|
8641
|
+
const limited = limitExceeded === "true";
|
|
8587
8642
|
return {
|
|
8588
8643
|
neighbor: name,
|
|
8589
8644
|
remote: remote || "-",
|
|
8590
|
-
established:
|
|
8645
|
+
established: up,
|
|
8591
8646
|
prefixes: prefixes || "-",
|
|
8592
|
-
uptime: uptime || "-"
|
|
8647
|
+
uptime: uptime || "-",
|
|
8648
|
+
...up ? {} : { reason: limited ? "limit exceeded" : "connecting" },
|
|
8649
|
+
...limited ? {
|
|
8650
|
+
limit: {
|
|
8651
|
+
...max && /^\d+$/.test(max) ? { max: Number(max) } : {},
|
|
8652
|
+
...stopped ? { stopped, stoppedAt: RouterOs.logTime(stopped) } : {}
|
|
8653
|
+
}
|
|
8654
|
+
} : {}
|
|
8593
8655
|
};
|
|
8594
8656
|
});
|
|
8595
8657
|
}
|
|
8658
|
+
async bgpClearLimit(session, sessions) {
|
|
8659
|
+
for (const bgp of sessions) {
|
|
8660
|
+
const answer = (await session.run(RouterOs.clearLimit(bgp.remote))).trim();
|
|
8661
|
+
if (answer)
|
|
8662
|
+
throw new Error(`could not clear ${bgp.neighbor}: ${answer.slice(0, 200)}`);
|
|
8663
|
+
}
|
|
8664
|
+
}
|
|
8596
8665
|
async bgpLog(session, minutes) {
|
|
8597
8666
|
const now = (await session.run(RouterOs.CLOCK)).trim();
|
|
8598
8667
|
const nowTime = RouterOs.logTime(now);
|
|
@@ -8688,7 +8757,7 @@ ${certificates}` : "";
|
|
|
8688
8757
|
console.log(` BGP: all ${neighbors.length} established again`);
|
|
8689
8758
|
return [];
|
|
8690
8759
|
}
|
|
8691
|
-
return down.map((bgp) => `BGP session ${bgp.neighbor} was established before the apply and is not ${limits.seconds} s after it (${bgp.uptime === "no session" ? "no session" : "not established"}). Look with: circuit bgp ${device.name} --log`);
|
|
8760
|
+
return down.map((bgp) => bgp.limit ? `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} ${bgp.neighbor} --clear-limit` : `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`);
|
|
8692
8761
|
}
|
|
8693
8762
|
static async invalidSwitchRules(session, device) {
|
|
8694
8763
|
const answer = (await session.run(RouterOs.COUNT_INVALID_SWITCH_RULES)).trim();
|
|
@@ -11520,10 +11589,14 @@ class Cli {
|
|
|
11520
11589
|
return new Cli(configPath, network, options);
|
|
11521
11590
|
}
|
|
11522
11591
|
static async load(path) {
|
|
11523
|
-
|
|
11524
|
-
|
|
11525
|
-
|
|
11526
|
-
|
|
11592
|
+
let network;
|
|
11593
|
+
if (process.versions.bun) {
|
|
11594
|
+
network = (await import(path)).default;
|
|
11595
|
+
} else {
|
|
11596
|
+
const { createJiti } = await import("jiti");
|
|
11597
|
+
network = await createJiti(import.meta.url).import(path, { default: true });
|
|
11598
|
+
}
|
|
11599
|
+
return { ...network, devices: Cabling.describeLinks(network.devices) };
|
|
11527
11600
|
}
|
|
11528
11601
|
static fail(message) {
|
|
11529
11602
|
console.error(message);
|
|
@@ -11567,7 +11640,7 @@ class Cli {
|
|
|
11567
11640
|
program.command("snapshot").description("read what each device runs into the state directory").argument("[device...]", "every device when none is named").option("--console", "through the device's serial console on its jump host, instead of SSH").option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP).action(async (devices, flags) => (await Cli.open(flags.config, flags)).snapshot(devices));
|
|
11568
11641
|
program.command("apply").description("read the device, plan against what it runs, and send with --confirm").argument("<device>").option("--confirm", "send the plan; without it, nothing is sent").option("--secrets", "also send every secret, to rotate one").option("--rollback <minutes>", "minutes before a device that can restore itself does", Cli.minutes, 10).option("--console", "through the device's serial console on its jump host, instead of SSH").option("--force", "send even when the pings before --confirm find a path that loses too much or is too slow").option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP).action(async (device, flags) => (await Cli.open(flags.config, flags)).apply(device, flags.confirm ?? false, flags.force ?? false));
|
|
11569
11642
|
program.command("show").description("run commands that only read on a device and print what it answers, secrets hidden").argument("<device>").argument("[command...]", "each command in quotes, such as '/ip route print'").option("--file <path>", "also each line of this file; blank lines and lines starting with # are skipped").option("--timeout <seconds>", "how long each command may take", Cli.seconds, 90).option("--console", "through the device's serial console, instead of SSH").option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP).action(async (device, commands, flags) => (await Cli.open(flags.config, flags)).show(device, commands, flags.file, flags.timeout));
|
|
11570
|
-
program.command("bgp").description("each BGP neighbor in the config next to its live session: up or down, prefixes, uptime").argument("[device]", "may be left out when only one device has BGP neighbors").argument("[name-regex]", "only the neighbors whose name matches").option("--wait [count]", `read again every ${BgpWait.COMMAND.every} seconds until every neighbor shown, or this many, is up`, Cli.count).option("--max <seconds>", "how long --wait waits at most", Cli.seconds, BgpWait.COMMAND.seconds).option("--log [minutes]", "also print the device's BGP log lines from the last minutes, 5 by default", Cli.minutes).option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP).action(async (first, second, flags) => (await Cli.open(flags.config)).bgp(first, second, flags));
|
|
11643
|
+
program.command("bgp").description("each BGP neighbor in the config next to its live session: up or down and why, prefixes, uptime").argument("[device]", "may be left out when only one device has BGP neighbors").argument("[name-regex]", "only the neighbors whose name matches").option("--wait [count]", `read again every ${BgpWait.COMMAND.every} seconds until every neighbor shown, or this many, is up`, Cli.count).option("--max <seconds>", "how long --wait waits at most", Cli.seconds, BgpWait.COMMAND.seconds).option("--log [minutes]", "also print the device's BGP log lines from the last minutes, 5 by default", Cli.minutes).option("--clear-limit", "open again the neighbors shown that closed on their prefix limit; any other is left alone").option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP).action(async (first, second, flags) => (await Cli.open(flags.config)).bgp(first, second, flags));
|
|
11571
11644
|
program.command("console").description("open a device's serial console here, logged in; or send it lines and print what it answers").argument("<device>").argument("[line...]", "lines to send, each in quotes; none joins the console to this terminal, Ctrl-] leaves").option("--raw", "do not log in or out: only join the console, or send the lines").option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP).action(async (device, lines, flags) => (await Cli.open(flags.config)).console(device, lines, flags.raw ?? false));
|
|
11572
11645
|
program.command("refresh").description("fetch prefix sets that come from a registry onto the device, with --confirm").argument("<device>").option("--confirm", "put them on the device; without it, nothing is sent").option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP).action(async (device, flags) => (await Cli.open(flags.config)).refresh(device, flags.confirm ?? false));
|
|
11573
11646
|
program.command("wireguard").description("a client config for one peer, its private key left for the peer to fill").argument("<device>").argument("<interface>").argument("<peer>").option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP).action(async (device, iface, peer, flags) => (await Cli.open(flags.config)).wireguard(device, iface, peer));
|
|
@@ -11797,6 +11870,10 @@ ${output.replace(/\r/g, "").trimEnd()}${stoppedAtLimit}
|
|
|
11797
11870
|
Cli.fail(`circuit bgp does not support ${device.platform} yet, only ${supported}`);
|
|
11798
11871
|
if (flags.log && !adapter.bgpLog)
|
|
11799
11872
|
Cli.fail(`circuit bgp --log does not support ${device.platform} yet`);
|
|
11873
|
+
if (flags.clearLimit && !adapter.bgpClearLimit)
|
|
11874
|
+
Cli.fail(`circuit bgp --clear-limit does not support ${device.platform} yet`);
|
|
11875
|
+
if (flags.clearLimit && flags.wait !== undefined)
|
|
11876
|
+
Cli.fail("--clear-limit and --wait go in two runs: clear first, then wait");
|
|
11800
11877
|
let matcher;
|
|
11801
11878
|
try {
|
|
11802
11879
|
matcher = new RegExp(pattern ?? ".");
|
|
@@ -11813,6 +11890,10 @@ ${output.replace(/\r/g, "").trimEnd()}${stoppedAtLimit}
|
|
|
11813
11890
|
const session = await adapter.session(device, this.optionsFor(device));
|
|
11814
11891
|
try {
|
|
11815
11892
|
const read = async () => (await adapter.bgpSessions(device, session)).filter((neighbor) => shown.includes(neighbor.neighbor));
|
|
11893
|
+
if (flags.clearLimit) {
|
|
11894
|
+
await Cli.clearLimits(adapter, session, read);
|
|
11895
|
+
return;
|
|
11896
|
+
}
|
|
11816
11897
|
const limits = { seconds: flags.max, every: BgpWait.COMMAND.every };
|
|
11817
11898
|
const sessions = await BgpWait.until(read, (sessions) => BgpWait.upCount(sessions) >= want, limits, (sessions) => console.log(`${new Date().toLocaleTimeString()}: ${BgpWait.upCount(sessions)}/${want} up, checking again in ${limits.every}s`));
|
|
11818
11899
|
Cli.printBgp(sessions);
|
|
@@ -11824,6 +11905,24 @@ ${output.replace(/\r/g, "").trimEnd()}${stoppedAtLimit}
|
|
|
11824
11905
|
session.close();
|
|
11825
11906
|
}
|
|
11826
11907
|
}
|
|
11908
|
+
static async clearLimits(adapter, session, read) {
|
|
11909
|
+
const sessions = await read();
|
|
11910
|
+
const closed = sessions.filter((bgp) => bgp.limit);
|
|
11911
|
+
if (!closed.length) {
|
|
11912
|
+
Cli.printBgp(sessions);
|
|
11913
|
+
console.error(`
|
|
11914
|
+
no neighbor shown is closed on its prefix limit, so nothing was cleared`);
|
|
11915
|
+
process.exitCode = 1;
|
|
11916
|
+
return;
|
|
11917
|
+
}
|
|
11918
|
+
for (const bgp of sessions)
|
|
11919
|
+
if (!bgp.limit)
|
|
11920
|
+
console.log(`${bgp.neighbor}: left alone, not closed on its limit`);
|
|
11921
|
+
await adapter.bgpClearLimit(session, closed);
|
|
11922
|
+
console.log(`cleared ${closed.map((bgp) => bgp.neighbor).join(", ")}: each connects again, and closes again if it still sends too much
|
|
11923
|
+
`);
|
|
11924
|
+
Cli.printBgp(await read());
|
|
11925
|
+
}
|
|
11827
11926
|
bgpTarget(first, second) {
|
|
11828
11927
|
if (second !== undefined || first && this.network.devices.some((device) => device.name === first)) {
|
|
11829
11928
|
return { device: this.one(first), pattern: second };
|
|
@@ -11840,9 +11939,25 @@ ${output.replace(/\r/g, "").trimEnd()}${stoppedAtLimit}
|
|
|
11840
11939
|
const width = Math.max(...sessions.map((session) => session.neighbor.length));
|
|
11841
11940
|
for (const session of sessions) {
|
|
11842
11941
|
const state = session.established ? "up " : "DOWN";
|
|
11843
|
-
|
|
11942
|
+
const why = session.reason ? ` ${Cli.downReason(session)}` : "";
|
|
11943
|
+
console.log(`${session.neighbor.padEnd(width)} ${state} ${session.prefixes.padStart(7)} ${session.uptime.padEnd(12)} ${session.remote}${why}`);
|
|
11844
11944
|
}
|
|
11845
11945
|
console.log(`${BgpWait.upCount(sessions)}/${sessions.length} up`);
|
|
11946
|
+
for (const burst of BgpWait.limitBursts(sessions)) {
|
|
11947
|
+
const names = burst.map((session) => session.neighbor);
|
|
11948
|
+
const listed = names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` : names[0];
|
|
11949
|
+
const seconds = Math.round((burst.at(-1).limit.stoppedAt - burst[0].limit.stoppedAt) / 1000);
|
|
11950
|
+
console.log(`
|
|
11951
|
+
${listed} closed on their prefix limit within ${seconds} s of each other, from ${burst[0].limit.stopped}. Several limits at once is usually a leak upstream, not growth: find the leak before raising a limit, then clear them with --clear-limit.`);
|
|
11952
|
+
}
|
|
11953
|
+
}
|
|
11954
|
+
static downReason(session) {
|
|
11955
|
+
const { limit } = session;
|
|
11956
|
+
if (!limit)
|
|
11957
|
+
return session.reason ?? "";
|
|
11958
|
+
const over = limit.max !== undefined ? `: over ${limit.max} prefixes` : "";
|
|
11959
|
+
const when = limit.stopped ? `, closed ${limit.stopped}` : "";
|
|
11960
|
+
return `${session.reason}${over}${when}`;
|
|
11846
11961
|
}
|
|
11847
11962
|
async printBgpLog(adapter, session, minutes) {
|
|
11848
11963
|
const lines = Cli.foldLog(await adapter.bgpLog(session, minutes));
|
package/dist/helpers/index.js
CHANGED
|
@@ -9,6 +9,10 @@ class Bgp {
|
|
|
9
9
|
["twice", 2],
|
|
10
10
|
["three_times", 3]
|
|
11
11
|
];
|
|
12
|
+
static DEFAULTS = [
|
|
13
|
+
{ family: "ipv4", prefix: "0.0.0.0/0" },
|
|
14
|
+
{ family: "ipv6", prefix: "::/0" }
|
|
15
|
+
];
|
|
12
16
|
static sanity(lengths = {}) {
|
|
13
17
|
const ipv4 = lengths.ipv4 ?? { min: 8, max: 24 };
|
|
14
18
|
const ipv6 = lengths.ipv6 ?? { min: 16, max: 48 };
|
|
@@ -36,6 +40,49 @@ class Bgp {
|
|
|
36
40
|
{ description: "RPKI invalid", match: { rpki: "invalid" }, action: "reject" }
|
|
37
41
|
];
|
|
38
42
|
}
|
|
43
|
+
static announce(prefixes) {
|
|
44
|
+
return [
|
|
45
|
+
...prefixes.map(({ prefix, description }) => ({
|
|
46
|
+
...description ? { description } : {},
|
|
47
|
+
match: { prefix },
|
|
48
|
+
action: "accept"
|
|
49
|
+
})),
|
|
50
|
+
{ description: "Nothing else", action: "reject" }
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
static learn(options = {}) {
|
|
54
|
+
const { tag } = options;
|
|
55
|
+
return [
|
|
56
|
+
...options.before ?? [],
|
|
57
|
+
...options.sanity ? [{ call: options.sanity }] : Bgp.sanity(),
|
|
58
|
+
...(options.sources ?? []).map((source) => ({ set: { preferred_source: source } })),
|
|
59
|
+
...options.local_pref !== undefined ? [{ set: { local_pref: options.local_pref } }] : [],
|
|
60
|
+
...tag === undefined ? [] : [typeof tag === "object" ? tag : Bgp.adding(tag)],
|
|
61
|
+
{ action: "accept" }
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
static defaultOnly(options = {}) {
|
|
65
|
+
return Bgp.DEFAULTS.map(({ family, prefix }) => {
|
|
66
|
+
const source = options.sources?.find((address) => Address2.family(address) === family);
|
|
67
|
+
const set = {
|
|
68
|
+
...options.local_pref !== undefined ? { local_pref: options.local_pref } : {},
|
|
69
|
+
...source ? { preferred_source: source } : {}
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
description: options.description ?? `Default route, ${family}`,
|
|
73
|
+
match: { prefix },
|
|
74
|
+
...Object.keys(set).length ? { set } : {},
|
|
75
|
+
action: "accept"
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
static adding(community) {
|
|
80
|
+
const large = community.split(":").length === 3;
|
|
81
|
+
return {
|
|
82
|
+
description: `Tag ${community}`,
|
|
83
|
+
set: large ? { add_large_communities: [community] } : { add_communities: [community] }
|
|
84
|
+
};
|
|
85
|
+
}
|
|
39
86
|
static neighborPolicies(device) {
|
|
40
87
|
const bgp = device.routing?.bgp;
|
|
41
88
|
return Object.entries(bgp?.neighbors ?? {}).map(([name, neighbor]) => {
|
|
@@ -181,6 +228,37 @@ class Bgp {
|
|
|
181
228
|
return /^[aeiou]/i.test(word) ? `an ${word}` : `a ${word}`;
|
|
182
229
|
}
|
|
183
230
|
}
|
|
231
|
+
// src/helpers/cables.ts
|
|
232
|
+
class Cables {
|
|
233
|
+
static link(spec) {
|
|
234
|
+
return {
|
|
235
|
+
a: { [spec.a.port]: Cables.end(spec, spec.b.device, spec.b.port) },
|
|
236
|
+
b: { [spec.b.port]: Cables.end(spec, spec.a.device, spec.a.port) }
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
static lag(spec) {
|
|
240
|
+
const pairs = Object.entries(spec.cables);
|
|
241
|
+
const taken = new Map;
|
|
242
|
+
for (const [near, far] of pairs) {
|
|
243
|
+
const other = taken.get(far);
|
|
244
|
+
if (other)
|
|
245
|
+
throw new Error(`Cables.lag: ${spec.b.device} ${far} is cabled to both ${other} and ${near} on ${spec.a.device}`);
|
|
246
|
+
taken.set(far, near);
|
|
247
|
+
}
|
|
248
|
+
const a = Object.fromEntries(pairs.map(([near, far]) => [near, { ...Cables.end(spec, spec.b.device, far), lag: spec.a.lag }]));
|
|
249
|
+
const b = Object.fromEntries(pairs.map(([near, far]) => [far, { ...Cables.end(spec, spec.a.device, near), lag: spec.b.lag }]));
|
|
250
|
+
return {
|
|
251
|
+
a,
|
|
252
|
+
b
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
static end(shared, device, port) {
|
|
256
|
+
return {
|
|
257
|
+
link: { device, port, ...shared.through ? { through: shared.through } : {} },
|
|
258
|
+
...shared.describe ? { description: shared.describe({ device, port }) } : {}
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
184
262
|
// src/helpers/checks.ts
|
|
185
263
|
class Checks {
|
|
186
264
|
static exportsEndInReject = (devices) => {
|
|
@@ -585,10 +663,200 @@ class Filters {
|
|
|
585
663
|
}));
|
|
586
664
|
}
|
|
587
665
|
}
|
|
666
|
+
// src/helpers/upstreams.ts
|
|
667
|
+
var DEFAULTS = {
|
|
668
|
+
vlan: "{key}",
|
|
669
|
+
native: "{key}-native",
|
|
670
|
+
interface: "{key}",
|
|
671
|
+
import: "{KEY}-IN",
|
|
672
|
+
export: "{KEY}-OUT",
|
|
673
|
+
group: "{key}"
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
class Upstreams {
|
|
677
|
+
static define(catalog, options = {}) {
|
|
678
|
+
const names = options.names ?? {};
|
|
679
|
+
const all = Object.entries(catalog);
|
|
680
|
+
const nameOf = (key, upstream) => upstream.name ?? key;
|
|
681
|
+
const grouped = (upstream) => upstream.role === "rs" || Boolean(upstream.multihop);
|
|
682
|
+
const named = (field, key, upstream) => {
|
|
683
|
+
const given = names[field];
|
|
684
|
+
const pattern = given === undefined || typeof given === "string" ? given ?? DEFAULTS[field] : (upstream.tunnel && "tunnel" in given ? given.tunnel : given[upstream.role]) ?? DEFAULTS[field];
|
|
685
|
+
return pattern.replaceAll("{key}", key).replaceAll("{KEY}", key.toUpperCase()).replaceAll("{number}", () => {
|
|
686
|
+
const number = upstream.handoff?.number ?? upstream.tunnel?.number;
|
|
687
|
+
if (number === undefined)
|
|
688
|
+
throw new Error(`Upstreams: ${key} needs a number for ${field} name ${pattern}`);
|
|
689
|
+
return String(number);
|
|
690
|
+
});
|
|
691
|
+
};
|
|
692
|
+
for (const field of Object.keys(DEFAULTS)) {
|
|
693
|
+
const taken = new Map;
|
|
694
|
+
for (const [key, upstream] of all) {
|
|
695
|
+
if (field === "native" && upstream.handoff?.native === undefined)
|
|
696
|
+
continue;
|
|
697
|
+
if (field === "import" && !upstream.import)
|
|
698
|
+
continue;
|
|
699
|
+
if (field === "group" && !grouped(upstream))
|
|
700
|
+
continue;
|
|
701
|
+
if (field === "vlan" && !upstream.handoff || field === "interface" && !upstream.handoff && !upstream.tunnel)
|
|
702
|
+
continue;
|
|
703
|
+
const name = named(field, key, upstream);
|
|
704
|
+
const other = taken.get(name);
|
|
705
|
+
if (other)
|
|
706
|
+
throw new Error(`Upstreams: ${other} and ${key} both have the ${field} name ${name}`);
|
|
707
|
+
taken.set(name, key);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
const vlan = (key, upstream) => named("vlan", key, upstream);
|
|
711
|
+
const native = (key, upstream) => named("native", key, upstream);
|
|
712
|
+
const policy = (direction, key, upstream) => named(direction, key, upstream);
|
|
713
|
+
const vlans = (device) => {
|
|
714
|
+
const result = {};
|
|
715
|
+
for (const [key, upstream] of all) {
|
|
716
|
+
const handoff = upstream.handoff;
|
|
717
|
+
if (!handoff || device !== undefined && handoff.port?.device !== device)
|
|
718
|
+
continue;
|
|
719
|
+
result[vlan(key, upstream)] = { id: handoff.vlan, description: nameOf(key, upstream) };
|
|
720
|
+
if (device !== undefined && handoff.native !== undefined) {
|
|
721
|
+
result[native(key, upstream)] = {
|
|
722
|
+
id: handoff.native,
|
|
723
|
+
description: `Native toward ${nameOf(key, upstream)}, nothing else in it`
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return result;
|
|
728
|
+
};
|
|
729
|
+
const vlansOn = (device) => all.filter(([, upstream]) => upstream.handoff?.port?.device === device).map(([key, upstream]) => vlan(key, upstream));
|
|
730
|
+
const ports = (device) => {
|
|
731
|
+
const result = {};
|
|
732
|
+
for (const [key, upstream] of all) {
|
|
733
|
+
const handoff = upstream.handoff;
|
|
734
|
+
if (!handoff?.port || handoff.port.device !== device)
|
|
735
|
+
continue;
|
|
736
|
+
result[handoff.port.port] = {
|
|
737
|
+
description: names.port ? names.port(key, upstream) : nameOf(key, upstream),
|
|
738
|
+
...handoff.native !== undefined ? { trunk_vlans: [vlan(key, upstream)], native_vlan: native(key, upstream) } : { access_vlan: vlan(key, upstream) },
|
|
739
|
+
lldp: false,
|
|
740
|
+
stp: false,
|
|
741
|
+
...handoff.port.storm_control ? { storm_control: handoff.port.storm_control } : {}
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
return result;
|
|
745
|
+
};
|
|
746
|
+
const interfaces = () => {
|
|
747
|
+
const result = {};
|
|
748
|
+
for (const [key, upstream] of all) {
|
|
749
|
+
const description = nameOf(key, upstream);
|
|
750
|
+
const name = named("interface", key, upstream);
|
|
751
|
+
if (upstream.tunnel) {
|
|
752
|
+
const { local, remote, addresses, mtu } = upstream.tunnel;
|
|
753
|
+
result[name] = { type: "gre", description, local, remote, addresses, ...mtu ? { mtu } : {} };
|
|
754
|
+
} else if (upstream.handoff) {
|
|
755
|
+
const { addresses, exchange } = upstream.handoff;
|
|
756
|
+
result[name] = { type: "vlan", vlan: vlan(key, upstream), description, addresses, ...exchange ? { exchange } : {} };
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return result;
|
|
760
|
+
};
|
|
761
|
+
const uplinks = () => all.filter(([, upstream]) => upstream.handoff).map(([key, upstream]) => named("interface", key, upstream));
|
|
762
|
+
const settings = (key, upstream) => ({
|
|
763
|
+
remote_as: upstream.asn,
|
|
764
|
+
description: nameOf(key, upstream),
|
|
765
|
+
role: upstream.role,
|
|
766
|
+
...upstream.import ? { import: policy("import", key, upstream) } : {},
|
|
767
|
+
export: policy("export", key, upstream)
|
|
768
|
+
});
|
|
769
|
+
const groups = () => Object.fromEntries(all.filter(([, upstream]) => grouped(upstream)).map(([key, upstream]) => {
|
|
770
|
+
const multihop = upstream.multihop;
|
|
771
|
+
return [
|
|
772
|
+
named("group", key, upstream),
|
|
773
|
+
{
|
|
774
|
+
...settings(key, upstream),
|
|
775
|
+
...multihop ? {
|
|
776
|
+
local_address: multihop.local_address,
|
|
777
|
+
multihop: true,
|
|
778
|
+
...multihop.families ? { families: multihop.families } : {},
|
|
779
|
+
...multihop.password ? { password: multihop.password } : {}
|
|
780
|
+
} : {}
|
|
781
|
+
}
|
|
782
|
+
];
|
|
783
|
+
}));
|
|
784
|
+
const neighbors = () => Object.fromEntries(all.flatMap(([key, upstream]) => Object.entries(upstream.sessions).map(([name, session]) => {
|
|
785
|
+
const limit = upstream.limits?.[Address2.family(session.address)];
|
|
786
|
+
return [
|
|
787
|
+
name,
|
|
788
|
+
{
|
|
789
|
+
...session,
|
|
790
|
+
...grouped(upstream) ? { group: named("group", key, upstream) } : settings(key, upstream),
|
|
791
|
+
...limit ? { max_prefixes: limit } : {}
|
|
792
|
+
}
|
|
793
|
+
];
|
|
794
|
+
})));
|
|
795
|
+
const policies = (options = {}) => {
|
|
796
|
+
const result = {};
|
|
797
|
+
for (const [key, upstream] of all) {
|
|
798
|
+
result[policy("export", key, upstream)] = [
|
|
799
|
+
...options.actions?.(key, upstream) ?? [],
|
|
800
|
+
...upstream.export ?? [],
|
|
801
|
+
...Bgp.announce(upstream.announce)
|
|
802
|
+
];
|
|
803
|
+
const taken = upstream.import;
|
|
804
|
+
if (!taken)
|
|
805
|
+
continue;
|
|
806
|
+
const tag = options.tag?.(key, upstream);
|
|
807
|
+
result[policy("import", key, upstream)] = [
|
|
808
|
+
...taken.default ? Bgp.defaultOnly({ ...taken.default, ...options.sources ? { sources: options.sources } : {} }) : [],
|
|
809
|
+
...taken.learn ? Bgp.learn({
|
|
810
|
+
...taken.learn.before ? { before: taken.learn.before } : {},
|
|
811
|
+
...options.sanity ? { sanity: options.sanity } : {},
|
|
812
|
+
...options.sources ? { sources: options.sources } : {},
|
|
813
|
+
...taken.learn.local_pref !== undefined ? { local_pref: taken.learn.local_pref } : {},
|
|
814
|
+
...tag !== undefined ? { tag } : {}
|
|
815
|
+
}) : [{ description: "Nothing else", action: "reject" }]
|
|
816
|
+
];
|
|
817
|
+
}
|
|
818
|
+
return result;
|
|
819
|
+
};
|
|
820
|
+
const untrusted = () => [
|
|
821
|
+
...all.filter(([, upstream]) => upstream.handoff).map(([key, upstream]) => vlan(key, upstream)),
|
|
822
|
+
...natives()
|
|
823
|
+
];
|
|
824
|
+
const natives = () => all.filter(([, upstream]) => upstream.handoff?.native !== undefined).map(([key, upstream]) => native(key, upstream));
|
|
825
|
+
const privateDrops = () => all.filter(([, upstream]) => upstream.handoff && upstream.drop_private).map(([key, upstream]) => vlan(key, upstream));
|
|
826
|
+
const tunnelRules = () => all.flatMap(([key, upstream]) => upstream.tunnel ? [
|
|
827
|
+
{
|
|
828
|
+
description: `GRE from ${nameOf(key, upstream)}`,
|
|
829
|
+
match: {
|
|
830
|
+
protocol: "gre",
|
|
831
|
+
src: `${upstream.tunnel.remote}/${Address2.bits(Address2.family(upstream.tunnel.remote))}`
|
|
832
|
+
},
|
|
833
|
+
action: "accept"
|
|
834
|
+
}
|
|
835
|
+
] : []);
|
|
836
|
+
const addresses = () => all.flatMap(([, upstream]) => Object.values(upstream.sessions).map((session) => session.address));
|
|
837
|
+
return {
|
|
838
|
+
vlans,
|
|
839
|
+
vlansOn,
|
|
840
|
+
ports,
|
|
841
|
+
interfaces,
|
|
842
|
+
uplinks,
|
|
843
|
+
groups,
|
|
844
|
+
neighbors,
|
|
845
|
+
policies,
|
|
846
|
+
untrusted,
|
|
847
|
+
natives,
|
|
848
|
+
privateDrops,
|
|
849
|
+
tunnelRules,
|
|
850
|
+
addresses
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
}
|
|
588
854
|
export {
|
|
589
855
|
Bgp,
|
|
856
|
+
Cables,
|
|
590
857
|
Checks,
|
|
591
858
|
Exchange,
|
|
592
859
|
Filters,
|
|
593
|
-
RouteServerConfig
|
|
860
|
+
RouteServerConfig,
|
|
861
|
+
Upstreams
|
|
594
862
|
};
|
|
@@ -31,6 +31,12 @@ export declare class Cabling {
|
|
|
31
31
|
* Only ports where both ends run LLDP, by the config, are compared, and only on devices whose neighbors were read. A link `through` a carrier is not compared. LLDP can be off at the far end, so each is a warning.
|
|
32
32
|
*/
|
|
33
33
|
static compare(devices: readonly Device[], neighbors: ReadonlyMap<string, readonly Neighbor[]>): Finding[];
|
|
34
|
+
/**
|
|
35
|
+
* 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.
|
|
36
|
+
*/
|
|
37
|
+
static describeLinks(devices: readonly Device[]): Device[];
|
|
38
|
+
/** A port as printed on the device, or Circuit's name for it when the model has no spelling for it. */
|
|
39
|
+
private static labelOf;
|
|
34
40
|
/** A system name is the device's name, alone or with a domain after it. */
|
|
35
41
|
private static isDevice;
|
|
36
42
|
private static deviceOf;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Community } from "../core/define.js";
|
|
2
|
-
import type { Device, PolicyRule, Prefix, StandardCommunity } from "../schema/index.js";
|
|
2
|
+
import type { Device, IP, LargeCommunity, PolicyRule, Prefix, StandardCommunity } from "../schema/index.js";
|
|
3
3
|
/** The prefix lengths a BGP import accepts. */
|
|
4
4
|
export type SanityLengths = {
|
|
5
5
|
/** Shortest and longest IPv4 prefix accepted. /8 to /24 when absent. */
|
|
@@ -13,6 +13,33 @@ export type SanityLengths = {
|
|
|
13
13
|
max: number;
|
|
14
14
|
};
|
|
15
15
|
};
|
|
16
|
+
/** A prefix we announce, and what it is. */
|
|
17
|
+
export type Announced = {
|
|
18
|
+
prefix: Prefix;
|
|
19
|
+
description?: string;
|
|
20
|
+
};
|
|
21
|
+
/** What `Bgp.learn` builds an import from. */
|
|
22
|
+
export type LearnOptions<P extends string> = {
|
|
23
|
+
/** Rules of your own, first. */
|
|
24
|
+
before?: readonly PolicyRule<P, never>[];
|
|
25
|
+
/** A policy holding `Bgp.sanity()` to call. The sanity rules are written in the import itself when absent. */
|
|
26
|
+
sanity?: P;
|
|
27
|
+
/** The source address for routes of its family, one per family. */
|
|
28
|
+
sources?: readonly IP[];
|
|
29
|
+
/** Preference for the routes learned. Higher wins. */
|
|
30
|
+
local_pref?: number;
|
|
31
|
+
/** Where the route came from: a community to add, or a rule, such as `communityScheme(...).tag(...)`. */
|
|
32
|
+
tag?: StandardCommunity | LargeCommunity | PolicyRule<never, never>;
|
|
33
|
+
};
|
|
34
|
+
/** What `Bgp.defaultOnly` sets on the default routes it takes. */
|
|
35
|
+
export type DefaultOnlyOptions = {
|
|
36
|
+
/** Preference for the default route. Higher wins. */
|
|
37
|
+
local_pref?: number;
|
|
38
|
+
/** Shown beside each rule. `Default route, ipv4` and `Default route, ipv6` when absent. */
|
|
39
|
+
description?: string;
|
|
40
|
+
/** The source address for the default of its family, one per family. */
|
|
41
|
+
sources?: readonly IP[];
|
|
42
|
+
};
|
|
16
43
|
/** Kinds of neighbor and the number each is tagged with, such as `{ transit: 1, customer: 3 }`. */
|
|
17
44
|
export type CommunityClasses = Readonly<Record<string, number>>;
|
|
18
45
|
/**
|
|
@@ -55,15 +82,31 @@ export type CommunityScheme<Asn extends number = number, LearnedFrom extends num
|
|
|
55
82
|
};
|
|
56
83
|
/** The class names a scheme declares, or any name when it declares none. */
|
|
57
84
|
type ClassName<Classes extends CommunityClasses> = {} extends Classes ? string : Extract<keyof Classes, string>;
|
|
58
|
-
/** BGP policy: an import sanity policy, a community scheme, and helpers that read a device's BGP neighbors. */
|
|
85
|
+
/** BGP policy: an import sanity policy, the shapes most imports and exports take, a community scheme, and helpers that read a device's BGP neighbors. */
|
|
59
86
|
export declare class Bgp {
|
|
60
87
|
private static readonly PREPENDS;
|
|
88
|
+
/** The default route of each family. */
|
|
89
|
+
private static readonly DEFAULTS;
|
|
61
90
|
/** Route policy rules rejecting what no neighbor should send: prefixes too short or too long for the global table, and RPKI invalid. Place it first in an import, or as a policy other imports call. */
|
|
62
91
|
static sanity(lengths?: SanityLengths): PolicyRule<never, never>[];
|
|
92
|
+
/** An export policy that announces each prefix, at exactly its length, then rejects everything else. The last rule is the unconditional reject `Checks.exportsEndInReject` looks for. */
|
|
93
|
+
static announce(prefixes: readonly Announced[]): PolicyRule<never, never>[];
|
|
94
|
+
/**
|
|
95
|
+
* An import policy that takes a neighbor's routes: `before` first, then the sanity rules, then each source address and the local preference, then the tag, then accept.
|
|
96
|
+
*
|
|
97
|
+
* `sanity` names a policy holding `Bgp.sanity()` to call, so several imports share one; without it, the sanity rules are written here. `tag` is a community to add, or a rule such as `communityScheme(...).tag(...)`. Each of `sources` is the source address for routes of its own family.
|
|
98
|
+
*/
|
|
99
|
+
static learn<const P extends string = never>(options?: LearnOptions<P>): PolicyRule<P, never>[];
|
|
100
|
+
/**
|
|
101
|
+
* Import rules that take the default route, `0.0.0.0/0` and `::/0`, from a transit. Any other route reaches the next rule, or is rejected when there is none. Each of `sources` is the source address for the default of its own family.
|
|
102
|
+
*/
|
|
103
|
+
static defaultOnly(options?: DefaultOnlyOptions): PolicyRule<never, never>[];
|
|
104
|
+
/** A rule adding one community, standard or large. */
|
|
105
|
+
private static adding;
|
|
63
106
|
/** Each BGP neighbor's own policies and role, or its group's. */
|
|
64
107
|
static neighborPolicies(device: Device): {
|
|
65
108
|
name: string;
|
|
66
|
-
address:
|
|
109
|
+
address: IP;
|
|
67
110
|
role: import("../index.js").BgpRole | undefined;
|
|
68
111
|
import: string | undefined;
|
|
69
112
|
export: string | undefined;
|