@push.rocks/smartnftables 4.0.1 → 4.2.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.
Files changed (41) hide show
  1. package/changelog.md +19 -0
  2. package/dist_rust/smartnftables_linux_amd64_musl +0 -0
  3. package/dist_rust/smartnftables_linux_amd64_musl.tsrust-build.json +5 -5
  4. package/dist_rust/smartnftables_linux_arm64_musl +0 -0
  5. package/dist_rust/smartnftables_linux_arm64_musl.tsrust-build.json +5 -5
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/classes.manageddockerforwarding.d.ts +2 -1
  8. package/dist_ts/classes.manageddockerforwarding.js +3 -2
  9. package/dist_ts/forwardhooks.d.ts +8 -0
  10. package/dist_ts/forwardhooks.js +42 -0
  11. package/dist_ts/index.d.ts +2 -0
  12. package/dist_ts/index.js +2 -1
  13. package/dist_ts/managed.docker.types.d.ts +2 -1
  14. package/dist_ts/managed.egress.types.d.ts +5 -0
  15. package/dist_ts/managed.forwardhooks.types.d.ts +35 -0
  16. package/dist_ts/managed.forwardhooks.types.js +2 -0
  17. package/package.json +5 -4
  18. package/readme.md +139 -19
  19. package/rust/src/docker.frontend.rs +2 -2
  20. package/rust/src/docker.graph.rs +36 -11
  21. package/rust/src/docker.policy.rs +95 -32
  22. package/rust/src/docker.rs +37 -13
  23. package/rust/src/docker_tests.rs +164 -2
  24. package/rust/src/egress.host.rs +53 -45
  25. package/rust/src/egress.rs +64 -0
  26. package/rust/src/egress_exclusiveforwarding_tests.rs +256 -0
  27. package/rust/src/forwardhooks.rs +176 -0
  28. package/rust/src/forwardhooks_tests.rs +136 -0
  29. package/rust/src/main.rs +12 -8
  30. package/rust/src/owner_exclusiveforwarding_tests.rs +200 -0
  31. package/rust/src/owner_host_traffic_tests.rs +1 -1
  32. package/rust/src/owner_tests.rs +3 -0
  33. package/rust/src/wire.links.rs +28 -6
  34. package/rust/src/wire.rs +22 -2
  35. package/ts/00_commitinfo_data.ts +1 -1
  36. package/ts/classes.manageddockerforwarding.ts +2 -1
  37. package/ts/forwardhooks.ts +39 -0
  38. package/ts/index.ts +2 -0
  39. package/ts/managed.docker.types.ts +2 -1
  40. package/ts/managed.egress.types.ts +5 -0
  41. package/ts/managed.forwardhooks.types.ts +26 -0
