@takodotid/circuit 0.15.0 → 0.17.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 CHANGED
@@ -2021,6 +2021,7 @@ function helpers(out, ctx) {
2021
2021
  }
2022
2022
  }
2023
2023
  function switchAcls(out, ctx) {
2024
+ const each = (ports) => ports === undefined ? [undefined] : [ports].flat();
2024
2025
  for (const [port, settings] of Object.entries(ctx.ports)) {
2025
2026
  if (!settings.acl)
2026
2027
  continue;
@@ -2028,20 +2029,24 @@ function switchAcls(out, ctx) {
2028
2029
  const match = rule.match ?? {};
2029
2030
  const ipv6 = match.family === "ipv6" || [match.src, match.dst].some((address2) => address2 && familyOf(address2) === "ipv6");
2030
2031
  const address = (prefix) => prefix && !ipv6 ? dottedMask(prefix) : prefix;
2031
- out.add("/interface ethernet switch rule", command("add", {
2032
- comment: rule.description,
2033
- [ipv6 ? "dst-address6" : "dst-address"]: address(match.dst),
2034
- "dst-port": portList(match.dst_port),
2035
- "mac-protocol": ipv6 ? "ipv6" : "ip",
2036
- "new-dst-ports": rule.action === "drop" ? "" : undefined,
2037
- ports: ctx.name(port),
2038
- protocol: match.protocol,
2039
- rate: rule.rate && bitrate(rule.rate),
2040
- [ipv6 ? "src-address6" : "src-address"]: address(match.src),
2041
- "src-port": portList(match.src_port),
2042
- switch: "switch1",
2043
- "vlan-id": match.vlan && ctx.vlanId(match.vlan)
2044
- }));
2032
+ for (const srcPort of each(match.src_port)) {
2033
+ for (const dstPort of each(match.dst_port)) {
2034
+ out.add("/interface ethernet switch rule", command("add", {
2035
+ comment: rule.description,
2036
+ [ipv6 ? "dst-address6" : "dst-address"]: address(match.dst),
2037
+ "dst-port": dstPort,
2038
+ "mac-protocol": ipv6 ? "ipv6" : "ip",
2039
+ "new-dst-ports": rule.action === "drop" ? "" : undefined,
2040
+ ports: ctx.name(port),
2041
+ protocol: match.protocol,
2042
+ rate: rule.rate && bitrate(rule.rate),
2043
+ [ipv6 ? "src-address6" : "src-address"]: address(match.src),
2044
+ "src-port": srcPort,
2045
+ switch: "switch1",
2046
+ "vlan-id": match.vlan && ctx.vlanId(match.vlan)
2047
+ }));
2048
+ }
2049
+ }
2045
2050
  }
2046
2051
  }
2047
2052
  }
@@ -11,6 +11,40 @@ var antiSpoofing = (vlan, sources) => sources.map((src) => ({
11
11
  match: { vlan, src },
12
12
  action: "drop"
13
13
  }));
