Skip to main content

ufw_rule_parser/
lib.rs

1use pest::{Parser, iterators::Pair};
2use pest_derive::Parser;
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6/// pest parser generated from grammar.pest.
7#[derive(Parser)]
8#[grammar = "./grammar.pest"]
9pub struct FirewallGrammar;
10
11/// parsed firewall rule: service or address rule.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(tag = "type", rename_all = "lowercase")]
14pub enum FirewallRule {
15    /// service rule (e.g., allow ssh)
16    Service(ServiceRule),
17    /// address rule with optional direction, interface, from/to, port, proto
18    Address(AddressRule),
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct ServiceRule {
23    pub action: Action,
24    pub service: String,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct AddressRule {
29    pub action: Action,
30    pub direction: Option<Direction>,
31    pub interface: Option<String>,
32    pub from: Option<Address>,
33    pub to: Option<Address>,
34    pub port: Option<u16>,
35    pub proto: Option<Protocol>,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "lowercase")]
40pub enum Action {
41    Allow,
42    Deny,
43    Reject,
44    Limit,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum Direction {
50    In,
51    Out,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum Protocol {
57    Tcp,
58    Udp,
59    Any,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(tag = "type", content = "value", rename_all = "lowercase")]
64pub enum Address {
65    Any,
66    Internal,
67    External,
68    IpCidr(String),
69}
70
71#[derive(Debug, Error)]
72pub enum ParseError {
73    #[error("pest parse error: {0}")]
74    Pest(Box<pest::error::Error<Rule>>),
75    #[error("{0}")]
76    Message(String),
77}
78
79type ParseResult<T> = Result<T, ParseError>;
80
81impl From<pest::error::Error<Rule>> for ParseError {
82    fn from(value: pest::error::Error<Rule>) -> Self {
83        Self::Pest(Box::new(value))
84    }
85}
86
87/// parses firewall rules file into vector of rules.
88pub fn parse_rules(input: &str) -> ParseResult<Vec<FirewallRule>> {
89    let mut file_pairs = FirewallGrammar::parse(Rule::file, input)?;
90    let file_pair = file_pairs
91        .next()
92        .ok_or_else(|| ParseError::Message("expected file pair to be present".into()))?;
93
94    let mut rules = Vec::new();
95    for pair in file_pair.into_inner() {
96        match pair.as_rule() {
97            Rule::service_rule => {
98                rules.push(FirewallRule::Service(parse_service_rule(pair)?));
99            }
100            Rule::addr_rule => {
101                rules.push(FirewallRule::Address(parse_address_rule(pair)?));
102            }
103            Rule::line | Rule::NEWLINE | Rule::COMMENT | Rule::EOI => {}
104            unexpected => {
105                return Err(ParseError::Message(format!(
106                    "unexpected rule inside file: {unexpected:?}"
107                )));
108            }
109        }
110    }
111
112    Ok(rules)
113}
114
115fn parse_service_rule(pair: Pair<Rule>) -> ParseResult<ServiceRule> {
116    let mut inner = pair.into_inner();
117    let action_pair = inner
118        .next()
119        .ok_or_else(|| ParseError::Message("service rule missing action".into()))?;
120    let ident_pair = inner
121        .next()
122        .ok_or_else(|| ParseError::Message("service rule missing identifier".into()))?;
123
124    Ok(ServiceRule {
125        action: parse_action(action_pair.as_str())?,
126        service: ident_pair.as_str().to_string(),
127    })
128}
129
130fn parse_address_rule(pair: Pair<Rule>) -> ParseResult<AddressRule> {
131    let mut inner = pair.into_inner();
132    let action_pair = inner
133        .next()
134        .ok_or_else(|| ParseError::Message("address rule missing action".into()))?;
135    let action = parse_action(action_pair.as_str())?;
136
137    let mut rule = AddressRule {
138        action,
139        direction: None,
140        interface: None,
141        from: None,
142        to: None,
143        port: None,
144        proto: None,
145    };
146
147    for sub_pair in inner {
148        match sub_pair.as_rule() {
149            Rule::direction => {
150                rule.direction = Some(parse_direction(sub_pair.as_str())?);
151            }
152            Rule::interface_clause => {
153                let ident = sub_pair.into_inner().next().ok_or_else(|| {
154                    ParseError::Message("interface clause missing identifier".into())
155                })?;
156                rule.interface = Some(ident.as_str().to_string());
157            }
158            Rule::from_clause => {
159                let addr_pair = sub_pair
160                    .into_inner()
161                    .next()
162                    .ok_or_else(|| ParseError::Message("from clause missing address".into()))?;
163                rule.from = Some(parse_address(addr_pair)?);
164            }
165            Rule::to_clause => {
166                let addr_pair = sub_pair
167                    .into_inner()
168                    .next()
169                    .ok_or_else(|| ParseError::Message("to clause missing address".into()))?;
170                rule.to = Some(parse_address(addr_pair)?);
171            }
172            Rule::port_clause => {
173                let port_pair = sub_pair
174                    .into_inner()
175                    .next()
176                    .ok_or_else(|| ParseError::Message("port clause missing number".into()))?;
177                rule.port = Some(parse_port(port_pair.as_str())?);
178            }
179            Rule::proto_clause => {
180                let proto_pair = sub_pair
181                    .into_inner()
182                    .next()
183                    .ok_or_else(|| ParseError::Message("proto clause missing proto".into()))?;
184                rule.proto = Some(parse_protocol(proto_pair.as_str())?);
185            }
186            other => {
187                return Err(ParseError::Message(format!(
188                    "unexpected rule inside addr_rule: {other:?}"
189                )));
190            }
191        }
192    }
193
194    Ok(rule)
195}
196
197fn parse_action(text: &str) -> ParseResult<Action> {
198    match text {
199        "allow" => Ok(Action::Allow),
200        "deny" => Ok(Action::Deny),
201        "reject" => Ok(Action::Reject),
202        "limit" => Ok(Action::Limit),
203        other => Err(ParseError::Message(format!("invalid action: {other}"))),
204    }
205}
206
207fn parse_direction(text: &str) -> ParseResult<Direction> {
208    match text {
209        "in" => Ok(Direction::In),
210        "out" => Ok(Direction::Out),
211        other => Err(ParseError::Message(format!("invalid direction: {other}"))),
212    }
213}
214
215fn parse_protocol(text: &str) -> ParseResult<Protocol> {
216    match text {
217        "tcp" => Ok(Protocol::Tcp),
218        "udp" => Ok(Protocol::Udp),
219        "any" => Ok(Protocol::Any),
220        other => Err(ParseError::Message(format!("invalid protocol: {other}"))),
221    }
222}
223
224fn parse_address(pair: Pair<Rule>) -> ParseResult<Address> {
225    let text = pair.as_str();
226    match text {
227        "any" => Ok(Address::Any),
228        "internal" => Ok(Address::Internal),
229        "external" => Ok(Address::External),
230        _ => Ok(Address::IpCidr(text.to_string())),
231    }
232}
233
234fn parse_port(text: &str) -> ParseResult<u16> {
235    text.parse::<u16>()
236        .map_err(|_| ParseError::Message(format!("invalid port value: {text}")))
237}
238
239/// grammar rule documentation from grammar.pest.
240pub mod grammar_docs {
241    /// matches spaces and tabs (silent rule).
242    pub const WHITESPACE: &str = r#"WHITESPACE = _{ " " | "\t" }"#;
243    /// matches line breaks (silent rule).
244    pub const NEWLINE: &str = r#"NEWLINE = _{ "\r\n" | "\n" }"#;
245    pub const COMMENT: &str = r##"COMMENT = _{ "#" ~ (!NEWLINE ~ ANY)* }"##;
246    pub const ACTION: &str = r#"action = { "allow" | "deny" | "reject" | "limit" }"#;
247    /// matches direction: in or out.
248    pub const DIRECTION: &str = r#"direction = { "in" | "out" }"#;
249    pub const IDENT: &str = r#"ident = @{ (ASCII_ALPHANUMERIC | "_" | "-")+ }"#;
250    /// matches ip address or cidr notation.
251    pub const IP: &str = r#"ip = @{ (ASCII_DIGIT | "." | "/")+ }"#;
252    /// matches address: any, internal, external, or ip.
253    pub const ADDR: &str = r#"addr = { "any" | "internal" | "external" | ip }"#;
254    /// matches port number as digits.
255    pub const PORT_NUMBER: &str = r#"port_number = @{ ASCII_DIGIT+ }"#;
256    pub const PORT_CLAUSE: &str = r#"port_clause = { "port" ~ port_number }"#;
257    /// matches protocol: tcp, udp, or any.
258    pub const PROTO: &str = r#"proto = { "tcp" | "udp" | "any" }"#;
259    pub const PROTO_CLAUSE: &str = r#"proto_clause = { "proto" ~ proto }"#;
260    pub const INTERFACE_CLAUSE: &str = r#"interface_clause = { "on" ~ ident }"#;
261    /// matches "from" keyword followed by address.
262    pub const FROM_CLAUSE: &str = r#"from_clause = { "from" ~ addr }"#;
263    pub const TO_CLAUSE: &str = r#"to_clause = { "to" ~ addr }"#;
264    /// matches address rule: action, optional direction/interface, one or more clauses.
265    pub const ADDR_RULE: &str = r#"addr_rule = { action ~ direction? ~ interface_clause? ~ (from_clause | to_clause | port_clause | proto_clause)+ }"#;
266    pub const SERVICE_RULE: &str = r#"service_rule = { action ~ ident }"#;
267    pub const LINE: &str = r#"line = _{ (addr_rule | service_rule) ~ COMMENT? | COMMENT }"#;
268    pub const FILE: &str = r#"file = { SOI ~ (line? ~ NEWLINE)* ~ EOI }"#;
269}