@@ -210,6 +210,67 @@ pub struct HostScope {
210
210
  /// bound links. Absent and empty are the same canonical policy.
211
211
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
212
212
  pub local_platform_endpoints: Vec<String>,
213
+ /// This table is the host's only forwarding owner: every forwarded packet,
214
+ /// IPv4 or IPv6, that the scope does not admit drops, instead of passing to
215
+ /// other owners. Absent and false are the same canonical policy.
216
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
217
+ pub exclusive_forwarding: bool,
218
+ }
219
+ /// One leased source port range of one allocation, on the handoff that holds it.
220
+ pub struct LeasedForward<'a> {
221
+ pub handoff: &'a HostHandoff,
222
+ pub allocation: &'a Allocation,
223
+ pub range: &'a PortRange,
224
+ }
225
+ /// One publication, on the handoff link that holds its leased target address.
226
+ pub struct PublishedForward<'a> {
227
+ pub port: &'a PublishedPort,
228
+ pub link: &'a LocalLink,
229
+ }
230
+ /// The forwarded flows a host-transit scope admits, in canonical scope order:
231
+ /// leased flows from a handoff to the uplink, and per publication its inbound
232
+ /// flows from the uplink to the handoff and, when symmetric, the flows it opens
233
+ /// from its target ports to the uplink; each with its replies. The scope's nft
234
+ /// forward chain and the Docker contribution both derive from this one list.
235
+ pub struct Forwarding<'a> {
236
+ pub leased: Vec<LeasedForward<'a>>,
237
+ pub published: Vec<PublishedForward<'a>>,
238
+ }
239
+ impl HostScope {
240
+ pub fn forwarding(&self) -> Result<Forwarding<'_>> {
241
+ let mut leased = Vec::new();
242
+ for handoff in &self.handoffs {
243
+ for allocation in &handoff.allocations {
244
+ for range in &allocation.source_port_ranges {
245
+ leased.push(LeasedForward {
246
+ handoff,
247
+ allocation,
248
+ range,
249
+ });
250
+ }
251
+ }
252
+ }
253
+ // Validation admits only leased transit source addresses as targets, and
254
+ // one address never spans two active handoff links, so each publication
255
+ // has exactly one handoff.
256
+ let mut published = Vec::new();
257
+ for port in &self.published_ports {
258
+ let handoff =
259
+ self.handoffs
260
+ .iter()
261
+ .find(|handoff| {
262
+ handoff.allocations.iter().any(|allocation| {
263
+ allocation.transit_source_address == port.target_address
264
+ })
265
+ })
266
+ .ok_or(Error::Invalid)?;
267
+ published.push(PublishedForward {
268
+ port,
269
+ link: &handoff.link,
270
+ });
271
+ }
272
+ Ok(Forwarding { leased, published })
273
+ }
213
274
  }
214
275
  /// One loopback TCP service that only one local user may dial: packets the host
215
276
  /// sends to the exact address and port from a socket of any other user reject,
@@ -1168,6 +1229,9 @@ fn normalize_host(value: &mut HostScope) -> Result<()> {
1168
1229
  )
1169
1230
  }
1170
1231
 
1232
+ #[cfg(test)]
1233
+ #[path = "egress_exclusiveforwarding_tests.rs"]
1234
+ mod exclusiveforwarding_tests;
1171
1235
  #[cfg(test)]
1172
1236
  #[path = "egress_hostgrant_tests.rs"]
1173
1237
  pub(crate) mod hostgrant_tests;
