Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement leak checker in daemon #7344

Draft
wants to merge 35 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
eef51e5
Add PoC leak checker library and CLI
hulthe Oct 22, 2024
5a9e891
Add experimental linux tracroute poc without root
hulthe Nov 14, 2024
0598ce9
wip: Integrate leak-checker in daemon
hulthe Nov 21, 2024
b529fac
fixup: remove spawn_blocking
hulthe Dec 13, 2024
fd8d39d
fixup: broken timeout
hulthe Dec 13, 2024
b544dc7
fixup: notes on how to find default route
hulthe Dec 13, 2024
25516d6
fixup: remove some old stuff
hulthe Dec 13, 2024
f4f1778
fixup
hulthe Dec 13, 2024
09891f3
fixup: select on new tunnel events
hulthe Dec 16, 2024
dc3c96b
fixup: no surge-ping
hulthe Dec 16, 2024
203c8c1
fixup: eyre -> anyhow
hulthe Dec 16, 2024
626abb2
stuff: expose route manager to daemon
hulthe Dec 16, 2024
93597ef
fixup: aaah
hulthe Dec 16, 2024
64083e0
fixup: windows
hulthe Dec 16, 2024
e9258be
fixup
hulthe Dec 17, 2024
4ab025e
use ping.exe on windows. fixme: hulthe is the real author
dlon Dec 19, 2024
53fd408
Get default route for macOS
dlon Dec 19, 2024
18ba070
fixup
hulthe Dec 20, 2024
af96597
fixup
hulthe Dec 20, 2024
89bc686
fixup
hulthe Dec 20, 2024
44d4c49
fixup
hulthe Dec 20, 2024
d6d0b64
fixup
hulthe Dec 20, 2024
ec6712f
fixup: concurrent pings
hulthe Dec 20, 2024
688bcdb
fixup: even more concurrent pings
hulthe Dec 20, 2024
001cbed
fixup
hulthe Dec 20, 2024
1d44fef
fixup: vent frustration
hulthe Dec 20, 2024
940f1bb
fixup: ipv6 on linux
hulthe Jan 7, 2025
03ea629
fixup: more ipv6
hulthe Jan 8, 2025
2808f42
fixup: Linux send errors
hulthe Jan 8, 2025
3c11154
fixup: remove notes
hulthe Jan 8, 2025
12f844c
fixup: workspace deps
hulthe Jan 8, 2025
ff91326
fixup: Deduplicate
hulthe Jan 8, 2025
cff752a
fixup: Put windows in the corner on timeout
hulthe Jan 10, 2025
7c84dd8
fixup
hulthe Jan 10, 2025
84b4f18
fixup: Less unused code
hulthe Jan 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 264 additions & 9 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ members = [
"tunnel-obfuscation",
"wireguard-go-rs",
"windows-installer",
"leak-checker",
]

# The default members may exclude packages that cannot be built for all targets, or that do not always
# need to be built
default-members = [
Expand Down Expand Up @@ -113,6 +115,7 @@ hickory-server = { version = "0.24.2", features = ["resolver"] }
tokio = { version = "1.42" }
parity-tokio-ipc = "0.9"
futures = "0.3.15"

# Tonic and related crates
tonic = "0.12.3"
tonic-build = { version = "0.10.0", default-features = false }
Expand All @@ -123,6 +126,7 @@ hyper-util = {version = "0.1.8", features = ["client", "client-legacy", "http2",

env_logger = "0.10.0"
thiserror = "2.0"
anyhow = "1.0"
log = "0.4"

shadowsocks = "1.20.3"
Expand All @@ -138,6 +142,7 @@ serde_json = "1.0.122"

ipnetwork = "0.20"
tun = { version = "0.7", features = ["async"] }
socket2 = "0.5.7"

# Test dependencies
proptest = "1.4"
Expand Down
34 changes: 34 additions & 0 deletions leak-checker/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
anyhow.workspace = true
socket2 = { workspace = true, 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 = { workspace = true, features = ["derive"] }

[dev-dependencies]
tokio = { workspace = true, features = ["full"] }

[target.'cfg(unix)'.dependencies]
nix = { version = "0.29.0", features = ["net", "socket", "uio"] }

[target.'cfg(windows)'.dependencies]
windows-sys.workspace = true
talpid-windows = { path = "../talpid-windows" }

[lints]
workspace = true
36 changes: 36 additions & 0 deletions leak-checker/examples/leaker-cli.rs
Original file line number Diff line number Diff line change
@@ -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() -> anyhow::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(())
}
90 changes: 90 additions & 0 deletions leak-checker/src/am_i_mullvad.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
use anyhow::{anyhow, 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) -> anyhow::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().context("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
.with_context(|| anyhow!("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().context("Malformed IP")?,
}))
}
}

match_cfg! {
#[cfg(target_os = "linux")] => {
fn bind_client_to_interface(
builder: ClientBuilder,
interface: &str
) -> anyhow::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
) -> anyhow::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))
}
}
}
50 changes: 50 additions & 0 deletions leak-checker/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
use std::{fmt, 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: Interface,
},

