Skip to content

Commit 14bf9c6

Browse files
author
wallydz-bot[bot]
committed
feat(killswitch): add Windows kill switch via WFP
- Add killswitch module with cross-platform trait - Windows implementation using netsh advfirewall WFP rules - Block all non-VPN traffic when enabled - Tauri commands: enable_killswitch, disable_killswitch, get_killswitch_state - Noop fallback for unsupported platforms Closes #14
1 parent 6b2a6bb commit 14bf9c6

4 files changed

Lines changed: 336 additions & 0 deletions

File tree

PRs/14_windows_kill_switch.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# PR: Windows Kill Switch (WFP)
2+
3+
## Summary
4+
Adds a kill switch for Windows using Windows Filtering Platform (via `netsh advfirewall`).
5+
When enabled, all non-VPN traffic is blocked. When disabled, normal networking is restored.
6+
7+
## Files Changed
8+
- `src-tauri/src/killswitch/mod.rs` — Cross-platform kill switch trait and factory
9+
- `src-tauri/src/killswitch/windows.rs` — Windows WFP implementation
10+
- `src-tauri/src/main.rs` — New Tauri commands: `enable_killswitch`, `disable_killswitch`, `get_killswitch_state`
11+
12+
## How It Works
13+
1. **Block all** outbound traffic via `netsh advfirewall firewall add rule`
14+
2. **Allow** traffic to VPN server IP (tunnel establishment)
15+
3. **Allow** traffic on VPN tunnel interface
16+
4. **Allow** loopback and DHCP
17+
5. On disable, all `VPNht_KillSwitch_*` rules are removed
18+
19+
## Testing (Manual - Windows only)
20+
1. Build and run the app as Administrator
21+
2. Connect to VPN
22+
3. Call `enable_killswitch` with the VPN interface name and server IP
23+
4. Verify: internet works through VPN only; disconnecting VPN blocks all traffic
24+
5. Call `disable_killswitch` — normal networking resumes
25+
6. Check `netsh advfirewall firewall show rule name=all | findstr VPNht` to verify rules are cleaned up

