From fcb21dbe2395c7076800aab4ff8c44565353aa87 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 30 Jul 2026 05:29:31 -0400 Subject: [PATCH 1/2] feat(macos): support Network Extension VPNs Detect dynamic utun paths and VPN-owned outer sockets without manual endpoint IPs. Keep PF changes scoped to a dedicated anchor and fail closed across disconnects and reconnects. --- Cargo.lock | 186 ------ Cargo.toml | 1 - IMPLEMENTATION.md | 99 ++++ README.md | 119 ++-- src/cli/actions/mod.rs | 1 + src/cli/actions/run.rs | 2 + src/cli/commands/mod.rs | 7 + src/cli/dispatch/mod.rs | 5 +- src/killswitch/mod.rs | 368 ++++++++---- src/killswitch/network.rs | 1160 +++++++++++++++++++------------------ src/killswitch/pf.rs | 400 ++++++++++--- src/killswitch/rules.rs | 327 +++++++---- test_killswitch.sh | 312 ++++------ 13 files changed, 1681 insertions(+), 1306 deletions(-) create mode 100644 IMPLEMENTATION.md diff --git a/Cargo.lock b/Cargo.lock index 65deb8b..9ced29b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "1.0.0" @@ -88,12 +79,6 @@ dependencies = [ "git2", ] -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - [[package]] name = "cc" version = "1.2.60" @@ -118,17 +103,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "num-traits", - "windows-link", -] - [[package]] name = "clap" version = "4.6.1" @@ -162,12 +136,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "displaydoc" version = "0.2.5" @@ -219,30 +187,6 @@ dependencies = [ "url", ] -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.2.0" @@ -362,16 +306,6 @@ dependencies = [ "libc", ] -[[package]] -name = "js-sys" -version = "0.3.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - [[package]] name = "libc" version = "0.2.185" @@ -436,21 +370,6 @@ dependencies = [ "memoffset", ] -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -502,12 +421,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - [[package]] name = "serde" version = "1.0.228" @@ -635,7 +548,6 @@ version = "0.8.3" dependencies = [ "anyhow", "built", - "chrono", "clap", "libc", "nix", @@ -650,110 +562,12 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasm-bindgen" -version = "0.2.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index 8171344..b84196c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,6 @@ large_stack_arrays = "deny" [dependencies] anyhow = "1" -chrono = { version = "0.4.44", default-features = false, features = ["clock"] } clap = { version = "4", features = ["string", "env"] } libc = "0.2" nix = { version = "0.31", features = ["net"] } diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md new file mode 100644 index 0000000..6e5c939 --- /dev/null +++ b/IMPLEMENTATION.md @@ -0,0 +1,99 @@ +# Implementation notes + +## macOS Network Extension model + +`NEPacketTunnelProvider` associates routes and addresses with a virtual +interface, then reads and writes packets through `NEPacketTunnelFlow`. The +provider's encapsulating socket uses the physical network path and is not +required to appear as a conventional VPN gateway route. + +On the AdGuard VPN system used during development: + +- `route -n get default` remains `en0` via the LAN gateway; +- `utun4` owns `172.16.209.2`, `fd00::2`, and the broad VPN routes; +- `utun0` through `utun3` have only link-local/service routes and are unrelated; +- `scutil --nc list` does not expose the active AdGuard packet tunnel; +- `lsof -F pcPnT` exposes `AdGuard VPN` UDP sockets from the `en0` address to + the current public server on port 443. + +The detector therefore does not interpret the `utun` peer `127.1.1.1`, the +tunnel address, the public exit IP, or an arbitrary static route as the outer +VPN endpoint. + +Relevant platform documentation: + +- [NEPacketTunnelProvider](https://developer.apple.com/documentation/networkextension/nepackettunnelprovider) +- [TN3120: Expected use cases for Network Extension packet tunnel providers](https://developer.apple.com/documentation/technotes/tn3120-expected-use-cases-for-network-extension-packet-tunnel-providers) +- [TN3165: Packet Filter is not API](https://developer.apple.com/documentation/technotes/tn3165-packet-filter-is-not-api) + +## Detection + +`network::VpnInfo` carries the selected tunnel, tunnel addresses, physical +interface and addresses, routes, VPN type, service name, and one or more outer +endpoints with transport and port. + +The active `utunN` is scored from broad routes whose gateway and output +interface are that `utun`, plus a configured tunnel IPv4 address. A link-local +`utun` alone is not considered an active VPN. Endpoint sources are: + +1. known VPN-provider sockets bound to a physical interface address; +2. `wg show all endpoints`; +3. connected `scutil --nc show` `RemoteAddress` values. + +An unknown endpoint never produces a physical-interface Internet allow rule. + +## PF lifecycle + +The main configuration contains only an attachment point: + + anchor "killswitch" + +It is placed before other filter-anchor calls. Allowed packets receive the +unique `KILLSWITCH_ALLOWED` tag and continue into later system anchors, while +the inverse-tagged direct block is `quick`. This prevents an earlier system +`pass quick` from bypassing the kill switch without making allowed VPN packets +bypass later system filtering. + +Normal enable, reload, and disable operations target only that anchor: + + pfctl -a killswitch -f /var/run/killswitch.pf.conf + pfctl -a killswitch -F rules + +PF is acquired and released with `-E`/`-X` reference tokens. No code path uses +`-Fa`, `-F all`, `-F states`, or disables PF globally. + +Because PF evaluates an established state before evaluating new filter rules, +initial enable and path changes terminate only states sourced from the current +physical local IPv4/IPv6 addresses (`pfctl -k
`). The operation is +needed to prevent a pre-existing direct connection from bypassing a newly +loaded anchor; it does not flush the state table. + +## Dynamic fail-closed behavior + +The monitor runs every two seconds. It replaces the tunnel allow rule after a +route change and removes it when the VPN disappears. The last currently +observed provider endpoints remain narrowly allowed so the provider can create +a new tunnel. When a new blocked connection attempt changes the provider +socket endpoint, `lsof` exposes the remote address before traffic succeeds; +the next monitor pass replaces the endpoint rule. + +The monitor keeps no wildcard physical Internet exception. A missing tunnel, +endpoint, physical path, malformed route table, or transient detection failure +therefore results in fewer allow rules, not more. + +## PF packet-path diagnosis + +Generated labels make counter inspection unambiguous: + + sudo pfctl -a killswitch -vvsr + sudo pfctl -ss + +Capture both layers while generating traffic: + + sudo tcpdump -ni en0 + sudo tcpdump -ni utun4 + +The `killswitch-vpn` counter should increase for application packets on the +tunnel, `killswitch-endpoint` for encapsulated packets on `en0`, and +`killswitch-direct-block` for attempted leaks. `pfctl -sr` intentionally shows +only the anchor call; use `-a killswitch` (or recursive `-a '*'`) for its rules. diff --git a/README.md b/README.md index 974bcb3..7ac7d96 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,106 @@ # killswitch -VPN kill switch for macOS. Blocks all outgoing traffic when the VPN connection -drops, preventing your real IP from leaking. +Fail-closed VPN kill switch for macOS. It supports dynamic Network Extension +packet tunnels such as AdGuard VPN as well as WireGuard and Tailscale. ## How it works -When enabled, killswitch loads [pf](https://docs.freebsd.org/en/books/handbook/firewalls/#firewalls-pf) firewall -rules that only allow traffic through the VPN tunnel. If the VPN disconnects, -the tunnel interface disappears but the firewall rules remain — blocking all -internet traffic until the VPN reconnects or the kill switch is disabled. - -Rules are written to `/tmp/killswitch.pf.conf` and loaded with `pfctl`. -The system default `/etc/pf.conf` is never modified. +macOS Network Extension VPNs do not necessarily expose their remote server as +a routing-table gateway. Killswitch therefore detects the two parts of the VPN +path independently: + +- the active `utunN` is selected from its tunnel addresses and routed prefixes; +- the physical path comes from the default route (for example, `en0`); +- the outer endpoint, protocol, and port come from sockets owned by a known VPN + provider process (for example, `AdGuard VPN` using UDP/443). + +The generated policy permits loopback, DHCP, the selected VPN endpoint on the +physical interface, and traffic on only the selected tunnel. Its final rule +blocks all other outbound IPv4 and IPv6 traffic. If detection is uncertain, no +tunnel allow rule is emitted. + +Rules are loaded only into the `killswitch` PF anchor. The program never runs +`pfctl -Fa` or flushes another anchor. On first use only, if `/etc/pf.conf` has +no `anchor "killswitch"` attachment point, killswitch validates and adds that +single line, saves `/etc/pf.conf.killswitch.backup`, and reloads `/etc/pf.conf` +without a flush flag. If the line exists after another filter anchor, it is +moved before those anchors so their `quick` rules cannot bypass the kill +switch. Allowed packets are tagged and continue through later system anchors; +only disallowed direct traffic terminates evaluation with `block quick`. + +When enabled, a small root monitor checks the route, tunnel and provider +sockets every two seconds. A reconnect from `utun4` to `utun5`, or a change of +VPN server endpoint, reloads only the killswitch anchor. When the tunnel +disappears, its allow rule is removed while the last observed VPN endpoint is +kept so the provider can reconnect. ## Usage -Show network interfaces, public IP, and detected VPN peer: +Show the detected VPN path: + + killswitch -vv - $ killswitch +Preview the exact PF anchor rules: -Enable the kill switch (requires root): + killswitch --print -vv - $ sudo killswitch -e +Enable the kill switch and monitor: -Disable and restore default firewall rules: + sudo killswitch -e -v - $ sudo killswitch -d +Show anchor counters or disable it: -Print the firewall rules without applying them: + sudo killswitch --status + sudo killswitch -d -v - $ killswitch --print +`--ipv4 ` remains available for compatibility, but it is not required for +AdGuard VPN when its provider socket is visible. A manual endpoint permits TCP +and UDP to that IP because the legacy flag has no protocol or port information. ### Options | Flag | Description | |------|-------------| -| `--leak` | Allow ICMP (ping) and DNS requests outside the VPN | -| `--local` | Allow local network traffic | -| `--ipv4 ` | Manually specify the VPN peer IP (auto-detected if omitted) | +| `-e`, `--enable` | Enable the anchor and dynamic monitor | +| `-d`, `--disable` | Stop the monitor and flush only this anchor | +| `-s`, `--status` | Show rules and packet counters for this anchor | +| `-p`, `--print` | Print rules without applying them | +| `--local` | Permit traffic within the physical interface's local network | +| `--leak` | Explicitly permit direct DNS and ICMP (reduces leak protection) | +| `--ipv4 ` | Legacy manual public IPv4 endpoint override | | `-v`, `-vv` | Verbose / debug output | -### Examples - -Enable with DNS leak and local network access: - - $ sudo killswitch -e --leak --local - -Specify the VPN peer IP manually: - - $ sudo killswitch -e --ipv4 203.0.113.1 +## macOS verification -Preview rules in debug mode: +PF's main `-sr` view shows the anchor call, not the nested rules. Inspect the +anchor and its counters explicitly: - $ killswitch --print --leak -vv + sudo pfctl -a killswitch -vvsr + sudo pfctl -ss -## VPN detection +Observe the inner and outer paths in separate terminals: -The VPN gateway IP is auto-detected using multiple methods (in order): + sudo tcpdump -ni en0 + sudo tcpdump -ni utun4 -1. **sysctl** — reads the kernel routing table directly -2. **netstat** — parses routes with `UGSH`/`UGSc` flags -3. **scutil** — queries macOS Network Extension services (works with WireGuard, ProtonVPN, etc.) -4. **ifconfig** — extracts peer addresses from tunnel interfaces +Test both address families. With the VPN connected, IPv4 must show the VPN +address; IPv6 must show a VPN address or time out. With the VPN disconnected +while killswitch remains enabled, both commands must time out: -If auto-detection fails, use `--ipv4` to specify the VPN peer IP manually. + curl -4 --max-time 10 https://api.ipify.org + curl -6 --max-time 10 https://api64.ipify.org -## Build from source +Apple documents PF as a legacy, unsupported API for third-party products. This +project therefore validates the generated rules before loading them and keeps +all normal updates scoped to its anchor, but final packet-path verification is +still required on each supported macOS release. -Requires [Rust](https://www.rust-lang.org/tools/install): +## Build and test - $ cargo build --release - $ sudo cp target/release/killswitch /usr/local/bin/ + just test + cargo build --release --locked -### Development +The ignored real-PF parser test is non-mutating but requires root: - $ just test # format check + clippy + tests - $ just fmt # check formatting - $ just clippy # lint all targets + sudo env CARGO_TARGET_DIR=/private/tmp/killswitch-root-tests \ + cargo test test_real_pf_parser_integration -- --ignored diff --git a/src/cli/actions/mod.rs b/src/cli/actions/mod.rs index 15ffd78..a16c99d 100644 --- a/src/cli/actions/mod.rs +++ b/src/cli/actions/mod.rs @@ -26,6 +26,7 @@ pub enum Action { ShowInterfaces { verbose: Verbosity, }, + Monitor, } impl Action { diff --git a/src/cli/actions/run.rs b/src/cli/actions/run.rs index 38db9e6..8b4cf87 100644 --- a/src/cli/actions/run.rs +++ b/src/cli/actions/run.rs @@ -63,6 +63,8 @@ pub fn execute(action: &Action) -> Result<()> { let output = killswitch::show_interfaces(*verbose)?; print!("{output}"); } + + Action::Monitor => killswitch::monitor()?, } Ok(()) diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 717bb0e..f0ca88c 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -91,6 +91,13 @@ pub fn new() -> Command { .help("Increase output verbosity (-v: verbose, -vv: debug)") .action(ArgAction::Count), ) + .arg( + Arg::new("monitor") + .long("monitor") + .hide(true) + .action(ArgAction::SetTrue) + .conflicts_with_all(["enable", "disable", "status", "print"]), + ) } #[cfg(test)] diff --git a/src/cli/dispatch/mod.rs b/src/cli/dispatch/mod.rs index 60f81f5..b38eea3 100644 --- a/src/cli/dispatch/mod.rs +++ b/src/cli/dispatch/mod.rs @@ -11,8 +11,11 @@ pub fn handler(matches: &ArgMatches, verbose: Verbosity) -> Result { let disable = matches.get_flag("disable"); let status = matches.get_flag("status"); let print = matches.get_flag("print"); + let monitor = matches.get_flag("monitor"); - if enable { + if monitor { + Ok(Action::Monitor) + } else if enable { let ipv4 = matches.get_one::("ipv4").map(String::from); let leak = matches.get_flag("leak"); let local = matches.get_flag("local"); diff --git a/src/killswitch/mod.rs b/src/killswitch/mod.rs index 6a65da7..59837c7 100644 --- a/src/killswitch/mod.rs +++ b/src/killswitch/mod.rs @@ -4,172 +4,336 @@ mod rules; use crate::cli::verbosity::Verbosity; use anyhow::{Context, Result, bail}; +use network::{Transport, VpnEndpoint, VpnInfo}; +use std::collections::BTreeSet; +use std::fs::{self, OpenOptions}; +use std::io::Write as _; +use std::net::{IpAddr, Ipv4Addr}; +use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; +use std::os::unix::process::CommandExt as _; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::Duration; -/// Check if an IP address is in a private/reserved range (RFC 1918, loopback, link-local) +const MONITOR_CONFIG_PATH: &str = "/var/run/killswitch.monitor.conf"; +const MONITOR_PID_PATH: &str = "/var/run/killswitch.monitor.pid"; +const MONITOR_INTERVAL: Duration = Duration::from_secs(2); + +#[derive(Clone, Debug, PartialEq, Eq)] +struct MonitorConfig { + leak: bool, + local: bool, + manual_endpoint: Option, +} + +/// Check whether an IPv4 address is private or locally scoped. #[must_use] -pub fn is_private_ip(ip: &std::net::Ipv4Addr) -> bool { - let o = ip.octets(); - // 10.0.0.0/8 - o[0] == 10 - // 172.16.0.0/12 - || (o[0] == 172 && (16..=31).contains(&o[1])) - // 192.168.0.0/16 - || (o[0] == 192 && o[1] == 168) - // 127.0.0.0/8 (loopback) - || o[0] == 127 - // 169.254.0.0/16 (link-local) - || (o[0] == 169 && o[1] == 254) +pub fn is_private_ip(ip: &Ipv4Addr) -> bool { + let octets = ip.octets(); + octets[0] == 10 + || (octets[0] == 172 && (16..=31).contains(&octets[1])) + || (octets[0] == 192 && octets[1] == 168) + || octets[0] == 127 + || (octets[0] == 169 && octets[1] == 254) } fn check_root() -> Result<()> { - let euid = unsafe { libc::geteuid() }; - if euid != 0 { + let effective_user = unsafe { libc::geteuid() }; + if effective_user != 0 { bail!("This operation requires root privileges. Try: sudo killswitch"); } Ok(()) } -fn validate_ipv4(ip: &str) -> Result<()> { - use std::net::IpAddr; - let addr: IpAddr = ip.parse().context("Invalid IP address")?; - let IpAddr::V4(v4) = addr else { - bail!("IPv6 addresses are not supported: {ip}"); +fn validate_manual_endpoint(value: &str) -> Result { + let address: IpAddr = value.parse().context("Invalid IP address")?; + let IpAddr::V4(address) = address else { + bail!("--ipv4 accepts an IPv4 endpoint only: {value}"); }; - if is_private_ip(&v4) { - bail!("{ip} is a private/reserved IP address. VPN peer must be a public IP"); + if is_private_ip(&address) { + bail!("{value} is a private/reserved IP address. VPN endpoint must be public"); } - Ok(()) + Ok(address) } -/// Resolve the VPN peer IP from user input or auto-detection -fn resolve_vpn_ip(ipv4: Option<&str>, verbose: Verbosity) -> Result { - if let Some(ip) = ipv4 { - validate_ipv4(ip)?; +fn detect_with_override(ipv4: Option<&str>, verbose: Verbosity) -> Result { + let mut info = network::detect_vpn(verbose); + if let Some(value) = ipv4 { + let address = validate_manual_endpoint(value)?; + info.endpoints = vec![VpnEndpoint { + address: IpAddr::V4(address), + port: None, + transport: Transport::Any, + }]; if verbose.is_debug() { - eprintln!(" Using provided VPN gateway: {ip}"); + eprintln!(" Using the manual compatibility endpoint: {address}"); } - Ok(ip.to_string()) - } else { - if verbose.is_verbose() { - eprintln!(" Auto-detecting VPN gateway address..."); - } - network::detect_vpn_gateway(verbose) } + Ok(info) +} + +fn physical_addresses(info: &VpnInfo) -> Vec { + info.physical_ipv4 + .iter() + .copied() + .map(IpAddr::V4) + .chain(info.physical_ipv6.iter().copied().map(IpAddr::V6)) + .collect() } -/// Enable the VPN kill switch +/// Enable the kill switch and start the interface/endpoint monitor. /// /// # Errors -/// Returns an error if: -/// - Not running with root privileges -/// - VPN gateway address cannot be detected (when not provided) -/// - Firewall rules cannot be generated or applied +/// Returns an error if privileges, detection, PF validation, or monitor startup fails. pub fn enable(leak: bool, local: bool, ipv4: Option<&str>, verbose: Verbosity) -> Result<()> { check_root()?; + let info = detect_with_override(ipv4, verbose)?; + let generated = rules::generate(&info, leak, local)?; + pf::apply_rules(&generated, &physical_addresses(&info), true, verbose)?; - let vpn_ip = resolve_vpn_ip(ipv4, verbose)?; - - if verbose.is_debug() { - eprintln!(" VPN gateway: {vpn_ip}"); - eprintln!(" Generating firewall rules..."); - } - - let rules_content = rules::generate(&vpn_ip, leak, local, verbose)?; - - if verbose.is_debug() { - eprintln!(" Applying rules to pf..."); - } - - pf::apply_rules(&rules_content, verbose)?; - + let config = MonitorConfig { + leak, + local, + manual_endpoint: ipv4.map(validate_manual_endpoint).transpose()?, + }; + write_monitor_config(&config)?; + restart_monitor(verbose)?; Ok(()) } -/// Disable the VPN kill switch +/// Disable only the dedicated anchor and stop its monitor. /// /// # Errors -/// Returns an error if: -/// - Not running with root privileges -/// - Firewall rules cannot be removed +/// Returns an error if privileges or the scoped PF cleanup fails. pub fn disable(verbose: Verbosity) -> Result<()> { check_root()?; - pf::disable(verbose)?; - Ok(()) + stop_monitor(verbose)?; + pf::disable(verbose) } -/// Get the current status of the VPN kill switch +/// Return PF rules and counters for the dedicated anchor. /// /// # Errors -/// Returns an error if the firewall status cannot be queried -#[must_use = "status returns the current state which should be displayed or checked"] +/// Returns an error if PF status cannot be queried. +#[must_use = "status returns user-facing text"] pub fn status() -> Result { pf::status() } -/// Generate firewall rules without applying them +/// Generate the exact anchor rules without applying them. /// /// # Errors -/// Returns an error if: -/// - VPN gateway address cannot be detected (when not provided) -/// - Rules cannot be generated +/// Returns an error if a manual endpoint is invalid or rules cannot be formatted. pub fn generate_rules( leak: bool, local: bool, ipv4: Option<&str>, verbose: Verbosity, ) -> Result { - let vpn_ip = resolve_vpn_ip(ipv4, verbose)?; - - rules::generate(&vpn_ip, leak, local, verbose) + let info = detect_with_override(ipv4, verbose)?; + rules::generate(&info, leak, local) } -/// Show active network interfaces, VPN peer IP, and usage hints. -/// Mirrors the Go (master) default behavior. +/// Show the detection result used by rule generation. /// /// # Errors -/// Returns an error if interface detection fails +/// Returns an error if the user-facing report cannot be produced. pub fn show_interfaces(verbose: Verbosity) -> Result { - use std::fmt::Write; + let info = network::detect_vpn(verbose); + let mut output = network::describe(&info); + output.push_str("\nTraffic policy when enabled:\n"); + output.push_str(" selected VPN interface: allowed\n"); + output.push_str(" direct IPv4/IPv6: blocked\n"); + output.push_str("\nTo enable: sudo killswitch -e\n"); + output.push_str("To disable: sudo killswitch -d\n"); + Ok(output) +} - let interfaces = network::get_interfaces()?; +fn write_monitor_config(config: &MonitorConfig) -> Result<()> { + let manual = config + .manual_endpoint + .map_or_else(String::new, |address| address.to_string()); + let contents = format!( + "leak={}\nlocal={}\nmanual_endpoint={manual}\n", + u8::from(config.leak), + u8::from(config.local) + ); + let mut options = OpenOptions::new(); + options.write(true).create(true).truncate(true).mode(0o600); + let mut file = options + .open(MONITOR_CONFIG_PATH) + .context("Failed to write monitor configuration")?; + file.write_all(contents.as_bytes()) + .context("Failed to write monitor configuration")?; + file.sync_all() + .context("Failed to sync monitor configuration")?; + Ok(()) +} - if interfaces.is_empty() { - bail!("No active interfaces found, verify you are connected to the network"); - } +fn read_monitor_config() -> Result { + let contents = + fs::read_to_string(MONITOR_CONFIG_PATH).context("Failed to read monitor configuration")?; + parse_monitor_config(&contents) +} - let mut out = String::new(); - let _ = writeln!(out, "Interface MAC address IP"); +fn parse_monitor_config(contents: &str) -> Result { + let mut leak = false; + let mut local = false; + let mut manual_endpoint = None; + for line in contents.lines() { + let Some((key, value)) = line.split_once('=') else { + continue; + }; + match key { + "leak" => leak = value == "1", + "local" => local = value == "1", + "manual_endpoint" if !value.is_empty() => { + manual_endpoint = Some(validate_manual_endpoint(value)?); + } + _ => {} + } + } + Ok(MonitorConfig { + leak, + local, + manual_endpoint, + }) +} - let has_vpn = interfaces.iter().any(network::InterfaceInfo::is_p2p); +fn restart_monitor(verbose: Verbosity) -> Result<()> { + stop_monitor(verbose)?; + let executable = + std::env::current_exe().context("Failed to locate the killswitch executable")?; + let mut command = Command::new(executable); + command + .arg("--monitor") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let child = command + .spawn() + .context("Failed to start the killswitch monitor")?; + fs::write(MONITOR_PID_PATH, format!("{}\n", child.id())) + .context("Failed to save monitor PID")?; + fs::set_permissions(MONITOR_PID_PATH, fs::Permissions::from_mode(0o600)) + .context("Failed to protect monitor PID file")?; + if verbose.is_verbose() { + eprintln!(" VPN monitor started (PID {})", child.id()); + } + Ok(()) +} - for iface in &interfaces { - let _ = writeln!( - out, - "{:<10} {:<19} {}", - iface.name(), - iface.mac(), - iface.ip() - ); +fn stop_monitor(verbose: Verbosity) -> Result<()> { + if let Ok(contents) = fs::read_to_string(MONITOR_PID_PATH) + && let Ok(pid) = contents.trim().parse::() + && process_is_monitor(pid) + { + let result = unsafe { libc::kill(pid, libc::SIGTERM) }; + if result != 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::ESRCH) { + return Err(error).context("Failed to stop the killswitch monitor"); + } + } else if verbose.is_debug() { + eprintln!(" Stopped VPN monitor PID {pid}"); + } } + if Path::new(MONITOR_PID_PATH).exists() { + fs::remove_file(MONITOR_PID_PATH).context("Failed to remove monitor PID file")?; + } + Ok(()) +} - // Show public IP - if let Ok(public_ip) = network::get_public_ip() { - let _ = writeln!(out, "\nPublic IP address: \x1b[0;31m{public_ip}\x1b[0m"); +fn process_is_monitor(pid: libc::pid_t) -> bool { + let output = Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "command="]) + .output(); + output.is_ok_and(|value| { + value.status.success() + && String::from_utf8_lossy(&value.stdout).contains("killswitch --monitor") + }) +} + +/// Hidden monitor entry point. It always removes a stale tunnel allow rule +/// when detection becomes uncertain, while retaining only observed VPN server +/// endpoints so the provider can reconnect. +/// +/// # Errors +/// Returns an error if privileges or the persisted monitor configuration is invalid. +pub fn monitor() -> Result<()> { + check_root()?; + let config = read_monitor_config()?; + let mut known_endpoints = BTreeSet::new(); + if let Some(address) = config.manual_endpoint { + known_endpoints.insert(VpnEndpoint { + address: IpAddr::V4(address), + port: None, + transport: Transport::Any, + }); } + let mut previous = fs::read_to_string("/var/run/killswitch.pf.conf").unwrap_or_default(); - // Try to detect VPN peer IP - match network::detect_vpn_gateway(verbose) { - Ok(peer) => { - let _ = writeln!(out, "PEER IP address: \x1b[0;33m{peer}\x1b[0m"); + loop { + let mut info = network::detect_vpn(Verbosity::Normal); + if config.manual_endpoint.is_none() && !info.endpoints.is_empty() { + known_endpoints = info.endpoints.iter().cloned().collect(); } - Err(_) if !has_vpn => { - let _ = writeln!(out, "\nNo VPN interface found, verify VPN is connected"); + info.endpoints = known_endpoints.iter().cloned().collect(); + if let Ok(generated) = rules::generate(&info, config.leak, config.local) + && generated != previous + && pf::apply_rules( + &generated, + &physical_addresses(&info), + true, + Verbosity::Normal, + ) + .is_ok() + { + previous = generated; } - Err(_) => {} + thread::sleep(MONITOR_INTERVAL); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_private_ipv4_detection() { + assert!(is_private_ip(&Ipv4Addr::new(10, 0, 0, 1))); + assert!(is_private_ip(&Ipv4Addr::new(172, 31, 255, 255))); + assert!(is_private_ip(&Ipv4Addr::new(192, 168, 1, 1))); + assert!(!is_private_ip(&Ipv4Addr::new(203, 0, 113, 1))); } - let _ = writeln!(out, "\nTo enable the kill switch run: sudo killswitch -e"); - let _ = writeln!(out, "To disable: sudo killswitch -d"); + #[test] + fn test_monitor_config_round_trip_parser() { + let config = parse_monitor_config("leak=0\nlocal=1\nmanual_endpoint=216.211.192.107\n") + .unwrap_or(MonitorConfig { + leak: true, + local: false, + manual_endpoint: None, + }); + assert!(!config.leak); + assert!(config.local); + assert_eq!( + config.manual_endpoint, + Some(Ipv4Addr::new(216, 211, 192, 107)) + ); + } - Ok(out) + #[test] + fn test_monitor_config_rejects_private_manual_endpoint() { + assert!(parse_monitor_config("manual_endpoint=192.168.1.1\n").is_err()); + } } diff --git a/src/killswitch/network.rs b/src/killswitch/network.rs index f51741e..c2d184f 100644 --- a/src/killswitch/network.rs +++ b/src/killswitch/network.rs @@ -1,684 +1,726 @@ -//! Network detection utilities for VPN kill switch. +//! Detection of the active VPN path on macOS. //! -//! This module provides functions to detect: -//! - VPN peer IP (the remote server's public IP address) -//! - Active network interfaces -//! - Public IP address +//! A Network Extension packet tunnel does not necessarily install a host route +//! for its remote server. Its provider normally opens a socket bound to the +//! physical path instead. Consequently the tunnel interface and the outer +//! endpoint are detected independently: routes identify the active `utun`, and +//! VPN-owned sockets identify the endpoint that must remain reachable. use crate::cli::verbosity::Verbosity; use crate::killswitch::is_private_ip; use anyhow::{Context, Result, bail}; -use std::net::{IpAddr, ToSocketAddrs}; +use std::fmt; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs as _}; use std::process::Command; -// ============================================================================ -// VPN Peer IP Detection -// ============================================================================ - -/// Detect the VPN server's public IP address (the remote peer endpoint). -/// -/// This is the IP that firewall rules must allow traffic to in order to keep -/// the VPN tunnel alive. Not to be confused with: -/// - Local tunnel IP (e.g., `10.8.0.2`) - your address inside the tunnel -/// - Tunnel gateway (e.g., `10.8.0.1`) - the server's address inside the tunnel -/// -/// Detection methods tried in order: -/// 1. netstat - Parse routing table for UGSH/UGSc routes (most reliable) -/// 2. `WireGuard` - Query `wg show` for endpoint IPs -/// 3. Tailscale - Query `tailscale status` for exit node -/// 4. scutil - Query macOS Network Extension VPN services -/// -/// # Errors -/// Returns an error if no VPN peer IP can be detected. -pub fn detect_vpn_peer(verbose: Verbosity) -> Result { - // Method 1: netstat routing table (most reliable for traditional VPNs) - if verbose.is_debug() { - eprintln!(" Trying netstat routing table..."); - } - if let Ok(peer) = detect_peer_from_netstat(verbose) { - return Ok(peer); - } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum VpnType { + WireGuard, + Tailscale, + MacOsNetworkExtension, + Unknown, +} - // Method 2: WireGuard - if verbose.is_debug() { - eprintln!(" Trying WireGuard (wg show)..."); - } - if let Ok(peer) = detect_peer_from_wireguard(verbose) { - return Ok(peer); +impl fmt::Display for VpnType { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::WireGuard => "WireGuard", + Self::Tailscale => "Tailscale", + Self::MacOsNetworkExtension => "macOS Network Extension", + Self::Unknown => "unknown", + }) } +} - // Method 3: Tailscale - if verbose.is_debug() { - eprintln!(" Trying Tailscale..."); - } - if let Ok(peer) = detect_peer_from_tailscale(verbose) { - return Ok(peer); - } +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Transport { + Tcp, + Udp, + Any, +} - // Method 4: macOS scutil (Network Extension VPNs) - if verbose.is_debug() { - eprintln!(" Trying scutil (macOS Network Extension)..."); - } - if let Ok(peer) = detect_peer_from_scutil(verbose) { - return Ok(peer); - } +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct VpnEndpoint { + pub address: IpAddr, + pub port: Option, + pub transport: Transport, +} - bail!("Could not detect VPN peer IP. Please specify it manually with --ipv4") +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RouteInfo { + pub destination: String, + pub gateway: String, + pub flags: String, + pub interface: String, } -/// Detect VPN peer IP from netstat routing table. -/// -/// Looks for routes with UGSH (Up, Gateway, Static, Host) or `UGSc` flags. -/// These routes point directly to the VPN server's public IP. -fn detect_peer_from_netstat(verbose: Verbosity) -> Result { - let output = Command::new("netstat") - .args(["-rn", "-f", "inet"]) - .output() - .context("Failed to execute netstat")?; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VpnInfo { + pub vpn_type: VpnType, + pub interface: Option, + pub tunnel_ipv4: Option, + pub tunnel_ipv6: Vec, + pub endpoints: Vec, + pub physical_interface: Option, + pub physical_ipv4: Vec, + pub physical_ipv6: Vec, + pub routes: Vec, + pub service: Option, +} - if !output.status.success() { - bail!("netstat command failed"); +impl VpnInfo { + #[must_use] + pub const fn is_connected(&self) -> bool { + self.interface.is_some() } +} - let stdout = String::from_utf8_lossy(&output.stdout); +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct InterfaceData { + name: String, + mac: String, + ipv4: Vec, + ipv6: Vec, + point_to_point: bool, +} - // Look for routes with UGSH or UGSc flags - // Format: "Destination Gateway Flags Netif Expire" - // For UGSH routes, Destination is the VPN server's public IP - for line in stdout.lines() { - if !line.contains("UGSH") && !line.contains("UGSc") { - continue; +/// Detect the physical path, active VPN interface and outer endpoint sockets. +/// +/// Partial command failures produce a conservative result. In particular, +/// an uncertain tunnel is reported as disconnected so rule generation fails +/// closed rather than allowing every `utun` interface. +pub fn detect_vpn(verbose: Verbosity) -> VpnInfo { + let ifconfig = command_text("ifconfig", &[]).unwrap_or_default(); + let routes4 = command_text("netstat", &["-rn", "-f", "inet"]).unwrap_or_default(); + let routes6 = command_text("netstat", &["-rn", "-f", "inet6"]).unwrap_or_default(); + let default_route = command_text("route", &["-n", "get", "default"]).unwrap_or_default(); + let lsof = command_text("/usr/sbin/lsof", &["-nP", "-iTCP", "-iUDP", "-F", "pcPnT"]) + .unwrap_or_default(); + let wireguard = command_text("wg", &["show", "all", "endpoints"]).unwrap_or_default(); + let tailscale = command_text("tailscale", &["status", "--json"]).unwrap_or_default(); + let scutil_list = command_text("scutil", &["--nc", "list"]).unwrap_or_default(); + let scutil = scutil_details(&scutil_list); + + let info = detect_from_outputs(DetectionOutputs { + ifconfig: &ifconfig, + routes4: &routes4, + routes6: &routes6, + default_route: &default_route, + lsof: &lsof, + wireguard: &wireguard, + tailscale: &tailscale, + scutil: &scutil, + }); + + if verbose.is_verbose() { + if info.is_connected() + && let Some(interface) = &info.interface + { + eprintln!(" VPN interface: {interface} ({})", info.vpn_type); + } else { + eprintln!(" No active VPN interface; using fail-closed policy"); } - - if let Some(peer_ip) = extract_route_destination(line) { - if is_valid_vpn_peer(&peer_ip) { - if verbose.is_verbose() { - eprintln!(" Detected VPN peer via netstat: {peer_ip}"); - } - return Ok(peer_ip); - } else if verbose.is_debug() { - eprintln!(" Skipping non-public route destination: {peer_ip}"); + if let Some(interface) = &info.physical_interface { + eprintln!(" Physical interface: {interface}"); + } + if info.endpoints.is_empty() { + eprintln!(" VPN endpoint: unknown (direct traffic remains blocked)"); + } else { + for endpoint in &info.endpoints { + eprintln!( + " VPN endpoint: {}{} ({:?})", + endpoint.address, + endpoint + .port + .map_or_else(String::new, |port| format!(":{port}")), + endpoint.transport + ); } } } - bail!("No VPN peer found in routing table") + info } -/// Detect VPN peer IP from `WireGuard`. -/// -/// Parses `wg show` output for endpoint addresses. -fn detect_peer_from_wireguard(verbose: Verbosity) -> Result { - let output = Command::new("wg") - .args(["show"]) - .output() - .context("Failed to execute wg show")?; - - if !output.status.success() { - bail!("wg show command failed (WireGuard not installed or no tunnels active)"); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - - // Look for "endpoint: :" lines - for line in stdout.lines() { - let trimmed = line.trim(); - if let Some(endpoint) = trimmed.strip_prefix("endpoint:") { - let endpoint = endpoint.trim(); - // Extract IP from "IP:port" format - if let Some(ip) = endpoint.split(':').next() - && is_valid_vpn_peer(ip) - { - if verbose.is_verbose() { - eprintln!(" Detected VPN peer via WireGuard: {ip}"); - } - return Ok(ip.to_string()); - } +fn scutil_details(list: &str) -> String { + let mut details = String::new(); + for line in list.lines().filter(|line| line.contains("(Connected)")) { + let Some(identifier) = line.split_whitespace().nth(2) else { + continue; + }; + if let Ok(output) = command_text("scutil", &["--nc", "show", identifier]) { + details.push_str(&output); + details.push('\n'); } } - - bail!("No WireGuard endpoint found") + details } -/// Detect VPN peer IP from Tailscale. -/// -/// Queries `tailscale status` for exit node information. -fn detect_peer_from_tailscale(verbose: Verbosity) -> Result { - // First check if using an exit node - let output = Command::new("tailscale") - .args(["status", "--json"]) +fn command_text(program: &str, args: &[&str]) -> Result { + let output = Command::new(program) + .args(args) .output() - .context("Failed to execute tailscale status")?; - + .with_context(|| format!("Failed to execute {program}"))?; if !output.status.success() { - bail!("tailscale status command failed"); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - - // Simple JSON parsing for ExitNodeStatus.Online and TailscaleIPs - // Looking for exit node's public IP in the DERP relay or direct connection - if !stdout.contains("\"ExitNodeStatus\"") { - bail!("No Tailscale exit node active"); - } - - // Try to find the exit node's IP from peer list - // This is a simplified approach - full JSON parsing would be more robust - for line in stdout.lines() { - let trimmed = line.trim(); - // Look for public IPs in the output that could be exit node endpoints - if trimmed.contains("\"CurAddr\"") - && let Some(start) = trimmed.find(':') - && let Some(addr_part) = trimmed.get(start + 1..) - { - let addr = addr_part.trim().trim_matches('"').trim_matches(','); - // Extract IP from "IP:port" format - if let Some(ip) = addr.split(':').next() - && is_valid_vpn_peer(ip) - { - if verbose.is_verbose() { - eprintln!(" Detected VPN peer via Tailscale: {ip}"); - } - return Ok(ip.to_string()); - } - } + bail!("{program} exited unsuccessfully"); } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} - bail!("No Tailscale exit node peer found") +#[derive(Clone, Copy)] +struct DetectionOutputs<'a> { + ifconfig: &'a str, + routes4: &'a str, + routes6: &'a str, + default_route: &'a str, + lsof: &'a str, + wireguard: &'a str, + tailscale: &'a str, + scutil: &'a str, } -/// Detect VPN peer IP via macOS Network Extension (scutil). -/// -/// Works for VPN apps that use macOS Network Extension framework -/// (e.g., `NordVPN`, `ProtonVPN`, Fortinet). -fn detect_peer_from_scutil(verbose: Verbosity) -> Result { - let list_output = Command::new("scutil") - .args(["--nc", "list"]) - .output() - .context("Failed to execute scutil --nc list")?; +fn detect_from_outputs(outputs: DetectionOutputs<'_>) -> VpnInfo { + let interfaces = parse_ifconfig(outputs.ifconfig); + let mut routes = parse_routes(outputs.routes4); + routes.extend(parse_routes(outputs.routes6)); + + let physical_interface = parse_default_interface(outputs.default_route) + .or_else(|| default_interface_from_routes(&routes)); + let physical = physical_interface + .as_deref() + .and_then(|name| interfaces.iter().find(|interface| interface.name == name)); + let physical_ipv4 = physical.map_or_else(Vec::new, |value| value.ipv4.clone()); + let physical_ipv6 = physical.map_or_else(Vec::new, |value| { + value + .ipv6 + .iter() + .copied() + .filter(|address| !is_link_local_v6(*address)) + .collect() + }); + + let active_name = select_active_tunnel(&interfaces, &routes); + let active = active_name + .as_deref() + .and_then(|name| interfaces.iter().find(|interface| interface.name == name)); + + let mut endpoints = parse_lsof_endpoints(outputs.lsof, &physical_ipv4, &physical_ipv6); + endpoints.extend(parse_wireguard_endpoints(outputs.wireguard)); + endpoints.extend(parse_scutil_remote_addresses(outputs.scutil)); + endpoints.sort(); + endpoints.dedup(); + + let service = parse_lsof_service(outputs.lsof); + let vpn_type = if !outputs.wireguard.trim().is_empty() { + VpnType::WireGuard + } else if outputs.tailscale.contains("\"ExitNodeStatus\"") + || service + .as_deref() + .is_some_and(|name| contains_folded(name, "tailscale")) + { + VpnType::Tailscale + } else if active_name + .as_deref() + .is_some_and(|name| name.starts_with("utun")) + { + VpnType::MacOsNetworkExtension + } else { + VpnType::Unknown + }; - if !list_output.status.success() { - bail!("scutil --nc list failed"); + VpnInfo { + vpn_type, + interface: active_name, + tunnel_ipv4: active.and_then(|value| value.ipv4.first().copied()), + tunnel_ipv6: active.map_or_else(Vec::new, |value| value.ipv6.clone()), + endpoints, + physical_interface, + physical_ipv4, + physical_ipv6, + routes, + service, } +} - let stdout = String::from_utf8_lossy(&list_output.stdout); +fn parse_ifconfig(input: &str) -> Vec { + let mut interfaces = Vec::new(); + let mut current: Option = None; - for line in stdout.lines() { - if !line.contains("(Connected)") { + for line in input.lines() { + if !line.starts_with([' ', '\t']) && line.contains(": flags=") { + if let Some(interface) = current.take() { + interfaces.push(interface); + } + let name = line.split(':').next().unwrap_or_default().to_string(); + if !line.contains("UP") || line.contains("LOOPBACK") { + current = None; + } else { + current = Some(InterfaceData { + name, + point_to_point: line.contains("POINTOPOINT"), + ..InterfaceData::default() + }); + } continue; } - // Extract UUID: "* (Connected) VPN ..." - let Some(uuid) = line.split_whitespace().nth(2) else { + let Some(interface) = current.as_mut() else { continue; }; - - if verbose.is_debug() { - eprintln!(" Found connected VPN service: {uuid}"); - } - - let show_output = Command::new("scutil") - .args(["--nc", "show", uuid]) - .output() - .context("Failed to execute scutil --nc show")?; - - if !show_output.status.success() { - continue; - } - - let detail = String::from_utf8_lossy(&show_output.stdout); - - // Look for "RemoteAddress : [:]" - for detail_line in detail.lines() { - let trimmed = detail_line.trim(); - if let Some(raw) = trimmed.strip_prefix("RemoteAddress : ") { - let raw = raw.trim(); - let host = strip_port(raw); - - // Resolve hostname to IP if needed - let resolved = if host.parse::().is_ok() { - host.to_string() - } else { - match resolve_hostname_v4(host, verbose) { - Some(ip) => ip, - None => continue, - } - }; - - if is_valid_vpn_peer(&resolved) { - if verbose.is_verbose() { - eprintln!(" Detected VPN peer via scutil: {resolved}"); - } - return Ok(resolved); - } else if verbose.is_debug() { - eprintln!(" Skipping non-public RemoteAddress: {resolved}"); - } + let trimmed = line.trim(); + if let Some(value) = trimmed.strip_prefix("ether ") { + interface.mac = value.trim().to_string(); + } else if let Some(value) = trimmed.strip_prefix("inet ") { + if let Some(raw) = value.split_whitespace().next() + && let Ok(address) = raw.parse::() + { + interface.ipv4.push(address); } + } else if let Some(value) = trimmed.strip_prefix("inet6 ") + && let Some(raw) = value.split_whitespace().next() + && let Some(address) = raw.split('%').next() + && let Ok(address) = address.parse::() + { + interface.ipv6.push(address); } } - bail!("No VPN peer found via scutil") -} - -/// Strip optional port suffix from a remote address. -/// -/// Handles `host:port`, `ip:port`, and `[ipv6]:port` forms. -/// Returns the bare host/IP. -fn strip_port(raw: &str) -> &str { - if let Some(rest) = raw.strip_prefix('[') { - // [ipv6]:port — extract content between brackets - rest.split(']').next().unwrap_or(raw) - } else if raw.matches(':').count() == 1 { - // host:port or ipv4:port — split on the single colon - raw.split(':').next().unwrap_or(raw) - } else { - // bare IP, bare hostname, or bare IPv6 (multiple colons, no brackets) - raw + if let Some(interface) = current { + interfaces.push(interface); } + interfaces } -/// Resolve a hostname to its first IPv4 address. -fn resolve_hostname_v4(host: &str, verbose: Verbosity) -> Option { - if verbose.is_debug() { - eprintln!(" Resolving hostname: {host}"); - } - match format!("{host}:0").to_socket_addrs() { - Ok(addrs) => { - if let Some(addr) = addrs.into_iter().find(std::net::SocketAddr::is_ipv4) { - Some(addr.ip().to_string()) - } else { - if verbose.is_debug() { - eprintln!(" No IPv4 address for: {host}"); - } - None +fn parse_routes(input: &str) -> Vec { + input + .lines() + .filter_map(|line| { + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 4 || fields.first().is_some_and(|value| *value == "Destination") { + return None; } - } - Err(e) => { - if verbose.is_debug() { - eprintln!(" DNS resolution failed for {host}: {e}"); + let interface = fields.get(3)?; + if !is_interface_name(interface) { + return None; } - None - } - } + Some(RouteInfo { + destination: fields.first()?.to_string(), + gateway: fields.get(1)?.to_string(), + flags: fields.get(2)?.to_string(), + interface: (*interface).to_string(), + }) + }) + .collect() } -/// Extract destination IP from netstat routing table line. -/// -/// Format: "Destination Gateway Flags Netif Expire" -/// For UGSH/UGSc routes, the Destination column contains the VPN server's public IP. -fn extract_route_destination(line: &str) -> Option { - let parts: Vec<&str> = line.split_whitespace().collect(); - let destination = parts.first()?; - - // Validate it's an IP address (not "default" or a network name) - if destination.parse::().is_ok() { - Some((*destination).to_string()) - } else { - None - } +fn is_interface_name(value: &str) -> bool { + value.starts_with("en") + || value.starts_with("utun") + || value.starts_with("lo") + || value.starts_with("bridge") + || value.starts_with("awdl") + || value.starts_with("llw") } -/// Check if an IP is a valid VPN peer (public, routable IPv4 address). -fn is_valid_vpn_peer(ip: &str) -> bool { - let Ok(addr) = ip.parse::() else { - return false; - }; - - let IpAddr::V4(ipv4) = addr else { - return false; // Only IPv4 supported for now - }; - - let octets = ipv4.octets(); - - // Reject special addresses used by VPN routing tricks - if ip == "0.0.0.0" || ip == "128.0.0.0" { - return false; - } - - // Reject private/reserved ranges - if is_private_ip(&ipv4) { - return false; - } - - // Reject broadcast - if octets == [255, 255, 255, 255] { - return false; - } - - // Reject multicast (224.0.0.0/4) and reserved (240.0.0.0/4) - if octets[0] >= 224 { - return false; - } - - true +fn parse_default_interface(input: &str) -> Option { + input.lines().find_map(|line| { + let trimmed = line.trim(); + trimmed + .strip_prefix("interface:") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) } -// ============================================================================ -// Network Interface Detection -// ============================================================================ - -/// Represents a detected network interface. -pub struct InterfaceInfo { - name: String, - mac: String, - ip: String, - is_p2p: bool, +fn default_interface_from_routes(routes: &[RouteInfo]) -> Option { + routes + .iter() + .find(|route| route.destination == "default" && !route.interface.starts_with("utun")) + .map(|route| route.interface.clone()) } -impl InterfaceInfo { - /// Get the interface name (e.g., "en0", "utun0"). - #[must_use] - pub fn name(&self) -> &str { - &self.name - } - - /// Get the MAC address (empty for virtual interfaces). - #[must_use] - pub fn mac(&self) -> &str { - &self.mac - } - - /// Get the IP address (may include CIDR notation for non-P2P interfaces). - #[must_use] - pub fn ip(&self) -> &str { - &self.ip - } - - /// Check if this is a point-to-point (VPN) interface. - #[must_use] - pub fn is_p2p(&self) -> bool { - self.is_p2p - } +fn select_active_tunnel(interfaces: &[InterfaceData], routes: &[RouteInfo]) -> Option { + interfaces + .iter() + .filter(|interface| interface.point_to_point && interface.name.starts_with("utun")) + .filter_map(|interface| { + let route_score: usize = routes + .iter() + .filter(|route| { + route.interface == interface.name + && route.gateway == interface.name + && is_tunnel_route(&route.destination, &route.flags) + }) + .map(|route| { + if route.destination == "default" { + 100 + } else { + 10 + } + }) + .sum(); + let address_score = usize::from(!interface.ipv4.is_empty()) * 50; + let score = route_score + address_score; + (score >= 30).then_some((score, interface.name.clone())) + }) + .max_by(std::cmp::Ord::cmp) + .map(|(_, name)| name) } -/// Discover active network interfaces (up, non-loopback, IPv4). -/// -/// Returns both regular interfaces and point-to-point (VPN) interfaces. -/// -/// # Errors -/// Returns an error if ifconfig fails to execute. -pub fn get_interfaces() -> Result> { - let output = Command::new("ifconfig") - .output() - .context("Failed to execute ifconfig")?; - - if !output.status.success() { - bail!("ifconfig command failed"); +fn is_tunnel_route(destination: &str, flags: &str) -> bool { + if destination.starts_with("fe80") || destination.starts_with("ff") { + return false; } + destination != "default" || !flags.contains('I') +} - let stdout = String::from_utf8_lossy(&output.stdout); - let mut interfaces = Vec::new(); - let mut current_name = String::new(); - let mut current_mac = String::new(); - let mut current_is_p2p = false; - - for line in stdout.lines() { - // New interface block: "en0: flags=8863 ..." - if !line.starts_with('\t') && !line.starts_with(' ') && line.contains(": flags=") { - current_name = line.split(':').next().unwrap_or("").to_string(); - current_mac = String::new(); - - let is_up = line.contains("UP"); - let is_loopback = line.contains("LOOPBACK"); - current_is_p2p = line.contains("POINTOPOINT"); - - if !is_up || is_loopback { - current_name.clear(); - } - continue; - } - - if current_name.is_empty() { +fn parse_lsof_endpoints( + input: &str, + physical_ipv4: &[Ipv4Addr], + physical_ipv6: &[Ipv6Addr], +) -> Vec { + let physical: Vec = physical_ipv4 + .iter() + .copied() + .map(IpAddr::V4) + .chain(physical_ipv6.iter().copied().map(IpAddr::V6)) + .collect(); + let mut command = String::new(); + let mut transport = Transport::Any; + let mut endpoints = Vec::new(); + + for line in input.lines() { + let Some((tag, value)) = line.split_at_checked(1) else { continue; - } - - let trimmed = line.trim(); - - // MAC address: "ether aa:bb:cc:dd:ee:ff" - if let Some(mac) = trimmed.strip_prefix("ether ") { - current_mac = mac.trim().to_string(); - } - - // IPv4: "inet 192.168.1.100 netmask 0xffffff00 broadcast ..." - if trimmed.starts_with("inet ") && !trimmed.starts_with("inet6") { - let parts: Vec<&str> = trimmed.split_whitespace().collect(); - if let Some(ip) = parts.get(1) { - // Skip loopback IPs - if ip.starts_with("127.") { + }; + match tag { + "p" => { + command.clear(); + transport = Transport::Any; + } + "c" => command = value.to_string(), + "P" => { + transport = match value { + "TCP" => Transport::Tcp, + "UDP" => Transport::Udp, + _ => Transport::Any, + }; + } + "n" if is_vpn_process(&command) => { + let Some((local, remote)) = value.split_once("->") else { continue; - } - - let ip_display = if current_is_p2p { - (*ip).to_string() - } else if let Some(mask_pos) = parts.iter().position(|&s| s == "netmask") - && let Some(mask_hex) = parts.get(mask_pos + 1) - && let Some(cidr) = hex_to_cidr(mask_hex) - { - format!("{ip}/{cidr}") - } else { - (*ip).to_string() }; - - interfaces.push(InterfaceInfo { - name: current_name.clone(), - mac: current_mac.clone(), - ip: ip_display, - is_p2p: current_is_p2p, - }); + let Some((local_address, _)) = parse_socket_address(local) else { + continue; + }; + let Some((remote_address, port)) = parse_socket_address(remote) else { + continue; + }; + if physical.contains(&local_address) && is_public_endpoint(remote_address) { + endpoints.push(VpnEndpoint { + address: remote_address, + port, + transport, + }); + } } + _ => {} } } - - Ok(interfaces) + endpoints } -// ============================================================================ -// Public IP Detection -// ============================================================================ - -/// Get the public IP address by querying external HTTP services. -/// -/// Tries multiple services with a 5-second timeout each. -/// -/// # Errors -/// Returns an error if all services fail or return invalid responses. -pub fn get_public_ip() -> Result { - const SERVICES: &[&str] = &[ - "https://ifconfig.me/ip", - "https://api.ipify.org", - "https://checkip.amazonaws.com", - ]; - - for url in SERVICES { - if let Ok(output) = Command::new("curl").args(["-s", "-m", "5", url]).output() - && output.status.success() - { - let ip = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if ip.parse::().is_ok() { - return Ok(ip); - } - } - } - - bail!("Failed to detect public IP") +fn parse_lsof_service(input: &str) -> Option { + input.lines().find_map(|line| { + line.strip_prefix('c') + .filter(|name| is_vpn_process(name)) + .map(str::to_string) + }) } -// ============================================================================ -// Utilities -// ============================================================================ - -/// Convert a hex netmask (e.g., "0xffffff00") to CIDR notation (e.g., 24). -#[must_use] -pub fn hex_to_cidr(hex: &str) -> Option { - let hex = hex.strip_prefix("0x")?; - let value = u32::from_str_radix(hex, 16).ok()?; - u8::try_from(value.count_ones()).ok() +fn is_vpn_process(name: &str) -> bool { + let folded = name.to_ascii_lowercase(); + [ + "adguard vpn", + "com.adguard", + "wireguard", + "tailscale", + "openvpn", + "protonvpn", + "nordvpn", + ] + .iter() + .any(|needle| folded.contains(needle)) } -// ============================================================================ -// Legacy Compatibility -// ============================================================================ - -/// Alias for `detect_vpn_peer` to maintain backward compatibility. -/// -/// # Errors -/// Returns an error if no VPN peer IP can be detected. -pub fn detect_vpn_gateway(verbose: Verbosity) -> Result { - detect_vpn_peer(verbose) +fn contains_folded(value: &str, needle: &str) -> bool { + value.to_ascii_lowercase().contains(needle) } -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; +fn parse_socket_address(value: &str) -> Option<(IpAddr, Option)> { + if let Some(bracketed) = value.strip_prefix('[') { + let (host, suffix) = bracketed.split_once(']')?; + let address = host.split('%').next()?.parse::().ok()?; + let port = suffix.strip_prefix(':').and_then(|raw| raw.parse().ok()); + return Some((address, port)); + } + let (host, port) = value.rsplit_once(':')?; + Some((host.parse::().ok()?, port.parse::().ok())) +} - // ------------------------------------------------------------------------- - // Route destination extraction tests - // ------------------------------------------------------------------------- +fn parse_wireguard_endpoints(input: &str) -> Vec { + input + .lines() + .filter_map(|line| { + let raw = line.split_whitespace().last()?; + if raw == "(none)" { + return None; + } + let (address, port) = parse_socket_address(raw)?; + is_public_endpoint(address).then_some(VpnEndpoint { + address, + port, + transport: Transport::Udp, + }) + }) + .collect() +} - #[test] - fn test_extract_route_destination_ugsh() { - let line = "52.1.2.3 192.168.1.1 UGSH en0"; - assert_eq!( - extract_route_destination(line), - Some("52.1.2.3".to_string()) - ); - } +fn parse_scutil_remote_addresses(input: &str) -> Vec { + input + .lines() + .filter_map(|line| { + let raw = line.trim().strip_prefix("RemoteAddress : ")?; + let (host, port) = split_host_and_port(raw); + let address = host.parse::().ok().or_else(|| resolve_host(host))?; + is_public_endpoint(address).then_some(VpnEndpoint { + address, + port, + transport: Transport::Any, + }) + }) + .collect() +} - #[test] - fn test_extract_route_destination_ugsc() { - let line = "203.0.113.50 10.0.0.1 UGSc en0"; - assert_eq!( - extract_route_destination(line), - Some("203.0.113.50".to_string()) +fn split_host_and_port(value: &str) -> (&str, Option) { + if let Some(bracketed) = value.strip_prefix('[') + && let Some((host, suffix)) = bracketed.split_once(']') + { + return ( + host, + suffix.strip_prefix(':').and_then(|raw| raw.parse().ok()), ); } - - #[test] - fn test_extract_route_destination_default_returns_none() { - let line = "default 192.168.1.1 UGSc en0"; - assert_eq!(extract_route_destination(line), None); - } - - #[test] - fn test_extract_route_destination_reads_first_column() { - // Verify we read column 0 (destination), not column 1 (gateway) - let line = "8.8.8.8 192.168.1.1 UGSH en0"; - assert_eq!(extract_route_destination(line), Some("8.8.8.8".to_string())); + if value.matches(':').count() == 1 + && let Some((host, raw_port)) = value.rsplit_once(':') + && let Ok(port) = raw_port.parse::() + { + return (host, Some(port)); } + (value, None) +} - // ------------------------------------------------------------------------- - // VPN peer validation tests - // ------------------------------------------------------------------------- +fn resolve_host(host: &str) -> Option { + (host, 0) + .to_socket_addrs() + .ok()? + .map(|socket| socket.ip()) + .find(|address| matches!(address, IpAddr::V4(_))) +} - #[test] - fn test_is_valid_vpn_peer_public_ips() { - assert!(is_valid_vpn_peer("8.8.8.8")); - assert!(is_valid_vpn_peer("1.1.1.1")); - assert!(is_valid_vpn_peer("52.1.2.3")); - assert!(is_valid_vpn_peer("203.0.113.50")); +fn is_public_endpoint(address: IpAddr) -> bool { + match address { + IpAddr::V4(value) => { + !is_private_ip(&value) + && value != Ipv4Addr::UNSPECIFIED + && value != Ipv4Addr::BROADCAST + && value.octets()[0] < 224 + } + IpAddr::V6(value) => { + !value.is_unspecified() + && !value.is_loopback() + && !value.is_multicast() + && !is_link_local_v6(value) + && value.segments()[0] & 0xfe00 != 0xfc00 + } } +} - #[test] - fn test_is_valid_vpn_peer_rejects_private() { - assert!(!is_valid_vpn_peer("10.0.0.1")); - assert!(!is_valid_vpn_peer("10.8.0.1")); // Common OpenVPN tunnel - assert!(!is_valid_vpn_peer("172.16.0.1")); - assert!(!is_valid_vpn_peer("192.168.1.1")); - assert!(!is_valid_vpn_peer("127.0.0.1")); - assert!(!is_valid_vpn_peer("169.254.1.1")); - } +fn is_link_local_v6(address: Ipv6Addr) -> bool { + address.segments()[0] & 0xffc0 == 0xfe80 +} - #[test] - fn test_is_valid_vpn_peer_rejects_special() { - assert!(!is_valid_vpn_peer("0.0.0.0")); - assert!(!is_valid_vpn_peer("128.0.0.0")); // VPN routing trick - assert!(!is_valid_vpn_peer("255.255.255.255")); +/// Display information for the default CLI action. +pub fn describe(info: &VpnInfo) -> String { + use std::fmt::Write as _; + let mut output = String::new(); + let _ = writeln!(output, "VPN type: {}", info.vpn_type); + let _ = writeln!( + output, + "VPN interface: {}", + info.interface.as_deref().unwrap_or("not detected") + ); + let _ = writeln!( + output, + "Tunnel IPv4: {}", + info.tunnel_ipv4 + .map_or_else(|| "none".to_string(), |ip| ip.to_string()) + ); + let _ = writeln!( + output, + "Physical interface: {}", + info.physical_interface.as_deref().unwrap_or("not detected") + ); + if info.endpoints.is_empty() { + let _ = writeln!(output, "VPN endpoint: unknown"); + } else { + for endpoint in &info.endpoints { + let _ = writeln!( + output, + "VPN endpoint: {}{} {:?}", + endpoint.address, + endpoint + .port + .map_or_else(String::new, |port| format!(":{port}")), + endpoint.transport + ); + } } + output +} - #[test] - fn test_is_valid_vpn_peer_rejects_multicast() { - assert!(!is_valid_vpn_peer("224.0.0.1")); - assert!(!is_valid_vpn_peer("239.255.255.255")); - } +#[cfg(test)] +mod tests { + use super::*; - #[test] - fn test_is_valid_vpn_peer_boundary_private_ranges() { - // 172.16-31.x.x range boundaries - assert!(!is_valid_vpn_peer("172.16.0.1")); - assert!(!is_valid_vpn_peer("172.31.255.255")); - assert!(is_valid_vpn_peer("172.15.255.255")); - assert!(is_valid_vpn_peer("172.32.0.1")); + const EN0: &str = r"en0: flags=8863 mtu 1500 + ether aa:bb:cc:dd:ee:ff + inet 192.168.1.66 netmask 0xffffff00 broadcast 192.168.1.255 + inet6 2a00:1370:817c:4a82::66 prefixlen 64 +"; + const UTUN4: &str = r"utun4: flags=8051 mtu 1500 + inet 172.16.209.2 --> 127.1.1.1 netmask 0xffffffff + inet6 fd00::2 prefixlen 64 +"; + const ROUTES4: &str = r"Destination Gateway Flags Netif Expire +default 192.168.1.254 UGScg en0 +1 utun4 USc utun4 +2/7 utun4 USc utun4 +64/2 utun4 USc utun4 +"; + + fn outputs<'a>(ifconfig: &'a str, routes4: &'a str, lsof: &'a str) -> DetectionOutputs<'a> { + DetectionOutputs { + ifconfig, + routes4, + routes6: "", + default_route: "interface: en0", + lsof, + wireguard: "", + tailscale: "", + scutil: "", + } } #[test] - fn test_is_valid_vpn_peer_rejects_ipv6() { - assert!(!is_valid_vpn_peer("::1")); - assert!(!is_valid_vpn_peer("2001:db8::1")); + fn test_vpn_not_found() { + let info = detect_from_outputs(outputs(EN0, "malformed", "")); + assert!(!info.is_connected()); + assert!(info.endpoints.is_empty()); } #[test] - fn test_is_valid_vpn_peer_rejects_invalid() { - assert!(!is_valid_vpn_peer("not-an-ip")); - assert!(!is_valid_vpn_peer("")); - assert!(!is_valid_vpn_peer("256.1.1.1")); + fn test_utun4_found_with_ipv4_and_ipv6() { + let input = format!("{EN0}{UTUN4}"); + let info = detect_from_outputs(outputs(&input, ROUTES4, "")); + assert_eq!(info.interface.as_deref(), Some("utun4")); + assert_eq!(info.tunnel_ipv4, Some(Ipv4Addr::new(172, 16, 209, 2))); + assert!( + info.tunnel_ipv6 + .contains(&"fd00::2".parse().unwrap_or(Ipv6Addr::LOCALHOST)) + ); + assert_eq!(info.vpn_type, VpnType::MacOsNetworkExtension); } - // ------------------------------------------------------------------------- - // Hex to CIDR conversion tests - // ------------------------------------------------------------------------- - #[test] - fn test_hex_to_cidr() { - assert_eq!(hex_to_cidr("0xffffffff"), Some(32)); - assert_eq!(hex_to_cidr("0xffffff00"), Some(24)); - assert_eq!(hex_to_cidr("0xffff0000"), Some(16)); - assert_eq!(hex_to_cidr("0xff000000"), Some(8)); - assert_eq!(hex_to_cidr("0x00000000"), Some(0)); + fn test_reconnect_selects_utun5() { + let utun5 = UTUN4.replace("utun4", "utun5"); + let routes = ROUTES4.replace("utun4", "utun5"); + let input = format!("{EN0}{utun5}"); + let info = detect_from_outputs(outputs(&input, &routes, "")); + assert_eq!(info.interface.as_deref(), Some("utun5")); } #[test] - fn test_hex_to_cidr_invalid() { - assert_eq!(hex_to_cidr("invalid"), None); - assert_eq!(hex_to_cidr("ffffff00"), None); // Missing 0x prefix - assert_eq!(hex_to_cidr(""), None); + fn test_endpoint_known_from_adguard_socket() { + let input = format!("{EN0}{UTUN4}"); + let lsof = concat!( + "p811\ncAdGuard VPN\nPUDP\n", + "n192.168.1.66:54794->216.211.192.107:443\n", + "n192.168.1.66:54795->216.211.192.107:443\n", + ); + let info = detect_from_outputs(outputs(&input, ROUTES4, lsof)); + // Ephemeral source ports must not become part of the PF exception: + // the provider retries from a new port after a blocked first SYN. + assert_eq!(info.endpoints.len(), 1); + assert_eq!( + info.endpoints.first(), + Some(&VpnEndpoint { + address: IpAddr::V4(Ipv4Addr::new(216, 211, 192, 107)), + port: Some(443), + transport: Transport::Udp, + }) + ); } - // ------------------------------------------------------------------------- - // Port stripping tests - // ------------------------------------------------------------------------- - #[test] - fn test_strip_port_bare_ipv4() { - assert_eq!(strip_port("1.2.3.4"), "1.2.3.4"); + fn test_endpoint_unknown_is_preserved_as_empty() { + let input = format!("{EN0}{UTUN4}"); + let info = detect_from_outputs(outputs(&input, ROUTES4, "")); + assert!(info.endpoints.is_empty()); } #[test] - fn test_strip_port_ipv4_with_port() { - assert_eq!(strip_port("1.2.3.4:51820"), "1.2.3.4"); + fn test_physical_interface_en0_and_global_addresses() { + let info = detect_from_outputs(outputs(EN0, ROUTES4, "")); + assert_eq!(info.physical_interface.as_deref(), Some("en0")); + assert_eq!(info.physical_ipv4, [Ipv4Addr::new(192, 168, 1, 66)]); + assert_eq!(info.physical_ipv6.len(), 1); } #[test] - fn test_strip_port_hostname_with_port() { - assert_eq!(strip_port("myvpn.example.com:51820"), "myvpn.example.com"); + fn test_malformed_routing_table_does_not_select_unrelated_utun() { + let unrelated = "utun0: flags=8051 mtu 1500\n inet6 fe80::1%utun0 prefixlen 64\n"; + let input = format!("{EN0}{unrelated}"); + let info = detect_from_outputs(outputs(&input, "bad route data", "")); + assert!(info.interface.is_none()); } #[test] - fn test_strip_port_bare_hostname() { - assert_eq!(strip_port("myvpn.example.com"), "myvpn.example.com"); + fn test_multiple_utun_selects_routed_tunnel() { + let unrelated = "utun0: flags=8051 mtu 1500\n inet6 fe80::1%utun0 prefixlen 64\n"; + let input = format!("{EN0}{unrelated}{UTUN4}"); + let info = detect_from_outputs(outputs(&input, ROUTES4, "")); + assert_eq!(info.interface.as_deref(), Some("utun4")); } #[test] - fn test_strip_port_ipv6_bracketed_with_port() { - assert_eq!(strip_port("[::1]:51820"), "::1"); + fn test_non_vpn_process_socket_is_not_endpoint() { + let lsof = "p99\ncBrowser\nPTCP\nn192.168.1.66:50000->203.0.113.1:443\n"; + let info = detect_from_outputs(outputs(EN0, ROUTES4, lsof)); + assert!(info.endpoints.is_empty()); } #[test] - fn test_strip_port_bare_ipv6() { - // Bare IPv6 has multiple colons, no brackets — returned as-is - assert_eq!(strip_port("2001:db8::1"), "2001:db8::1"); + fn test_wireguard_ipv4_and_ipv6_endpoints() { + let endpoints = + parse_wireguard_endpoints("wg0 key 203.0.113.8:51820\nwg1 key [2001:db8::8]:51820\n"); + assert_eq!(endpoints.len(), 2); + assert!( + endpoints + .iter() + .all(|endpoint| endpoint.transport == Transport::Udp) + ); } } diff --git a/src/killswitch/pf.rs b/src/killswitch/pf.rs index 2327ef6..bd894cf 100644 --- a/src/killswitch/pf.rs +++ b/src/killswitch/pf.rs @@ -1,148 +1,366 @@ use crate::cli::verbosity::Verbosity; use anyhow::{Context, Result, bail}; -use std::fs; -use std::io::Write; -use std::path::Path; +use std::fs::{self, OpenOptions}; +use std::io::Write as _; +use std::net::IpAddr; +use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; +use std::path::{Path, PathBuf}; use std::process::Command; -const PF_RULES_PATH: &str = "/tmp/killswitch.pf.conf"; +const ANCHOR: &str = "killswitch"; +const PF_RULES_PATH: &str = "/var/run/killswitch.pf.conf"; const PF_SYSTEM_CONF: &str = "/etc/pf.conf"; +const PF_SYSTEM_BACKUP: &str = "/etc/pf.conf.killswitch.backup"; +const PF_TOKEN_PATH: &str = "/var/run/killswitch.pf.token"; +const ANCHOR_MARKER: &str = "# killswitch anchor (managed by killswitch)"; -pub fn apply_rules(rules: &str, verbose: Verbosity) -> Result<()> { - if verbose.is_debug() { - eprintln!(" Writing rules to {PF_RULES_PATH}"); - } +pub fn apply_rules( + rules: &str, + physical_addresses: &[IpAddr], + terminate_direct_states: bool, + verbose: Verbosity, +) -> Result<()> { + ensure_anchor_reference(verbose)?; + write_rules(rules)?; + validate_rules(PF_RULES_PATH)?; + ensure_pf_enabled(verbose)?; - let mut file = - fs::File::create(PF_RULES_PATH).context("Failed to create killswitch rules file")?; - file.write_all(rules.as_bytes()) - .context("Failed to write rules")?; + run_pfctl( + &["-a", ANCHOR, "-f", PF_RULES_PATH], + "load killswitch anchor", + )?; - if verbose.is_debug() { - eprintln!(" Rules written"); + if terminate_direct_states { + kill_states_from(physical_addresses, verbose)?; + } + if verbose.is_verbose() { + eprintln!(" Loaded PF anchor: {ANCHOR}"); } + Ok(()) +} - enable_pf(verbose)?; +fn write_rules(rules: &str) -> Result<()> { + let mut options = OpenOptions::new(); + options.write(true).create(true).truncate(true).mode(0o600); + let mut file = options + .open(PF_RULES_PATH) + .context("Failed to create the killswitch anchor file")?; + file.write_all(rules.as_bytes()) + .context("Failed to write the killswitch anchor file")?; + file.sync_all() + .context("Failed to sync the killswitch anchor file")?; + Ok(()) +} - // Flush all rules and load killswitch rules +fn validate_rules(path: &str) -> Result<()> { + run_pfctl( + &["-n", "-a", ANCHOR, "-f", path], + "validate killswitch anchor", + )?; + Ok(()) +} + +fn ensure_pf_enabled(verbose: Verbosity) -> Result<()> { + if Path::new(PF_TOKEN_PATH).exists() { + return Ok(()); + } let output = Command::new("pfctl") - .args(["-Fa", "-f", PF_RULES_PATH]) + .arg("-E") .output() - .context("Failed to execute pfctl")?; - + .context("Failed to execute pfctl -E")?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - bail!("Failed to load rules: {stderr}"); + bail!( + "Failed to acquire a PF enable reference: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); } - - if verbose.is_verbose() { - eprintln!(" Firewall rules applied"); + let combined = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let token = parse_pf_token(&combined) + .context("pfctl -E succeeded but did not return an enable-reference token")?; + fs::write(PF_TOKEN_PATH, format!("{token}\n")) + .context("Failed to save the PF enable-reference token")?; + fs::set_permissions(PF_TOKEN_PATH, fs::Permissions::from_mode(0o600)) + .context("Failed to protect the PF token file")?; + if verbose.is_debug() { + eprintln!(" Acquired PF enable reference"); } + Ok(()) +} +fn parse_pf_token(output: &str) -> Option { + output.lines().find_map(|line| { + let (_, value) = line.split_once("Token")?; + let token = value.trim_start_matches([' ', ':']).trim(); + (!token.is_empty()).then(|| token.to_string()) + }) +} + +fn kill_states_from(addresses: &[IpAddr], verbose: Verbosity) -> Result<()> { + for address in addresses { + if verbose.is_debug() { + eprintln!(" Terminating pre-existing direct states from {address}"); + } + run_pfctl( + &["-k", &address.to_string()], + "terminate pre-existing direct PF states", + )?; + } Ok(()) } -fn enable_pf(verbose: Verbosity) -> Result<()> { - if verbose.is_debug() { - eprintln!(" Enabling pf"); +fn ensure_anchor_reference(verbose: Verbosity) -> Result<()> { + let config = fs::read_to_string(PF_SYSTEM_CONF).context("Failed to read /etc/pf.conf")?; + let updated = normalize_anchor_reference(&config); + if updated == config { + return activate_anchor_reference_if_needed(verbose); } - let output = Command::new("pfctl") - .args(["-e"]) - .output() - .context("Failed to execute pfctl -e")?; + if verbose.is_verbose() { + eprintln!(" Placing the killswitch attachment point before other PF filter anchors"); + } + if !Path::new(PF_SYSTEM_BACKUP).exists() { + fs::copy(PF_SYSTEM_CONF, PF_SYSTEM_BACKUP) + .context("Failed to create the one-time /etc/pf.conf backup")?; + } - // pfctl -e returns exit code 1 if pf is already enabled, which is fine - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - if !stderr.contains("already enabled") { - bail!("Failed to enable pf: {stderr}"); - } + let temporary = temporary_pf_conf_path(); + let result = install_pf_conf(&temporary, &updated); + if temporary.exists() { + let _ = fs::remove_file(&temporary); } + result?; - if verbose.is_debug() { - eprintln!(" pf enabled"); + // Reloading the main configuration is required only once to create the + // anchor attachment point. Unlike the old implementation this does not + // use -F or flush all anchors/states. + run_pfctl( + &["-f", PF_SYSTEM_CONF], + "activate killswitch anchor reference", + )?; + verify_live_anchor_reference() +} + +fn activate_anchor_reference_if_needed(verbose: Verbosity) -> Result<()> { + let live = run_pfctl(&["-sr"], "inspect the main PF ruleset")?; + if has_anchor_reference(&live) { + return Ok(()); + } + if verbose.is_verbose() { + eprintln!(" Activating the configured killswitch anchor attachment point"); } + run_pfctl( + &["-f", PF_SYSTEM_CONF], + "activate configured killswitch anchor reference", + )?; + verify_live_anchor_reference() +} +fn verify_live_anchor_reference() -> Result<()> { + let live = run_pfctl(&["-sr"], "verify the killswitch anchor attachment point")?; + if !has_anchor_reference(&live) { + bail!("The killswitch anchor is configured but is not attached to the live PF ruleset"); + } Ok(()) } -pub fn disable(verbose: Verbosity) -> Result<()> { - if verbose.is_debug() { - eprintln!(" Restoring system pf rules"); - } +fn install_pf_conf(temporary: &Path, contents: &str) -> Result<()> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true).mode(0o644); + let mut file = options + .open(temporary) + .context("Failed to create a temporary PF configuration")?; + file.write_all(contents.as_bytes()) + .context("Failed to write a temporary PF configuration")?; + file.sync_all() + .context("Failed to sync a temporary PF configuration")?; + let path = temporary + .to_str() + .context("Temporary PF configuration path is not valid UTF-8")?; + run_pfctl(&["-n", "-f", path], "validate updated /etc/pf.conf")?; + fs::rename(temporary, PF_SYSTEM_CONF).context("Failed to install updated /etc/pf.conf")?; + Ok(()) +} + +fn temporary_pf_conf_path() -> PathBuf { + PathBuf::from(format!("/etc/.pf.conf.killswitch.{}", std::process::id())) +} - // Clean up any leftover anchor references in pf.conf from older versions - cleanup_legacy_anchor(verbose)?; +fn has_anchor_reference(contents: &str) -> bool { + contents.lines().any(is_killswitch_anchor_line) +} - enable_pf(verbose)?; +fn is_killswitch_anchor_line(line: &str) -> bool { + let code = line.split('#').next().unwrap_or_default().trim(); + code.starts_with("anchor") && code.contains(&format!("\"{ANCHOR}\"")) +} + +fn is_filter_anchor_line(line: &str) -> bool { + let code = line.split('#').next().unwrap_or_default().trim(); + code.starts_with("anchor ") && !is_killswitch_anchor_line(line) +} + +fn normalize_anchor_reference(contents: &str) -> String { + let mut lines = Vec::new(); + let mut inserted = false; + for line in contents.lines() { + if is_killswitch_anchor_line(line) || line.trim() == ANCHOR_MARKER { + continue; + } + if !inserted && is_filter_anchor_line(line) { + lines.push(ANCHOR_MARKER); + lines.push("anchor \"killswitch\""); + inserted = true; + } + lines.push(line); + } + if !inserted { + if lines.last().is_some_and(|line| !line.is_empty()) { + lines.push(""); + } + lines.push(ANCHOR_MARKER); + lines.push("anchor \"killswitch\""); + } + let mut normalized = lines.join("\n"); + normalized.push('\n'); + normalized +} - // Flush all and reload system default rules +fn run_pfctl(args: &[&str], action: &str) -> Result { let output = Command::new("pfctl") - .args(["-Fa", "-f", PF_SYSTEM_CONF]) + .args(args) .output() - .context("Failed to execute pfctl")?; - + .with_context(|| format!("Failed to execute pfctl while trying to {action}"))?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - bail!("Failed to restore system rules: {stderr}"); + bail!( + "Failed to {action}: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} - // Clean up rules file +pub fn disable(verbose: Verbosity) -> Result<()> { + run_pfctl( + &["-a", ANCHOR, "-F", "rules"], + "flush killswitch anchor rules", + )?; + + if let Ok(token) = fs::read_to_string(PF_TOKEN_PATH) { + let token = token.trim(); + if !token.is_empty() { + run_pfctl(&["-X", token], "release the killswitch PF enable reference")?; + } + fs::remove_file(PF_TOKEN_PATH).context("Failed to remove the PF token file")?; + } if Path::new(PF_RULES_PATH).exists() { - fs::remove_file(PF_RULES_PATH).context("Failed to remove rules file")?; + fs::remove_file(PF_RULES_PATH).context("Failed to remove the runtime anchor file")?; } - if verbose.is_verbose() { - eprintln!(" Firewall rules removed"); + eprintln!(" Flushed only the killswitch anchor; other PF anchors were preserved"); } - Ok(()) } -fn cleanup_legacy_anchor(verbose: Verbosity) -> Result<()> { - let conf = fs::read_to_string(PF_SYSTEM_CONF).context("Failed to read pf.conf")?; - if !conf.contains("killswitch") { - return Ok(()); +pub fn status() -> Result { + let main = run_pfctl(&["-sr"], "read the main PF ruleset")?; + if !has_anchor_reference(&main) { + return Ok("VPN kill switch: DISABLED (anchor is not attached)".to_string()); } - - if verbose.is_verbose() { - eprintln!(" Removing legacy killswitch anchor from pf.conf"); + let rules = run_pfctl(&["-a", ANCHOR, "-sr"], "read killswitch anchor rules")?; + if rules.trim().is_empty() { + return Ok("VPN kill switch: DISABLED".to_string()); } + let counters = run_pfctl(&["-a", ANCHOR, "-vvsr"], "read killswitch counters")?; + Ok(format!("VPN kill switch: ENABLED\n\n{counters}")) +} - let cleaned: String = conf - .lines() - .filter(|line| !line.contains("killswitch")) - .collect::>() - .join("\n") - + "\n"; +#[cfg(test)] +mod tests { + use super::*; - fs::write(PF_SYSTEM_CONF, cleaned).context("Failed to clean pf.conf")?; - Ok(()) -} + #[test] + fn test_anchor_reference_detection() { + assert!(has_anchor_reference("anchor \"killswitch\"\n")); + assert!(!has_anchor_reference("# anchor \"killswitch\"\n")); + assert!(!has_anchor_reference("anchor \"com.apple/*\"\n")); + } -pub fn status() -> Result { - let output = Command::new("pfctl") - .args(["-sr"]) - .output() - .context("Failed to execute pfctl")?; + #[test] + fn test_anchor_reference_is_added_before_system_filter_anchors() { + let original = "scrub-anchor \"com.apple/*\"\nanchor \"com.apple/*\"\n"; + let updated = normalize_anchor_reference(original); + assert!(updated.contains(ANCHOR_MARKER)); + let killswitch = updated + .lines() + .position(|line| line == "anchor \"killswitch\""); + let apple = updated + .lines() + .position(|line| line == "anchor \"com.apple/*\""); + assert!( + killswitch + .zip(apple) + .is_some_and(|(left, right)| left < right) + ); + } - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - bail!("Failed to get status: {stderr}"); + #[test] + fn test_anchor_reference_is_reordered_idempotently() { + let original = "anchor \"com.apple/*\"\nanchor \"killswitch\"\n"; + let updated = normalize_anchor_reference(original); + assert_eq!(normalize_anchor_reference(&updated), updated); + assert_eq!(updated.matches("anchor \"killswitch\"").count(), 1); } - let stdout = String::from_utf8_lossy(&output.stdout); + #[test] + fn test_pf_enable_token_parsing() { + assert_eq!( + parse_pf_token("pf enabled\nToken : 123456789\n"), + Some("123456789".to_string()) + ); + assert_eq!(parse_pf_token("pf enabled"), None); + } - // If killswitch rules file exists and pf has rules beyond defaults, it's enabled - let has_killswitch = Path::new(PF_RULES_PATH).exists() - && stdout - .lines() - .any(|line| !line.is_empty() && !line.contains("ALTQ")); + #[test] + fn test_anchor_reload_and_disable_are_scoped() { + let reload = ["-a", ANCHOR, "-f", PF_RULES_PATH]; + let disable = ["-a", ANCHOR, "-F", "rules"]; + assert_eq!(reload[0..2], ["-a", "killswitch"]); + assert_eq!(disable, ["-a", "killswitch", "-F", "rules"]); + assert!(!reload.contains(&"-Fa")); + assert!(!disable.contains(&"all")); + } - if has_killswitch { - Ok(format!("VPN kill switch: ENABLED\n\n{stdout}")) - } else { - Ok("VPN kill switch: DISABLED".to_string()) + #[test] + fn test_rules_path_is_not_world_writable_tmp() { + assert!(PF_RULES_PATH.starts_with("/var/run/")); + assert!(!PF_RULES_PATH.starts_with("/tmp/")); + } + + #[cfg(target_os = "macos")] + #[test] + #[ignore = "requires root and intentionally validates only; run manually on macOS"] + fn test_real_pf_parser_integration() { + let rules = concat!( + "pass on lo0 all tag KILLSWITCH_ALLOWED keep state\n", + "pass out on en0 inet proto tcp from any to 203.0.113.1 port 443 ", + "flags any tag KILLSWITCH_ALLOWED keep state (if-bound)\n", + "pass out on en0 inet proto udp from any to 203.0.113.1 port 443 ", + "tag KILLSWITCH_ALLOWED keep state (if-bound)\n", + "block drop out quick all ! tagged KILLSWITCH_ALLOWED\n", + ); + let path = format!("/tmp/killswitch-test-{}.pf", std::process::id()); + let mut options = OpenOptions::new(); + options.write(true).create_new(true).mode(0o600); + if let Ok(mut file) = options.open(&path) + && file.write_all(rules.as_bytes()).is_ok() + { + let result = validate_rules(&path); + let _ = fs::remove_file(&path); + assert!(result.is_ok()); + } } } diff --git a/src/killswitch/rules.rs b/src/killswitch/rules.rs index 47d10bc..7e9b6be 100644 --- a/src/killswitch/rules.rs +++ b/src/killswitch/rules.rs @@ -1,161 +1,250 @@ -use crate::cli::verbosity::Verbosity; -use crate::killswitch::network; -use anyhow::{Context, Result}; -use chrono::Local; +use crate::killswitch::network::{VpnEndpoint, VpnInfo}; +use anyhow::Result; use std::fmt::Write as _; use std::net::IpAddr; -pub fn generate(vpn_peer: &str, leak: bool, local: bool, verbose: Verbosity) -> Result { - let vpn_peer_ip: IpAddr = vpn_peer.parse().context("Invalid VPN peer IP address")?; - let interfaces = network::get_interfaces()?; +const ALLOWED_TAG: &str = "KILLSWITCH_ALLOWED"; - if verbose.is_debug() { - eprintln!(" VPN gateway: {vpn_peer_ip}"); - eprintln!(" Leak mode: {leak}"); - eprintln!(" Local network: {local}"); - } - - let sep = "-".repeat(62); +/// Generate rules for the dedicated `killswitch` anchor. +/// +/// Every allow rule is explicit and `quick`; the final rule blocks all other +/// outbound traffic. IPv6 is therefore allowed through the selected tunnel +/// and blocked on physical interfaces without disabling IPv6 system-wide. +pub fn generate(info: &VpnInfo, leak: bool, local: bool) -> Result { let mut rules = String::new(); + rules.push_str("# Managed by killswitch; load only into the killswitch anchor.\n"); + rules.push_str("# Do not load this file as the main PF ruleset.\n\n"); - // Header - writeln!(rules, "# {sep}")?; writeln!( rules, - "# {}", - Local::now().format("%a, %d %b %Y %H:%M:%S %z") + "pass on lo0 all tag {ALLOWED_TAG} keep state label \"killswitch-loopback\"" )?; - rules.push_str("# sudo pfctl -Fa -f /tmp/killswitch.pf.conf -e\n"); - writeln!(rules, "# {sep}")?; - - // Interface macros - for iface in &interfaces { - if iface.is_p2p() { - writeln!(rules, "vpn_{} = \"{}\"", iface.name(), iface.name())?; - } else { - writeln!(rules, "int_{} = \"{}\"", iface.name(), iface.name())?; - } - } - writeln!(rules, "vpn_ip = \"{vpn_peer_ip}\"")?; - rules.push('\n'); - - // Global settings - rules.push_str("set block-policy drop\n"); - rules.push_str("set ruleset-optimization basic\n"); - rules.push_str("set skip on lo0\n"); - rules.push('\n'); - - // Block all - rules.push_str("block all\n"); - rules.push_str("block out inet6\n"); - rules.push('\n'); - - // DNS - if leak { - rules.push_str("# dns\n"); - rules.push_str("pass quick proto {tcp, udp} from any to any port 53 keep state\n"); - rules.push('\n'); - } - // Broadcast - rules.push_str("# Allow broadcasts on internal interface\n"); - rules.push_str("pass from any to 255.255.255.255 keep state\n"); - rules.push_str("pass from 255.255.255.255 to any keep state\n"); - rules.push('\n'); - - // Multicast - rules.push_str("# Allow multicast\n"); - rules.push_str("pass proto udp from any to 224.0.0.0/4 keep state\n"); - rules.push_str("pass proto udp from 224.0.0.0/4 to any keep state\n"); - rules.push('\n'); + if let Some(interface) = &info.interface { + writeln!( + rules, + "pass on {interface} all tag {ALLOWED_TAG} keep state (if-bound) label \"killswitch-vpn\"" + )?; + } - // Per physical interface rules - for iface in interfaces.iter().filter(|i| !i.is_p2p()) { - if leak { + if let Some(physical) = &info.physical_interface { + add_dhcp_rules(&mut rules, physical)?; + if local { writeln!( rules, - "# Allow ping\npass on $int_{} inet proto icmp all icmp-type 8 code 0 keep state", - iface.name() + "pass on {physical} from {physical}:network to {physical}:network tag {ALLOWED_TAG} keep state (if-bound) label \"killswitch-local\"" )?; - rules.push('\n'); } - writeln!( - rules, - "# Allow dhcp\npass on $int_{} proto {{tcp,udp}} from any port 67:68 to any port 67:68 keep state", - iface.name() - )?; - rules.push('\n'); - if local { + if leak { writeln!( rules, - "pass from $int_{0}:network to $int_{0}:network", - iface.name() + "pass out on {physical} proto {{ tcp, udp }} to any port 53 tag {ALLOWED_TAG} keep state (if-bound) label \"killswitch-dns-leak\"" + )?; + writeln!( + rules, + "pass out on {physical} inet proto icmp all tag {ALLOWED_TAG} keep state (if-bound) label \"killswitch-icmp-leak\"" + )?; + writeln!( + rules, + "pass out on {physical} inet6 proto icmp6 all tag {ALLOWED_TAG} keep state (if-bound) label \"killswitch-icmp6-leak\"" )?; } - writeln!( - rules, - "# use only the vpn\npass on $int_{} proto {{tcp, udp}} from any to $vpn_ip", - iface.name() - )?; + for endpoint in &info.endpoints { + add_endpoint_rule(&mut rules, physical, endpoint)?; + } } - // VPN interface pass-all - for iface in interfaces.iter().filter(|i| i.is_p2p()) { - writeln!(rules, "pass on $vpn_{} all", iface.name())?; + writeln!( + rules, + "block drop out quick all ! tagged {ALLOWED_TAG} label \"killswitch-direct-block\"" + )?; + Ok(rules) +} + +fn add_dhcp_rules(rules: &mut String, interface: &str) -> Result<()> { + writeln!( + rules, + "pass out on {interface} inet proto udp from any port 68 to any port 67 tag {ALLOWED_TAG} keep state (if-bound) label \"killswitch-dhcp-out\"" + )?; + writeln!( + rules, + "pass in on {interface} inet proto udp from any port 67 to any port 68 tag {ALLOWED_TAG} keep state (if-bound) label \"killswitch-dhcp-in\"" + )?; + writeln!( + rules, + "pass out on {interface} inet6 proto udp from any port 546 to ff02::1:2 port 547 tag {ALLOWED_TAG} keep state (if-bound) label \"killswitch-dhcp6-out\"" + )?; + writeln!( + rules, + "pass in on {interface} inet6 proto udp from any port 547 to any port 546 tag {ALLOWED_TAG} keep state (if-bound) label \"killswitch-dhcp6-in\"" + )?; + Ok(()) +} + +fn add_endpoint_rule(rules: &mut String, interface: &str, endpoint: &VpnEndpoint) -> Result<()> { + let family = match endpoint.address { + IpAddr::V4(_) => "inet", + IpAddr::V6(_) => "inet6", + }; + let port = endpoint + .port + .map_or_else(String::new, |value| format!(" port {value}")); + + match endpoint.transport { + crate::killswitch::network::Transport::Tcp => { + add_endpoint_transport( + rules, + interface, + family, + "tcp", + " flags any", + endpoint, + &port, + )?; + } + crate::killswitch::network::Transport::Udp => { + add_endpoint_transport(rules, interface, family, "udp", "", endpoint, &port)?; + } + crate::killswitch::network::Transport::Any => { + add_endpoint_transport( + rules, + interface, + family, + "tcp", + " flags any", + endpoint, + &port, + )?; + add_endpoint_transport(rules, interface, family, "udp", "", endpoint, &port)?; + } } + Ok(()) +} - Ok(rules) +fn add_endpoint_transport( + rules: &mut String, + interface: &str, + family: &str, + protocol: &str, + tcp_flags: &str, + endpoint: &VpnEndpoint, + port: &str, +) -> Result<()> { + writeln!( + rules, + "pass out on {interface} {family} proto {protocol} from any to {}{port}{tcp_flags} tag {ALLOWED_TAG} keep state (if-bound) label \"killswitch-endpoint\"", + endpoint.address + )?; + Ok(()) } #[cfg(test)] mod tests { use super::*; - use crate::killswitch::network::hex_to_cidr; - - fn extract_network(line: &str) -> Option { - let parts: Vec<&str> = line.split_whitespace().collect(); - let inet_pos = parts.iter().position(|&s| s == "inet")?; - let ip = parts.get(inet_pos + 1)?; - let netmask_pos = parts.iter().position(|&s| s == "netmask")?; - let netmask_hex = parts.get(netmask_pos + 1)?; - let cidr = hex_to_cidr(netmask_hex)?; - Some(format!("{ip}/{cidr}")) + use crate::killswitch::network::{Transport, VpnEndpoint, VpnType}; + use std::net::{Ipv4Addr, Ipv6Addr}; + + fn info(connected: bool, endpoint: bool) -> VpnInfo { + VpnInfo { + vpn_type: VpnType::MacOsNetworkExtension, + interface: connected.then(|| "utun4".to_string()), + tunnel_ipv4: connected.then(|| Ipv4Addr::new(172, 16, 209, 2)), + tunnel_ipv6: connected + .then(|| "fd00::2".parse::().ok()) + .flatten() + .into_iter() + .collect(), + endpoints: endpoint + .then(|| VpnEndpoint { + address: IpAddr::V4(Ipv4Addr::new(216, 211, 192, 107)), + port: Some(443), + transport: Transport::Udp, + }) + .into_iter() + .collect(), + physical_interface: Some("en0".to_string()), + physical_ipv4: vec![Ipv4Addr::new(192, 168, 1, 66)], + physical_ipv6: vec![ + "2a00:1370:817c:4a82::66" + .parse() + .unwrap_or(Ipv6Addr::LOCALHOST), + ], + routes: Vec::new(), + service: Some("AdGuard VPN".to_string()), + } + } + + #[test] + fn test_anchor_generation_vpn_on() { + let rules = generate(&info(true, true), false, false).unwrap_or_default(); + assert!(rules.contains("pass on utun4 all tag KILLSWITCH_ALLOWED")); + assert!( + rules.contains("pass out on en0 inet proto udp from any to 216.211.192.107 port 443") + ); + assert!(rules.contains("block drop out quick all ! tagged KILLSWITCH_ALLOWED")); + assert!(!rules.contains("block out inet6")); + } + + #[test] + fn test_anchor_generation_vpn_off_is_fail_closed() { + let rules = generate(&info(false, false), false, false).unwrap_or_default(); + assert!(!rules.contains("pass on utun")); + assert!(!rules.contains("killswitch-endpoint")); + assert!(rules.contains("block drop out quick all ! tagged KILLSWITCH_ALLOWED")); + } + + #[test] + fn test_endpoint_unknown_does_not_open_physical_interface() { + let rules = generate(&info(true, false), false, false).unwrap_or_default(); + assert!(!rules.contains("killswitch-endpoint")); + assert!(rules.contains("block drop out quick all ! tagged KILLSWITCH_ALLOWED")); } - #[allow(clippy::unwrap_used)] #[test] - fn test_generate_basic() { - use crate::cli::verbosity::Verbosity; - let rules = generate("203.0.113.1", false, false, Verbosity::Normal).unwrap(); - assert!(rules.contains("vpn_ip = \"203.0.113.1\"")); - assert!(rules.contains("set block-policy drop")); - assert!(rules.contains("set skip on lo0")); - assert!(rules.contains("block all")); - assert!(rules.contains("block out inet6")); - assert!(rules.contains("pass from any to 255.255.255.255 keep state")); - assert!(rules.contains("from any port 67:68 to any port 67:68 keep state")); - assert!(!rules.contains("icmp-type 8 code 0")); + fn test_ipv6_endpoint_and_tunnel_are_supported() { + let mut value = info(true, false); + value.endpoints.push(VpnEndpoint { + address: "2001:db8::8" + .parse() + .unwrap_or(IpAddr::V6(Ipv6Addr::LOCALHOST)), + port: Some(443), + transport: Transport::Tcp, + }); + let rules = generate(&value, false, false).unwrap_or_default(); + assert!(rules.contains("inet6 proto tcp")); + assert!(rules.contains("to 2001:db8::8 port 443")); + assert!(rules.contains("port 443 flags any tag KILLSWITCH_ALLOWED")); } - #[allow(clippy::unwrap_used)] #[test] - fn test_generate_with_leak() { - use crate::cli::verbosity::Verbosity; - let rules = generate("203.0.113.1", true, false, Verbosity::Normal).unwrap(); - assert!(rules.contains("pass quick proto {tcp, udp} from any to any port 53 keep state")); - assert!(rules.contains("icmp-type 8 code 0 keep state")); + fn test_dhcp_loopback_and_optional_local_rules() { + let rules = generate(&info(true, true), false, true).unwrap_or_default(); + assert!(rules.contains("pass on lo0")); + assert!(rules.contains("port 68 to any port 67")); + assert!(rules.contains("port 546 to ff02::1:2 port 547")); + assert!(rules.contains("from en0:network to en0:network")); } #[test] - fn test_hex_to_cidr() { - assert_eq!(hex_to_cidr("0xffffff00"), Some(24)); - assert_eq!(hex_to_cidr("0xffff0000"), Some(16)); - assert_eq!(hex_to_cidr("0xffffffff"), Some(32)); + fn test_leak_mode_remains_opt_in() { + let secure = generate(&info(true, true), false, false).unwrap_or_default(); + let leak = generate(&info(true, true), true, false).unwrap_or_default(); + assert!(!secure.contains("killswitch-dns-leak")); + assert!(leak.contains("killswitch-dns-leak")); + assert!(leak.contains("killswitch-icmp6-leak")); } #[test] - fn test_extract_network() { - let line = "\tinet 192.168.1.100 netmask 0xffffff00 broadcast 192.168.1.255"; - assert_eq!(extract_network(line), Some("192.168.1.100/24".to_string())); + fn test_manual_endpoint_allows_tcp_and_udp_without_fixed_port() { + let mut value = info(true, false); + value.endpoints.push(VpnEndpoint { + address: IpAddr::V4(Ipv4Addr::new(203, 0, 113, 8)), + port: None, + transport: Transport::Any, + }); + let rules = generate(&value, false, false).unwrap_or_default(); + assert!(rules.contains("proto tcp from any to 203.0.113.8 flags any")); + assert!(rules.contains("proto udp from any to 203.0.113.8 tag")); + assert!(!rules.contains("203.0.113.8 port")); } } diff --git a/test_killswitch.sh b/test_killswitch.sh index afb1a52..5946d1c 100755 --- a/test_killswitch.sh +++ b/test_killswitch.sh @@ -1,232 +1,142 @@ #!/bin/bash -# Killswitch Interactive Test Script -# Run with: sudo ./test_killswitch.sh +# Interactive macOS packet-path test. Build first, then run with sudo. -set -e +set -u KILLSWITCH="./target/release/killswitch" -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -CYAN='\033[0;36m' -BOLD='\033[1m' -DIM='\033[2m' -NC='\033[0m' - cleanup() { - echo "" - echo -e "${YELLOW}Cleanup: Disabling killswitch...${NC}" - "$KILLSWITCH" -d 2>/dev/null || true - echo -e "${GREEN}✓ Killswitch disabled - internet restored${NC}" + "$KILLSWITCH" -d >/dev/null 2>&1 || true } +trap cleanup EXIT INT TERM -# Trap to ensure cleanup on exit/error/ctrl+c -trap cleanup EXIT +if [[ ${EUID} -ne 0 ]]; then + echo "Run with sudo: sudo ./test_killswitch.sh" + exit 1 +fi -test_ping() { - local target=$1 - echo -n " Ping ($target): " >&2 - if ping -c 1 -W 3 "$target" >/dev/null 2>&1; then - echo -e "${GREEN}OK${NC}" >&2 - return 0 - else - echo -e "${RED}BLOCKED${NC}" >&2 - return 1 - fi -} +if [[ ! -x "$KILLSWITCH" ]]; then + echo "Missing $KILLSWITCH. Run: cargo build --release --locked" + exit 1 +fi -test_dns() { - echo -n " DNS (nslookup google.com): " >&2 - if nslookup google.com 8.8.8.8 >/dev/null 2>&1; then - echo -e "${GREEN}OK${NC}" >&2 - return 0 - else - echo -e "${RED}BLOCKED${NC}" >&2 - return 1 - fi +probe() { + local family=$1 + local url=$2 + curl "$family" --silent --show-error --max-time 10 "$url" 2>/dev/null } -test_http() { - echo -n " HTTP (curl): " >&2 - # Try multiple services - IP="" - for url in "https://ifconfig.me/ip" "https://trackip.net/ip"; do - IP=$(curl -s -m 5 "$url" 2>/dev/null) && [ "$IP" != "" ] && break - done - if [ "$IP" != "" ]; then - echo -e "${GREEN}OK${NC} ${DIM}(IP: $IP)${NC}" >&2 - return 0 - else - echo -e "${RED}BLOCKED${NC}" >&2 +expect_blocked() { + local family=$1 + local url=$2 + if value=$(probe "$family" "$url"); then + echo "FAIL: $family leaked ($value)" return 1 fi + echo "PASS: $family blocked" } -run_all_tests() { - local label=$1 - echo -e " ${CYAN}$label${NC}" >&2 +echo "Detected path:" +"$KILLSWITCH" -vv - local ping_ok=0 dns_ok=0 http_ok=0 - test_ping "8.8.8.8" && ping_ok=1 || true - test_dns && dns_ok=1 || true - test_http && http_ok=1 || true - - echo "$ping_ok $dns_ok $http_ok" -} +echo +echo "Generated anchor:" +"$KILLSWITCH" --print -vv -echo "==============================================" -echo -e "${BOLD} KILLSWITCH INTERACTIVE TEST${NC}" -echo "==============================================" -echo "" -echo -e "${DIM}Tests: Ping (ICMP), DNS, HTTP${NC}" -echo "" - -# Check root -if [ "$EUID" -ne 0 ]; then - echo -e "${RED}ERROR: Please run with sudo${NC}" - trap - EXIT +echo +echo "Baseline with VPN connected:" +ipv4_before=$(probe -4 https://api.ipify.org) || { + echo "IPv4 baseline failed; connect the VPN first." exit 1 +} +echo "IPv4: $ipv4_before" +ipv6_available=0 +if ipv6_before=$(probe -6 https://api64.ipify.org); then + ipv6_available=1 + echo "IPv6: $ipv6_before" +else + echo "IPv6: unavailable through VPN (acceptable if the VPN has no IPv6)" fi -# Build if needed -if [ ! -f "$KILLSWITCH" ]; then - echo "Building release binary..." - cargo build --release -fi +echo +"$KILLSWITCH" -e -v || exit 1 +sleep 2 -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -echo -e "${CYAN}━━━ STEP 1: Pre-flight ━━━${NC}" -echo "" -echo "Checking VPN and peer detection..." -"$KILLSWITCH" -vv 2>&1 | head -20 -echo "" - -read -p "Is VPN connected and PEER IP shown above? (y/n) " -n 1 -r -echo "" -if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Please connect VPN and re-run." - trap - EXIT +ipv4_enabled=$(probe -4 https://api.ipify.org) || { + echo "FAIL: IPv4 did not work through VPN after enabling." exit 1 +} +if [[ "$ipv4_enabled" != "$ipv4_before" ]]; then + echo "WARNING: public IPv4 changed: $ipv4_before -> $ipv4_enabled" fi - -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -echo "" -echo -e "${CYAN}━━━ STEP 2: Baseline (VPN ON, no killswitch) ━━━${NC}" -echo "" -BASELINE=$(run_all_tests "All traffic should work:") -BASELINE_PING=$(echo "$BASELINE" | cut -d' ' -f1) -BASELINE_DNS=$(echo "$BASELINE" | cut -d' ' -f2) -BASELINE_HTTP=$(echo "$BASELINE" | cut -d' ' -f3) - -if [ "$BASELINE_HTTP" -eq 0 ]; then - echo "" - echo -e "${RED}ERROR: No HTTP connectivity. Check your VPN.${NC}" - trap - EXIT - exit 1 +echo "PASS: VPN IPv4 works ($ipv4_enabled)" + +if [[ $ipv6_available -eq 1 ]]; then + ipv6_enabled=$(probe -6 https://api64.ipify.org) || { + echo "FAIL: VPN provided IPv6 before enable, but IPv6 is blocked after enable." + exit 1 + } + echo "PASS: VPN IPv6 works ($ipv6_enabled)" +else + expect_blocked -6 https://api64.ipify.org || exit 1 fi -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -echo "" -echo -e "${CYAN}━━━ STEP 3: Enable killswitch ━━━${NC}" -"$KILLSWITCH" -e -v -echo -e "${GREEN}✓ Killswitch ENABLED${NC}" +echo +echo "PF counters after VPN-on traffic:" +pfctl -a killswitch -vvsr +pfctl -ss -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -echo "" -echo -e "${CYAN}━━━ STEP 4: Test with VPN ON + killswitch ━━━${NC}" -echo "" -sleep 2 -VPN_RESULTS=$(run_all_tests "Traffic through VPN should work:") -VPN_PING=$(echo "$VPN_RESULTS" | cut -d' ' -f1) -VPN_DNS=$(echo "$VPN_RESULTS" | cut -d' ' -f2) -VPN_HTTP=$(echo "$VPN_RESULTS" | cut -d' ' -f3) - -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -echo "" -echo -e "${CYAN}━━━ STEP 5: THE CRITICAL TEST ━━━${NC}" -echo "" -echo -e "${BOLD}${YELLOW}╔══════════════════════════════════════════════╗${NC}" -echo -e "${BOLD}${YELLOW}║ 👉 DISCONNECT YOUR VPN NOW 👈 ║${NC}" -echo -e "${BOLD}${YELLOW}║ ║${NC}" -echo -e "${BOLD}${YELLOW}║ Then press ENTER to test if traffic leaks ║${NC}" -echo -e "${BOLD}${YELLOW}╚══════════════════════════════════════════════╝${NC}" -echo "" -read -p "Press ENTER after disconnecting VPN..." - -echo "" -NOVPN_RESULTS=$(run_all_tests "All traffic should be BLOCKED:") -NOVPN_PING=$(echo "$NOVPN_RESULTS" | cut -d' ' -f1) -NOVPN_DNS=$(echo "$NOVPN_RESULTS" | cut -d' ' -f2) -NOVPN_HTTP=$(echo "$NOVPN_RESULTS" | cut -d' ' -f3) - -# Check if traffic was blocked -NOVPN_TOTAL=$((NOVPN_PING + NOVPN_DNS + NOVPN_HTTP)) -if [ "$NOVPN_TOTAL" -eq 0 ]; then - echo "" - echo -e "${GREEN}${BOLD}✓ EXCELLENT! All traffic is BLOCKED without VPN${NC}" - echo -e "${GREEN} Killswitch is protecting you!${NC}" - BLOCK_SUCCESS=1 +echo +read -r -p "Disconnect AdGuard VPN, then press Enter... " +sleep 3 + +failed=0 +expect_blocked -4 https://api.ipify.org || failed=1 +expect_blocked -6 https://api64.ipify.org || failed=1 + +echo +echo "PF counters after VPN-off probes:" +pfctl -a killswitch -vvsr +pfctl -ss + +echo +read -r -p "Reconnect AdGuard VPN (utun may change), then press Enter... " + +connected=0 +for _ in {1..15}; do + if "$KILLSWITCH" -vv 2>&1 | grep -q "VPN interface: *utun"; then + connected=1 + break + fi + sleep 2 +done + +if [[ $connected -eq 0 ]]; then + echo "FAIL: no active routed utun detected after reconnect." + failed=1 +elif ipv4_reconnected=$(probe -4 https://api.ipify.org); then + echo "PASS: reconnect works without --ipv4 ($ipv4_reconnected)" else - echo "" - echo -e "${RED}${BOLD}✗ WARNING: Some traffic LEAKED without VPN!${NC}" - echo -e "${RED} Killswitch did NOT fully protect you!${NC}" - BLOCK_SUCCESS=0 + echo "FAIL: IPv4 did not recover after reconnect." + failed=1 fi -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -echo "" -echo -e "${CYAN}━━━ STEP 6: Disable killswitch FIRST ━━━${NC}" -echo "" -echo -e "${DIM}(Must disable before VPN reconnect - new server may have different IP)${NC}" -# Remove trap and disable manually -trap - EXIT -"$KILLSWITCH" -d -v -echo -e "${GREEN}✓ Killswitch DISABLED${NC}" - -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -echo "" -echo -e "${CYAN}━━━ STEP 7: Reconnect VPN ━━━${NC}" -echo "" -echo -e "${YELLOW}👉 RECONNECT YOUR VPN NOW${NC}" -read -p "Press ENTER after reconnecting VPN..." - -echo "" -RECONNECT_RESULTS=$(run_all_tests "Traffic should work again:") -RECONNECT_PING=$(echo "$RECONNECT_RESULTS" | cut -d' ' -f1) -RECONNECT_DNS=$(echo "$RECONNECT_RESULTS" | cut -d' ' -f2) -RECONNECT_HTTP=$(echo "$RECONNECT_RESULTS" | cut -d' ' -f3) - -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -echo "" -echo "==============================================" -echo -e "${BOLD} TEST SUMMARY${NC}" -echo "==============================================" -echo "" -echo -e " ${BOLD}VPN ON + Killswitch:${NC}" -[ "$VPN_PING" -eq 1 ] && echo -e " ${GREEN}✓${NC} Ping" || echo -e " ${RED}✗${NC} Ping" -[ "$VPN_DNS" -eq 1 ] && echo -e " ${GREEN}✓${NC} DNS" || echo -e " ${RED}✗${NC} DNS" -[ "$VPN_HTTP" -eq 1 ] && echo -e " ${GREEN}✓${NC} HTTP" || echo -e " ${RED}✗${NC} HTTP" -echo "" -echo -e " ${BOLD}VPN OFF + Killswitch (should be blocked):${NC}" -[ "$NOVPN_PING" -eq 0 ] && echo -e " ${GREEN}✓${NC} Ping BLOCKED" || echo -e " ${RED}✗${NC} Ping LEAKED!" -[ "$NOVPN_DNS" -eq 0 ] && echo -e " ${GREEN}✓${NC} DNS BLOCKED" || echo -e " ${RED}✗${NC} DNS LEAKED!" -[ "$NOVPN_HTTP" -eq 0 ] && echo -e " ${GREEN}✓${NC} HTTP BLOCKED" || echo -e " ${RED}✗${NC} HTTP LEAKED!" -echo "" -echo -e " ${BOLD}After disable + VPN reconnect:${NC}" -[ "$RECONNECT_HTTP" -eq 1 ] && echo -e " ${GREEN}✓${NC} Internet restored" || echo -e " ${YELLOW}⚠${NC} Check manually" -echo "" - -# Final verdict -VPN_OK=$((VPN_PING + VPN_DNS + VPN_HTTP)) -if [ "$VPN_OK" -ge 2 ] && [ "$BLOCK_SUCCESS" -eq 1 ]; then - echo -e "${GREEN}══════════════════════════════════════════════${NC}" - echo -e "${GREEN} KILLSWITCH IS WORKING CORRECTLY! 🎉${NC}" - echo -e "${GREEN}══════════════════════════════════════════════${NC}" -else - echo -e "${RED}══════════════════════════════════════════════${NC}" - echo -e "${RED} TEST FAILED - Review results above${NC}" - echo -e "${RED}══════════════════════════════════════════════${NC}" +if [[ $connected -eq 1 && $ipv6_available -eq 1 ]]; then + if ipv6_reconnected=$(probe -6 https://api64.ipify.org); then + echo "PASS: reconnect IPv6 works ($ipv6_reconnected)" + else + echo "FAIL: IPv6 did not recover after reconnect." + failed=1 + fi fi -echo "" + +echo +"$KILLSWITCH" -vv +pfctl -a killswitch -vvsr + +if [[ $failed -ne 0 ]]; then + echo "Packet-path test failed. Capture en0 and the detected utun with tcpdump." + exit 1 +fi + +echo "All automated checks passed." From 62e942b0472605f2af07a43dd00594c4986abf14 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 30 Jul 2026 15:04:29 -0400 Subject: [PATCH 2/2] fix(macos): support AdGuard reconnect Pin trusted VPN transport endpoints so the monitor cannot learn unrelated direct sockets. Add an opt-in bounded bootstrap allowlist for AdGuard reconnects while direct traffic stays blocked. --- IMPLEMENTATION.md | 28 +++- README.md | 21 ++- src/cli/actions/mod.rs | 5 + src/cli/actions/run.rs | 14 +- src/cli/commands/mod.rs | 13 ++ src/cli/dispatch/mod.rs | 8 ++ src/killswitch/mod.rs | 273 +++++++++++++++++++++++++++++++++----- src/killswitch/network.rs | 196 +++++++++++++++++++++++---- src/killswitch/rules.rs | 82 ++++++++---- test_killswitch.sh | 95 ++++++++++--- 10 files changed, 609 insertions(+), 126 deletions(-) diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 6e5c939..ede1262 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -13,8 +13,8 @@ On the AdGuard VPN system used during development: - `utun4` owns `172.16.209.2`, `fd00::2`, and the broad VPN routes; - `utun0` through `utun3` have only link-local/service routes and are unrelated; - `scutil --nc list` does not expose the active AdGuard packet tunnel; -- `lsof -F pcPnT` exposes `AdGuard VPN` UDP sockets from the `en0` address to - the current public server on port 443. +- `lsof -F pcPnT` exposes `AdGuard VPN` transport sockets from the `en0` + address to the current public server. The detector therefore does not interpret the `utun` peer `127.1.1.1`, the tunnel address, the public exit IP, or an arbitrary static route as the outer @@ -40,6 +40,11 @@ interface are that `utun`, plus a configured tunnel IPv4 address. A link-local 2. `wg show all endpoints`; 3. connected `scutil --nc show` `RemoteAddress` values. +AdGuard also creates one-off physical-interface sockets for connectivity and +DNS probes. Its endpoint selector therefore accepts only a unique remote that +is repeated across multiple provider sockets; an ambiguous observation fails +closed. + An unknown endpoint never produces a physical-interface Internet allow rule. ## PF lifecycle @@ -71,11 +76,20 @@ loaded anchor; it does not flush the state table. ## Dynamic fail-closed behavior The monitor runs every two seconds. It replaces the tunnel allow rule after a -route change and removes it when the VPN disappears. The last currently -observed provider endpoints remain narrowly allowed so the provider can create -a new tunnel. When a new blocked connection attempt changes the provider -socket endpoint, `lsof` exposes the remote address before traffic succeeds; -the next monitor pass replaces the endpoint rule. +route change and removes it when the VPN disappears. Endpoint exceptions are +detected and pinned when the kill switch is enabled, then persisted in the +root-only runtime monitor configuration. Later direct sockets never expand +that allowlist. This permits reconnection to the same endpoint; switching to a +different server requires disabling and enabling the kill switch around the +server change. + +The opt-in `--reconnect` mode also emits separately labeled +`killswitch-bootstrap` rules for TCP/UDP port 443 destinations currently +opened by the exact `AdGuard VPN` process. The monitor replaces this temporary +set every two seconds, excludes the pinned transport endpoint, and caps it at +eight destinations. These narrow direct exceptions let AdGuard complete its +connectivity checks and discover a new transport endpoint without adding a +wildcard Internet exception. The monitor keeps no wildcard physical Internet exception. A missing tunnel, endpoint, physical path, malformed route table, or transient detection failure diff --git a/README.md b/README.md index 7ac7d96..5d3a26f 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,18 @@ switch. Allowed packets are tagged and continue through later system anchors; only disallowed direct traffic terminates evaluation with `block quick`. When enabled, a small root monitor checks the route, tunnel and provider -sockets every two seconds. A reconnect from `utun4` to `utun5`, or a change of -VPN server endpoint, reloads only the killswitch anchor. When the tunnel -disappears, its allow rule is removed while the last observed VPN endpoint is -kept so the provider can reconnect. +sockets every two seconds. A reconnect from `utun4` to `utun5` reloads only +the killswitch anchor. The endpoint detected at enable time is pinned: when +the tunnel disappears its allow rule is removed, but that endpoint remains +allowed so the provider can reconnect to the same server. A different server +endpoint is not learned from direct sockets while the kill switch is active; +disable, connect to the new server, then enable again. + +For AdGuard VPN, `--reconnect` additionally permits up to eight temporary +TCP/UDP port 443 destinations currently opened by the AdGuard process. The set +is replaced on every monitor pass and never accumulated. This lets AdGuard run +its bootstrap/connectivity checks and reconnect automatically, at the cost of +narrow direct exceptions to those observed destinations. ## Usage @@ -48,6 +56,10 @@ Enable the kill switch and monitor: sudo killswitch -e -v +Enable automatic AdGuard reconnect support: + + sudo killswitch -e --reconnect -v + Show anchor counters or disable it: sudo killswitch --status @@ -66,6 +78,7 @@ and UDP to that IP because the legacy flag has no protocol or port information. | `-s`, `--status` | Show rules and packet counters for this anchor | | `-p`, `--print` | Print rules without applying them | | `--local` | Permit traffic within the physical interface's local network | +| `--reconnect` | Temporarily permit bounded AdGuard bootstrap sockets on port 443 | | `--leak` | Explicitly permit direct DNS and ICMP (reduces leak protection) | | `--ipv4 ` | Legacy manual public IPv4 endpoint override | | `-v`, `-vv` | Verbose / debug output | diff --git a/src/cli/actions/mod.rs b/src/cli/actions/mod.rs index a16c99d..3859338 100644 --- a/src/cli/actions/mod.rs +++ b/src/cli/actions/mod.rs @@ -9,6 +9,7 @@ pub enum Action { ipv4: Option, leak: bool, local: bool, + reconnect: bool, verbose: Verbosity, }, Disable { @@ -21,6 +22,7 @@ pub enum Action { ipv4: Option, leak: bool, local: bool, + reconnect: bool, verbose: Verbosity, }, ShowInterfaces { @@ -50,6 +52,7 @@ mod tests { ipv4: Some("10.8.0.1".to_string()), leak: false, local: false, + reconnect: false, verbose: Verbosity::Normal, }; let debug_str = format!("{action:?}"); @@ -63,6 +66,7 @@ mod tests { ipv4: None, leak: true, local: true, + reconnect: true, verbose: Verbosity::Verbose, }; assert!(matches!(enable, Action::Enable { .. })); @@ -81,6 +85,7 @@ mod tests { ipv4: Some("192.168.1.1".to_string()), leak: false, local: false, + reconnect: false, verbose: Verbosity::Normal, }; assert!(matches!(print, Action::Print { .. })); diff --git a/src/cli/actions/run.rs b/src/cli/actions/run.rs index 8b4cf87..a1c2fff 100644 --- a/src/cli/actions/run.rs +++ b/src/cli/actions/run.rs @@ -12,6 +12,7 @@ pub fn execute(action: &Action) -> Result<()> { ipv4, leak, local, + reconnect, verbose, } => { if verbose.is_verbose() { @@ -25,8 +26,11 @@ pub fn execute(action: &Action) -> Result<()> { if *local { eprintln!(" Allowing local network"); } + if *reconnect { + eprintln!(" Allowing temporary AdGuard reconnect bootstrap sockets"); + } } - killswitch::enable(*leak, *local, ipv4.as_deref(), *verbose)?; + killswitch::enable(*leak, *local, *reconnect, ipv4.as_deref(), *verbose)?; println!("✓ VPN kill switch enabled"); } @@ -50,12 +54,14 @@ pub fn execute(action: &Action) -> Result<()> { ipv4, leak, local, + reconnect, verbose, } => { if verbose.is_verbose() { eprintln!("Generating pf rules..."); } - let rules = killswitch::generate_rules(*leak, *local, ipv4.as_deref(), *verbose)?; + let rules = + killswitch::generate_rules(*leak, *local, *reconnect, ipv4.as_deref(), *verbose)?; println!("{rules}"); } @@ -83,6 +89,7 @@ mod tests { ipv4: Some("203.0.113.1".to_string()), leak: false, local: false, + reconnect: false, verbose: Verbosity::Normal, }; @@ -98,6 +105,7 @@ mod tests { ipv4: Some("198.51.100.1".to_string()), leak: true, local: true, + reconnect: true, verbose: Verbosity::Normal, }; @@ -112,6 +120,7 @@ mod tests { ipv4: Some("10.8.0.1".to_string()), leak: false, local: false, + reconnect: false, verbose: Verbosity::Normal, }; @@ -126,6 +135,7 @@ mod tests { ipv4: Some("10.8.0.1".to_string()), leak: false, local: false, + reconnect: false, verbose: Verbosity::Normal, }; diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index f0ca88c..a45d78e 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -76,6 +76,13 @@ pub fn new() -> Command { .action(ArgAction::SetTrue) .conflicts_with_all(["disable", "status"]), ) + .arg( + Arg::new("reconnect") + .long("reconnect") + .help("Temporarily allow bounded AdGuard bootstrap sockets for reconnects") + .action(ArgAction::SetTrue) + .conflicts_with_all(["disable", "status"]), + ) .arg( Arg::new("print") .short('p') @@ -134,4 +141,10 @@ mod tests { let matches = new().get_matches_from(vec!["killswitch", "-vvv"]); assert_eq!(matches.get_count("verbose"), 3); } + + #[test] + fn test_reconnect_flag() { + let matches = new().get_matches_from(vec!["killswitch", "--enable", "--reconnect"]); + assert!(matches.get_flag("reconnect")); + } } diff --git a/src/cli/dispatch/mod.rs b/src/cli/dispatch/mod.rs index b38eea3..f8a1ffd 100644 --- a/src/cli/dispatch/mod.rs +++ b/src/cli/dispatch/mod.rs @@ -19,12 +19,14 @@ pub fn handler(matches: &ArgMatches, verbose: Verbosity) -> Result { let ipv4 = matches.get_one::("ipv4").map(String::from); let leak = matches.get_flag("leak"); let local = matches.get_flag("local"); + let reconnect = matches.get_flag("reconnect"); if print { Ok(Action::Print { ipv4, leak, local, + reconnect, verbose, }) } else { @@ -32,6 +34,7 @@ pub fn handler(matches: &ArgMatches, verbose: Verbosity) -> Result { ipv4, leak, local, + reconnect, verbose, }) } @@ -43,10 +46,12 @@ pub fn handler(matches: &ArgMatches, verbose: Verbosity) -> Result { let ipv4 = matches.get_one::("ipv4").map(String::from); let leak = matches.get_flag("leak"); let local = matches.get_flag("local"); + let reconnect = matches.get_flag("reconnect"); Ok(Action::Print { ipv4, leak, local, + reconnect, verbose, }) } else { @@ -104,6 +109,7 @@ mod tests { "-e", "--local", "--leak", + "--reconnect", "--ipv4", "10.0.0.1", ]); @@ -112,12 +118,14 @@ mod tests { ipv4, leak, local, + reconnect, verbose: _, } = action { assert_eq!(ipv4, Some("10.0.0.1".to_string())); assert!(leak); assert!(local); + assert!(reconnect); } else { panic!("Expected Action::Enable"); } diff --git a/src/killswitch/mod.rs b/src/killswitch/mod.rs index 59837c7..c315bc8 100644 --- a/src/killswitch/mod.rs +++ b/src/killswitch/mod.rs @@ -6,6 +6,7 @@ use crate::cli::verbosity::Verbosity; use anyhow::{Context, Result, bail}; use network::{Transport, VpnEndpoint, VpnInfo}; use std::collections::BTreeSet; +use std::fmt::Write as _; use std::fs::{self, OpenOptions}; use std::io::Write as _; use std::net::{IpAddr, Ipv4Addr}; @@ -19,23 +20,21 @@ use std::time::Duration; const MONITOR_CONFIG_PATH: &str = "/var/run/killswitch.monitor.conf"; const MONITOR_PID_PATH: &str = "/var/run/killswitch.monitor.pid"; const MONITOR_INTERVAL: Duration = Duration::from_secs(2); +const MAX_RECONNECT_ENDPOINTS: usize = 8; #[derive(Clone, Debug, PartialEq, Eq)] struct MonitorConfig { leak: bool, local: bool, + reconnect: bool, manual_endpoint: Option, + trusted_endpoints: Vec, } /// Check whether an IPv4 address is private or locally scoped. #[must_use] pub fn is_private_ip(ip: &Ipv4Addr) -> bool { - let octets = ip.octets(); - octets[0] == 10 - || (octets[0] == 172 && (16..=31).contains(&octets[1])) - || (octets[0] == 192 && octets[1] == 168) - || octets[0] == 127 - || (octets[0] == 169 && octets[1] == 254) + ip.is_private() || ip.is_loopback() || ip.is_link_local() } fn check_root() -> Result<()> { @@ -86,16 +85,24 @@ fn physical_addresses(info: &VpnInfo) -> Vec { /// /// # Errors /// Returns an error if privileges, detection, PF validation, or monitor startup fails. -pub fn enable(leak: bool, local: bool, ipv4: Option<&str>, verbose: Verbosity) -> Result<()> { +pub fn enable( + leak: bool, + local: bool, + reconnect: bool, + ipv4: Option<&str>, + verbose: Verbosity, +) -> Result<()> { check_root()?; let info = detect_with_override(ipv4, verbose)?; - let generated = rules::generate(&info, leak, local)?; + let generated = rules::generate(&info, leak, local, reconnect)?; pf::apply_rules(&generated, &physical_addresses(&info), true, verbose)?; let config = MonitorConfig { leak, local, + reconnect, manual_endpoint: ipv4.map(validate_manual_endpoint).transpose()?, + trusted_endpoints: info.endpoints, }; write_monitor_config(&config)?; restart_monitor(verbose)?; @@ -128,11 +135,12 @@ pub fn status() -> Result { pub fn generate_rules( leak: bool, local: bool, + reconnect: bool, ipv4: Option<&str>, verbose: Verbosity, ) -> Result { let info = detect_with_override(ipv4, verbose)?; - rules::generate(&info, leak, local) + rules::generate(&info, leak, local, reconnect) } /// Show the detection result used by rule generation. @@ -154,11 +162,27 @@ fn write_monitor_config(config: &MonitorConfig) -> Result<()> { let manual = config .manual_endpoint .map_or_else(String::new, |address| address.to_string()); - let contents = format!( - "leak={}\nlocal={}\nmanual_endpoint={manual}\n", + let mut contents = format!( + "leak={}\nlocal={}\nreconnect={}\nmanual_endpoint={manual}\n", u8::from(config.leak), - u8::from(config.local) + u8::from(config.local), + u8::from(config.reconnect) ); + for endpoint in &config.trusted_endpoints { + let transport = match endpoint.transport { + Transport::Tcp => "tcp", + Transport::Udp => "udp", + Transport::Any => "any", + }; + let port = endpoint + .port + .map_or_else(String::new, |port| port.to_string()); + writeln!( + contents, + "trusted_endpoint={transport}|{}|{port}", + endpoint.address + )?; + } let mut options = OpenOptions::new(); options.write(true).create(true).truncate(true).mode(0o600); let mut file = options @@ -180,7 +204,9 @@ fn read_monitor_config() -> Result { fn parse_monitor_config(contents: &str) -> Result { let mut leak = false; let mut local = false; + let mut reconnect = false; let mut manual_endpoint = None; + let mut trusted_endpoints = Vec::new(); for line in contents.lines() { let Some((key, value)) = line.split_once('=') else { continue; @@ -188,16 +214,50 @@ fn parse_monitor_config(contents: &str) -> Result { match key { "leak" => leak = value == "1", "local" => local = value == "1", + "reconnect" => reconnect = value == "1", "manual_endpoint" if !value.is_empty() => { manual_endpoint = Some(validate_manual_endpoint(value)?); } + "trusted_endpoint" => trusted_endpoints.push(parse_trusted_endpoint(value)?), _ => {} } } + trusted_endpoints.sort(); + trusted_endpoints.dedup(); Ok(MonitorConfig { leak, local, + reconnect, manual_endpoint, + trusted_endpoints, + }) +} + +fn parse_trusted_endpoint(value: &str) -> Result { + let mut fields = value.split('|'); + let transport = match fields.next() { + Some("tcp") => Transport::Tcp, + Some("udp") => Transport::Udp, + Some("any") => Transport::Any, + _ => bail!("Invalid trusted endpoint transport"), + }; + let address = fields + .next() + .context("Missing trusted endpoint address")? + .parse() + .context("Invalid trusted endpoint address")?; + let port = match fields.next() { + Some("") => None, + Some(port) => Some(port.parse().context("Invalid trusted endpoint port")?), + None => bail!("Missing trusted endpoint port"), + }; + if fields.next().is_some() { + bail!("Invalid trusted endpoint fields"); + } + Ok(VpnEndpoint { + address, + port, + transport, }) } @@ -233,9 +293,25 @@ fn restart_monitor(verbose: Verbosity) -> Result<()> { } fn stop_monitor(verbose: Verbosity) -> Result<()> { + let mut monitor_pids = BTreeSet::new(); if let Ok(contents) = fs::read_to_string(MONITOR_PID_PATH) && let Ok(pid) = contents.trim().parse::() - && process_is_monitor(pid) + { + monitor_pids.insert(pid); + } + if let Ok(output) = Command::new("ps") + .args(["ax", "-o", "pid=,command="]) + .output() + && output.status.success() + { + monitor_pids.extend(parse_monitor_processes(&String::from_utf8_lossy( + &output.stdout, + ))); + } + + for pid in monitor_pids + .into_iter() + .filter(|pid| process_is_monitor(*pid)) { let result = unsafe { libc::kill(pid, libc::SIGTERM) }; if result != 0 { @@ -258,37 +334,84 @@ fn process_is_monitor(pid: libc::pid_t) -> bool { .args(["-p", &pid.to_string(), "-o", "command="]) .output(); output.is_ok_and(|value| { - value.status.success() - && String::from_utf8_lossy(&value.stdout).contains("killswitch --monitor") + value.status.success() && command_is_monitor(&String::from_utf8_lossy(&value.stdout)) }) } +fn parse_monitor_processes(input: &str) -> Vec { + input + .lines() + .filter_map(|line| { + let line = line.trim_start(); + let (raw_pid, command) = line.split_once(char::is_whitespace)?; + command_is_monitor(command) + .then(|| raw_pid.parse().ok()) + .flatten() + }) + .collect() +} + +fn command_is_monitor(command: &str) -> bool { + let mut arguments = command.split_whitespace(); + let Some(executable) = arguments.next() else { + return false; + }; + let Some(name) = Path::new(executable) + .file_name() + .and_then(|name| name.to_str()) + else { + return false; + }; + (name == "killswitch" || name.starts_with("killswitch-")) + && arguments.any(|argument| argument == "--monitor") +} + +fn monitor_pid_is_current(pid: u32) -> bool { + fs::read_to_string(MONITOR_PID_PATH).is_ok_and(|contents| contents.trim() == pid.to_string()) +} + +fn reconnect_endpoints( + known_endpoints: &BTreeSet, + detected_endpoints: &[VpnEndpoint], + bootstrap_endpoints: &[VpnEndpoint], +) -> Vec { + let mut selected = Vec::new(); + for endpoint in detected_endpoints.iter().chain(bootstrap_endpoints) { + if selected.len() == MAX_RECONNECT_ENDPOINTS { + break; + } + if endpoint.port == Some(443) + && matches!(endpoint.transport, Transport::Tcp | Transport::Udp) + && !known_endpoints.contains(endpoint) + && !selected.contains(endpoint) + { + selected.push(endpoint.clone()); + } + } + selected +} + /// Hidden monitor entry point. It always removes a stale tunnel allow rule -/// when detection becomes uncertain, while retaining only observed VPN server -/// endpoints so the provider can reconnect. +/// when detection becomes uncertain. Endpoint exceptions are pinned when the +/// kill switch is enabled; the monitor never learns from later direct sockets. /// /// # Errors /// Returns an error if privileges or the persisted monitor configuration is invalid. pub fn monitor() -> Result<()> { check_root()?; let config = read_monitor_config()?; - let mut known_endpoints = BTreeSet::new(); - if let Some(address) = config.manual_endpoint { - known_endpoints.insert(VpnEndpoint { - address: IpAddr::V4(address), - port: None, - transport: Transport::Any, - }); - } + let known_endpoints: BTreeSet<_> = config.trusted_endpoints.iter().cloned().collect(); let mut previous = fs::read_to_string("/var/run/killswitch.pf.conf").unwrap_or_default(); loop { let mut info = network::detect_vpn(Verbosity::Normal); - if config.manual_endpoint.is_none() && !info.endpoints.is_empty() { - known_endpoints = info.endpoints.iter().cloned().collect(); - } + info.bootstrap_endpoints = if config.reconnect { + reconnect_endpoints(&known_endpoints, &info.endpoints, &info.bootstrap_endpoints) + } else { + Vec::new() + }; info.endpoints = known_endpoints.iter().cloned().collect(); - if let Ok(generated) = rules::generate(&info, config.leak, config.local) + if let Ok(generated) = rules::generate(&info, config.leak, config.local, config.reconnect) && generated != previous && pf::apply_rules( &generated, @@ -301,6 +424,9 @@ pub fn monitor() -> Result<()> { previous = generated; } thread::sleep(MONITOR_INTERVAL); + if !monitor_pid_is_current(std::process::id()) { + return Ok(()); + } } } @@ -318,17 +444,38 @@ mod tests { #[test] fn test_monitor_config_round_trip_parser() { - let config = parse_monitor_config("leak=0\nlocal=1\nmanual_endpoint=216.211.192.107\n") - .unwrap_or(MonitorConfig { - leak: true, - local: false, - manual_endpoint: None, - }); + let config = parse_monitor_config(concat!( + "leak=0\n", + "local=1\n", + "reconnect=1\n", + "manual_endpoint=198.51.100.107\n", + "trusted_endpoint=any|198.51.100.107|\n", + "trusted_endpoint=tcp|2001:db8::8|443\n", + )) + .unwrap_or(MonitorConfig { + leak: true, + local: false, + reconnect: false, + manual_endpoint: None, + trusted_endpoints: Vec::new(), + }); assert!(!config.leak); assert!(config.local); + assert!(config.reconnect); assert_eq!( config.manual_endpoint, - Some(Ipv4Addr::new(216, 211, 192, 107)) + Some(Ipv4Addr::new(198, 51, 100, 107)) + ); + assert_eq!(config.trusted_endpoints.len(), 2); + assert_eq!( + config.trusted_endpoints.get(1), + Some(&VpnEndpoint { + address: "2001:db8::8" + .parse() + .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)), + port: Some(443), + transport: Transport::Tcp, + }) ); } @@ -336,4 +483,58 @@ mod tests { fn test_monitor_config_rejects_private_manual_endpoint() { assert!(parse_monitor_config("manual_endpoint=192.168.1.1\n").is_err()); } + + #[test] + fn test_monitor_process_detection_accepts_renamed_binary() { + assert!(command_is_monitor( + "/opt/homebrew/bin/killswitch-ne --monitor" + )); + assert!(command_is_monitor("/usr/local/bin/killswitch --monitor")); + assert!(!command_is_monitor("/usr/local/bin/killswitch --status")); + assert!(!command_is_monitor("/usr/bin/not-killswitch --monitor")); + } + + #[test] + fn test_parse_monitor_processes_finds_all_instances() { + let processes = concat!( + " 34249 /opt/homebrew/bin/killswitch-ne --monitor\n", + " 51068 /opt/homebrew/bin/killswitch-ne --monitor\n", + " 52000 /usr/local/bin/killswitch --status\n", + ); + + assert_eq!(parse_monitor_processes(processes), [34249, 51068]); + } + + #[test] + fn test_monitor_config_rejects_malformed_trusted_endpoint() { + assert!(parse_monitor_config("trusted_endpoint=tcp|not-an-ip|443\n").is_err()); + assert!(parse_monitor_config("trusted_endpoint=sctp|203.0.113.1|443\n").is_err()); + } + + #[test] + fn test_reconnect_endpoints_are_bounded_and_exclude_pinned_transport() { + let pinned = VpnEndpoint { + address: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 107)), + port: Some(443), + transport: Transport::Tcp, + }; + let detected: Vec<_> = (1..=12) + .map(|last| VpnEndpoint { + address: IpAddr::V4(Ipv4Addr::new(203, 0, 113, last)), + port: Some(443), + transport: Transport::Tcp, + }) + .collect(); + let ignored = VpnEndpoint { + address: IpAddr::V4(Ipv4Addr::new(203, 0, 113, 100)), + port: Some(80), + transport: Transport::Tcp, + }; + let known = BTreeSet::from([pinned.clone()]); + + let selected = reconnect_endpoints(&known, &[pinned, ignored], &detected); + + assert_eq!(selected.len(), MAX_RECONNECT_ENDPOINTS); + assert!(selected.iter().all(|endpoint| endpoint.port == Some(443))); + } } diff --git a/src/killswitch/network.rs b/src/killswitch/network.rs index c2d184f..cde1d06 100644 --- a/src/killswitch/network.rs +++ b/src/killswitch/network.rs @@ -9,6 +9,7 @@ use crate::cli::verbosity::Verbosity; use crate::killswitch::is_private_ip; use anyhow::{Context, Result, bail}; +use std::collections::BTreeMap; use std::fmt; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs as _}; use std::process::Command; @@ -61,6 +62,7 @@ pub struct VpnInfo { pub tunnel_ipv4: Option, pub tunnel_ipv6: Vec, pub endpoints: Vec, + pub bootstrap_endpoints: Vec, pub physical_interface: Option, pub physical_ipv4: Vec, pub physical_ipv6: Vec, @@ -84,6 +86,14 @@ struct InterfaceData { point_to_point: bool, } +const MAX_ADGUARD_BOOTSTRAP_ENDPOINTS: usize = 8; + +#[derive(Debug, Default)] +struct LsofEndpoints { + transport: Vec, + bootstrap: Vec, +} + /// Detect the physical path, active VPN interface and outer endpoint sockets. /// /// Partial command failures produce a conservative result. In particular, @@ -204,11 +214,15 @@ fn detect_from_outputs(outputs: DetectionOutputs<'_>) -> VpnInfo { .as_deref() .and_then(|name| interfaces.iter().find(|interface| interface.name == name)); - let mut endpoints = parse_lsof_endpoints(outputs.lsof, &physical_ipv4, &physical_ipv6); + let lsof_endpoints = parse_lsof_endpoints(outputs.lsof, &physical_ipv4, &physical_ipv6); + let mut endpoints = lsof_endpoints.transport; endpoints.extend(parse_wireguard_endpoints(outputs.wireguard)); endpoints.extend(parse_scutil_remote_addresses(outputs.scutil)); endpoints.sort(); endpoints.dedup(); + let mut bootstrap_endpoints = lsof_endpoints.bootstrap; + bootstrap_endpoints.sort(); + bootstrap_endpoints.dedup(); let service = parse_lsof_service(outputs.lsof); let vpn_type = if !outputs.wireguard.trim().is_empty() { @@ -234,6 +248,7 @@ fn detect_from_outputs(outputs: DetectionOutputs<'_>) -> VpnInfo { tunnel_ipv4: active.and_then(|value| value.ipv4.first().copied()), tunnel_ipv6: active.map_or_else(Vec::new, |value| value.ipv6.clone()), endpoints, + bootstrap_endpoints, physical_interface, physical_ipv4, physical_ipv6, @@ -379,7 +394,7 @@ fn parse_lsof_endpoints( input: &str, physical_ipv4: &[Ipv4Addr], physical_ipv6: &[Ipv6Addr], -) -> Vec { +) -> LsofEndpoints { let physical: Vec = physical_ipv4 .iter() .copied() @@ -389,6 +404,7 @@ fn parse_lsof_endpoints( let mut command = String::new(); let mut transport = Transport::Any; let mut endpoints = Vec::new(); + let mut adguard_candidates = Vec::new(); for line in input.lines() { let Some((tag, value)) = line.split_at_checked(1) else { @@ -407,7 +423,7 @@ fn parse_lsof_endpoints( _ => Transport::Any, }; } - "n" if is_vpn_process(&command) => { + "n" if is_vpn_transport_process(&command) => { let Some((local, remote)) = value.split_once("->") else { continue; }; @@ -418,40 +434,86 @@ fn parse_lsof_endpoints( continue; }; if physical.contains(&local_address) && is_public_endpoint(remote_address) { - endpoints.push(VpnEndpoint { + let endpoint = VpnEndpoint { address: remote_address, port, transport, - }); + }; + if command.eq_ignore_ascii_case("AdGuard VPN") { + adguard_candidates.push(endpoint); + } else { + endpoints.push(endpoint); + } } } _ => {} } } - endpoints + let mut adguard = select_adguard_endpoints(&adguard_candidates); + endpoints.append(&mut adguard.transport); + LsofEndpoints { + transport: endpoints, + bootstrap: adguard.bootstrap, + } +} + +fn select_adguard_endpoints(candidates: &[VpnEndpoint]) -> LsofEndpoints { + let mut counts = BTreeMap::new(); + for endpoint in candidates { + *counts.entry(endpoint.clone()).or_insert(0_usize) += 1; + } + let Some(maximum) = counts.values().copied().max() else { + return LsofEndpoints::default(); + }; + let mut dominant: Vec<_> = counts + .iter() + .filter_map(|(endpoint, count)| (*count == maximum).then_some(endpoint)) + .cloned() + .collect(); + // AdGuard opens one-off connectivity and DNS probe sockets on the physical + // interface. A unique endpoint repeated across sockets is the observable + // distinction between its tunnel transport and those probes. + if maximum < 2 || dominant.len() != 1 { + dominant.clear(); + } + let bootstrap = counts + .into_keys() + .filter(|endpoint| !dominant.contains(endpoint)) + .filter(|endpoint| { + endpoint.port == Some(443) + && matches!(endpoint.transport, Transport::Tcp | Transport::Udp) + }) + .take(MAX_ADGUARD_BOOTSTRAP_ENDPOINTS) + .collect(); + LsofEndpoints { + transport: dominant, + bootstrap, + } } fn parse_lsof_service(input: &str) -> Option { input.lines().find_map(|line| { line.strip_prefix('c') - .filter(|name| is_vpn_process(name)) + .filter(|name| is_vpn_service_process(name)) .map(str::to_string) }) } -fn is_vpn_process(name: &str) -> bool { +fn is_vpn_transport_process(name: &str) -> bool { let folded = name.to_ascii_lowercase(); - [ - "adguard vpn", - "com.adguard", - "wireguard", - "tailscale", - "openvpn", - "protonvpn", - "nordvpn", - ] - .iter() - .any(|needle| folded.contains(needle)) + // AdGuard's Network Extension also owns proxied application sockets. Only + // the app process owns the outer tunnel socket observed on the physical IP. + if folded == "adguard vpn" { + return true; + } + + ["wireguard", "tailscale", "openvpn", "protonvpn", "nordvpn"] + .iter() + .any(|needle| folded.contains(needle)) +} + +fn is_vpn_service_process(name: &str) -> bool { + is_vpn_transport_process(name) || contains_folded(name, "com.adguard") } fn contains_folded(value: &str, needle: &str) -> bool { @@ -594,17 +656,18 @@ pub fn describe(info: &VpnInfo) -> String { mod tests { use super::*; + // Public fixture addresses are from IANA documentation-only ranges. const EN0: &str = r"en0: flags=8863 mtu 1500 ether aa:bb:cc:dd:ee:ff - inet 192.168.1.66 netmask 0xffffff00 broadcast 192.168.1.255 - inet6 2a00:1370:817c:4a82::66 prefixlen 64 + inet 192.0.2.10 netmask 0xffffff00 broadcast 192.0.2.255 + inet6 2001:db8:1:2::66 prefixlen 64 "; const UTUN4: &str = r"utun4: flags=8051 mtu 1500 inet 172.16.209.2 --> 127.1.1.1 netmask 0xffffffff inet6 fd00::2 prefixlen 64 "; const ROUTES4: &str = r"Destination Gateway Flags Netif Expire -default 192.168.1.254 UGScg en0 +default 192.0.2.1 UGScg en0 1 utun4 USc utun4 2/7 utun4 USc utun4 64/2 utun4 USc utun4 @@ -657,8 +720,8 @@ default 192.168.1.254 UGScg en0 let input = format!("{EN0}{UTUN4}"); let lsof = concat!( "p811\ncAdGuard VPN\nPUDP\n", - "n192.168.1.66:54794->216.211.192.107:443\n", - "n192.168.1.66:54795->216.211.192.107:443\n", + "n192.0.2.10:54794->198.51.100.107:443\n", + "n192.0.2.10:54795->198.51.100.107:443\n", ); let info = detect_from_outputs(outputs(&input, ROUTES4, lsof)); // Ephemeral source ports must not become part of the PF exception: @@ -667,7 +730,7 @@ default 192.168.1.254 UGScg en0 assert_eq!( info.endpoints.first(), Some(&VpnEndpoint { - address: IpAddr::V4(Ipv4Addr::new(216, 211, 192, 107)), + address: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 107)), port: Some(443), transport: Transport::Udp, }) @@ -685,7 +748,7 @@ default 192.168.1.254 UGScg en0 fn test_physical_interface_en0_and_global_addresses() { let info = detect_from_outputs(outputs(EN0, ROUTES4, "")); assert_eq!(info.physical_interface.as_deref(), Some("en0")); - assert_eq!(info.physical_ipv4, [Ipv4Addr::new(192, 168, 1, 66)]); + assert_eq!(info.physical_ipv4, [Ipv4Addr::new(192, 0, 2, 10)]); assert_eq!(info.physical_ipv6.len(), 1); } @@ -707,9 +770,88 @@ default 192.168.1.254 UGScg en0 #[test] fn test_non_vpn_process_socket_is_not_endpoint() { - let lsof = "p99\ncBrowser\nPTCP\nn192.168.1.66:50000->203.0.113.1:443\n"; + let lsof = "p99\ncBrowser\nPTCP\nn192.0.2.10:50000->203.0.113.1:443\n"; + let info = detect_from_outputs(outputs(EN0, ROUTES4, lsof)); + assert!(info.endpoints.is_empty()); + } + + #[test] + fn test_adguard_network_extension_socket_is_not_endpoint() { + let input = format!("{EN0}{UTUN4}"); + let lsof = concat!( + "p811\ncAdGuard VPN\nPTCP\n", + "n192.0.2.10:53593->198.51.100.107:443\n", + "n192.0.2.10:53594->198.51.100.107:443\n", + "p4414\nccom.adguard.mac.vpn.network-extension\nPTCP\n", + "n192.0.2.10:55433->203.0.113.26:443\n", + "n192.0.2.10:55434->203.0.113.188:5228\n", + ); + let info = detect_from_outputs(outputs(&input, ROUTES4, lsof)); + + assert_eq!( + info.endpoints, + [VpnEndpoint { + address: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 107)), + port: Some(443), + transport: Transport::Tcp, + }] + ); + assert!(info.bootstrap_endpoints.is_empty()); + assert_eq!(info.service.as_deref(), Some("AdGuard VPN")); + } + + #[test] + fn test_adguard_network_extension_still_identifies_service() { + let lsof = "p4414\nccom.adguard.mac.vpn.network-extension\n"; let info = detect_from_outputs(outputs(EN0, ROUTES4, lsof)); + + assert_eq!( + info.service.as_deref(), + Some("com.adguard.mac.vpn.network-extension") + ); + assert!(info.endpoints.is_empty()); + } + + #[test] + fn test_adguard_repeated_transport_excludes_one_off_probes() { + let input = format!("{EN0}{UTUN4}"); + let lsof = concat!( + "p811\ncAdGuard VPN\nPTCP\n", + "n192.0.2.10:50001->198.51.100.107:443\n", + "n192.0.2.10:50002->198.51.100.107:443\n", + "n192.0.2.10:50003->203.0.113.8:443\n", + "n192.0.2.10:50004->203.0.113.9:443\n", + ); + let info = detect_from_outputs(outputs(&input, ROUTES4, lsof)); + + assert_eq!( + info.endpoints, + [VpnEndpoint { + address: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 107)), + port: Some(443), + transport: Transport::Tcp, + }] + ); + assert_eq!(info.bootstrap_endpoints.len(), 2); + assert!( + info.bootstrap_endpoints + .iter() + .all(|endpoint| endpoint.port == Some(443)) + ); + } + + #[test] + fn test_adguard_one_off_physical_sockets_are_not_endpoints() { + let lsof = concat!( + "p811\ncAdGuard VPN\nPTCP\n", + "n192.0.2.10:50001->203.0.113.8:443\n", + "n192.0.2.10:50002->203.0.113.9:443\n", + "n192.0.2.10:50003->203.0.113.10:80\n", + ); + let info = detect_from_outputs(outputs(EN0, ROUTES4, lsof)); + assert!(info.endpoints.is_empty()); + assert_eq!(info.bootstrap_endpoints.len(), 2); } #[test] diff --git a/src/killswitch/rules.rs b/src/killswitch/rules.rs index 7e9b6be..982e2af 100644 --- a/src/killswitch/rules.rs +++ b/src/killswitch/rules.rs @@ -10,7 +10,7 @@ const ALLOWED_TAG: &str = "KILLSWITCH_ALLOWED"; /// Every allow rule is explicit and `quick`; the final rule blocks all other /// outbound traffic. IPv6 is therefore allowed through the selected tunnel /// and blocked on physical interfaces without disabling IPv6 system-wide. -pub fn generate(info: &VpnInfo, leak: bool, local: bool) -> Result { +pub fn generate(info: &VpnInfo, leak: bool, local: bool, reconnect: bool) -> Result { let mut rules = String::new(); rules.push_str("# Managed by killswitch; load only into the killswitch anchor.\n"); rules.push_str("# Do not load this file as the main PF ruleset.\n\n"); @@ -50,7 +50,12 @@ pub fn generate(info: &VpnInfo, leak: bool, local: bool) -> Result { )?; } for endpoint in &info.endpoints { - add_endpoint_rule(&mut rules, physical, endpoint)?; + add_endpoint_rule(&mut rules, physical, endpoint, "killswitch-endpoint")?; + } + if reconnect { + for endpoint in &info.bootstrap_endpoints { + add_endpoint_rule(&mut rules, physical, endpoint, "killswitch-bootstrap")?; + } } } @@ -81,15 +86,16 @@ fn add_dhcp_rules(rules: &mut String, interface: &str) -> Result<()> { Ok(()) } -fn add_endpoint_rule(rules: &mut String, interface: &str, endpoint: &VpnEndpoint) -> Result<()> { +fn add_endpoint_rule( + rules: &mut String, + interface: &str, + endpoint: &VpnEndpoint, + label: &str, +) -> Result<()> { let family = match endpoint.address { IpAddr::V4(_) => "inet", IpAddr::V6(_) => "inet6", }; - let port = endpoint - .port - .map_or_else(String::new, |value| format!(" port {value}")); - match endpoint.transport { crate::killswitch::network::Transport::Tcp => { add_endpoint_transport( @@ -99,11 +105,11 @@ fn add_endpoint_rule(rules: &mut String, interface: &str, endpoint: &VpnEndpoint "tcp", " flags any", endpoint, - &port, + label, )?; } crate::killswitch::network::Transport::Udp => { - add_endpoint_transport(rules, interface, family, "udp", "", endpoint, &port)?; + add_endpoint_transport(rules, interface, family, "udp", "", endpoint, label)?; } crate::killswitch::network::Transport::Any => { add_endpoint_transport( @@ -113,9 +119,9 @@ fn add_endpoint_rule(rules: &mut String, interface: &str, endpoint: &VpnEndpoint "tcp", " flags any", endpoint, - &port, + label, )?; - add_endpoint_transport(rules, interface, family, "udp", "", endpoint, &port)?; + add_endpoint_transport(rules, interface, family, "udp", "", endpoint, label)?; } } Ok(()) @@ -128,11 +134,14 @@ fn add_endpoint_transport( protocol: &str, tcp_flags: &str, endpoint: &VpnEndpoint, - port: &str, + label: &str, ) -> Result<()> { + let port = endpoint + .port + .map_or_else(String::new, |value| format!(" port {value}")); writeln!( rules, - "pass out on {interface} {family} proto {protocol} from any to {}{port}{tcp_flags} tag {ALLOWED_TAG} keep state (if-bound) label \"killswitch-endpoint\"", + "pass out on {interface} {family} proto {protocol} from any to {}{port}{tcp_flags} tag {ALLOWED_TAG} keep state (if-bound) label \"{label}\"", endpoint.address )?; Ok(()) @@ -144,6 +153,7 @@ mod tests { use crate::killswitch::network::{Transport, VpnEndpoint, VpnType}; use std::net::{Ipv4Addr, Ipv6Addr}; + // Public fixture addresses are from IANA documentation-only ranges. fn info(connected: bool, endpoint: bool) -> VpnInfo { VpnInfo { vpn_type: VpnType::MacOsNetworkExtension, @@ -156,19 +166,16 @@ mod tests { .collect(), endpoints: endpoint .then(|| VpnEndpoint { - address: IpAddr::V4(Ipv4Addr::new(216, 211, 192, 107)), + address: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 107)), port: Some(443), transport: Transport::Udp, }) .into_iter() .collect(), + bootstrap_endpoints: Vec::new(), physical_interface: Some("en0".to_string()), - physical_ipv4: vec![Ipv4Addr::new(192, 168, 1, 66)], - physical_ipv6: vec![ - "2a00:1370:817c:4a82::66" - .parse() - .unwrap_or(Ipv6Addr::LOCALHOST), - ], + physical_ipv4: vec![Ipv4Addr::new(192, 0, 2, 10)], + physical_ipv6: vec!["2001:db8:1:2::66".parse().unwrap_or(Ipv6Addr::LOCALHOST)], routes: Vec::new(), service: Some("AdGuard VPN".to_string()), } @@ -176,10 +183,10 @@ mod tests { #[test] fn test_anchor_generation_vpn_on() { - let rules = generate(&info(true, true), false, false).unwrap_or_default(); + let rules = generate(&info(true, true), false, false, false).unwrap_or_default(); assert!(rules.contains("pass on utun4 all tag KILLSWITCH_ALLOWED")); assert!( - rules.contains("pass out on en0 inet proto udp from any to 216.211.192.107 port 443") + rules.contains("pass out on en0 inet proto udp from any to 198.51.100.107 port 443") ); assert!(rules.contains("block drop out quick all ! tagged KILLSWITCH_ALLOWED")); assert!(!rules.contains("block out inet6")); @@ -187,7 +194,7 @@ mod tests { #[test] fn test_anchor_generation_vpn_off_is_fail_closed() { - let rules = generate(&info(false, false), false, false).unwrap_or_default(); + let rules = generate(&info(false, false), false, false, false).unwrap_or_default(); assert!(!rules.contains("pass on utun")); assert!(!rules.contains("killswitch-endpoint")); assert!(rules.contains("block drop out quick all ! tagged KILLSWITCH_ALLOWED")); @@ -195,7 +202,7 @@ mod tests { #[test] fn test_endpoint_unknown_does_not_open_physical_interface() { - let rules = generate(&info(true, false), false, false).unwrap_or_default(); + let rules = generate(&info(true, false), false, false, false).unwrap_or_default(); assert!(!rules.contains("killswitch-endpoint")); assert!(rules.contains("block drop out quick all ! tagged KILLSWITCH_ALLOWED")); } @@ -210,7 +217,7 @@ mod tests { port: Some(443), transport: Transport::Tcp, }); - let rules = generate(&value, false, false).unwrap_or_default(); + let rules = generate(&value, false, false, false).unwrap_or_default(); assert!(rules.contains("inet6 proto tcp")); assert!(rules.contains("to 2001:db8::8 port 443")); assert!(rules.contains("port 443 flags any tag KILLSWITCH_ALLOWED")); @@ -218,7 +225,7 @@ mod tests { #[test] fn test_dhcp_loopback_and_optional_local_rules() { - let rules = generate(&info(true, true), false, true).unwrap_or_default(); + let rules = generate(&info(true, true), false, true, false).unwrap_or_default(); assert!(rules.contains("pass on lo0")); assert!(rules.contains("port 68 to any port 67")); assert!(rules.contains("port 546 to ff02::1:2 port 547")); @@ -227,8 +234,8 @@ mod tests { #[test] fn test_leak_mode_remains_opt_in() { - let secure = generate(&info(true, true), false, false).unwrap_or_default(); - let leak = generate(&info(true, true), true, false).unwrap_or_default(); + let secure = generate(&info(true, true), false, false, false).unwrap_or_default(); + let leak = generate(&info(true, true), true, false, false).unwrap_or_default(); assert!(!secure.contains("killswitch-dns-leak")); assert!(leak.contains("killswitch-dns-leak")); assert!(leak.contains("killswitch-icmp6-leak")); @@ -242,9 +249,26 @@ mod tests { port: None, transport: Transport::Any, }); - let rules = generate(&value, false, false).unwrap_or_default(); + let rules = generate(&value, false, false, false).unwrap_or_default(); assert!(rules.contains("proto tcp from any to 203.0.113.8 flags any")); assert!(rules.contains("proto udp from any to 203.0.113.8 tag")); assert!(!rules.contains("203.0.113.8 port")); } + + #[test] + fn test_reconnect_bootstrap_rules_are_opt_in_and_labeled() { + let mut value = info(false, true); + value.bootstrap_endpoints.push(VpnEndpoint { + address: IpAddr::V4(Ipv4Addr::new(203, 0, 113, 9)), + port: Some(443), + transport: Transport::Tcp, + }); + + let strict = generate(&value, false, false, false).unwrap_or_default(); + let reconnect = generate(&value, false, false, true).unwrap_or_default(); + + assert!(!strict.contains("killswitch-bootstrap")); + assert!(reconnect.contains("to 203.0.113.9 port 443")); + assert!(reconnect.contains("label \"killswitch-bootstrap\"")); + } } diff --git a/test_killswitch.sh b/test_killswitch.sh index 5946d1c..d47c13b 100755 --- a/test_killswitch.sh +++ b/test_killswitch.sh @@ -36,12 +36,30 @@ expect_blocked() { echo "PASS: $family blocked" } +show_pf_diagnostics() { + pfctl -a killswitch -vvsr + if [[ ${KILLSWITCH_DEBUG_STATES:-0} -eq 1 ]]; then + pfctl -ss + fi +} + +endpoint_rules() { + pfctl -a killswitch -sr 2>/dev/null \ + | grep 'label "killswitch-endpoint"' \ + | sort +} + +bootstrap_rule_count() { + pfctl -a killswitch -sr 2>/dev/null \ + | grep -c 'label "killswitch-bootstrap"' || true +} + echo "Detected path:" "$KILLSWITCH" -vv echo echo "Generated anchor:" -"$KILLSWITCH" --print -vv +"$KILLSWITCH" --print --reconnect -vv echo echo "Baseline with VPN connected:" @@ -59,9 +77,22 @@ else fi echo -"$KILLSWITCH" -e -v || exit 1 +"$KILLSWITCH" -e --reconnect -v || exit 1 sleep 2 +monitor_count=$(ps ax -o command= | grep -Ec '[k]illswitch[^ ]* --monitor') +if [[ $monitor_count -ne 1 ]]; then + echo "FAIL: expected exactly one VPN monitor, found $monitor_count." + exit 1 +fi +echo "PASS: exactly one VPN monitor is running" + +endpoint_rules_enabled=$(endpoint_rules) +if [[ -z "$endpoint_rules_enabled" ]]; then + echo "FAIL: no trusted VPN endpoint rule was installed." + exit 1 +fi + ipv4_enabled=$(probe -4 https://api.ipify.org) || { echo "FAIL: IPv4 did not work through VPN after enabling." exit 1 @@ -83,56 +114,78 @@ fi echo echo "PF counters after VPN-on traffic:" -pfctl -a killswitch -vvsr -pfctl -ss +show_pf_diagnostics echo read -r -p "Disconnect AdGuard VPN, then press Enter... " sleep 3 failed=0 +endpoint_rules_disconnected=$(endpoint_rules) +if [[ "$endpoint_rules_disconnected" != "$endpoint_rules_enabled" ]]; then + echo "FAIL: endpoint allowlist changed while the VPN was disconnected." + echo "Expected:" + echo "$endpoint_rules_enabled" + echo "Actual:" + echo "$endpoint_rules_disconnected" + failed=1 +else + echo "PASS: endpoint allowlist remained pinned" +fi + +bootstrap_count=$(bootstrap_rule_count) +if [[ $bootstrap_count -gt 8 ]]; then + echo "FAIL: reconnect bootstrap allowlist exceeded its limit ($bootstrap_count > 8)." + failed=1 +else + echo "PASS: reconnect bootstrap allowlist is bounded ($bootstrap_count/8)" +fi + expect_blocked -4 https://api.ipify.org || failed=1 expect_blocked -6 https://api64.ipify.org || failed=1 echo echo "PF counters after VPN-off probes:" -pfctl -a killswitch -vvsr -pfctl -ss +show_pf_diagnostics echo read -r -p "Reconnect AdGuard VPN (utun may change), then press Enter... " -connected=0 -for _ in {1..15}; do - if "$KILLSWITCH" -vv 2>&1 | grep -q "VPN interface: *utun"; then - connected=1 +reconnected=0 +for _ in {1..20}; do + if ipv4_reconnected=$(probe -4 https://api.ipify.org); then + reconnected=1 break fi sleep 2 done -if [[ $connected -eq 0 ]]; then - echo "FAIL: no active routed utun detected after reconnect." +if [[ $reconnected -eq 0 ]]; then + echo "FAIL: IPv4 did not recover within 40 seconds after reconnect." failed=1 -elif ipv4_reconnected=$(probe -4 https://api.ipify.org); then - echo "PASS: reconnect works without --ipv4 ($ipv4_reconnected)" else - echo "FAIL: IPv4 did not recover after reconnect." - failed=1 + echo "PASS: reconnect works without --ipv4 ($ipv4_reconnected)" fi -if [[ $connected -eq 1 && $ipv6_available -eq 1 ]]; then - if ipv6_reconnected=$(probe -6 https://api64.ipify.org); then - echo "PASS: reconnect IPv6 works ($ipv6_reconnected)" - else +if [[ $reconnected -eq 1 && $ipv6_available -eq 1 ]]; then + ipv6_reconnected="" + for _ in {1..10}; do + if ipv6_reconnected=$(probe -6 https://api64.ipify.org); then + break + fi + sleep 2 + done + if [[ -z "$ipv6_reconnected" ]]; then echo "FAIL: IPv6 did not recover after reconnect." failed=1 + else + echo "PASS: reconnect IPv6 works ($ipv6_reconnected)" fi fi echo "$KILLSWITCH" -vv -pfctl -a killswitch -vvsr +show_pf_diagnostics if [[ $failed -ne 0 ]]; then echo "Packet-path test failed. Capture en0 and the detected utun with tcpdump."