@@ -0,0 +1,256 @@
1
+ //! Exclusive forwarding in host transit: the host forwards exactly the scope's
2
+ //! leased, published and symmetric handoff flows, and every other forwarded
3
+ //! packet drops instead of passing to other owners.
4
+ use super::hostgrant_tests::{
5
+ bytes, chains, decide, normalized, opening, prepared, Flow, ACK, ESTABLISHED,
6
+ };
7
+ use super::tests::{host, pool_guard, published_host, published_port, router};
8
+ use super::Prepared;
9
+ use crate::policy::verdict;
10
+ use serde_json::{json, Value};
11
+
12
+ const TRANSIT: [u8; 4] = [10, 240, 0, 2];
13
+ const UPLINK: [u8; 4] = [192, 0, 2, 2];
14
+ const PUBLIC: [u8; 4] = [198, 51, 100, 200];
15
+ const CLIENT: [u8; 4] = [198, 51, 100, 9];
16
+ const LAN_PEER: [u8; 4] = [172, 16, 0, 9];
17
+ const HANDOFF: Option<(u32, &str)> = Some((4, "handoff"));
18
+ const UPLINK_LINK: Option<(u32, &str)> = Some((2, "ens18"));
19
+ /// Another host link that no scope binds: a management LAN, a second uplink.
20
+ const LAN: Option<(u32, &str)> = Some((7, "lan0"));
21
+
22
+ fn exclusive(mut value: Value) -> Value {
23
+ value["scope"]["exclusiveForwarding"] = json!(true);
24
+ value
25
+ }
26
+ /// The fixture with one symmetric UDP publication of the transit address.
27
+ fn published() -> Value {
28
+ let mut port = published_port("udp", 5060, 5060);
29
+ port["symmetric"] = json!(true);
30
+ published_host(json!([published_port("tcp", 443, 8443), port]))
31
+ }
32
+ /// One forwarded packet; the originally tracked tuple equals the live one.
33
+ fn forwarded(
34
+ input: Option<(u32, &'static str)>,
35
+ output: Option<(u32, &'static str)>,
36
+ (source, source_port): ([u8; 4], u16),
37
+ (destination, destination_port): ([u8; 4], u16),
38
+ ) -> Flow {
39
+ Flow {
40
+ input,
41
+ output,
42
+ source,
43
+ destination,
44
+ source_port,
45
+ destination_port,
46
+ original: (source, destination, source_port, destination_port),
47
+ ..opening(input, output)
48
+ }
49
+ }
50
+ /// The answer to `flow` on the reverse links.
51
+ fn answer(flow: Flow) -> Flow {
52
+ Flow {
53
+ input: flow.output,
54
+ output: flow.input,
55
+ source: flow.destination,
56
+ destination: flow.source,
57
+ source_port: flow.destination_port,
58
+ destination_port: flow.source_port,
59
+ tcp_flags: ACK,
60
+ state: ESTABLISHED,
61
+ reply: true,
62
+ ..flow
63
+ }
64
+ }
65
+ /// Forwarded traffic no scope admits: the host as a gateway between its other
66
+ /// links and the uplink, a hairpin back out of the uplink, and traffic between
67
+ /// two links the scope does not bind, in both directions and both protocols.
68
+ fn unadmitted() -> Vec<Flow> {
69
+ let mut result = Vec::new();
70
+ for flow in [
71
+ forwarded(LAN, UPLINK_LINK, (LAN_PEER, 40000), (PUBLIC, 443)),
72
+ forwarded(UPLINK_LINK, LAN, (CLIENT, 40000), (LAN_PEER, 22)),
73
+ forwarded(UPLINK_LINK, UPLINK_LINK, (CLIENT, 40000), (PUBLIC, 443)),
74
+ forwarded(
75
+ LAN,
76
+ Some((8, "lan1")),
77
+ (LAN_PEER, 40000),
78
+ ([172, 17, 0, 9], 80),
79
+ ),
80
+ ] {
81
+ result.extend([
82
+ flow,
83
+ answer(flow),
84
+ Flow {
85
+ protocol: 17,
86
+ ..flow
87
+ },
88
+ ]);
89
+ }
90
+ result
91
+ }
92
+ /// Every flow the scope forwards: leased TCP and UDP egress with replies,
93
+ /// published inbound TCP and UDP with replies, and symmetric UDP outbound.
94
+ fn admitted() -> Vec<Flow> {
95
+ let leased = forwarded(HANDOFF, UPLINK_LINK, (TRANSIT, 10500), (PUBLIC, 443));
96
+ let inbound = Flow {
97
+ original: (CLIENT, UPLINK, 40000, 443),
98
+ ..forwarded(UPLINK_LINK, HANDOFF, (CLIENT, 40000), (TRANSIT, 8443))
99
+ };
100
+ let signalling = Flow {
101
+ protocol: 17,
102
+ original: (CLIENT, UPLINK, 40001, 5060),
103
+ ..forwarded(UPLINK_LINK, HANDOFF, (CLIENT, 40001), (TRANSIT, 5060))
104
+ };
105
+ let symmetric = Flow {
106
+ protocol: 17,
107
+ ..forwarded(HANDOFF, UPLINK_LINK, (TRANSIT, 5060), (CLIENT, 5060))
108
+ };
109
+ let mut result = Vec::new();
110
+ for flow in [
111
+ leased,
112
+ Flow {
113
+ protocol: 17,
114
+ ..leased
115
+ },
116
+ inbound,
117
+ signalling,
118
+ symmetric,
119
+ ] {
120
+ result.extend([
121
+ flow,
122
+ Flow {
123
+ state: ESTABLISHED,
124
+ tcp_flags: ACK,
125
+ ..flow
126
+ },
127
+ answer(flow),
128
+ ]);
129
+ }
130
+ result
131
+ }
132
+
133
+ #[test]
134
+ fn exclusive_forwarding_absent_and_false_keep_the_host_digest_and_compiled_bytes() {
135
+ let baseline = prepared(host());
136
+ assert!(!serde_json::to_string(&baseline.policy)
137
+ .unwrap()
138
+ .contains("exclusiveForwarding"));
139
+ let mut value = host();
140
+ value["scope"]["exclusiveForwarding"] = json!(false);
141
+ let explicit = prepared(value);
142
+ assert_eq!(explicit, baseline);
143
+ assert_eq!(bytes(&explicit), bytes(&baseline));
144
+ let with = prepared(exclusive(host()));
145
+ assert_ne!(with.digest, baseline.digest);
146
+ assert!(serde_json::to_string(&with.policy)
147
+ .unwrap()
148
+ .contains("\"exclusiveForwarding\":true"));
149
+ }
150
+
151
+ #[test]
152
+ fn exclusive_forwarding_appends_one_unconditional_forward_drop_and_nothing_else() {
153
+ for fixture in [host(), published()] {
154
+ let baseline = chains(&prepared(fixture.clone()));
155
+ let with = chains(&prepared(exclusive(fixture)));
156
+ assert_eq!(
157
+ with.keys().collect::<Vec<_>>(),
158
+ baseline.keys().collect::<Vec<_>>()
159
+ );
160
+ for (name, rules) in &baseline {
161
+ if name != "forward" {
162
+ assert_eq!(&with[name], rules, "{name}");
163
+ }
164
+ }
165
+ // The previous forward rules keep their order and the drop follows them.
166
+ // It has no family or interface match: forwarded IPv6 and every link
167
+ // pair drop like forwarded IPv4.
168
+ let forward = &with["forward"];
169
+ assert_eq!(&forward[..forward.len() - 1], &baseline["forward"][..]);
170
+ assert_eq!(forward.last().unwrap(), &vec![verdict(0, None)]);
171
+ }
172
+ }
173
+
174
+ #[test]
175
+ fn exclusive_forwarding_denies_every_unadmitted_forward_and_keeps_every_admitted_one() {
176
+ for fixture in [host(), published()] {
177
+ let baseline = prepared(fixture.clone());
178
+ let with = prepared(exclusive(fixture));
179
+ // Without the member another owner's forwarding passes this table.
180
+ for (index, flow) in unadmitted().iter().enumerate() {
181
+ let decision = decide(&baseline, "forward", flow);
182
+ assert_eq!(
183
+ (decision.verdict, decision.chain.as_str()),
184
+ (1, "policy"),
185
+ "baseline {index}"
186
+ );
187
+ let decision = decide(&with, "forward", flow);
188
+ assert_eq!(
189
+ (decision.verdict, decision.chain.as_str()),
190
+ (0, "forward"),
191
+ "exclusive {index}"
192
+ );
193
+ }
194
+ // Every flow the scope forwards keeps its exact decision, and so does
195
+ // every handoff flow it already denies.
196
+ let handoff = [
197
+ forwarded(HANDOFF, LAN, (TRANSIT, 10500), (LAN_PEER, 443)),
198
+ forwarded(LAN, HANDOFF, (LAN_PEER, 40000), (TRANSIT, 8443)),
199
+ forwarded(HANDOFF, UPLINK_LINK, (TRANSIT, 9999), (PUBLIC, 443)),
200
+ forwarded(
201
+ HANDOFF,
202
+ UPLINK_LINK,
203
+ (TRANSIT, 10500),
204
+ ([10, 250, 0, 2], 443),
205
+ ),
206
+ ];
207
+ for (index, flow) in admitted().iter().chain(&handoff).enumerate() {
208
+ assert_eq!(
209
+ decide(&with, "forward", flow),
210
+ decide(&baseline, "forward", flow),
211
+ "flow {index}"
212
+ );
213
+ }
214
+ }
215
+ // The positive controls are real admissions of the published fixture.
216
+ let published = prepared(exclusive(published()));
217
+ for (index, flow) in admitted().iter().enumerate() {
218
+ assert_eq!(
219
+ decide(&published, "forward", flow).verdict,
220
+ 1,
221
+ "admitted {index}"
222
+ );
223
+ }
224
+ }
225
+
226
+ #[test]
227
+ fn exclusive_forwarding_without_handoffs_forwards_nothing() {
228
+ let mut value = exclusive(host());
229
+ value["scope"]["handoffs"] = json!([]);
230
+ let empty = prepared(value);
231
+ assert_eq!(chains(&empty)["forward"], vec![vec![verdict(0, None)]]);
232
+ let leased = forwarded(HANDOFF, UPLINK_LINK, (TRANSIT, 10500), (PUBLIC, 443));
233
+ for flow in unadmitted().into_iter().chain([leased, answer(leased)]) {
234
+ assert_eq!(decide(&empty, "forward", &flow).verdict, 0);
235
+ }
236
+ // Without the member an empty scope forwards everything, as before.
237
+ let mut value = host();
238
+ value["scope"]["handoffs"] = json!([]);
239
+ let open = prepared(value);
240
+ assert!(chains(&open)["forward"].is_empty());
241
+ assert_eq!(decide(&open, "forward", &leased).verdict, 1);
242
+ }
243
+
244
+ #[test]
245
+ fn exclusive_forwarding_is_a_host_transit_boolean_only() {
246
+ for fixture in [router(), pool_guard()] {
247
+ assert!(normalized(exclusive(fixture)).is_err());
248
+ }
249
+ for invalid in [json!(1), json!("true"), json!(null)] {
250
+ let mut value = host();
251
+ value["scope"]["exclusiveForwarding"] = invalid;
252
+ assert!(normalized(value).is_err());
253
+ }
254
+ let restored: Prepared = prepared(exclusive(host()));
255
+ assert_eq!(restored.clone().policy.prepare().unwrap(), restored);
256
+ }
@@ -0,0 +1,176 @@
1
+ //! Read-only view of every packet-filter owner on the IPv4/IPv6 forward hook of
2
+ //! this network namespace. It claims, adopts and changes nothing.
3
+ use crate::wire::{self, Attr, Socket};
4
+ use crate::{decode, encode, Error, Request, Result};
5
+ use serde::Serialize;
6
+
7
+ #[cfg(test)]
8
+ #[path = "forwardhooks_tests.rs"]
9
+ mod tests;
10
+
11
+ /// One nf_tables base chain on the forward hook.
12
+ #[derive(Clone, Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
13
+ #[serde(rename_all = "camelCase")]
14
+ pub struct Chain {
15
+ pub family: &'static str,
16
+ pub table: String,
17
+ pub table_handle: String,
18
+ pub chain: String,
19
+ #[serde(rename = "type")]
20
+ pub chain_type: String,
21
+ pub priority: i32,
22
+ pub policy: &'static str,
23
+ }
24
+ /// Docker's iptables-nft dispatch chains in table `ip filter`.
25
+ #[derive(Clone, Debug, Serialize, PartialEq, Eq)]
26
+ #[serde(rename_all = "camelCase")]
27
+ pub struct Docker {
28
+ pub docker_user: bool,
29
+ pub docker_forward: bool,
30
+ }
31
+ /// Registered legacy xtables tables. Their hooks, including FORWARD, are not
32
+ /// nf_tables objects and never appear in `chains`.
33
+ #[derive(Clone, Debug, Serialize, PartialEq, Eq)]
34
+ #[serde(rename_all = "camelCase")]
35
+ pub struct Legacy {
36
+ pub ipv4: Vec<String>,
37
+ pub ipv6: Vec<String>,
38
+ }
39
+ #[derive(Clone, Debug, Serialize, PartialEq, Eq)]
40
+ #[serde(rename_all = "camelCase")]
41
+ pub struct Inspection {
42
+ pub chains: Vec<Chain>,
43
+ pub docker: Docker,
44
+ pub legacy_tables: Legacy,
45
+ }
46
+
47
+ /// The families whose forward hook sees routed IPv4 or IPv6 packets.
48
+ fn family(value: u8) -> Option<&'static str> {
49
+ match value {
50
+ 1 => Some("inet"),
51
+ 2 => Some("ip"),
52
+ 10 => Some("ip6"),
53
+ _ => None,
54
+ }
55
+ }
56
+ const NF_INET_FORWARD: u32 = 2;
57
+
58
+ /// Classify one consistent table and chain dump.
59
+ pub(crate) fn classify(
60
+ tables: &[(u8, Vec<Attr>)],
61
+ chains: &[(u8, Vec<Attr>)],
62
+ ) -> Result<(Vec<Chain>, Docker)> {
63
+ let mut result = Vec::new();
64
+ let mut docker = Docker {
65
+ docker_user: false,
66
+ docker_forward: false,
67
+ };
68
+ for (number, attributes) in chains {
69
+ let table = wire::text(attributes, 1)?;
70
+ let name = wire::text(attributes, 3)?;
71
+ if *number == 2 && table == "filter" {
72
+ match name.as_str() {
73
+ "DOCKER-USER" => docker.docker_user = true,
74
+ "DOCKER-FORWARD" => docker.docker_forward = true,
75
+ _ => {}
76
+ }
77
+ }
78
+ let Some(family) = family(*number) else {
79
+ continue;
80
+ };
81
+ // Only base chains carry a hook.
82
+ if !attributes.iter().any(|attribute| attribute.id() == 4) {
83
+ continue;
84
+ }
85
+ let hook = wire::attrs(&wire::one(attributes, 4)?.value)?;
86
+ if wire::number(&hook, 1)? != NF_INET_FORWARD {
87
+ continue;
88
+ }
89
+ let priority = wire::number(&hook, 2)? as i32;
90
+ let policy = match wire::number(attributes, 5)? {
91
+ 0 => "drop",
92
+ 1 => "accept",
93
+ _ => return Err(Error::Protocol),
94
+ };
95
+ let handle = tables
96
+ .iter()
97
+ .filter(|(owner, table_attributes)| {
98
+ owner == number
99
+ && wire::text(table_attributes, 1).ok().as_deref() == Some(table.as_str())
100
+ })
101
+ .map(|(_, table_attributes)| wire::handle(table_attributes, 4))
102
+ .collect::<Result<Vec<_>>>()?;
103
+ let [handle] = handle.as_slice() else {
104
+ return Err(Error::Conflict);
105
+ };
106
+ result.push(Chain {
107
+ family,
108
+ table,
109
+ table_handle: handle.to_string(),
110
+ chain: name,
111
+ chain_type: wire::text(attributes, 7)?,
112
+ priority,
113
+ policy,
114
+ });
115
+ }
116
+ result.sort();
117
+ Ok((result, docker))
118
+ }
119
+
120
+ /// One registered table name per line; an absent file means none is registered.
121
+ fn legacy(path: &str) -> Result<Vec<String>> {
122
+ use std::io::Read;
123
+ let file = match std::fs::File::open(path) {
124
+ Ok(file) => file,
125
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
126
+ Err(_) => return Err(Error::Unavailable),
127
+ };
128
+ let mut text = String::new();
129
+ file.take(4097)
130
+ .read_to_string(&mut text)
131
+ .map_err(|_| Error::Protocol)?;
132
+ if text.len() > 4096 {
133
+ return Err(Error::Protocol);
134
+ }
135
+ let mut names = Vec::new();
136
+ for line in text.lines() {
137
+ if !crate::policy::identifier(line) {
138
+ return Err(Error::Protocol);
139
+ }
140
+ names.push(line.to_owned());
141
+ }
142
+ names.sort();
143
+ Ok(names)
144
+ }
145
+
146
+ pub fn inspect() -> Result<Inspection> {
147
+ let mut socket = Socket::open()?;
148
+ let generation = socket.generation()?;
149
+ let tables = socket.dump_all_families(1)?;
150
+ let chains = socket.dump_all_families(4)?;
151
+ // Both dumps describe one ruleset generation, or the view is inconclusive.
152
+ if socket.generation()? != generation {
153
+ return Err(Error::Conflict);
154
+ }
155
+ let (chains, docker) = classify(&tables, &chains)?;
156
+ Ok(Inspection {
157
+ chains,
158
+ docker,
159
+ legacy_tables: Legacy {
160
+ ipv4: legacy("/proc/self/net/ip_tables_names")?,
161
+ ipv6: legacy("/proc/self/net/ip6_tables_names")?,
162
+ },
163
+ })
164
+ }
165
+
166
+ pub fn dispatch(request: Request) -> Result<serde_json::Value> {
167
+ match request.method.as_str() {
168
+ "inspectForwardHooks" => {
169
+ if decode::<serde_json::Value>(request.params)? != serde_json::json!({}) {
170
+ return Err(Error::Invalid);
171
+ }
172
+ encode(inspect()?)
173
+ }
174
+ _ => Err(Error::Invalid),
175
+ }
176
+ }
@@ -0,0 +1,136 @@
1
+ use super::*;
2
+
3
+ fn table(family: u8, name: &str, handle: u64) -> (u8, Vec<Attr>) {
4
+ (family, vec![Attr::string(1, name), Attr::u64(4, handle)])
5
+ }
6
+ fn base(
7
+ family: u8,
8
+ table: &str,
9
+ name: &str,
10
+ hook: u32,
11
+ priority: i32,
12
+ policy: u32,
13
+ ) -> (u8, Vec<Attr>) {
14
+ let hook = Attr::nested(
15
+ 4,
16
+ vec![
17
+ Attr::bytes(1, hook.to_be_bytes().to_vec()),
18
+ Attr::bytes(2, priority.to_be_bytes().to_vec()),
19
+ ],
20
+ );
21
+ (
22
+ family,
23
+ vec![
24
+ Attr::string(1, table),
25
+ Attr::string(3, name),
26
+ hook,
27
+ Attr::bytes(5, policy.to_be_bytes().to_vec()),
28
+ Attr::string(7, "filter"),
29
+ ],
30
+ )
31
+ }
32
+ fn regular(family: u8, table: &str, name: &str) -> (u8, Vec<Attr>) {
33
+ (family, vec![Attr::string(1, table), Attr::string(3, name)])
34
+ }
35
+
36
+ #[test]
37
+ fn forward_hooks_report_every_routed_forward_base_chain_with_its_table() {
38
+ let tables = [
39
+ table(1, "snft_host", 7),
40
+ table(2, "filter", 3),
41
+ table(10, "filter", 4),
42
+ table(7, "filter", 5),
43
+ table(5, "edge", 6),
44
+ table(1, "other", 8),
45
+ ];
46
+ let chains = [
47
+ base(1, "snft_host", "forward", 2, 0, 1),
48
+ base(1, "snft_host", "input", 1, 0, 1),
49
+ base(2, "filter", "FORWARD", 2, 0, 0),
50
+ regular(2, "filter", "DOCKER-USER"),
51
+ regular(2, "filter", "DOCKER-FORWARD"),
52
+ base(10, "filter", "FORWARD", 2, 0, 1),
53
+ // Bridge and netdev hooks do not see routed packets.
54
+ base(7, "filter", "FORWARD", 2, -200, 0),
55
+ base(5, "edge", "ingress", 0, -500, 1),
56
+ base(1, "other", "early", 2, -150, 0),
57
+ ];
58
+ let (chains, docker) = classify(&tables, &chains).unwrap();
59
+ let summary: Vec<_> = chains
60
+ .iter()
61
+ .map(|c| {
62
+ (
63
+ c.family,
64
+ c.table.as_str(),
65
+ c.table_handle.as_str(),
66
+ c.chain.as_str(),
67
+ c.priority,
68
+ c.policy,
69
+ )
70
+ })
71
+ .collect();
72
+ assert_eq!(
73
+ summary,
74
+ vec![
75
+ ("inet", "other", "8", "early", -150, "drop"),
76
+ ("inet", "snft_host", "7", "forward", 0, "accept"),
77
+ ("ip", "filter", "3", "FORWARD", 0, "drop"),
78
+ ("ip6", "filter", "4", "FORWARD", 0, "accept"),
79
+ ]
80
+ );
81
+ assert_eq!(
82
+ docker,
83
+ Docker {
84
+ docker_user: true,
85
+ docker_forward: true
86
+ }
87
+ );
88
+ let json = serde_json::to_value(&chains[0]).unwrap();
89
+ assert_eq!(
90
+ json,
91
+ serde_json::json!({"family":"inet","table":"other","tableHandle":"8","chain":"early",
92
+ "type":"filter","priority":-150,"policy":"drop"})
93
+ );
94
+ }
95
+
96
+ #[test]
97
+ fn forward_hooks_report_absent_docker_chains_and_reject_inconsistent_dumps() {
98
+ let (chains, docker) = classify(
99
+ &[],
100
+ &[
101
+ regular(10, "filter", "DOCKER-USER"),
102
+ regular(2, "nat", "DOCKER-FORWARD"),
103
+ ],
104
+ )
105
+ .unwrap();
106
+ assert!(chains.is_empty());
107
+ assert_eq!(
108
+ docker,
109
+ Docker {
110
+ docker_user: false,
111
+ docker_forward: false
112
+ }
113
+ );
114
+ // A base chain whose table the table dump does not hold exactly once.
115
+ let chain = [base(2, "filter", "FORWARD", 2, 0, 0)];
116
+ assert_eq!(classify(&[], &chain), Err(Error::Conflict));
117
+ assert_eq!(
118
+ classify(&[table(2, "filter", 1), table(2, "filter", 2)], &chain),
119
+ Err(Error::Conflict)
120
+ );
121
+ assert_eq!(
122
+ classify(
123
+ &[table(2, "filter", 1)],
124
+ &[base(2, "filter", "FORWARD", 2, 0, 3)]
125
+ ),
126
+ Err(Error::Protocol)
127
+ );
128
+ }
129
+
130
+ #[test]
131
+ fn forward_hooks_read_absent_legacy_tables_as_none() {
132
+ assert_eq!(
133
+ legacy("/proc/self/net/smartnftables-absent-fixture").unwrap(),
134
+ Vec::<String>::new()
135
+ );
136
+ }
package/rust/src/main.rs CHANGED
@@ -1,5 +1,6 @@
1
1
  mod egress;
2
2
  mod docker;
3
+ mod forwardhooks;
3
4
  mod managed;
4
5
  mod namespace;
5
6
  mod owner;
@@ -172,14 +173,12 @@ fn failure(id: &str, error: Error) -> serde_json::Value {
172
173
 
173
174
  fn main() {
174
175
  let arguments = std::env::args().skip(1).collect::<Vec<_>>();
175
- let (docker_mode, descriptor) = match arguments.as_slice() {
176
- [management] if management == "--management" => (false, None),
177
- [management] if management == "--docker-forwarding" => (true, None),
178
- [management, option, fd]
179
- if (management == "--management" || management == "--docker-forwarding") && option == "--network-namespace-fd" =>
180
- {
176
+ const MODES: [&str; 3] = ["--management", "--docker-forwarding", "--forward-hooks"];
177
+ let (mode, descriptor) = match arguments.as_slice() {
178
+ [mode] if MODES.contains(&mode.as_str()) => (mode.clone(), None),
179
+ [mode, option, fd] if MODES.contains(&mode.as_str()) && option == "--network-namespace-fd" => {
181
180
  match fd.parse::<i32>() {
182
- Ok(value) => (management == "--docker-forwarding", Some(value)),
181
+ Ok(value) => (mode.clone(), Some(value)),
183
182
  Err(_) => std::process::exit(2),
184
183
  }
185
184
  }
@@ -217,7 +216,12 @@ fn main() {
217
216
  std::process::exit(2);
218
217
  }
219
218
  let id = request.id.clone();
220
- let response = match if docker_mode { docker::dispatch(&mut docker, request) } else { dispatch(&mut owner, request) } {
219
+ let result = match mode.as_str() {
220
+ "--docker-forwarding" => docker::dispatch(&mut docker, request),
221
+ "--forward-hooks" => forwardhooks::dispatch(request),
222
+ _ => dispatch(&mut owner, request),
223
+ };
224
+ let response = match result {
221
225
  Ok(result) => serde_json::json!({"id":id,"success":true,"result":result}),
222
226
  Err(error) => failure(&id, error),
223
227
  };