release: NX9-WG v1.0.0
This commit is contained in:
1 parent
c8a9b7cde6
commit
4dfe42fe68
42 files changed
+4689
-336
No files matched your search
@@ -10,4 +10,6 @@ pub mod nftables;
|
||||
pub use engine::{NativeLinuxNetworkEngine, NetworkEngine, SimulatedNetworkEngine};
|
||||
pub use error::{NetworkError, Result};
|
||||
pub use forwarding::IpForwardingStatus;
|
||||
pub use nftables::NftablesRulesetBuilder;
|
||||
pub use nftables::{
|
||||
CanonicalNftablesRuleset, NftablesRulesetBuilder, has_nftables_drift, normalize_rule_statement,
|
||||
};
|
||||
@@ -138,7 +138,7 @@ impl NftablesRulesetBuilder {
|
||||
// Build Postrouting / NAT Masquerade rules
|
||||
let mut nat_rules = Vec::new();
|
||||
if enable_nat {
|
||||
let mut unique_subnets = wg_subnets.to_vec();
|
||||
let mut unique_subnets: Vec<IpNet> = wg_subnets.iter().map(|s| s.trunc()).collect();
|
||||
unique_subnets.sort();
|
||||
unique_subnets.dedup();
|
||||
|
||||
@@ -200,6 +200,224 @@ impl NftablesRulesetBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured semantic representation of the managed `table inet nx9_wg` ruleset.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct CanonicalNftablesRuleset {
|
||||
pub table_exists: bool,
|
||||
pub input_rules: Vec<String>,
|
||||
pub forward_rules: Vec<String>,
|
||||
pub postrouting_rules: Vec<String>,
|
||||
pub other_rules: Vec<String>,
|
||||
}
|
||||
|
||||
impl CanonicalNftablesRuleset {
|
||||
/// Parse an nftables ruleset string (from builder or kernel `list table`) into canonical semantic state.
|
||||
pub fn parse(ruleset: &str) -> Option<Self> {
|
||||
let trimmed_ruleset = ruleset.trim();
|
||||
if trimmed_ruleset.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut in_table = false;
|
||||
let mut current_chain: Option<&str> = None;
|
||||
let mut input_rules = Vec::new();
|
||||
let mut forward_rules = Vec::new();
|
||||
let mut postrouting_rules = Vec::new();
|
||||
let mut other_rules = Vec::new();
|
||||
|
||||
for raw_line in trimmed_ruleset.lines() {
|
||||
// Strip comments (e.g. "# handle 46", "# NX9 WireGuard...")
|
||||
let line_no_comment = if let Some(idx) = raw_line.find('#') {
|
||||
&raw_line[..idx]
|
||||
} else {
|
||||
raw_line
|
||||
};
|
||||
let trimmed = line_no_comment.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed.starts_with("table inet nx9_wg") {
|
||||
in_table = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !in_table {
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed == "}" {
|
||||
if current_chain.is_some() {
|
||||
current_chain = None;
|
||||
} else {
|
||||
in_table = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed.starts_with("chain input") {
|
||||
current_chain = Some("input");
|
||||
continue;
|
||||
} else if trimmed.starts_with("chain forward") {
|
||||
current_chain = Some("forward");
|
||||
continue;
|
||||
} else if trimmed.starts_with("chain postrouting") {
|
||||
current_chain = Some("postrouting");
|
||||
continue;
|
||||
} else if trimmed.starts_with("chain ") {
|
||||
current_chain = Some("other");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip chain type/hook declarations (e.g. "type filter hook input priority ...; policy accept;")
|
||||
if trimmed.starts_with("type filter")
|
||||
|| trimmed.starts_with("type nat")
|
||||
|| trimmed.starts_with("type route")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let normalized = normalize_rule_statement(trimmed);
|
||||
if normalized.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match current_chain {
|
||||
Some("input") => input_rules.push(normalized),
|
||||
Some("forward") => forward_rules.push(normalized),
|
||||
Some("postrouting") => postrouting_rules.push(normalized),
|
||||
Some(_) => other_rules.push(normalized),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
if in_table
|
||||
|| !input_rules.is_empty()
|
||||
|| !forward_rules.is_empty()
|
||||
|| !postrouting_rules.is_empty()
|
||||
|| trimmed_ruleset.contains("table inet nx9_wg")
|
||||
{
|
||||
Some(Self {
|
||||
table_exists: true,
|
||||
input_rules,
|
||||
forward_rules,
|
||||
postrouting_rules,
|
||||
other_rules,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize an individual nftables rule statement for canonical comparison.
|
||||
pub fn normalize_rule_statement(rule: &str) -> String {
|
||||
// 1. Strip double quotes (e.g. `"lo"` -> `lo`, `"wg*"` -> `wg*`)
|
||||
let no_quotes = rule.replace('"', "");
|
||||
|
||||
// 2. Normalize whitespace around braces and commas: "{ a, b }" -> "{a,b}"
|
||||
let mut normalized = String::with_capacity(no_quotes.len());
|
||||
let mut chars = no_quotes.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if c == ',' {
|
||||
normalized.push(',');
|
||||
while let Some(&next) = chars.peek() {
|
||||
if next == ' ' || next == '\t' {
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if c == '{' {
|
||||
normalized.push('{');
|
||||
while let Some(&next) = chars.peek() {
|
||||
if next == ' ' || next == '\t' {
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if c == ' ' || c == '\t' {
|
||||
let mut is_before_close_brace = false;
|
||||
let temp_peek = chars.clone();
|
||||
for peek_char in temp_peek {
|
||||
if peek_char == '}' {
|
||||
is_before_close_brace = true;
|
||||
break;
|
||||
} else if peek_char != ' ' && peek_char != '\t' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if is_before_close_brace {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !normalized.ends_with(' ') && !normalized.is_empty() {
|
||||
normalized.push(' ');
|
||||
}
|
||||
} else {
|
||||
normalized.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = normalized.trim().to_string();
|
||||
|
||||
// Standardize "ct state {established,related}" to "ct state established,related"
|
||||
if result.contains("ct state {established,related}") {
|
||||
result = result.replace(
|
||||
"ct state {established,related}",
|
||||
"ct state established,related",
|
||||
);
|
||||
}
|
||||
|
||||
// Standardize redundant leading protocol prefixes before IP selectors
|
||||
if (result.starts_with("tcp ip ") || result.starts_with("tcp ip6 "))
|
||||
&& (result.contains("tcp dport")
|
||||
|| result.contains("tcp sport")
|
||||
|| result.contains("th dport"))
|
||||
{
|
||||
result = result[4..].trim().to_string();
|
||||
}
|
||||
if (result.starts_with("udp ip ") || result.starts_with("udp ip6 "))
|
||||
&& (result.contains("udp dport")
|
||||
|| result.contains("udp sport")
|
||||
|| result.contains("th dport"))
|
||||
{
|
||||
result = result[4..].trim().to_string();
|
||||
}
|
||||
result = result.replace("tcp tcp dport", "tcp dport");
|
||||
result = result.replace("udp udp dport", "udp dport");
|
||||
result = result.replace("tcp tcp sport", "tcp sport");
|
||||
result = result.replace("udp udp sport", "udp sport");
|
||||
|
||||
let mut words: Vec<String> = result.split_whitespace().map(|s| s.to_string()).collect();
|
||||
for word in &mut words {
|
||||
if word.contains('/')
|
||||
&& let Ok(net) = word.parse::<IpNet>()
|
||||
{
|
||||
*word = net.trunc().to_string();
|
||||
}
|
||||
}
|
||||
result = words.join(" ");
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Detect semantic drift between desired and live nftables rulesets.
|
||||
/// Ignores non-semantic variations such as rule handles (`# handle 46`), formatting, tabs, comments, and hook priority keywords.
|
||||
pub fn has_nftables_drift(expected_ruleset: &str, active_ruleset: &str) -> bool {
|
||||
let expected = CanonicalNftablesRuleset::parse(expected_ruleset);
|
||||
let active = CanonicalNftablesRuleset::parse(active_ruleset);
|
||||
|
||||
match (expected, active) {
|
||||
(Some(exp), Some(act)) => exp != act,
|
||||
(None, None) => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -322,7 +540,6 @@ mod tests {
|
||||
assert!(ruleset.contains("ip6 saddr fd00:1::/64 oifname != \"wg*\" masquerade"));
|
||||
assert!(ruleset.contains("ip6 saddr fd00:2::/64 oifname != \"wg*\" masquerade"));
|
||||
|
||||
// Verify deduplication: 10.100.0.0/24 appears exactly once in masquerade statements
|
||||
let count = ruleset
|
||||
.matches("ip saddr 10.100.0.0/24 oifname != \"wg*\" masquerade")
|
||||
.count();
|
||||
@@ -331,4 +548,189 @@ mod tests {
|
||||
"Duplicate subnet must be deduplicated to exactly one masquerade rule"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonical_nftables_drift_ignores_handles_and_formatting() {
|
||||
let desired = r#"#!/usr/sbin/nft -f
|
||||
|
||||
# NX9 WireGuard Dedicated Firewall Ruleset
|
||||
table inet nx9_wg {
|
||||
chain input {
|
||||
type filter hook input priority 0; policy accept;
|
||||
ct state established,related accept
|
||||
iifname "lo" accept
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority 0; policy accept;
|
||||
ct state established,related accept
|
||||
}
|
||||
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
ip saddr 10.100.0.0/24 oifname != "wg*" masquerade
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
let live_with_kernel_handles = r#"table inet nx9_wg {
|
||||
chain input {
|
||||
type filter hook input priority filter; policy accept;
|
||||
ct state established,related accept # handle 46
|
||||
iifname "lo" accept # handle 1
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy accept;
|
||||
ct state established,related accept # handle 4
|
||||
}
|
||||
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
ip saddr 10.100.0.0/24 oifname != "wg*" masquerade # handle 3
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
assert!(
|
||||
!has_nftables_drift(desired, live_with_kernel_handles),
|
||||
"Desired ruleset and live kernel output with handles and tabs must converge with zero drift"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonical_nftables_drift_detects_real_rule_changes() {
|
||||
let desired = r#"table inet nx9_wg {
|
||||
chain input {
|
||||
type filter hook input priority 0; policy accept;
|
||||
ct state established,related accept
|
||||
iifname "lo" accept
|
||||
tcp dport 80 accept
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority 0; policy accept;
|
||||
ct state established,related accept
|
||||
}
|
||||
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
ip saddr 10.100.0.0/24 oifname != "wg*" masquerade
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
let live_missing_port_80 = r#"table inet nx9_wg {
|
||||
chain input {
|
||||
type filter hook input priority filter; policy accept;
|
||||
ct state established,related accept # handle 2
|
||||
iifname "lo" accept # handle 3
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy accept;
|
||||
ct state established,related accept # handle 4
|
||||
}
|
||||
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
ip saddr 10.100.0.0/24 oifname != "wg*" masquerade # handle 5
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
assert!(
|
||||
has_nftables_drift(desired, live_missing_port_80),
|
||||
"Missing port 80 rule must detect drift"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonical_nftables_drift_nat_toggle() {
|
||||
let desired_nat_disabled = r#"table inet nx9_wg {
|
||||
chain input {
|
||||
type filter hook input priority 0; policy accept;
|
||||
ct state established,related accept
|
||||
iifname "lo" accept
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority 0; policy accept;
|
||||
ct state established,related accept
|
||||
}
|
||||
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
let live_with_nat = r#"table inet nx9_wg {
|
||||
chain input {
|
||||
type filter hook input priority 0; policy accept;
|
||||
ct state established,related accept
|
||||
iifname "lo" accept
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority 0; policy accept;
|
||||
ct state established,related accept
|
||||
}
|
||||
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
ip saddr 10.100.0.0/24 oifname != "wg*" masquerade # handle 5
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
assert!(
|
||||
has_nftables_drift(desired_nat_disabled, live_with_nat),
|
||||
"Disabling NAT in desired state while active in kernel must detect drift"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonical_nftables_drift_missing_table() {
|
||||
let desired = r#"table inet nx9_wg {
|
||||
chain input {
|
||||
type filter hook input priority 0; policy accept;
|
||||
ct state established,related accept
|
||||
iifname "lo" accept
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
assert!(
|
||||
has_nftables_drift(desired, ""),
|
||||
"Empty active ruleset (missing table) must detect drift"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonical_nftables_drift_unexpected_rules() {
|
||||
let desired = r#"table inet nx9_wg {
|
||||
chain input {
|
||||
type filter hook input priority 0; policy accept;
|
||||
ct state established,related accept
|
||||
iifname "lo" accept
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
let live_with_rogue_rule = r#"table inet nx9_wg {
|
||||
chain input {
|
||||
type filter hook input priority 0; policy accept;
|
||||
ct state established,related accept
|
||||
iifname "lo" accept
|
||||
tcp dport 22 drop # handle 99
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
assert!(
|
||||
has_nftables_drift(desired, live_with_rogue_rule),
|
||||
"Unexpected kernel rules must detect drift"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user