@takodotid/circuit 0.17.0 → 0.18.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
@@ -1472,10 +1472,11 @@ function command(verb, properties) {
1472
1472
  }
1473
1473
  var yesNo = (on) => on ? "yes" : "no";
1474
1474
  var familySuffix = (family) => family === "ipv4" ? "V4" : "V6";
1475
+ var eachPort = (ports) => [ports].flat().map((port) => typeof port === "number" ? String(port) : `${port.min}-${port.max}`);
1475
1476
  function portList(ports) {
1476
1477
  if (ports === undefined)
1477
1478
  return;
1478
- return [ports].flat().join(",");
1479
+ return eachPort(ports).join(",");
1479
1480
  }
1480
1481
  function duration(seconds) {
1481
1482
  const units = [
@@ -1940,12 +1941,26 @@ var HELPERS = ["ftp", "tftp", "irc", "h323", "sip", "pptp", "rtsp"];
1940
1941
  var CHAINS = ["input", "forward", "output"];
1941
1942
  function renderFirewall(out, ctx) {
1942
1943
  for (const family of ["ipv4", "ipv6"]) {
1944
+ untracked(out, ctx, family);
1943
1945
  filter(out, ctx, family);
1944
1946
  nat(out, ctx, family);
1945
1947
  }
1946
1948
  helpers(out, ctx);
1947
1949
  switchAcls(out, ctx);
1948
1950
  }
1951
+ function untracked(out, ctx, family) {
1952
+ const menu = family === "ipv4" ? "/ip firewall raw" : "/ipv6 firewall raw";
1953
+ for (const prefix of ctx.device.firewall?.untracked ?? []) {
1954
+ if (familyOf(prefix) !== family)
1955
+ continue;
1956
+ for (const [direction, property] of [
1957
+ ["to", "dst-address"],
1958
+ ["from", "src-address"]
1959
+ ]) {
1960
+ out.add(menu, command("add", { action: "notrack", chain: "prerouting", comment: `Untracked ${direction} ${prefix}`, [property]: prefix }));
1961
+ }
1962
+ }
1963
+ }
1949
1964
  function families(ctx, match) {
1950
1965
  const sets = ctx.device.firewall?.address_sets ?? {};
1951
1966
  const address = match?.src ?? match?.dst;
@@ -2021,7 +2036,7 @@ function helpers(out, ctx) {
2021
2036
  }
2022
2037
  }
2023
2038
  function switchAcls(out, ctx) {
2024
- const each = (ports) => ports === undefined ? [undefined] : [ports].flat();
2039
+ const each = (ports) => ports === undefined ? [undefined] : eachPort(ports);
2025
2040
  for (const [port, settings] of Object.entries(ctx.ports)) {
2026
2041
  if (!settings.acl)
2027
2042
  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.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",
@@ -17,6 +17,7 @@ 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
+ - 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
21
  - 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
22
  - `/ip cloud ddns-enabled` cannot be turned off on 7.23.5.
22
23
  - `mac-server` and `mac-winbox` work at layer 2 and ignore the IP firewall; they are limited by interface list.
@@ -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
  };