use std::fmt::Write;
use std::net::IpAddr;
use crate::vortix_core::cidr::Cidr;
use crate::vortix_core::cidr_subtract::cidr_subtract;
use crate::vortix_core::ports::killswitch::{
ActiveTunnelInfo, Killswitch, KillswitchError, Result,
};
use crate::vortix_process::{CommandSpec, PrivilegeReq};
use tracing::{debug, error, info};
const CHAIN_NAME: &str = "VORTIX_KILLSWITCH";
const NFT_TABLE: &str = "vortix_killswitch";
enum FirewallBackend {
Iptables,
Nftables,
}
pub struct IptablesFirewall;
fn fmt_cidr(c: &Cidr) -> String {
format!("{}/{}", c.addr, c.prefix_len)
}
fn rfc1918_base() -> Vec<Cidr> {
["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
.iter()
.map(|s| s.parse().expect("static RFC1918 CIDRs parse"))
.collect()
}
impl IptablesFirewall {
fn detect_backend() -> Option<FirewallBackend> {
if Self::has_iptables() {
Some(FirewallBackend::Iptables)
} else if Self::has_nft() {
Some(FirewallBackend::Nftables)
} else {
None
}
}
fn has_iptables() -> bool {
crate::vortix_process::run_to_output(CommandSpec::oneshot(
"iptables",
vec!["--version".into()],
))
.is_ok_and(|o| o.status.success())
}
fn has_nft() -> bool {
crate::vortix_process::run_to_output(CommandSpec::oneshot("nft", vec!["--version".into()]))
.is_ok_and(|o| o.status.success())
}
#[must_use]
pub fn generate_v4_ruleset(active: &[ActiveTunnelInfo]) -> String {
let mut rules = String::new();
writeln!(rules, "# Vortix Kill Switch Rules - Auto-generated").unwrap();
writeln!(rules, "# DO NOT EDIT - Will be overwritten").unwrap();
writeln!(rules, "*filter").unwrap();
writeln!(rules, ":INPUT ACCEPT [0:0]").unwrap();
writeln!(rules, ":FORWARD ACCEPT [0:0]").unwrap();
writeln!(rules, ":OUTPUT DROP [0:0]").unwrap();
writeln!(rules, "-A OUTPUT -o lo -j ACCEPT").unwrap();
let secondary_cidrs: Vec<Cidr> = active
.iter()
.filter(|t| !t.is_primary)
.flat_map(|t| t.declared_cidrs.iter().copied())
.collect();
let rfc1918 = cidr_subtract(&rfc1918_base(), &secondary_cidrs);
for c in &rfc1918 {
writeln!(rules, "-A OUTPUT -d {} -j ACCEPT", fmt_cidr(c)).unwrap();
}
writeln!(rules, "-A OUTPUT -p udp --sport 68 --dport 67 -j ACCEPT").unwrap();
for tunnel in active {
writeln!(
rules,
"# Tunnel: {} (primary={})",
tunnel.interface, tunnel.is_primary
)
.unwrap();
writeln!(rules, "-A OUTPUT -o {} -j ACCEPT", tunnel.interface).unwrap();
for ip in &tunnel.server_ips {
if let IpAddr::V4(v4) = ip {
writeln!(rules, "-A OUTPUT -d {v4} -j ACCEPT").unwrap();
}
}
}
writeln!(rules, "COMMIT").unwrap();
rules
}
#[must_use]
pub fn generate_v6_ruleset(active: &[ActiveTunnelInfo]) -> Option<String> {
let has_v6 = active
.iter()
.any(|t| t.server_ips.iter().any(IpAddr::is_ipv6));
if !has_v6 {
return None;
}
let mut rules = String::new();
writeln!(rules, "# Vortix Kill Switch Rules (IPv6) - Auto-generated").unwrap();
writeln!(rules, "# DO NOT EDIT - Will be overwritten").unwrap();
writeln!(rules, "*filter").unwrap();
writeln!(rules, ":INPUT ACCEPT [0:0]").unwrap();
writeln!(rules, ":FORWARD ACCEPT [0:0]").unwrap();
writeln!(rules, ":OUTPUT DROP [0:0]").unwrap();
writeln!(rules, "-A OUTPUT -o lo -j ACCEPT").unwrap();
for tunnel in active {
let v6_ips: Vec<&IpAddr> = tunnel.server_ips.iter().filter(|ip| ip.is_ipv6()).collect();
if v6_ips.is_empty() {
continue;
}
writeln!(
rules,
"# Tunnel: {} (primary={})",
tunnel.interface, tunnel.is_primary
)
.unwrap();
writeln!(rules, "-A OUTPUT -o {} -j ACCEPT", tunnel.interface).unwrap();
for ip in v6_ips {
if let IpAddr::V6(v6) = ip {
writeln!(rules, "-A OUTPUT -d {v6} -j ACCEPT").unwrap();
}
}
}
writeln!(rules, "COMMIT").unwrap();
Some(rules)
}
fn iptables_restore_stdin(ruleset: &[u8]) -> std::result::Result<(), String> {
let output = crate::vortix_process::run_to_output(
CommandSpec::oneshot("iptables-restore", vec![])
.privilege(PrivilegeReq::Root)
.stdin(ruleset.to_vec()),
)
.map_err(|e| format!("Failed to spawn iptables-restore: {e}"))?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
fn ip6tables_restore_stdin(ruleset: &[u8]) -> std::result::Result<(), String> {
let output = crate::vortix_process::run_to_output(
CommandSpec::oneshot("ip6tables-restore", vec![])
.privilege(PrivilegeReq::Root)
.stdin(ruleset.to_vec()),
)
.map_err(|e| format!("Failed to spawn ip6tables-restore: {e}"))?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
fn iptables(args: &[&str]) -> std::result::Result<(), String> {
let owned: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
let output = crate::vortix_process::run_to_output(
CommandSpec::oneshot("iptables", owned).privilege(PrivilegeReq::Root),
)
.map_err(|e| format!("Failed to run iptables: {e}"))?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
fn setup_iptables(active: &[ActiveTunnelInfo]) -> Result<()> {
let v4 = Self::generate_v4_ruleset(active);
debug!(
target: "vortix::killswitch",
bytes = v4.len(),
tunnels = active.len(),
"loading iptables ruleset via iptables-restore stdin"
);
Self::iptables_restore_stdin(v4.as_bytes()).map_err(|e| {
error!(target: "vortix::killswitch", stderr = %e, "iptables-restore failed");
KillswitchError::CommandFailed(format!("iptables-restore: {e}"))
})?;
if let Some(v6) = Self::generate_v6_ruleset(active) {
debug!(
target: "vortix::killswitch",
bytes = v6.len(),
"loading ip6tables ruleset via ip6tables-restore stdin"
);
Self::ip6tables_restore_stdin(v6.as_bytes()).map_err(|e| {
error!(target: "vortix::killswitch", stderr = %e, "ip6tables-restore failed");
KillswitchError::CommandFailed(format!("ip6tables-restore: {e}"))
})?;
}
Ok(())
}
fn teardown_iptables() {
let reset =
"*filter\n:INPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n";
let _ = Self::iptables_restore_stdin(reset.as_bytes());
let _ = Self::ip6tables_restore_stdin(reset.as_bytes());
let _ = Self::iptables(&["-D", "OUTPUT", "-j", CHAIN_NAME]);
let _ = Self::iptables(&["-F", CHAIN_NAME]);
let _ = Self::iptables(&["-X", CHAIN_NAME]);
}
fn nft(args: &[&str]) -> std::result::Result<(), String> {
let owned: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
let output = crate::vortix_process::run_to_output(
CommandSpec::oneshot("nft", owned).privilege(PrivilegeReq::Root),
)
.map_err(|e| format!("Failed to run nft: {e}"))?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
fn setup_nftables(vpn_interface: &str, vpn_server_ip: Option<&str>) -> Result<()> {
use std::fmt::Write;
let mut ruleset = format!(
r#"table inet {NFT_TABLE} {{
chain output {{
type filter hook output priority 0; policy drop;
# Allow loopback
oifname "lo" accept
# Allow VPN interface
oifname "{vpn_interface}" accept
# Allow local networks (RFC1918)
ip daddr 192.168.0.0/16 accept
ip daddr 10.0.0.0/8 accept
ip daddr 172.16.0.0/12 accept
# Allow DHCP
udp sport 68 udp dport 67 accept
"#,
);
if let Some(ip) = vpn_server_ip {
let _ = write!(
ruleset,
"\n # Allow VPN server for reconnection\n ip daddr {ip} accept\n"
);
}
ruleset.push_str(" }\n}\n");
let _ = Self::nft(&["delete", "table", "inet", NFT_TABLE]);
let output = crate::vortix_process::run_to_output(
CommandSpec::oneshot("nft", vec!["-f".into(), "-".into()])
.privilege(PrivilegeReq::Root)
.stdin(ruleset.into_bytes()),
)
.map_err(|e| KillswitchError::CommandFailed(format!("nft spawn: {e}")))?;
if !output.status.success() {
return Err(KillswitchError::CommandFailed(
"nft failed to load ruleset".to_string(),
));
}
Ok(())
}
fn teardown_nftables() {
let _ = Self::nft(&["delete", "table", "inet", NFT_TABLE]);
}
}
fn is_root() -> bool {
#[allow(unsafe_code)]
unsafe {
libc::geteuid() == 0
}
}
impl Killswitch for IptablesFirewall {
fn enable_blocking_multi(active: &[ActiveTunnelInfo]) -> Result<()> {
if !is_root() {
error!(target: "vortix::killswitch", "kill switch requires root privileges");
return Err(KillswitchError::NotRoot);
}
info!(
target: "vortix::killswitch",
tunnels = active.len(),
"killswitch.engage"
);
match Self::detect_backend() {
Some(FirewallBackend::Iptables) => {
debug!(target: "vortix::killswitch", "using iptables backend (iptables-restore atomic)");
Self::setup_iptables(active)?;
}
Some(FirewallBackend::Nftables) => {
debug!(target: "vortix::killswitch", "using nftables backend");
let first = active.first();
let interface = first.map_or("lo", |t| t.interface.as_str());
let server_ip_owned: Option<String> =
first.and_then(|t| t.server_ips.first().map(ToString::to_string));
Self::setup_nftables(interface, server_ip_owned.as_deref())?;
}
None => {
return Err(KillswitchError::NoBackendAvailable);
}
}
info!(
target: "vortix::killswitch",
tunnels = active.len(),
"kill switch ACTIVE — blocking non-VPN traffic"
);
Ok(())
}
fn disable_blocking() -> Result<()> {
info!(target: "vortix::killswitch", "disabling kill switch");
if !is_root() {
error!(target: "vortix::killswitch", "disabling kill switch requires root");
return Err(KillswitchError::NotRoot);
}
Self::teardown_iptables();
Self::teardown_nftables();
info!(target: "vortix::killswitch", "kill switch DISABLED — normal traffic restored");
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
fn cidr(s: &str) -> Cidr {
s.parse().expect("valid cidr in test")
}
fn ip(s: &str) -> IpAddr {
s.parse().expect("valid ip in test")
}
fn tunnel(
interface: &str,
server_ips: &[&str],
declared: &[&str],
is_primary: bool,
) -> ActiveTunnelInfo {
ActiveTunnelInfo {
interface: interface.to_string(),
server_ips: server_ips.iter().map(|s| ip(s)).collect(),
declared_cidrs: declared.iter().map(|s| cidr(s)).collect(),
is_primary,
}
}
#[test]
fn empty_active_set_yields_base_blockall() {
let rules = IptablesFirewall::generate_v4_ruleset(&[]);
assert!(rules.contains("*filter"));
assert!(rules.contains(":OUTPUT DROP [0:0]"));
assert!(rules.contains("-A OUTPUT -o lo -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -d 10.0.0.0/8 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -d 172.16.0.0/12 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -d 192.168.0.0/16 -j ACCEPT"));
assert!(rules.contains("--sport 68 --dport 67"));
assert!(!rules.contains("# Tunnel:"));
assert!(rules.trim_end().ends_with("COMMIT"));
}
#[test]
fn single_primary_zero_slash_zero_keeps_full_rfc1918() {
let t = tunnel("wg0", &["1.2.3.4"], &["0.0.0.0/0"], true);
let rules = IptablesFirewall::generate_v4_ruleset(&[t]);
assert!(rules.contains("-A OUTPUT -d 10.0.0.0/8 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -d 172.16.0.0/12 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -d 192.168.0.0/16 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -o wg0 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -d 1.2.3.4 -j ACCEPT"));
}
#[test]
fn single_secondary_ten_dot_carves_rfc1918() {
let t = tunnel("wg1", &["5.6.7.8"], &["10.0.0.0/8"], false);
let rules = IptablesFirewall::generate_v4_ruleset(&[t]);
assert!(!rules.contains("-A OUTPUT -d 10.0.0.0/8 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -d 172.16.0.0/12 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -d 192.168.0.0/16 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -o wg1 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -d 5.6.7.8 -j ACCEPT"));
}
#[test]
fn two_secondaries_disjoint_carve_correctly() {
let t1 = tunnel("wg1", &["1.1.1.1"], &["10.0.0.0/8"], false);
let t2 = tunnel("wg2", &["2.2.2.2"], &["192.168.0.0/16"], false);
let rules = IptablesFirewall::generate_v4_ruleset(&[t1, t2]);
assert!(!rules.contains("-A OUTPUT -d 10.0.0.0/8 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -d 172.16.0.0/12 -j ACCEPT"));
assert!(!rules.contains("-A OUTPUT -d 192.168.0.0/16 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -o wg1 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -o wg2 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -d 1.1.1.1 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -d 2.2.2.2 -j ACCEPT"));
}
#[test]
fn two_secondaries_overlapping_dont_double_subtract() {
let t1 = tunnel("wg3", &["1.1.1.1"], &["10.0.0.0/8"], false);
let t2 = tunnel("wg4", &["2.2.2.2"], &["10.5.0.0/16"], false);
let rules = IptablesFirewall::generate_v4_ruleset(&[t1, t2]);
assert!(!rules.contains("-A OUTPUT -d 10."));
assert!(rules.contains("-A OUTPUT -d 172.16.0.0/12 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -d 192.168.0.0/16 -j ACCEPT"));
}
#[test]
fn primary_plus_secondary_only_secondary_carves() {
let prim = tunnel("wg0", &["9.9.9.9"], &["0.0.0.0/0"], true);
let sec = tunnel("wg1", &["8.8.8.8"], &["10.0.0.0/8"], false);
let rules = IptablesFirewall::generate_v4_ruleset(&[prim, sec]);
assert!(!rules.contains("-A OUTPUT -d 10.0.0.0/8 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -d 172.16.0.0/12 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -d 192.168.0.0/16 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -o wg0 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -o wg1 -j ACCEPT"));
}
#[test]
fn tunnel_with_no_server_ips_still_gets_interface_rule() {
let t = tunnel("wg5", &[], &[], true);
let rules = IptablesFirewall::generate_v4_ruleset(&[t]);
assert!(rules.contains("-A OUTPUT -o wg5 -j ACCEPT"));
let occurrences = rules.matches("wg5").count();
assert_eq!(
occurrences, 2,
"wg5 should appear exactly twice (comment + rule), got ruleset:\n{rules}"
);
}
#[test]
fn tunnel_with_multiple_server_ips_emits_one_pass_per_ip() {
let t = tunnel("wg6", &["1.2.3.4", "5.6.7.8"], &[], true);
let rules = IptablesFirewall::generate_v4_ruleset(&[t]);
assert!(rules.contains("-A OUTPUT -d 1.2.3.4 -j ACCEPT"));
assert!(rules.contains("-A OUTPUT -d 5.6.7.8 -j ACCEPT"));
}
#[test]
fn no_v6_server_ips_yields_none_v6_ruleset() {
let t = tunnel("wg0", &["1.2.3.4"], &[], true);
assert!(IptablesFirewall::generate_v6_ruleset(&[t]).is_none());
assert!(IptablesFirewall::generate_v6_ruleset(&[]).is_none());
}
#[test]
fn v6_server_ip_routes_to_ip6tables_ruleset() {
let t = ActiveTunnelInfo {
interface: "wg7".to_string(),
server_ips: vec!["2001:db8::1".parse().unwrap()],
declared_cidrs: vec![],
is_primary: true,
};
let v6 = IptablesFirewall::generate_v6_ruleset(&[t]).expect("v6 ruleset present");
assert!(v6.contains("*filter"));
assert!(v6.contains(":OUTPUT DROP [0:0]"));
assert!(v6.contains("-A OUTPUT -o lo -j ACCEPT"));
assert!(v6.contains("-A OUTPUT -o wg7 -j ACCEPT"));
assert!(v6.contains("-A OUTPUT -d 2001:db8::1 -j ACCEPT"));
assert!(v6.trim_end().ends_with("COMMIT"));
}
#[test]
fn mixed_v4_and_v6_server_ips_emit_both_rulesets() {
let t = ActiveTunnelInfo {
interface: "wg8".to_string(),
server_ips: vec![ip("1.2.3.4"), "2001:db8::1".parse().unwrap()],
declared_cidrs: vec![],
is_primary: true,
};
let v4 = IptablesFirewall::generate_v4_ruleset(std::slice::from_ref(&t));
let v6 = IptablesFirewall::generate_v6_ruleset(std::slice::from_ref(&t))
.expect("v6 ruleset present");
assert!(v4.contains("-A OUTPUT -d 1.2.3.4 -j ACCEPT"));
assert!(!v4.contains("2001:db8"));
assert!(v6.contains("-A OUTPUT -d 2001:db8::1 -j ACCEPT"));
assert!(!v6.contains("1.2.3.4"));
}
#[test]
fn v6_ruleset_skips_tunnels_with_only_v4_ips() {
let t9 = tunnel("wg9", &["1.2.3.4"], &[], true);
let t10 = ActiveTunnelInfo {
interface: "wg10".to_string(),
server_ips: vec!["2001:db8::1".parse().unwrap()],
declared_cidrs: vec![],
is_primary: false,
};
let v6 = IptablesFirewall::generate_v6_ruleset(&[t9, t10]).expect("v6 ruleset present");
assert!(!v6.contains("wg9"));
assert!(v6.contains("-A OUTPUT -o wg10 -j ACCEPT"));
assert!(v6.contains("-A OUTPUT -d 2001:db8::1 -j ACCEPT"));
}
#[test]
fn snapshot_empty_active_set() {
let rules = IptablesFirewall::generate_v4_ruleset(&[]);
let expected = "\
# Vortix Kill Switch Rules - Auto-generated
# DO NOT EDIT - Will be overwritten
*filter
:INPUT ACCEPT [0:0]
:FORWARD ACCEPT [0:0]
:OUTPUT DROP [0:0]
-A OUTPUT -o lo -j ACCEPT
-A OUTPUT -d 10.0.0.0/8 -j ACCEPT
-A OUTPUT -d 172.16.0.0/12 -j ACCEPT
-A OUTPUT -d 192.168.0.0/16 -j ACCEPT
-A OUTPUT -p udp --sport 68 --dport 67 -j ACCEPT
COMMIT
";
assert_eq!(rules, expected);
}
#[test]
fn snapshot_single_primary() {
let t = ActiveTunnelInfo {
interface: "wg0".to_string(),
server_ips: vec![IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))],
declared_cidrs: vec![cidr("0.0.0.0/0")],
is_primary: true,
};
let rules = IptablesFirewall::generate_v4_ruleset(&[t]);
let expected = "\
# Vortix Kill Switch Rules - Auto-generated
# DO NOT EDIT - Will be overwritten
*filter
:INPUT ACCEPT [0:0]
:FORWARD ACCEPT [0:0]
:OUTPUT DROP [0:0]
-A OUTPUT -o lo -j ACCEPT
-A OUTPUT -d 10.0.0.0/8 -j ACCEPT
-A OUTPUT -d 172.16.0.0/12 -j ACCEPT
-A OUTPUT -d 192.168.0.0/16 -j ACCEPT
-A OUTPUT -p udp --sport 68 --dport 67 -j ACCEPT
# Tunnel: wg0 (primary=true)
-A OUTPUT -o wg0 -j ACCEPT
-A OUTPUT -d 1.2.3.4 -j ACCEPT
COMMIT
";
assert_eq!(rules, expected);
}
#[test]
fn snapshot_primary_plus_secondary() {
let prim = ActiveTunnelInfo {
interface: "wg0".to_string(),
server_ips: vec![IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))],
declared_cidrs: vec![cidr("0.0.0.0/0")],
is_primary: true,
};
let sec = ActiveTunnelInfo {
interface: "wg1".to_string(),
server_ips: vec![IpAddr::V4(Ipv4Addr::new(5, 6, 7, 8))],
declared_cidrs: vec![cidr("10.0.0.0/8")],
is_primary: false,
};
let rules = IptablesFirewall::generate_v4_ruleset(&[prim, sec]);
let expected = "\
# Vortix Kill Switch Rules - Auto-generated
# DO NOT EDIT - Will be overwritten
*filter
:INPUT ACCEPT [0:0]
:FORWARD ACCEPT [0:0]
:OUTPUT DROP [0:0]
-A OUTPUT -o lo -j ACCEPT
-A OUTPUT -d 172.16.0.0/12 -j ACCEPT
-A OUTPUT -d 192.168.0.0/16 -j ACCEPT
-A OUTPUT -p udp --sport 68 --dport 67 -j ACCEPT
# Tunnel: wg0 (primary=true)
-A OUTPUT -o wg0 -j ACCEPT
-A OUTPUT -d 1.2.3.4 -j ACCEPT
# Tunnel: wg1 (primary=false)
-A OUTPUT -o wg1 -j ACCEPT
-A OUTPUT -d 5.6.7.8 -j ACCEPT
COMMIT
";
assert_eq!(rules, expected);
}
}