Skip to main content

fips_core/control/
firewall_state.rs

1//! Read-side classifier for the `inet fips` baseline filter.
2//!
3//! Used by the fipstop listener panel to classify whether a listening
4//! `(proto, port)` pair is accepted, filtered, unknown, or unprotected because
5//! the baseline firewall table is absent.
6
7#[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}