@takodotid/circuit 0.42.1 → 0.43.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-20kcjxjg.js → index-3b343xva.js} +3 -0
- package/dist/cli/index.js +109 -54
- package/dist/helpers/index.js +43 -11
- package/dist/index.js +1 -1
- package/dist/types/core/utils.d.ts +2 -0
- package/dist/types/helpers/bgp.d.ts +23 -12
- package/dist/types/helpers/index.d.ts +1 -1
- package/dist/types/helpers/upstreams.d.ts +15 -4
- package/dist/types/platforms/fortios/plan.d.ts +15 -5
- package/dist/types/schema/routing.d.ts +4 -4
- package/package.json +1 -1
- package/src/core/utils.ts +5 -0
- package/src/core/validator.ts +10 -5
- package/src/helpers/bgp.ts +50 -30
- package/src/helpers/index.ts +10 -1
- package/src/helpers/upstreams.ts +43 -4
- package/src/platforms/derive.ts +4 -3
- package/src/platforms/fortios/plan.ts +132 -44
- package/src/platforms/routeros/index.ts +11 -3
- package/src/platforms/routeros/render.ts +3 -2
- package/src/schema/routing.ts +4 -4
package/dist/cli/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import {
|
|
3
3
|
Utils2,
|
|
4
4
|
Secrets2
|
|
5
|
-
} from "../chunks/index-
|
|
5
|
+
} from "../chunks/index-3b343xva.js";
|
|
6
6
|
import {
|
|
7
7
|
Topology,
|
|
8
8
|
AddressSpace,
|
|
@@ -549,7 +549,7 @@ class Derive {
|
|
|
549
549
|
for (const route of routing?.static ?? []) {
|
|
550
550
|
if (route.bfd && !has("static-route-bfd"))
|
|
551
551
|
found.push(`routing.static ${route.prefix} bfd, BFD on a static route is ${unproved}`);
|
|
552
|
-
if (route.table && !has("routing-tables"))
|
|
552
|
+
if (Utils2.otherTable(route.table) && !has("routing-tables"))
|
|
553
553
|
found.push(`routing.static ${route.prefix} table, a routing table beside the main one is ${unproved}`);
|
|
554
554
|
}
|
|
555
555
|
if (!has("routing-tables")) {
|
|
@@ -560,11 +560,11 @@ class Derive {
|
|
|
560
560
|
}
|
|
561
561
|
const bgp = routing?.bgp;
|
|
562
562
|
for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
|
|
563
|
-
if (group.table && !has("routing-tables"))
|
|
563
|
+
if (Utils2.otherTable(group.table) && !has("routing-tables"))
|
|
564
564
|
found.push(`routing.bgp.groups.${name}.table, a routing table beside the main one is ${unproved}`);
|
|
565
565
|
}
|
|
566
566
|
for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
|
|
567
|
-
if (neighbor.table && !has("routing-tables"))
|
|
567
|
+
if (Utils2.otherTable(neighbor.table) && !has("routing-tables"))
|
|
568
568
|
found.push(`routing.bgp.neighbors.${name}.table, a routing table beside the main one is ${unproved}`);
|
|
569
569
|
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
570
570
|
const bfd = neighbor.bfd ?? group?.bfd;
|
|
@@ -1611,7 +1611,9 @@ class FortiOsPlan {
|
|
|
1611
1611
|
"Fortinet_SSL_ED25519",
|
|
1612
1612
|
"Fortinet_SSL_ED448"
|
|
1613
1613
|
];
|
|
1614
|
-
static
|
|
1614
|
+
static BY_COMMENTS = { keys: [["comments"]] };
|
|
1615
|
+
static ORDERED = { ordered: true, numbered: FortiOsPlan.BY_COMMENTS };
|
|
1616
|
+
static ROUTES = { numbered: { keys: [["comment"]] }, distinct: true };
|
|
1615
1617
|
static TABLES = {
|
|
1616
1618
|
"system interface": {
|
|
1617
1619
|
factory: ["modem", "ssl.root"],
|
|
@@ -1636,8 +1638,11 @@ class FortiOsPlan {
|
|
|
1636
1638
|
"firewall local-in-policy": FortiOsPlan.ORDERED,
|
|
1637
1639
|
"firewall local-in-policy6": FortiOsPlan.ORDERED,
|
|
1638
1640
|
"firewall central-snat-map": FortiOsPlan.ORDERED,
|
|
1639
|
-
"
|
|
1640
|
-
"
|
|
1641
|
+
"firewall acl": { numbered: FortiOsPlan.BY_COMMENTS },
|
|
1642
|
+
"firewall acl6": { numbered: FortiOsPlan.BY_COMMENTS },
|
|
1643
|
+
"firewall vip": { numbered: { keys: [["comment"], ["extintf", "extip", "protocol", "extport"]], prefix: "dnat-" } },
|
|
1644
|
+
"router static": FortiOsPlan.ROUTES,
|
|
1645
|
+
"router static6": FortiOsPlan.ROUTES
|
|
1641
1646
|
};
|
|
1642
1647
|
static IGNORED = new Set(["uuid", "snmp-index"]);
|
|
1643
1648
|
static IGNORED_IN = {
|
|
@@ -1798,7 +1803,7 @@ class FortiOsPlan {
|
|
|
1798
1803
|
}
|
|
1799
1804
|
for (const [header, child] of want.blocks)
|
|
1800
1805
|
nested(header, child, have.blocks.get(header) ?? new FortiOsBlock);
|
|
1801
|
-
const entries = table?.byContent ? FortiOsPlan.byContent(path, want, have) : table?.
|
|
1806
|
+
const entries = table?.byContent ? FortiOsPlan.byContent(path, want, have) : table?.numbered ? FortiOsPlan.numbered(path, want, have, table, table.numbered, context) : FortiOsPlan.byId(path, want, have, table, context);
|
|
1802
1807
|
send.push(...entries.send);
|
|
1803
1808
|
show.push(...entries.show);
|
|
1804
1809
|
if (top) {
|
|
@@ -1840,55 +1845,100 @@ class FortiOsPlan {
|
|
|
1840
1845
|
removals.push(`delete ${entry.id}`);
|
|
1841
1846
|
removed.push(...FortiOsPlan.shown("-", [...path, FortiOsTree.bare(entry.id)], entry.block));
|
|
1842
1847
|
}
|
|
1843
|
-
if (table?.ordered) {
|
|
1844
|
-
const moved = FortiOsPlan.moves(want, have);
|
|
1845
|
-
send.push(...moved);
|
|
1846
|
-
show.push(...moved.map((line) => `+ ${FortiOsPlan.labelOf(path)}${line}`));
|
|
1847
|
-
}
|
|
1848
1848
|
return { send, show, removals, removed };
|
|
1849
1849
|
}
|
|
1850
|
-
static
|
|
1851
|
-
const
|
|
1852
|
-
const creates = [];
|
|
1850
|
+
static numbered(path, want, have, table, numbered, context) {
|
|
1851
|
+
const send = [];
|
|
1853
1852
|
const show = [];
|
|
1853
|
+
const pattern = new RegExp(`^${numbered.prefix ?? ""}(\\d+)$`);
|
|
1854
|
+
const numberOf = (entry) => Number(pattern.exec(FortiOsTree.bare(entry.id))?.[1] ?? NaN);
|
|
1855
|
+
const held = [...have.entries.values()].filter((entry) => !FortiOsPlan.isFactory(table, entry));
|
|
1856
|
+
const kept = new Map;
|
|
1857
|
+
const unmatched = new Set(held.filter((entry) => Number.isInteger(numberOf(entry))));
|
|
1858
|
+
const keep = (key, old) => {
|
|
1859
|
+
kept.set(key, old);
|
|
1860
|
+
unmatched.delete(old);
|
|
1861
|
+
};
|
|
1862
|
+
const alike = Map.groupBy(unmatched, (old) => FortiOsPlan.signature(old.block, context.section));
|
|
1863
|
+
const same = (entry, old) => !FortiOsPlan.compare([], entry.block, old.block, undefined, false, context).send.length;
|
|
1854
1864
|
for (const [key, entry] of want.entries) {
|
|
1855
|
-
const
|
|
1856
|
-
const
|
|
1857
|
-
if (
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1865
|
+
const candidates = alike.get(FortiOsPlan.signature(entry.block, context.section)) ?? [];
|
|
1866
|
+
const index = candidates.findIndex((old) => same(entry, old));
|
|
1867
|
+
if (index >= 0)
|
|
1868
|
+
keep(key, candidates.splice(index, 1)[0]);
|
|
1869
|
+
}
|
|
1870
|
+
for (const settings of numbered.keys) {
|
|
1871
|
+
const valueOf = (entry) => {
|
|
1872
|
+
const parts = settings.map((setting) => FortiOsPlan.canonical(setting, entry.block.settings.get(setting) ?? ""));
|
|
1873
|
+
return parts.some(Boolean) ? parts.join(`
|
|
1874
|
+
`) : undefined;
|
|
1875
|
+
};
|
|
1876
|
+
const changed = Map.groupBy([...want.entries].filter(([key]) => !kept.has(key)), ([, entry]) => valueOf(entry));
|
|
1877
|
+
const left = Map.groupBy(unmatched, valueOf);
|
|
1878
|
+
for (const [value, entries] of changed) {
|
|
1879
|
+
const old = left.get(value);
|
|
1880
|
+
if (value !== undefined && entries.length === 1 && old?.length === 1)
|
|
1881
|
+
keep(entries[0][0], old[0]);
|
|
1862
1882
|
}
|
|
1863
|
-
creates.push(...FortiOsPlan.creation(entry, table));
|
|
1864
|
-
show.push(...FortiOsPlan.shown("+", where, entry.block));
|
|
1865
1883
|
}
|
|
1866
|
-
|
|
1867
|
-
|
|
1884
|
+
let next = Math.max(0, ...[...have.entries.values()].map(numberOf).filter(Number.isInteger)) + 1;
|
|
1885
|
+
const wanted = [];
|
|
1886
|
+
const replaced = [];
|
|
1887
|
+
for (const [key, entry] of want.entries) {
|
|
1888
|
+
const old = kept.get(key);
|
|
1889
|
+
if (!old) {
|
|
1890
|
+
const name = `${numbered.prefix ?? ""}${next++}`;
|
|
1891
|
+
const id = numbered.prefix ? FortiOsTree.quote(name) : name;
|
|
1892
|
+
wanted.push(id);
|
|
1893
|
+
send.push(...FortiOsPlan.creation(entry, table, id));
|
|
1894
|
+
show.push(...FortiOsPlan.shown("+", [...path, name], entry.block));
|
|
1868
1895
|
continue;
|
|
1869
|
-
|
|
1870
|
-
|
|
1896
|
+
}
|
|
1897
|
+
wanted.push(old.id);
|
|
1898
|
+
const where = [...path, FortiOsTree.bare(old.id)];
|
|
1899
|
+
const diff = FortiOsPlan.compare(where, entry.block, old.block, undefined, false, context);
|
|
1900
|
+
if (!diff.send.length)
|
|
1901
|
+
continue;
|
|
1902
|
+
if (table.distinct) {
|
|
1903
|
+
replaced.push(`delete ${old.id}`);
|
|
1904
|
+
send.push(...FortiOsPlan.creation(entry, table, old.id));
|
|
1905
|
+
show.push(...FortiOsPlan.shown("-", where, old.block), ...FortiOsPlan.shown("+", where, entry.block));
|
|
1906
|
+
continue;
|
|
1907
|
+
}
|
|
1908
|
+
send.push(`edit ${old.id}`, ...diff.send, "next");
|
|
1909
|
+
show.push(...diff.show);
|
|
1871
1910
|
}
|
|
1872
|
-
|
|
1911
|
+
const keptEntries = new Set(kept.values());
|
|
1912
|
+
if (table.ordered) {
|
|
1913
|
+
const order = held.filter((entry) => keptEntries.has(entry)).map((entry) => entry.id);
|
|
1914
|
+
const moved = FortiOsPlan.moves(wanted, order);
|
|
1915
|
+
send.push(...moved);
|
|
1916
|
+
show.push(...moved.map((line) => `+ ${FortiOsPlan.labelOf(path)}${line}`));
|
|
1917
|
+
}
|
|
1918
|
+
const gone = held.filter((entry) => !keptEntries.has(entry));
|
|
1919
|
+
const removals = gone.map((entry) => `delete ${entry.id}`);
|
|
1920
|
+
const removed = gone.flatMap((entry) => FortiOsPlan.shown("-", [...path, FortiOsTree.bare(entry.id)], entry.block));
|
|
1921
|
+
if (table.distinct)
|
|
1922
|
+
return { send: [...replaced, ...removals, ...send], show: [...removed, ...show], removals: [], removed: [] };
|
|
1923
|
+
return { send, show, removals, removed };
|
|
1873
1924
|
}
|
|
1874
|
-
static moves(
|
|
1875
|
-
const
|
|
1876
|
-
const order = [...have.entries.keys()].filter((key) => want.entries.has(key));
|
|
1877
|
-
order.push(...wanted.filter((key) => !have.entries.has(key)));
|
|
1925
|
+
static moves(wanted, held) {
|
|
1926
|
+
const order = [...held, ...wanted.filter((id) => !held.includes(id))];
|
|
1878
1927
|
const send = [];
|
|
1879
|
-
const idOf = (key) => want.entries.get(key).id;
|
|
1880
1928
|
for (let index = 0;index < wanted.length; index++) {
|
|
1881
|
-
|
|
1929
|
+
const id = wanted[index];
|
|
1930
|
+
if (order[index] === id)
|
|
1882
1931
|
continue;
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
order.splice(
|
|
1886
|
-
order.splice(index, 0, key);
|
|
1932
|
+
send.push(index === 0 ? `move ${id} before ${order[0]}` : `move ${id} after ${wanted[index - 1]}`);
|
|
1933
|
+
order.splice(order.indexOf(id), 1);
|
|
1934
|
+
order.splice(index, 0, id);
|
|
1887
1935
|
}
|
|
1888
1936
|
return send;
|
|
1889
1937
|
}
|
|
1890
|
-
static signature(block) {
|
|
1891
|
-
|
|
1938
|
+
static signature(block, section = "") {
|
|
1939
|
+
const defaults = FortiOsPlan.DEFAULTS[section] ?? {};
|
|
1940
|
+
const isDefault = (key, value) => defaults[key] !== undefined && FortiOsPlan.canonical(key, defaults[key]) === FortiOsPlan.canonical(key, value);
|
|
1941
|
+
return [...block.settings].filter(([key, value]) => !isDefault(key, value)).map(([key, value]) => `${key} ${FortiOsPlan.canonical(key, value)}`).sort().join(`
|
|
1892
1942
|
`);
|
|
1893
1943
|
}
|
|
1894
1944
|
static byContent(path, want, have) {
|
|
@@ -8343,7 +8393,7 @@ class RouterOsRender {
|
|
|
8343
8393
|
distance: route.distance,
|
|
8344
8394
|
"dst-address": route.prefix,
|
|
8345
8395
|
gateway,
|
|
8346
|
-
"routing-table": route.vrf ?? route.table
|
|
8396
|
+
"routing-table": route.vrf ?? Utils2.otherTable(route.table)
|
|
8347
8397
|
});
|
|
8348
8398
|
}
|
|
8349
8399
|
}
|
|
@@ -8464,7 +8514,7 @@ class RouterOsRender {
|
|
|
8464
8514
|
"output.network": originates ? RouterOsRender.NETWORKS[family] : undefined,
|
|
8465
8515
|
"remote.address": `${neighbor.address}/${addressFamily === "ipv4" ? 32 : 128}`,
|
|
8466
8516
|
"remote.as": settings.remote_as,
|
|
8467
|
-
"routing-table": settings.table,
|
|
8517
|
+
"routing-table": Utils2.otherTable(settings.table),
|
|
8468
8518
|
"tcp-md5-key": settings.password && Secrets2.marker(settings.password),
|
|
8469
8519
|
"use-bfd": settings.bfd ? "yes" : undefined
|
|
8470
8520
|
});
|
|
@@ -8732,19 +8782,22 @@ class RouterOs extends Platform {
|
|
|
8732
8782
|
const findings = [];
|
|
8733
8783
|
for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
|
|
8734
8784
|
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
8735
|
-
const table = neighbor.table ?? group?.table;
|
|
8785
|
+
const table = Utils2.otherTable(neighbor.table ?? group?.table);
|
|
8736
8786
|
if (!table)
|
|
8737
8787
|
continue;
|
|
8738
8788
|
const families = neighbor.families ?? group?.families ?? [Address2.family(neighbor.address)];
|
|
8739
8789
|
const networks = (bgp?.networks ?? []).filter((prefix) => families.includes(Address2.family(Address2.host(prefix))));
|
|
8740
8790
|
const inTable = (prefix) => statics.some((route) => route.table === table && Address2.canonical(route.prefix) === Address2.canonical(prefix));
|
|
8741
|
-
const
|
|
8791
|
+
const exportPolicy = neighbor.export ?? group?.export;
|
|
8792
|
+
const accepted = (exportPolicy ? device.policies?.[exportPolicy] ?? [] : []).filter((rule) => rule.action === "accept" && rule.match?.prefix).map((rule) => Address2.canonical(rule.match.prefix));
|
|
8793
|
+
const announced = accepted.length ? networks.filter((prefix) => accepted.includes(Address2.canonical(prefix))) : networks;
|
|
8794
|
+
const missing = announced.filter((prefix) => !inTable(prefix));
|
|
8742
8795
|
if (!missing.length)
|
|
8743
8796
|
continue;
|
|
8744
8797
|
const where = `routing.bgp.neighbors.${name}`;
|
|
8745
8798
|
const route = (prefix) => `{ prefix: "${prefix}", blackhole: true, table: "${table}" }`;
|
|
8746
8799
|
const fix = `On RouterOS a session announces from its own table, ${table}`;
|
|
8747
|
-
if (missing.length ===
|
|
8800
|
+
if (missing.length === announced.length) {
|
|
8748
8801
|
findings.push({
|
|
8749
8802
|
level: "error",
|
|
8750
8803
|
device: device.name,
|
|
@@ -11555,8 +11608,8 @@ class Validator {
|
|
|
11555
11608
|
report(`static route ${route.prefix} has bfd without via, the address BFD watches`);
|
|
11556
11609
|
names.expectInterface(`static route ${route.prefix} interface`, route.interface);
|
|
11557
11610
|
names.expect(`static route ${route.prefix} VRF`, route.vrf, route.vrf !== undefined && route.vrf in (device.vrfs ?? {}));
|
|
11558
|
-
names.expect(`static route ${route.prefix} table`, route.table, route.table !== undefined && route.table in (routing?.tables ?? {}));
|
|
11559
|
-
if (route.vrf && route.table)
|
|
11611
|
+
names.expect(`static route ${route.prefix} table`, route.table, route.table !== undefined && (route.table === "main" || (route.table in (routing?.tables ?? {}))));
|
|
11612
|
+
if (route.vrf && Utils2.otherTable(route.table))
|
|
11560
11613
|
report(`static route ${route.prefix} has both vrf and table; a route is in one of them`);
|
|
11561
11614
|
}
|
|
11562
11615
|
Validator.checkTables(device, names, report, cidr);
|
|
@@ -11564,7 +11617,7 @@ class Validator {
|
|
|
11564
11617
|
const isPolicy = (policy) => policy !== undefined && (policy in policies);
|
|
11565
11618
|
for (const prefix of bgp?.networks ?? [])
|
|
11566
11619
|
cidr("BGP network", prefix);
|
|
11567
|
-
const isTable = (table) => table !== undefined && (table in (routing?.tables ?? {}));
|
|
11620
|
+
const isTable = (table) => table !== undefined && (table === "main" || (table in (routing?.tables ?? {})));
|
|
11568
11621
|
for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
|
|
11569
11622
|
for (const policy of [group.import, group.export]) {
|
|
11570
11623
|
names.expect(`BGP group ${name} policy`, policy, isPolicy(policy));
|
|
@@ -11597,11 +11650,13 @@ class Validator {
|
|
|
11597
11650
|
const tables = routing?.tables ?? {};
|
|
11598
11651
|
const bgp = routing?.bgp;
|
|
11599
11652
|
const installed = new Set;
|
|
11600
|
-
for (const route of routing?.static ?? [])
|
|
11601
|
-
|
|
11602
|
-
|
|
11653
|
+
for (const route of routing?.static ?? []) {
|
|
11654
|
+
const table = Utils2.otherTable(route.table);
|
|
11655
|
+
if (table)
|
|
11656
|
+
installed.add(table);
|
|
11657
|
+
}
|
|
11603
11658
|
for (const neighbor of Object.values(bgp?.neighbors ?? {})) {
|
|
11604
|
-
const table = neighbor.table ?? (neighbor.group ? bgp?.groups?.[neighbor.group]?.table : undefined);
|
|
11659
|
+
const table = Utils2.otherTable(neighbor.table ?? (neighbor.group ? bgp?.groups?.[neighbor.group]?.table : undefined));
|
|
11605
11660
|
if (table)
|
|
11606
11661
|
installed.add(table);
|
|
11607
11662
|
}
|
package/dist/helpers/index.js
CHANGED
|
@@ -147,22 +147,31 @@ class Bgp {
|
|
|
147
147
|
action: "accept"
|
|
148
148
|
}));
|
|
149
149
|
};
|
|
150
|
-
const
|
|
151
|
-
const
|
|
150
|
+
const asnOf = (neighbor) => typeof neighbor === "number" ? neighbor : neighbor.asn;
|
|
151
|
+
const passOnTo = (neighbor) => typeof neighbor === "number" ? undefined : neighbor.communities?.blackhole;
|
|
152
|
+
const blackholeExport = (neighbor) => {
|
|
152
153
|
if (scheme.blackhole === undefined)
|
|
153
154
|
return [];
|
|
154
|
-
const passOn = passOnTo(
|
|
155
|
+
const passOn = passOnTo(neighbor);
|
|
155
156
|
const match = { large_community: community(scheme.blackhole.function, 0) };
|
|
156
|
-
|
|
157
|
+
if (!passOn)
|
|
158
|
+
return [{ description: "Blackholed here, never announced", match, action: "reject" }];
|
|
159
|
+
const large = passOn.split(":").length === 3;
|
|
160
|
+
return [
|
|
157
161
|
{
|
|
158
|
-
description: `Blackhole passed on to AS${
|
|
162
|
+
description: `Blackhole passed on to AS${asnOf(neighbor)}`,
|
|
159
163
|
match,
|
|
160
|
-
set: {
|
|
164
|
+
set: {
|
|
165
|
+
remove_communities: ours.standard,
|
|
166
|
+
remove_large_communities: ours.large,
|
|
167
|
+
...large ? { add_large_communities: [passOn] } : { add_communities: [passOn] }
|
|
168
|
+
},
|
|
161
169
|
action: "accept"
|
|
162
170
|
}
|
|
163
|
-
]
|
|
171
|
+
];
|
|
164
172
|
};
|
|
165
|
-
const rejectDoNotAnnounce = (
|
|
173
|
+
const rejectDoNotAnnounce = (neighbor) => {
|
|
174
|
+
const neighborAs = asnOf(neighbor);
|
|
166
175
|
const fn = scheme.do_not_announce;
|
|
167
176
|
if (fn === undefined)
|
|
168
177
|
return [];
|
|
@@ -175,7 +184,8 @@ class Bgp {
|
|
|
175
184
|
}
|
|
176
185
|
];
|
|
177
186
|
};
|
|
178
|
-
const prepends = (
|
|
187
|
+
const prepends = (neighbor) => Bgp.PREPENDS.flatMap(([name, times]) => {
|
|
188
|
+
const neighborAs = asnOf(neighbor);
|
|
179
189
|
const fn = scheme.prepend?.[name];
|
|
180
190
|
if (fn === undefined)
|
|
181
191
|
return [];
|
|
@@ -191,7 +201,7 @@ class Bgp {
|
|
|
191
201
|
description: "Our communities stay inside our network",
|
|
192
202
|
set: { remove_communities: ours.standard, remove_large_communities: ours.large }
|
|
193
203
|
});
|
|
194
|
-
const actions = (
|
|
204
|
+
const actions = (neighbor) => passOnTo(neighbor) ? [...rejectDoNotAnnounce(neighbor), ...blackholeExport(neighbor), ...prepends(neighbor), stripOurs()] : [...blackholeExport(neighbor), ...rejectDoNotAnnounce(neighbor), ...prepends(neighbor), stripOurs()];
|
|
195
205
|
const catalogue = [
|
|
196
206
|
...Object.entries(scheme.learned_from?.classes ?? {}).map(([name, value]) => ({
|
|
197
207
|
community: written(scheme.learned_from.function, value),
|
|
@@ -216,7 +226,7 @@ class Bgp {
|
|
|
216
226
|
...scheme.blackhole !== undefined ? [
|
|
217
227
|
{
|
|
218
228
|
community: written(scheme.blackhole.function, 0),
|
|
219
|
-
description:
|
|
229
|
+
description: `Blackhole: dropped in AS${asn}, and by the upstreams that take it`
|
|
220
230
|
}
|
|
221
231
|
] : []
|
|
222
232
|
];
|
|
@@ -691,6 +701,28 @@ class Filters {
|
|
|
691
701
|
}
|
|
692
702
|
// src/helpers/upstreams.ts
|
|
693
703
|
class Upstreams {
|
|
704
|
+
static catalog(catalog) {
|
|
705
|
+
return catalog;
|
|
706
|
+
}
|
|
707
|
+
static doNotAnnounceTo(upstream, asns, description) {
|
|
708
|
+
if (!asns.length)
|
|
709
|
+
return [];
|
|
710
|
+
const community = upstream.communities?.do_not_announce_to;
|
|
711
|
+
if (!community)
|
|
712
|
+
throw new Error(`Upstreams: ${upstream.name ?? `AS${upstream.asn}`} has no communities.do_not_announce_to, so it can't be asked to keep our routes from AS${asns.join(", AS")}`);
|
|
713
|
+
const communities = asns.map(community);
|
|
714
|
+
const large = communities.filter((value) => value.split(":").length === 3);
|
|
715
|
+
const standard = communities.filter((value) => value.split(":").length === 2);
|
|
716
|
+
return [
|
|
717
|
+
{
|
|
718
|
+
description,
|
|
719
|
+
set: {
|
|
720
|
+
...standard.length ? { add_communities: standard } : {},
|
|
721
|
+
...large.length ? { add_large_communities: large } : {}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
];
|
|
725
|
+
}
|
|
694
726
|
static define(catalog, options = {}) {
|
|
695
727
|
const all = Object.entries(catalog);
|
|
696
728
|
const nameOf = (key, upstream) => upstream.name ?? key;
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,8 @@ type Merged<T extends readonly object[]> = T extends readonly [infer Head, ...in
|
|
|
2
2
|
export declare class Utils {
|
|
3
3
|
/** Combine records into one, such as a device's own interfaces and those a module derives for it. Unlike spreading them, a name two of them hold is an error rather than one silently replacing the other. */
|
|
4
4
|
static merge<const T extends readonly Record<string, unknown>[]>(...records: T): Merged<T>;
|
|
5
|
+
/** The table a route or a BGP session is in, beside the main one: `undefined` for the main table, written `main` or left out. */
|
|
6
|
+
static otherTable(table: string | undefined): string | undefined;
|
|
5
7
|
/** Minutes east of UTC an IANA zone is at now, for platforms that take an offset rather than a zone. */
|
|
6
8
|
static timeZoneOffset(zone: string): number;
|
|
7
9
|
}
|
|
@@ -82,15 +82,24 @@ export type CommunityScheme<Asn extends number = number, LearnedFrom extends num
|
|
|
82
82
|
three_times?: number;
|
|
83
83
|
};
|
|
84
84
|
/**
|
|
85
|
-
* A neighbor asks: drop all traffic to this route, `asn:function:0`, usually one address under attack. Your router drops it
|
|
86
|
-
*
|
|
87
|
-
* `upstreams` maps an upstream's AS to its own blackhole community, as its NOC gives it. The route is passed to that upstream with it, so the traffic stops before it reaches your links. An upstream not listed never receives the route.
|
|
85
|
+
* A neighbor asks: drop all traffic to this route, `asn:function:0`, usually one address under attack. Your router drops it. An upstream whose own blackhole community you know (`communities.blackhole` on the neighbor you pass to `actions`) receives the route with it and drops the traffic too; any other never receives the route.
|
|
88
86
|
*/
|
|
89
87
|
blackhole?: {
|
|
90
88
|
function: number;
|
|
91
|
-
upstreams?: Readonly<Record<number, StandardCommunity>>;
|
|
92
89
|
};
|
|
93
90
|
};
|
|
91
|
+
/** The communities another network offers us, as its NOC gives them: what it does with a route we send it. */
|
|
92
|
+
export type NeighborCommunities = {
|
|
93
|
+
/** It drops traffic to a route we send it with this community. */
|
|
94
|
+
blackhole?: StandardCommunity | LargeCommunity;
|
|
95
|
+
/** It does not pass a route we send it with this community to the AS given, such as an exchange's route servers' `rs-asn:0:peer-asn`. */
|
|
96
|
+
do_not_announce_to?: (asn: number) => StandardCommunity | LargeCommunity;
|
|
97
|
+
};
|
|
98
|
+
/** A neighbor an export goes to: its AS, or its AS and the communities it offers. */
|
|
99
|
+
export type ExportNeighbor = number | {
|
|
100
|
+
asn: number;
|
|
101
|
+
communities?: NeighborCommunities;
|
|
102
|
+
};
|
|
94
103
|
/** The class names a scheme declares, or any name when it declares none. */
|
|
95
104
|
type ClassName<Classes extends CommunityClasses> = {} extends Classes ? string : Extract<keyof Classes, string>;
|
|
96
105
|
/** 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. */
|
|
@@ -153,10 +162,12 @@ export declare class Bgp {
|
|
|
153
162
|
* Policy rules and a catalogue from one community scheme, so the communities your routers set, the ones they act on, and the ones you publish never disagree.
|
|
154
163
|
*
|
|
155
164
|
* - `tag(neighborClass, neighborAs, { site, trusted })`: one rule for an import policy, `{ description: "Learned from AS<n>", set }`. Its `set` removes your standard communities and your large ones (a `trusted` neighbor loses only the "where it came from" functions, so it keeps its requests: do not announce, prepend, blackhole), then adds `learned_from`, `learned_at` and `learned_from_as` where the scheme has them. `site` is needed when the scheme has `learned_at`.
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* - `
|
|
159
|
-
* - `
|
|
165
|
+
* Each export helper takes the neighbor: its AS, or `{ asn, communities }` with the communities it offers, such as an `Upstreams.catalog` entry.
|
|
166
|
+
*
|
|
167
|
+
* - `actions(neighbor)`: the rules for an export policy toward one neighbor, before the rules that accept. They are `[...blackholeExport(n), ...rejectDoNotAnnounce(n), ...prepends(n), stripOurs()]`; toward a neighbor that takes blackholes (`communities.blackhole`), `rejectDoNotAnnounce(n)` comes first, so a route asked not to go there is never passed on as a blackhole.
|
|
168
|
+
* - `blackholeExport(neighbor)`: a blackholed route goes no further, `Blackholed here, never announced`, unless the neighbor has `communities.blackhole`: then it is passed on with it, so the neighbor drops the traffic too. Nothing without `blackhole` in the scheme.
|
|
169
|
+
* - `rejectDoNotAnnounce(neighbor)`: rejects a route asked not to be announced anywhere, then one asked not to be announced to this neighbor. Nothing without `do_not_announce`.
|
|
170
|
+
* - `prepends(neighbor)`: prepends once, twice or three times toward this neighbor, where a route asks. Nothing without `prepend`.
|
|
160
171
|
* - `stripOurs()`: one rule removing every community of yours, `Our communities stay inside our network`.
|
|
161
172
|
* - `blackhole(prefixSets)`: rules for the import policy of a neighbor you trust with blackholes, before anything that rejects long prefixes. They take a route the neighbor asked to blackhole, but only inside `prefixSets`, its own space, and drop traffic to it here. One rule per set, so one call covers both families.
|
|
162
173
|
* - `catalogue`: every community, for `communities` in `defineNetwork`.
|
|
@@ -170,10 +181,10 @@ export declare class Bgp {
|
|
|
170
181
|
site?: number;
|
|
171
182
|
trusted?: boolean;
|
|
172
183
|
}) => PolicyRule<never, never>;
|
|
173
|
-
actions: (
|
|
174
|
-
blackholeExport: (
|
|
175
|
-
rejectDoNotAnnounce: (
|
|
176
|
-
prepends: (
|
|
184
|
+
actions: (neighbor: ExportNeighbor) => PolicyRule<never, never>[];
|
|
185
|
+
blackholeExport: (neighbor: ExportNeighbor) => PolicyRule<never, never>[];
|
|
186
|
+
rejectDoNotAnnounce: (neighbor: ExportNeighbor) => PolicyRule<never, never>[];
|
|
187
|
+
prepends: (neighbor: ExportNeighbor) => PolicyRule<never, never>[];
|
|
177
188
|
stripOurs: () => PolicyRule<never, never>;
|
|
178
189
|
blackhole: <S extends string>(prefixSets: readonly S[]) => PolicyRule<never, S>[];
|
|
179
190
|
catalogue: Community[];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { Bgp } from "./bgp.js";
|
|
2
|
-
export type { AnnounceOptions, Announced, CommunityScheme, DefaultOnlyOptions, LearnOptions, SanityLengths } from "./bgp.js";
|
|
2
|
+
export type { AnnounceOptions, Announced, CommunityScheme, DefaultOnlyOptions, ExportNeighbor, LearnOptions, NeighborCommunities, SanityLengths, } from "./bgp.js";
|
|
3
3
|
export { Cables } from "./cables.js";
|
|
4
4
|
export type { FarEnd } from "./cables.js";
|
|
5
5
|
export { Checks } from "./checks.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Exchange as ExchangeLan, Family, FilterRule, Gre, InterfaceAddress, IP, Neighbor, NeighborSettings, PolicyRule, Port, Secret, Vlan, VlanInterface } from "../schema/index.js";
|
|
2
|
+
import type { NeighborCommunities } from "./bgp.js";
|
|
2
3
|
/** A route policy an upstream's sessions use: its name on the device, and its rules, in order. */
|
|
3
4
|
export type UpstreamPolicy = {
|
|
4
5
|
/** The policy's name, as the device carries it. */
|
|
@@ -67,7 +68,7 @@ export type Upstream = {
|
|
|
67
68
|
families?: readonly Family[];
|
|
68
69
|
password?: Secret;
|
|
69
70
|
};
|
|
70
|
-
/** The routing table its sessions install the routes they learn in
|
|
71
|
+
/** The routing table its sessions install the routes they learn in: `main`, or one of the router's `routing.tables`, for a transit that carries only one block's traffic out. The main table when absent; write `main` to say so. On RouterOS the sessions also announce from that table, so what they announce needs a route there too. */
|
|
71
72
|
table?: string;
|
|
72
73
|
/** The most prefixes a session may send, by its family: about twice what it sends. Over it, the session closes until it is cleared. */
|
|
73
74
|
limits?: {
|
|
@@ -80,6 +81,8 @@ export type Upstream = {
|
|
|
80
81
|
export: UpstreamPolicy;
|
|
81
82
|
/** Its VLAN is listed by `privateDrops()`, for an ACL that drops RFC 1918 sources arriving from it. Nothing else is done with it. */
|
|
82
83
|
drop_private?: true;
|
|
84
|
+
/** The communities it offers, as its NOC gives them: its blackhole community, and how to keep a route from one AS. `communityScheme().actions(upstream)` passes blackholes on with it, and `Upstreams.doNotAnnounceTo` uses the other. */
|
|
85
|
+
communities?: NeighborCommunities;
|
|
83
86
|
};
|
|
84
87
|
/** Upstreams by key. */
|
|
85
88
|
export type UpstreamCatalog = Readonly<Record<string, Upstream>>;
|
|
@@ -145,14 +148,22 @@ type GroupName<C> = Values<{
|
|
|
145
148
|
group: infer N extends string;
|
|
146
149
|
} ? N : never;
|
|
147
150
|
}>;
|
|
148
|
-
/** Each routing table name. */
|
|
149
|
-
type TableName<C> = Values<{
|
|
151
|
+
/** Each routing table name, other than main. */
|
|
152
|
+
type TableName<C> = Exclude<Values<{
|
|
150
153
|
[K in keyof C]: C[K] extends {
|
|
151
154
|
table: infer N extends string;
|
|
152
155
|
} ? N : never;
|
|
153
|
-
}>;
|
|
156
|
+
}>, "main">;
|
|
154
157
|
/** A network's upstreams written once, and each device's part built from them. */
|
|
155
158
|
export declare class Upstreams {
|
|
159
|
+
/**
|
|
160
|
+
* A catalog of upstreams, by key, written once and checked: a key the shape doesn't have is refused by its name. It gives the catalog back as written, so its keys and entries can be read, and is what `define` takes.
|
|
161
|
+
*/
|
|
162
|
+
static catalog<const C extends UpstreamCatalog>(catalog: C & UpstreamCatalog): C;
|
|
163
|
+
/**
|
|
164
|
+
* Export rules asking an upstream not to pass our routes to these ASes, with its own `communities.do_not_announce_to`: one rule, `description`, adding one community per AS. Nothing for no AS. An upstream without `do_not_announce_to` is an error, since it can't be asked.
|
|
165
|
+
*/
|
|
166
|
+
static doNotAnnounceTo(upstream: Upstream, asns: readonly number[], description: string): PolicyRule<never, never>[];
|
|
156
167
|
/**
|
|
157
168
|
* Each upstream written once: its AS, where it lands, its sessions, limits and policies, and every name it puts on a device. Everything a device needs is built from it:
|
|
158
169
|
*
|
|
@@ -5,7 +5,11 @@ export declare class FortiOsPlan {
|
|
|
5
5
|
private static readonly FACTORY_SERVICES;
|
|
6
6
|
/** The device's own certificates, which the factory makes for each device. */
|
|
7
7
|
private static readonly FACTORY_CERTIFICATES;
|
|
8
|
+
/** An entry known by what it holds, or else by its description. */
|
|
9
|
+
private static readonly BY_COMMENTS;
|
|
8
10
|
private static readonly ORDERED;
|
|
11
|
+
/** A static route known by what it holds, or else by its description. Many routes share a destination, so that is no key. */
|
|
12
|
+
private static readonly ROUTES;
|
|
9
13
|
/** Tables whose entries need more than a plain comparison, by section. */
|
|
10
14
|
private static readonly TABLES;
|
|
11
15
|
/** Settings the device keeps for itself: an object's UUID and an interface's SNMP index. */
|
|
@@ -61,13 +65,19 @@ export declare class FortiOsPlan {
|
|
|
61
65
|
* What goes is undone before what comes is set, since a setting can hide another: `send-deny-packet` exists only while a policy denies. A nested list that goes is deleted before the settings, since one can hide the list and keep its entries hidden, such as `secondary-IP`; NTP refuses to delete its last server while it synchronises, so `ntpsync` goes before its list.
|
|
62
66
|
*/
|
|
63
67
|
private static compare;
|
|
64
|
-
/** Entries matched by name: new ones created, changed ones edited, gone ones deleted
|
|
68
|
+
/** Entries matched by name: new ones created, changed ones edited, gone ones deleted. */
|
|
65
69
|
private static byId;
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Entries the render numbers by their place, while on the device each keeps the number it was created with, so they are matched by what they are.
|
|
72
|
+
*
|
|
73
|
+
* An entry that holds the same as one on the device is kept. One that changes is found by the first of the table's keys that no other entry left to match shares, such as its description, and edited under its own number. Any other is new and takes a number the device does not use; in a table the device keeps in order, it is created at the end and moved into place. An entry on the device that nothing matched is deleted last, so every entry the render keeps stays on the device through the whole change.
|
|
74
|
+
*
|
|
75
|
+
* In a `distinct` table, an entry that changes is deleted and created again under its own number, and every deletion comes before any creation.
|
|
76
|
+
*/
|
|
77
|
+
private static numbered;
|
|
78
|
+
/** The `move` commands that put a table's entries in the render's order, by the device's numbers: `wanted` in the render's order, `held` the kept ones in the device's. Kept entries hold their place on the device and new ones are added at the end. */
|
|
69
79
|
private static moves;
|
|
70
|
-
/** An entry's settings in the form two entries compare in, whatever their numbers. */
|
|
80
|
+
/** An entry's settings in the form two entries compare in, whatever their numbers. A setting at its section's default is left out, as the device does not print it. */
|
|
71
81
|
private static signature;
|
|
72
82
|
/** Entries matched by what they hold. One the device holds and the render does not is deleted; one the render adds takes a number the device does not use. */
|
|
73
83
|
private static byContent;
|
|
@@ -15,8 +15,8 @@ export type StaticRoute<I extends string, F extends string, T extends string = n
|
|
|
15
15
|
distance?: number;
|
|
16
16
|
/** The VRF the route is in. The main table when absent. */
|
|
17
17
|
vrf?: F;
|
|
18
|
-
/** The table the route is in
|
|
19
|
-
table?: T;
|
|
18
|
+
/** The table the route is in: `main`, or one of `routing.tables`. The main table when absent; write `main` to say so. A route has a `vrf` or a `table`, not both. */
|
|
19
|
+
table?: T | "main";
|
|
20
20
|
/** Why the route exists. */
|
|
21
21
|
description?: string;
|
|
22
22
|
};
|
|
@@ -54,8 +54,8 @@ export type NeighborSettings<P extends string, T extends string = never> = {
|
|
|
54
54
|
passive?: boolean;
|
|
55
55
|
/** Detect a dead link in under a second with BFD. */
|
|
56
56
|
bfd?: boolean;
|
|
57
|
-
/** The table the routes learned are installed in
|
|
58
|
-
table?: T;
|
|
57
|
+
/** The table the routes learned are installed in: `main`, or one of `routing.tables`. The main table when absent; write `main` to say so. 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. */
|
|
58
|
+
table?: T | "main";
|
|
59
59
|
};
|
|
60
60
|
/** One BGP session. Settings it does not set come from its group. */
|
|
61
61
|
export type Neighbor<P extends string, G extends string, T extends string = never> = NeighborSettings<P, T> & {
|
package/package.json
CHANGED
package/src/core/utils.ts
CHANGED
|
@@ -19,6 +19,11 @@ export class Utils {
|
|
|
19
19
|
return result as Merged<T>;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** The table a route or a BGP session is in, beside the main one: `undefined` for the main table, written `main` or left out. */
|
|
23
|
+
static otherTable(table: string | undefined): string | undefined {
|
|
24
|
+
return table === "main" ? undefined : table;
|
|
25
|
+
}
|
|
26
|
+
|
|
22
27
|
/** Minutes east of UTC an IANA zone is at now, for platforms that take an offset rather than a zone. */
|
|
23
28
|
static timeZoneOffset(zone: string): number {
|
|
24
29
|
const name = new Intl.DateTimeFormat("en-US", {
|