diff options
| author | Joakim Hulthe <joakim.hulthe@mullvad.net> | 2024-10-22 14:29:01 +0200 |
|---|---|---|
| committer | Joakim Hulthe <joakim.hulthe@mullvad.net> | 2025-01-24 17:38:27 +0100 |
| commit | 73ecf1c4a954318359a10c1be232570798b398de (patch) | |
| tree | a78c7cc36f9d8f4260a177c54d711021f2181463 | |
| parent | 654de1cd2d3cdde6a3c26fe0cf5f26a4b0d1a89c (diff) | |
| download | mullvadvpn-73ecf1c4a954318359a10c1be232570798b398de.tar.xz mullvadvpn-73ecf1c4a954318359a10c1be232570798b398de.zip | |
Add PoC leak checker library and CLI
| -rw-r--r-- | Cargo.lock | 287 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | leak-checker/Cargo.toml | 34 | ||||
| -rw-r--r-- | leak-checker/examples/leaker-cli.rs | 36 | ||||
| -rw-r--r-- | leak-checker/notes.md | 16 | ||||
| -rw-r--r-- | leak-checker/src/am_i_mullvad.rs | 90 | ||||
| -rw-r--r-- | leak-checker/src/lib.rs | 24 | ||||
| -rw-r--r-- | leak-checker/src/traceroute.rs | 617 | ||||
| -rw-r--r-- | leak-checker/src/util.rs | 35 | ||||
| -rw-r--r-- | mullvad-daemon/src/leak_checker/mod.rs | 26 | ||||
| -rw-r--r-- | mullvad-daemon/src/lib.rs | 1 | ||||
| -rw-r--r-- | talpid-core/Cargo.toml | 3 | ||||
| -rw-r--r-- | talpid-core/src/firewall/macos.rs | 55 |
13 files changed, 1197 insertions, 28 deletions
diff --git a/Cargo.lock b/Cargo.lock index 110b6b197a..651439802b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1163,6 +1163,16 @@ dependencies = [ ] [[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] name = "fastrand" version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1374,8 +1384,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1706,6 +1718,24 @@ dependencies = [ ] [[package]] +name = "hyper-rustls" +version = "0.27.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" +dependencies = [ + "futures-util", + "http 1.1.0", + "hyper", + "hyper-util", + "rustls 0.23.18", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.0", + "tower-service", + "webpki-roots 0.26.7", +] + +[[package]] name = "hyper-timeout" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1906,6 +1936,12 @@ dependencies = [ ] [[package]] +name = "indenter" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" + +[[package]] name = "indexmap" version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2175,6 +2211,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] +name = "leak-checker" +version = "0.1.0" +dependencies = [ + "clap", + "eyre", + "futures", + "log", + "match_cfg", + "nix 0.29.0", + "pnet_packet 0.35.0", + "pretty_env_logger", + "reqwest", + "serde", + "socket2", + "talpid-windows", + "tokio", + "windows-sys 0.52.0", +] + +[[package]] name = "libc" version = "0.2.158" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2566,7 +2622,7 @@ dependencies = [ "rustls 0.21.11", "serde", "tokio", - "webpki-roots", + "webpki-roots 0.25.4", ] [[package]] @@ -2925,6 +2981,7 @@ dependencies = [ "cfg-if", "cfg_aliases 0.2.1", "libc", + "memoffset 0.9.1", ] [[package]] @@ -3289,8 +3346,6 @@ dependencies = [ [[package]] name = "pfctl" version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44e65c0d3523afa79a600a3964c3ac0fabdabe2d7c68da624b2bb0b441b9d61" dependencies = [ "derive_builder", "ioctl-sys 0.8.0", @@ -3405,6 +3460,15 @@ dependencies = [ ] [[package]] +name = "pnet_base" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc190d4067df16af3aba49b3b74c469e611cad6314676eaf1157f31aa0fb2f7" +dependencies = [ + "no-std-net", +] + +[[package]] name = "pnet_macros" version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3417,12 +3481,33 @@ dependencies = [ ] [[package]] +name = "pnet_macros" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13325ac86ee1a80a480b0bc8e3d30c25d133616112bb16e86f712dcf8a71c863" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.89", +] + +[[package]] name = "pnet_macros_support" version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eea925b72f4bd37f8eab0f221bbe4c78b63498350c983ffa9dd4bcde7e030f56" dependencies = [ - "pnet_base", + "pnet_base 0.34.0", +] + +[[package]] +name = "pnet_macros_support" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed67a952585d509dd0003049b1fc56b982ac665c8299b124b90ea2bdb3134ab" +dependencies = [ + "pnet_base 0.35.0", ] [[package]] @@ -3432,9 +3517,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9a005825396b7fe7a38a8e288dbc342d5034dac80c15212436424fef8ea90ba" dependencies = [ "glob", - "pnet_base", - "pnet_macros", - "pnet_macros_support", + "pnet_base 0.34.0", + "pnet_macros 0.34.0", + "pnet_macros_support 0.34.0", +] + +[[package]] +name = "pnet_packet" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c96ebadfab635fcc23036ba30a7d33a80c39e8461b8bd7dc7bb186acb96560f" +dependencies = [ + "glob", + "pnet_base 0.35.0", + "pnet_macros 0.35.0", + "pnet_macros_support 0.35.0", ] [[package]] @@ -3473,6 +3570,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] +name = "pretty_env_logger" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c" +dependencies = [ + "env_logger 0.10.2", + "log", +] + +[[package]] name = "prettyplease" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3622,6 +3729,58 @@ dependencies = [ ] [[package]] +name = "quinn" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e96808277ec6f97351a2380e6c25114bc9e67037775464979f3037c92d05ef" +dependencies = [ + "bytes", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.18", + "socket2", + "thiserror 2.0.9", + "tokio", + "tracing", +] + +[[package]] +name = "quinn-proto" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d" +dependencies = [ + "bytes", + "getrandom 0.2.14", + "rand 0.8.5", + "ring", + "rustc-hash", + "rustls 0.23.18", + "rustls-pki-types", + "slab", + "thiserror 2.0.9", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c40286217b4ba3a71d644d752e6a0b71f13f1b6a2c5311acfcbe0c2418ed904" +dependencies = [ + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] name = "quote" version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3770,6 +3929,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" [[package]] +name = "reqwest" +version = "0.12.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http 1.1.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.18", + "rustls-pemfile 2.1.3", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.1", + "tokio", + "tokio-rustls 0.26.0", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 0.26.7", + "windows-registry", +] + +[[package]] name = "resolv-conf" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3841,6 +4042,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" [[package]] +name = "rustc-hash" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" + +[[package]] name = "rustc_version" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3913,6 +4120,9 @@ name = "rustls-pki-types" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" +dependencies = [ + "web-time", +] [[package]] name = "rustls-webpki" @@ -4287,9 +4497,9 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "socket2" -version = "0.5.6" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" dependencies = [ "libc", "windows-sys 0.52.0", @@ -4340,7 +4550,7 @@ checksum = "efbf95ce4c7c5b311d2ce3f088af2b93edef0f09727fa50fbe03c7a979afce77" dependencies = [ "hex", "parking_lot", - "pnet_packet", + "pnet_packet 0.34.0", "rand 0.8.5", "socket2", "thiserror 1.0.59", @@ -4392,6 +4602,9 @@ name = "sync_wrapper" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -4450,7 +4663,7 @@ dependencies = [ "parking_lot", "pcap", "pfctl", - "pnet_packet", + "pnet_packet 0.34.0", "rand 0.8.5", "resolv-conf", "serde", @@ -5344,6 +5557,18 @@ dependencies = [ ] [[package]] +name = "wasm-bindgen-futures" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] name = "wasm-bindgen-macro" version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5373,12 +5598,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" [[package]] +name = "web-sys" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] name = "webpki-roots" version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" [[package]] +name = "webpki-roots" +version = "0.26.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d642ff16b7e79272ae451b7322067cdc17cadf68c23264be9d94a32319efe7e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] name = "which" version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5493,6 +5747,17 @@ dependencies = [ ] [[package]] +name = "windows-registry" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +dependencies = [ + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] name = "windows-result" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml index 24f40c5e8f..13206e5db7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ members = [ "tunnel-obfuscation", "wireguard-go-rs", "windows-installer", + "leak-checker", ] # Default members dictate what is built when running `cargo build` in the root directory. # This is set to a minimal set of packages to speed up the build process and avoid building diff --git a/leak-checker/Cargo.toml b/leak-checker/Cargo.toml new file mode 100644 index 0000000000..6a24daba0c --- /dev/null +++ b/leak-checker/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "leak-checker" +version = "0.1.0" +authors.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +log.workspace = true +eyre = "0.6.12" +socket2 = { version = "0.5.7", features = ["all"] } +match_cfg = "0.1.0" +pnet_packet = "0.35.0" +pretty_env_logger = "0.5.0" +tokio = { workspace = true, features = ["macros", "time", "rt", "sync", "net"] } +futures.workspace = true +serde = { workspace = true, features = ["derive"] } +reqwest = { version = "0.12.9", default-features = false, features = ["json", "rustls-tls"] } +clap = { version = "*", features = ["derive"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["full"] } + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.29.0", features = ["net"] } + +[target.'cfg(windows)'.dependencies] +windows-sys.workspace = true +talpid-windows = { path = "../talpid-windows" } + +[lints] +workspace = true diff --git a/leak-checker/examples/leaker-cli.rs b/leak-checker/examples/leaker-cli.rs new file mode 100644 index 0000000000..3a391e7bf1 --- /dev/null +++ b/leak-checker/examples/leaker-cli.rs @@ -0,0 +1,36 @@ +use clap::{Parser, Subcommand}; +use leak_checker::{am_i_mullvad::AmIMullvadOpt, traceroute::TracerouteOpt}; + +#[derive(Parser)] +pub struct Opt { + #[clap(subcommand)] + pub method: LeakMethod, +} + +#[derive(Subcommand, Clone)] +pub enum LeakMethod { + /// Check for leaks by binding to a non-tunnel interface and probing for reachable nodes. + Traceroute(#[clap(flatten)] TracerouteOpt), + + /// Ask `am.i.mullvad.net` whether you are leaking. + AmIMullvad(#[clap(flatten)] AmIMullvadOpt), +} + +#[tokio::main] +async fn main() -> eyre::Result<()> { + pretty_env_logger::formatted_builder() + .filter_level(log::LevelFilter::Debug) + .parse_default_env() + .init(); + + let opt = Opt::parse(); + + let leak_status = match &opt.method { + LeakMethod::Traceroute(opt) => leak_checker::traceroute::run_leak_test(opt).await, + LeakMethod::AmIMullvad(opt) => leak_checker::am_i_mullvad::run_leak_test(opt).await, + }; + + log::info!("Leak status: {leak_status:#?}"); + + Ok(()) +} diff --git a/leak-checker/notes.md b/leak-checker/notes.md new file mode 100644 index 0000000000..237bc2f12b --- /dev/null +++ b/leak-checker/notes.md @@ -0,0 +1,16 @@ +# Apple notes + +The first packet is always dropped when a connection is routed and NATed + + +The NAT rules do not match up with the firewall rules in regards to the relay + + +``` +# NAT-rule +no nat inet from any to 185.213.154.68 + +# FW-rule +pass out quick inet proto udp from any to 185.213.154.68 port = 49020 user = 0 keep state +``` + diff --git a/leak-checker/src/am_i_mullvad.rs b/leak-checker/src/am_i_mullvad.rs new file mode 100644 index 0000000000..f024e54ea7 --- /dev/null +++ b/leak-checker/src/am_i_mullvad.rs @@ -0,0 +1,90 @@ +use eyre::{eyre, Context}; +use futures::TryFutureExt; +use match_cfg::match_cfg; +use reqwest::{Client, ClientBuilder}; +use serde::Deserialize; + +use crate::{LeakInfo, LeakStatus}; + +#[derive(Clone, clap::Args)] +pub struct AmIMullvadOpt { + /// Try to bind to a specific interface + #[clap(short, long)] + interface: Option<String>, +} + +const AM_I_MULLVAD_URL: &str = "https://am.i.mullvad.net/json"; + +/// [try_run_leak_test], but on an error, assume we aren't leaking. +pub async fn run_leak_test(opt: &AmIMullvadOpt) -> LeakStatus { + try_run_leak_test(opt) + .await + .inspect_err(|e| log::debug!("Leak test errored, assuming no leak. {e:?}")) + .unwrap_or(LeakStatus::NoLeak) +} + +/// Check if connected to Mullvad and print the result to stdout +pub async fn try_run_leak_test(opt: &AmIMullvadOpt) -> eyre::Result<LeakStatus> { + #[derive(Debug, Deserialize)] + struct Response { + ip: String, + mullvad_exit_ip_hostname: Option<String>, + } + + let mut client = Client::builder(); + + if let Some(interface) = &opt.interface { + client = bind_client_to_interface(client, interface)?; + } + + let client = client.build().wrap_err("Failed to create HTTP client")?; + let response: Response = client + .get(AM_I_MULLVAD_URL) + //.timeout(Duration::from_secs(opt.timeout)) + .send() + .and_then(|r| r.json()) + .await + .wrap_err_with(|| eyre!("Failed to GET {AM_I_MULLVAD_URL}"))?; + + if let Some(server) = &response.mullvad_exit_ip_hostname { + log::debug!( + "You are connected to Mullvad (server {}). Your IP address is {}", + server, + response.ip + ); + Ok(LeakStatus::NoLeak) + } else { + log::debug!( + "You are not connected to Mullvad. Your IP address is {}", + response.ip + ); + Ok(LeakStatus::LeakDetected(LeakInfo::AmIMullvad { + ip: response.ip.parse().wrap_err("Malformed IP")?, + })) + } +} + +match_cfg! { + #[cfg(target_os = "linux")] => { + fn bind_client_to_interface( + builder: ClientBuilder, + interface: &str + ) -> eyre::Result<ClientBuilder> { + log::debug!("Binding HTTP client to {interface}"); + Ok(builder.interface(interface)) + } + } + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "android"))] => { + fn bind_client_to_interface( + builder: ClientBuilder, + interface: &str + ) -> eyre::Result<ClientBuilder> { + use crate::util::get_interface_ip; + + let ip = get_interface_ip(interface)?; + + log::debug!("Binding HTTP client to {ip} ({interface})"); + Ok(builder.local_address(ip)) + } + } +} diff --git a/leak-checker/src/lib.rs b/leak-checker/src/lib.rs new file mode 100644 index 0000000000..1927385bc1 --- /dev/null +++ b/leak-checker/src/lib.rs @@ -0,0 +1,24 @@ +use std::net::IpAddr; + +pub mod am_i_mullvad; +pub mod traceroute; +mod util; + +#[derive(Clone, Debug)] +pub enum LeakStatus { + NoLeak, + LeakDetected(LeakInfo), +} + +/// Details about how a leak happened +#[derive(Clone, Debug)] +pub enum LeakInfo { + /// Managed to reach another network node on the physical interface, bypassing firewall rules. + NodeReachableOnInterface { + reachable_nodes: Vec<IpAddr>, + interface: String, + }, + + /// Queried a <https://am.i.mullvad.net>, and was not mullvad. + AmIMullvad { ip: IpAddr }, +} diff --git a/leak-checker/src/traceroute.rs b/leak-checker/src/traceroute.rs new file mode 100644 index 0000000000..836546c15c --- /dev/null +++ b/leak-checker/src/traceroute.rs @@ -0,0 +1,617 @@ +use std::{ + ascii::escape_default, + ffi::c_void, + io, + net::{IpAddr, Ipv4Addr}, + ops::{Range, RangeFrom}, + os::fd::{AsFd, AsRawFd, FromRawFd, IntoRawFd}, + time::Duration, +}; + +use eyre::{bail, ensure, eyre, OptionExt, WrapErr}; +use futures::{future::pending, stream, StreamExt, TryFutureExt, TryStreamExt}; +use match_cfg::match_cfg; +use nix::libc::setsockopt; +use pnet_packet::{ + icmp::{ + echo_request::EchoRequestPacket, time_exceeded::TimeExceededPacket, IcmpPacket, IcmpTypes, + }, + ip::IpNextHeaderProtocols as IpProtocol, + ipv4::Ipv4Packet, + udp::UdpPacket, + Packet, +}; +use socket2::{Domain, Protocol, Socket, Type}; +use tokio::{ + net::UdpSocket, + select, + time::{sleep, sleep_until, timeout, Instant}, +}; + +use crate::{LeakInfo, LeakStatus}; + +#[derive(Clone, clap::Args)] +pub struct TracerouteOpt { + /// Try to bind to a specific interface + #[clap(short, long)] + pub interface: String, + + /// Destination IP of the probe packets + #[clap(short, long)] + pub destination: Ipv4Addr, + + /// Avoid sending probe packets to this port + #[clap(long)] + pub exclude_port: Option<u16>, + + /// Send probe packets only to this port, instead of the default ports. + #[clap(long)] + pub port: Option<u16>, + + /// Use ICMP-Echo for the probe packets instead of UDP. + #[clap(long)] + pub icmp: bool, +} + +/// Type of the UDP payload of the probe packets +type ProbePayload = [u8; 32]; + +/// Value of the UDP payload of the probe packets +const PROBE_PAYLOAD: ProbePayload = *b"ABCDEFGHIJKLMNOPQRSTUVWXYZ123456"; + +/// Timeout of the leak test as a whole. Should be more than [SEND_TIMEOUT] + [RECV_TIMEOUT]. +const LEAK_TIMEOUT: Duration = Duration::from_secs(5); + +/// Timeout of sending probe packets +const SEND_TIMEOUT: Duration = Duration::from_secs(1); + +/// Timeout of receiving additional probe packets after the first one +const RECV_TIMEOUT: Duration = Duration::from_secs(1); + +/// Default range of ports for the probe packets. Stolen from `traceroute`. +const DEFAULT_PORT_RANGE: RangeFrom<u16> = 33434..; + +/// Range of TTL values for the probe packets. +const DEFAULT_TTL_RANGE: Range<u16> = 1..6; + +/// [try_run_leak_test], but on an error, assume we aren't leaking. +pub async fn run_leak_test(opt: &TracerouteOpt) -> LeakStatus { + try_run_leak_test(opt) + .await + .inspect_err(|e| log::debug!("Leak test errored, assuming no leak. {e:?}")) + .unwrap_or(LeakStatus::NoLeak) +} + +/// Run a traceroute-based leak test. +/// +/// This test will try to create a socket and bind it to `interface`. Then it will send either UDP +/// or ICMP Echo packets to `destination` with very low TTL values. If any network nodes between +/// this one and `destination` see a packet with a TTL value of 0, they will _probably_ return an +/// ICMP/TimeExceeded response. +/// +/// If we receive the response, we know the outgoing packet was NOT blocked by the firewall, and +/// therefore we are leaking. Since we set the TTL very low, this also means that in the event of a +/// leak, the packet will _probably_ not make it out of the users local network, e.g. the local +/// router will probably be the first node that gives a reply. Since the packet should not actually +/// reach `destination`, this testing method is resistant to being fingerprinted or censored. +/// +/// This test needs a raw socket to be able to listen for the ICMP responses, therefore it requires +/// root/admin priviliges. +pub async fn try_run_leak_test(opt: &TracerouteOpt) -> eyre::Result<LeakStatus> { + // create the socket used for receiving the ICMP/TimeExceeded responses + let icmp_socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::ICMPV4)) + .wrap_err("Failed to open ICMP socket")?; + + icmp_socket + .set_nonblocking(true) + .wrap_err("Failed to set icmp_socket to nonblocking")?; + + let n = 1; + unsafe { + setsockopt( + icmp_socket.as_fd().as_raw_fd(), + nix::libc::SOL_IP, + nix::libc::IP_RECVERR, + &n as *const _ as *const c_void, + size_of_val(&n) as u32, + ) + }; + + bind_socket_to_interface(&icmp_socket, &opt.interface)?; + + // HACK: Wrap the socket in a tokio::net::UdpSocket to be able to use it async + // SAFETY: `into_raw_fd()` consumes the socket and returns an owned & open file descriptor. + let icmp_socket = unsafe { std::net::UdpSocket::from_raw_fd(icmp_socket.into_raw_fd()) }; + let icmp_socket = UdpSocket::from_std(icmp_socket)?; + + // on Windows, we need to do some additional configuration of the raw socket + #[cfg(target_os = "windows")] + configure_listen_socket(&icmp_socket, interface)?; + + // create the socket used for sending the UDP probing packets + let udp_socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)) + .wrap_err("Failed to open UDP socket")?; + bind_socket_to_interface(&udp_socket, &opt.interface) + .wrap_err("Failed to bind UDP socket to interface")?; + udp_socket + .set_nonblocking(true) + .wrap_err("Failed to set udp_socket to nonblocking")?; + + // HACK: Wrap the socket in a tokio::net::UdpSocket to be able to use it async + // SAFETY: `into_raw_fd()` consumes the socket and returns an owned & open file descriptor. + let udp_socket = unsafe { std::net::UdpSocket::from_raw_fd(udp_socket.into_raw_fd()) }; + let udp_socket = UdpSocket::from_std(udp_socket)?; + drop(udp_socket); + + let mut icmp_socket = icmp_socket; + timeout(SEND_TIMEOUT, send_icmp_probes(&mut icmp_socket, opt)) + .map_err(|_timeout| eyre!("Timed out while trying to send probe packet")) + .await??; + + let recv_task = read_probe_responses(&opt.interface, icmp_socket); + + // wait until either task exits, or the timeout is reached + let leak_status = select! { + _ = sleep(LEAK_TIMEOUT) => LeakStatus::NoLeak, + result = recv_task => result?, + }; + + // let send_task = timeout(SEND_TIMEOUT, send_icmp_probes(&mut udp_socket, opt)) + // .map_err(|_timeout| eyre!("Timed out while trying to send probe packet")) + // // never return on success + // .and_then(|_| pending()); + // + // let recv_task = read_probe_responses(&opt.interface, icmp_socket); + // + // wait until either thread exits, or the timeout is reached + // let leak_status = select! { + // _ = sleep(LEAK_TIMEOUT) => LeakStatus::NoLeak, + // result = recv_task => result?, + // result = send_task => result?, + // }; + + Ok(leak_status) +} + +async fn send_icmp_probes(socket: &mut UdpSocket, opt: &TracerouteOpt) -> eyre::Result<()> { + use pnet_packet::icmp::{echo_request::*, *}; + + let ports = DEFAULT_PORT_RANGE + // ensure we don't send anything to `opt.exclude_port` + .filter(|&p| Some(p) != opt.exclude_port) + // `opt.port` overrides the default port range + .map(|port| opt.port.unwrap_or(port)); + + for (port, ttl) in ports.zip(DEFAULT_TTL_RANGE) { + log::debug!("sending probe packet (ttl={ttl})"); + + socket + .set_ttl(ttl.into()) + .wrap_err("Failed to set TTL on socket")?; + + // the first packet will sometimes get dropped on MacOS, thus we send two packets + let number_of_sends = if cfg!(target_os = "macos") { 2 } else { 1 }; + + let echo = EchoRequest { + icmp_type: IcmpTypes::EchoRequest, + icmp_code: IcmpCode(0), + checksum: 0, + identifier: 1, + sequence_number: 1, + payload: PROBE_PAYLOAD.to_vec(), + }; + let mut packet = + MutableEchoRequestPacket::owned(vec![0u8; 8 + PROBE_PAYLOAD.len()]).unwrap(); + packet.populate(&echo); + packet.set_checksum(checksum(&IcmpPacket::new(packet.packet()).unwrap())); + + log::error!("echo packet: {:02x?}", packet.packet()); + + let result: io::Result<()> = stream::iter(0..number_of_sends) + // call `send_to` `number_of_sends` times + .then(|_| socket.send_to(&packet.packet(), (opt.destination, port))) + .map_ok(drop) + .try_collect() // abort on the first error + .await; + + let Err(e) = result else { continue }; + match e.kind() { + io::ErrorKind::PermissionDenied => { + // Linux returns this error if our packet was rejected by nftables. + log::debug!("send_to failed with 'permission denied'"); + } + _ => return Err(e).wrap_err("Failed to send packet")?, + } + } + + Ok(()) +} + +async fn send_udp_probes(socket: UdpSocket, opt: &TracerouteOpt) -> eyre::Result<()> { + // ensure we don't send anything to `opt.exclude_port` + let ports = DEFAULT_PORT_RANGE + // skip the excluded port + .filter(|&p| Some(p) != opt.exclude_port) + // `opt.port` overrides the default port range + .map(|port| opt.port.unwrap_or(port)); + + for (port, ttl) in ports.zip(DEFAULT_TTL_RANGE) { + log::debug!("sending probe packet (ttl={ttl})"); + + socket + .set_ttl(ttl.into()) + .wrap_err("Failed to set TTL on socket")?; + + // the first packet will sometimes get dropped on MacOS, thus we send two packets + let number_of_sends = if cfg!(target_os = "macos") { 2 } else { 1 }; + + let result: io::Result<()> = stream::iter(0..number_of_sends) + // call `send_to` `number_of_sends` times + .then(|_| socket.send_to(&PROBE_PAYLOAD, (opt.destination, port))) + .map_ok(drop) + .try_collect() // abort on the first error + .await; + + let Err(e) = result else { continue }; + match e.kind() { + io::ErrorKind::PermissionDenied => { + // Linux returns this error if our packet was rejected by nftables. + log::debug!("send_to failed with 'permission denied'"); + } + _ => return Err(e).wrap_err("Failed to send packet")?, + } + } + + Ok(()) +} + +async fn read_probe_responses(interface: &str, socket: UdpSocket) -> eyre::Result<LeakStatus> { + // the list of node IP addresses from which we received a response to our probe packets. + let mut reachable_nodes = vec![]; + + // a time at which this function should exit. this is set when we receive the first probe + // response, and allows us to wait a while to collect any additional probe responses before + // returning. + let mut timeout_at = None; + + let mut read_buf = vec![0u8; usize::from(u16::MAX)].into_boxed_slice(); + loop { + let timer = async { + match timeout_at { + // resolve future at the timeout, if it's set + Some(time) => sleep_until(time).await, + + // otherwise, never resolve + None => pending().await, + } + }; + + log::debug!("Reading from ICMP socket"); + + // let n = socket + // .recv(unsafe { &mut *(&mut read_buf[..] as *mut [u8] as *mut [MaybeUninit<u8>]) }) + // .wrap_err("Failed to read from raw socket")?; + + let (n, source) = select! { + result = socket.recv_from(&mut read_buf[..]) => result + .wrap_err("Failed to read from raw socket")?, + + _timeout = timer => { + return Ok(LeakStatus::LeakDetected(LeakInfo::NodeReachableOnInterface { + reachable_nodes, + interface: interface.to_string(), + })); + } + }; + + let source = source.ip(); + let packet = &read_buf[..n]; + let result = parse_ipv4(packet) + .map_err(|e| eyre!("Ignoring packet: (len={n}, ip.src={source}) {e} ({packet:02x?})")) + .and_then(|ip_packet| { + parse_icmp_time_exceeded(&ip_packet).map_err(|e| { + eyre!( + "Ignoring packet (len={n}, ip.src={source}, ip.dest={}): {e}", + ip_packet.get_destination(), + ) + }) + }); + + match result { + Ok(ip) => { + log::debug!("Got a probe response, we are leaking!"); + timeout_at.get_or_insert_with(|| Instant::now() + RECV_TIMEOUT); + let ip = IpAddr::from(ip); + if !reachable_nodes.contains(&ip) { + reachable_nodes.push(ip); + } + } + + // an error means the packet wasn't the ICMP/TimeExceeded we're listening for. + Err(e) => log::debug!("{e}"), + } + } +} + +/// Configure the raw socket we use for listening to ICMP responses. +/// +/// This will bind the socket to an interface, and set the `SIO_RCVALL`-option. +#[cfg(target_os = "windows")] +fn configure_listen_socket(socket: &Socket, interface: &str) -> eyre::Result<()> { + use std::{ffi::c_void, os::windows::io::AsRawSocket, ptr::null_mut}; + use windows_sys::Win32::Networking::WinSock::{ + WSAGetLastError, WSAIoctl, SIO_RCVALL, SOCKET, SOCKET_ERROR, + }; + + bind_socket_to_interface(&socket, interface) + .wrap_err("Failed to bind listen socket to interface")?; + + let j = 1; + let mut _in: u32 = 0; + let result = unsafe { + WSAIoctl( + socket.as_raw_socket() as SOCKET, + SIO_RCVALL, + &j as *const _ as *const c_void, + size_of_val(&j) as u32, + null_mut(), + 0, + &mut _in as *mut u32, + null_mut(), + None, + ) + }; + + if result == SOCKET_ERROR { + let code = unsafe { WSAGetLastError() }; + bail!("Failed to call WSAIoctl(listen_socket, SIO_RCVALL, ...), code = {code}"); + } + + Ok(()) +} + +/// Try to parse the bytes as an IPv4 packet. +/// +/// This only valdiates the IPv4 header, not the payload. +fn parse_ipv4(packet: &[u8]) -> eyre::Result<Ipv4Packet<'_>> { + let ip_packet = Ipv4Packet::new(packet).ok_or_eyre("Too small")?; + ensure!(ip_packet.get_version() == 4, "Not IPv4"); + eyre::Ok(ip_packet) +} + +/// Try to parse an [Ipv4Packet] as an ICMP/TimeExceeded response to a packet sent by +/// [send_probes]. If successful, returns the [Ipv4Addr] of the packet source. +/// +/// If the packet fails to parse, or is not a reply to a packet sent by [send_probes], this +/// function returns an error. +fn parse_icmp_time_exceeded(ip_packet: &Ipv4Packet<'_>) -> eyre::Result<Ipv4Addr> { + let too_small = || eyre!("Too small"); + + let ip_protocol = ip_packet.get_next_level_protocol(); + ensure!(ip_protocol == IpProtocol::Icmp, "Not ICMP"); + + let icmp_packet = IcmpPacket::new(ip_packet.payload()).ok_or_else(too_small)?; + let correct_type = icmp_packet.get_icmp_type() == IcmpTypes::TimeExceeded; + ensure!(correct_type, "Not ICMP/TimeExceeded"); + + let time_exceeeded = TimeExceededPacket::new(icmp_packet.packet()).ok_or_else(too_small)?; + + let original_ip_packet = Ipv4Packet::new(time_exceeeded.payload()).ok_or_else(too_small)?; + let original_ip_protocol = original_ip_packet.get_next_level_protocol(); + ensure!(original_ip_packet.get_version() == 4, "Not IPv4"); + + match original_ip_protocol { + IpProtocol::Udp => { + let original_udp_packet = + UdpPacket::new(original_ip_packet.payload()).ok_or_else(too_small)?; + + // check if payload looks right + // some network nodes will strip the payload, that's fine. + if !original_udp_packet.payload().is_empty() { + let udp_len = usize::from(original_udp_packet.get_length()); + let udp_payload = udp_len + .checked_sub(UdpPacket::minimum_packet_size()) + .and_then(|len| original_udp_packet.payload().get(..len)) + .ok_or_eyre("Invalid UDP length")?; + if udp_payload != &PROBE_PAYLOAD { + let udp_payload: String = udp_payload + .iter() + .copied() + .flat_map(escape_default) + .map(char::from) + .collect(); + bail!("Wrong UDP payload: {udp_payload:?}"); + } + } + + Ok(ip_packet.get_source()) + } + + IpProtocol::Icmp => { + let original_icmp_packet = + EchoRequestPacket::new(original_ip_packet.payload()).ok_or_else(too_small)?; + + ensure!( + original_icmp_packet.get_icmp_type() == IcmpTypes::EchoRequest, + "Not ICMP/EchoRequest" + ); + + // check if payload looks right + // some network nodes will strip the payload, that's fine. + let echo_payload = original_icmp_packet.payload(); + if !echo_payload.is_empty() && !echo_payload.starts_with(&PROBE_PAYLOAD) { + let echo_payload: String = echo_payload + .iter() + .copied() + .flat_map(escape_default) + .map(char::from) + .collect(); + bail!("Wrong ICMP/Echo payload: {echo_payload:?}"); + } + + Ok(ip_packet.get_source()) + } + + _ => bail!("Not UDP/ICMP"), + } +} + +match_cfg! { + #[cfg(any(target_os = "windows", target_os = "android"))] => { + fn bind_socket_to_interface(socket: &Socket, interface: &str) -> eyre::Result<()> { + use crate::util::get_interface_ip; + + let interface_ip = get_interface_ip(interface)?; + + log::info!("Binding socket to {interface_ip} ({interface:?})"); + + socket.bind(&SocketAddrV4::new(interface_ip, 0).into()) + .wrap_err("Failed to bind socket to interface address")?; + + return Ok(()); + } + } + #[cfg(target_os = "linux")] => { + fn bind_socket_to_interface(socket: &Socket, interface: &str) -> eyre::Result<()> { + log::info!("Binding socket to {interface:?}"); + + socket + .bind_device(Some(interface.as_bytes())) + .wrap_err("Failed to bind socket to interface")?; + + Ok(()) + } + } + #[cfg(target_os = "macos")] => { + fn bind_socket_to_interface(socket: &Socket, interface: &str) -> eyre::Result<()> { + use nix::net::if_::if_nametoindex; + use std::num::NonZero; + + log::info!("Binding socket to {interface:?}"); + + let interface_index = if_nametoindex(interface) + .map_err(eyre::Report::from) + .and_then(|code| NonZero::new(code).ok_or_eyre("Non-zero error code")) + .wrap_err("Failed to get interface index")?; + + socket.bind_device_by_index_v4(Some(interface_index))?; + Ok(()) + } + } +} + +// OLD ICMP SEND CODE +// +// use talpid_windows::net::{get_ip_address_for_interface, luid_from_alias, AddressFamily}; +// let interface_luid = luid_from_alias(INTERFACE)?; +// let IpAddr::V4(interface_ip) = +// get_ip_address_for_interface(AddressFamily::Ipv4, interface_luid)? +// .ok_or(eyre!("No IP for interface {INTERFACE:?}"))? +// else { +// panic!() +// }; +// +// for ttl in 1..=5 { +// let mut packet = Packet { +// ip: Ipv4Header { +// version_and_ihl: 0x45, +// dscp_and_ecn: 0, // should be fine +// total_length: (size_of::<Packet>() as u16).to_be_bytes(), +// _stuff: Default::default(), // should be fine +// ttl, +// protocol: 1, // icmp +// header_checksum: Default::default(), +// source_address: interface_ip.octets(), +// destination_address: destination.octets(), +// }, +// icmp: Icmpv4Header { +// icmp_type: 8, // echo +// code: 0, +// checksum: Default::default(), +// }, +// }; +// let icmp = Icmpv4Header { +// icmp_type: 8, // echo +// code: 0, +// checksum: Default::default(), +// }; +// +// packet.ip.header_checksum = checksum(packet.ip.as_bytes()); +// let mut packet = Icmpv4Packet { +// header: icmp, +// payload: Icmpv4EchoPayload { +// identifier: 0u16.to_be_bytes(), +// sequence_number: (ttl as u16).to_be_bytes(), +// data: [0x77; 32], +// }, +// }; +// +// packet.header.checksum = checksum(packet.as_bytes()); +// +// let packet = packet; +// +// listen_socket.set_ttl(ttl).wrap_err("Failed to set TTL")?; +// listen_socket +// .send_to( +// packet.as_bytes(), +// &SocketAddrV4::new(destination, 0u16).into(), +// ) +// .wrap_err("Failed to send on raw socket")?; +// } + +// use talpid_windows::net::{get_ip_address_for_interface, luid_from_alias, AddressFamily}; +// let interface_luid = luid_from_alias(INTERFACE)?; +// let IpAddr::V4(interface_ip) = +// get_ip_address_for_interface(AddressFamily::Ipv4, interface_luid)? +// .ok_or(eyre!("No IP for interface {INTERFACE:?}"))? +// else { +// panic!() +// }; +// +// for ttl in 1..=5 { +// let mut packet = Packet { +// ip: Ipv4Header { +// version_and_ihl: 0x45, +// dscp_and_ecn: 0, // should be fine +// total_length: (size_of::<Packet>() as u16).to_be_bytes(), +// _stuff: Default::default(), // should be fine +// ttl, +// protocol: 1, // icmp +// header_checksum: Default::default(), +// source_address: interface_ip.octets(), +// destination_address: destination.octets(), +// }, +// icmp: Icmpv4Header { +// icmp_type: 8, // echo +// code: 0, +// checksum: Default::default(), +// }, +// }; +// let icmp = Icmpv4Header { +// icmp_type: 8, // echo +// code: 0, +// checksum: Default::default(), +// }; +// +// packet.ip.header_checksum = checksum(packet.ip.as_bytes()); +// let mut packet = Icmpv4Packet { +// header: icmp, +// payload: Icmpv4EchoPayload { +// identifier: 0u16.to_be_bytes(), +// sequence_number: (ttl as u16).to_be_bytes(), +// data: [0x77; 32], +// }, +// }; +// +// packet.header.checksum = checksum(packet.as_bytes()); +// +// let packet = packet; +// +// listen_socket.set_ttl(ttl).wrap_err("Failed to set TTL")?; +// listen_socket +// .send_to( +// packet.as_bytes(), +// &SocketAddrV4::new(destination, 0u16).into(), +// ) +// .wrap_err("Failed to send on raw socket")?; +// } diff --git a/leak-checker/src/util.rs b/leak-checker/src/util.rs new file mode 100644 index 0000000000..a7a61febf3 --- /dev/null +++ b/leak-checker/src/util.rs @@ -0,0 +1,35 @@ +use match_cfg::match_cfg; + +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "android"))] +use std::net::IpAddr; + +match_cfg! { + #[cfg(target_os = "windows")] => { + pub fn get_interface_ip(interface: &str) -> eyre::Result<IpAddr> { + use talpid_windows::net::{get_ip_address_for_interface, luid_from_alias, AddressFamily}; + + let interface_luid = luid_from_alias(interface)?; + + // TODO: ipv6 + let interface_ip = get_ip_address_for_interface(AddressFamily::Ipv4, interface_luid)? + .ok_or(eyre!("No IP for interface {interface:?}"))?; + + Ok(interface_ip) + } + } + #[cfg(any(target_os = "macos", target_os = "android"))] => { + pub fn get_interface_ip(interface: &str) -> eyre::Result<IpAddr> { + for interface_address in nix::ifaddrs::getifaddrs()? { + if interface_address.interface_name != interface { continue }; + let Some(address) = interface_address.address else { continue }; + let Some(address) = address.as_sockaddr_in() else { continue }; + // TODO: ipv6 + //let Some(address) = address.as_sockaddr_in6() else { continue }; + + return Ok(address.ip().into()); + } + + eyre::bail!("Interface {interface:?} has no valid IP to bind to"); + } + } +} diff --git a/mullvad-daemon/src/leak_checker/mod.rs b/mullvad-daemon/src/leak_checker/mod.rs new file mode 100644 index 0000000000..e3cd57d194 --- /dev/null +++ b/mullvad-daemon/src/leak_checker/mod.rs @@ -0,0 +1,26 @@ +pub fn check_for_leaks() { + // TODO: When do we run this? + // After connecting? + // Periodically? + // Whenever something changes? (interface, connection state, dns server, etc) + // All of the above? + + // TODO: Figure out which interface(s) to bind to + + // TODO: get connection check config + // http get https://am.i.mullvad.net/config + + // TODO: For each interface: + + // TODO: send an ICMP ping (to the relay?) + // TODO: how to see if the pings are actually going outside the tunnel? + + // TODO: send a DNS request to leak check endpoint + // TODO: will the service be able to handle all of the mullvad users constantly doing leak + // checks + + // TODO: query DNS leak checker HTTPS endpoint + + // TODO: query https://ipv4.am.i.mullvad.net/ + // TODO: query https://ipv6.am.i.mullvad.net/ +} diff --git a/mullvad-daemon/src/lib.rs b/mullvad-daemon/src/lib.rs index aa6a21a564..7e61873df6 100644 --- a/mullvad-daemon/src/lib.rs +++ b/mullvad-daemon/src/lib.rs @@ -13,6 +13,7 @@ pub mod device; mod dns; pub mod exception_logging; mod geoip; +mod leak_checker; pub mod logging; #[cfg(target_os = "macos")] mod macos; diff --git a/talpid-core/Cargo.toml b/talpid-core/Cargo.toml index 620e4a6964..2552f6089b 100644 --- a/talpid-core/Cargo.toml +++ b/talpid-core/Cargo.toml @@ -46,7 +46,8 @@ duct = "0.13" [target.'cfg(target_os = "macos")'.dependencies] async-trait = "0.1" -pfctl = "0.6.1" +#pfctl = "0.6.1" +pfctl = { path = "../../pfctl-rs" } system-configuration = "0.5.1" hickory-proto = { workspace = true } hickory-server = { workspace = true, features = ["resolver"] } diff --git a/talpid-core/src/firewall/macos.rs b/talpid-core/src/firewall/macos.rs index 953c4abfe0..e608d94668 100644 --- a/talpid-core/src/firewall/macos.rs +++ b/talpid-core/src/firewall/macos.rs @@ -295,11 +295,6 @@ impl Firewall { peer_endpoint, tunnel, .. - } - | FirewallPolicy::Connecting { - peer_endpoint, - tunnel: Some(tunnel), - .. }) = policy else { return Ok(vec![]); @@ -327,18 +322,30 @@ impl Firewall { } // no nat to [vpn ip] - let no_nat_to_vpn_server = pfctl::NatRuleBuilder::default() - .action(pfctl::NatRuleAction::NoNat) - .to(peer_endpoint.endpoint.address.ip()) - .build()?; - rules.push(no_nat_to_vpn_server); + //let no_nat_to_vpn_server = pfctl::NatRuleBuilder::default() + // .action(pfctl::NatRuleAction::NoNat) + // .to(peer_endpoint.endpoint.address) + // .user(Uid::from(0)) + // .build()?; + //rules.push(no_nat_to_vpn_server); - // no nat on [tun interface] - let no_nat_on_tun = pfctl::NatRuleBuilder::default() - .action(pfctl::NatRuleAction::NoNat) - .interface(&tunnel.interface) - .build()?; - rules.push(no_nat_on_tun); + //for ip in &tunnel.ips { + // rules.push( + // pfctl::NatRuleBuilder::default() + // .action(pfctl::NatRuleAction::Nat { + // nat_to: pfctl::NatEndpoint::from(pfctl::Ip::from(*ip)), + // }) + // .to(peer_endpoint.endpoint.address.ip()) + // .build()?, + // ); + //} + + //// no nat on [tun interface] + //let no_nat_on_tun = pfctl::NatRuleBuilder::default() + // .action(pfctl::NatRuleAction::NoNat) + // .interface(&tunnel.interface) + // .build()?; + //rules.push(no_nat_on_tun); // Masquerade other traffic via VPN utun for ip in &tunnel.ips { @@ -431,6 +438,7 @@ impl Firewall { } rules.push(self.get_allow_relay_rule(peer_endpoint)?); + //rules.push(self.get_block_relay_rule(peer_endpoint)?); // Important to block DNS *before* we allow the tunnel and allow LAN. So DNS // can't leak to the wrong IPs in the tunnel or on the LAN. @@ -577,6 +585,7 @@ impl Firewall { Ok(rules) } + /// Allow traffic to relay_endpoint on the correct ip/port/protocol, for the root-user only. fn get_allow_relay_rule(&self, relay_endpoint: &AllowedEndpoint) -> Result<pfctl::FilterRule> { let pfctl_proto = as_pfctl_proto(relay_endpoint.endpoint.protocol); @@ -595,6 +604,20 @@ impl Firewall { builder.build() } + /// Block traffic to relay_endpoint ip. Should come after [Self::get_allow_relay_rule]. + fn get_block_relay_rule( + &self, + relay_endpoint: &net::AllowedEndpoint, + ) -> Result<pfctl::FilterRule> { + let mut builder = self.create_rule_builder(FilterRuleAction::Drop(DropAction::Return)); + builder + .direction(pfctl::Direction::Out) + .to(relay_endpoint.endpoint.address.ip()) + .quick(true); + + builder.build() + } + /// Produces a rule that allows traffic to flow to the API. Allows the app (or other apps if /// configured) to reach the API in blocked states. fn get_allowed_endpoint_rule( |
