@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/src/helpers/bgp.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
+
import { Address } from "../core/address";
|
|
1
2
|
import type { Community } from "../core/define";
|
|
2
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
Device,
|
|
5
|
+
IP,
|
|
6
|
+
LargeCommunity,
|
|
7
|
+
LargeCommunityPattern,
|
|
8
|
+
PolicyRule,
|
|
9
|
+
Prefix,
|
|
10
|
+
StandardCommunity,
|
|
11
|
+
StandardCommunityPattern,
|
|
12
|
+
} from "../schema";
|
|
3
13
|
|
|
4
14
|
/** The prefix lengths a BGP import accepts. */
|
|
5
15
|
export type SanityLengths = {
|
|
@@ -9,6 +19,33 @@ export type SanityLengths = {
|
|
|
9
19
|
ipv6?: { min: number; max: number };
|
|
10
20
|
};
|
|
11
21
|
|
|
22
|
+
/** A prefix we announce, and what it is. */
|
|
23
|
+
export type Announced = { prefix: Prefix; description?: string };
|
|
24
|
+
|
|
25
|
+
/** What `Bgp.learn` builds an import from. */
|
|
26
|
+
export type LearnOptions<P extends string> = {
|
|
27
|
+
/** Rules of your own, first. */
|
|
28
|
+
before?: readonly PolicyRule<P, never>[];
|
|
29
|
+
/** A policy holding `Bgp.sanity()` to call. The sanity rules are written in the import itself when absent. */
|
|
30
|
+
sanity?: P;
|
|
31
|
+
/** The source address for routes of its family, one per family. */
|
|
32
|
+
sources?: readonly IP[];
|
|
33
|
+
/** Preference for the routes learned. Higher wins. */
|
|
34
|
+
local_pref?: number;
|
|
35
|
+
/** Where the route came from: a community to add, or a rule, such as `communityScheme(...).tag(...)`. */
|
|
36
|
+
tag?: StandardCommunity | LargeCommunity | PolicyRule<never, never>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** What `Bgp.defaultOnly` sets on the default routes it takes. */
|
|
40
|
+
export type DefaultOnlyOptions = {
|
|
41
|
+
/** Preference for the default route. Higher wins. */
|
|
42
|
+
local_pref?: number;
|
|
43
|
+
/** Shown beside each rule. `Default route, ipv4` and `Default route, ipv6` when absent. */
|
|
44
|
+
description?: string;
|
|
45
|
+
/** The source address for the default of its family, one per family. */
|
|
46
|
+
sources?: readonly IP[];
|
|
47
|
+
};
|
|
48
|
+
|
|
12
49
|
/** Kinds of neighbor and the number each is tagged with, such as `{ transit: 1, customer: 3 }`. */
|
|
13
50
|
export type CommunityClasses = Readonly<Record<string, number>>;
|
|
14
51
|
|
|
@@ -45,7 +82,7 @@ export type CommunityScheme<
|
|
|
45
82
|
/** The class names a scheme declares, or any name when it declares none. */
|
|
46
83
|
type ClassName<Classes extends CommunityClasses> = {} extends Classes ? string : Extract<keyof Classes, string>;
|
|
47
84
|
|
|
48
|
-
/** 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. */
|
|
49
86
|
export class Bgp {
|
|
50
87
|
private static readonly PREPENDS = [
|
|
51
88
|
["once", 1],
|
|
@@ -53,6 +90,12 @@ export class Bgp {
|
|
|
53
90
|
["three_times", 3],
|
|
54
91
|
] as const;
|
|
55
92
|
|
|
93
|
+
/** The default route of each family. */
|
|
94
|
+
private static readonly DEFAULTS = [
|
|
95
|
+
{ family: "ipv4", prefix: "0.0.0.0/0" },
|
|
96
|
+
{ family: "ipv6", prefix: "::/0" },
|
|
97
|
+
] as const;
|
|
98
|
+
|
|
56
99
|
/** 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. */
|
|
57
100
|
static sanity(lengths: SanityLengths = {}): PolicyRule<never, never>[] {
|
|
58
101
|
const ipv4 = lengths.ipv4 ?? { min: 8, max: 24 };
|
|
@@ -83,6 +126,65 @@ export class Bgp {
|
|
|
83
126
|
];
|
|
84
127
|
}
|
|
85
128
|
|
|
129
|
+
/** 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. */
|
|
130
|
+
static announce(prefixes: readonly Announced[]): PolicyRule<never, never>[] {
|
|
131
|
+
return [
|
|
132
|
+
...prefixes.map(({ prefix, description }) => ({
|
|
133
|
+
...(description ? { description } : {}),
|
|
134
|
+
match: { prefix },
|
|
135
|
+
action: "accept" as const,
|
|
136
|
+
})),
|
|
137
|
+
{ description: "Nothing else", action: "reject" },
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 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.
|
|
143
|
+
*
|
|
144
|
+
* `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.
|
|
145
|
+
*/
|
|
146
|
+
static learn<const P extends string = never>(options: LearnOptions<P> = {}): PolicyRule<P, never>[] {
|
|
147
|
+
const { tag } = options;
|
|
148
|
+
|
|
149
|
+
return [
|
|
150
|
+
...(options.before ?? []),
|
|
151
|
+
...(options.sanity ? [{ call: options.sanity }] : Bgp.sanity()),
|
|
152
|
+
...(options.sources ?? []).map((source) => ({ set: { preferred_source: source } })),
|
|
153
|
+
...(options.local_pref !== undefined ? [{ set: { local_pref: options.local_pref } }] : []),
|
|
154
|
+
...(tag === undefined ? [] : [typeof tag === "object" ? tag : Bgp.adding(tag)]),
|
|
155
|
+
{ action: "accept" },
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 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.
|
|
161
|
+
*/
|
|
162
|
+
static defaultOnly(options: DefaultOnlyOptions = {}): PolicyRule<never, never>[] {
|
|
163
|
+
return Bgp.DEFAULTS.map(({ family, prefix }) => {
|
|
164
|
+
const source = options.sources?.find((address) => Address.family(address) === family);
|
|
165
|
+
const set = {
|
|
166
|
+
...(options.local_pref !== undefined ? { local_pref: options.local_pref } : {}),
|
|
167
|
+
...(source ? { preferred_source: source } : {}),
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
description: options.description ?? `Default route, ${family}`,
|
|
172
|
+
match: { prefix },
|
|
173
|
+
...(Object.keys(set).length ? { set } : {}),
|
|
174
|
+
action: "accept" as const,
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A rule adding one community, standard or large. */
|
|
180
|
+
private static adding(community: StandardCommunity | LargeCommunity): PolicyRule<never, never> {
|
|
181
|
+
const large = community.split(":").length === 3;
|
|
182
|
+
return {
|
|
183
|
+
description: `Tag ${community}`,
|
|
184
|
+
set: large ? { add_large_communities: [community as LargeCommunity] } : { add_communities: [community as StandardCommunity] },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
86
188
|
/** Each BGP neighbor's own policies and role, or its group's. */
|
|
87
189
|
static neighborPolicies(device: Device) {
|
|
88
190
|
const bgp = device.routing?.bgp;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** The far end of a port, for a description of your own: the device and its port by Circuit's name. */
|
|
2
|
+
export type FarEnd = { device: string; port: string };
|
|
3
|
+
|
|
4
|
+
/** What every cable of one entry shares. */
|
|
5
|
+
type Shared = {
|
|
6
|
+
/** What is between the two ends, such as a carrier's local loop. Both ends get it on their `link`. */
|
|
7
|
+
through?: string;
|
|
8
|
+
/** A port's description from its far end. When absent, Circuit writes the far device and the far port as printed on it, such as `firewall-1 port6`, from the far device's own file. */
|
|
9
|
+
describe?: (far: FarEnd) => string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** A port a cable builds: the other end, the LAG it is in, and a description when `describe` gives one. */
|
|
13
|
+
type CablePort<L extends string> = {
|
|
14
|
+
link: { device: string; port: string; through?: string };
|
|
15
|
+
lag?: L;
|
|
16
|
+
description?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Ports for both ends of the cables between two of our devices, each cable written once. */
|
|
20
|
+
export class Cables {
|
|
21
|
+
/**
|
|
22
|
+
* One cable, both ends. Spread each end into its device's `ports`:
|
|
23
|
+
*
|
|
24
|
+
* ```ts
|
|
25
|
+
* const uplink = Cables.link({ a: { device: "router-1", port: "25g-1" }, b: { device: "switch-1", port: "10g-48" } });
|
|
26
|
+
* // router-1: ports: { ...uplink.a }
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
static link<const NA extends string, const NB extends string>(
|
|
30
|
+
spec: Shared & { a: { device: string; port: NA }; b: { device: string; port: NB } }
|
|
31
|
+
) {
|
|
32
|
+
return {
|
|
33
|
+
a: { [spec.a.port]: Cables.end(spec, spec.b.device, spec.b.port) } as Record<NA, CablePort<never>>,
|
|
34
|
+
b: { [spec.b.port]: Cables.end(spec, spec.a.device, spec.a.port) } as Record<NB, CablePort<never>>,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The cables of a LAG, both ends, from one list read from end A: `{ "25g-1": "10g-2" }` is a cable from A's 25g-1 to B's 10g-2. Each port is a member of its end's LAG. Spread each end into its device's `ports`; the LAG itself stays on each device.
|
|
40
|
+
*/
|
|
41
|
+
static lag<const LA extends string, const LB extends string, const C extends Readonly<Record<string, string>>>(
|
|
42
|
+
spec: Shared & { a: { device: string; lag: LA }; b: { device: string; lag: LB }; cables: C }
|
|
43
|
+
) {
|
|
44
|
+
const pairs = Object.entries(spec.cables);
|
|
45
|
+
|
|
46
|
+
const taken = new Map<string, string>();
|
|
47
|
+
for (const [near, far] of pairs) {
|
|
48
|
+
const other = taken.get(far);
|
|
49
|
+
if (other) throw new Error(`Cables.lag: ${spec.b.device} ${far} is cabled to both ${other} and ${near} on ${spec.a.device}`);
|
|
50
|
+
taken.set(far, near);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const a = Object.fromEntries(pairs.map(([near, far]) => [near, { ...Cables.end(spec, spec.b.device, far), lag: spec.a.lag }]));
|
|
54
|
+
const b = Object.fromEntries(pairs.map(([near, far]) => [far, { ...Cables.end(spec, spec.a.device, near), lag: spec.b.lag }]));
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
a: a as Record<keyof C & string, CablePort<LA>>,
|
|
58
|
+
b: b as Record<C[keyof C], CablePort<LB>>,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A port facing `port` on the far device. */
|
|
63
|
+
private static end(shared: Shared, device: string, port: string): CablePort<never> {
|
|
64
|
+
return {
|
|
65
|
+
link: { device, port, ...(shared.through ? { through: shared.through } : {}) },
|
|
66
|
+
...(shared.describe ? { description: shared.describe({ device, port }) } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/helpers/index.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
// Building blocks most networks write the same way: address lists, edge filters, a BGP import sanity policy, an exchange's member ports and route servers, checks, and helpers for checks of your own. Each returns plain config to place where it belongs, so nothing here applies itself.
|
|
1
|
+
// Building blocks most networks write the same way: address lists, cables written once for both ends, edge filters, a BGP import sanity policy, an exchange's member ports and route servers, upstreams written once, checks, and helpers for checks of your own. Each returns plain config to place where it belongs, so nothing here applies itself.
|
|
2
2
|
|
|
3
3
|
export { Bgp } from "./bgp";
|
|
4
|
-
export type { CommunityScheme, SanityLengths } from "./bgp";
|
|
4
|
+
export type { Announced, CommunityScheme, DefaultOnlyOptions, LearnOptions, SanityLengths } from "./bgp";
|
|
5
|
+
export { Cables } from "./cables";
|
|
6
|
+
export type { FarEnd } from "./cables";
|
|
5
7
|
export { Checks } from "./checks";
|
|
6
8
|
export type { TrustBoundary } from "./checks";
|
|
7
9
|
export { Exchange } from "./exchange";
|
|
8
10
|
export type { ExchangeMember, InternetExchange } from "./exchange";
|
|
9
11
|
export { Filters } from "./filters";
|
|
10
12
|
export { RouteServerConfig } from "./route-server";
|
|
13
|
+
export { Upstreams } from "./upstreams";
|
|
14
|
+
export type { Upstream, UpstreamCatalog, UpstreamPolicies } from "./upstreams";
|
|
11
15
|
export type { RouteServer } from "./route-server";
|
|
12
16
|
export type { AntiAmplificationOptions } from "./filters";
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { Address } from "../core/address";
|
|
2
|
+
import type {
|
|
3
|
+
Exchange as ExchangeLan,
|
|
4
|
+
Family,
|
|
5
|
+
FilterRule,
|
|
6
|
+
Gre,
|
|
7
|
+
InterfaceAddress,
|
|
8
|
+
IP,
|
|
9
|
+
LargeCommunity,
|
|
10
|
+
Neighbor,
|
|
11
|
+
NeighborSettings,
|
|
12
|
+
PolicyRule,
|
|
13
|
+
Port,
|
|
14
|
+
Prefix,
|
|
15
|
+
Secret,
|
|
16
|
+
StandardCommunity,
|
|
17
|
+
Vlan,
|
|
18
|
+
VlanInterface,
|
|
19
|
+
} from "../schema";
|
|
20
|
+
import { Bgp, type Announced } from "./bgp";
|
|
21
|
+
|
|
22
|
+
/** One network we exchange routes with: a transit we buy, an exchange's route servers, or a peer. */
|
|
23
|
+
export type Upstream = {
|
|
24
|
+
/** Its name as people write it, for descriptions: `Transit A`. The key when absent. */
|
|
25
|
+
name?: string;
|
|
26
|
+
asn: number;
|
|
27
|
+
/** What it is to us: `provider` for a transit, `rs` for an exchange's route servers, `peer` for a direct session. */
|
|
28
|
+
role: "provider" | "rs" | "peer";
|
|
29
|
+
/** Where it lands on our side: a VLAN, our addresses on it, and the switch port it arrives on. A peer over a tunnel, or over an exchange's VLAN, has none. */
|
|
30
|
+
handoff?: {
|
|
31
|
+
/** The VLAN's ID. */
|
|
32
|
+
vlan: number;
|
|
33
|
+
/** It sends its VLAN tagged: its port gets a native VLAN of its own with this ID, which nothing else is in. Untagged when absent. */
|
|
34
|
+
native?: number;
|
|
35
|
+
/** Its number in names with `{number}`, such as `core-ipt01-transita`. Written here, so removing another upstream renumbers nothing. */
|
|
36
|
+
number?: number;
|
|
37
|
+
/** Our addresses on it. `{ address, peer }` for a point-to-point address whose far end is outside its own prefix. */
|
|
38
|
+
addresses: readonly (InterfaceAddress | { address: InterfaceAddress; peer: IP })[];
|
|
39
|
+
/** The switch port it arrives on. */
|
|
40
|
+
port?: { device: string; port: string; storm_control?: Port<string, string, string, string>["storm_control"] };
|
|
41
|
+
/** An exchange's LAN, for `circuit peeringdb`. */
|
|
42
|
+
exchange?: ExchangeLan;
|
|
43
|
+
};
|
|
44
|
+
/** A GRE tunnel to it. `number` is its number in names with `{number}`. */
|
|
45
|
+
tunnel?: { local: IP; remote: IP; addresses: readonly InterfaceAddress[]; mtu?: number; number?: number };
|
|
46
|
+
/** Our BGP sessions with it, by name: its address, and ours when it is not the interface's. */
|
|
47
|
+
sessions: Readonly<Record<string, { address: IP; local_address?: IP }>>;
|
|
48
|
+
/** The sessions are more than one hop away, from this address of ours. They share a group. */
|
|
49
|
+
multihop?: { local_address: IP; families?: readonly Family[]; password?: Secret };
|
|
50
|
+
/** The most prefixes a session may send, by its family: about twice what it sends. Over it, the session closes until it is cleared. */
|
|
51
|
+
limits?: { ipv4?: number; ipv6?: number };
|
|
52
|
+
/** What we take. Nothing when absent. */
|
|
53
|
+
import?: {
|
|
54
|
+
/** Its default route, at this preference. */
|
|
55
|
+
default?: { local_pref?: number; description?: string };
|
|
56
|
+
/** Its routes, through `Bgp.learn`: the `before` rules, sanity, this preference, the tag. Only the default when absent. */
|
|
57
|
+
learn?: { local_pref?: number; before?: readonly PolicyRule<never, never>[] };
|
|
58
|
+
};
|
|
59
|
+
/** Rules of yours in its export, between the community actions and what we announce, such as a prepend. */
|
|
60
|
+
export?: readonly PolicyRule<never, never>[];
|
|
61
|
+
/** What we announce to it. Everything else is rejected. */
|
|
62
|
+
announce: readonly Announced[];
|
|
63
|
+
/** Drop RFC 1918 sources arriving from it. `privateDrops()` lists the VLANs. */
|
|
64
|
+
drop_private?: true;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Upstreams by key. The key names everything built from it. */
|
|
68
|
+
export type UpstreamCatalog = Readonly<Record<string, Upstream>>;
|
|
69
|
+
|
|
70
|
+
/** A name made from an upstream: `{key}` is its key, `{KEY}` the key in capitals, `{number}` its `handoff.number` or `tunnel.number`. */
|
|
71
|
+
type Pattern = string;
|
|
72
|
+
/** A pattern for every upstream, or one per role. A role left out takes the default. */
|
|
73
|
+
type ByRole = Pattern | { readonly provider?: Pattern; readonly rs?: Pattern; readonly peer?: Pattern };
|
|
74
|
+
|
|
75
|
+
/** How the names built from each upstream are made. Each one left out takes its default. */
|
|
76
|
+
export type UpstreamNames = {
|
|
77
|
+
/** The VLAN a handoff is on. `{key}` when absent. */
|
|
78
|
+
vlan?: ByRole;
|
|
79
|
+
/** The native VLAN of a handoff sent tagged. `{key}-native` when absent. */
|
|
80
|
+
native?: Pattern;
|
|
81
|
+
/** The router's interface: per role, and `tunnel` for one reached over a tunnel. `{key}` when absent. */
|
|
82
|
+
interface?: Pattern | { readonly provider?: Pattern; readonly rs?: Pattern; readonly peer?: Pattern; readonly tunnel?: Pattern };
|
|
83
|
+
/** The import policy. `{KEY}-IN` when absent. */
|
|
84
|
+
import?: Pattern;
|
|
85
|
+
/** The export policy. `{KEY}-OUT` when absent. */
|
|
86
|
+
export?: Pattern;
|
|
87
|
+
/** The BGP group of an exchange's route servers or of multihop sessions. `{key}` when absent. */
|
|
88
|
+
group?: Pattern;
|
|
89
|
+
/** The description of a handoff's switch port. Its `name` when absent. No device names it, so it may be any function. */
|
|
90
|
+
port?: (key: string, upstream: Upstream) => string;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** How each upstream's policies are written: shared by every upstream of a catalog. */
|
|
94
|
+
export type UpstreamPolicies<P extends string> = {
|
|
95
|
+
/** A policy holding `Bgp.sanity()` that each import calls. The sanity rules are written in each import when absent. */
|
|
96
|
+
sanity?: P;
|
|
97
|
+
/** Where a route came from, by upstream: a community or a rule, such as `communityScheme(...).tag(...)`. Nothing is tagged when absent. */
|
|
98
|
+
tag?: (key: string, upstream: Upstream) => StandardCommunity | LargeCommunity | PolicyRule<never, never>;
|
|
99
|
+
/** Rules first in each export, such as `communityScheme(...).actions(upstream.asn)`. */
|
|
100
|
+
actions?: (key: string, upstream: Upstream) => readonly PolicyRule<never, never>[];
|
|
101
|
+
/** The source address for routes of its family, one per family. */
|
|
102
|
+
sources?: readonly IP[];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** The names a device can refer to, and their patterns when the project gives none. */
|
|
106
|
+
const DEFAULTS = {
|
|
107
|
+
vlan: "{key}",
|
|
108
|
+
native: "{key}-native",
|
|
109
|
+
interface: "{key}",
|
|
110
|
+
import: "{KEY}-IN",
|
|
111
|
+
export: "{KEY}-OUT",
|
|
112
|
+
group: "{key}",
|
|
113
|
+
} as const;
|
|
114
|
+
|
|
115
|
+
type Defaults = typeof DEFAULTS;
|
|
116
|
+
type Field = keyof Defaults;
|
|
117
|
+
|
|
118
|
+
type Key<C> = keyof C & string;
|
|
119
|
+
/** The upstreams that land on a VLAN of ours. */
|
|
120
|
+
type Landed<C> = { [K in Key<C>]: C[K] extends { handoff: object } ? K : never }[Key<C>];
|
|
121
|
+
/** The upstreams that send their VLAN tagged. */
|
|
122
|
+
type Tagged<C> = { [K in Key<C>]: C[K] extends { handoff: { native: number } } ? K : never }[Key<C>];
|
|
123
|
+
/** The upstreams reached over a tunnel. */
|
|
124
|
+
type Tunneled<C> = { [K in Key<C>]: C[K] extends { tunnel: object } ? K : never }[Key<C>];
|
|
125
|
+
/** The upstreams whose sessions share a group: an exchange's route servers, or multihop sessions. */
|
|
126
|
+
type Grouped<C> = { [K in Key<C>]: C[K] extends { role: "rs" } | { multihop: object } ? K : never }[Key<C>];
|
|
127
|
+
/** The upstreams we take routes from. */
|
|
128
|
+
type Importing<C> = { [K in Key<C>]: C[K] extends { import: object } ? K : never }[Key<C>];
|
|
129
|
+
|
|
130
|
+
/** The project's pattern for a name, or the default. */
|
|
131
|
+
type PatternOf<N, F extends Field> = N extends { [X in F]: infer P } ? P : Defaults[F];
|
|
132
|
+
/** The pattern an upstream takes: the only one, its tunnel's, its role's, or the default. */
|
|
133
|
+
type Chosen<P, U, F extends Field> = P extends string
|
|
134
|
+
? P
|
|
135
|
+
: U extends { tunnel: object }
|
|
136
|
+
? P extends { tunnel: infer T extends string }
|
|
137
|
+
? T
|
|
138
|
+
: Defaults[F]
|
|
139
|
+
: U extends { role: infer R extends string }
|
|
140
|
+
? P extends { [X in R]: infer T extends string }
|
|
141
|
+
? T
|
|
142
|
+
: Defaults[F]
|
|
143
|
+
: Defaults[F];
|
|
144
|
+
/** Every `From` in `S` replaced. */
|
|
145
|
+
type Replace<S extends string, From extends string, To extends string> = S extends `${infer A}${From}${infer B}`
|
|
146
|
+
? `${A}${To}${Replace<B, From, To>}`
|
|
147
|
+
: S;
|
|
148
|
+
/** An upstream's `handoff.number` or `tunnel.number`. */
|
|
149
|
+
type NumberOf<U> = U extends { handoff: { number: infer N extends number } }
|
|
150
|
+
? N
|
|
151
|
+
: U extends { tunnel: { number: infer N extends number } }
|
|
152
|
+
? N
|
|
153
|
+
: number;
|
|
154
|
+
/** The name built for upstream `K`: its pattern with the key, the key in capitals and its number filled in. */
|
|
155
|
+
type Named<C, N, F extends Field, K extends Key<C>> = Replace<
|
|
156
|
+
Replace<Replace<Chosen<PatternOf<N, F>, C[K], F>, "{key}", K>, "{KEY}", Uppercase<K>>,
|
|
157
|
+
"{number}",
|
|
158
|
+
`${NumberOf<C[K]>}`
|
|
159
|
+
>;
|
|
160
|
+
/** Each of these upstreams' names. */
|
|
161
|
+
type Names<C, N, F extends Field, Ks extends Key<C>> = { [K in Ks]: Named<C, N, F, K> }[Ks];
|
|
162
|
+
|
|
163
|
+
/** The catalog as written, with any key the shape does not have refused, at every level. */
|
|
164
|
+
type Exact<S, T> = T extends readonly unknown[] | string | number | boolean | Secret | ((...args: never[]) => unknown)
|
|
165
|
+
? T
|
|
166
|
+
: { [K in keyof T]: K extends keyof S ? Exact<NonNullable<S[K]>, T[K]> : never };
|
|
167
|
+
|
|
168
|
+
/** The names as written, with a name or a role Upstreams does not make refused. */
|
|
169
|
+
type ExactNames<N> = {
|
|
170
|
+
[F in keyof N]: F extends keyof UpstreamNames
|
|
171
|
+
? N[F] extends string | ((...args: never[]) => unknown)
|
|
172
|
+
? N[F]
|
|
173
|
+
: { [R in keyof N[F]]: R extends "provider" | "rs" | "peer" | "tunnel" ? N[F][R] : never }
|
|
174
|
+
: never;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** A network's upstreams written once, and each device's part built from them. */
|
|
178
|
+
export class Upstreams {
|
|
179
|
+
/**
|
|
180
|
+
* Each upstream written once: its AS, where it lands, its sessions, limits and policy. Everything a device needs is built from it, under names made from each key, so a device that names one is checked. By default:
|
|
181
|
+
*
|
|
182
|
+
* - VLAN `<key>`, and `<key>-native` for one that sends its VLAN tagged. The router's interface is `<key>` too, a VLAN interface or a GRE tunnel.
|
|
183
|
+
* - Policies `<KEY>-IN` and `<KEY>-OUT`; a BGP group `<key>` for an exchange's route servers or multihop sessions.
|
|
184
|
+
*
|
|
185
|
+
* `names` gives patterns of your own, such as `{ interface: { provider: "core-ipt0{number}-{key}" } }`; see `UpstreamNames`.
|
|
186
|
+
*
|
|
187
|
+
* On the router: `vlans()`, `interfaces()`, `uplinks()`, `groups()`, `neighbors()`, `policies({ ... })`, `tunnelRules()`, `addresses()`. On a switch: `vlans(device)`, `ports(device)`, `vlansOn(device)`. For checks and ACLs: `untrusted()`, `natives()`, `privateDrops()`. Lists follow the catalog's order, where order counts on a device.
|
|
188
|
+
*/
|
|
189
|
+
static define<const C extends UpstreamCatalog, const N extends UpstreamNames = {}>(
|
|
190
|
+
catalog: C & { [K in keyof C]: Exact<Upstream, C[K]> },
|
|
191
|
+
options: { names?: N & ExactNames<N> } = {}
|
|
192
|
+
) {
|
|
193
|
+
type V = Names<C, N, "vlan", Landed<C>>;
|
|
194
|
+
type T = Names<C, N, "native", Tagged<C>>;
|
|
195
|
+
type I = Names<C, N, "interface", Landed<C>>;
|
|
196
|
+
type G = Names<C, N, "interface", Tunneled<C>>;
|
|
197
|
+
type PolicyName = Names<C, N, "import", Importing<C>> | Names<C, N, "export", Key<C>>;
|
|
198
|
+
type Group = Names<C, N, "group", Grouped<C>>;
|
|
199
|
+
|
|
200
|
+
const names: UpstreamNames = options.names ?? {};
|
|
201
|
+
const all = Object.entries(catalog) as [Key<C>, Upstream][];
|
|
202
|
+
const nameOf = (key: string, upstream: Upstream) => upstream.name ?? key;
|
|
203
|
+
const grouped = (upstream: Upstream) => upstream.role === "rs" || Boolean(upstream.multihop);
|
|
204
|
+
|
|
205
|
+
/** The name built for one upstream: its pattern with the key, the key in capitals and its number filled in. */
|
|
206
|
+
const named = (field: Field, key: string, upstream: Upstream): string => {
|
|
207
|
+
const given = names[field];
|
|
208
|
+
const pattern =
|
|
209
|
+
given === undefined || typeof given === "string"
|
|
210
|
+
? (given ?? DEFAULTS[field])
|
|
211
|
+
: ((upstream.tunnel && "tunnel" in given ? given.tunnel : given[upstream.role]) ?? DEFAULTS[field]);
|
|
212
|
+
|
|
213
|
+
return pattern
|
|
214
|
+
.replaceAll("{key}", key)
|
|
215
|
+
.replaceAll("{KEY}", key.toUpperCase())
|
|
216
|
+
.replaceAll("{number}", () => {
|
|
217
|
+
const number = upstream.handoff?.number ?? upstream.tunnel?.number;
|
|
218
|
+
if (number === undefined) throw new Error(`Upstreams: ${key} needs a number for ${field} name ${pattern}`);
|
|
219
|
+
return String(number);
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Two upstreams under one name would replace each other on the device.
|
|
224
|
+
for (const field of Object.keys(DEFAULTS) as Field[]) {
|
|
225
|
+
const taken = new Map<string, string>();
|
|
226
|
+
|
|
227
|
+
for (const [key, upstream] of all) {
|
|
228
|
+
if (field === "native" && upstream.handoff?.native === undefined) continue;
|
|
229
|
+
if (field === "import" && !upstream.import) continue;
|
|
230
|
+
if (field === "group" && !grouped(upstream)) continue;
|
|
231
|
+
if ((field === "vlan" && !upstream.handoff) || (field === "interface" && !upstream.handoff && !upstream.tunnel)) continue;
|
|
232
|
+
|
|
233
|
+
const name = named(field, key, upstream);
|
|
234
|
+
const other = taken.get(name);
|
|
235
|
+
if (other) throw new Error(`Upstreams: ${other} and ${key} both have the ${field} name ${name}`);
|
|
236
|
+
taken.set(name, key);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const vlan = (key: string, upstream: Upstream) => named("vlan", key, upstream) as V;
|
|
241
|
+
const native = (key: string, upstream: Upstream) => named("native", key, upstream) as T;
|
|
242
|
+
const policy = (direction: "import" | "export", key: string, upstream: Upstream) => named(direction, key, upstream) as PolicyName;
|
|
243
|
+
|
|
244
|
+
/** Each landed upstream's VLAN; with a device, the VLANs and natives of those arriving on its ports. */
|
|
245
|
+
const vlans = <const D extends string | undefined = undefined>(device?: D) => {
|
|
246
|
+
const result: Record<string, Vlan> = {};
|
|
247
|
+
|
|
248
|
+
for (const [key, upstream] of all) {
|
|
249
|
+
const handoff = upstream.handoff;
|
|
250
|
+
if (!handoff || (device !== undefined && handoff.port?.device !== device)) continue;
|
|
251
|
+
|
|
252
|
+
result[vlan(key, upstream)] = { id: handoff.vlan, description: nameOf(key, upstream) };
|
|
253
|
+
if (device !== undefined && handoff.native !== undefined) {
|
|
254
|
+
result[native(key, upstream)] = {
|
|
255
|
+
id: handoff.native,
|
|
256
|
+
description: `Native toward ${nameOf(key, upstream)}, nothing else in it`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return result as D extends string ? { [K in V]: Vlan } & { [K in T]: Vlan } : { [K in V]: Vlan };
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const vlansOn = (device: string) =>
|
|
265
|
+
all.filter(([, upstream]) => upstream.handoff?.port?.device === device).map(([key, upstream]) => vlan(key, upstream));
|
|
266
|
+
|
|
267
|
+
const ports = (device: string) => {
|
|
268
|
+
const result: Record<string, Port<V | T, never, never, never>> = {};
|
|
269
|
+
|
|
270
|
+
for (const [key, upstream] of all) {
|
|
271
|
+
const handoff = upstream.handoff;
|
|
272
|
+
if (!handoff?.port || handoff.port.device !== device) continue;
|
|
273
|
+
|
|
274
|
+
result[handoff.port.port] = {
|
|
275
|
+
description: names.port ? names.port(key, upstream) : nameOf(key, upstream),
|
|
276
|
+
...(handoff.native !== undefined
|
|
277
|
+
? { trunk_vlans: [vlan(key, upstream)], native_vlan: native(key, upstream) }
|
|
278
|
+
: { access_vlan: vlan(key, upstream) }),
|
|
279
|
+
lldp: false,
|
|
280
|
+
stp: false,
|
|
281
|
+
...(handoff.port.storm_control ? { storm_control: handoff.port.storm_control } : {}),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return result;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const interfaces = () => {
|
|
289
|
+
const result: Record<string, VlanInterface<V, never> | Gre<never>> = {};
|
|
290
|
+
|
|
291
|
+
for (const [key, upstream] of all) {
|
|
292
|
+
const description = nameOf(key, upstream);
|
|
293
|
+
const name = named("interface", key, upstream);
|
|
294
|
+
|
|
295
|
+
if (upstream.tunnel) {
|
|
296
|
+
const { local, remote, addresses, mtu } = upstream.tunnel;
|
|
297
|
+
result[name] = { type: "gre", description, local, remote, addresses, ...(mtu ? { mtu } : {}) };
|
|
298
|
+
} else if (upstream.handoff) {
|
|
299
|
+
const { addresses, exchange } = upstream.handoff;
|
|
300
|
+
result[name] = { type: "vlan", vlan: vlan(key, upstream), description, addresses, ...(exchange ? { exchange } : {}) };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return result as { [K in I]: VlanInterface<V, never> } & { [K in G]: Gre<never> };
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/** The router's interfaces toward the internet: every landed upstream's, for NAT and lists like it. */
|
|
308
|
+
const uplinks = () =>
|
|
309
|
+
all.filter(([, upstream]) => upstream.handoff).map(([key, upstream]) => named("interface", key, upstream) as I);
|
|
310
|
+
|
|
311
|
+
/** What a session or its group takes from its upstream. */
|
|
312
|
+
const settings = (key: string, upstream: Upstream): NeighborSettings<PolicyName> => ({
|
|
313
|
+
remote_as: upstream.asn,
|
|
314
|
+
description: nameOf(key, upstream),
|
|
315
|
+
role: upstream.role,
|
|
316
|
+
...(upstream.import ? { import: policy("import", key, upstream) } : {}),
|
|
317
|
+
export: policy("export", key, upstream),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const groups = () =>
|
|
321
|
+
Object.fromEntries(
|
|
322
|
+
all
|
|
323
|
+
.filter(([, upstream]) => grouped(upstream))
|
|
324
|
+
.map(([key, upstream]) => {
|
|
325
|
+
const multihop = upstream.multihop;
|
|
326
|
+
return [
|
|
327
|
+
named("group", key, upstream),
|
|
328
|
+
{
|
|
329
|
+
...settings(key, upstream),
|
|
330
|
+
...(multihop
|
|
331
|
+
? {
|
|
332
|
+
local_address: multihop.local_address,
|
|
333
|
+
multihop: true,
|
|
334
|
+
...(multihop.families ? { families: multihop.families } : {}),
|
|
335
|
+
...(multihop.password ? { password: multihop.password } : {}),
|
|
336
|
+
}
|
|
337
|
+
: {}),
|
|
338
|
+
},
|
|
339
|
+
];
|
|
340
|
+
})
|
|
341
|
+
) as Record<Group, NeighborSettings<PolicyName>>;
|
|
342
|
+
|
|
343
|
+
const neighbors = () =>
|
|
344
|
+
Object.fromEntries(
|
|
345
|
+
all.flatMap(([key, upstream]) =>
|
|
346
|
+
Object.entries(upstream.sessions).map(([name, session]) => {
|
|
347
|
+
const limit = upstream.limits?.[Address.family(session.address)];
|
|
348
|
+
return [
|
|
349
|
+
name,
|
|
350
|
+
{
|
|
351
|
+
...session,
|
|
352
|
+
...(grouped(upstream) ? { group: named("group", key, upstream) } : settings(key, upstream)),
|
|
353
|
+
...(limit ? { max_prefixes: limit } : {}),
|
|
354
|
+
},
|
|
355
|
+
];
|
|
356
|
+
})
|
|
357
|
+
)
|
|
358
|
+
) as Record<string, Neighbor<PolicyName, Group>>;
|
|
359
|
+
|
|
360
|
+
const policies = <const P extends string = never>(options: UpstreamPolicies<P> = {}) => {
|
|
361
|
+
const result: Record<string, PolicyRule<P, never>[]> = {};
|
|
362
|
+
|
|
363
|
+
for (const [key, upstream] of all) {
|
|
364
|
+
result[policy("export", key, upstream)] = [
|
|
365
|
+
...(options.actions?.(key, upstream) ?? []),
|
|
366
|
+
...(upstream.export ?? []),
|
|
367
|
+
...Bgp.announce(upstream.announce),
|
|
368
|
+
];
|
|
369
|
+
|
|
370
|
+
const taken = upstream.import;
|
|
371
|
+
if (!taken) continue;
|
|
372
|
+
|
|
373
|
+
const tag = options.tag?.(key, upstream);
|
|
374
|
+
result[policy("import", key, upstream)] = [
|
|
375
|
+
...(taken.default
|
|
376
|
+
? Bgp.defaultOnly({ ...taken.default, ...(options.sources ? { sources: options.sources } : {}) })
|
|
377
|
+
: []),
|
|
378
|
+
...(taken.learn
|
|
379
|
+
? Bgp.learn<P>({
|
|
380
|
+
...(taken.learn.before ? { before: taken.learn.before } : {}),
|
|
381
|
+
...(options.sanity ? { sanity: options.sanity } : {}),
|
|
382
|
+
...(options.sources ? { sources: options.sources } : {}),
|
|
383
|
+
...(taken.learn.local_pref !== undefined ? { local_pref: taken.learn.local_pref } : {}),
|
|
384
|
+
...(tag !== undefined ? { tag } : {}),
|
|
385
|
+
})
|
|
386
|
+
: [{ description: "Nothing else", action: "reject" as const }]),
|
|
387
|
+
];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return result as Record<PolicyName, PolicyRule<P, never>[]>;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
/** The VLANs straight off an upstream, natives included: `untrusted` for `Checks.trustBoundary`. */
|
|
394
|
+
const untrusted = () => [
|
|
395
|
+
...all.filter(([, upstream]) => upstream.handoff).map(([key, upstream]) => vlan(key, upstream)),
|
|
396
|
+
...natives(),
|
|
397
|
+
];
|
|
398
|
+
|
|
399
|
+
const natives = () =>
|
|
400
|
+
all.filter(([, upstream]) => upstream.handoff?.native !== undefined).map(([key, upstream]) => native(key, upstream));
|
|
401
|
+
|
|
402
|
+
const privateDrops = () =>
|
|
403
|
+
all.filter(([, upstream]) => upstream.handoff && upstream.drop_private).map(([key, upstream]) => vlan(key, upstream));
|
|
404
|
+
|
|
405
|
+
const tunnelRules = (): FilterRule<never, never>[] =>
|
|
406
|
+
all.flatMap(([key, upstream]) =>
|
|
407
|
+
upstream.tunnel
|
|
408
|
+
? [
|
|
409
|
+
{
|
|
410
|
+
description: `GRE from ${nameOf(key, upstream)}`,
|
|
411
|
+
match: {
|
|
412
|
+
protocol: "gre" as const,
|
|
413
|
+
src: `${upstream.tunnel.remote}/${Address.bits(Address.family(upstream.tunnel.remote))}` as Prefix,
|
|
414
|
+
},
|
|
415
|
+
action: "accept" as const,
|
|
416
|
+
},
|
|
417
|
+
]
|
|
418
|
+
: []
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
const addresses = () => all.flatMap(([, upstream]) => Object.values(upstream.sessions).map((session) => session.address));
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
vlans,
|
|
425
|
+
vlansOn,
|
|
426
|
+
ports,
|
|
427
|
+
interfaces,
|
|
428
|
+
uplinks,
|
|
429
|
+
groups,
|
|
430
|
+
neighbors,
|
|
431
|
+
policies,
|
|
432
|
+
untrusted,
|
|
433
|
+
natives,
|
|
434
|
+
privateDrops,
|
|
435
|
+
tunnelRules,
|
|
436
|
+
addresses,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
}
|