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..ede1262
--- /dev/null
+++ b/IMPLEMENTATION.md
@@ -0,0 +1,113 @@
+# 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` 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
+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.
+
+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
+
+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. 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
+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..5d3a26f 100644
--- a/README.md
+++ b/README.md
@@ -1,79 +1,119 @@
# 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` 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
-Show network interfaces, public IP, and detected VPN peer:
+Show the detected VPN path:
+
+ killswitch -vv
+
+Preview the exact PF anchor rules:
+
+ killswitch --print -vv
- $ killswitch
+Enable the kill switch and monitor:
-Enable the kill switch (requires root):
+ sudo killswitch -e -v
- $ sudo killswitch -e
+Enable automatic AdGuard reconnect support:
-Disable and restore default firewall rules:
+ sudo killswitch -e --reconnect -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 |
+| `--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 |
-### 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..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,11 +22,13 @@ pub enum Action {
ipv4: Option,
leak: bool,
local: bool,
+ reconnect: bool,
verbose: Verbosity,
},
ShowInterfaces {
verbose: Verbosity,
},
+ Monitor,
}
impl Action {
@@ -49,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:?}");
@@ -62,6 +66,7 @@ mod tests {
ipv4: None,
leak: true,
local: true,
+ reconnect: true,
verbose: Verbosity::Verbose,
};
assert!(matches!(enable, Action::Enable { .. }));
@@ -80,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 38db9e6..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}");
}
@@ -63,6 +69,8 @@ pub fn execute(action: &Action) -> Result<()> {
let output = killswitch::show_interfaces(*verbose)?;
print!("{output}");
}
+
+ Action::Monitor => killswitch::monitor()?,
}
Ok(())
@@ -81,6 +89,7 @@ mod tests {
ipv4: Some("203.0.113.1".to_string()),
leak: false,
local: false,
+ reconnect: false,
verbose: Verbosity::Normal,
};
@@ -96,6 +105,7 @@ mod tests {
ipv4: Some("198.51.100.1".to_string()),
leak: true,
local: true,
+ reconnect: true,
verbose: Verbosity::Normal,
};
@@ -110,6 +120,7 @@ mod tests {
ipv4: Some("10.8.0.1".to_string()),
leak: false,
local: false,
+ reconnect: false,
verbose: Verbosity::Normal,
};
@@ -124,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 717bb0e..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')
@@ -91,6 +98,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)]
@@ -127,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 60f81f5..f8a1ffd 100644
--- a/src/cli/dispatch/mod.rs
+++ b/src/cli/dispatch/mod.rs
@@ -11,17 +11,22 @@ 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");
+ let reconnect = matches.get_flag("reconnect");
if print {
Ok(Action::Print {
ipv4,
leak,
local,
+ reconnect,
verbose,
})
} else {
@@ -29,6 +34,7 @@ pub fn handler(matches: &ArgMatches, verbose: Verbosity) -> Result {
ipv4,
leak,
local,
+ reconnect,
verbose,
})
}
@@ -40,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 {
@@ -101,6 +109,7 @@ mod tests {
"-e",
"--local",
"--leak",
+ "--reconnect",
"--ipv4",
"10.0.0.1",
]);
@@ -109,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 6a65da7..c315bc8 100644
--- a/src/killswitch/mod.rs
+++ b/src/killswitch/mod.rs
@@ -4,172 +4,537 @@ mod rules;
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};
+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);
+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: &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 {
+ ip.is_private() || ip.is_loopback() || ip.is_link_local()
}
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
-pub fn enable(leak: bool, local: bool, ipv4: Option<&str>, verbose: Verbosity) -> Result<()> {
+/// Returns an error if privileges, detection, PF validation, or monitor startup fails.
+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, reconnect)?;
+ 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,
+ reconnect,
+ manual_endpoint: ipv4.map(validate_manual_endpoint).transpose()?,
+ trusted_endpoints: info.endpoints,
+ };
+ 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,
+ reconnect: 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, reconnect)
}
-/// 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 mut contents = format!(
+ "leak={}\nlocal={}\nreconnect={}\nmanual_endpoint={manual}\n",
+ u8::from(config.leak),
+ 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
+ .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)
+}
+
+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;
+ };
+ 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,
+ })
+}
- let mut out = String::new();
- let _ = writeln!(out, "Interface MAC address IP");
+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,
+ })
+}
- 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<()> {
+ let mut monitor_pids = BTreeSet::new();
+ if let Ok(contents) = fs::read_to_string(MONITOR_PID_PATH)
+ && let Ok(pid) = contents.trim().parse::()
+ {
+ 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 {
+ 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() && 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. 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 known_endpoints: BTreeSet<_> = config.trusted_endpoints.iter().cloned().collect();
+ 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);
+ 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, config.reconnect)
+ && generated != previous
+ && pf::apply_rules(
+ &generated,
+ &physical_addresses(&info),
+ true,
+ Verbosity::Normal,
+ )
+ .is_ok()
+ {
+ previous = generated;
}
- Err(_) if !has_vpn => {
- let _ = writeln!(out, "\nNo VPN interface found, verify VPN is connected");
+ thread::sleep(MONITOR_INTERVAL);
+ if !monitor_pid_is_current(std::process::id()) {
+ return Ok(());
}
- Err(_) => {}
}
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
- let _ = writeln!(out, "\nTo enable the kill switch run: sudo killswitch -e");
- let _ = writeln!(out, "To disable: sudo killswitch -d");
+ #[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)));
+ }
- Ok(out)
+ #[test]
+ fn test_monitor_config_round_trip_parser() {
+ 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(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,
+ })
+ );
+ }
+
+ #[test]
+ 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 f51741e..cde1d06 100644
--- a/src/killswitch/network.rs
+++ b/src/killswitch/network.rs
@@ -1,684 +1,868 @@
-//! 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::collections::BTreeMap;
+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 bootstrap_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;
- }
+const MAX_ADGUARD_BOOTSTRAP_ENDPOINTS: usize = 8;
- 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}");
- }
- }
- }
-
- bail!("No VPN peer found in routing table")
+#[derive(Debug, Default)]
+struct LsofEndpoints {
+ transport: Vec,
+ bootstrap: Vec,
}
-/// Detect VPN peer IP from `WireGuard`.
+/// Detect the physical path, active VPN interface and outer endpoint sockets.
///
-/// 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)");
+/// 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(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
+ );
+ }
+ }
}
- let stdout = String::from_utf8_lossy(&output.stdout);
+ info
+}
- // 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 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() {
+ 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,
+ bootstrap_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;
- };
+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)
+ })
+}
- let IpAddr::V4(ipv4) = addr else {
- return false; // Only IPv4 supported for now
- };
+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())
+}
- let octets = ipv4.octets();
+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)
+}
- // Reject special addresses used by VPN routing tricks
- if ip == "0.0.0.0" || ip == "128.0.0.0" {
+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')
+}
- // Reject private/reserved ranges
- if is_private_ip(&ipv4) {
- return false;
+fn parse_lsof_endpoints(
+ input: &str,
+ physical_ipv4: &[Ipv4Addr],
+ physical_ipv6: &[Ipv6Addr],
+) -> LsofEndpoints {
+ 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();
+ let mut adguard_candidates = Vec::new();
+
+ for line in input.lines() {
+ let Some((tag, value)) = line.split_at_checked(1) else {
+ continue;
+ };
+ 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_transport_process(&command) => {
+ let Some((local, remote)) = value.split_once("->") else {
+ continue;
+ };
+ 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) {
+ let endpoint = VpnEndpoint {
+ address: remote_address,
+ port,
+ transport,
+ };
+ if command.eq_ignore_ascii_case("AdGuard VPN") {
+ adguard_candidates.push(endpoint);
+ } else {
+ endpoints.push(endpoint);
+ }
+ }
+ }
+ _ => {}
+ }
}
-
- // Reject broadcast
- if octets == [255, 255, 255, 255] {
- return false;
+ let mut adguard = select_adguard_endpoints(&adguard_candidates);
+ endpoints.append(&mut adguard.transport);
+ LsofEndpoints {
+ transport: endpoints,
+ bootstrap: adguard.bootstrap,
}
+}
- // Reject multicast (224.0.0.0/4) and reserved (240.0.0.0/4)
- if octets[0] >= 224 {
- return false;
+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,
}
-
- true
}
-// ============================================================================
-// Network Interface Detection
-// ============================================================================
-
-/// Represents a detected network interface.
-pub struct InterfaceInfo {
- name: String,
- mac: String,
- ip: String,
- is_p2p: bool,
+fn parse_lsof_service(input: &str) -> Option {
+ input.lines().find_map(|line| {
+ line.strip_prefix('c')
+ .filter(|name| is_vpn_service_process(name))
+ .map(str::to_string)
+ })
}
-impl InterfaceInfo {
- /// Get the interface name (e.g., "en0", "utun0").
- #[must_use]
- pub fn name(&self) -> &str {
- &self.name
+fn is_vpn_transport_process(name: &str) -> bool {
+ let folded = name.to_ascii_lowercase();
+ // 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;
}
- /// 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
- }
+ ["wireguard", "tailscale", "openvpn", "protonvpn", "nordvpn"]
+ .iter()
+ .any(|needle| folded.contains(needle))
+}
- /// Check if this is a point-to-point (VPN) interface.
- #[must_use]
- pub fn is_p2p(&self) -> bool {
- self.is_p2p
- }
+fn is_vpn_service_process(name: &str) -> bool {
+ is_vpn_transport_process(name) || contains_folded(name, "com.adguard")
}
-/// 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")?;
+fn contains_folded(value: &str, needle: &str) -> bool {
+ value.to_ascii_lowercase().contains(needle)
+}
- if !output.status.success() {
- bail!("ifconfig command failed");
+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()))
+}
- 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();
+fn parse_wireguard_endpoints(input: &str) -> Vec {
+ input
+ .lines()
+ .filter_map(|line| {
+ let raw = line.split_whitespace().last()?;
+ if raw == "(none)" {
+ return None;
}
- continue;
- }
-
- if current_name.is_empty() {
- 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.") {
- continue;
- }
+ let (address, port) = parse_socket_address(raw)?;
+ is_public_endpoint(address).then_some(VpnEndpoint {
+ address,
+ port,
+ transport: Transport::Udp,
+ })
+ })
+ .collect()
+}
- 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()
- };
+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()
+}
- interfaces.push(InterfaceInfo {
- name: current_name.clone(),
- mac: current_mac.clone(),
- ip: ip_display,
- is_p2p: current_is_p2p,
- });
- }
- }
+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()),
+ );
}
-
- Ok(interfaces)
+ 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)
}
-// ============================================================================
-// Public IP Detection
-// ============================================================================
+fn resolve_host(host: &str) -> Option {
+ (host, 0)
+ .to_socket_addrs()
+ .ok()?
+ .map(|socket| socket.ip())
+ .find(|address| matches!(address, IpAddr::V4(_)))
+}
-/// 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);
- }
+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
}
}
-
- bail!("Failed to detect public IP")
}
-// ============================================================================
-// 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_link_local_v6(address: Ipv6Addr) -> bool {
+ address.segments()[0] & 0xffc0 == 0xfe80
}
-// ============================================================================
-// 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)
+/// 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
}
-// ============================================================================
-// Tests
-// ============================================================================
-
#[cfg(test)]
mod tests {
use super::*;
- // -------------------------------------------------------------------------
- // Route destination extraction tests
- // -------------------------------------------------------------------------
-
- #[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())
- );
+ // 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.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.0.2.1 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_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 test_vpn_not_found() {
+ let info = detect_from_outputs(outputs(EN0, "malformed", ""));
+ assert!(!info.is_connected());
+ assert!(info.endpoints.is_empty());
}
#[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);
+ 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);
}
#[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()));
+ 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"));
}
- // -------------------------------------------------------------------------
- // VPN peer validation tests
- // -------------------------------------------------------------------------
-
#[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 test_endpoint_known_from_adguard_socket() {
+ let input = format!("{EN0}{UTUN4}");
+ let lsof = concat!(
+ "p811\ncAdGuard VPN\nPUDP\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:
+ // 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(198, 51, 100, 107)),
+ port: Some(443),
+ transport: Transport::Udp,
+ })
+ );
}
#[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 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_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"));
+ 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, 0, 2, 10)]);
+ assert_eq!(info.physical_ipv6.len(), 1);
}
#[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"));
+ 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_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"));
+ 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_is_valid_vpn_peer_rejects_ipv6() {
- assert!(!is_valid_vpn_peer("::1"));
- assert!(!is_valid_vpn_peer("2001:db8::1"));
+ fn test_non_vpn_process_socket_is_not_endpoint() {
+ 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_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"));
- }
-
- // -------------------------------------------------------------------------
- // Hex to CIDR conversion tests
- // -------------------------------------------------------------------------
+ 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));
- #[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));
+ 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_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);
- }
-
- // -------------------------------------------------------------------------
- // Port stripping tests
- // -------------------------------------------------------------------------
+ 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));
- #[test]
- fn test_strip_port_bare_ipv4() {
- assert_eq!(strip_port("1.2.3.4"), "1.2.3.4");
+ assert_eq!(
+ info.service.as_deref(),
+ Some("com.adguard.mac.vpn.network-extension")
+ );
+ 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_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));
- #[test]
- fn test_strip_port_hostname_with_port() {
- assert_eq!(strip_port("myvpn.example.com:51820"), "myvpn.example.com");
+ 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_strip_port_bare_hostname() {
- assert_eq!(strip_port("myvpn.example.com"), "myvpn.example.com");
- }
+ 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));
- #[test]
- fn test_strip_port_ipv6_bracketed_with_port() {
- assert_eq!(strip_port("[::1]:51820"), "::1");
+ assert!(info.endpoints.is_empty());
+ assert_eq!(info.bootstrap_endpoints.len(), 2);
}
#[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..982e2af 100644
--- a/src/killswitch/rules.rs
+++ b/src/killswitch/rules.rs
@@ -1,161 +1,274 @@
-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, 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");
- // 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 out on {physical} proto {{ tcp, udp }} to any port 53 tag {ALLOWED_TAG} keep state (if-bound) label \"killswitch-dns-leak\""
+ )?;
writeln!(
rules,
- "pass from $int_{0}:network to $int_{0}:network",
- iface.name()
+ "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, "killswitch-endpoint")?;
+ }
+ if reconnect {
+ for endpoint in &info.bootstrap_endpoints {
+ add_endpoint_rule(&mut rules, physical, endpoint, "killswitch-bootstrap")?;
+ }
+ }
}
- // 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,
+ label: &str,
+) -> Result<()> {
+ let family = match endpoint.address {
+ IpAddr::V4(_) => "inet",
+ IpAddr::V6(_) => "inet6",
+ };
+ match endpoint.transport {
+ crate::killswitch::network::Transport::Tcp => {
+ add_endpoint_transport(
+ rules,
+ interface,
+ family,
+ "tcp",
+ " flags any",
+ endpoint,
+ label,
+ )?;
+ }
+ crate::killswitch::network::Transport::Udp => {
+ add_endpoint_transport(rules, interface, family, "udp", "", endpoint, label)?;
+ }
+ crate::killswitch::network::Transport::Any => {
+ add_endpoint_transport(
+ rules,
+ interface,
+ family,
+ "tcp",
+ " flags any",
+ endpoint,
+ label,
+ )?;
+ add_endpoint_transport(rules, interface, family, "udp", "", endpoint, label)?;
+ }
}
+ Ok(())
+}
- Ok(rules)
+fn add_endpoint_transport(
+ rules: &mut String,
+ interface: &str,
+ family: &str,
+ protocol: &str,
+ tcp_flags: &str,
+ endpoint: &VpnEndpoint,
+ 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 \"{label}\"",
+ 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};
+
+ // Public fixture addresses are from IANA documentation-only ranges.
+ 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(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, 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()),
+ }
}
- #[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_anchor_generation_vpn_on() {
+ 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 198.51.100.107 port 443")
+ );
+ assert!(rules.contains("block drop out quick all ! tagged KILLSWITCH_ALLOWED"));
+ assert!(!rules.contains("block out inet6"));
}
- #[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_anchor_generation_vpn_off_is_fail_closed() {
+ 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"));
}
#[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_endpoint_unknown_does_not_open_physical_interface() {
+ 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"));
}
#[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_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, 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"));
+ }
+
+ #[test]
+ fn test_dhcp_loopback_and_optional_local_rules() {
+ 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"));
+ assert!(rules.contains("from en0:network to en0:network"));
+ }
+
+ #[test]
+ fn test_leak_mode_remains_opt_in() {
+ 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"));
+ }
+
+ #[test]
+ 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, 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 afb1a52..d47c13b 100755
--- a/test_killswitch.sh
+++ b/test_killswitch.sh
@@ -1,232 +1,195 @@
#!/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
+
+probe() {
+ local family=$1
+ local url=$2
+ curl "$family" --silent --show-error --max-time 10 "$url" 2>/dev/null
}
-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
+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"
}
-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
- return 1
+show_pf_diagnostics() {
+ pfctl -a killswitch -vvsr
+ if [[ ${KILLSWITCH_DEBUG_STATES:-0} -eq 1 ]]; then
+ pfctl -ss
fi
}
-run_all_tests() {
- local label=$1
- echo -e " ${CYAN}$label${NC}" >&2
-
- 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
+endpoint_rules() {
+ pfctl -a killswitch -sr 2>/dev/null \
+ | grep 'label "killswitch-endpoint"' \
+ | sort
+}
- echo "$ping_ok $dns_ok $http_ok"
+bootstrap_rule_count() {
+ pfctl -a killswitch -sr 2>/dev/null \
+ | grep -c 'label "killswitch-bootstrap"' || true
}
-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 "Detected path:"
+"$KILLSWITCH" -vv
+
+echo
+echo "Generated anchor:"
+"$KILLSWITCH" --print --reconnect -vv
+
+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
+echo
+"$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"
-# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-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
+endpoint_rules_enabled=$(endpoint_rules)
+if [[ -z "$endpoint_rules_enabled" ]]; then
+ echo "FAIL: no trusted VPN endpoint rule was installed."
exit 1
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
+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 "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:"
+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
-# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-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
+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 ""
- 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 "PASS: reconnect bootstrap allowlist is bounded ($bootstrap_count/8)"
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}"
+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:"
+show_pf_diagnostics
+
+echo
+read -r -p "Reconnect AdGuard VPN (utun may change), then press Enter... "
+
+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 [[ $reconnected -eq 0 ]]; then
+ echo "FAIL: IPv4 did not recover within 40 seconds after reconnect."
+ failed=1
else
- echo -e "${RED}══════════════════════════════════════════════${NC}"
- echo -e "${RED} TEST FAILED - Review results above${NC}"
- echo -e "${RED}══════════════════════════════════════════════${NC}"
+ echo "PASS: reconnect works without --ipv4 ($ipv4_reconnected)"
fi
-echo ""
+
+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
+show_pf_diagnostics
+
+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."