release: NX9-WG v1.0.0

This commit is contained in:
thakares committed 2026-08-18 17:32:56 +05:30
1 parent c8a9b7cde6
commit 4dfe42fe68
42 files changed
+4689 -336

No files matched your search

+3 -1
View File
@@ -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,
};
+404 -2
View File
@@ -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"
);
}
}