@takodotid/circuit 0.37.0 → 0.38.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/chunks/index-6wb41q67.js +62 -0
- package/dist/chunks/{index-k38bby43.js → index-yzw86rc8.js} +4 -62
- package/dist/chunks/{server-ggertnce.js → server-cxr9ynxc.js} +1 -1
- package/dist/cli/index.js +104 -13
- package/dist/helpers/index.js +227 -1
- package/dist/types/helpers/bgp.d.ts +46 -3
- package/dist/types/helpers/cables.d.ts +70 -0
- package/dist/types/helpers/index.d.ts +5 -1
- package/dist/types/helpers/upstreams.d.ts +156 -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/package.json +1 -1
- package/src/cli/index.ts +55 -4
- package/src/helpers/bgp.ts +104 -2
- package/src/helpers/cables.ts +91 -0
- package/src/helpers/index.ts +6 -2
- package/src/helpers/upstreams.ts +306 -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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Catalog, type ModelName, type PlatformName, type PortName } from "../platforms/catalog";
|
|
2
|
+
|
|
3
|
+
/** One end of a cable: the device, and its platform and model, which name its ports. */
|
|
4
|
+
export type CableEnd<P extends PlatformName, M extends ModelName<P>> = {
|
|
5
|
+
device: string;
|
|
6
|
+
platform: P;
|
|
7
|
+
model: M;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** The far end of a port, for its description: the device, the port by Circuit's name, and by the name printed on the device. */
|
|
11
|
+
export type FarEnd = { device: string; port: string; label: string };
|
|
12
|
+
|
|
13
|
+
/** What every cable of one entry shares. */
|
|
14
|
+
type Shared = {
|
|
15
|
+
/** What is between the two ends, such as a carrier's local loop. Both ends get it on their `link`. */
|
|
16
|
+
through?: string;
|
|
17
|
+
/** A port's description from its far end. The far device and the far port's label when absent, such as `firewall-1 port6`. */
|
|
18
|
+
describe?: (far: FarEnd) => string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** A port a cable builds: what it is, the LAG it is in, and the other end. */
|
|
22
|
+
type CablePort<L extends string> = {
|
|
23
|
+
description: string;
|
|
24
|
+
link: { device: string; port: string; through?: string };
|
|
25
|
+
lag?: L;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Ports for both ends of the cables between two of our devices, each cable written once. */
|
|
29
|
+
export class Cables {
|
|
30
|
+
/**
|
|
31
|
+
* One cable, both ends. Spread each end into its device's `ports`:
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* const uplink = Cables.link({ a: { device: "router-1", platform: "routeros", model: "CCR2216-1G-12XS-2XQ", port: "25g-1" }, b: { ... } });
|
|
35
|
+
* // router-1: ports: { ...uplink.a }
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
static link<
|
|
39
|
+
const PA extends PlatformName,
|
|
40
|
+
const MA extends ModelName<PA>,
|
|
41
|
+
const NA extends PortName<PA, MA>,
|
|
42
|
+
const PB extends PlatformName,
|
|
43
|
+
const MB extends ModelName<PB>,
|
|
44
|
+
const NB extends PortName<PB, MB>,
|
|
45
|
+
>(spec: Shared & { a: CableEnd<PA, MA> & { port: NA }; b: CableEnd<PB, MB> & { port: NB } }) {
|
|
46
|
+
return {
|
|
47
|
+
a: { [spec.a.port]: Cables.end(spec, spec.b, spec.b.port) } as Record<NA, CablePort<never>>,
|
|
48
|
+
b: { [spec.b.port]: Cables.end(spec, spec.a, spec.a.port) } as Record<NB, CablePort<never>>,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 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.
|
|
54
|
+
*/
|
|
55
|
+
static lag<
|
|
56
|
+
const PA extends PlatformName,
|
|
57
|
+
const MA extends ModelName<PA>,
|
|
58
|
+
const LA extends string,
|
|
59
|
+
const PB extends PlatformName,
|
|
60
|
+
const MB extends ModelName<PB>,
|
|
61
|
+
const LB extends string,
|
|
62
|
+
const C extends Partial<Record<PortName<PA, MA>, PortName<PB, MB>>>,
|
|
63
|
+
>(spec: Shared & { a: CableEnd<PA, MA> & { lag: LA }; b: CableEnd<PB, MB> & { lag: LB }; cables: C }) {
|
|
64
|
+
const pairs = Object.entries(spec.cables) as [string, string][];
|
|
65
|
+
|
|
66
|
+
const taken = new Map<string, string>();
|
|
67
|
+
for (const [near, far] of pairs) {
|
|
68
|
+
const other = taken.get(far);
|
|
69
|
+
if (other) throw new Error(`Cables.lag: ${spec.b.device} ${far} is cabled to both ${other} and ${near} on ${spec.a.device}`);
|
|
70
|
+
taken.set(far, near);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const a = Object.fromEntries(pairs.map(([near, far]) => [near, { ...Cables.end(spec, spec.b, far), lag: spec.a.lag }]));
|
|
74
|
+
const b = Object.fromEntries(pairs.map(([near, far]) => [far, { ...Cables.end(spec, spec.a, near), lag: spec.b.lag }]));
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
a: a as Record<keyof C & string, CablePort<LA>>,
|
|
78
|
+
b: b as Record<NonNullable<C[keyof C]> & string, CablePort<LB>>,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A port facing `port` on the far device. */
|
|
83
|
+
private static end(shared: Shared, far: { device: string; platform: string; model: string }, port: string): CablePort<never> {
|
|
84
|
+
const farEnd = { device: far.device, port, label: Catalog.spell(far.platform, far.model, port) };
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
description: shared.describe ? shared.describe(farEnd) : `${farEnd.device} ${farEnd.label}`,
|
|
88
|
+
link: { device: far.device, port, ...(shared.through ? { through: shared.through } : {}) },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
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 { CableEnd, 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,306 @@
|
|
|
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
|
+
/** Our addresses on it. `{ address, peer }` for a point-to-point address whose far end is outside its own prefix. */
|
|
36
|
+
addresses: readonly (InterfaceAddress | { address: InterfaceAddress; peer: IP })[];
|
|
37
|
+
/** The switch port it arrives on. */
|
|
38
|
+
port?: { device: string; port: string; storm_control?: Port<string, string, string, string>["storm_control"] };
|
|
39
|
+
/** An exchange's LAN, for `circuit peeringdb`. */
|
|
40
|
+
exchange?: ExchangeLan;
|
|
41
|
+
};
|
|
42
|
+
/** A GRE tunnel to it. */
|
|
43
|
+
tunnel?: { local: IP; remote: IP; addresses: readonly InterfaceAddress[]; mtu?: number };
|
|
44
|
+
/** Our BGP sessions with it, by name: its address, and ours when it is not the interface's. */
|
|
45
|
+
sessions: Readonly<Record<string, { address: IP; local_address?: IP }>>;
|
|
46
|
+
/** The sessions are more than one hop away, from this address of ours. They share a group. */
|
|
47
|
+
multihop?: { local_address: IP; families?: readonly Family[]; password?: Secret };
|
|
48
|
+
/** The most prefixes a session may send, by its family: about twice what it sends. Over it, the session closes until it is cleared. */
|
|
49
|
+
limits?: { ipv4?: number; ipv6?: number };
|
|
50
|
+
/** What we take. Nothing when absent. */
|
|
51
|
+
import?: {
|
|
52
|
+
/** Its default route, at this preference. */
|
|
53
|
+
default?: { local_pref?: number; description?: string };
|
|
54
|
+
/** Its routes, through `Bgp.learn`: the `before` rules, sanity, this preference, the tag. Only the default when absent. */
|
|
55
|
+
learn?: { local_pref?: number; before?: readonly PolicyRule<never, never>[] };
|
|
56
|
+
};
|
|
57
|
+
/** Rules of yours in its export, between the community actions and what we announce, such as a prepend. */
|
|
58
|
+
export?: readonly PolicyRule<never, never>[];
|
|
59
|
+
/** What we announce to it. Everything else is rejected. */
|
|
60
|
+
announce: readonly Announced[];
|
|
61
|
+
/** Drop RFC 1918 sources arriving from it. `privateDrops()` lists the VLANs. */
|
|
62
|
+
drop_private?: true;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Upstreams by key. The key names everything built from it. */
|
|
66
|
+
export type UpstreamCatalog = Readonly<Record<string, Upstream>>;
|
|
67
|
+
|
|
68
|
+
/** How each upstream's policies are written: shared by every upstream of a catalog. */
|
|
69
|
+
export type UpstreamPolicies<P extends string> = {
|
|
70
|
+
/** A policy holding `Bgp.sanity()` that each import calls. The sanity rules are written in each import when absent. */
|
|
71
|
+
sanity?: P;
|
|
72
|
+
/** Where a route came from, by upstream: a community or a rule, such as `communityScheme(...).tag(...)`. Nothing is tagged when absent. */
|
|
73
|
+
tag?: (key: string, upstream: Upstream) => StandardCommunity | LargeCommunity | PolicyRule<never, never>;
|
|
74
|
+
/** Rules first in each export, such as `communityScheme(...).actions(upstream.asn)`. */
|
|
75
|
+
actions?: (key: string, upstream: Upstream) => readonly PolicyRule<never, never>[];
|
|
76
|
+
/** The source address for routes of its family, one per family. */
|
|
77
|
+
sources?: readonly IP[];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type Key<C> = keyof C & string;
|
|
81
|
+
/** The upstreams that land on a VLAN of ours. */
|
|
82
|
+
type Landed<C> = { [K in Key<C>]: C[K] extends { handoff: object } ? K : never }[Key<C>];
|
|
83
|
+
/** The upstreams that send their VLAN tagged. */
|
|
84
|
+
type Tagged<C> = { [K in Key<C>]: C[K] extends { handoff: { native: number } } ? K : never }[Key<C>];
|
|
85
|
+
/** The upstreams reached over a tunnel. */
|
|
86
|
+
type Tunneled<C> = { [K in Key<C>]: C[K] extends { tunnel: object } ? K : never }[Key<C>];
|
|
87
|
+
/** The upstreams whose sessions share a group: an exchange's route servers, or multihop sessions. */
|
|
88
|
+
type Grouped<C> = { [K in Key<C>]: C[K] extends { role: "rs" } | { multihop: object } ? K : never }[Key<C>];
|
|
89
|
+
/** The upstreams we take routes from. */
|
|
90
|
+
type Importing<C> = { [K in Key<C>]: C[K] extends { import: object } ? K : never }[Key<C>];
|
|
91
|
+
|
|
92
|
+
/** A native VLAN's name. */
|
|
93
|
+
type Native<K extends string> = `${K}-native`;
|
|
94
|
+
/** Each upstream's policies: `TRANSIT_A-IN` and `TRANSIT_A-OUT`. */
|
|
95
|
+
type PolicyName<C> = `${Uppercase<Importing<C>>}-IN` | `${Uppercase<Key<C>>}-OUT`;
|
|
96
|
+
|
|
97
|
+
/** The catalog as written, with any key the shape does not have refused, at every level. */
|
|
98
|
+
type Exact<S, T> = T extends readonly unknown[] | string | number | boolean | Secret
|
|
99
|
+
? T
|
|
100
|
+
: { [K in keyof T]: K extends keyof S ? Exact<NonNullable<S[K]>, T[K]> : never };
|
|
101
|
+
|
|
102
|
+
/** A network's upstreams written once, and each device's part built from them. */
|
|
103
|
+
export class Upstreams {
|
|
104
|
+
/**
|
|
105
|
+
* 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:
|
|
106
|
+
*
|
|
107
|
+
* - 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.
|
|
108
|
+
* - Policies `<KEY>-IN` and `<KEY>-OUT`; a BGP group `<key>` for an exchange's route servers or multihop sessions.
|
|
109
|
+
*
|
|
110
|
+
* On the router: `vlans()`, `interfaces()`, `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.
|
|
111
|
+
*/
|
|
112
|
+
static define<const C extends UpstreamCatalog>(catalog: C & { [K in keyof C]: Exact<Upstream, C[K]> }) {
|
|
113
|
+
type V = Landed<C>;
|
|
114
|
+
type T = Tagged<C>;
|
|
115
|
+
|
|
116
|
+
const all = Object.entries(catalog) as [Key<C>, Upstream][];
|
|
117
|
+
const nameOf = (key: string, upstream: Upstream) => upstream.name ?? key;
|
|
118
|
+
const policy = (key: string) => key.toUpperCase();
|
|
119
|
+
const grouped = (upstream: Upstream) => upstream.role === "rs" || Boolean(upstream.multihop);
|
|
120
|
+
|
|
121
|
+
/** Each landed upstream's VLAN; with a device, the VLANs and natives of those arriving on its ports. */
|
|
122
|
+
const vlans = <const D extends string | undefined = undefined>(device?: D) => {
|
|
123
|
+
const result: Record<string, Vlan> = {};
|
|
124
|
+
|
|
125
|
+
for (const [key, upstream] of all) {
|
|
126
|
+
const handoff = upstream.handoff;
|
|
127
|
+
if (!handoff || (device !== undefined && handoff.port?.device !== device)) continue;
|
|
128
|
+
|
|
129
|
+
result[key] = { id: handoff.vlan, description: nameOf(key, upstream) };
|
|
130
|
+
if (device !== undefined && handoff.native !== undefined) {
|
|
131
|
+
result[`${key}-native`] = {
|
|
132
|
+
id: handoff.native,
|
|
133
|
+
description: `Native toward ${nameOf(key, upstream)}, nothing else in it`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return result as D extends string ? { [K in V]: Vlan } & { [K in T as Native<K>]: Vlan } : { [K in V]: Vlan };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const vlansOn = (device: string) =>
|
|
142
|
+
all.filter(([, upstream]) => upstream.handoff?.port?.device === device).map(([key]) => key as V);
|
|
143
|
+
|
|
144
|
+
const ports = (device: string) => {
|
|
145
|
+
const result: Record<string, Port<V | Native<T>, never, never, never>> = {};
|
|
146
|
+
|
|
147
|
+
for (const [key, upstream] of all) {
|
|
148
|
+
const handoff = upstream.handoff;
|
|
149
|
+
if (!handoff?.port || handoff.port.device !== device) continue;
|
|
150
|
+
|
|
151
|
+
result[handoff.port.port] = {
|
|
152
|
+
description: nameOf(key, upstream),
|
|
153
|
+
...(handoff.native !== undefined
|
|
154
|
+
? { trunk_vlans: [key as V], native_vlan: `${key}-native` as Native<T> }
|
|
155
|
+
: { access_vlan: key as V }),
|
|
156
|
+
lldp: false,
|
|
157
|
+
stp: false,
|
|
158
|
+
...(handoff.port.storm_control ? { storm_control: handoff.port.storm_control } : {}),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return result;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const interfaces = () => {
|
|
166
|
+
const result: Record<string, VlanInterface<V, never> | Gre<never>> = {};
|
|
167
|
+
|
|
168
|
+
for (const [key, upstream] of all) {
|
|
169
|
+
const description = nameOf(key, upstream);
|
|
170
|
+
|
|
171
|
+
if (upstream.tunnel) {
|
|
172
|
+
const { local, remote, addresses, mtu } = upstream.tunnel;
|
|
173
|
+
result[key] = { type: "gre", description, local, remote, addresses, ...(mtu ? { mtu } : {}) };
|
|
174
|
+
} else if (upstream.handoff) {
|
|
175
|
+
const { addresses, exchange } = upstream.handoff;
|
|
176
|
+
result[key] = { type: "vlan", vlan: key as V, description, addresses, ...(exchange ? { exchange } : {}) };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return result as { [K in V]: VlanInterface<V, never> } & { [K in Tunneled<C>]: Gre<never> };
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** What a session or its group takes from its upstream. */
|
|
184
|
+
const settings = (key: string, upstream: Upstream): NeighborSettings<PolicyName<C>> => ({
|
|
185
|
+
remote_as: upstream.asn,
|
|
186
|
+
description: nameOf(key, upstream),
|
|
187
|
+
role: upstream.role,
|
|
188
|
+
...(upstream.import ? { import: `${policy(key)}-IN` as PolicyName<C> } : {}),
|
|
189
|
+
export: `${policy(key)}-OUT` as PolicyName<C>,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const groups = () =>
|
|
193
|
+
Object.fromEntries(
|
|
194
|
+
all
|
|
195
|
+
.filter(([, upstream]) => grouped(upstream))
|
|
196
|
+
.map(([key, upstream]) => {
|
|
197
|
+
const multihop = upstream.multihop;
|
|
198
|
+
return [
|
|
199
|
+
key,
|
|
200
|
+
{
|
|
201
|
+
...settings(key, upstream),
|
|
202
|
+
...(multihop
|
|
203
|
+
? {
|
|
204
|
+
local_address: multihop.local_address,
|
|
205
|
+
multihop: true,
|
|
206
|
+
...(multihop.families ? { families: multihop.families } : {}),
|
|
207
|
+
...(multihop.password ? { password: multihop.password } : {}),
|
|
208
|
+
}
|
|
209
|
+
: {}),
|
|
210
|
+
},
|
|
211
|
+
];
|
|
212
|
+
})
|
|
213
|
+
) as Record<Grouped<C>, NeighborSettings<PolicyName<C>>>;
|
|
214
|
+
|
|
215
|
+
const neighbors = () =>
|
|
216
|
+
Object.fromEntries(
|
|
217
|
+
all.flatMap(([key, upstream]) =>
|
|
218
|
+
Object.entries(upstream.sessions).map(([name, session]) => {
|
|
219
|
+
const limit = upstream.limits?.[Address.family(session.address)];
|
|
220
|
+
return [
|
|
221
|
+
name,
|
|
222
|
+
{
|
|
223
|
+
...session,
|
|
224
|
+
...(grouped(upstream) ? { group: key } : settings(key, upstream)),
|
|
225
|
+
...(limit ? { max_prefixes: limit } : {}),
|
|
226
|
+
},
|
|
227
|
+
];
|
|
228
|
+
})
|
|
229
|
+
)
|
|
230
|
+
) as Record<string, Neighbor<PolicyName<C>, Grouped<C>>>;
|
|
231
|
+
|
|
232
|
+
const policies = <const P extends string = never>(options: UpstreamPolicies<P> = {}) => {
|
|
233
|
+
const result: Record<string, PolicyRule<P, never>[]> = {};
|
|
234
|
+
|
|
235
|
+
for (const [key, upstream] of all) {
|
|
236
|
+
result[`${policy(key)}-OUT`] = [
|
|
237
|
+
...(options.actions?.(key, upstream) ?? []),
|
|
238
|
+
...(upstream.export ?? []),
|
|
239
|
+
...Bgp.announce(upstream.announce),
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
const taken = upstream.import;
|
|
243
|
+
if (!taken) continue;
|
|
244
|
+
|
|
245
|
+
const tag = options.tag?.(key, upstream);
|
|
246
|
+
result[`${policy(key)}-IN`] = [
|
|
247
|
+
...(taken.default
|
|
248
|
+
? Bgp.defaultOnly({ ...taken.default, ...(options.sources ? { sources: options.sources } : {}) })
|
|
249
|
+
: []),
|
|
250
|
+
...(taken.learn
|
|
251
|
+
? Bgp.learn<P>({
|
|
252
|
+
...(taken.learn.before ? { before: taken.learn.before } : {}),
|
|
253
|
+
...(options.sanity ? { sanity: options.sanity } : {}),
|
|
254
|
+
...(options.sources ? { sources: options.sources } : {}),
|
|
255
|
+
...(taken.learn.local_pref !== undefined ? { local_pref: taken.learn.local_pref } : {}),
|
|
256
|
+
...(tag !== undefined ? { tag } : {}),
|
|
257
|
+
})
|
|
258
|
+
: [{ description: "Nothing else", action: "reject" as const }]),
|
|
259
|
+
];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return result as Record<PolicyName<C>, PolicyRule<P, never>[]>;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/** The VLANs straight off an upstream, natives included: `untrusted` for `Checks.trustBoundary`. */
|
|
266
|
+
const untrusted = () => [...all.filter(([, upstream]) => upstream.handoff).map(([key]) => key as V), ...natives()];
|
|
267
|
+
|
|
268
|
+
const natives = () =>
|
|
269
|
+
all.filter(([, upstream]) => upstream.handoff?.native !== undefined).map(([key]) => `${key}-native` as Native<T>);
|
|
270
|
+
|
|
271
|
+
const privateDrops = () => all.filter(([, upstream]) => upstream.handoff && upstream.drop_private).map(([key]) => key as V);
|
|
272
|
+
|
|
273
|
+
const tunnelRules = (): FilterRule<never, never>[] =>
|
|
274
|
+
all.flatMap(([key, upstream]) =>
|
|
275
|
+
upstream.tunnel
|
|
276
|
+
? [
|
|
277
|
+
{
|
|
278
|
+
description: `GRE from ${nameOf(key, upstream)}`,
|
|
279
|
+
match: {
|
|
280
|
+
protocol: "gre" as const,
|
|
281
|
+
src: `${upstream.tunnel.remote}/${Address.bits(Address.family(upstream.tunnel.remote))}` as Prefix,
|
|
282
|
+
},
|
|
283
|
+
action: "accept" as const,
|
|
284
|
+
},
|
|
285
|
+
]
|
|
286
|
+
: []
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const addresses = () => all.flatMap(([, upstream]) => Object.values(upstream.sessions).map((session) => session.address));
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
vlans,
|
|
293
|
+
vlansOn,
|
|
294
|
+
ports,
|
|
295
|
+
interfaces,
|
|
296
|
+
groups,
|
|
297
|
+
neighbors,
|
|
298
|
+
policies,
|
|
299
|
+
untrusted,
|
|
300
|
+
natives,
|
|
301
|
+
privateDrops,
|
|
302
|
+
tunnelRules,
|
|
303
|
+
addresses,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// BGP sessions as `circuit bgp` and the check after an apply read them: waiting for them to be established, and spotting limits that closed several at once.
|
|
2
2
|
|
|
3
3
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
4
|
import type { BgpSession } from "./platform";
|
|
@@ -38,4 +38,27 @@ export class BgpWait {
|
|
|
38
38
|
static upCount(sessions: readonly BgpSession[]): number {
|
|
39
39
|
return sessions.filter((session) => session.established).length;
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
/** Sessions closed on their limit within this many minutes of each other are one burst. */
|
|
43
|
+
static readonly BURST_MINUTES = 5;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Groups of two or more sessions closed on their limit within `BURST_MINUTES` of each other, earliest first. Several limits at once is usually a leak upstream, not growth, so raising them is the wrong fix.
|
|
47
|
+
*/
|
|
48
|
+
static limitBursts(sessions: readonly BgpSession[]): BgpSession[][] {
|
|
49
|
+
const closed = sessions
|
|
50
|
+
.filter((session) => session.limit?.stoppedAt !== undefined && !Number.isNaN(session.limit.stoppedAt))
|
|
51
|
+
.sort((a, b) => a.limit!.stoppedAt! - b.limit!.stoppedAt!);
|
|
52
|
+
|
|
53
|
+
const bursts: BgpSession[][] = [];
|
|
54
|
+
for (const session of closed) {
|
|
55
|
+
const last = bursts.at(-1);
|
|
56
|
+
const previous = last?.at(-1);
|
|
57
|
+
if (last && previous && session.limit!.stoppedAt! - previous.limit!.stoppedAt! <= BgpWait.BURST_MINUTES * 60_000)
|
|
58
|
+
last.push(session);
|
|
59
|
+
else bursts.push([session]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return bursts.filter((burst) => burst.length > 1);
|
|
63
|
+
}
|
|
41
64
|
}
|
|
@@ -58,8 +58,15 @@ export type BgpSession = {
|
|
|
58
58
|
prefixes: string;
|
|
59
59
|
/** How long it has been up, as the device prints it, or `no session`. */
|
|
60
60
|
uptime: string;
|
|
61
|
+
/** Why it is down, when the device says: `limit exceeded`, `admin down`, `connecting`. */
|
|
62
|
+
reason?: string;
|
|
63
|
+
/** It closed because the neighbor sent more prefixes than its limit, and stays closed until cleared. */
|
|
64
|
+
limit?: BgpLimit;
|
|
61
65
|
};
|
|
62
66
|
|
|
67
|
+
/** A session closed on its prefix limit: the limit, and when it closed, as the device prints it and in milliseconds, to compare. */
|
|
68
|
+
export type BgpLimit = { max?: number; stopped?: string; stoppedAt?: number };
|
|
69
|
+
|
|
63
70
|
/** One BGP log line: when, as the device printed it, such as `2026-10-24 03:12:44`, and the rest of the line. */
|
|
64
71
|
export type BgpLogLine = { at: string; message: string };
|
|
65
72
|
|
|
@@ -115,6 +122,8 @@ export abstract class Platform {
|
|
|
115
122
|
bgpSessions?(device: Device, session: ReadSession): Promise<BgpSession[]>;
|
|
116
123
|
/** The device's BGP log lines from the last `minutes`, by the device's own clock, read over a `session()`. */
|
|
117
124
|
bgpLog?(session: ReadSession, minutes: number): Promise<BgpLogLine[]>;
|
|
125
|
+
/** Open again these sessions, each closed on its prefix limit, read by `bgpSessions`. Only a session still closed on its limit is touched. */
|
|
126
|
+
bgpClearLimit?(session: ReadSession, sessions: readonly BgpSession[]): Promise<void>;
|
|
118
127
|
/**
|
|
119
128
|
* Checks that the device is healthy after an apply, not only that it took the commands. Called before the plan is sent, to read what the checks compare against; the CLI runs them after the apply and the snapshot. Absent on a platform with no checks yet.
|
|
120
129
|
*/
|
|
@@ -12,6 +12,7 @@ Behaviour that looks like something else. Each one has been hit on a real device
|
|
|
12
12
|
- A session edited repeatedly can stick: SYN both ways, no RST, no log. Remove the connection and add it again.
|
|
13
13
|
- A session that never established is missing from `/routing bgp session print`; look at `/routing bgp connection print`. One that is trying prints `established` empty, not `false`, and no uptime (7.23.5).
|
|
14
14
|
- A BGP session is named `<connection>-<n>`, and a renamed connection keeps its session's old name until the session restarts. `circuit bgp` matches a session by the neighbor's address first.
|
|
15
|
+
- A session closed on its limit keeps its entry with `limit-exceeded=true`, its `input.limit-process-routes` and `last-stopped`, and the notification it sent ends in `0601` (cease, maximum prefixes). Its `prefix-count` is back to 0, so the count that tripped it is gone. `/routing bgp session clear <id> flag=limit-exceeded` opens it again; a misspelled flag is a `syntax error`, and a `find` that matches nothing does nothing (CCR2216, 7.23.5).
|
|
15
16
|
- `time>` in a `/log print` filter does not filter. `circuit bgp --log` keeps the lines of the last minutes itself, against the device's clock.
|
|
16
17
|
- `tcp-md5-key` set from the CLI has been seen discarded without an error, and a mismatch logs nothing. Confirm with `/tool sniffer quick port=179`.
|
|
17
18
|
- Incoming GRE is subject to the input chain. Accept it before testing the tunnel.
|
|
@@ -70,14 +70,24 @@ export class RouterOs extends Platform {
|
|
|
70
70
|
"}",
|
|
71
71
|
].join(" ");
|
|
72
72
|
|
|
73
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* Every BGP session, one line each: name, remote address, established, prefixes received, uptime, closed on its limit, the limit, when it last closed. Then each disabled connection, `disabled|<name>|<address>`. One line, because a script sent over exec runs as one.
|
|
75
|
+
*/
|
|
74
76
|
private static readonly LIST_BGP_SESSIONS = [
|
|
75
77
|
":foreach s in=[/routing bgp session find] do={",
|
|
76
78
|
":local g [/routing bgp session get $s];",
|
|
77
|
-
':put (($g->"name") . "|" . ($g->"remote.address") . "|" . ($g->"established") . "|" . ($g->"prefix-count") . "|" . ($g->"uptime"))',
|
|
79
|
+
':put (($g->"name") . "|" . ($g->"remote.address") . "|" . ($g->"established") . "|" . ($g->"prefix-count") . "|" . ($g->"uptime") . "|" . ($g->"limit-exceeded") . "|" . ($g->"input.limit-process-routes") . "|" . ($g->"last-stopped"))',
|
|
80
|
+
"};",
|
|
81
|
+
":foreach c in=[/routing bgp connection find disabled] do={",
|
|
82
|
+
':put ("disabled|" . [/routing bgp connection get $c name] . "|" . [/routing bgp connection get $c remote.address])',
|
|
78
83
|
"}",
|
|
79
84
|
].join(" ");
|
|
80
85
|
|
|
86
|
+
/** Open again the session from one address, only while it is closed on its limit. */
|
|
87
|
+
private static clearLimit(address: string): string {
|
|
88
|
+
return `/routing bgp session clear [find remote.address=${address} and limit-exceeded] flag=limit-exceeded`;
|
|
89
|
+
}
|
|
90
|
+
|
|
81
91
|
/**
|
|
82
92
|
* Every LLDP neighbor, one line each: our interfaces, the port first and then the LAG and bridge it is in, the far system name, and the far port as LLDP reports it. `:tostr` keeps the interface list one value; joined to a string as it is, a list repeats the whole line for each of its items. One line, because a script sent over exec runs as one.
|
|
83
93
|
*/
|
|
@@ -325,32 +335,68 @@ export class RouterOs extends Platform {
|
|
|
325
335
|
|
|
326
336
|
/**
|
|
327
337
|
* A session is named `<connection>-<n>`, but a renamed connection keeps its session's old name until the session restarts. So a neighbor is matched by its address first, then by name.
|
|
338
|
+
*
|
|
339
|
+
* A session that is down says why: closed on its limit, or still connecting. A neighbor with no session and a disabled connection is admin down.
|
|
328
340
|
*/
|
|
329
341
|
async bgpSessions(device: Device, session: ReadSession): Promise<BgpSession[]> {
|
|
330
342
|
const output = await session.run(RouterOs.LIST_BGP_SESSIONS, 60_000);
|
|
331
|
-
const
|
|
343
|
+
const lines = output
|
|
332
344
|
.split(/\r?\n/)
|
|
333
345
|
.filter((line) => line.includes("|"))
|
|
334
346
|
.map((line) => line.trim().split("|"));
|
|
347
|
+
const live = lines.filter(([first]) => first !== "disabled");
|
|
348
|
+
// A connection's address carries its length, `10.0.0.1/32`.
|
|
349
|
+
const disabled = lines
|
|
350
|
+
.filter(([first]) => first === "disabled")
|
|
351
|
+
.map(([, name, address]) => ({ name, address: address?.split("/")[0] }));
|
|
335
352
|
|
|
336
|
-
return Object.entries(device.routing?.bgp?.neighbors ?? {}).map(([name, neighbor]) => {
|
|
353
|
+
return Object.entries(device.routing?.bgp?.neighbors ?? {}).map(([name, neighbor]): BgpSession => {
|
|
337
354
|
const found =
|
|
338
355
|
live.find(([, remote]) => remote === neighbor.address) ??
|
|
339
356
|
live.find(([sessionName]) => sessionName === name || sessionName?.replace(/-\d+$/, "") === name);
|
|
340
357
|
|
|
341
|
-
if (!found)
|
|
358
|
+
if (!found) {
|
|
359
|
+
const off = disabled.some((connection) => connection.address === neighbor.address || connection.name === name);
|
|
360
|
+
return {
|
|
361
|
+
neighbor: name,
|
|
362
|
+
remote: "-",
|
|
363
|
+
established: false,
|
|
364
|
+
prefixes: "-",
|
|
365
|
+
uptime: "no session",
|
|
366
|
+
...(off ? { reason: "admin down" } : {}),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const [, remote, established, prefixes, uptime, limitExceeded, max, stopped] = found;
|
|
371
|
+
const up = established === "true";
|
|
372
|
+
const limited = limitExceeded === "true";
|
|
342
373
|
|
|
343
|
-
const [, remote, established, prefixes, uptime] = found;
|
|
344
374
|
return {
|
|
345
375
|
neighbor: name,
|
|
346
376
|
remote: remote || "-",
|
|
347
|
-
established:
|
|
377
|
+
established: up,
|
|
348
378
|
prefixes: prefixes || "-",
|
|
349
379
|
uptime: uptime || "-",
|
|
380
|
+
...(up ? {} : { reason: limited ? "limit exceeded" : "connecting" }),
|
|
381
|
+
...(limited
|
|
382
|
+
? {
|
|
383
|
+
limit: {
|
|
384
|
+
...(max && /^\d+$/.test(max) ? { max: Number(max) } : {}),
|
|
385
|
+
...(stopped ? { stopped, stoppedAt: RouterOs.logTime(stopped) } : {}),
|
|
386
|
+
},
|
|
387
|
+
}
|
|
388
|
+
: {}),
|
|
350
389
|
};
|
|
351
390
|
});
|
|
352
391
|
}
|
|
353
392
|
|
|
393
|
+
async bgpClearLimit(session: ReadSession, sessions: readonly BgpSession[]): Promise<void> {
|
|
394
|
+
for (const bgp of sessions) {
|
|
395
|
+
const answer = (await session.run(RouterOs.clearLimit(bgp.remote))).trim();
|
|
396
|
+
if (answer) throw new Error(`could not clear ${bgp.neighbor}: ${answer.slice(0, 200)}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
354
400
|
/** A `time>` filter in `/log print` does not filter, so the lines are kept here by comparing each one's time with the device's clock. */
|
|
355
401
|
async bgpLog(session: ReadSession, minutes: number): Promise<BgpLogLine[]> {
|
|
356
402
|
const now = (await session.run(RouterOs.CLOCK)).trim();
|
|
@@ -501,9 +547,10 @@ export class RouterOs extends Platform {
|
|
|
501
547
|
return [];
|
|
502
548
|
}
|
|
503
549
|
|
|
504
|
-
return down.map(
|
|
505
|
-
|
|
506
|
-
`BGP session ${bgp.neighbor} was established before the apply and
|
|
550
|
+
return down.map((bgp) =>
|
|
551
|
+
bgp.limit
|
|
552
|
+
? `BGP session ${bgp.neighbor} was established before the apply and closed on its limit${bgp.limit.max !== undefined ? ` of ${bgp.limit.max} prefixes` : ""}: the neighbor sends more than it may. Find out why, then: circuit bgp ${device.name} ${bgp.neighbor} --clear-limit`
|
|
553
|
+
: `BGP session ${bgp.neighbor} was established before the apply and is not ${limits.seconds} s after it (${bgp.reason ?? (bgp.uptime === "no session" ? "no session" : "not established")}). Look with: circuit bgp ${device.name} --log`
|
|
507
554
|
);
|
|
508
555
|
}
|
|
509
556
|
|