@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 +19 -14
- package/dist/presets/index.js +102 -66
- package/dist/types/presets/index.d.ts +40 -18
- package/dist/types/schema/firewall.d.ts +1 -1
- package/package.json +1 -1
- package/src/adapters/devices/routeros/TRAPS.md +3 -0
- package/src/adapters/devices/routeros/render.ts +24 -18
- package/src/presets/index.ts +134 -89
- package/src/schema/firewall.ts +1 -1
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
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
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
|
}
|
package/dist/presets/index.js
CHANGED
|
@@ -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:
|
|
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 = (
|
|
188
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
238
|
-
match: { large_community: community(
|
|
239
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
|
263
|
-
{ community: community(scheme.do_not_announce
|
|
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 ? [
|
|
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?:
|
|
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?:
|
|
103
|
+
prepend?: {
|
|
80
104
|
once?: number;
|
|
81
105
|
twice?: number;
|
|
82
106
|
three_times?: number;
|
|
83
107
|
};
|
|
84
|
-
/**
|
|
85
|
-
|
|
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
|
|
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(
|
|
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,
|
|
108
|
-
|
|
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
|
@@ -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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
}
|
package/src/presets/index.ts
CHANGED
|
@@ -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?:
|
|
275
|
+
do_not_announce?: number;
|
|
235
276
|
/** A neighbor asks: prepend toward an AS once, twice or three times, `asn:function:asn`. */
|
|
236
|
-
prepend?:
|
|
237
|
-
/**
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
291
|
-
remove_large_communities:
|
|
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>(
|
|
302
|
-
|
|
303
|
-
if (
|
|
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(
|
|
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
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
390
|
-
{ community: community(scheme.do_not_announce
|
|
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
|
-
? [
|
|
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
|
|
package/src/schema/firewall.ts
CHANGED
|
@@ -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
|
};
|