1#![allow(dead_code)]
44
45use serde::{Deserialize, Serialize};
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct NftCounterRow {
57 pub family: String,
59 pub table: String,
61 pub chain: String,
63 pub handle: u64,
65 pub packet_count: u64,
70 pub byte_count: u64,
72 pub rule_repr: String,
74}
75
76#[derive(Debug, thiserror::Error)]
78pub enum NftCountersError {
79 #[error("nft counter JSON parse failure: {0}")]
81 Json(#[from] serde_json::Error),
82 #[error("nft counter JSON missing top-level `nftables` array")]
84 MissingNftablesArray,
85}
86
87pub fn parse_nft_list_ruleset_json(raw: &str) -> Result<Vec<NftCounterRow>, NftCountersError> {
110 let value: serde_json::Value = serde_json::from_str(raw)?;
111 let arr = value
112 .get("nftables")
113 .and_then(|v| v.as_array())
114 .ok_or(NftCountersError::MissingNftablesArray)?;
115
116 let mut rows = Vec::new();
117 for item in arr {
118 let Some(rule) = item.get("rule").and_then(|v| v.as_object()) else {
119 continue;
120 };
121 let family = rule
122 .get("family")
123 .and_then(|v| v.as_str())
124 .unwrap_or_default()
125 .to_string();
126 let table = rule
127 .get("table")
128 .and_then(|v| v.as_str())
129 .unwrap_or_default()
130 .to_string();
131 let chain = rule
132 .get("chain")
133 .and_then(|v| v.as_str())
134 .unwrap_or_default()
135 .to_string();
136 let handle = rule.get("handle").and_then(|v| v.as_u64()).unwrap_or(0);
137 let expr = rule.get("expr").and_then(|v| v.as_array());
138
139 let mut packet_count: u64 = 0;
140 let mut byte_count: u64 = 0;
141 let mut repr_parts: Vec<String> = Vec::new();
142 if let Some(exprs) = expr {
143 for e in exprs {
144 emit_expr(e, &mut repr_parts, &mut packet_count, &mut byte_count);
145 }
146 }
147 let rule_repr = repr_parts.join(" ");
148 rows.push(NftCounterRow {
149 family,
150 table,
151 chain,
152 handle,
153 packet_count,
154 byte_count,
155 rule_repr,
156 });
157 }
158 Ok(rows)
159}
160
161fn emit_expr(
165 expr: &serde_json::Value,
166 repr_parts: &mut Vec<String>,
167 packet_count: &mut u64,
168 byte_count: &mut u64,
169) {
170 let Some(obj) = expr.as_object() else {
171 return;
172 };
173 for (kind, body) in obj {
174 match kind.as_str() {
175 "counter" => {
176 if let Some(c) = body.as_object() {
177 if let Some(p) = c.get("packets").and_then(|v| v.as_u64()) {
178 *packet_count = (*packet_count).max(p);
183 }
184 if let Some(b) = c.get("bytes").and_then(|v| v.as_u64()) {
185 *byte_count = (*byte_count).max(b);
186 }
187 }
188 }
189 "match" => {
190 if let Some(m) = body.as_object() {
191 let left = m.get("left");
192 let right = m.get("right");
193 let op = m.get("op").and_then(|v| v.as_str()).unwrap_or("==");
194 if op == "==" {
195 if let Some(rendered) = render_match(left, right) {
196 repr_parts.push(rendered);
197 }
198 }
199 }
200 }
201 "accept" => repr_parts.push("accept".to_string()),
202 "drop" => repr_parts.push("drop".to_string()),
203 "policy" => {
204 if let Some(s) = body.as_str() {
205 repr_parts.push(format!("policy {s}"));
206 }
207 }
208 _ => {}
210 }
211 }
212}
213
214fn render_match(
218 left: Option<&serde_json::Value>,
219 right: Option<&serde_json::Value>,
220) -> Option<String> {
221 let left = left?.as_object()?;
222 let payload = left.get("payload")?.as_object()?;
223 let proto = payload.get("protocol").and_then(|v| v.as_str())?;
224 let field = payload.get("field").and_then(|v| v.as_str())?;
225 let right_value = right?;
226 let rendered_right = match right_value {
227 serde_json::Value::String(s) => s.clone(),
228 serde_json::Value::Number(n) => n.to_string(),
229 _ => return None,
230 };
231 match (proto, field) {
232 ("ip", "daddr") => Some(format!("ip daddr {rendered_right}")),
233 ("ip6", "daddr") => Some(format!("ip6 daddr {rendered_right}")),
234 ("udp", "dport") => Some(format!("udp dport {rendered_right}")),
235 ("tcp", "dport") => Some(format!("tcp dport {rendered_right}")),
236 _ => None,
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct ClassifiedRule {
243 pub decision: NftDecision,
245 pub reason_code: &'static str,
248 pub nft_rule_ref: Option<String>,
250 pub dst_addr: Option<String>,
252 pub dst_port: Option<u16>,
254 pub protocol: Option<String>,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub enum NftDecision {
263 Allow,
264 Deny,
265}
266
267pub fn classify_rule_repr(rule_repr: &str) -> ClassifiedRule {
312 let trimmed = rule_repr.trim();
313
314 if trimmed == "policy drop" {
316 return ClassifiedRule {
317 decision: NftDecision::Deny,
318 reason_code: "nft_default_drop",
319 nft_rule_ref: Some("default-drop".to_string()),
320 dst_addr: None,
321 dst_port: None,
322 protocol: None,
323 };
324 }
325
326 let has_accept = trimmed.split_whitespace().any(|t| t == "accept");
327 let has_drop = trimmed.split_whitespace().any(|t| t == "drop");
328
329 let dst_addr = extract_daddr(trimmed);
331 let (proto, dport) = extract_dport(trimmed);
332
333 if has_drop && dst_addr.is_none() && dport == Some(53) {
334 let proto_string = proto.map(|p| p.to_string());
335 return ClassifiedRule {
336 decision: NftDecision::Deny,
337 reason_code: "nft_workload_dns_block",
338 nft_rule_ref: Some("dnsAuthority.workloadDnsBlock".to_string()),
339 dst_addr: None,
340 dst_port: Some(53),
341 protocol: proto_string,
342 };
343 }
344
345 if has_drop && dst_addr.is_none() && dport == Some(853) && proto == Some("udp") {
347 return ClassifiedRule {
348 decision: NftDecision::Deny,
349 reason_code: "nft_doq_blocked",
350 nft_rule_ref: Some("dnsAuthority.blockUdpDoq".to_string()),
351 dst_addr: None,
352 dst_port: Some(853),
353 protocol: Some("udp".to_string()),
354 };
355 }
356
357 if has_drop && dst_addr.is_none() && dport == Some(443) && proto == Some("udp") {
359 return ClassifiedRule {
360 decision: NftDecision::Deny,
361 reason_code: "nft_http3_blocked",
362 nft_rule_ref: Some("dnsAuthority.blockUdpHttp3".to_string()),
363 dst_addr: None,
364 dst_port: Some(443),
365 protocol: Some("udp".to_string()),
366 };
367 }
368
369 if has_accept {
370 let is_phase3d_udp_resolver_port =
376 proto == Some("udp") && (dport == Some(853) || dport == Some(443));
377 if dport == Some(53) || is_phase3d_udp_resolver_port {
378 let proto_string = proto.map(|p| p.to_string());
379 return ClassifiedRule {
380 decision: NftDecision::Allow,
381 reason_code: "nft_resolver_allowlist_match",
382 nft_rule_ref: None,
383 dst_addr,
384 dst_port: dport,
385 protocol: proto_string,
386 };
387 }
388 let proto_string = proto.map(|p| p.to_string());
389 return ClassifiedRule {
390 decision: NftDecision::Allow,
391 reason_code: "nft_egress_rule_match",
392 nft_rule_ref: None,
393 dst_addr,
394 dst_port: dport,
395 protocol: proto_string,
396 };
397 }
398
399 if has_drop {
400 let proto_string = proto.map(|p| p.to_string());
401 return ClassifiedRule {
402 decision: NftDecision::Deny,
403 reason_code: "nft_default_drop",
404 nft_rule_ref: None,
405 dst_addr,
406 dst_port: dport,
407 protocol: proto_string,
408 };
409 }
410
411 ClassifiedRule {
415 decision: NftDecision::Deny,
416 reason_code: "nft_default_drop",
417 nft_rule_ref: None,
418 dst_addr: None,
419 dst_port: None,
420 protocol: None,
421 }
422}
423
424fn extract_daddr(repr: &str) -> Option<String> {
428 let tokens: Vec<&str> = repr.split_whitespace().collect();
429 for win in tokens.windows(3) {
430 if (win[0] == "ip" || win[0] == "ip6") && win[1] == "daddr" {
431 return Some(win[2].to_string());
432 }
433 }
434 None
435}
436
437fn extract_dport(repr: &str) -> (Option<&'static str>, Option<u16>) {
440 let tokens: Vec<&str> = repr.split_whitespace().collect();
441 for win in tokens.windows(3) {
442 let proto = match win[0] {
443 "udp" => Some("udp"),
444 "tcp" => Some("tcp"),
445 _ => None,
446 };
447 if proto.is_some() && win[1] == "dport" {
448 if let Ok(p) = win[2].parse::<u16>() {
449 return (proto, Some(p));
450 }
451 }
452 }
453 (None, None)
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 #[test]
461 fn classify_recognizes_default_drop_policy() {
462 let c = classify_rule_repr("policy drop");
463 assert_eq!(c.decision, NftDecision::Deny);
464 assert_eq!(c.reason_code, "nft_default_drop");
465 assert_eq!(c.nft_rule_ref.as_deref(), Some("default-drop"));
466 assert!(c.dst_addr.is_none());
467 assert!(c.dst_port.is_none());
468 assert!(c.protocol.is_none());
469 }
470
471 #[test]
472 fn classify_recognizes_workload_dns_block_udp() {
473 let c = classify_rule_repr("udp dport 53 drop");
474 assert_eq!(c.decision, NftDecision::Deny);
475 assert_eq!(c.reason_code, "nft_workload_dns_block");
476 assert_eq!(
477 c.nft_rule_ref.as_deref(),
478 Some("dnsAuthority.workloadDnsBlock")
479 );
480 assert_eq!(c.dst_port, Some(53));
481 assert_eq!(c.protocol.as_deref(), Some("udp"));
482 assert!(c.dst_addr.is_none());
483 }
484
485 #[test]
486 fn classify_recognizes_workload_dns_block_tcp() {
487 let c = classify_rule_repr("tcp dport 53 drop");
488 assert_eq!(c.decision, NftDecision::Deny);
489 assert_eq!(c.reason_code, "nft_workload_dns_block");
490 assert_eq!(c.dst_port, Some(53));
491 assert_eq!(c.protocol.as_deref(), Some("tcp"));
492 }
493
494 #[test]
495 fn classify_recognizes_resolver_allowlist_udp() {
496 let c = classify_rule_repr("ip daddr 1.1.1.1 udp dport 53 accept");
497 assert_eq!(c.decision, NftDecision::Allow);
498 assert_eq!(c.reason_code, "nft_resolver_allowlist_match");
499 assert_eq!(c.dst_addr.as_deref(), Some("1.1.1.1"));
500 assert_eq!(c.dst_port, Some(53));
501 assert_eq!(c.protocol.as_deref(), Some("udp"));
502 }
503
504 #[test]
505 fn classify_recognizes_resolver_allowlist_tcp() {
506 let c = classify_rule_repr("ip daddr 1.1.1.1 tcp dport 53 accept");
507 assert_eq!(c.decision, NftDecision::Allow);
508 assert_eq!(c.reason_code, "nft_resolver_allowlist_match");
509 assert_eq!(c.dst_port, Some(53));
510 assert_eq!(c.protocol.as_deref(), Some("tcp"));
511 }
512
513 #[test]
514 fn classify_recognizes_egress_rule_accept_v4() {
515 let c = classify_rule_repr("ip daddr 10.0.0.1 tcp dport 443 accept");
516 assert_eq!(c.decision, NftDecision::Allow);
517 assert_eq!(c.reason_code, "nft_egress_rule_match");
518 assert_eq!(c.dst_addr.as_deref(), Some("10.0.0.1"));
519 assert_eq!(c.dst_port, Some(443));
520 assert_eq!(c.protocol.as_deref(), Some("tcp"));
521 }
522
523 #[test]
524 fn classify_recognizes_egress_rule_accept_v6() {
525 let c = classify_rule_repr("ip6 daddr 2001:db8::1 tcp dport 443 accept");
526 assert_eq!(c.decision, NftDecision::Allow);
527 assert_eq!(c.reason_code, "nft_egress_rule_match");
528 assert_eq!(c.dst_addr.as_deref(), Some("2001:db8::1"));
529 assert_eq!(c.dst_port, Some(443));
530 assert_eq!(c.protocol.as_deref(), Some("tcp"));
531 }
532
533 #[test]
540 fn classify_recognises_nft_doq_blocked() {
541 let c = classify_rule_repr("udp dport 853 drop");
542 assert_eq!(c.decision, NftDecision::Deny);
543 assert_eq!(c.reason_code, "nft_doq_blocked");
544 assert_eq!(c.nft_rule_ref.as_deref(), Some("dnsAuthority.blockUdpDoq"));
545 assert_eq!(c.dst_port, Some(853));
546 assert_eq!(c.protocol.as_deref(), Some("udp"));
547 assert!(c.dst_addr.is_none());
548 }
549
550 #[test]
551 fn classify_recognises_nft_http3_blocked() {
552 let c = classify_rule_repr("udp dport 443 drop");
553 assert_eq!(c.decision, NftDecision::Deny);
554 assert_eq!(c.reason_code, "nft_http3_blocked");
555 assert_eq!(
556 c.nft_rule_ref.as_deref(),
557 Some("dnsAuthority.blockUdpHttp3")
558 );
559 assert_eq!(c.dst_port, Some(443));
560 assert_eq!(c.protocol.as_deref(), Some("udp"));
561 assert!(c.dst_addr.is_none());
562 }
563
564 #[test]
565 fn classify_recognises_resolver_allowlist_match_for_udp_443() {
566 let c = classify_rule_repr("ip daddr 1.1.1.1 udp dport 443 accept");
567 assert_eq!(c.decision, NftDecision::Allow);
568 assert_eq!(c.reason_code, "nft_resolver_allowlist_match");
569 assert_eq!(c.dst_addr.as_deref(), Some("1.1.1.1"));
570 assert_eq!(c.dst_port, Some(443));
571 assert_eq!(c.protocol.as_deref(), Some("udp"));
572 }
573
574 #[test]
575 fn classify_recognises_resolver_allowlist_match_for_udp_853() {
576 let c = classify_rule_repr("ip daddr 1.1.1.1 udp dport 853 accept");
577 assert_eq!(c.decision, NftDecision::Allow);
578 assert_eq!(c.reason_code, "nft_resolver_allowlist_match");
579 assert_eq!(c.dst_addr.as_deref(), Some("1.1.1.1"));
580 assert_eq!(c.dst_port, Some(853));
581 assert_eq!(c.protocol.as_deref(), Some("udp"));
582 }
583
584 #[test]
585 fn classify_does_not_misroute_tcp_443_to_http3_blocked() {
586 let c = classify_rule_repr("tcp dport 443 drop");
590 assert_eq!(c.decision, NftDecision::Deny);
591 assert_eq!(c.reason_code, "nft_default_drop");
592 }
593
594 #[test]
595 fn classify_egress_rule_match_still_recognised_for_non_resolver_udp_dport() {
596 let c = classify_rule_repr("ip daddr 10.0.0.7 udp dport 4242 accept");
600 assert_eq!(c.decision, NftDecision::Allow);
601 assert_eq!(c.reason_code, "nft_egress_rule_match");
602 assert_eq!(c.dst_port, Some(4242));
603 }
604
605 #[test]
606 fn classify_handles_malformed_input_gracefully() {
607 let c = classify_rule_repr("");
608 assert_eq!(c.decision, NftDecision::Deny);
609 assert_eq!(c.reason_code, "nft_default_drop");
610 assert!(c.dst_addr.is_none());
611 assert!(c.dst_port.is_none());
612 }
613
614 #[test]
615 fn classify_unrecognized_drop_falls_back_to_default_drop() {
616 let c = classify_rule_repr("ct state invalid drop");
617 assert_eq!(c.decision, NftDecision::Deny);
618 assert_eq!(c.reason_code, "nft_default_drop");
619 }
620
621 #[test]
622 fn extract_dport_ignores_wrong_proto() {
623 let (p, port) = extract_dport("icmp echo-request accept");
624 assert!(p.is_none());
625 assert!(port.is_none());
626 }
627
628 #[test]
629 fn parse_extracts_one_egress_accept_row() {
630 let json = r#"{
633 "nftables": [
634 {"metainfo": {"version": "1.0.6"}},
635 {"table": {"family": "inet", "name": "cellos_test"}},
636 {"chain": {"family": "inet", "table": "cellos_test", "name": "output"}},
637 {"rule": {
638 "family": "inet",
639 "table": "cellos_test",
640 "chain": "output",
641 "handle": 7,
642 "expr": [
643 {"match": {
644 "left": {"payload": {"protocol": "ip", "field": "daddr"}},
645 "right": "10.0.0.1",
646 "op": "=="
647 }},
648 {"match": {
649 "left": {"payload": {"protocol": "tcp", "field": "dport"}},
650 "right": 443,
651 "op": "=="
652 }},
653 {"counter": {"packets": 5, "bytes": 320}},
654 {"accept": null}
655 ]
656 }}
657 ]
658 }"#;
659 let rows = parse_nft_list_ruleset_json(json).expect("parse ok");
660 assert_eq!(rows.len(), 1);
661 let row = &rows[0];
662 assert_eq!(row.family, "inet");
663 assert_eq!(row.table, "cellos_test");
664 assert_eq!(row.chain, "output");
665 assert_eq!(row.handle, 7);
666 assert_eq!(row.packet_count, 5);
667 assert_eq!(row.byte_count, 320);
668 assert_eq!(row.rule_repr, "ip daddr 10.0.0.1 tcp dport 443 accept");
669
670 let classified = classify_rule_repr(&row.rule_repr);
671 assert_eq!(classified.decision, NftDecision::Allow);
672 assert_eq!(classified.reason_code, "nft_egress_rule_match");
673 assert_eq!(classified.dst_addr.as_deref(), Some("10.0.0.1"));
674 assert_eq!(classified.dst_port, Some(443));
675 }
676
677 #[test]
678 fn parse_extracts_workload_dns_block_drop_row() {
679 let json = r#"{
680 "nftables": [
681 {"rule": {
682 "family": "inet",
683 "table": "cellos_test",
684 "chain": "output",
685 "handle": 4,
686 "expr": [
687 {"match": {
688 "left": {"payload": {"protocol": "udp", "field": "dport"}},
689 "right": 53,
690 "op": "=="
691 }},
692 {"counter": {"packets": 2, "bytes": 80}},
693 {"drop": null}
694 ]
695 }}
696 ]
697 }"#;
698 let rows = parse_nft_list_ruleset_json(json).expect("parse ok");
699 assert_eq!(rows.len(), 1);
700 assert_eq!(rows[0].packet_count, 2);
701 assert_eq!(rows[0].rule_repr, "udp dport 53 drop");
702 let c = classify_rule_repr(&rows[0].rule_repr);
703 assert_eq!(c.reason_code, "nft_workload_dns_block");
704 assert_eq!(c.decision, NftDecision::Deny);
705 }
706
707 #[test]
708 fn parse_returns_empty_for_empty_nftables() {
709 let json = r#"{"nftables": []}"#;
710 let rows = parse_nft_list_ruleset_json(json).expect("parse ok");
711 assert!(rows.is_empty());
712 }
713
714 #[test]
715 fn parse_errors_when_nftables_array_missing() {
716 let json = r#"{"not_nftables": []}"#;
717 let err = parse_nft_list_ruleset_json(json).expect_err("must error");
718 assert!(matches!(err, NftCountersError::MissingNftablesArray));
719 }
720
721 #[test]
722 fn parse_errors_on_malformed_json() {
723 let err = parse_nft_list_ruleset_json("not json").expect_err("must error");
724 assert!(matches!(err, NftCountersError::Json(_)));
725 }
726}