@takodotid/circuit 0.17.0 → 0.18.1

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
@@ -1202,6 +1202,11 @@ var DYNAMIC = new Set([
1202
1202
  "/ip firewall filter",
1203
1203
  "/ipv6 firewall filter",
1204
1204
  "/ip firewall nat",
1205
+ "/ipv6 firewall nat",
1206
+ "/ip firewall mangle",
1207
+ "/ipv6 firewall mangle",
1208
+ "/ip firewall raw",
1209
+ "/ipv6 firewall raw",
1205
1210
  "/ip dhcp-server lease"
1206
1211
  ]);
1207
1212
  var quote = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$")}"`;
@@ -1472,10 +1477,11 @@ function command(verb, properties) {
1472
1477
  }
1473
1478
  var yesNo = (on) => on ? "yes" : "no";
1474
1479
  var familySuffix = (family) => family === "ipv4" ? "V4" : "V6";
1480
+ var eachPort = (ports) => [ports].flat().map((port) => typeof port === "number" ? String(port) : `${port.min}-${port.max}`);
1475
1481
  function portList(ports) {
1476
1482
  if (ports === undefined)
1477
1483
  return;
1478
- return [ports].flat().join(",");
1484
+ return eachPort(ports).join(",");
1479
1485
  }
1480
1486
  function duration(seconds) {
1481
1487
  const units = [
@@ -1940,12 +1946,26 @@ var HELPERS = ["ftp", "tftp", "irc", "h323", "sip", "pptp", "rtsp"];
1940
1946
  var CHAINS = ["input", "forward", "output"];
1941
1947
  function renderFirewall(out, ctx) {
1942
1948
  for (const family of ["ipv4", "ipv6"]) {
1949
+ untracked(out, ctx, family);
1943
1950
  filter(out, ctx, family);
1944
1951
  nat(out, ctx, family);
1945
1952
  }
1946
1953
  helpers(out, ctx);
1947
1954
  switchAcls(out, ctx);
1948
1955
  }
1956
+ function untracked(out, ctx, family) {
1957
+ const menu = family === "ipv4" ? "/ip firewall raw" : "/ipv6 firewall raw";
1958
+ for (const prefix of ctx.device.firewall?.untracked ?? []) {
1959
+ if (familyOf(prefix) !== family)
1960
+ continue;
1961
+ for (const [direction, property] of [
1962
+ ["to", "dst-address"],
1963
+ ["from", "src-address"]
1964
+ ]) {
1965
+ out.add(menu, command("add", { action: "notrack", chain: "prerouting", comment: `Untracked ${direction} ${prefix}`, [property]: prefix }));
1966
+ }
1967
+ }
1968
+ }
1949
1969
  function families(ctx, match) {
1950
1970
  const sets = ctx.device.firewall?.address_sets ?? {};
1951
1971
  const address = match?.src ?? match?.dst;
@@ -2021,7 +2041,7 @@ function helpers(out, ctx) {
2021
2041
  }
2022
2042
  }
2023
2043
  function switchAcls(out, ctx) {
2024
- const each = (ports) => ports === undefined ? [undefined] : [ports].flat();
2044
+ const each = (ports) => ports === undefined ? [undefined] : eachPort(ports);
2025
2045
  for (const [port, settings] of Object.entries(ctx.ports)) {
2026
2046
  if (!settings.acl)
2027
2047
  continue;
@@ -32,19 +32,26 @@ var AMPLIFIERS = {
32
32
  memcached: 11211,
33
33
  dvr: 37810
34
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
- ]);
35
+ var ASKED = new Set([AMPLIFIERS.dns, AMPLIFIERS.ntp, AMPLIFIERS.snmp]);
36
+ function antiAmplification(destinations, options = {}) {
37
+ const below1024Except = (port) => [
38
+ { min: 0, max: port - 1 },
39
+ { min: port + 1, max: 1023 }
40
+ ];
41
+ return destinations.flatMap((dst) => [
42
+ ...(options.keep_fragments ?? []).filter((prefix) => contains(dst, prefix)).map((prefix) => ({
43
+ description: `UDP fragments to ${prefix} kept`,
44
+ match: { protocol: "udp", dst: prefix, src_port: 0 },
45
+ action: "accept"
46
+ })),
47
+ { description: `UDP fragments to ${dst}`, match: { protocol: "udp", dst, src_port: 0 }, action: "drop" },
48
+ ...Object.entries(AMPLIFIERS).map(([name, port]) => ({
49
+ description: ASKED.has(port) ? `UDP to ${dst} from ${name}, to a port below 1024` : `UDP to ${dst} from ${name}`,
50
+ match: { protocol: "udp", dst, src_port: port, ...ASKED.has(port) ? { dst_port: below1024Except(port) } : {} },
51
+ action: "drop"
52
+ }))
53
+ ]);
54
+ }
48
55
  var BAD_TCP_FLAGS = [
49
56
  ["FIN+SYN", ["fin", "syn"], []],
50
57
  ["SYN+RST", ["syn", "rst"], []],
@@ -1,4 +1,4 @@
1
- import type { Device, Family, Interface, Port } from "../../../schema";
1
+ import type { Device, Family, Interface, Port, Ports } from "../../../schema";
2
2
  /** A property value as a command takes it. `undefined` and `false` leave the property out. */
3
3
  export type Value = string | number | undefined | false;
4
4
  /** Collects commands under their menu. */
@@ -21,8 +21,10 @@ export declare function command(verb: string, properties: Record<string, Value>)
21
21
  export declare const yesNo: (on: boolean | undefined) => string;
22
22
  /** `V4` or `V6`, the suffix of a chain rendered for one family. */
23
23
  export declare const familySuffix: (family: Family) => string;
24
- /** One port or a list of ports, as `53,123`. */
25
- export declare function portList(ports: number | readonly number[] | undefined): string | undefined;
24
+ /** Each port or range of ports as the platform writes it: `53`, `0-52`. */
25
+ export declare const eachPort: (ports: Ports) => string[];
26
+ /** One port, a range, or a list of them, as `53,0-52`. */
27
+ export declare function portList(ports: Ports | undefined): string | undefined;
26
28
  /** Seconds as the platform writes a duration: `3600` becomes `1h`, `90` becomes `1m30s`. */
27
29
  export declare function duration(seconds: number): string;
28
30
  /** A rate as the switch chip takes it: `2G` becomes `2.0Gbps`. */
@@ -28,10 +28,19 @@ export declare const AMPLIFIERS: {
28
28
  readonly memcached: 11211;
29
29
  readonly dvr: 37810;
30
30
  };
31
+ /** What `antiAmplification` leaves alone. */
32
+ export type AntiAmplificationOptions = {
33
+ /** Prefixes inside the destinations whose UDP fragments pass: for a host that receives UDP too large for one packet, such as SIP or some VPNs. Every fragment after the first is dropped when absent. */
34
+ keep_fragments?: readonly Prefix[];
35
+ };
31
36
  /**
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.
37
+ * Hardware ACL rules dropping reflected UDP floods toward `destinations`, in the switch chip, without limiting anything else.
38
+ *
39
+ * - Replies from an amplifier no host asks across the internet are dropped.
40
+ * - Replies from DNS, NTP and SNMP are dropped only toward a port below 1024 that is not the service's own. A host that runs its own resolver, time server or monitoring keeps its answers.
41
+ * - The fragments after the first carry no port, and a switch chip reads them as port 0. They are dropped, but toward `keep_fragments`.
33
42
  */
34
- export declare const antiAmplification: (destinations: readonly Prefix[], trusted?: readonly Prefix[]) => AclRule<never>[];
43
+ export declare function antiAmplification(destinations: readonly Prefix[], options?: AntiAmplificationOptions): AclRule<never>[];
35
44
  /** Firewall rules dropping packets with a TCP flag combination no legitimate packet carries, for a forward chain. */
36
45
  export declare const badTcpFlags: () => FilterRule<never, never>[];
37
46
  /** The prefix lengths a BGP import accepts. */
@@ -3,6 +3,13 @@ import type { Family, IP, Prefix, Rate } from "./common";
3
3
  export type Protocol = "tcp" | "udp" | "icmp" | "gre" | "esp" | "ah" | "ospf" | "vrrp";
4
4
  /** The state connection tracking assigns a packet. */
5
5
  export type ConnectionState = "new" | "established" | "related" | "untracked" | "invalid";
6
+ /** Ports from `min` to `max`, both included. */
7
+ export type PortRange = {
8
+ min: number;
9
+ max: number;
10
+ };
11
+ /** A port, a range of ports, or any of several. */
12
+ export type Ports = number | PortRange | readonly (number | PortRange)[];
6
13
  /** A TCP header flag. */
7
14
  export type TcpFlag = "fin" | "syn" | "rst" | "psh" | "ack" | "urg";
8
15
  /** Conditions on a packet. All must hold. A rule with addresses of one family applies to that family only. */
@@ -20,9 +27,9 @@ export type FilterMatch<I extends string, AS extends string> = {
20
27
  /** Destination is in this address set. */
21
28
  dst_set?: AS;
22
29
  /** Source port, or any of several. TCP and UDP only. */
23
- src_port?: number | readonly number[];
30
+ src_port?: Ports;
24
31
  /** Destination port, or any of several. TCP and UDP only. */
25
- dst_port?: number | readonly number[];
32
+ dst_port?: Ports;
26
33
  /** Arrived on this interface. */
27
34
  in_interface?: I;
28
35
  /** Leaves through this interface. */
@@ -89,6 +96,8 @@ export type Firewall<I extends string, AS extends string> = {
89
96
  };
90
97
  /** Helpers that run. None when absent. */
91
98
  helpers?: readonly Helper[];
99
+ /** Traffic to and from these prefixes passes without connection tracking. A flood toward them cannot fill the connection table, and rules see its packets in the state `untracked`. Everything is tracked when absent. */
100
+ untracked?: readonly Prefix[];
92
101
  };
93
102
  /** A stateless rule evaluated in hardware where traffic arrives, before routing. */
94
103
  export type AclRule<V extends string> = {
@@ -105,9 +114,9 @@ export type AclRule<V extends string> = {
105
114
  /** Destination prefix. */
106
115
  dst?: Prefix;
107
116
  /** Source port, or any of several. */
108
- src_port?: number | readonly number[];
117
+ src_port?: Ports;
109
118
  /** Destination port, or any of several. */
110
- dst_port?: number | readonly number[];
119
+ dst_port?: Ports;
111
120
  /** Carried in this VLAN. */
112
121
  vlan?: V;
113
122
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
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",
@@ -17,6 +17,8 @@ Behaviour that looks like something else. Each one has been hit on a real device
17
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
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
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.
20
+ - Any rule using fasttrack adds a dynamic "special dummy rule to show fasttrack counters" to the filter and the raw table. It cannot be removed, `cannot remove builtin`, so every selector over those menus skips dynamic entries.
21
+ - The connection table holds 1,048,576 entries on a CCR2216 with 16 GiB, and a full table refuses every new connection through the router, not only the target's. A SYN flood of 210,000 packets a second filled it in five seconds; a UDP flood from random sources fills it about six times faster, because an unanswered UDP entry lives 30 seconds. Traffic in `untracked` prefixes never enters it.
20
22
  - 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.
21
23
  - `/ip cloud ddns-enabled` cannot be turned off on 7.23.5.
22
24
  - `mac-server` and `mac-winbox` work at layer 2 and ignore the IP firewall; they are limited by interface list.
@@ -74,6 +74,11 @@ const DYNAMIC = new Set([
74
74
  "/ip firewall filter",
75
75
  "/ipv6 firewall filter",
76
76
  "/ip firewall nat",
77
+ "/ipv6 firewall nat",
78
+ "/ip firewall mangle",
79
+ "/ipv6 firewall mangle",
80
+ "/ip firewall raw",
81
+ "/ipv6 firewall raw",
77
82
  "/ip dhcp-server lease",
78
83
  ]);
79
84
 
@@ -13,6 +13,7 @@ import type {
13
13
  NeighborSettings,
14
14
  PolicyRule,
15
15
  Port,
16
+ Ports,
16
17
  Service,
17
18
  Severity,
18
19
  } from "../../../schema";
@@ -72,11 +73,15 @@ export const yesNo = (on: boolean | undefined): string => (on ? "yes" : "no");
72
73
  /** `V4` or `V6`, the suffix of a chain rendered for one family. */
73
74
  export const familySuffix = (family: Family): string => (family === "ipv4" ? "V4" : "V6");
74
75
 
75
- /** One port or a list of ports, as `53,123`. */
76
- export function portList(ports: number | readonly number[] | undefined): string | undefined {
76
+ /** Each port or range of ports as the platform writes it: `53`, `0-52`. */
77
+ export const eachPort = (ports: Ports): string[] =>
78
+ [ports].flat().map((port) => (typeof port === "number" ? String(port) : `${port.min}-${port.max}`));
79
+
80
+ /** One port, a range, or a list of them, as `53,0-52`. */
81
+ export function portList(ports: Ports | undefined): string | undefined {
77
82
  if (ports === undefined) return undefined;
78
83
 
79
- return [ports].flat().join(",");
84
+ return eachPort(ports).join(",");
80
85
  }
81
86
 
82
87
  /** Seconds as the platform writes a duration: `3600` becomes `1h`, `90` becomes `1m30s`. */
@@ -734,6 +739,7 @@ const CHAINS = ["input", "forward", "output"] as const;
734
739
 
735
740
  export function renderFirewall(out: Output, ctx: Context): void {
736
741
  for (const family of ["ipv4", "ipv6"] as const) {
742
+ untracked(out, ctx, family);
737
743
  filter(out, ctx, family);
738
744
  nat(out, ctx, family);
739
745
  }
@@ -742,6 +748,25 @@ export function renderFirewall(out: Output, ctx: Context): void {
742
748
  switchAcls(out, ctx);
743
749
  }
744
750
 
751
+ /** Untracked prefixes skip connection tracking in the raw table, both ways, before a connection is ever made. */
752
+ function untracked(out: Output, ctx: Context, family: Family): void {
753
+ const menu = family === "ipv4" ? "/ip firewall raw" : "/ipv6 firewall raw";
754
+
755
+ for (const prefix of ctx.device.firewall?.untracked ?? []) {
756
+ if (familyOf(prefix) !== family) continue;
757
+
758
+ for (const [direction, property] of [
759
+ ["to", "dst-address"],
760
+ ["from", "src-address"],
761
+ ] as const) {
762
+ out.add(
763
+ menu,
764
+ command("add", { action: "notrack", chain: "prerouting", comment: `Untracked ${direction} ${prefix}`, [property]: prefix })
765
+ );
766
+ }
767
+ }
768
+ }
769
+
745
770
  /**
746
771
  * The families a rule applies to. An address in the rule decides it; otherwise it applies to both.
747
772
  *
@@ -836,10 +861,10 @@ function helpers(out: Output, ctx: Context): void {
836
861
  }
837
862
 
838
863
  /**
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.
864
+ * 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 or range, so a list becomes one rule for each.
840
865
  */
841
866
  function switchAcls(out: Output, ctx: Context): void {
842
- const each = (ports: number | readonly number[] | undefined) => (ports === undefined ? [undefined] : [ports].flat());
867
+ const each = (ports: Ports | undefined) => (ports === undefined ? [undefined] : eachPort(ports));
843
868
 
844
869
  for (const [port, settings] of Object.entries(ctx.ports)) {
845
870
  if (!settings.acl) continue;
@@ -41,23 +41,44 @@ export const AMPLIFIERS = {
41
41
  dvr: 37810,
42
42
  } as const satisfies Record<string, number>;
43
43
 
44
+ /** Of the amplifiers, the services hosts do ask across the internet: resolvers, time servers, monitoring. A reply to a real request goes back to the asking port, 1024 or above, or to the service's own port when two servers talk. */
45
+ const ASKED: ReadonlySet<number> = new Set([AMPLIFIERS.dns, AMPLIFIERS.ntp, AMPLIFIERS.snmp]);
46
+
47
+ /** What `antiAmplification` leaves alone. */
48
+ export type AntiAmplificationOptions = {
49
+ /** Prefixes inside the destinations whose UDP fragments pass: for a host that receives UDP too large for one packet, such as SIP or some VPNs. Every fragment after the first is dropped when absent. */
50
+ keep_fragments?: readonly Prefix[];
51
+ };
52
+
44
53
  /**
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.
54
+ * Hardware ACL rules dropping reflected UDP floods toward `destinations`, in the switch chip, without limiting anything else.
55
+ *
56
+ * - Replies from an amplifier no host asks across the internet are dropped.
57
+ * - Replies from DNS, NTP and SNMP are dropped only toward a port below 1024 that is not the service's own. A host that runs its own resolver, time server or monitoring keeps its answers.
58
+ * - The fragments after the first carry no port, and a switch chip reads them as port 0. They are dropped, but toward `keep_fragments`.
46
59
  */
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
- })),
60
+ export function antiAmplification(destinations: readonly Prefix[], options: AntiAmplificationOptions = {}): AclRule<never>[] {
61
+ const below1024Except = (port: number) => [
62
+ { min: 0, max: port - 1 },
63
+ { min: port + 1, max: 1023 },
64
+ ];
65
+
66
+ return destinations.flatMap((dst) => [
67
+ ...(options.keep_fragments ?? [])
68
+ .filter((prefix) => contains(dst, prefix))
69
+ .map((prefix): AclRule<never> => ({
70
+ description: `UDP fragments to ${prefix} kept`,
71
+ match: { protocol: "udp", dst: prefix, src_port: 0 },
72
+ action: "accept",
73
+ })),
54
74
  { description: `UDP fragments to ${dst}`, match: { protocol: "udp", dst, src_port: 0 }, action: "drop" },
55
75
  ...Object.entries(AMPLIFIERS).map(([name, port]): AclRule<never> => ({
56
- description: `UDP to ${dst} from ${name}`,
57
- match: { protocol: "udp", dst, src_port: port },
76
+ description: ASKED.has(port) ? `UDP to ${dst} from ${name}, to a port below 1024` : `UDP to ${dst} from ${name}`,
77
+ match: { protocol: "udp", dst, src_port: port, ...(ASKED.has(port) ? { dst_port: below1024Except(port) } : {}) },
58
78
  action: "drop",
59
79
  })),
60
80
  ]);
81
+ }
61
82
 
62
83
  /** TCP flag combinations no legitimate packet carries: name, flags set, flags unset. */
63
84
  const BAD_TCP_FLAGS = [
@@ -6,6 +6,12 @@ export type Protocol = "tcp" | "udp" | "icmp" | "gre" | "esp" | "ah" | "ospf" |
6
6
  /** The state connection tracking assigns a packet. */
7
7
  export type ConnectionState = "new" | "established" | "related" | "untracked" | "invalid";
8
8
 
9
+ /** Ports from `min` to `max`, both included. */
10
+ export type PortRange = { min: number; max: number };
11
+
12
+ /** A port, a range of ports, or any of several. */
13
+ export type Ports = number | PortRange | readonly (number | PortRange)[];
14
+
9
15
  /** A TCP header flag. */
10
16
  export type TcpFlag = "fin" | "syn" | "rst" | "psh" | "ack" | "urg";
11
17
 
@@ -24,9 +30,9 @@ export type FilterMatch<I extends string, AS extends string> = {
24
30
  /** Destination is in this address set. */
25
31
  dst_set?: AS;
26
32
  /** Source port, or any of several. TCP and UDP only. */
27
- src_port?: number | readonly number[];
33
+ src_port?: Ports;
28
34
  /** Destination port, or any of several. TCP and UDP only. */
29
- dst_port?: number | readonly number[];
35
+ dst_port?: Ports;
30
36
  /** Arrived on this interface. */
31
37
  in_interface?: I;
32
38
  /** Leaves through this interface. */
@@ -98,6 +104,8 @@ export type Firewall<I extends string, AS extends string> = {
98
104
  };
99
105
  /** Helpers that run. None when absent. */
100
106
  helpers?: readonly Helper[];
107
+ /** Traffic to and from these prefixes passes without connection tracking. A flood toward them cannot fill the connection table, and rules see its packets in the state `untracked`. Everything is tracked when absent. */
108
+ untracked?: readonly Prefix[];
101
109
  };
102
110
 
103
111
  /** A stateless rule evaluated in hardware where traffic arrives, before routing. */
@@ -115,9 +123,9 @@ export type AclRule<V extends string> = {
115
123
  /** Destination prefix. */
116
124
  dst?: Prefix;
117
125
  /** Source port, or any of several. */
118
- src_port?: number | readonly number[];
126
+ src_port?: Ports;
119
127
  /** Destination port, or any of several. */
120
- dst_port?: number | readonly number[];
128
+ dst_port?: Ports;
121
129
  /** Carried in this VLAN. */
122
130
  vlan?: V;
123
131
  };