1#[cfg(any(test, target_os = "linux"))]
8use serde_json::Value;
9
10use crate::control::listening::Proto;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum FilterState {
14 NoFirewall,
15 Accept,
16 Drop,
17 Unknown,
18}
19
20impl FilterState {
21 pub fn as_str(self) -> &'static str {
22 match self {
23 FilterState::NoFirewall => "no_firewall",
24 FilterState::Accept => "accept",
25 FilterState::Drop => "drop",
26 FilterState::Unknown => "unknown",
27 }
28 }
29}
30
31pub struct FilterClassifier {
32 #[cfg(any(test, target_os = "linux"))]
33 rules: Option<Vec<Rule>>,
34}
35
36#[cfg(any(test, target_os = "linux"))]
37#[derive(Debug, Clone)]
38struct Rule {
39 matches: Vec<MatchExpr>,
40 verdict: Verdict,
41}
42
43#[cfg(any(test, target_os = "linux"))]
44#[derive(Debug, Clone)]
45enum MatchExpr {
46 Iifname,
47 L4Proto(Proto),
48 Dport(Proto, PortMatch),
49 Unrecognized,
50}
51
52#[cfg(any(test, target_os = "linux"))]
53#[derive(Debug, Clone)]
54enum PortMatch {
55 Single(u16),
56 Set(Vec<u16>),
57 Range(u16, u16),
58}
59
60#[cfg(any(test, target_os = "linux"))]
61impl PortMatch {
62 fn matches(&self, port: u16) -> bool {
63 match self {
64 PortMatch::Single(p) => *p == port,
65 PortMatch::Set(ports) => ports.contains(&port),
66 PortMatch::Range(lo, hi) => *lo <= port && port <= *hi,
67 }
68 }
69}
70
71#[cfg(any(test, target_os = "linux"))]
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73enum Verdict {
74 Accept,
75 Drop,
76 Other,
77}
78
79impl FilterClassifier {
80 pub fn no_firewall() -> Self {
81 Self {
82 #[cfg(any(test, target_os = "linux"))]
83 rules: None,
84 }
85 }
86
87 #[cfg(target_os = "linux")]
88 pub fn query() -> Self {
89 let Some(json) = run_nft_list() else {
90 return Self::no_firewall();
91 };
92 Self {
93 rules: Some(parse_inbound_rules(&json)),
94 }
95 }
96
97 #[cfg(not(target_os = "linux"))]
98 pub fn query() -> Self {
99 Self::no_firewall()
100 }
101
102 pub fn is_active(&self) -> bool {
103 #[cfg(any(test, target_os = "linux"))]
104 {
105 self.rules.is_some()
106 }
107 #[cfg(not(any(test, target_os = "linux")))]
108 {
109 false
110 }
111 }
112
113 pub fn classify(&self, proto: Proto, port: u16) -> FilterState {
114 #[cfg(not(any(test, target_os = "linux")))]
115 {
116 let _ = (proto, port);
117 FilterState::NoFirewall
118 }
119
120 #[cfg(any(test, target_os = "linux"))]
121 {
122 let Some(rules) = &self.rules else {
123 return FilterState::NoFirewall;
124 };
125
126 let mut saw_unknown_for_port = false;
127
128 for rule in rules {
129 let mut references_port = false;
130 let mut canonical_for_port = true;
131 let mut has_proto_match = None;
132
133 for matcher in &rule.matches {
134 match matcher {
135 MatchExpr::Iifname => {}
136 MatchExpr::L4Proto(p) => {
137 has_proto_match = Some(*p);
138 if *p != proto {
139 canonical_for_port = false;
140 }
141 }
142 MatchExpr::Dport(p, port_match) => {
143 if *p == proto && port_match.matches(port) {
144 references_port = true;
145 } else if !port_match.matches(port) {
146 canonical_for_port = false;
147 }
148 }
149 MatchExpr::Unrecognized => {
150 if rule_might_reference_port(rule, proto, port) {
151 saw_unknown_for_port = true;
152 }
153 canonical_for_port = false;
154 }
155 }
156 }
157
158 if !references_port {
159 continue;
160 }
161 if !canonical_for_port {
162 saw_unknown_for_port = true;
163 continue;
164 }
165 if let Some(p) = has_proto_match
166 && p != proto
167 {
168 continue;
169 }
170
171 match rule.verdict {
172 Verdict::Accept => return FilterState::Accept,
173 Verdict::Drop => return FilterState::Drop,
174 Verdict::Other => saw_unknown_for_port = true,
175 }
176 }
177
178 if saw_unknown_for_port {
179 FilterState::Unknown
180 } else {
181 FilterState::Drop
182 }
183 }
184 }
185}
186
187#[cfg(any(test, target_os = "linux"))]
188fn rule_might_reference_port(rule: &Rule, proto: Proto, port: u16) -> bool {
189 rule.matches.iter().any(|matcher| match matcher {
190 MatchExpr::Dport(p, port_match) => *p == proto && port_match.matches(port),
191 _ => false,
192 })
193}
194
195#[cfg(target_os = "linux")]
196fn run_nft_list() -> Option<Value> {
197 use std::process::Command;
198
199 let output = Command::new("nft")
200 .args(["-j", "list", "table", "inet", "fips"])
201 .output()
202 .ok()?;
203
204 if !output.status.success() {
205 return None;
206 }
207
208 serde_json::from_slice::<Value>(&output.stdout).ok()
209}
210
211#[cfg(any(test, target_os = "linux"))]
212fn parse_inbound_rules(json: &Value) -> Vec<Rule> {
213 let Some(entries) = json.get("nftables").and_then(|v| v.as_array()) else {
214 return Vec::new();
215 };
216
217 entries
218 .iter()
219 .filter_map(|entry| entry.get("rule"))
220 .filter(|rule| {
221 rule.get("table").and_then(|v| v.as_str()) == Some("fips")
222 && rule.get("chain").and_then(|v| v.as_str()) == Some("inbound")
223 })
224 .map(parse_rule)
225 .collect()
226}
227
228#[cfg(any(test, target_os = "linux"))]
229fn parse_rule(rule: &Value) -> Rule {
230 let exprs = rule
231 .get("expr")
232 .and_then(|v| v.as_array())
233 .cloned()
234 .unwrap_or_default();
235
236 let mut matches = Vec::new();
237 let mut verdict = Verdict::Other;
238
239 for expr in &exprs {
240 if let Some(matcher) = expr.get("match") {
241 matches.push(parse_match(matcher));
242 } else if expr.get("accept").is_some() {
243 verdict = Verdict::Accept;
244 } else if expr.get("drop").is_some() {
245 verdict = Verdict::Drop;
246 } else if expr.get("return").is_some()
247 || expr.get("jump").is_some()
248 || expr.get("goto").is_some()
249 || expr.get("continue").is_some()
250 || expr.get("reject").is_some()
251 || expr.get("queue").is_some()
252 {
253 verdict = Verdict::Other;
254 }
255 }
256
257 Rule { matches, verdict }
258}
259
260#[cfg(any(test, target_os = "linux"))]
261fn parse_match(matcher: &Value) -> MatchExpr {
262 let op = matcher
263 .get("op")
264 .and_then(|value| value.as_str())
265 .unwrap_or("==");
266 let left = matcher.get("left").cloned().unwrap_or(Value::Null);
267 let right = matcher.get("right").cloned().unwrap_or(Value::Null);
268
269 if let Some(meta) = left.get("meta")
270 && meta.get("key").and_then(|v| v.as_str()) == Some("iifname")
271 && right.as_str().is_some()
272 {
273 let _ = op;
274 return MatchExpr::Iifname;
275 }
276
277 if let Some(meta) = left.get("meta")
278 && meta.get("key").and_then(|v| v.as_str()) == Some("l4proto")
279 && let Some(proto_str) = right.as_str()
280 && let Some(proto) = parse_proto(proto_str)
281 && op == "=="
282 {
283 return MatchExpr::L4Proto(proto);
284 }
285
286 if let Some(payload) = left.get("payload")
287 && payload.get("field").and_then(|v| v.as_str()) == Some("dport")
288 && let Some(proto_str) = payload.get("protocol").and_then(|v| v.as_str())
289 && let Some(proto) = parse_proto(proto_str)
290 && op == "=="
291 {
292 if let Some(port) = right.as_u64() {
293 return MatchExpr::Dport(proto, PortMatch::Single(port as u16));
294 }
295 if let Some(set) = right.get("set").and_then(|v| v.as_array()) {
296 let ports: Vec<u16> = set
297 .iter()
298 .filter_map(|v| v.as_u64().map(|port| port as u16))
299 .collect();
300 if ports.len() == set.len() {
301 return MatchExpr::Dport(proto, PortMatch::Set(ports));
302 }
303 }
304 if let Some(range) = right.get("range").and_then(|v| v.as_array())
305 && range.len() == 2
306 && let (Some(lo), Some(hi)) = (range[0].as_u64(), range[1].as_u64())
307 {
308 return MatchExpr::Dport(proto, PortMatch::Range(lo as u16, hi as u16));
309 }
310 }
311
312 MatchExpr::Unrecognized
313}
314
315#[cfg(any(test, target_os = "linux"))]
316fn parse_proto(value: &str) -> Option<Proto> {
317 match value {
318 "tcp" => Some(Proto::Tcp),
319 "udp" => Some(Proto::Udp),
320 _ => None,
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use serde_json::json;
328
329 fn make_classifier(rules_json: Value) -> FilterClassifier {
330 let nft_json = json!({
331 "nftables": rules_json
332 .as_array()
333 .unwrap()
334 .iter()
335 .map(|rule| json!({"rule": {
336 "family": "inet",
337 "table": "fips",
338 "chain": "inbound",
339 "expr": rule,
340 }}))
341 .collect::<Vec<_>>(),
342 });
343 FilterClassifier {
344 rules: Some(parse_inbound_rules(&nft_json)),
345 }
346 }
347
348 #[test]
349 fn no_firewall_means_no_firewall() {
350 let classifier = FilterClassifier::no_firewall();
351 assert_eq!(classifier.classify(Proto::Tcp, 22), FilterState::NoFirewall);
352 assert_eq!(
353 classifier.classify(Proto::Udp, 5353),
354 FilterState::NoFirewall
355 );
356 }
357
358 #[test]
359 fn empty_chain_drops_everything() {
360 let classifier = make_classifier(json!([]));
361 assert_eq!(classifier.classify(Proto::Tcp, 22), FilterState::Drop);
362 assert_eq!(classifier.classify(Proto::Udp, 5353), FilterState::Drop);
363 }
364
365 #[test]
366 fn canonical_tcp_dport_accept() {
367 let classifier = make_classifier(json!([
368 [
369 {"match": {
370 "op": "==",
371 "left": {"payload": {"protocol": "tcp", "field": "dport"}},
372 "right": 22
373 }},
374 {"accept": null}
375 ]
376 ]));
377 assert_eq!(classifier.classify(Proto::Tcp, 22), FilterState::Accept);
378 assert_eq!(classifier.classify(Proto::Tcp, 80), FilterState::Drop);
379 assert_eq!(classifier.classify(Proto::Udp, 22), FilterState::Drop);
380 }
381
382 #[test]
383 fn canonical_udp_dport_accept() {
384 let classifier = make_classifier(json!([
385 [
386 {"match": {
387 "op": "==",
388 "left": {"payload": {"protocol": "udp", "field": "dport"}},
389 "right": 5353
390 }},
391 {"accept": null}
392 ]
393 ]));
394 assert_eq!(classifier.classify(Proto::Udp, 5353), FilterState::Accept);
395 assert_eq!(classifier.classify(Proto::Tcp, 5353), FilterState::Drop);
396 }
397
398 #[test]
399 fn dport_set_accept() {
400 let classifier = make_classifier(json!([
401 [
402 {"match": {
403 "op": "==",
404 "left": {"payload": {"protocol": "tcp", "field": "dport"}},
405 "right": {"set": [22, 80, 443]}
406 }},
407 {"accept": null}
408 ]
409 ]));
410 assert_eq!(classifier.classify(Proto::Tcp, 22), FilterState::Accept);
411 assert_eq!(classifier.classify(Proto::Tcp, 80), FilterState::Accept);
412 assert_eq!(classifier.classify(Proto::Tcp, 443), FilterState::Accept);
413 assert_eq!(classifier.classify(Proto::Tcp, 25), FilterState::Drop);
414 }
415
416 #[test]
417 fn dport_range_accept() {
418 let classifier = make_classifier(json!([
419 [
420 {"match": {
421 "op": "==",
422 "left": {"payload": {"protocol": "tcp", "field": "dport"}},
423 "right": {"range": [22, 25]}
424 }},
425 {"accept": null}
426 ]
427 ]));
428 assert_eq!(classifier.classify(Proto::Tcp, 22), FilterState::Accept);
429 assert_eq!(classifier.classify(Proto::Tcp, 25), FilterState::Accept);
430 assert_eq!(classifier.classify(Proto::Tcp, 26), FilterState::Drop);
431 }
432
433 #[test]
434 fn saddr_restricted_is_unknown() {
435 let classifier = make_classifier(json!([
436 [
437 {"match": {
438 "op": "==",
439 "left": {"payload": {"protocol": "ip6", "field": "saddr"}},
440 "right": {"prefix": {"addr": "fd97::", "len": 64}}
441 }},
442 {"match": {
443 "op": "==",
444 "left": {"payload": {"protocol": "tcp", "field": "dport"}},
445 "right": 22
446 }},
447 {"accept": null}
448 ]
449 ]));
450 assert_eq!(classifier.classify(Proto::Tcp, 22), FilterState::Unknown);
451 assert_eq!(classifier.classify(Proto::Tcp, 80), FilterState::Drop);
452 }
453
454 #[test]
455 fn jump_verdict_is_unknown() {
456 let classifier = make_classifier(json!([
457 [
458 {"match": {
459 "op": "==",
460 "left": {"payload": {"protocol": "tcp", "field": "dport"}},
461 "right": 22
462 }},
463 {"jump": {"target": "some_chain"}}
464 ]
465 ]));
466 assert_eq!(classifier.classify(Proto::Tcp, 22), FilterState::Unknown);
467 }
468
469 #[test]
470 fn explicit_drop_classifies_as_drop() {
471 let classifier = make_classifier(json!([
472 [
473 {"match": {
474 "op": "==",
475 "left": {"payload": {"protocol": "tcp", "field": "dport"}},
476 "right": 22
477 }},
478 {"drop": null}
479 ]
480 ]));
481 assert_eq!(classifier.classify(Proto::Tcp, 22), FilterState::Drop);
482 }
483
484 #[test]
485 fn unrelated_rules_do_not_affect_port() {
486 let classifier = make_classifier(json!([
487 [
488 {"match": {
489 "op": "!=",
490 "left": {"meta": {"key": "iifname"}},
491 "right": "fips0"
492 }},
493 {"return": null}
494 ],
495 [
496 {"match": {
497 "op": "in",
498 "left": {"ct": {"key": "state"}},
499 "right": ["established", "related"]
500 }},
501 {"accept": null}
502 ],
503 [
504 {"match": {
505 "op": "==",
506 "left": {"payload": {"protocol": "icmpv6", "field": "type"}},
507 "right": "echo-request"
508 }},
509 {"accept": null}
510 ],
511 ]));
512 assert_eq!(classifier.classify(Proto::Tcp, 22), FilterState::Drop);
513 assert_eq!(classifier.classify(Proto::Udp, 5353), FilterState::Drop);
514 }
515
516 #[test]
517 fn l4proto_then_dport_accept() {
518 let classifier = make_classifier(json!([
519 [
520 {"match": {
521 "op": "==",
522 "left": {"meta": {"key": "l4proto"}},
523 "right": "tcp"
524 }},
525 {"match": {
526 "op": "==",
527 "left": {"payload": {"protocol": "tcp", "field": "dport"}},
528 "right": 22
529 }},
530 {"accept": null}
531 ]
532 ]));
533 assert_eq!(classifier.classify(Proto::Tcp, 22), FilterState::Accept);
534 assert_eq!(classifier.classify(Proto::Udp, 22), FilterState::Drop);
535 }
536
537 #[test]
538 fn first_accept_match_wins() {
539 let classifier = make_classifier(json!([
540 [
541 {"match": {
542 "op": "==",
543 "left": {"payload": {"protocol": "tcp", "field": "dport"}},
544 "right": 22
545 }},
546 {"accept": null}
547 ],
548 [
549 {"match": {
550 "op": "==",
551 "left": {"payload": {"protocol": "ip6", "field": "saddr"}},
552 "right": "fd00::1"
553 }},
554 {"match": {
555 "op": "==",
556 "left": {"payload": {"protocol": "tcp", "field": "dport"}},
557 "right": 22
558 }},
559 {"drop": null}
560 ]
561 ]));
562 assert_eq!(classifier.classify(Proto::Tcp, 22), FilterState::Accept);
563 }
564}