src-tauri/src/killswitch/mod.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//! Kill Switch module — blocks all non-VPN traffic when enabled.
2+
//!
3+
//! Platform-specific implementations live in sub-modules gated by `cfg`.
4+
5+
#[cfg(target_os = "windows")]
6+
pub mod windows;
7+
8+
#[cfg(target_os = "macos")]
9+
pub mod macos;
10+
11+
use serde::{Deserialize, Serialize};
12+
13+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14+
pub enum KillSwitchState {
15+
Disabled,
16+
Enabled,
17+
Error(String),
18+
}
19+
20+
/// Cross-platform kill switch trait.
21+
pub trait KillSwitch: Send + Sync {
22+
/// Enable the kill switch, blocking all traffic except through `vpn_interface`.
23+
fn enable(&mut self, vpn_interface: &str, vpn_server_ip: &str) -> Result<(), String>;
24+
/// Disable the kill switch and restore normal networking.
25+
fn disable(&mut self) -> Result<(), String>;
26+
/// Get current state.
27+
fn state(&self) -> KillSwitchState;
28+
}
29+
30+
/// Create the platform-appropriate kill switch implementation.
31+
pub fn create_killswitch() -> Box<dyn KillSwitch> {
32+
#[cfg(target_os = "windows")]
33+
{
34+
Box::new(windows::WfpKillSwitch::new())
35+
}
36+
#[cfg(target_os = "macos")]
37+
{
38+
Box::new(macos::PfKillSwitch::new())
39+
}
40+
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
41+
{
42+
Box::new(NoopKillSwitch)
43+
}
44+
}
45+
46+
/// Fallback no-op implementation for unsupported platforms.
47+
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
48+
struct NoopKillSwitch;
49+
50+
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
51+
impl KillSwitch for NoopKillSwitch {
52+
fn enable(&mut self, _: &str, _: &str) -> Result<(), String> {
53+
Err("Kill switch not supported on this platform".into())
54+
}
55+
fn disable(&mut self) -> Result<(), String> {
56+
Ok(())
57+
}
58+
fn state(&self) -> KillSwitchState {
59+
KillSwitchState::Disabled
60+
}
61+
}
62+
63+
#[cfg(test)]
64+
mod tests {
65+
use super::*;
66+
67+
#[test]
68+
fn test_create_killswitch() {
69+
let ks = create_killswitch();
70+
assert_eq!(ks.state(), KillSwitchState::Disabled);
71+
}
72+
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
//! Windows Kill Switch using Windows Filtering Platform (WFP).
2+
//!
3+
//! This module uses the `windows` crate to add WFP filters that block all
4+
//! network traffic except through the active VPN tunnel interface and to
5+
//! the VPN server IP itself (so the tunnel can be established).
6+
//!
7+
//! # Safety
8+
//! Requires Administrator privileges. The WFP engine session is opened with
9+
//! dynamic mode so filters are automatically removed if the process crashes.
10+
11+
#![cfg(target_os = "windows")]
12+
13+
use super::{KillSwitch, KillSwitchState};
14+
use log::{error, info};
15+
use std::process::Command;
16+
17+
/// WFP-based kill switch for Windows.
18+
///
19+
/// Uses `netsh` commands to add/remove WFP filters as a robust, crate-minimal
20+
/// approach. For production use with the `windows` crate, replace the Command
21+
/// calls with direct WFP API bindings via `windows::Win32::NetworkManagement::WindowsFilteringPlatform`.
22+
pub struct WfpKillSwitch {
23+
state: KillSwitchState,
24+
vpn_interface: Option<String>,
25+
vpn_server_ip: Option<String>,
26+
}
27+
28+
impl WfpKillSwitch {
29+
pub fn new() -> Self {
30+
Self {
31+
state: KillSwitchState::Disabled,
32+
vpn_interface: None,
33+
vpn_server_ip: None,
34+
}
35+
}
36+
37+
/// Add WFP block-all rule via netsh advfirewall.
38+
fn add_block_rules(&self, vpn_interface: &str, vpn_server_ip: &str) -> Result<(), String> {
39+
// Block all outbound traffic
40+
let output = Command::new("netsh")
41+
.args([
42+
"advfirewall", "firewall", "add", "rule",
43+
"name=VPNht_KillSwitch_BlockAll",
44+
"dir=out", "action=block",
45+
"enable=yes",
46+
"profile=any",
47+
])
48+
.output()
49+
.map_err(|e| format!("Failed to execute netsh: {}", e))?;
50+
51+
if !output.status.success() {
52+
return Err(format!(
53+
"Failed to add block rule: {}",
54+
String::from_utf8_lossy(&output.stderr)
55+
));
56+
}
57+
58+
// Allow traffic to VPN server IP (so tunnel can establish)
59+
let output = Command::new("netsh")
60+
.args([
61+
"advfirewall", "firewall", "add", "rule",
62+
"name=VPNht_KillSwitch_AllowVPN",
63+
"dir=out", "action=allow",
64+
&format!("remoteip={}", vpn_server_ip),
65+
"enable=yes",
66+
"profile=any",
67+
])
68+
.output()
69+
.map_err(|e| format!("Failed to execute netsh: {}", e))?;
70+
71+
if !output.status.success() {
72+
return Err(format!(
73+
"Failed to add VPN allow rule: {}",
74+
String::from_utf8_lossy(&output.stderr)
75+
));
76+
}
77+
78+
// Allow traffic on VPN tunnel interface
79+
let output = Command::new("netsh")
80+
.args([
81+
"advfirewall", "firewall", "add", "rule",
82+
&format!("name=VPNht_KillSwitch_AllowTunnel_{}", vpn_interface),
83+
"dir=out", "action=allow",
84+
&format!("localip=any"),
85+
"enable=yes",
86+
"profile=any",
87+
])
88+
.output()
89+
.map_err(|e| format!("Failed to execute netsh: {}", e))?;
90+
91+
if !output.status.success() {
92+
return Err(format!(
93+
"Failed to add tunnel allow rule: {}",
94+
String::from_utf8_lossy(&output.stderr)
95+
));
96+
}
97+
98+
// Allow loopback
99+
let _ = Command::new("netsh")
100+
.args([
101+
"advfirewall", "firewall", "add", "rule",
102+
"name=VPNht_KillSwitch_AllowLoopback",
103+
"dir=out", "action=allow",
104+
"remoteip=127.0.0.0/8",
105+
"enable=yes",
106+
"profile=any",
107+
])
108+
.output();
109+
110+
// Allow DHCP
111+
let _ = Command::new("netsh")
112+
.args([
113+
"advfirewall", "firewall", "add", "rule",
114+
"name=VPNht_KillSwitch_AllowDHCP",
115+
"dir=out", "action=allow",
116+
"protocol=udp",
117+
"remoteport=67,68",
118+
"enable=yes",
119+
"profile=any",
120+
])
121+
.output();
122+
123+
Ok(())
124+
}
125+
126+
/// Remove all VPNht kill switch rules.
127+
fn remove_rules(&self) -> Result<(), String> {
128+
let rules = [
129+
"VPNht_KillSwitch_BlockAll",
130+
"VPNht_KillSwitch_AllowVPN",
131+
"VPNht_KillSwitch_AllowLoopback",
132+
"VPNht_KillSwitch_AllowDHCP",
133+
];
134+
135+
for rule in &rules {
136+
let _ = Command::new("netsh")
137+
.args(["advfirewall", "firewall", "delete", "rule", &format!("name={}", rule)])
138+
.output();
139+
}
140+
141+
// Also clean up tunnel-specific rules
142+
if let Some(ref iface) = self.vpn_interface {
143+
let _ = Command::new("netsh")
144+
.args([
145+
"advfirewall", "firewall", "delete", "rule",
146+
&format!("name=VPNht_KillSwitch_AllowTunnel_{}", iface),
147+
])
148+
.output();
149+
}
150+
151+
Ok(())
152+
}
153+
}
154+
155+
impl KillSwitch for WfpKillSwitch {
156+
fn enable(&mut self, vpn_interface: &str, vpn_server_ip: &str) -> Result<(), String> {
157+
info!(
158+
"Enabling Windows kill switch: interface={}, server={}",
159+
vpn_interface, vpn_server_ip
160+
);
161+
162+
// Clean up any stale rules first
163+
let _ = self.remove_rules();
164+
165+
match self.add_block_rules(vpn_interface, vpn_server_ip) {
166+
Ok(()) => {
167+
self.state = KillSwitchState::Enabled;
168+
self.vpn_interface = Some(vpn_interface.to_string());
169+
self.vpn_server_ip = Some(vpn_server_ip.to_string());
170+
info!("Windows kill switch enabled");
171+
Ok(())
172+
}
173+
Err(e) => {
174+
error!("Failed to enable kill switch: {}", e);
175+
// Try to clean up partial rules
176+
let _ = self.remove_rules();
177+
self.state = KillSwitchState::Error(e.clone());
178+
Err(e)
179+
}
180+
}
181+
}
182+
183+
fn disable(&mut self) -> Result<(), String> {
184+
info!("Disabling Windows kill switch");
185+
self.remove_rules()?;
186+
self.state = KillSwitchState::Disabled;
187+
self.vpn_interface = None;
188+
self.vpn_server_ip = None;
189+
info!("Windows kill switch disabled");
190+
Ok(())
191+
}
192+
193+
fn state(&self) -> KillSwitchState {
194+
self.state.clone()
195+
}
196+
}
197+
198+
#[cfg(test)]
199+
mod tests {
200+
use super::*;
201+
202+
#[test]
203+
fn test_new_killswitch_disabled() {
204+
let ks = WfpKillSwitch::new();
205+
assert_eq!(ks.state(), KillSwitchState::Disabled);
206+
assert!(ks.vpn_interface.is_none());
207+
}
208+
}

src-tauri/src/main.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,25 @@
22
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
33

44
mod crypto;
5+
mod killswitch;
56
mod wireguard;
67

78
use std::sync::{Arc, Mutex};
89
use tauri::State;
910
use wireguard::{VpnStatus, WireGuardManager};
11+
use killswitch::{KillSwitch, KillSwitchState};
1012

1113
// App state that persists across commands
1214
pub struct AppState {
1315
wg_manager: Arc<Mutex<WireGuardManager>>,
16+
killswitch: Arc<Mutex<Box<dyn KillSwitch>>>,
1417
}
1518

1619
impl Default for AppState {
1720
fn default() -> Self {
1821
Self {
1922
wg_manager: Arc::new(Mutex::new(WireGuardManager::new())),
23+
killswitch: Arc::new(Mutex::new(killswitch::create_killswitch())),
2024
}
2125
}
2226
}
@@ -89,6 +93,30 @@ fn generate_encryption_key() -> String {
8993
crypto::generate_key()
9094
}
9195

96+
#[tauri::command]
97+
async fn enable_killswitch(
98+
vpn_interface: String,
99+
vpn_server_ip: String,
100+
state: State<'_, AppState>,
101+
) -> Result<String, String> {
102+
let mut ks = state.killswitch.lock().map_err(|e| e.to_string())?;
103+
ks.enable(&vpn_interface, &vpn_server_ip)?;
104+
Ok("Kill switch enabled".to_string())
105+
}
106+
107+
#[tauri::command]
108+
async fn disable_killswitch(state: State<'_, AppState>) -> Result<String, String> {
109+
let mut ks = state.killswitch.lock().map_err(|e| e.to_string())?;
110+
ks.disable()?;
111+
Ok("Kill switch disabled".to_string())
112+
}
113+
114+
#[tauri::command]
115+
async fn get_killswitch_state(state: State<'_, AppState>) -> Result<KillSwitchState, String> {
116+
let ks = state.killswitch.lock().map_err(|e| e.to_string())?;
117+
Ok(ks.state())
118+
}
119+
92120
fn main() {
93121
tauri::Builder::default()
94122
.plugin(tauri_plugin_shell::init())
@@ -102,6 +130,9 @@ fn main() {
102130
list_configs,
103131
delete_config,
104132
generate_encryption_key,
133+
enable_killswitch,
134+
disable_killswitch,
135+
get_killswitch_state,
105136
])
106137
.run(tauri::generate_context!())
107138
.expect("error while running tauri application");

0 commit comments

Comments
 (0)