@takodotid/circuit 0.10.0 → 0.12.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/presets/index.js +82 -5
- package/dist/types/presets/index.d.ts +48 -7
- package/package.json +1 -1
- package/src/presets/index.ts +131 -15
package/dist/presets/index.js
CHANGED
|
@@ -47,7 +47,13 @@ function neighborPolicies(device) {
|
|
|
47
47
|
const bgp = device.routing?.bgp;
|
|
48
48
|
return Object.entries(bgp?.neighbors ?? {}).map(([name, neighbor]) => {
|
|
49
49
|
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
50
|
-
return {
|
|
50
|
+
return {
|
|
51
|
+
name,
|
|
52
|
+
address: neighbor.address,
|
|
53
|
+
role: neighbor.role ?? group?.role,
|
|
54
|
+
import: neighbor.import ?? group?.import,
|
|
55
|
+
export: neighbor.export ?? group?.export
|
|
56
|
+
};
|
|
51
57
|
});
|
|
52
58
|
}
|
|
53
59
|
function offered(device, policy) {
|
|
@@ -58,6 +64,8 @@ var exportsEndInReject = (devices) => {
|
|
|
58
64
|
const findings = [];
|
|
59
65
|
for (const device of devices) {
|
|
60
66
|
for (const neighbor of neighborPolicies(device)) {
|
|
67
|
+
if (neighbor.role === "customer")
|
|
68
|
+
continue;
|
|
61
69
|
const last = neighbor.export ? device.policies?.[neighbor.export]?.at(-1) : undefined;
|
|
62
70
|
if (neighbor.export && (last?.action !== "reject" || last.match)) {
|
|
63
71
|
findings.push({
|
|
@@ -153,14 +161,15 @@ function communityScheme(scheme) {
|
|
|
153
161
|
const ours = { standard: [`${asn}:*`], large: [`${asn}:*:*`] };
|
|
154
162
|
const information = [scheme.learned_from?.function, scheme.learned_at?.function, scheme.learned_from_as].filter((fn) => fn !== undefined);
|
|
155
163
|
const tag = (neighborClass, neighborAs, options = {}) => {
|
|
156
|
-
const { site
|
|
164
|
+
const { site } = options;
|
|
165
|
+
const mayAsk = scheme.requests_from?.includes(neighborClass) ?? false;
|
|
157
166
|
if (scheme.learned_at && site === undefined)
|
|
158
167
|
throw new Error(`communityScheme: tag for AS${neighborAs} needs a site, for learned_at`);
|
|
159
168
|
return {
|
|
160
169
|
description: `Learned from AS${neighborAs}`,
|
|
161
170
|
set: {
|
|
162
171
|
remove_communities: ours.standard,
|
|
163
|
-
remove_large_communities:
|
|
172
|
+
remove_large_communities: mayAsk ? information.map((fn) => community(fn, "*")) : ours.large,
|
|
164
173
|
add_large_communities: [
|
|
165
174
|
...scheme.learned_from ? [community(scheme.learned_from.function, scheme.learned_from.classes[neighborClass])] : [],
|
|
166
175
|
...scheme.learned_at ? [community(scheme.learned_at.function, site)] : [],
|
|
@@ -179,8 +188,8 @@ function communityScheme(scheme) {
|
|
|
179
188
|
action: "accept"
|
|
180
189
|
};
|
|
181
190
|
};
|
|
182
|
-
const actions = (neighborAs) => [
|
|
183
|
-
...scheme.blackhole !== undefined ? [
|
|
191
|
+
const actions = (neighborAs, options = {}) => [
|
|
192
|
+
...scheme.blackhole !== undefined && !options.blackhole ? [
|
|
184
193
|
{
|
|
185
194
|
description: "Blackholed here, never announced",
|
|
186
195
|
match: { large_community: community(scheme.blackhole, 0) },
|
|
@@ -199,6 +208,18 @@ function communityScheme(scheme) {
|
|
|
199
208
|
action: "reject"
|
|
200
209
|
}
|
|
201
210
|
] : [],
|
|
211
|
+
...scheme.blackhole !== undefined && options.blackhole ? [
|
|
212
|
+
{
|
|
213
|
+
description: `Blackhole passed on to AS${neighborAs}`,
|
|
214
|
+
match: { large_community: community(scheme.blackhole, 0) },
|
|
215
|
+
set: {
|
|
216
|
+
remove_communities: ours.standard,
|
|
217
|
+
remove_large_communities: ours.large,
|
|
218
|
+
add_communities: [options.blackhole]
|
|
219
|
+
},
|
|
220
|
+
action: "accept"
|
|
221
|
+
}
|
|
222
|
+
] : [],
|
|
202
223
|
...PREPENDS.flatMap(([name, times]) => {
|
|
203
224
|
const fn = scheme.prepend?.[name];
|
|
204
225
|
if (fn === undefined)
|
|
@@ -241,12 +262,68 @@ function communityScheme(scheme) {
|
|
|
241
262
|
];
|
|
242
263
|
return { community, tag, actions, blackhole, catalogue };
|
|
243
264
|
}
|
|
265
|
+
function internetExchange(exchange, members) {
|
|
266
|
+
const percent = exchange.storm_percent ?? 1;
|
|
267
|
+
const ports = (device) => {
|
|
268
|
+
const result = {};
|
|
269
|
+
for (const member of members) {
|
|
270
|
+
const port = member.ports[device];
|
|
271
|
+
if (!port)
|
|
272
|
+
continue;
|
|
273
|
+
result[port] = {
|
|
274
|
+
description: `${member.name}, AS${member.asn}`,
|
|
275
|
+
access_vlan: exchange.vlan,
|
|
276
|
+
stp: false,
|
|
277
|
+
lldp: false,
|
|
278
|
+
storm_control: { broadcast: { percent }, multicast: { percent }, unknown_unicast: { percent } }
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return result;
|
|
282
|
+
};
|
|
283
|
+
const check = (devices) => {
|
|
284
|
+
const findings = [];
|
|
285
|
+
const error = (message, device) => findings.push({ level: "error", ...device ? { device } : {}, message });
|
|
286
|
+
const taken = new Map((exchange.reserved ?? []).map((address) => [address, "reserved"]));
|
|
287
|
+
members.forEach((member, index) => {
|
|
288
|
+
for (const other of members.slice(index + 1)) {
|
|
289
|
+
if (other.name === member.name)
|
|
290
|
+
error(`member ${member.name} is declared twice`);
|
|
291
|
+
if (other.asn === member.asn)
|
|
292
|
+
error(`members ${member.name} and ${other.name} share AS${member.asn}`);
|
|
293
|
+
}
|
|
294
|
+
for (const [address, lan] of [
|
|
295
|
+
[member.ipv4, exchange.lan.ipv4],
|
|
296
|
+
[member.ipv6, exchange.lan.ipv6]
|
|
297
|
+
]) {
|
|
298
|
+
if (!address)
|
|
299
|
+
continue;
|
|
300
|
+
if (!lan || !contains(lan, address))
|
|
301
|
+
error(`${address} of ${member.name} is outside the peering LAN`);
|
|
302
|
+
const holder = taken.get(address);
|
|
303
|
+
if (holder)
|
|
304
|
+
error(`${address} of ${member.name} is already ${holder === "reserved" ? "reserved" : `${holder}'s`}`);
|
|
305
|
+
taken.set(address, member.name);
|
|
306
|
+
}
|
|
307
|
+
for (const [deviceName, portName] of Object.entries(member.ports)) {
|
|
308
|
+
const device = devices.find((candidate) => candidate.name === deviceName);
|
|
309
|
+
const port = device?.ports?.[portName];
|
|
310
|
+
const carried = port ? vlansOf(port) : [];
|
|
311
|
+
if (carried.length !== 1 || carried[0] !== exchange.vlan) {
|
|
312
|
+
error(`port ${portName} of ${member.name} does not carry the peering VLAN alone`, deviceName);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
return findings;
|
|
317
|
+
};
|
|
318
|
+
return { ports, check };
|
|
319
|
+
}
|
|
244
320
|
export {
|
|
245
321
|
vlansOf,
|
|
246
322
|
tunnelsOutsideOffered,
|
|
247
323
|
trustBoundary,
|
|
248
324
|
offered,
|
|
249
325
|
neighborPolicies,
|
|
326
|
+
internetExchange,
|
|
250
327
|
exportsEndInReject,
|
|
251
328
|
communityScheme,
|
|
252
329
|
bgpSanity,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Check, Community } from "../core/define";
|
|
2
|
-
import type { AclRule, Device, FilterRule, PolicyRule, Prefix } from "../schema";
|
|
2
|
+
import type { AclRule, Device, FilterRule, IP, PolicyRule, Port, Prefix } from "../schema";
|
|
3
3
|
/** IPv4 sources that never arrive from the internet: loopback, link-local and "this network", after RFC 6890. */
|
|
4
4
|
export declare const MARTIANS: readonly ["127.0.0.0/8", "169.254.0.0/16", "0.0.0.0/8"];
|
|
5
5
|
/** IPv4 private space, RFC 1918. */
|
|
@@ -23,16 +23,19 @@ export type SanityLengths = {
|
|
|
23
23
|
};
|
|
24
24
|
/** 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. */
|
|
25
25
|
export declare function bgpSanity(lengths?: SanityLengths): PolicyRule<never, never>[];
|
|
26
|
-
/** Each BGP neighbor's own policies, or its group's. */
|
|
26
|
+
/** Each BGP neighbor's own policies and role, or its group's. */
|
|
27
27
|
export declare function neighborPolicies(device: Device): {
|
|
28
28
|
name: string;
|
|
29
29
|
address: string;
|
|
30
|
+
role: import("..").BgpRole | undefined;
|
|
30
31
|
import: string | undefined;
|
|
31
32
|
export: string | undefined;
|
|
32
33
|
}[];
|
|
33
34
|
/** The prefixes an export policy accepts by name. */
|
|
34
35
|
export declare function offered(device: Device, policy: string | undefined): string[];
|
|
35
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Every BGP export ends in a reject that matches everything, so nothing leaves by accident. An export to a neighbor whose `role` is `customer` is left out: sending a customer everything you know is what it pays for, and cannot leak a route to the rest of the internet.
|
|
38
|
+
*/
|
|
36
39
|
export declare const exportsEndInReject: Check;
|
|
37
40
|
/** The VLANs a port carries, untagged or tagged. */
|
|
38
41
|
export declare const vlansOf: (port: {
|
|
@@ -61,6 +64,8 @@ export type CommunityScheme<Class extends string = string> = {
|
|
|
61
64
|
function: number;
|
|
62
65
|
classes: Readonly<Record<Class, number>>;
|
|
63
66
|
};
|
|
67
|
+
/** The kinds of neighbor allowed to ask you for something, usually your customers: the requests below that they set on their routes stay. From anyone else, they are removed on import. */
|
|
68
|
+
requests_from?: readonly NoInfer<Class>[];
|
|
64
69
|
/** Set on import: the site a route was learned at, `asn:function:site`. `sites` names the sites, for the catalogue. */
|
|
65
70
|
learned_at?: {
|
|
66
71
|
function: number;
|
|
@@ -82,8 +87,8 @@ export type CommunityScheme<Class extends string = string> = {
|
|
|
82
87
|
/**
|
|
83
88
|
* 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.
|
|
84
89
|
*
|
|
85
|
-
* - `tag(neighborClass, neighborAs, { site
|
|
86
|
-
* - `actions(neighborAs)`: rules for an export policy toward one neighbor. They act on a customer's action communities, then remove all of yours. Place them before the rules that accept.
|
|
90
|
+
* - `tag(neighborClass, neighborAs, { site })`: a rule for an import policy. It removes your communities a neighbor set, then adds where the route was learned. A neighbor of a class in `requests_from` keeps its requests. `site` is needed when the scheme has `learned_at`.
|
|
91
|
+
* - `actions(neighborAs, { blackhole })`: rules for an export policy toward one neighbor. They act on a customer's action communities, then remove all of yours. Place them before the rules that accept. A blackholed route goes no further, unless `blackhole` names the neighbor's own blackhole community: then it is passed on with it, so the neighbor drops the traffic too.
|
|
87
92
|
* - `blackhole(prefixSet)`: a rule for a customer's import policy, before anything that rejects long prefixes. It takes a route the customer asked to blackhole, but only inside `prefixSet`, its own space, and drops traffic to it here.
|
|
88
93
|
* - `catalogue`: every community, for `communities` in `defineNetwork`.
|
|
89
94
|
*/
|
|
@@ -91,9 +96,45 @@ export declare function communityScheme<const Class extends string>(scheme: Comm
|
|
|
91
96
|
community: (fn: number, parameter: number | string) => string;
|
|
92
97
|
tag: (neighborClass: Class, neighborAs: number, options?: {
|
|
93
98
|
site?: number;
|
|
94
|
-
keepActions?: boolean;
|
|
95
99
|
}) => PolicyRule<never, never>;
|
|
96
|
-
actions: (neighborAs: number
|
|
100
|
+
actions: (neighborAs: number, options?: {
|
|
101
|
+
blackhole?: string;
|
|
102
|
+
}) => PolicyRule<never, never>[];
|
|
97
103
|
blackhole: <S extends string>(prefixSet: S) => PolicyRule<never, S>;
|
|
98
104
|
catalogue: Community[];
|
|
99
105
|
};
|
|
106
|
+
/** A member of an internet exchange: a network with a port on the fabric and an address on the peering LAN. */
|
|
107
|
+
export type ExchangeMember = {
|
|
108
|
+
/** Lowercase, and never reused. */
|
|
109
|
+
name: string;
|
|
110
|
+
asn: number;
|
|
111
|
+
/** The member's port on each fabric switch, by switch name. */
|
|
112
|
+
ports: Readonly<Record<string, string>>;
|
|
113
|
+
/** Its addresses on the peering LAN. The route servers peer with these. */
|
|
114
|
+
ipv4?: IP;
|
|
115
|
+
ipv6?: IP;
|
|
116
|
+
};
|
|
117
|
+
/** An internet exchange's peering LAN, as its fabric carries it. */
|
|
118
|
+
export type InternetExchange<V extends string> = {
|
|
119
|
+
/** The peering VLAN, by the name the fabric switches give it. */
|
|
120
|
+
vlan: V;
|
|
121
|
+
/** The peering LAN's subnets. */
|
|
122
|
+
lan: {
|
|
123
|
+
ipv4?: Prefix;
|
|
124
|
+
ipv6?: Prefix;
|
|
125
|
+
};
|
|
126
|
+
/** Addresses on the peering LAN no member may take, such as the route servers'. */
|
|
127
|
+
reserved?: readonly IP[];
|
|
128
|
+
/** How much broadcast, multicast and unknown unicast a member port lets through, in percent of its speed. 1 when absent. */
|
|
129
|
+
storm_percent?: number;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Member ports and a check for an internet exchange's fabric.
|
|
133
|
+
*
|
|
134
|
+
* - `ports(device)`: every member's port on one switch. A member port is in the peering VLAN and nothing else, takes no part in spanning tree or LLDP, and lets only a trickle of broadcast, multicast and unknown unicast through.
|
|
135
|
+
* - `check`: members never share a name, an AS or an address; every address is inside the peering LAN and not reserved; every member port carries the peering VLAN alone.
|
|
136
|
+
*/
|
|
137
|
+
export declare function internetExchange<const V extends string>(exchange: InternetExchange<V>, members: readonly ExchangeMember[]): {
|
|
138
|
+
ports: (device: string) => Record<string, Port<V, string, string, string>>;
|
|
139
|
+
check: Check;
|
|
140
|
+
};
|
package/package.json
CHANGED
package/src/presets/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { contains } from "../core/addr";
|
|
4
4
|
import type { Check, Community, Finding } from "../core/define";
|
|
5
|
-
import type { AclRule, Device, FilterRule, PolicyRule, Prefix, TcpFlag } from "../schema";
|
|
5
|
+
import type { AclRule, Device, FilterRule, IP, PolicyRule, Port, Prefix, TcpFlag } from "../schema";
|
|
6
6
|
|
|
7
7
|
/** IPv4 sources that never arrive from the internet: loopback, link-local and "this network", after RFC 6890. */
|
|
8
8
|
export const MARTIANS = ["127.0.0.0/8", "169.254.0.0/16", "0.0.0.0/8"] as const satisfies readonly Prefix[];
|
|
@@ -66,13 +66,20 @@ export function bgpSanity(lengths: SanityLengths = {}): PolicyRule<never, never>
|
|
|
66
66
|
];
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
/** Each BGP neighbor's own policies, or its group's. */
|
|
69
|
+
/** Each BGP neighbor's own policies and role, or its group's. */
|
|
70
70
|
export function neighborPolicies(device: Device) {
|
|
71
71
|
const bgp = device.routing?.bgp;
|
|
72
72
|
|
|
73
73
|
return Object.entries(bgp?.neighbors ?? {}).map(([name, neighbor]) => {
|
|
74
74
|
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
75
|
-
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
name,
|
|
78
|
+
address: neighbor.address,
|
|
79
|
+
role: neighbor.role ?? group?.role,
|
|
80
|
+
import: neighbor.import ?? group?.import,
|
|
81
|
+
export: neighbor.export ?? group?.export,
|
|
82
|
+
};
|
|
76
83
|
});
|
|
77
84
|
}
|
|
78
85
|
|
|
@@ -82,12 +89,16 @@ export function offered(device: Device, policy: string | undefined): string[] {
|
|
|
82
89
|
return rules.filter((rule) => rule.action === "accept" && rule.match?.prefix).map((rule) => rule.match!.prefix!);
|
|
83
90
|
}
|
|
84
91
|
|
|
85
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Every BGP export ends in a reject that matches everything, so nothing leaves by accident. An export to a neighbor whose `role` is `customer` is left out: sending a customer everything you know is what it pays for, and cannot leak a route to the rest of the internet.
|
|
94
|
+
*/
|
|
86
95
|
export const exportsEndInReject: Check = (devices) => {
|
|
87
96
|
const findings: Finding[] = [];
|
|
88
97
|
|
|
89
98
|
for (const device of devices) {
|
|
90
99
|
for (const neighbor of neighborPolicies(device)) {
|
|
100
|
+
if (neighbor.role === "customer") continue;
|
|
101
|
+
|
|
91
102
|
const last = neighbor.export ? device.policies?.[neighbor.export]?.at(-1) : undefined;
|
|
92
103
|
|
|
93
104
|
if (neighbor.export && (last?.action !== "reject" || last.match)) {
|
|
@@ -215,6 +226,8 @@ export type CommunityScheme<Class extends string = string> = {
|
|
|
215
226
|
asn: number;
|
|
216
227
|
/** Set on import: the kind of neighbor a route came from, `asn:function:class`. */
|
|
217
228
|
learned_from?: { function: number; classes: Readonly<Record<Class, number>> };
|
|
229
|
+
/** The kinds of neighbor allowed to ask you for something, usually your customers: the requests below that they set on their routes stay. From anyone else, they are removed on import. */
|
|
230
|
+
requests_from?: readonly NoInfer<Class>[];
|
|
218
231
|
/** Set on import: the site a route was learned at, `asn:function:site`. `sites` names the sites, for the catalogue. */
|
|
219
232
|
learned_at?: { function: number; sites?: Readonly<Record<number, string>> };
|
|
220
233
|
/** Set on import: the AS a route was learned from, `asn:function:asn`. */
|
|
@@ -238,8 +251,8 @@ const article = (word: string) => (/^[aeiou]/i.test(word) ? `an ${word}` : `a ${
|
|
|
238
251
|
/**
|
|
239
252
|
* 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.
|
|
240
253
|
*
|
|
241
|
-
* - `tag(neighborClass, neighborAs, { site
|
|
242
|
-
* - `actions(neighborAs)`: rules for an export policy toward one neighbor. They act on a customer's action communities, then remove all of yours. Place them before the rules that accept.
|
|
254
|
+
* - `tag(neighborClass, neighborAs, { site })`: a rule for an import policy. It removes your communities a neighbor set, then adds where the route was learned. A neighbor of a class in `requests_from` keeps its requests. `site` is needed when the scheme has `learned_at`.
|
|
255
|
+
* - `actions(neighborAs, { blackhole })`: rules for an export policy toward one neighbor. They act on a customer's action communities, then remove all of yours. Place them before the rules that accept. A blackholed route goes no further, unless `blackhole` names the neighbor's own blackhole community: then it is passed on with it, so the neighbor drops the traffic too.
|
|
243
256
|
* - `blackhole(prefixSet)`: a rule for a customer's import policy, before anything that rejects long prefixes. It takes a route the customer asked to blackhole, but only inside `prefixSet`, its own space, and drops traffic to it here.
|
|
244
257
|
* - `catalogue`: every community, for `communities` in `defineNetwork`.
|
|
245
258
|
*/
|
|
@@ -252,12 +265,9 @@ export function communityScheme<const Class extends string>(scheme: CommunitySch
|
|
|
252
265
|
(fn): fn is number => fn !== undefined
|
|
253
266
|
);
|
|
254
267
|
|
|
255
|
-
const tag = (
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
options: { site?: number; keepActions?: boolean } = {}
|
|
259
|
-
): PolicyRule<never, never> => {
|
|
260
|
-
const { site, keepActions = false } = options;
|
|
268
|
+
const tag = (neighborClass: Class, neighborAs: number, options: { site?: number } = {}): PolicyRule<never, never> => {
|
|
269
|
+
const { site } = options;
|
|
270
|
+
const mayAsk = scheme.requests_from?.includes(neighborClass) ?? false;
|
|
261
271
|
if (scheme.learned_at && site === undefined)
|
|
262
272
|
throw new Error(`communityScheme: tag for AS${neighborAs} needs a site, for learned_at`);
|
|
263
273
|
|
|
@@ -265,7 +275,7 @@ export function communityScheme<const Class extends string>(scheme: CommunitySch
|
|
|
265
275
|
description: `Learned from AS${neighborAs}`,
|
|
266
276
|
set: {
|
|
267
277
|
remove_communities: ours.standard,
|
|
268
|
-
remove_large_communities:
|
|
278
|
+
remove_large_communities: mayAsk ? information.map((fn) => community(fn, "*")) : ours.large,
|
|
269
279
|
add_large_communities: [
|
|
270
280
|
...(scheme.learned_from ? [community(scheme.learned_from.function, scheme.learned_from.classes[neighborClass])] : []),
|
|
271
281
|
...(scheme.learned_at ? [community(scheme.learned_at.function, site!)] : []),
|
|
@@ -286,8 +296,8 @@ export function communityScheme<const Class extends string>(scheme: CommunitySch
|
|
|
286
296
|
};
|
|
287
297
|
};
|
|
288
298
|
|
|
289
|
-
const actions = (neighborAs: number): PolicyRule<never, never>[] => [
|
|
290
|
-
...(scheme.blackhole !== undefined
|
|
299
|
+
const actions = (neighborAs: number, options: { blackhole?: string } = {}): PolicyRule<never, never>[] => [
|
|
300
|
+
...(scheme.blackhole !== undefined && !options.blackhole
|
|
291
301
|
? [
|
|
292
302
|
{
|
|
293
303
|
description: "Blackholed here, never announced",
|
|
@@ -310,6 +320,20 @@ export function communityScheme<const Class extends string>(scheme: CommunitySch
|
|
|
310
320
|
},
|
|
311
321
|
]
|
|
312
322
|
: []),
|
|
323
|
+
...(scheme.blackhole !== undefined && options.blackhole
|
|
324
|
+
? [
|
|
325
|
+
{
|
|
326
|
+
description: `Blackhole passed on to AS${neighborAs}`,
|
|
327
|
+
match: { large_community: community(scheme.blackhole, 0) },
|
|
328
|
+
set: {
|
|
329
|
+
remove_communities: ours.standard,
|
|
330
|
+
remove_large_communities: ours.large,
|
|
331
|
+
add_communities: [options.blackhole],
|
|
332
|
+
},
|
|
333
|
+
action: "accept" as const,
|
|
334
|
+
},
|
|
335
|
+
]
|
|
336
|
+
: []),
|
|
313
337
|
...PREPENDS.flatMap(([name, times]) => {
|
|
314
338
|
const fn = scheme.prepend?.[name];
|
|
315
339
|
if (fn === undefined) return [];
|
|
@@ -362,3 +386,95 @@ export function communityScheme<const Class extends string>(scheme: CommunitySch
|
|
|
362
386
|
|
|
363
387
|
return { community, tag, actions, blackhole, catalogue };
|
|
364
388
|
}
|
|
389
|
+
|
|
390
|
+
/** A member of an internet exchange: a network with a port on the fabric and an address on the peering LAN. */
|
|
391
|
+
export type ExchangeMember = {
|
|
392
|
+
/** Lowercase, and never reused. */
|
|
393
|
+
name: string;
|
|
394
|
+
asn: number;
|
|
395
|
+
/** The member's port on each fabric switch, by switch name. */
|
|
396
|
+
ports: Readonly<Record<string, string>>;
|
|
397
|
+
/** Its addresses on the peering LAN. The route servers peer with these. */
|
|
398
|
+
ipv4?: IP;
|
|
399
|
+
ipv6?: IP;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/** An internet exchange's peering LAN, as its fabric carries it. */
|
|
403
|
+
export type InternetExchange<V extends string> = {
|
|
404
|
+
/** The peering VLAN, by the name the fabric switches give it. */
|
|
405
|
+
vlan: V;
|
|
406
|
+
/** The peering LAN's subnets. */
|
|
407
|
+
lan: { ipv4?: Prefix; ipv6?: Prefix };
|
|
408
|
+
/** Addresses on the peering LAN no member may take, such as the route servers'. */
|
|
409
|
+
reserved?: readonly IP[];
|
|
410
|
+
/** How much broadcast, multicast and unknown unicast a member port lets through, in percent of its speed. 1 when absent. */
|
|
411
|
+
storm_percent?: number;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Member ports and a check for an internet exchange's fabric.
|
|
416
|
+
*
|
|
417
|
+
* - `ports(device)`: every member's port on one switch. A member port is in the peering VLAN and nothing else, takes no part in spanning tree or LLDP, and lets only a trickle of broadcast, multicast and unknown unicast through.
|
|
418
|
+
* - `check`: members never share a name, an AS or an address; every address is inside the peering LAN and not reserved; every member port carries the peering VLAN alone.
|
|
419
|
+
*/
|
|
420
|
+
export function internetExchange<const V extends string>(exchange: InternetExchange<V>, members: readonly ExchangeMember[]) {
|
|
421
|
+
const percent = exchange.storm_percent ?? 1;
|
|
422
|
+
|
|
423
|
+
const ports = (device: string) => {
|
|
424
|
+
const result: Record<string, Port<V, string, string, string>> = {};
|
|
425
|
+
|
|
426
|
+
for (const member of members) {
|
|
427
|
+
const port = member.ports[device];
|
|
428
|
+
if (!port) continue;
|
|
429
|
+
|
|
430
|
+
result[port] = {
|
|
431
|
+
description: `${member.name}, AS${member.asn}`,
|
|
432
|
+
access_vlan: exchange.vlan,
|
|
433
|
+
stp: false,
|
|
434
|
+
lldp: false,
|
|
435
|
+
storm_control: { broadcast: { percent }, multicast: { percent }, unknown_unicast: { percent } },
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return result;
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const check: Check = (devices) => {
|
|
443
|
+
const findings: Finding[] = [];
|
|
444
|
+
const error = (message: string, device?: string) => findings.push({ level: "error", ...(device ? { device } : {}), message });
|
|
445
|
+
const taken = new Map<string, string>((exchange.reserved ?? []).map((address) => [address, "reserved"]));
|
|
446
|
+
|
|
447
|
+
members.forEach((member, index) => {
|
|
448
|
+
for (const other of members.slice(index + 1)) {
|
|
449
|
+
if (other.name === member.name) error(`member ${member.name} is declared twice`);
|
|
450
|
+
if (other.asn === member.asn) error(`members ${member.name} and ${other.name} share AS${member.asn}`);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
for (const [address, lan] of [
|
|
454
|
+
[member.ipv4, exchange.lan.ipv4],
|
|
455
|
+
[member.ipv6, exchange.lan.ipv6],
|
|
456
|
+
] as const) {
|
|
457
|
+
if (!address) continue;
|
|
458
|
+
if (!lan || !contains(lan, address)) error(`${address} of ${member.name} is outside the peering LAN`);
|
|
459
|
+
|
|
460
|
+
const holder = taken.get(address);
|
|
461
|
+
if (holder) error(`${address} of ${member.name} is already ${holder === "reserved" ? "reserved" : `${holder}'s`}`);
|
|
462
|
+
taken.set(address, member.name);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
for (const [deviceName, portName] of Object.entries(member.ports)) {
|
|
466
|
+
const device = devices.find((candidate) => candidate.name === deviceName);
|
|
467
|
+
const port = device?.ports?.[portName as keyof typeof device.ports];
|
|
468
|
+
const carried = port ? vlansOf(port) : [];
|
|
469
|
+
|
|
470
|
+
if (carried.length !== 1 || carried[0] !== exchange.vlan) {
|
|
471
|
+
error(`port ${portName} of ${member.name} does not carry the peering VLAN alone`, deviceName);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
return findings;
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
return { ports, check };
|
|
480
|
+
}
|