14
+ var AMPLIFIERS = {
15
+ qotd: 17,
16
+ chargen: 19,
17
+ dns: 53,
18
+ portmap: 111,
19
+ ntp: 123,
20
+ netbios: 137,
21
+ snmp: 161,
22
+ cldap: 389,
23
+ slp: 427,
24
+ rip: 520,
25
+ "ms-sql": 1434,
26
+ ssdp: 1900,
27
+ ard: 3283,
28
+ "ws-discovery": 3702,
29
+ mdns: 5353,
30
+ coap: 5683,
31
+ ubiquiti: 10001,
32
+ memcached: 11211,
33
+ dvr: 37810
34
+ };
35
+ var antiAmplification = (destinations, trusted = []) => destinations.flatMap((dst) => [
36
+ ...trusted.map((src) => ({
37
+ description: `UDP to ${dst} from ${src}`,
38
+ match: { protocol: "udp", src, dst },
39
+ action: "accept"
40
+ })),
41
+ { description: `UDP fragments to ${dst}`, match: { protocol: "udp", dst, src_port: 0 }, action: "drop" },
42
+ ...Object.entries(AMPLIFIERS).map(([name, port]) => ({
43
+ description: `UDP to ${dst} from ${name}`,
44
+ match: { protocol: "udp", dst, src_port: port },
45
+ action: "drop"
46
+ }))
47
+ ]);
14
48
  var BAD_TCP_FLAGS = [
15
49
  ["FIN+SYN", ["fin", "syn"], []],
16
50
  ["SYN+RST", ["syn", "rst"], []],
@@ -160,22 +194,15 @@ function communityScheme(scheme) {
160
194
  const community = (fn, parameter) => `${asn}:${fn}:${parameter}`;
161
195
  const ours = { standard: [`${asn}:*`], large: [`${asn}:*:*`] };
162
196
  const information = [scheme.learned_from?.function, scheme.learned_at?.function, scheme.learned_from_as].filter((fn) => fn !== undefined);
163
- const requests = [
164
- { functions: [scheme.do_not_announce?.function], trusted: scheme.do_not_announce?.trusted },
165
- { functions: PREPENDS.map(([name]) => scheme.prepend?.[name]), trusted: scheme.prepend?.trusted },
166
- { functions: [scheme.blackhole?.function], trusted: scheme.blackhole?.trusted }
167
- ].map(({ functions, trusted }) => ({ functions: functions.filter((fn) => fn !== undefined), trusted: trusted ?? [] }));
168
197
  const tag = (neighborClass, neighborAs, options = {}) => {
169
- const { site } = options;
170
- const untrusted = requests.filter((request) => !request.trusted.includes(neighborClass)).flatMap((request) => request.functions);
171
- const trustedForAny = requests.some((request) => request.functions.length && request.trusted.includes(neighborClass));
198
+ const { site, trusted = false } = options;
172
199
  if (scheme.learned_at && site === undefined)
173
200
  throw new Error(`communityScheme: tag for AS${neighborAs} needs a site, for learned_at`);
174
201
  return {
175
202
  description: `Learned from AS${neighborAs}`,
176
203
  set: {
177
204
  remove_communities: ours.standard,
178
- remove_large_communities: trustedForAny ? [...information, ...untrusted].map((fn) => community(fn, "*")) : ours.large,
205
+ remove_large_communities: trusted ? information.map((fn) => community(fn, "*")) : ours.large,
179
206
  add_large_communities: [
180
207
  ...scheme.learned_from ? [community(scheme.learned_from.function, scheme.learned_from.classes[neighborClass])] : [],
181
208
  ...scheme.learned_at ? [community(scheme.learned_at.function, site)] : [],
@@ -184,67 +211,69 @@ function communityScheme(scheme) {
184
211
  }
185
212
  };
186
213
  };
187
- const blackhole = (neighborClass, prefixSet) => {
188
- if (scheme.blackhole === undefined)
214
+ const blackhole = (prefixSets) => {
215
+ const fn = scheme.blackhole?.function;
216
+ if (fn === undefined)
189
217
  throw new Error("communityScheme: blackhole needs a function number in the scheme");
190
- if (!scheme.blackhole.trusted.includes(neighborClass))
191
- throw new Error(`communityScheme: ${neighborClass} is not trusted to ask for a blackhole`);
192
- return {
218
+ return prefixSets.map((prefixSet) => ({
193
219
  description: "Blackhole, asked by the neighbor",
194
- match: { large_community: community(scheme.blackhole.function, 0), prefix_set: prefixSet },
220
+ match: { large_community: community(fn, 0), prefix_set: prefixSet },
195
221
  set: { blackhole: true },
196
222
  action: "accept"
197
- };
223
+ }));
198
224
  };
199
- const actions = (neighborAs, options = {}) => [
200
- ...scheme.blackhole !== undefined && !options.blackhole ? [
201
- {
202
- description: "Blackholed here, never announced",
203
- match: { large_community: community(scheme.blackhole.function, 0) },
204
- action: "reject"
205
- }
206
- ] : [],
207
- ...scheme.do_not_announce !== undefined ? [
208
- {
209
- description: "Asked not to be announced anywhere",
210
- match: { large_community: community(scheme.do_not_announce.function, 0) },
211
- action: "reject"
212
- },
213
- {
214
- description: `Asked not to be announced to AS${neighborAs}`,
215
- match: { large_community: community(scheme.do_not_announce.function, neighborAs) },
216
- action: "reject"
217
- }
218
- ] : [],
219
- ...scheme.blackhole !== undefined && options.blackhole ? [
220
- {
221
- description: `Blackhole passed on to AS${neighborAs}`,
222
- match: { large_community: community(scheme.blackhole.function, 0) },
223
- set: {
224
- remove_communities: ours.standard,
225
- remove_large_communities: ours.large,
226
- add_communities: [options.blackhole]
225
+ const actions = (neighborAs) => {
226
+ const passOn = scheme.blackhole?.upstreams?.[neighborAs];
227
+ return [
228
+ ...scheme.blackhole !== undefined && !passOn ? [
229
+ {
230
+ description: "Blackholed here, never announced",
231
+ match: { large_community: community(scheme.blackhole.function, 0) },
232
+ action: "reject"
233
+ }
234
+ ] : [],
235
+ ...scheme.do_not_announce !== undefined ? [
236
+ {
237
+ description: "Asked not to be announced anywhere",
238
+ match: { large_community: community(scheme.do_not_announce, 0) },
239
+ action: "reject"
227
240
  },
228
- action: "accept"
229
- }
230
- ] : [],
231
- ...PREPENDS.flatMap(([name, times]) => {
232
- const fn = scheme.prepend?.[name];
233
- if (fn === undefined)
234
- return [];
235
- return [
236
241
  {
237
- description: `Asked to prepend ${name.replace("_", " ")} to AS${neighborAs}`,
238
- match: { large_community: community(fn, neighborAs) },
239
- set: { prepend: times }
242
+ description: `Asked not to be announced to AS${neighborAs}`,
243
+ match: { large_community: community(scheme.do_not_announce, neighborAs) },
244
+ action: "reject"
240
245
  }
241
- ];
242
- }),
243
- {
244
- description: "Our communities stay inside our network",
245
- set: { remove_communities: ours.standard, remove_large_communities: ours.large }
246
- }
247
- ];
246
+ ] : [],
247
+ ...scheme.blackhole !== undefined && passOn ? [
248
+ {
249
+ description: `Blackhole passed on to AS${neighborAs}`,
250
+ match: { large_community: community(scheme.blackhole.function, 0) },
251
+ set: {
252
+ remove_communities: ours.standard,
253
+ remove_large_communities: ours.large,
254
+ add_communities: [passOn]
255
+ },
256
+ action: "accept"
257
+ }
258
+ ] : [],
259
+ ...PREPENDS.flatMap(([name, times]) => {
260
+ const fn = scheme.prepend?.[name];
261
+ if (fn === undefined)
262
+ return [];
263
+ return [
264
+ {
265
+ description: `Asked to prepend ${name.replace("_", " ")} to AS${neighborAs}`,
266
+ match: { large_community: community(fn, neighborAs) },
267
+ set: { prepend: times }
268
+ }
269
+ ];
270
+ }),
271
+ {
272
+ description: "Our communities stay inside our network",
273
+ set: { remove_communities: ours.standard, remove_large_communities: ours.large }
274
+ }
275
+ ];
276
+ };
248
277
  const catalogue = [
249
278
  ...Object.entries(scheme.learned_from?.classes ?? {}).map(([name, value]) => ({
250
279
  community: community(scheme.learned_from.function, value),
@@ -259,14 +288,19 @@ function communityScheme(scheme) {
259
288
  ] : [],
260
289
  ...scheme.learned_from_as !== undefined ? [{ community: community(scheme.learned_from_as, "nnn"), description: "Learned from AS$0" }] : [],
261
290
  ...scheme.do_not_announce !== undefined ? [
262
- { community: community(scheme.do_not_announce.function, 0), description: "Do not announce to anyone" },
263
- { community: community(scheme.do_not_announce.function, "nnn"), description: "Do not announce to AS$0" }
291
+ { community: community(scheme.do_not_announce, 0), description: "Do not announce to anyone" },
292
+ { community: community(scheme.do_not_announce, "nnn"), description: "Do not announce to AS$0" }
264
293
  ] : [],
265
294
  ...PREPENDS.flatMap(([name]) => {
266
295
  const fn = scheme.prepend?.[name];
267
296
  return fn === undefined ? [] : [{ community: community(fn, "nnn"), description: `Prepend ${name.replace("_", " ")} to AS$0` }];
268
297
  }),
269
- ...scheme.blackhole !== undefined ? [{ community: community(scheme.blackhole.function, 0), description: `Blackhole: dropped in AS${asn}, not announced further` }] : []
298
+ ...scheme.blackhole !== undefined ? [
299
+ {
300
+ community: community(scheme.blackhole.function, 0),
301
+ description: Object.keys(scheme.blackhole.upstreams ?? {}).length ? `Blackhole: dropped in AS${asn} and by its upstreams that take it` : `Blackhole: dropped in AS${asn}, not announced further`
302
+ }
303
+ ] : []
270
304
  ];
271
305
  return { community, tag, actions, blackhole, catalogue };
272
306
  }
@@ -337,6 +371,8 @@ export {
337
371
  bgpSanity,
338
372
  badTcpFlags,
339
373
  antiSpoofing,
374
+ antiAmplification,
340
375
  PRIVATE_RANGES,
341
- MARTIANS
376
+ MARTIANS,
377
+ AMPLIFIERS
342
378
  };
@@ -6,6 +6,32 @@ export declare const MARTIANS: readonly ["127.0.0.0/8", "169.254.0.0/16", "0.0.0
6
6
  export declare const PRIVATE_RANGES: readonly ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"];
7
7
  /** Hardware ACL rules dropping traffic arriving on a VLAN from sources that cannot be there: your own space, martians, private ranges. One rule per source. */
8
8
  export declare const antiSpoofing: <V extends string>(vlan: V, sources: readonly Prefix[]) => AclRule<V>[];
9
+ /** UDP ports of services that answer a small request with a much larger reply, which attackers use to reflect floods at a victim. A reflected flood arrives from these ports. */
10
+ export declare const AMPLIFIERS: {
11
+ readonly qotd: 17;
12
+ readonly chargen: 19;
13
+ readonly dns: 53;
14
+ readonly portmap: 111;
15
+ readonly ntp: 123;
16
+ readonly netbios: 137;
17
+ readonly snmp: 161;
18
+ readonly cldap: 389;
19
+ readonly slp: 427;
20
+ readonly rip: 520;
21
+ readonly "ms-sql": 1434;
22
+ readonly ssdp: 1900;
23
+ readonly ard: 3283;
24
+ readonly "ws-discovery": 3702;
25
+ readonly mdns: 5353;
26
+ readonly coap: 5683;
27
+ readonly ubiquiti: 10001;
28
+ readonly memcached: 11211;
29
+ readonly dvr: 37810;
30
+ };
31
+ /**
32
+ * Hardware ACL rules dropping reflected UDP floods toward `destinations`: replies from the ports in `AMPLIFIERS`, and the fragments after the first, which carry no port and which a switch chip reads as port 0. UDP from `trusted` sources, the resolvers and time servers your own hosts ask, is accepted before anything is dropped. Traffic that matches is dropped whole and everything else passes untouched, so nothing legitimate is slowed.
33
+ */
34
+ export declare const antiAmplification: (destinations: readonly Prefix[], trusted?: readonly Prefix[]) => AclRule<never>[];
9
35
  /** Firewall rules dropping packets with a TCP flag combination no legitimate packet carries, for a forward chain. */
10
36
  export declare const badTcpFlags: () => FilterRule<never, never>[];
11
37
  /** The prefix lengths a BGP import accepts. */
@@ -72,42 +98,39 @@ export type CommunityScheme<Class extends string = string> = {
72
98
  /** Set on import: the AS a route was learned from, `asn:function:asn`. */
73
99
  learned_from_as?: number;
74
100
  /** A neighbor asks: do not announce the route to an AS, `asn:function:asn`, or to anyone, `asn:function:0`. */
75
- do_not_announce?: Request<Class> & {
76
- function: number;
77
- };
101
+ do_not_announce?: number;
78
102
  /** A neighbor asks: prepend toward an AS once, twice or three times, `asn:function:asn`. */
79
- prepend?: Request<Class> & {
103
+ prepend?: {
80
104
  once?: number;
81
105
  twice?: number;
82
106
  three_times?: number;
83
107
  };
84
- /** A neighbor asks: drop traffic to the route in your network, before it reaches them, `asn:function:0`. */
85
- blackhole?: Request<Class> & {
108
+ /**
109
+ * A neighbor asks: drop all traffic to this route, `asn:function:0`, usually one address under attack. Your router drops it, and so does every upstream in `upstreams`.
110
+ *
111
+ * `upstreams` maps an upstream's AS to its own blackhole community, as its NOC gives it. The route is passed to that upstream with it, so the traffic stops before it reaches your links. An upstream not listed never receives the route.
112
+ */
113
+ blackhole?: {
86
114
  function: number;
115
+ upstreams?: Readonly<Record<number, string>>;
87
116
  };
88
117
  };
89
- /** Something a neighbor can ask you for, and who may. */
90
- type Request<Class extends string> = {
91
- /** The classes allowed to ask for it, from `learned_from.classes`. From anyone else, the request is removed when the route comes in. */
92
- trusted: readonly NoInfer<Class>[];
93
- };
94
118
  /**
95
119
  * 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.
96
120
  *
97
- * - `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 keeps the requests its class is trusted with. `site` is needed when the scheme has `learned_at`.
121
+ * - `tag(neighborClass, neighborAs, { site, trusted })`: a rule for an import policy. It removes your communities a neighbor set, then adds where the route was learned. A `trusted` neighbor keeps its requests: do not announce, prepend, blackhole. `site` is needed when the scheme has `learned_at`.
98
122
  * - `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.
99
- * - `blackhole(neighborClass, prefixSet)`: a rule for the import policy of a neighbor trusted with blackholes, before anything that rejects long prefixes. It takes a route the neighbor asked to blackhole, but only inside `prefixSet`, its own space, and drops traffic to it here.
123
+ * - `blackhole(prefixSets)`: rules for the import policy of a neighbor you trust with blackholes, before anything that rejects long prefixes. They take a route the neighbor asked to blackhole, but only inside `prefixSets`, its own space, and drop traffic to it here. One rule per set, so one call covers both families.
100
124
  * - `catalogue`: every community, for `communities` in `defineNetwork`.
101
125
  */
102
126
  export declare function communityScheme<const Class extends string>(scheme: CommunityScheme<Class>): {
103
127
  community: (fn: number, parameter: number | string) => string;
104
128
  tag: (neighborClass: Class, neighborAs: number, options?: {
105
129
  site?: number;
130
+ trusted?: boolean;
106
131
  }) => PolicyRule<never, never>;
107
- actions: (neighborAs: number, options?: {
108
- blackhole?: string;
109
- }) => PolicyRule<never, never>[];
110
- blackhole: <S extends string>(neighborClass: Class, prefixSet: S) => PolicyRule<never, S>;
132
+ actions: (neighborAs: number) => PolicyRule<never, never>[];
133
+ blackhole: <S extends string>(prefixSets: readonly S[]) => PolicyRule<never, S>[];
111
134
  catalogue: Community[];
112
135
  };
113
136
  /** A member of an internet exchange: a network with a port on the fabric and an address on the peering LAN. */
@@ -145,4 +168,3 @@ export declare function internetExchange<const V extends string>(exchange: Inter
145
168
  ports: (device: string) => Record<string, Port<V, string, string, string>>;
146
169
  check: Check;
147
170
  };
148
- export {};
@@ -113,6 +113,6 @@ export type AclRule<V extends string> = {
113
113
  };
114
114
  /** What happens to a matching packet. */
115
115
  action: "accept" | "drop";
116
- /** Accepted traffic above this rate is dropped. */
116
+ /** Accepted traffic above this rate is dropped. On RouterOS it did not limit traffic the CPU routes; see its TRAPS.md. */
117
117
  rate?: Rate;
118
118
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Declarative, vendor-neutral network configuration: describe each device in TypeScript, and Circuit makes the device match. Alpha.",
5
5
  "keywords": [
6
6
  "network",
@@ -14,6 +14,9 @@ Behaviour that looks like something else. Each one has been hit on a real device
14
14
  - `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`.
15
15
  - Incoming GRE is subject to the input chain. Accept it before testing the tunnel.
16
16
  - Switch-chip rules have no address lists and no connection state. A drop is an empty `new-dst-ports`.
17
+ - A switch-chip rule's `rate` did not limit traffic the CPU routes. On a CCR2216 on 7.23.5, under a 12 Gbps flood, a rule at `2G` and then at `100M` changed nothing that reached the CPU, while a drop with the same match left 3 Mbps. Drop what you can recognise instead of limiting it.
18
+ - The switch chip reads a UDP fragment after the first as port 0. `src-port=0` drops the rest of a fragmented flood whose first fragments another rule dropped by port; without it they reach the CPU, which holds them waiting for a first fragment that never comes.
19
+ - A switch-chip rule takes one port or a range, `1900-1901`; a list is refused. The render makes one rule for each port in a list.
17
20
  - With hardware offload on, routed traffic bypasses the forward chain entirely. `hardware_offload: false` on an interface is what makes the firewall apply to it, at the cost of CPU. The only proof of offload is the `H` flag on a route.
18
21
  - `/ip cloud ddns-enabled` cannot be turned off on 7.23.5.
19
22
  - `mac-server` and `mac-winbox` work at layer 2 and ignore the IP firewall; they are limited by interface list.
@@ -836,9 +836,11 @@ function helpers(out: Output, ctx: Context): void {
836
836
  }
837
837
 
838
838
  /**
839
- * ACLs run in the switch chip on the port traffic arrives on. The chip matches addresses with a dotted mask on IPv4, drops by forwarding to no port, and limits by rate.
839
+ * ACLs run in the switch chip on the port traffic arrives on. The chip matches addresses with a dotted mask on IPv4, drops by forwarding to no port, and limits by rate. A rule takes one port, so a list of ports becomes one rule for each.
840
840
  */
841
841
  function switchAcls(out: Output, ctx: Context): void {
842
+ const each = (ports: number | readonly number[] | undefined) => (ports === undefined ? [undefined] : [ports].flat());
843
+
842
844
  for (const [port, settings] of Object.entries(ctx.ports)) {
843
845
  if (!settings.acl) continue;
844
846
 
@@ -847,23 +849,27 @@ function switchAcls(out: Output, ctx: Context): void {
847
849
  const ipv6 = match.family === "ipv6" || [match.src, match.dst].some((address) => address && familyOf(address) === "ipv6");
848
850
  const address = (prefix: string | undefined) => (prefix && !ipv6 ? dottedMask(prefix) : prefix);
849
851
 
850
- out.add(
851
- "/interface ethernet switch rule",
852
- command("add", {
853
- comment: rule.description,
854
- [ipv6 ? "dst-address6" : "dst-address"]: address(match.dst),
855
- "dst-port": portList(match.dst_port),
856
- "mac-protocol": ipv6 ? "ipv6" : "ip",
857
- "new-dst-ports": rule.action === "drop" ? "" : undefined,
858
- ports: ctx.name(port),
859
- protocol: match.protocol,
860
- rate: rule.rate && bitrate(rule.rate),
861
- [ipv6 ? "src-address6" : "src-address"]: address(match.src),
862
- "src-port": portList(match.src_port),
863
- switch: "switch1",
864
- "vlan-id": match.vlan && ctx.vlanId(match.vlan),
865
- })
866
- );
852
+ for (const srcPort of each(match.src_port)) {
853
+ for (const dstPort of each(match.dst_port)) {
854
+ out.add(
855
+ "/interface ethernet switch rule",
856
+ command("add", {
857
+ comment: rule.description,
858
+ [ipv6 ? "dst-address6" : "dst-address"]: address(match.dst),
859
+ "dst-port": dstPort,
860
+ "mac-protocol": ipv6 ? "ipv6" : "ip",
861
+ "new-dst-ports": rule.action === "drop" ? "" : undefined,
862
+ ports: ctx.name(port),
863
+ protocol: match.protocol,
864
+ rate: rule.rate && bitrate(rule.rate),
865
+ [ipv6 ? "src-address6" : "src-address"]: address(match.src),
866
+ "src-port": srcPort,
867
+ switch: "switch1",
868
+ "vlan-id": match.vlan && ctx.vlanId(match.vlan),
869
+ })
870
+ );
871
+ }
872
+ }
867
873
  }
868
874
  }
869
875
  }
@@ -18,6 +18,47 @@ export const antiSpoofing = <V extends string>(vlan: V, sources: readonly Prefix
18
18
  action: "drop",
19
19
  }));
20
20
 
21
+ /** UDP ports of services that answer a small request with a much larger reply, which attackers use to reflect floods at a victim. A reflected flood arrives from these ports. */
22
+ export const AMPLIFIERS = {
23
+ qotd: 17,
24
+ chargen: 19,
25
+ dns: 53,
26
+ portmap: 111,
27
+ ntp: 123,
28
+ netbios: 137,
29
+ snmp: 161,
30
+ cldap: 389,
31
+ slp: 427,
32
+ rip: 520,
33
+ "ms-sql": 1434,
34
+ ssdp: 1900,
35
+ ard: 3283,
36
+ "ws-discovery": 3702,
37
+ mdns: 5353,
38
+ coap: 5683,
39
+ ubiquiti: 10001,
40
+ memcached: 11211,
41
+ dvr: 37810,
42
+ } as const satisfies Record<string, number>;
43
+
44
+ /**
45
+ * Hardware ACL rules dropping reflected UDP floods toward `destinations`: replies from the ports in `AMPLIFIERS`, and the fragments after the first, which carry no port and which a switch chip reads as port 0. UDP from `trusted` sources, the resolvers and time servers your own hosts ask, is accepted before anything is dropped. Traffic that matches is dropped whole and everything else passes untouched, so nothing legitimate is slowed.
46
+ */
47
+ export const antiAmplification = (destinations: readonly Prefix[], trusted: readonly Prefix[] = []): AclRule<never>[] =>
48
+ destinations.flatMap((dst) => [
49
+ ...trusted.map((src): AclRule<never> => ({
50
+ description: `UDP to ${dst} from ${src}`,
51
+ match: { protocol: "udp", src, dst },
52
+ action: "accept",
53
+ })),
54
+ { description: `UDP fragments to ${dst}`, match: { protocol: "udp", dst, src_port: 0 }, action: "drop" },
55
+ ...Object.entries(AMPLIFIERS).map(([name, port]): AclRule<never> => ({
56
+ description: `UDP to ${dst} from ${name}`,
57
+ match: { protocol: "udp", dst, src_port: port },
58
+ action: "drop",
59
+ })),
60
+ ]);
61
+
21
62
  /** TCP flag combinations no legitimate packet carries: name, flags set, flags unset. */
22
63
  const BAD_TCP_FLAGS = [
23
64
  ["FIN+SYN", ["fin", "syn"], []],
@@ -231,17 +272,15 @@ export type CommunityScheme<Class extends string = string> = {
231
272
  /** Set on import: the AS a route was learned from, `asn:function:asn`. */
232
273
  learned_from_as?: number;
233
274
  /** A neighbor asks: do not announce the route to an AS, `asn:function:asn`, or to anyone, `asn:function:0`. */
234
- do_not_announce?: Request<Class> & { function: number };
275
+ do_not_announce?: number;
235
276
  /** A neighbor asks: prepend toward an AS once, twice or three times, `asn:function:asn`. */
236
- prepend?: Request<Class> & { once?: number; twice?: number; three_times?: number };
237
- /** A neighbor asks: drop traffic to the route in your network, before it reaches them, `asn:function:0`. */
238
- blackhole?: Request<Class> & { function: number };
239
- };
240
-
241
- /** Something a neighbor can ask you for, and who may. */
242
- type Request<Class extends string> = {
243
- /** The classes allowed to ask for it, from `learned_from.classes`. From anyone else, the request is removed when the route comes in. */
244
- trusted: readonly NoInfer<Class>[];
277
+ prepend?: { once?: number; twice?: number; three_times?: number };
278
+ /**
279
+ * A neighbor asks: drop all traffic to this route, `asn:function:0`, usually one address under attack. Your router drops it, and so does every upstream in `upstreams`.
280
+ *
281
+ * `upstreams` maps an upstream's AS to its own blackhole community, as its NOC gives it. The route is passed to that upstream with it, so the traffic stops before it reaches your links. An upstream not listed never receives the route.
282
+ */
283
+ blackhole?: { function: number; upstreams?: Readonly<Record<number, string>> };
245
284
  };
246
285
 
247
286
  const PREPENDS = [
@@ -255,9 +294,9 @@ const article = (word: string) => (/^[aeiou]/i.test(word) ? `an ${word}` : `a ${
255
294
  /**
256
295
  * 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.
257
296
  *
258
- * - `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 keeps the requests its class is trusted with. `site` is needed when the scheme has `learned_at`.
297
+ * - `tag(neighborClass, neighborAs, { site, trusted })`: a rule for an import policy. It removes your communities a neighbor set, then adds where the route was learned. A `trusted` neighbor keeps its requests: do not announce, prepend, blackhole. `site` is needed when the scheme has `learned_at`.
259
298
  * - `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.
260
- * - `blackhole(neighborClass, prefixSet)`: a rule for the import policy of a neighbor trusted with blackholes, before anything that rejects long prefixes. It takes a route the neighbor asked to blackhole, but only inside `prefixSet`, its own space, and drops traffic to it here.
299
+ * - `blackhole(prefixSets)`: rules for the import policy of a neighbor you trust with blackholes, before anything that rejects long prefixes. They take a route the neighbor asked to blackhole, but only inside `prefixSets`, its own space, and drop traffic to it here. One rule per set, so one call covers both families.
261
300
  * - `catalogue`: every community, for `communities` in `defineNetwork`.
262
301
  */
263
302
  export function communityScheme<const Class extends string>(scheme: CommunityScheme<Class>) {
@@ -269,17 +308,12 @@ export function communityScheme<const Class extends string>(scheme: CommunitySch
269
308
  (fn): fn is number => fn !== undefined
270
309
  );
271
310
 
272
- // Each request's function numbers, and who may make it.
273
- const requests = [
274
- { functions: [scheme.do_not_announce?.function], trusted: scheme.do_not_announce?.trusted },
275
- { functions: PREPENDS.map(([name]) => scheme.prepend?.[name]), trusted: scheme.prepend?.trusted },
276
- { functions: [scheme.blackhole?.function], trusted: scheme.blackhole?.trusted },
277
- ].map(({ functions, trusted }) => ({ functions: functions.filter((fn): fn is number => fn !== undefined), trusted: trusted ?? [] }));
278
-
279
- const tag = (neighborClass: Class, neighborAs: number, options: { site?: number } = {}): PolicyRule<never, never> => {
280
- const { site } = options;
281
- const untrusted = requests.filter((request) => !request.trusted.includes(neighborClass)).flatMap((request) => request.functions);
282
- const trustedForAny = requests.some((request) => request.functions.length && request.trusted.includes(neighborClass));
311
+ const tag = (
312
+ neighborClass: Class,
313
+ neighborAs: number,
314
+ options: { site?: number; trusted?: boolean } = {}
315
+ ): PolicyRule<never, never> => {
316
+ const { site, trusted = false } = options;
283
317
  if (scheme.learned_at && site === undefined)
284
318
  throw new Error(`communityScheme: tag for AS${neighborAs} needs a site, for learned_at`);
285
319
 
@@ -287,8 +321,8 @@ export function communityScheme<const Class extends string>(scheme: CommunitySch
287
321
  description: `Learned from AS${neighborAs}`,
288
322
  set: {
289
323
  remove_communities: ours.standard,
290
- // A neighbor trusted with nothing loses every community of ours it set. One trusted with some requests keeps those, and loses the rest.
291
- remove_large_communities: trustedForAny ? [...information, ...untrusted].map((fn) => community(fn, "*")) : ours.large,
324
+ // A trusted neighbor keeps its requests and loses only the "where it came from" tags, which are ours to set. Anyone else loses every community of ours.
325
+ remove_large_communities: trusted ? information.map((fn) => community(fn, "*")) : ours.large,
292
326
  add_large_communities: [
293
327
  ...(scheme.learned_from ? [community(scheme.learned_from.function, scheme.learned_from.classes[neighborClass])] : []),
294
328
  ...(scheme.learned_at ? [community(scheme.learned_at.function, site!)] : []),
@@ -298,74 +332,78 @@ export function communityScheme<const Class extends string>(scheme: CommunitySch
298
332
  };
299
333
  };
300
334
 
301
- const blackhole = <S extends string>(neighborClass: Class, prefixSet: S): PolicyRule<never, S> => {
302
- if (scheme.blackhole === undefined) throw new Error("communityScheme: blackhole needs a function number in the scheme");
303
- if (!scheme.blackhole.trusted.includes(neighborClass))
304
- throw new Error(`communityScheme: ${neighborClass} is not trusted to ask for a blackhole`);
335
+ const blackhole = <S extends string>(prefixSets: readonly S[]): PolicyRule<never, S>[] => {
336
+ const fn = scheme.blackhole?.function;
337
+ if (fn === undefined) throw new Error("communityScheme: blackhole needs a function number in the scheme");
305
338
 
306
- return {
339
+ return prefixSets.map((prefixSet) => ({
307
340
  description: "Blackhole, asked by the neighbor",
308
- match: { large_community: community(scheme.blackhole.function, 0), prefix_set: prefixSet },
341
+ match: { large_community: community(fn, 0), prefix_set: prefixSet },
309
342
  set: { blackhole: true },
310
343
  action: "accept",
311
- };
344
+ }));
312
345
  };
313
346
 
314
- const actions = (neighborAs: number, options: { blackhole?: string } = {}): PolicyRule<never, never>[] => [
315
- ...(scheme.blackhole !== undefined && !options.blackhole
316
- ? [
317
- {
318
- description: "Blackholed here, never announced",
319
- match: { large_community: community(scheme.blackhole.function, 0) },
320
- action: "reject" as const,
321
- },
322
- ]
323
- : []),
324
- ...(scheme.do_not_announce !== undefined
325
- ? [
326
- {
327
- description: "Asked not to be announced anywhere",
328
- match: { large_community: community(scheme.do_not_announce.function, 0) },
329
- action: "reject" as const,
330
- },
331
- {
332
- description: `Asked not to be announced to AS${neighborAs}`,
333
- match: { large_community: community(scheme.do_not_announce.function, neighborAs) },
334
- action: "reject" as const,
335
- },
336
- ]
337
- : []),
338
- ...(scheme.blackhole !== undefined && options.blackhole
339
- ? [
340
- {
341
- description: `Blackhole passed on to AS${neighborAs}`,
342
- match: { large_community: community(scheme.blackhole.function, 0) },
343
- set: {
344
- remove_communities: ours.standard,
345
- remove_large_communities: ours.large,
346
- add_communities: [options.blackhole],
347
+ const actions = (neighborAs: number): PolicyRule<never, never>[] => {
348
+ // The upstream's own blackhole community, when it takes blackholes from us.
349
+ const passOn = scheme.blackhole?.upstreams?.[neighborAs];
350
+
351
+ return [
352
+ ...(scheme.blackhole !== undefined && !passOn
353
+ ? [
354
+ {
355
+ description: "Blackholed here, never announced",
356
+ match: { large_community: community(scheme.blackhole.function, 0) },
357
+ action: "reject" as const,
347
358
  },
348
- action: "accept" as const,
349
- },
350
- ]
351
- : []),
352
- ...PREPENDS.flatMap(([name, times]) => {
353
- const fn = scheme.prepend?.[name];
354
- if (fn === undefined) return [];
355
-
356
- return [
357
- {
358
- description: `Asked to prepend ${name.replace("_", " ")} to AS${neighborAs}`,
359
- match: { large_community: community(fn, neighborAs) },
360
- set: { prepend: times },
361
- },
362
- ];
363
- }),
364
- {
365
- description: "Our communities stay inside our network",
366
- set: { remove_communities: ours.standard, remove_large_communities: ours.large },
367
- },
368
- ];
359
+ ]
360
+ : []),
361
+ ...(scheme.do_not_announce !== undefined
362
+ ? [
363
+ {
364
+ description: "Asked not to be announced anywhere",
365
+ match: { large_community: community(scheme.do_not_announce, 0) },
366
+ action: "reject" as const,
367
+ },
368
+ {
369
+ description: `Asked not to be announced to AS${neighborAs}`,
370
+ match: { large_community: community(scheme.do_not_announce, neighborAs) },
371
+ action: "reject" as const,
372
+ },
373
+ ]
374
+ : []),
375
+ ...(scheme.blackhole !== undefined && passOn
376
+ ? [
377
+ {
378
+ description: `Blackhole passed on to AS${neighborAs}`,
379
+ match: { large_community: community(scheme.blackhole.function, 0) },
380
+ set: {
381
+ remove_communities: ours.standard,
382
+ remove_large_communities: ours.large,
383
+ add_communities: [passOn],
384
+ },
385
+ action: "accept" as const,
386
+ },
387
+ ]
388
+ : []),
389
+ ...PREPENDS.flatMap(([name, times]) => {
390
+ const fn = scheme.prepend?.[name];
391
+ if (fn === undefined) return [];
392
+
393
+ return [
394
+ {
395
+ description: `Asked to prepend ${name.replace("_", " ")} to AS${neighborAs}`,
396
+ match: { large_community: community(fn, neighborAs) },
397
+ set: { prepend: times },
398
+ },
399
+ ];
400
+ }),
401
+ {
402
+ description: "Our communities stay inside our network",
403
+ set: { remove_communities: ours.standard, remove_large_communities: ours.large },
404
+ },
405
+ ];
406
+ };
369
407
 
370
408
  const catalogue: Community[] = [
371
409
  ...Object.entries<number>(scheme.learned_from?.classes ?? {}).map(([name, value]) => ({
@@ -386,8 +424,8 @@ export function communityScheme<const Class extends string>(scheme: CommunitySch
386
424
  : []),
387
425
  ...(scheme.do_not_announce !== undefined
388
426
  ? [
389
- { community: community(scheme.do_not_announce.function, 0), description: "Do not announce to anyone" },
390
- { community: community(scheme.do_not_announce.function, "nnn"), description: "Do not announce to AS$0" },
427
+ { community: community(scheme.do_not_announce, 0), description: "Do not announce to anyone" },
428
+ { community: community(scheme.do_not_announce, "nnn"), description: "Do not announce to AS$0" },
391
429
  ]
392
430
  : []),
393
431
  ...PREPENDS.flatMap(([name]) => {
@@ -395,7 +433,14 @@ export function communityScheme<const Class extends string>(scheme: CommunitySch
395
433
  return fn === undefined ? [] : [{ community: community(fn, "nnn"), description: `Prepend ${name.replace("_", " ")} to AS$0` }];
396
434
  }),
397
435
  ...(scheme.blackhole !== undefined
398
- ? [{ community: community(scheme.blackhole.function, 0), description: `Blackhole: dropped in AS${asn}, not announced further` }]
436
+ ? [
437
+ {
438
+ community: community(scheme.blackhole.function, 0),
439
+ description: Object.keys(scheme.blackhole.upstreams ?? {}).length
440
+ ? `Blackhole: dropped in AS${asn} and by its upstreams that take it`
441
+ : `Blackhole: dropped in AS${asn}, not announced further`,
442
+ },
443
+ ]
399
444
  : []),
400
445
  ];
401
446
 
@@ -123,6 +123,6 @@ export type AclRule<V extends string> = {
123
123
  };
124
124
  /** What happens to a matching packet. */
125
125
  action: "accept" | "drop";
126
- /** Accepted traffic above this rate is dropped. */
126
+ /** Accepted traffic above this rate is dropped. On RouterOS it did not limit traffic the CPU routes; see its TRAPS.md. */
127
127
  rate?: Rate;
128
128
  };