/// Queried a <https://am.i.mullvad.net>, and was not mullvad.
AmIMullvad { ip: IpAddr },
}

#[derive(Clone)]
pub enum Interface {
Name(String),

#[cfg(target_os = "windows")]
Luid(windows_sys::Win32::NetworkManagement::Ndis::NET_LUID_LH),
}

impl From<String> for Interface {
fn from(name: String) -> Self {
Interface::Name(name)
}
}

impl fmt::Debug for Interface {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Name(arg0) => f.debug_tuple("Name").field(arg0).finish(),

// TODO:
#[cfg(target_os = "windows")]
Self::Luid(arg0) => f.debug_tuple("Luid").field(unsafe { &arg0.Value }).finish(),
}
}
}
96 changes: 96 additions & 0 deletions leak-checker/src/traceroute.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
use std::{net::IpAddr, ops::Range, time::Duration};

use crate::{Interface, LeakStatus};

/// Traceroute implementation for windows.
#[cfg(target_os = "windows")]
mod windows;

/// Traceroute implementation for unix.
#[cfg(unix)]
mod unix;

#[derive(Clone, clap::Args)]
pub struct TracerouteOpt {
/// Try to bind to a specific interface
#[clap(short, long)]
pub interface: Interface,

/// Destination IP of the probe packets
#[clap(short, long)]
pub destination: IpAddr,

/// Avoid sending UDP probe packets to this port.
#[clap(long, conflicts_with = "icmp")]
pub exclude_port: Option<u16>,

/// Send UDP probe packets only to this port, instead of the default ports.
#[clap(long, conflicts_with = "icmp")]
pub port: Option<u16>,

/// Use ICMP-Echo for the probe packets instead of UDP.
#[clap(long)]
pub icmp: bool,
}

/// 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_GRACE_TIME: Duration = Duration::from_millis(220);

/// Time in-between send of each probe packet.
const PROBE_INTERVAL: Duration = Duration::from_millis(100);

/// 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) -> anyhow::Result<LeakStatus> {
#[cfg(unix)]
return {
#[cfg(target_os = "android")]
type Impl = unix::android::TracerouteAndroid;
#[cfg(target_os = "linux")]
type Impl = unix::linux::TracerouteLinux;
#[cfg(target_os = "macos")]
type Impl = unix::macos::TracerouteMacos;

unix::try_run_leak_test::<Impl>(opt).await
};

#[cfg(target_os = "windows")]
return windows::traceroute_using_ping(opt).await;
}

/// IP version, v4 or v6, with some associated data.
#[derive(Clone, Copy)]
enum Ip<V4 = (), V6 = ()> {
V4(V4),
V6(V6),
}
31 changes: 31 additions & 0 deletions leak-checker/src/traceroute/unix/android.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
use socket2::Socket;

use crate::{
traceroute::{Ip, TracerouteOpt},
Interface,
};

use super::{
common::bind_socket_to_interface,
linux::{self, TracerouteLinux},
Traceroute,
};

pub struct TracerouteAndroid;

impl Traceroute for TracerouteAndroid {
type AsyncIcmpSocket = linux::AsyncIcmpSocketImpl;

fn bind_socket_to_interface(
socket: &Socket,
interface: &Interface,
ip_version: Ip,
) -> anyhow::Result<()> {
// can't use the same method as desktop-linux here beacuse reasons
bind_socket_to_interface(socket, interface, ip_version)
}

fn configure_icmp_socket(socket: &socket2::Socket, opt: &TracerouteOpt) -> anyhow::Result<()> {
TracerouteLinux::configure_icmp_socket(socket, opt)
}
}
Loading
Loading