use std::fmt::Write as FmtWrite;
use std::fs;
use std::io::Write as IoWrite;
use std::net::IpAddr;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
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 PF_CONF_PATH: &str = "/var/run/vortix/killswitch.conf";
const PF_CONF_PATH_LEGACY: &str = "/tmp/vortix_killswitch.conf";
fn pfctl(args: &[&str]) -> std::io::Result<std::process::Output> {
let owned: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
crate::vortix_process::run_to_output(
CommandSpec::oneshot("pfctl", owned).privilege(PrivilegeReq::Root),
)
}
fn pfctl_load_stdin(ruleset: &[u8]) -> std::io::Result<std::process::Output> {
crate::vortix_process::run_to_output(
CommandSpec::oneshot("pfctl", vec!["-f".to_string(), "-".to_string()])
.privilege(PrivilegeReq::Root)
.stdin(ruleset.to_vec()),
)
}
fn is_root() -> bool {
#[allow(unsafe_code)]
unsafe {
libc::geteuid() == 0
}
}
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()
}
pub struct PfFirewall;
impl PfFirewall {
#[must_use]
pub fn generate_pf_rules(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).unwrap();
writeln!(rules, "# Default: block all egress").unwrap();
writeln!(rules, "block out all").unwrap();
writeln!(rules).unwrap();
writeln!(rules, "# Allow loopback").unwrap();
writeln!(rules, "pass out quick on lo0 all").unwrap();
writeln!(rules).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);
writeln!(
rules,
"# Allow local network (RFC1918, minus secondaries' claimed CIDRs)"
)
.unwrap();
for c in &rfc1918 {
writeln!(rules, "pass out quick to {}", fmt_cidr(c)).unwrap();
}
writeln!(rules).unwrap();
writeln!(rules, "# Allow DHCP").unwrap();
writeln!(
rules,
"pass out quick proto udp from any port 68 to any port 67"
)
.unwrap();
writeln!(
rules,
"pass in quick proto udp from any port 67 to any port 68"
)
.unwrap();
for tunnel in active {
writeln!(rules).unwrap();
writeln!(
rules,
"# Tunnel: {} (primary={})",
tunnel.interface, tunnel.is_primary
)
.unwrap();
writeln!(rules, "pass out quick on {} all", tunnel.interface).unwrap();
for ip in &tunnel.server_ips {
writeln!(rules, "pass out quick to {}", fmt_ip(ip)).unwrap();
}
}
rules
}
fn write_diagnostic_snapshot(rules: &str) {
let conf_path = std::path::Path::new(PF_CONF_PATH);
if let Some(parent) = conf_path.parent() {
if !parent.exists() {
if let Err(e) = fs::create_dir_all(parent) {
debug!(target: "vortix::killswitch", err = %e, "snapshot dir create skipped");
return;
}
let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
}
}
match fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(PF_CONF_PATH)
{
Ok(mut file) => {
if let Err(e) = file.write_all(rules.as_bytes()) {
debug!(target: "vortix::killswitch", err = %e, "snapshot write skipped");
}
}
Err(e) => {
debug!(target: "vortix::killswitch", err = %e, "snapshot open skipped");
}
}
let _ = fs::remove_file(PF_CONF_PATH_LEGACY);
}
}
fn fmt_ip(ip: &IpAddr) -> String {
ip.to_string()
}
impl Killswitch for PfFirewall {
fn enable_blocking_multi(active: &[ActiveTunnelInfo]) -> Result<()> {
info!(
target: "vortix::killswitch",
tunnels = active.len(),
"killswitch.engage"
);
if !is_root() {
error!(target: "vortix::killswitch", "kill switch requires root privileges");
return Err(KillswitchError::NotRoot);
}
let rules = Self::generate_pf_rules(active);
Self::write_diagnostic_snapshot(&rules);
debug!(
target: "vortix::killswitch",
path = %PF_CONF_PATH,
bytes = rules.len(),
"loading pf ruleset via stdin"
);
let output = pfctl_load_stdin(rules.as_bytes())?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr).to_string();
error!(target: "vortix::killswitch", stderr = %err, "pfctl -f - failed");
return Err(KillswitchError::CommandFailed(err));
}
let output = pfctl(&["-e"])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("enabled") {
error!(target: "vortix::killswitch", stderr = %stderr, "pfctl -e failed");
return Err(KillswitchError::CommandFailed(stderr.to_string()));
}
}
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);
}
let output = pfctl(&["-F", "all"])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("not enabled") {
error!(target: "vortix::killswitch", stderr = %stderr, "pfctl -F failed");
return Err(KillswitchError::CommandFailed(stderr.to_string()));
}
}
let _ = pfctl(&["-d"])?;
let _ = fs::remove_file(PF_CONF_PATH);
let _ = fs::remove_file(PF_CONF_PATH_LEGACY);
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 = PfFirewall::generate_pf_rules(&[]);
assert!(rules.contains("block out all"));
assert!(rules.contains("pass out quick on lo0"));
assert!(rules.contains("pass out quick to 10.0.0.0/8"));
assert!(rules.contains("pass out quick to 172.16.0.0/12"));
assert!(rules.contains("pass out quick to 192.168.0.0/16"));
assert!(rules.contains("port 68 to any port 67"));
assert!(rules.contains("port 67 to any port 68"));
assert!(!rules.contains("pass out quick on utun"));
assert!(!rules.contains("# Tunnel:"));
}
#[test]
fn single_primary_zero_slash_zero_keeps_full_rfc1918() {
let t = tunnel("utun3", &["1.2.3.4"], &["0.0.0.0/0"], true);
let rules = PfFirewall::generate_pf_rules(&[t]);
assert!(rules.contains("pass out quick to 10.0.0.0/8"));
assert!(rules.contains("pass out quick to 172.16.0.0/12"));
assert!(rules.contains("pass out quick to 192.168.0.0/16"));
assert!(rules.contains("pass out quick on utun3 all"));
assert!(rules.contains("pass out quick to 1.2.3.4"));
}
#[test]
fn single_secondary_ten_dot_carves_rfc1918() {
let t = tunnel("utun4", &["5.6.7.8"], &["10.0.0.0/8"], false);
let rules = PfFirewall::generate_pf_rules(&[t]);
assert!(!rules.contains("pass out quick to 10.0.0.0/8"));
assert!(rules.contains("pass out quick to 172.16.0.0/12"));
assert!(rules.contains("pass out quick to 192.168.0.0/16"));
assert!(rules.contains("pass out quick on utun4 all"));
assert!(rules.contains("pass out quick to 5.6.7.8"));
}
#[test]
fn two_secondaries_disjoint_carve_correctly() {
let t1 = tunnel("utun5", &["1.1.1.1"], &["10.0.0.0/8"], false);
let t2 = tunnel("utun6", &["2.2.2.2"], &["192.168.0.0/16"], false);
let rules = PfFirewall::generate_pf_rules(&[t1, t2]);
assert!(!rules.contains("pass out quick to 10.0.0.0/8"));
assert!(rules.contains("pass out quick to 172.16.0.0/12"));
assert!(!rules.contains("pass out quick to 192.168.0.0/16"));
assert!(rules.contains("pass out quick on utun5 all"));
assert!(rules.contains("pass out quick on utun6 all"));
assert!(rules.contains("pass out quick to 1.1.1.1"));
assert!(rules.contains("pass out quick to 2.2.2.2"));
}
#[test]
fn two_secondaries_overlapping_dont_double_subtract() {
let t1 = tunnel("utun7", &["1.1.1.1"], &["10.0.0.0/8"], false);
let t2 = tunnel("utun8", &["2.2.2.2"], &["10.5.0.0/16"], false);
let rules = PfFirewall::generate_pf_rules(&[t1, t2]);
assert!(!rules.contains("pass out quick to 10."));
assert!(rules.contains("pass out quick to 172.16.0.0/12"));
assert!(rules.contains("pass out quick to 192.168.0.0/16"));
}
#[test]
fn primary_plus_secondary_only_secondary_carves() {
let prim = tunnel("utun9", &["9.9.9.9"], &["0.0.0.0/0"], true);
let sec = tunnel("utun10", &["8.8.8.8"], &["10.0.0.0/8"], false);
let rules = PfFirewall::generate_pf_rules(&[prim, sec]);
assert!(!rules.contains("pass out quick to 10.0.0.0/8"));
assert!(rules.contains("pass out quick to 172.16.0.0/12"));
assert!(rules.contains("pass out quick to 192.168.0.0/16"));
assert!(rules.contains("pass out quick on utun9 all"));
assert!(rules.contains("pass out quick on utun10 all"));
}
#[test]
fn tunnel_with_no_server_ips_still_gets_interface_rule() {
let t = tunnel("utun11", &[], &[], true);
let rules = PfFirewall::generate_pf_rules(&[t]);
assert!(rules.contains("pass out quick on utun11 all"));
}
#[test]
fn tunnel_with_multiple_server_ips_emits_one_pass_per_ip() {
let t = tunnel("utun12", &["1.2.3.4", "5.6.7.8"], &[], true);
let rules = PfFirewall::generate_pf_rules(&[t]);
assert!(rules.contains("pass out quick to 1.2.3.4"));
assert!(rules.contains("pass out quick to 5.6.7.8"));
}
#[test]
fn ipv6_server_ip_renders_without_brackets() {
let t = ActiveTunnelInfo {
interface: "utun13".to_string(),
server_ips: vec!["2001:db8::1".parse().unwrap()],
declared_cidrs: vec![],
is_primary: true,
};
let rules = PfFirewall::generate_pf_rules(&[t]);
assert!(rules.contains("pass out quick to 2001:db8::1"));
}
#[test]
fn snapshot_empty_active_set() {
let rules = PfFirewall::generate_pf_rules(&[]);
let expected = "\
# Vortix Kill Switch Rules - Auto-generated
# DO NOT EDIT - Will be overwritten
# Default: block all egress
block out all
# Allow loopback
pass out quick on lo0 all
# Allow local network (RFC1918, minus secondaries' claimed CIDRs)
pass out quick to 10.0.0.0/8
pass out quick to 172.16.0.0/12
pass out quick to 192.168.0.0/16
# Allow DHCP
pass out quick proto udp from any port 68 to any port 67
pass in quick proto udp from any port 67 to any port 68
";
assert_eq!(rules, expected);
}
#[test]
fn snapshot_single_primary() {
let t = ActiveTunnelInfo {
interface: "utun3".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 = PfFirewall::generate_pf_rules(&[t]);
let expected = "\
# Vortix Kill Switch Rules - Auto-generated
# DO NOT EDIT - Will be overwritten
# Default: block all egress
block out all
# Allow loopback
pass out quick on lo0 all
# Allow local network (RFC1918, minus secondaries' claimed CIDRs)
pass out quick to 10.0.0.0/8
pass out quick to 172.16.0.0/12
pass out quick to 192.168.0.0/16
# Allow DHCP
pass out quick proto udp from any port 68 to any port 67
pass in quick proto udp from any port 67 to any port 68
# Tunnel: utun3 (primary=true)
pass out quick on utun3 all
pass out quick to 1.2.3.4
";
assert_eq!(rules, expected);
}
#[test]
fn snapshot_primary_plus_secondary() {
let prim = ActiveTunnelInfo {
interface: "utun3".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: "utun4".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 = PfFirewall::generate_pf_rules(&[prim, sec]);
let expected = "\
# Vortix Kill Switch Rules - Auto-generated
# DO NOT EDIT - Will be overwritten
# Default: block all egress
block out all
# Allow loopback
pass out quick on lo0 all
# Allow local network (RFC1918, minus secondaries' claimed CIDRs)
pass out quick to 172.16.0.0/12
pass out quick to 192.168.0.0/16
# Allow DHCP
pass out quick proto udp from any port 68 to any port 67
pass in quick proto udp from any port 67 to any port 68
# Tunnel: utun3 (primary=true)
pass out quick on utun3 all
pass out quick to 1.2.3.4
# Tunnel: utun4 (primary=false)
pass out quick on utun4 all
pass out quick to 5.6.7.8
";
assert_eq!(rules, expected);
}
}