#![allow(dead_code)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NftCounterRow {
pub family: String,
pub table: String,
pub chain: String,
pub handle: u64,
pub packet_count: u64,
pub byte_count: u64,
pub rule_repr: String,
}
#[derive(Debug, thiserror::Error)]
pub enum NftCountersError {
#[error("nft counter JSON parse failure: {0}")]
Json(#[from] serde_json::Error),
#[error("nft counter JSON missing top-level `nftables` array")]
MissingNftablesArray,
}
pub fn parse_nft_list_ruleset_json(raw: &str) -> Result<Vec<NftCounterRow>, NftCountersError> {
let value: serde_json::Value = serde_json::from_str(raw)?;
let arr = value
.get("nftables")
.and_then(|v| v.as_array())
.ok_or(NftCountersError::MissingNftablesArray)?;
let mut rows = Vec::new();
for item in arr {
let Some(rule) = item.get("rule").and_then(|v| v.as_object()) else {
continue;
};
let family = rule
.get("family")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let table = rule
.get("table")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let chain = rule
.get("chain")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let handle = rule.get("handle").and_then(|v| v.as_u64()).unwrap_or(0);
let expr = rule.get("expr").and_then(|v| v.as_array());
let mut packet_count: u64 = 0;
let mut byte_count: u64 = 0;
let mut repr_parts: Vec<String> = Vec::new();
if let Some(exprs) = expr {
for e in exprs {
emit_expr(e, &mut repr_parts, &mut packet_count, &mut byte_count);
}
}
let rule_repr = repr_parts.join(" ");
rows.push(NftCounterRow {
family,
table,
chain,
handle,
packet_count,
byte_count,
rule_repr,
});
}
Ok(rows)
}
fn emit_expr(
expr: &serde_json::Value,
repr_parts: &mut Vec<String>,
packet_count: &mut u64,
byte_count: &mut u64,
) {
let Some(obj) = expr.as_object() else {
return;
};
for (kind, body) in obj {
match kind.as_str() {
"counter" => {
if let Some(c) = body.as_object() {
if let Some(p) = c.get("packets").and_then(|v| v.as_u64()) {
*packet_count = (*packet_count).max(p);
}
if let Some(b) = c.get("bytes").and_then(|v| v.as_u64()) {
*byte_count = (*byte_count).max(b);
}
}
}
"match" => {
if let Some(m) = body.as_object() {
let left = m.get("left");
let right = m.get("right");
let op = m.get("op").and_then(|v| v.as_str()).unwrap_or("==");
if op == "==" {
if let Some(rendered) = render_match(left, right) {
repr_parts.push(rendered);
}
}
}
}
"accept" => repr_parts.push("accept".to_string()),
"drop" => repr_parts.push("drop".to_string()),
"policy" => {
if let Some(s) = body.as_str() {
repr_parts.push(format!("policy {s}"));
}
}
_ => {}
}
}
}
fn render_match(
left: Option<&serde_json::Value>,
right: Option<&serde_json::Value>,
) -> Option<String> {
let left = left?.as_object()?;
let payload = left.get("payload")?.as_object()?;
let proto = payload.get("protocol").and_then(|v| v.as_str())?;
let field = payload.get("field").and_then(|v| v.as_str())?;
let right_value = right?;
let rendered_right = match right_value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
_ => return None,
};
match (proto, field) {
("ip", "daddr") => Some(format!("ip daddr {rendered_right}")),
("ip6", "daddr") => Some(format!("ip6 daddr {rendered_right}")),
("udp", "dport") => Some(format!("udp dport {rendered_right}")),
("tcp", "dport") => Some(format!("tcp dport {rendered_right}")),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClassifiedRule {
pub decision: NftDecision,
pub reason_code: &'static str,
pub nft_rule_ref: Option<String>,
pub dst_addr: Option<String>,
pub dst_port: Option<u16>,
pub protocol: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NftDecision {
Allow,
Deny,
}
pub fn classify_rule_repr(rule_repr: &str) -> ClassifiedRule {
let trimmed = rule_repr.trim();
if trimmed == "policy drop" {
return ClassifiedRule {
decision: NftDecision::Deny,
reason_code: "nft_default_drop",
nft_rule_ref: Some("default-drop".to_string()),
dst_addr: None,
dst_port: None,
protocol: None,
};
}
let has_accept = trimmed.split_whitespace().any(|t| t == "accept");
let has_drop = trimmed.split_whitespace().any(|t| t == "drop");
let dst_addr = extract_daddr(trimmed);
let (proto, dport) = extract_dport(trimmed);
if has_drop && dst_addr.is_none() && dport == Some(53) {
let proto_string = proto.map(|p| p.to_string());
return ClassifiedRule {
decision: NftDecision::Deny,
reason_code: "nft_workload_dns_block",
nft_rule_ref: Some("dnsAuthority.workloadDnsBlock".to_string()),
dst_addr: None,
dst_port: Some(53),
protocol: proto_string,
};
}
if has_drop && dst_addr.is_none() && dport == Some(853) && proto == Some("udp") {
return ClassifiedRule {
decision: NftDecision::Deny,
reason_code: "nft_doq_blocked",
nft_rule_ref: Some("dnsAuthority.blockUdpDoq".to_string()),
dst_addr: None,
dst_port: Some(853),
protocol: Some("udp".to_string()),
};
}
if has_drop && dst_addr.is_none() && dport == Some(443) && proto == Some("udp") {
return ClassifiedRule {
decision: NftDecision::Deny,
reason_code: "nft_http3_blocked",
nft_rule_ref: Some("dnsAuthority.blockUdpHttp3".to_string()),
dst_addr: None,
dst_port: Some(443),
protocol: Some("udp".to_string()),
};
}
if has_accept {
let is_phase3d_udp_resolver_port =
proto == Some("udp") && (dport == Some(853) || dport == Some(443));
if dport == Some(53) || is_phase3d_udp_resolver_port {
let proto_string = proto.map(|p| p.to_string());
return ClassifiedRule {
decision: NftDecision::Allow,
reason_code: "nft_resolver_allowlist_match",
nft_rule_ref: None,
dst_addr,
dst_port: dport,
protocol: proto_string,
};
}
let proto_string = proto.map(|p| p.to_string());
return ClassifiedRule {
decision: NftDecision::Allow,
reason_code: "nft_egress_rule_match",
nft_rule_ref: None,
dst_addr,
dst_port: dport,
protocol: proto_string,
};
}
if has_drop {
let proto_string = proto.map(|p| p.to_string());
return ClassifiedRule {
decision: NftDecision::Deny,
reason_code: "nft_default_drop",
nft_rule_ref: None,
dst_addr,
dst_port: dport,
protocol: proto_string,
};
}
ClassifiedRule {
decision: NftDecision::Deny,
reason_code: "nft_default_drop",
nft_rule_ref: None,
dst_addr: None,
dst_port: None,
protocol: None,
}
}
fn extract_daddr(repr: &str) -> Option<String> {
let tokens: Vec<&str> = repr.split_whitespace().collect();
for win in tokens.windows(3) {
if (win[0] == "ip" || win[0] == "ip6") && win[1] == "daddr" {
return Some(win[2].to_string());
}
}
None
}
fn extract_dport(repr: &str) -> (Option<&'static str>, Option<u16>) {
let tokens: Vec<&str> = repr.split_whitespace().collect();
for win in tokens.windows(3) {
let proto = match win[0] {
"udp" => Some("udp"),
"tcp" => Some("tcp"),
_ => None,
};
if proto.is_some() && win[1] == "dport" {
if let Ok(p) = win[2].parse::<u16>() {
return (proto, Some(p));
}
}
}
(None, None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_recognizes_default_drop_policy() {
let c = classify_rule_repr("policy drop");
assert_eq!(c.decision, NftDecision::Deny);
assert_eq!(c.reason_code, "nft_default_drop");
assert_eq!(c.nft_rule_ref.as_deref(), Some("default-drop"));
assert!(c.dst_addr.is_none());
assert!(c.dst_port.is_none());
assert!(c.protocol.is_none());
}
#[test]
fn classify_recognizes_workload_dns_block_udp() {
let c = classify_rule_repr("udp dport 53 drop");
assert_eq!(c.decision, NftDecision::Deny);
assert_eq!(c.reason_code, "nft_workload_dns_block");
assert_eq!(
c.nft_rule_ref.as_deref(),
Some("dnsAuthority.workloadDnsBlock")
);
assert_eq!(c.dst_port, Some(53));
assert_eq!(c.protocol.as_deref(), Some("udp"));
assert!(c.dst_addr.is_none());
}
#[test]
fn classify_recognizes_workload_dns_block_tcp() {
let c = classify_rule_repr("tcp dport 53 drop");
assert_eq!(c.decision, NftDecision::Deny);
assert_eq!(c.reason_code, "nft_workload_dns_block");
assert_eq!(c.dst_port, Some(53));
assert_eq!(c.protocol.as_deref(), Some("tcp"));
}
#[test]
fn classify_recognizes_resolver_allowlist_udp() {
let c = classify_rule_repr("ip daddr 1.1.1.1 udp dport 53 accept");
assert_eq!(c.decision, NftDecision::Allow);
assert_eq!(c.reason_code, "nft_resolver_allowlist_match");
assert_eq!(c.dst_addr.as_deref(), Some("1.1.1.1"));
assert_eq!(c.dst_port, Some(53));
assert_eq!(c.protocol.as_deref(), Some("udp"));
}
#[test]
fn classify_recognizes_resolver_allowlist_tcp() {
let c = classify_rule_repr("ip daddr 1.1.1.1 tcp dport 53 accept");
assert_eq!(c.decision, NftDecision::Allow);
assert_eq!(c.reason_code, "nft_resolver_allowlist_match");
assert_eq!(c.dst_port, Some(53));
assert_eq!(c.protocol.as_deref(), Some("tcp"));
}
#[test]
fn classify_recognizes_egress_rule_accept_v4() {
let c = classify_rule_repr("ip daddr 10.0.0.1 tcp dport 443 accept");
assert_eq!(c.decision, NftDecision::Allow);
assert_eq!(c.reason_code, "nft_egress_rule_match");
assert_eq!(c.dst_addr.as_deref(), Some("10.0.0.1"));
assert_eq!(c.dst_port, Some(443));
assert_eq!(c.protocol.as_deref(), Some("tcp"));
}
#[test]
fn classify_recognizes_egress_rule_accept_v6() {
let c = classify_rule_repr("ip6 daddr 2001:db8::1 tcp dport 443 accept");
assert_eq!(c.decision, NftDecision::Allow);
assert_eq!(c.reason_code, "nft_egress_rule_match");
assert_eq!(c.dst_addr.as_deref(), Some("2001:db8::1"));
assert_eq!(c.dst_port, Some(443));
assert_eq!(c.protocol.as_deref(), Some("tcp"));
}
#[test]
fn classify_recognises_nft_doq_blocked() {
let c = classify_rule_repr("udp dport 853 drop");
assert_eq!(c.decision, NftDecision::Deny);
assert_eq!(c.reason_code, "nft_doq_blocked");
assert_eq!(c.nft_rule_ref.as_deref(), Some("dnsAuthority.blockUdpDoq"));
assert_eq!(c.dst_port, Some(853));
assert_eq!(c.protocol.as_deref(), Some("udp"));
assert!(c.dst_addr.is_none());
}
#[test]
fn classify_recognises_nft_http3_blocked() {
let c = classify_rule_repr("udp dport 443 drop");
assert_eq!(c.decision, NftDecision::Deny);
assert_eq!(c.reason_code, "nft_http3_blocked");
assert_eq!(
c.nft_rule_ref.as_deref(),
Some("dnsAuthority.blockUdpHttp3")
);
assert_eq!(c.dst_port, Some(443));
assert_eq!(c.protocol.as_deref(), Some("udp"));
assert!(c.dst_addr.is_none());
}
#[test]
fn classify_recognises_resolver_allowlist_match_for_udp_443() {
let c = classify_rule_repr("ip daddr 1.1.1.1 udp dport 443 accept");
assert_eq!(c.decision, NftDecision::Allow);
assert_eq!(c.reason_code, "nft_resolver_allowlist_match");
assert_eq!(c.dst_addr.as_deref(), Some("1.1.1.1"));
assert_eq!(c.dst_port, Some(443));
assert_eq!(c.protocol.as_deref(), Some("udp"));
}
#[test]
fn classify_recognises_resolver_allowlist_match_for_udp_853() {
let c = classify_rule_repr("ip daddr 1.1.1.1 udp dport 853 accept");
assert_eq!(c.decision, NftDecision::Allow);
assert_eq!(c.reason_code, "nft_resolver_allowlist_match");
assert_eq!(c.dst_addr.as_deref(), Some("1.1.1.1"));
assert_eq!(c.dst_port, Some(853));
assert_eq!(c.protocol.as_deref(), Some("udp"));
}
#[test]
fn classify_does_not_misroute_tcp_443_to_http3_blocked() {
let c = classify_rule_repr("tcp dport 443 drop");
assert_eq!(c.decision, NftDecision::Deny);
assert_eq!(c.reason_code, "nft_default_drop");
}
#[test]
fn classify_egress_rule_match_still_recognised_for_non_resolver_udp_dport() {
let c = classify_rule_repr("ip daddr 10.0.0.7 udp dport 4242 accept");
assert_eq!(c.decision, NftDecision::Allow);
assert_eq!(c.reason_code, "nft_egress_rule_match");
assert_eq!(c.dst_port, Some(4242));
}
#[test]
fn classify_handles_malformed_input_gracefully() {
let c = classify_rule_repr("");
assert_eq!(c.decision, NftDecision::Deny);
assert_eq!(c.reason_code, "nft_default_drop");
assert!(c.dst_addr.is_none());
assert!(c.dst_port.is_none());
}
#[test]
fn classify_unrecognized_drop_falls_back_to_default_drop() {
let c = classify_rule_repr("ct state invalid drop");
assert_eq!(c.decision, NftDecision::Deny);
assert_eq!(c.reason_code, "nft_default_drop");
}
#[test]
fn extract_dport_ignores_wrong_proto() {
let (p, port) = extract_dport("icmp echo-request accept");
assert!(p.is_none());
assert!(port.is_none());
}
#[test]
fn parse_extracts_one_egress_accept_row() {
let json = r#"{
"nftables": [
{"metainfo": {"version": "1.0.6"}},
{"table": {"family": "inet", "name": "cellos_test"}},
{"chain": {"family": "inet", "table": "cellos_test", "name": "output"}},
{"rule": {
"family": "inet",
"table": "cellos_test",
"chain": "output",
"handle": 7,
"expr": [
{"match": {
"left": {"payload": {"protocol": "ip", "field": "daddr"}},
"right": "10.0.0.1",
"op": "=="
}},
{"match": {
"left": {"payload": {"protocol": "tcp", "field": "dport"}},
"right": 443,
"op": "=="
}},
{"counter": {"packets": 5, "bytes": 320}},
{"accept": null}
]
}}
]
}"#;
let rows = parse_nft_list_ruleset_json(json).expect("parse ok");
assert_eq!(rows.len(), 1);
let row = &rows[0];
assert_eq!(row.family, "inet");
assert_eq!(row.table, "cellos_test");
assert_eq!(row.chain, "output");
assert_eq!(row.handle, 7);
assert_eq!(row.packet_count, 5);
assert_eq!(row.byte_count, 320);
assert_eq!(row.rule_repr, "ip daddr 10.0.0.1 tcp dport 443 accept");
let classified = classify_rule_repr(&row.rule_repr);
assert_eq!(classified.decision, NftDecision::Allow);
assert_eq!(classified.reason_code, "nft_egress_rule_match");
assert_eq!(classified.dst_addr.as_deref(), Some("10.0.0.1"));
assert_eq!(classified.dst_port, Some(443));
}
#[test]
fn parse_extracts_workload_dns_block_drop_row() {
let json = r#"{
"nftables": [
{"rule": {
"family": "inet",
"table": "cellos_test",
"chain": "output",
"handle": 4,
"expr": [
{"match": {
"left": {"payload": {"protocol": "udp", "field": "dport"}},
"right": 53,
"op": "=="
}},
{"counter": {"packets": 2, "bytes": 80}},
{"drop": null}
]
}}
]
}"#;
let rows = parse_nft_list_ruleset_json(json).expect("parse ok");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].packet_count, 2);
assert_eq!(rows[0].rule_repr, "udp dport 53 drop");
let c = classify_rule_repr(&rows[0].rule_repr);
assert_eq!(c.reason_code, "nft_workload_dns_block");
assert_eq!(c.decision, NftDecision::Deny);
}
#[test]
fn parse_returns_empty_for_empty_nftables() {
let json = r#"{"nftables": []}"#;
let rows = parse_nft_list_ruleset_json(json).expect("parse ok");
assert!(rows.is_empty());
}
#[test]
fn parse_errors_when_nftables_array_missing() {
let json = r#"{"not_nftables": []}"#;
let err = parse_nft_list_ruleset_json(json).expect_err("must error");
assert!(matches!(err, NftCountersError::MissingNftablesArray));
}
#[test]
fn parse_errors_on_malformed_json() {
let err = parse_nft_list_ruleset_json("not json").expect_err("must error");
assert!(matches!(err, NftCountersError::Json(_)));
}
}