summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJoakim Hulthe <joakim.hulthe@mullvad.net>2024-10-22 14:29:01 +0200
committerJoakim Hulthe <joakim.hulthe@mullvad.net>2025-01-24 17:38:27 +0100
commit73ecf1c4a954318359a10c1be232570798b398de (patch)
treea78c7cc36f9d8f4260a177c54d711021f2181463
parent654de1cd2d3cdde6a3c26fe0cf5f26a4b0d1a89c (diff)
downloadmullvadvpn-73ecf1c4a954318359a10c1be232570798b398de.tar.xz
mullvadvpn-73ecf1c4a954318359a10c1be232570798b398de.zip
Add PoC leak checker library and CLI
-rw-r--r--Cargo.lock287
-rw-r--r--Cargo.toml1
-rw-r--r--leak-checker/Cargo.toml34
-rw-r--r--leak-checker/examples/leaker-cli.rs36
-rw-r--r--leak-checker/notes.md16
-rw-r--r--leak-checker/src/am_i_mullvad.rs90
-rw-r--r--leak-checker/src/lib.rs24
-rw-r--r--leak-checker/src/traceroute.rs617
-rw-r--r--leak-checker/src/util.rs35
-rw-r--r--mullvad-daemon/src/leak_checker/mod.rs26
-rw-r--r--mullvad-daemon/src/lib.rs1
-rw-r--r--talpid-core/Cargo.toml3
-rw-r--r--talpid-core/src/firewall/macos.rs55
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(