use crate::constants;
use crate::logger::{self, LogLevel};
use crate::state::{KillSwitchMode, KillSwitchState};
use crate::utils;
use std::fs;
use std::io;
use std::path::PathBuf;
pub use crate::vortix_core::ports::killswitch::{ActiveTunnelInfo, KillswitchError, Result};
pub type KillSwitchError = KillswitchError;
pub fn enable_blocking_multi(active: &[ActiveTunnelInfo]) -> Result<()> {
crate::platform::current_platform()
.killswitch
.enable_blocking_multi(active)
}
pub fn disable_blocking() -> Result<()> {
crate::platform::current_platform()
.killswitch
.disable_blocking()
}
fn get_state_path() -> Option<PathBuf> {
utils::get_app_config_dir()
.ok()
.map(|dir| dir.join(constants::KILLSWITCH_STATE_FILE))
}
pub const PERSISTED_STATE_SCHEMA_V2: u8 = 2;
fn default_schema_version() -> u8 {
1
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PersistedTunnelInfo {
pub interface: String,
#[serde(default)]
pub server_ips: Vec<String>,
#[serde(default)]
pub declared_cidrs: Vec<String>,
#[serde(default)]
pub is_primary: bool,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PersistedState {
#[serde(default = "default_schema_version")]
pub schema_version: u8,
pub mode: KillSwitchMode,
pub state: KillSwitchState,
#[serde(default)]
pub vpn_interface: Option<String>,
#[serde(default)]
pub vpn_server_ip: Option<String>,
#[serde(default)]
pub active_tunnels: Vec<PersistedTunnelInfo>,
}
fn coerce_v1_to_v2(state: &mut PersistedState) {
if state.active_tunnels.is_empty() {
if let Some(iface) = state.vpn_interface.clone() {
state.active_tunnels.push(PersistedTunnelInfo {
interface: iface,
server_ips: state.vpn_server_ip.clone().into_iter().collect(),
declared_cidrs: Vec::new(),
is_primary: true,
});
}
}
state.schema_version = PERSISTED_STATE_SCHEMA_V2;
}
fn filter_phantom_tunnels(state: &mut PersistedState, live: &[String]) {
if live.is_empty() {
return;
}
let mut dropped: Vec<String> = Vec::new();
state.active_tunnels.retain(|t| {
if live.iter().any(|name| name == &t.interface) {
true
} else {
dropped.push(t.interface.clone());
false
}
});
if !dropped.is_empty() {
tracing::warn!(
target: "FIREWALL",
dropped = ?dropped,
"Dropped persisted tunnel entries whose interface no longer exists in the kernel"
);
}
}
#[must_use]
pub fn load_state() -> Option<PersistedState> {
let path = get_state_path()?;
let content = fs::read_to_string(&path).ok()?;
let mut persisted: PersistedState = match serde_json::from_str(&content) {
Ok(p) => p,
Err(e) => {
logger::log(
LogLevel::Warning,
"FIREWALL",
format!("Failed to parse persisted state: {e}"),
);
return None;
}
};
logger::log(
LogLevel::Debug,
"FIREWALL",
format!(
"Loaded persisted state from {} (schema v{})",
path.display(),
persisted.schema_version
),
);
match persisted.schema_version {
0 | 1 => {
if !persisted.active_tunnels.is_empty()
|| persisted.vpn_interface.is_some()
|| persisted.vpn_server_ip.is_some()
{
logger::log(
LogLevel::Info,
"FIREWALL",
"Migrating persisted killswitch state V1 → V2".to_string(),
);
}
coerce_v1_to_v2(&mut persisted);
}
PERSISTED_STATE_SCHEMA_V2 => {}
other => {
tracing::warn!(
target: "FIREWALL",
schema = other,
"Unknown PersistedState schema version; falling back to V1 coercion"
);
coerce_v1_to_v2(&mut persisted);
}
}
let live = crate::platform::current_platform().available_network_interfaces();
filter_phantom_tunnels(&mut persisted, &live);
Some(persisted)
}
pub fn save_state(
mode: KillSwitchMode,
state: KillSwitchState,
active_tunnels: Vec<PersistedTunnelInfo>,
) -> Result<()> {
let Some(path) = get_state_path() else {
return Ok(()); };
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let persisted = PersistedState {
schema_version: PERSISTED_STATE_SCHEMA_V2,
mode,
state,
vpn_interface: None,
vpn_server_ip: None,
active_tunnels,
};
let content = serde_json::to_string_pretty(&persisted).map_err(io::Error::other)?;
atomic_write(&path, content.as_bytes())?;
Ok(())
}
#[must_use]
pub fn persisted_from_active(active: &[ActiveTunnelInfo]) -> Vec<PersistedTunnelInfo> {
active
.iter()
.map(|a| PersistedTunnelInfo {
interface: a.interface.clone(),
server_ips: a.server_ips.iter().map(ToString::to_string).collect(),
declared_cidrs: a
.declared_cidrs
.iter()
.map(|c| format!("{}/{}", c.addr, c.prefix_len))
.collect(),
is_primary: a.is_primary,
})
.collect()
}
fn atomic_write(path: &std::path::Path, contents: &[u8]) -> io::Result<()> {
let tmp_path = path.with_extension("json.tmp");
crate::utils::write_user_file(&tmp_path, contents)?;
{
let file = std::fs::OpenOptions::new().read(true).open(&tmp_path)?;
let _ = file.sync_all();
}
fs::rename(&tmp_path, path)?;
Ok(())
}
pub fn clear_state() {
if let Some(path) = get_state_path() {
let _ = fs::remove_file(path);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn v2_persisted_state_round_trips() {
let state = PersistedState {
schema_version: PERSISTED_STATE_SCHEMA_V2,
mode: KillSwitchMode::Auto,
state: KillSwitchState::Armed,
vpn_interface: None,
vpn_server_ip: None,
active_tunnels: vec![PersistedTunnelInfo {
interface: "utun3".to_string(),
server_ips: vec!["1.2.3.4".to_string()],
declared_cidrs: vec!["10.0.0.0/8".to_string()],
is_primary: true,
}],
};
let json = serde_json::to_string_pretty(&state).unwrap();
let deserialized: PersistedState = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.schema_version, PERSISTED_STATE_SCHEMA_V2);
assert_eq!(deserialized.mode, KillSwitchMode::Auto);
assert_eq!(deserialized.state, KillSwitchState::Armed);
assert_eq!(deserialized.active_tunnels.len(), 1);
assert_eq!(deserialized.active_tunnels[0].interface, "utun3");
assert!(deserialized.active_tunnels[0].is_primary);
}
#[test]
fn v1_file_deserializes_with_serde_defaults() {
let json =
r#"{"mode":"Auto","state":"Armed","vpn_interface":"utun3","vpn_server_ip":"1.2.3.4"}"#;
let mut state: PersistedState = serde_json::from_str(json).unwrap();
assert_eq!(
state.schema_version, 1,
"missing schema_version defaults to 1"
);
assert_eq!(state.vpn_interface.as_deref(), Some("utun3"));
assert!(state.active_tunnels.is_empty());
coerce_v1_to_v2(&mut state);
assert_eq!(state.schema_version, PERSISTED_STATE_SCHEMA_V2);
assert_eq!(state.active_tunnels.len(), 1);
assert_eq!(state.active_tunnels[0].interface, "utun3");
assert_eq!(
state.active_tunnels[0].server_ips,
vec!["1.2.3.4".to_string()]
);
assert!(state.active_tunnels[0].is_primary);
}
#[test]
fn v1_with_no_interface_coerces_to_empty_active_tunnels() {
let json = r#"{"mode":"Off","state":"Disabled","vpn_interface":null,"vpn_server_ip":null}"#;
let mut state: PersistedState = serde_json::from_str(json).unwrap();
coerce_v1_to_v2(&mut state);
assert_eq!(state.schema_version, PERSISTED_STATE_SCHEMA_V2);
assert!(state.active_tunnels.is_empty());
}
#[test]
fn v2_file_with_schema_version_field_deserializes() {
let json = r#"{
"schema_version": 2,
"mode": "Auto",
"state": "Armed",
"vpn_interface": null,
"vpn_server_ip": null,
"active_tunnels": [
{"interface":"wg0","server_ips":["1.2.3.4"],"declared_cidrs":[],"is_primary":true},
{"interface":"utun5","server_ips":["5.6.7.8"],"declared_cidrs":["10.0.0.0/8"],"is_primary":false}
]
}"#;
let state: PersistedState = serde_json::from_str(json).unwrap();
assert_eq!(state.schema_version, PERSISTED_STATE_SCHEMA_V2);
assert_eq!(state.active_tunnels.len(), 2);
assert!(state.active_tunnels[0].is_primary);
assert!(!state.active_tunnels[1].is_primary);
assert_eq!(
state.active_tunnels[1].declared_cidrs,
vec!["10.0.0.0/8".to_string()]
);
}
#[test]
fn persisted_state_corrupted_mode_fails() {
let json = r#"{"mode":"InvalidValue","state":"Disabled"}"#;
let result: std::result::Result<PersistedState, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn persisted_state_empty_json_fails() {
let result: std::result::Result<PersistedState, _> = serde_json::from_str("{}");
assert!(result.is_err());
}
#[test]
fn filter_phantom_tunnels_drops_unknown_interfaces() {
let mut state = PersistedState {
schema_version: PERSISTED_STATE_SCHEMA_V2,
mode: KillSwitchMode::Auto,
state: KillSwitchState::Armed,
vpn_interface: None,
vpn_server_ip: None,
active_tunnels: vec![
PersistedTunnelInfo {
interface: "eth0".to_string(),
server_ips: Vec::new(),
declared_cidrs: Vec::new(),
is_primary: true,
},
PersistedTunnelInfo {
interface: "utun99".to_string(),
server_ips: Vec::new(),
declared_cidrs: Vec::new(),
is_primary: false,
},
],
};
let live = vec!["lo".to_string(), "eth0".to_string()];
filter_phantom_tunnels(&mut state, &live);
assert_eq!(state.active_tunnels.len(), 1);
assert_eq!(state.active_tunnels[0].interface, "eth0");
}
#[test]
fn filter_phantom_tunnels_noop_on_empty_live_list() {
let mut state = PersistedState {
schema_version: PERSISTED_STATE_SCHEMA_V2,
mode: KillSwitchMode::Auto,
state: KillSwitchState::Armed,
vpn_interface: None,
vpn_server_ip: None,
active_tunnels: vec![PersistedTunnelInfo {
interface: "utun99".to_string(),
server_ips: Vec::new(),
declared_cidrs: Vec::new(),
is_primary: true,
}],
};
filter_phantom_tunnels(&mut state, &[]);
assert_eq!(state.active_tunnels.len(), 1);
}
#[test]
fn unknown_future_schema_falls_back_to_v1_coercion() {
let json = r#"{
"schema_version": 99,
"mode": "Auto",
"state": "Armed",
"vpn_interface": null,
"vpn_server_ip": null,
"active_tunnels": [
{"interface":"wg0","server_ips":[],"declared_cidrs":[],"is_primary":true}
]
}"#;
let mut state: PersistedState = serde_json::from_str(json).unwrap();
assert_eq!(state.schema_version, 99);
coerce_v1_to_v2(&mut state);
assert_eq!(state.schema_version, PERSISTED_STATE_SCHEMA_V2);
assert_eq!(state.active_tunnels.len(), 1);
}
#[test]
fn atomic_write_creates_target_and_no_tmp_left_behind() {
let tmp_dir = std::env::temp_dir();
let path = tmp_dir.join(format!(
"vortix-killswitch-atomic-write-{}.json",
std::process::id()
));
let _ = fs::remove_file(&path);
let tmp = path.with_extension("json.tmp");
let _ = fs::remove_file(&tmp);
atomic_write(&path, b"hello").unwrap();
assert!(path.exists(), "target file must exist after atomic_write");
assert_eq!(fs::read(&path).unwrap(), b"hello");
assert!(!tmp.exists(), "temp file must be renamed away");
let _ = fs::remove_file(&path);
}
#[test]
fn persisted_from_active_stringifies_addresses_and_cidrs() {
use crate::vortix_core::cidr::Cidr;
use std::net::IpAddr;
let active = vec![ActiveTunnelInfo {
interface: "utun3".to_string(),
server_ips: vec!["1.2.3.4".parse::<IpAddr>().unwrap()],
declared_cidrs: vec!["10.0.0.0/8".parse::<Cidr>().unwrap()],
is_primary: true,
}];
let persisted = persisted_from_active(&active);
assert_eq!(persisted.len(), 1);
assert_eq!(persisted[0].interface, "utun3");
assert_eq!(persisted[0].server_ips, vec!["1.2.3.4".to_string()]);
assert_eq!(persisted[0].declared_cidrs, vec!["10.0.0.0/8".to_string()]);
assert!(persisted[0].is_primary);
}
#[test]
fn v0_3_x_v1_reader_tolerates_v2_file() {
#[derive(Debug, serde::Deserialize)]
#[allow(dead_code)]
struct V1PersistedState {
mode: KillSwitchMode,
state: KillSwitchState,
vpn_interface: Option<String>,
vpn_server_ip: Option<String>,
}
let v2_json = r#"{
"schema_version": 2,
"mode": "Auto",
"state": "Armed",
"vpn_interface": null,
"vpn_server_ip": null,
"active_tunnels": [
{"interface":"wg0","server_ips":["1.2.3.4"],"declared_cidrs":[],"is_primary":true}
]
}"#;
let parsed: V1PersistedState = serde_json::from_str(v2_json).unwrap();
assert_eq!(parsed.mode, KillSwitchMode::Auto);
assert_eq!(parsed.state, KillSwitchState::Armed);
assert!(parsed.vpn_interface.is_none());
}
}