KeiSeiKit-1.0/_primitives/_rust/firewall-diff/src/ufw.rs
Parfii-bot 0be354a920 KeiSeiKit-public — clean state
Single-commit clean baseline after security scrub of niche-tells,
project codenames, internal jargon, and contributor-email leaks.

Contents:
- 100 Rust crates (_primitives/_rust/)
- 37 agent manifests (_manifests/) + generated specs (_generated/)
- 67 user-invocable skills (skills/)
- 33 hooks (hooks/)
- Composition blocks (_blocks/)
- Documentation (docs/, README.md)
- TS adapter packages (_ts_packages/)
- Assembler (_assembler/)
- Roles (_roles/)
- Templates (_templates/)
- Forgejo CI (.forgejo/)

Author: Denis Parfionovich <info@greendragon.info>

License: see LICENSE.
2026-05-01 12:09:03 +08:00

173 lines
4.7 KiB
Rust

//! Parse `ufw status numbered` output.
//!
//! Typical shape (Ubuntu 22.04, ufw 0.36):
//!
//! Status: active
//!
//! To Action From
//! -- ------ ----
//! [ 1] 22/tcp LIMIT IN Anywhere
//! [ 2] 443/tcp ALLOW IN Anywhere
//! [ 3] 22/tcp (v6) LIMIT IN Anywhere (v6)
//!
//! We normalise "(v6)" to a separate family tag but key rules on port/proto
//! only (v6 and v4 rules with the same port/proto are treated as duplicates
//! of intent, which is usually the desired behaviour for parity checks).
use crate::intent::Action;
use serde::Serialize;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct LiveRule {
pub port: u16,
pub proto: String,
pub action: Action,
pub from: String,
pub family: Family,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub enum Family {
V4,
V6,
}
#[derive(Debug, Clone, Serialize)]
pub struct Live {
pub active: bool,
pub rules: Vec<LiveRule>,
}
pub fn parse(text: &str) -> Result<Live, String> {
let mut active = false;
let mut rules = Vec::new();
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix("Status:") {
active = rest.trim().eq_ignore_ascii_case("active");
continue;
}
if line.starts_with("To") || line.starts_with("--") {
continue;
}
if let Some(r) = parse_rule(line) {
rules.push(r);
}
}
if text.trim().is_empty() {
return Err("could not detect an `ufw status` block (empty input)".into());
}
Ok(Live { active, rules })
}
/// Parse one numbered rule line. Returns None if the line is not a rule.
fn parse_rule(line: &str) -> Option<LiveRule> {
// Strip leading "[ N]" if present.
let body = if let Some(idx) = line.find(']') {
line[idx + 1..].trim()
} else {
line
};
// Columns: <to> <ACTION IN|OUT|FWD> <from>
// We split on 2+ whitespace runs which ufw pads with.
let parts: Vec<&str> = body.split(" ").filter(|s| !s.is_empty()).map(str::trim).collect();
if parts.len() < 3 {
return None;
}
let to = parts[0];
let action_raw = parts[1];
let from = parts[2];
let (to_clean, family) = if to.contains("(v6)") {
(to.replace("(v6)", "").trim().to_string(), Family::V6)
} else {
(to.to_string(), Family::V4)
};
let (port, proto) = split_port_proto(&to_clean)?;
let action = parse_action(action_raw)?;
Some(LiveRule {
port,
proto,
action,
from: from.replace("(v6)", "").trim().to_string(),
family,
})
}
fn split_port_proto(tok: &str) -> Option<(u16, String)> {
// "22/tcp" | "53" | "443/udp"
if let Some((port_s, proto_s)) = tok.split_once('/') {
Some((port_s.parse().ok()?, proto_s.to_ascii_lowercase()))
} else {
Some((tok.parse().ok()?, "tcp".into()))
}
}
fn parse_action(raw: &str) -> Option<Action> {
let up = raw.to_ascii_uppercase();
if up.starts_with("ALLOW") {
Some(Action::Allow)
} else if up.starts_with("DENY") {
Some(Action::Deny)
} else if up.starts_with("LIMIT") {
Some(Action::Limit)
} else if up.starts_with("REJECT") {
Some(Action::Reject)
} else {
None
}
}
impl LiveRule {
pub fn key(&self) -> String {
let from = if self.from.eq_ignore_ascii_case("Anywhere") {
"any"
} else {
&self.from
};
format!(
"{}/{}::{}::{}",
self.port,
self.proto,
from.to_ascii_lowercase(),
format!("{:?}", self.action).to_ascii_lowercase()
)
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
Status: active
To Action From
-- ------ ----
[ 1] 22/tcp LIMIT IN Anywhere
[ 2] 443/tcp ALLOW IN Anywhere
[ 3] 22/tcp (v6) LIMIT IN Anywhere (v6)
"#;
#[test]
fn parses_active_and_rules() {
let l = parse(SAMPLE).unwrap();
assert!(l.active);
assert_eq!(l.rules.len(), 3);
assert_eq!(l.rules[0].port, 22);
assert_eq!(l.rules[0].proto, "tcp");
assert_eq!(l.rules[0].action, Action::Limit);
assert_eq!(l.rules[2].family, Family::V6);
}
#[test]
fn inactive_status_rejects_only_if_no_rules() {
let l = parse("Status: inactive\n").unwrap();
assert!(!l.active);
assert!(l.rules.is_empty());
}
}