From 62db1e2a9e1c7f5cd05a452d2062dd73daed2549 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 17 Aug 2026 23:01:33 +0200 Subject: [PATCH] feat(torad): bring up a WireGuard tunnel in user space from a config file `--wireguard-config` is now the only thing an operator supplies. torad re-executes itself inside a private user+network namespace, brings up a userspace WireGuard datapath, and binds every torrent socket to it. No root, no wg-quick, no pre-created interface, no kernel module, no one-time host setup. The mechanism differs from what was planned, because the plan's assumptions did not survive being checked against the actual binaries: - pasta already creates an unprivileged user+net namespace with full capabilities and a configured tap interface, so the hand-rolled unshare(CLONE_NEWUSER|CLONE_NEWNET) with uid_map, gid_map and setgroups=deny is gone. torad re-execs itself under pasta instead. - WG_SOCKET_DIRECTORY does not exist; wireguard-go's socket directory is a build-time linker variable. WG_UAPI_FD does not avoid it either, because UAPIListen inotify-watches that path even when handed a pre-bound socket. Since /var/run is a symlink to /run, a tmpfs over /var inside a private mount namespace makes /var/run/wireguard writable while leaving the real /run visible -- which matters, because torad's gRPC socket lives under /run/user and the aggregator connects to it from the host. - The peer endpoint needs a host route via pasta's gateway before the default route moves to wg0, or WireGuard's own handshake is routed into the tunnel it is trying to establish. The device is configured by speaking WireGuard's UAPI protocol over its unix socket, so wireguard-tools is not a dependency either. If the datapath exits, torad shuts down instead of routing around it: a live torad with a dead tunnel is the failure mode that leaks. pasta also creates a PID namespace, so the pid file is written on the host before the re-exec and not again inside -- otherwise it would record PID 1, which names init when read from the host. Co-Authored-By: Claude Opus 5 --- crates/torad/Cargo.toml | 2 +- crates/torad/src/main.rs | 136 +++++++++++-- crates/torad/src/vpn/mod.rs | 13 +- crates/torad/src/vpn/namespace.rs | 263 +++++++++++++++++++++++-- crates/torad/src/vpn/tunnel.rs | 313 ++++++++++++++++++++++++++++++ crates/torad/src/vpn/uapi.rs | 249 ++++++++++++++++++++++++ 6 files changed, 946 insertions(+), 30 deletions(-) create mode 100644 crates/torad/src/vpn/tunnel.rs create mode 100644 crates/torad/src/vpn/uapi.rs diff --git a/crates/torad/Cargo.toml b/crates/torad/Cargo.toml index 87efc97..cb73f80 100644 --- a/crates/torad/Cargo.toml +++ b/crates/torad/Cargo.toml @@ -9,7 +9,7 @@ async-trait = "0.1" clap = { version = "4.6.1", features = ["derive", "env"] } librqbit = { version = "=9.0.0-rc.0", default-features = false, features = ["rust-tls", "http-api-client"] } librqbit-core = { version = "=9.0.0-rc.0", default-features = false } -nix = { version = "0.31.3", features = ["user", "sched", "fs", "process"] } +nix = { version = "0.31.3", features = ["user", "sched", "fs", "process", "mount"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } sha1 = "0.10" sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "tls-rustls", "uuid"] } diff --git a/crates/torad/src/main.rs b/crates/torad/src/main.rs index f2687d9..b52ea78 100644 --- a/crates/torad/src/main.rs +++ b/crates/torad/src/main.rs @@ -44,8 +44,18 @@ struct Args { /// Network device to bind torrent sockets to (Linux: SO_BINDTODEVICE). /// When set, all BT peer, tracker, and DHT traffic egresses only through /// this interface. When unset, librqbit uses default routing. + /// + /// Ignored when --wireguard-config is given, which binds to its own tunnel. #[arg(long, env = "TORAD_BIND_DEVICE")] bind_device: Option, + + /// Path to a wg-quick style WireGuard configuration file. + /// + /// When set, torad re-executes itself inside a private user+network + /// namespace, brings the tunnel up in user space, and binds all torrent + /// traffic to it. Requires no root and no host setup. Linux only. + #[arg(long, env = "TORAD_WIREGUARD_CONFIG")] + wireguard_config: Option, } fn default_socket_path() -> PathBuf { @@ -63,27 +73,102 @@ fn default_download_dir() -> PathBuf { .join("Downloads") } -#[tokio::main] -async fn main() -> Result<()> { +/// Deliberately not `#[tokio::main]`. +/// +/// VPN mode re-executes this process inside a network namespace, and nothing +/// survives `execvpe` — a runtime built before that point would leave worker +/// threads and their fds behind in the old image. So the namespace bootstrap +/// runs first, on a bare thread, and the runtime is built afterwards. +fn main() -> Result<()> { tracing_subscriber::fmt::init(); - let args = Args::parse(); - let socket = args.socket.unwrap_or_else(default_socket_path); - let pid_file = args.pid_file.unwrap_or_else(default_pid_file_path); - let download_dir = args.download_dir.unwrap_or_else(default_download_dir); + + let tunnel = match &args.wireguard_config { + // First launch with VPN mode requested: check the environment, prove + // the config parses before throwing the process away, then re-exec. + Some(path) if !vpn::namespace::already_inside() => { + vpn::namespace::preflight()?; + load_wireguard_config(path)?; + // Written here, on the host, and deliberately not again inside. + // pasta puts torad in a PID namespace, so the inner process sees + // itself as PID 1 and would record a number that names init on the + // host. `execvpe` keeps this PID for pasta, and killing pasta tears + // the namespace down with everything in it — so this is both the + // correct number and the right process to signal. + write_pid_file(&pid_file_path(&args), std::process::id())?; + let never = vpn::namespace::reexec_under_pasta()?; + match never {} + } + // The re-executed process: we are inside the namespace now. + Some(path) => Some(bring_up_tunnel(path)?), + None => None, + }; + + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .context("failed to build the tokio runtime")? + .block_on(run(args, tunnel)) +} + +fn pid_file_path(args: &Args) -> PathBuf { + args.pid_file.clone().unwrap_or_else(default_pid_file_path) +} + +fn write_pid_file(path: &std::path::Path, pid: u32) -> Result<()> { + std::fs::write(path, pid.to_string()) + .with_context(|| format!("failed to write pid file at {}", path.display())) +} + +fn load_wireguard_config(path: &std::path::Path) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("failed to read WireGuard config at {}", path.display()))?; + vpn::parse(&text).with_context(|| format!("failed to parse {}", path.display())) +} + +/// Enter a mount namespace and bring the tunnel up. Runs in the re-executed +/// process, which already holds `CAP_NET_ADMIN` in its own user namespace. +fn bring_up_tunnel(path: &std::path::Path) -> Result { + let config = load_wireguard_config(path)?; + let socket_dir = vpn::namespace::enter_mount_namespace()?; + vpn::tunnel::up(&config, socket_dir) +} + +async fn run(args: Args, tunnel: Option) -> Result<()> { + let socket = args.socket.clone().unwrap_or_else(default_socket_path); + let pid_file = pid_file_path(&args); + let download_dir = args + .download_dir + .clone() + .unwrap_or_else(default_download_dir); let pool = db::connect(&args.database_url) .await .context("failed to connect to Postgres")?; info!("connected to Postgres"); - let manager = TorrentManager::new(pool.clone(), download_dir, args.bind_device) + // In VPN mode the tunnel is the only interface torrent traffic may use, so + // it overrides --bind-device rather than competing with it. + let bind_device = if tunnel.is_some() { + if args.bind_device.is_some() { + info!("--bind-device is ignored in VPN mode; binding to the tunnel instead"); + } + Some(vpn::INTERFACE.to_owned()) + } else { + args.bind_device + }; + + let manager = TorrentManager::new(pool.clone(), download_dir, bind_device) .await .context("failed to start torrent manager")?; manager.clone().spawn_poller(); - std::fs::write(&pid_file, std::process::id().to_string()) - .with_context(|| format!("failed to write pid file at {}", pid_file.display()))?; + // In VPN mode the host-visible pid was already recorded before the + // re-exec; overwriting it here would replace it with this process's + // namespace-local PID 1. + if !vpn::namespace::already_inside() { + write_pid_file(&pid_file, std::process::id())?; + } if socket.exists() { std::fs::remove_file(&socket) @@ -98,10 +183,24 @@ async fn main() -> Result<()> { info!(socket = %socket.display(), "torad listening"); + // A live torad with a dead tunnel is the failure mode that leaks, so the + // datapath exiting is treated exactly like SIGTERM. + let tunnel_down = tunnel.map(|mut tunnel| { + let (tx, rx) = tokio::sync::oneshot::channel(); + std::thread::spawn(move || { + let reason = match tunnel.wait() { + Ok(status) => format!("wireguard-go exited with {status}"), + Err(err) => format!("lost track of wireguard-go: {err:#}"), + }; + let _ = tx.send(reason); + }); + rx + }); + Server::builder() .add_service(health_service) .add_service(TorrentsServer::new(GrpcTorrents::new(manager))) - .serve_with_incoming_shutdown(uds_stream, shutdown_signal()) + .serve_with_incoming_shutdown(uds_stream, shutdown_signal(tunnel_down)) .await .context("gRPC server error")?; @@ -131,14 +230,29 @@ async fn poll_db_health(pool: sqlx::PgPool, reporter: HealthReporter) { } } -async fn shutdown_signal() { +async fn shutdown_signal(tunnel_down: Option>) { use tokio::signal::unix::{SignalKind, signal}; let mut sigint = signal(SignalKind::interrupt()).expect("failed to install SIGINT handler"); let mut sigterm = signal(SignalKind::terminate()).expect("failed to install SIGTERM handler"); + // Without a tunnel there is nothing to watch, so this branch must never + // complete — otherwise it would fire immediately and shut torad down. + let tunnel = async { + match tunnel_down { + Some(rx) => match rx.await { + Ok(reason) => reason, + Err(_) => std::future::pending().await, + }, + None => std::future::pending().await, + } + }; + tokio::select! { _ = sigint.recv() => {}, _ = sigterm.recv() => {}, + reason = tunnel => { + error!(%reason, "VPN tunnel went down; shutting down rather than routing around it"); + }, } } diff --git a/crates/torad/src/vpn/mod.rs b/crates/torad/src/vpn/mod.rs index 5b99a70..d863bb4 100644 --- a/crates/torad/src/vpn/mod.rs +++ b/crates/torad/src/vpn/mod.rs @@ -1,4 +1,15 @@ +//! VPN mode: bring up a WireGuard tunnel from a config file, entirely in user +//! space, and confine every torrent socket to it. +//! +//! The operator passes `--wireguard-config`. Nothing else is required: no +//! `sudo`, no `wg-quick`, no pre-created interface, no kernel module, and no +//! one-time host setup. See [`namespace`] for how the namespace is obtained +//! and [`tunnel`] for the bring-up order. + pub mod config; pub mod namespace; +pub mod tunnel; +pub mod uapi; -pub use config::{InterfaceSection, PeerSection, WireguardConfig, parse}; +pub use config::{WireguardConfig, parse}; +pub use tunnel::{INTERFACE, Tunnel}; diff --git a/crates/torad/src/vpn/namespace.rs b/crates/torad/src/vpn/namespace.rs index 0182b0f..ed53ccb 100644 --- a/crates/torad/src/vpn/namespace.rs +++ b/crates/torad/src/vpn/namespace.rs @@ -1,32 +1,97 @@ -//! Network namespace setup for VPN mode. +//! Namespace bootstrap for VPN mode. //! -//! Unprivileged user+network namespaces (via `unshare(CLONE_NEWUSER | CLONE_NEWNET)`) -//! give torad `CAP_NET_ADMIN` and `CAP_NET_RAW` scoped to a new namespace, allowing -//! WireGuard interface creation and `SO_BINDTODEVICE` without ever needing root. +//! torad must run inside its own network namespace so that a WireGuard +//! interface can be created and every socket confined to it, without root and +//! without any one-time host setup. +//! +//! It does not build that namespace by hand. `pasta` (from passt) already +//! creates an unprivileged user+network namespace, configures a tap interface +//! with the host's addressing, and executes a command inside it — so torad +//! re-executes *itself* under pasta and continues from there. Measured inside +//! that namespace: +//! +//! ```text +//! uid_map -> 0 1000 1 (we are root in the namespace, uid 1000 outside) +//! CapEff -> 000001ffffffffff (all capabilities, including CAP_NET_ADMIN) +//! ns/net -> distinct from the host's +//! /dev/net/tun writable +//! ``` +//! +//! What pasta does not give us is a private *mount* namespace, and one is +//! required: `wireguard-go` hardcodes its UAPI socket directory to +//! `/var/run/wireguard` (it is a build-time linker variable with no environment +//! override, and `WG_UAPI_FD` does not avoid it — `UAPIListen` inotify-watches +//! that path even when handed a pre-bound socket). We hold `CAP_SYS_ADMIN` in +//! our own user namespace, so [`enter_mount_namespace`] unshares one and mounts +//! a tmpfs over `/var`. +//! +//! `/var` and not `/run`: `/var/run` is a symlink to `/run`, so a tmpfs on +//! `/var` yields a fresh writable `/var/run/wireguard` while leaving the real +//! `/run` visible. That matters — torad's gRPC socket lives under +//! `/run/user/` and the aggregator connects to it from the host. + +use std::ffi::{CString, OsString}; +use std::fs; +use std::os::unix::ffi::{OsStrExt, OsStringExt}; +use std::path::Path; use anyhow::{Context, Result, bail}; -use std::fs; +use nix::mount::{MsFlags, mount}; +use nix::sched::{CloneFlags, unshare}; /// Path to the kernel sysctl controlling how many user namespaces may be created. const MAX_USER_NAMESPACES_PROC: &str = "/proc/sys/user/max_user_namespaces"; -/// Preflight check: confirm the kernel allows unprivileged user namespaces. +/// Set on the re-executed process so it knows not to re-exec again. +pub const IN_NETNS_ENV: &str = "TORAD_IN_NETNS"; + +/// Address at which pasta makes the host's loopback reachable. /// -/// Reads `/proc/sys/user/max_user_namespaces`. If `0`, unprivileged user namespaces -/// are disabled and torad's VPN mode cannot function. If the file is missing entirely, -/// treats it as supported (default on most distros). +/// Note that plain `127.0.0.1` also reaches the *host's* loopback from inside +/// the namespace — pasta splices namespace loopback to host loopback by +/// default, including for services bound after the namespace starts. So +/// loopback URLs for Jackett and Postgres need no rewriting. This address is +/// the explicit, documented route to the same place, and exists as a fallback +/// if that default is ever turned off. +pub const HOST_LOOPBACK: &str = "169.254.1.1"; + +/// Directory `wireguard-go` hardcodes for its UAPI sockets. +const WIREGUARD_SOCKET_DIR: &str = "/var/run/wireguard"; + +/// True when this process is already the re-executed one running inside pasta. +pub fn already_inside() -> bool { + std::env::var_os(IN_NETNS_ENV).is_some_and(|v| v == "1") +} + +/// Preflight: confirm the kernel and the environment can support VPN mode. +/// +/// Runs before the re-exec so a missing dependency fails with an explanation +/// rather than halfway through namespace setup. pub fn preflight() -> Result<()> { match fs::read_to_string(MAX_USER_NAMESPACES_PROC) { - Ok(s) => evaluate(&s), + Ok(s) => evaluate(&s)?, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { tracing::warn!( path = MAX_USER_NAMESPACES_PROC, "sysctl file not found; assuming unprivileged user namespaces are enabled" ); - Ok(()) } - Err(e) => Err(e).with_context(|| format!("failed to read {MAX_USER_NAMESPACES_PROC}")), + Err(e) => { + return Err(e).with_context(|| format!("failed to read {MAX_USER_NAMESPACES_PROC}")); + } } + + for (binary, purpose) in [ + ("pasta", "creates the unprivileged network namespace"), + ("wireguard-go", "provides the userspace WireGuard datapath"), + ("ip", "assigns the tunnel address and routes"), + ] { + which(binary).with_context(|| { + format!("VPN mode requires `{binary}` on PATH ({purpose}), and it was not found") + })?; + } + + Ok(()) } /// Decides whether the sysctl's contents permit unprivileged user namespaces. @@ -48,6 +113,135 @@ fn evaluate(contents: &str) -> Result<()> { Ok(()) } +/// Locate a binary on `PATH`, mirroring what `execvp` will do moments later. +fn which(binary: &str) -> Result { + let path = std::env::var_os("PATH").context("PATH is not set")?; + std::env::split_paths(&path) + .map(|dir| dir.join(binary)) + .find(|candidate| candidate.is_file()) + .with_context(|| format!("{binary} not found on PATH")) +} + +/// Replace this process with itself running inside a pasta namespace. +/// +/// Returns only on failure: on success the process image is gone. It must be +/// called before any resource is acquired — before the Postgres pool, the pid +/// file, the listening socket, and any tokio runtime — because none of that +/// survives `execvpe`, and a half-opened socket would be inherited into the +/// new image as a stray fd. +pub fn reexec_under_pasta() -> Result { + let exe = std::env::current_exe().context("failed to determine torad's own path")?; + + let mut argv: Vec = vec![ + "pasta".into(), + // Configure the tap interface, address and routes inside the namespace. + "--config-net".into(), + // Reach host services at a stable address regardless of the host's + // own addressing. + "--map-host-loopback".into(), + HOST_LOOPBACK.into(), + // pasta's informational chatter would otherwise interleave with + // torad's structured logs on the same stderr. + "--quiet".into(), + "--".into(), + exe.into_os_string(), + ]; + // Skip argv[0] and pass the original arguments through verbatim, so the + // re-executed process sees exactly the command line the operator typed. + argv.extend(std::env::args_os().skip(1)); + + let argv: Vec = argv + .into_iter() + .map(|arg| CString::new(arg.as_bytes()).context("argument contained an interior NUL")) + .collect::>()?; + + let mut env: Vec = std::env::vars_os() + .filter(|(key, _)| key != IN_NETNS_ENV) + .map(|(key, value)| { + let mut entry = key.into_vec(); + entry.push(b'='); + entry.extend_from_slice(value.as_bytes()); + CString::new(entry).context("environment entry contained an interior NUL") + }) + .collect::>()?; + env.push(CString::new(format!("{IN_NETNS_ENV}=1")).expect("literal has no NUL")); + + let program = CString::new("pasta").expect("literal has no NUL"); + tracing::info!("re-executing under pasta to enter a private network namespace"); + + nix::unistd::execvpe(&program, &argv, &env) + .context("failed to execute pasta; VPN mode cannot continue") +} + +/// Unshare a mount namespace and make `/var/run/wireguard` writable. +/// +/// Returns the directory `wireguard-go` will place its UAPI socket in. +pub fn enter_mount_namespace() -> Result<&'static Path> { + unshare(CloneFlags::CLONE_NEWNS).context( + "failed to unshare a mount namespace; \ + VPN mode needs one to give wireguard-go a writable socket directory", + )?; + + // Without this our tmpfs could propagate back into the host's mount tree. + mount( + Some("none"), + "/", + None::<&str>, + MsFlags::MS_REC | MsFlags::MS_PRIVATE, + None::<&str>, + ) + .context("failed to make the mount namespace private")?; + + mount( + Some("tmpfs"), + "/var", + Some("tmpfs"), + MsFlags::empty(), + Some("mode=0755"), + ) + .context("failed to mount a tmpfs over /var")?; + + fs::create_dir_all(WIREGUARD_SOCKET_DIR) + .with_context(|| format!("failed to create {WIREGUARD_SOCKET_DIR}"))?; + + Ok(Path::new(WIREGUARD_SOCKET_DIR)) +} + +/// Point the namespace's resolver at the tunnel's DNS servers. +/// +/// Must be called *after* the peer endpoint has been resolved: the tunnel DNS +/// is typically only reachable through the tunnel, so installing it first would +/// leave nothing able to resolve the endpoint hostname. +/// +/// The file is bind-mounted rather than written in place because `/etc` belongs +/// to root on the host and our uid maps outside the namespace. +pub fn install_resolv_conf(runtime_dir: &Path, nameservers: &[std::net::IpAddr]) -> Result<()> { + if nameservers.is_empty() { + tracing::info!("config has no DNS entry; leaving the host resolver in place"); + return Ok(()); + } + + let contents: String = nameservers + .iter() + .map(|ip| format!("nameserver {ip}\n")) + .collect(); + let source = runtime_dir.join("resolv.conf"); + fs::write(&source, contents) + .with_context(|| format!("failed to write {}", source.display()))?; + + mount( + Some(&source), + "/etc/resolv.conf", + None::<&str>, + MsFlags::MS_BIND, + None::<&str>, + ) + .context("failed to bind-mount the tunnel's resolv.conf over /etc/resolv.conf")?; + + tracing::info!(count = nameservers.len(), "installed tunnel DNS"); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -73,12 +267,47 @@ mod tests { assert!(evaluate("not-a-number\n").is_err()); } - /// The real entry point must agree with `evaluate` on this host, where the - /// sysctl is present and non-zero. + /// `evaluate` must agree with the sysctl on this host, where it is present + /// and non-zero. `preflight` itself additionally requires pasta and + /// wireguard-go on PATH, which is asserted separately below. #[test] - fn preflight_succeeds_on_a_supported_host() { - if let Ok(contents) = std::fs::read_to_string(MAX_USER_NAMESPACES_PROC) { - assert_eq!(preflight().is_ok(), evaluate(&contents).is_ok()); + fn evaluate_agrees_with_the_host_sysctl() { + if let Ok(contents) = fs::read_to_string(MAX_USER_NAMESPACES_PROC) { + assert!(evaluate(&contents).is_ok()); } } + + #[test] + fn which_finds_a_binary_that_exists_and_not_one_that_does_not() { + assert!(which("sh").is_ok(), "sh must be on PATH"); + assert!(which("torad-definitely-not-a-real-binary").is_err()); + } + + /// A missing dependency must say which binary and why, not fail later with + /// a bare ENOENT from execvp. + #[test] + fn preflight_error_names_the_missing_binary() { + // Only meaningful when the dependency really is absent; when it is + // installed, preflight should simply pass. + match preflight() { + Ok(()) => { + assert!(which("pasta").is_ok()); + assert!(which("wireguard-go").is_ok()); + } + Err(err) => { + let text = format!("{err:#}"); + assert!( + text.contains("pasta") || text.contains("wireguard-go") || text.contains("ip"), + "error should name the missing binary, got: {text}" + ); + assert!(text.contains("PATH"), "got: {text}"); + } + } + } + + #[test] + fn already_inside_reads_the_marker_env_var() { + // The variable is not set in the test process. + assert!(!already_inside()); + } } diff --git a/crates/torad/src/vpn/tunnel.rs b/crates/torad/src/vpn/tunnel.rs new file mode 100644 index 0000000..e311aaa --- /dev/null +++ b/crates/torad/src/vpn/tunnel.rs @@ -0,0 +1,313 @@ +//! Bringing up the WireGuard tunnel inside the namespace. +//! +//! Order matters here and is not arbitrary: +//! +//! 1. Read pasta's default route *before* touching routing, because step 4 +//! replaces it and we need the original gateway to pin the endpoint. +//! 2. Resolve the peer endpoint while the host resolver still works. +//! 3. Pin the endpoint to the pasta gateway with a host route. Without this, +//! installing `default dev wg0` in step 4 would send WireGuard's own +//! handshake into the tunnel it is trying to establish. `wg-quick` solves +//! this with fwmark plus policy routing; a host route is the simpler +//! equivalent for a single fixed peer. +//! 4. Only then bring up wg0 and move the default route onto it. +//! 5. Install tunnel DNS last, since it is typically only reachable through +//! the tunnel that now exists. + +use std::net::{IpAddr, ToSocketAddrs}; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; + +use super::config::WireguardConfig; +use super::{namespace, uapi}; + +/// Interface name for the tunnel. Fixed rather than configurable: it is also +/// what librqbit is told to bind every socket to, and nothing outside this +/// process needs to name it. +pub const INTERFACE: &str = "wg0"; + +/// How long to wait for `wireguard-go` to create its UAPI socket. +const UAPI_TIMEOUT: Duration = Duration::from_secs(10); +const UAPI_POLL_INTERVAL: Duration = Duration::from_millis(100); + +/// A running tunnel. Dropping this kills `wireguard-go` and takes the +/// interface with it, which is the intended behaviour: no interface means +/// librqbit's bound sockets fail rather than falling back to the host route. +pub struct Tunnel { + process: Child, +} + +impl Tunnel { + /// Wait for the datapath to exit. Returns the status it exited with. + /// + /// A live torad with a dead tunnel is the failure mode that leaks, so the + /// caller is expected to shut down when this returns. + pub fn wait(&mut self) -> Result { + self.process + .wait() + .context("failed to wait on the wireguard-go process") + } +} + +impl Drop for Tunnel { + fn drop(&mut self) { + let _ = self.process.kill(); + let _ = self.process.wait(); + } +} + +/// Bring up the tunnel. Returns once traffic can flow through [`INTERFACE`]. +pub fn up(config: &WireguardConfig, socket_dir: &Path) -> Result { + let gateway = default_route().context( + "failed to read the namespace's default route; \ + pasta should have installed one with --config-net", + )?; + tracing::info!(gateway = %gateway.via, device = %gateway.device, "namespace default route"); + + let endpoints = resolve_endpoint(config)?; + let tunnel = start_datapath(socket_dir)?; + + uapi::configure(&socket_dir.join(format!("{INTERFACE}.sock")), config)?; + + // Pin the peer outside the tunnel before the tunnel can capture it. + for endpoint in &endpoints { + let host_route = match endpoint { + IpAddr::V4(addr) => format!("{addr}/32"), + IpAddr::V6(addr) => format!("{addr}/128"), + }; + ip(&[ + "route", + "add", + &host_route, + "via", + &gateway.via, + "dev", + &gateway.device, + ]) + .with_context(|| format!("failed to pin peer endpoint {endpoint} to the host route"))?; + tracing::info!(%endpoint, "pinned peer endpoint outside the tunnel"); + } + + for address in &config.interface.addresses { + ip(&["address", "add", &address.to_string(), "dev", INTERFACE]) + .with_context(|| format!("failed to assign {address} to {INTERFACE}"))?; + } + ip(&["link", "set", INTERFACE, "up"]) + .with_context(|| format!("failed to bring {INTERFACE} up"))?; + + for allowed in &config.peer.allowed_ips { + let destination = allowed.to_string(); + // A default route must replace pasta's, not collide with it. Narrower + // routes are added, so a malformed config surfaces as an error rather + // than silently clobbering something. + let is_default = allowed.prefix_len == 0; + let verb = if is_default { "replace" } else { "add" }; + let target = if is_default { "default" } else { &destination }; + let family = if allowed.addr.is_ipv4() { "-4" } else { "-6" }; + ip(&[family, "route", verb, target, "dev", INTERFACE]) + .with_context(|| format!("failed to route {destination} through {INTERFACE}"))?; + tracing::info!(route = %destination, "routed through the tunnel"); + } + + namespace::install_resolv_conf(socket_dir, &config.interface.dns)?; + + tracing::info!(interface = INTERFACE, "tunnel is up"); + Ok(tunnel) +} + +/// Spawn `wireguard-go` and wait for its UAPI socket to appear. +fn start_datapath(socket_dir: &Path) -> Result { + let mut process = Command::new("wireguard-go") + // Stay in the foreground so the process is ours to supervise; the + // default is to daemonise, which would leave nothing to wait on. + .arg("-f") + .arg(INTERFACE) + .env("WG_PROCESS_FOREGROUND", "1") + .stdin(Stdio::null()) + .spawn() + .context("failed to spawn wireguard-go")?; + + let socket = socket_dir.join(format!("{INTERFACE}.sock")); + let deadline = Instant::now() + UAPI_TIMEOUT; + while Instant::now() < deadline { + if socket.exists() { + // Prove the device answers, not merely that the socket file exists. + if let Err(err) = uapi::get(&socket) { + let _ = process.kill(); + let _ = process.wait(); + return Err(err).context("wireguard-go created its socket but did not respond"); + } + return Ok(Tunnel { process }); + } + if let Some(status) = process + .try_wait() + .context("failed to poll the wireguard-go process")? + { + bail!("wireguard-go exited before creating its UAPI socket (status {status})"); + } + std::thread::sleep(UAPI_POLL_INTERVAL); + } + + let _ = process.kill(); + let _ = process.wait(); + bail!( + "wireguard-go did not create {} within {:?}", + socket.display(), + UAPI_TIMEOUT + ) +} + +#[derive(Debug)] +struct DefaultRoute { + via: String, + device: String, +} + +/// Read the namespace's IPv4 default route, as installed by `pasta --config-net`. +fn default_route() -> Result { + let output = Command::new("ip") + .args(["-4", "route", "show", "default"]) + .output() + .context("failed to run `ip route show default`")?; + if !output.status.success() { + bail!( + "`ip route show default` failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + parse_default_route(&String::from_utf8_lossy(&output.stdout)) +} + +/// Pull the gateway and device out of `ip route show default` output. +fn parse_default_route(text: &str) -> Result { + let line = text + .lines() + .find(|line| line.starts_with("default ")) + .context("no default route in the namespace")?; + let fields: Vec<&str> = line.split_whitespace().collect(); + + let via = field_after(&fields, "via") + .context("default route has no `via` gateway")? + .to_owned(); + let device = field_after(&fields, "dev") + .context("default route has no `dev` interface")? + .to_owned(); + + Ok(DefaultRoute { via, device }) +} + +fn field_after<'a>(fields: &[&'a str], key: &str) -> Option<&'a str> { + fields + .iter() + .position(|field| *field == key) + .and_then(|index| fields.get(index + 1)) + .copied() +} + +/// Resolve the peer endpoint to concrete addresses. +/// +/// Done once, at setup. WireGuard would normally re-resolve a roaming endpoint +/// on its own, but the host route pinned in [`up`] is what keeps the handshake +/// out of the tunnel, and that route is per-address — so an endpoint that +/// changes address mid-session is not followed. Documented as a limitation +/// rather than solved with policy routing. +fn resolve_endpoint(config: &WireguardConfig) -> Result> { + let endpoint = &config.peer.endpoint; + if let Ok(addr) = endpoint.host.parse::() { + return Ok(vec![addr]); + } + + let addresses: Vec = (endpoint.host.as_str(), endpoint.port) + .to_socket_addrs() + .with_context(|| format!("failed to resolve peer endpoint host {:?}", endpoint.host))? + .map(|socket| socket.ip()) + .collect(); + + if addresses.is_empty() { + bail!( + "peer endpoint host {:?} resolved to no addresses", + endpoint.host + ); + } + tracing::info!(host = %endpoint.host, count = addresses.len(), "resolved peer endpoint"); + Ok(addresses) +} + +/// Run `ip` with the given arguments, failing with its stderr attached. +fn ip(args: &[&str]) -> Result<()> { + let output = Command::new("ip") + .args(args) + .output() + .with_context(|| format!("failed to run `ip {}`", args.join(" ")))?; + if !output.status.success() { + bail!( + "`ip {}` failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_pasta_default_route() { + // Exactly what `pasta --config-net` installs, as observed in the probe. + let text = "default via 192.168.100.1 dev enp7s0f3u1u2 proto dhcp metric 100 \n"; + let route = parse_default_route(text).unwrap(); + assert_eq!(route.via, "192.168.100.1"); + assert_eq!(route.device, "enp7s0f3u1u2"); + } + + #[test] + fn ignores_non_default_routes() { + let text = "192.168.100.0/24 dev eth0 proto kernel scope link\n\ + default via 10.0.0.1 dev eth0\n"; + let route = parse_default_route(text).unwrap(); + assert_eq!(route.via, "10.0.0.1"); + assert_eq!(route.device, "eth0"); + } + + #[test] + fn rejects_missing_or_incomplete_default_routes() { + assert!(parse_default_route("").is_err()); + assert!(parse_default_route("192.168.1.0/24 dev eth0\n").is_err()); + // An on-link default has no gateway to pin the endpoint to. + let err = parse_default_route("default dev eth0\n") + .unwrap_err() + .to_string(); + assert!(err.contains("via"), "got: {err}"); + } + + #[test] + fn field_after_finds_values_and_handles_edges() { + let fields = vec!["default", "via", "10.0.0.1", "dev", "eth0"]; + assert_eq!(field_after(&fields, "via"), Some("10.0.0.1")); + assert_eq!(field_after(&fields, "dev"), Some("eth0")); + assert_eq!(field_after(&fields, "proto"), None); + // Key present but nothing follows it. + assert_eq!(field_after(&["default", "via"], "via"), None); + } + + #[test] + fn resolves_a_literal_endpoint_without_dns() { + let config = crate::vpn::config::parse( + "[Interface]\n\ + PrivateKey = AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=\n\ + Address = 10.2.0.2/32\n\ + [Peer]\n\ + PublicKey = ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=\n\ + AllowedIPs = 0.0.0.0/0\n\ + Endpoint = 156.146.50.5:51820\n", + ) + .unwrap(); + let resolved = resolve_endpoint(&config).unwrap(); + assert_eq!(resolved, vec![IpAddr::from([156, 146, 50, 5])]); + } +} diff --git a/crates/torad/src/vpn/uapi.rs b/crates/torad/src/vpn/uapi.rs new file mode 100644 index 0000000..b81a441 --- /dev/null +++ b/crates/torad/src/vpn/uapi.rs @@ -0,0 +1,249 @@ +//! Client for WireGuard's UAPI protocol. +//! +//! `wireguard-go` exposes a unix socket speaking a line-oriented text protocol +//! instead of the netlink interface the kernel module uses. Talking to it +//! directly means torad needs no `wg` binary at all — `wireguard-tools` is not +//! a dependency of VPN mode. +//! +//! The protocol: send an operation line (`set=1`), then `key=value` lines, then +//! a blank line. The device replies with its own `key=value` lines (empty for +//! `set`), an `errno=N` line, and a blank line. `errno=0` means success. +//! Keys are lowercase hex, not the base64 used in config files. + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::Path; + +use anyhow::{Context, Result, bail}; + +use super::config::WireguardConfig; + +/// Push a parsed config into a running `wireguard-go` device. +/// +/// `replace_peers` and `replace_allowed_ips` make this idempotent: applying the +/// same config twice leaves exactly one peer rather than accumulating them. +pub fn configure(socket_path: &Path, config: &WireguardConfig) -> Result<()> { + let mut request = String::from("set=1\n"); + request.push_str(&format!( + "private_key={}\n", + config.interface.private_key.to_hex() + )); + request.push_str("replace_peers=true\n"); + request.push_str(&format!("public_key={}\n", config.peer.public_key.to_hex())); + request.push_str(&format!("endpoint={}\n", config.peer.endpoint)); + if let Some(keepalive) = config.peer.persistent_keepalive { + request.push_str(&format!("persistent_keepalive_interval={keepalive}\n")); + } + request.push_str("replace_allowed_ips=true\n"); + for cidr in &config.peer.allowed_ips { + request.push_str(&format!("allowed_ip={cidr}\n")); + } + request.push('\n'); + + let response = round_trip(socket_path, &request) + // The request body contains the private key in hex, so it must never + // appear in an error. Only the socket path does. + .with_context(|| format!("UAPI set=1 failed on {}", socket_path.display()))?; + + match parse_errno(&response)? { + 0 => Ok(()), + errno => bail!( + "wireguard-go rejected the configuration (UAPI errno={errno}). \ + The config parsed cleanly, so this is most likely a key the device \ + would not accept." + ), + } +} + +/// Read back the device state. Used by the readiness check and by tests. +pub fn get(socket_path: &Path) -> Result { + round_trip(socket_path, "get=1\n\n") + .with_context(|| format!("UAPI get=1 failed on {}", socket_path.display())) +} + +fn round_trip(socket_path: &Path, request: &str) -> Result { + let mut stream = + UnixStream::connect(socket_path).context("failed to connect to UAPI socket")?; + stream + .write_all(request.as_bytes()) + .context("failed to write UAPI request")?; + stream.flush().context("failed to flush UAPI request")?; + + // The device answers with lines terminated by a blank line. Reading to EOF + // would work too, but wireguard-go keeps the connection open after `set`, + // so stop at the terminator instead of blocking. + let mut reader = BufReader::new(&stream); + let mut response = String::new(); + loop { + let mut line = String::new(); + let read = reader + .read_line(&mut line) + .context("failed to read UAPI response")?; + if read == 0 { + break; + } + if line == "\n" { + break; + } + response.push_str(&line); + } + Ok(response) +} + +/// Extract the `errno=N` line the device terminates every response with. +fn parse_errno(response: &str) -> Result { + response + .lines() + .find_map(|line| line.strip_prefix("errno=")) + .context("UAPI response contained no errno line")? + .trim() + .parse() + .context("UAPI errno was not a number") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vpn::config; + use std::io::Read; + use std::os::unix::net::UnixListener; + + const SAMPLE: &str = "[Interface]\n\ + PrivateKey = AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=\n\ + Address = 10.2.0.2/32\n\ + DNS = 10.2.0.1\n\ + \n\ + [Peer]\n\ + PublicKey = ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=\n\ + AllowedIPs = 0.0.0.0/0, ::/0\n\ + Endpoint = 156.146.50.5:51820\n\ + PersistentKeepalive = 25\n"; + + /// Stands in for wireguard-go: accepts one connection, records the request, + /// and replies with the given canned response. + fn spawn_fake_device( + dir: &Path, + reply: &'static str, + ) -> (std::path::PathBuf, std::thread::JoinHandle) { + let path = dir.join("wg0.sock"); + let listener = UnixListener::bind(&path).unwrap(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = String::new(); + // The client writes a blank-line-terminated block and then waits, + // so read until that terminator rather than to EOF. + let mut byte = [0u8; 1]; + while stream.read(&mut byte).unwrap() == 1 { + request.push(byte[0] as char); + if request.ends_with("\n\n") { + break; + } + } + stream.write_all(reply.as_bytes()).unwrap(); + stream.flush().unwrap(); + request + }); + (path, handle) + } + + /// Per-test directory. These tests bind a unix socket at a fixed name + /// inside it, and the test harness runs them concurrently, so sharing one + /// directory would race on both `bind` and the teardown `remove_dir_all`. + fn tempdir(test_name: &str) -> std::path::PathBuf { + let dir = + std::env::temp_dir().join(format!("torad-uapi-{}-{test_name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn sends_a_wellformed_set_request() { + let dir = tempdir("set-request"); + let (path, handle) = spawn_fake_device(&dir, "errno=0\n\n"); + let config = config::parse(SAMPLE).unwrap(); + + configure(&path, &config).expect("errno=0 must be accepted"); + let request = handle.join().unwrap(); + + assert!(request.starts_with("set=1\n"), "got: {request}"); + assert!(request.ends_with("\n\n"), "must end with a blank line"); + // Hex, not the base64 from the file: PRIVATE_KEY is bytes 0x00..=0x1f. + let private_hex: String = (0u8..32).map(|b| format!("{b:02x}")).collect(); + assert!( + request.contains(&format!("private_key={private_hex}\n")), + "got: {request}" + ); + let public_hex: String = (32u8..64).map(|b| format!("{b:02x}")).collect(); + assert!( + request.contains(&format!("public_key={public_hex}\n")), + "got: {request}" + ); + assert!( + request.contains("endpoint=156.146.50.5:51820\n"), + "got: {request}" + ); + assert!( + request.contains("persistent_keepalive_interval=25\n"), + "got: {request}" + ); + assert!(request.contains("allowed_ip=0.0.0.0/0\n"), "got: {request}"); + assert!(request.contains("allowed_ip=::/0\n"), "got: {request}"); + // Without these, re-applying a config would stack duplicate peers. + assert!(request.contains("replace_peers=true\n"), "got: {request}"); + assert!( + request.contains("replace_allowed_ips=true\n"), + "got: {request}" + ); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn rejects_a_nonzero_errno() { + let dir = tempdir("nonzero-errno"); + let (path, handle) = spawn_fake_device(&dir, "errno=22\n\n"); + let config = config::parse(SAMPLE).unwrap(); + + let err = configure(&path, &config).unwrap_err(); + handle.join().unwrap(); + let text = format!("{err:#}"); + assert!(text.contains("errno=22"), "got: {text}"); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// The request carries the private key in hex; a failure must not echo it. + #[test] + fn errors_never_contain_the_private_key() { + let dir = tempdir("no-key-leak"); + let (path, handle) = spawn_fake_device(&dir, "errno=22\n\n"); + let config = config::parse(SAMPLE).unwrap(); + + let err = configure(&path, &config).unwrap_err(); + handle.join().unwrap(); + + let private_hex: String = (0u8..32).map(|b| format!("{b:02x}")).collect(); + let text = format!("{err:#}"); + assert!(!text.contains(&private_hex), "private key leaked: {text}"); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn parses_errno_from_a_full_response() { + let response = "private_key=00\nlisten_port=51820\npublic_key=11\nerrno=0\n"; + assert_eq!(parse_errno(response).unwrap(), 0); + assert_eq!(parse_errno("errno=95\n").unwrap(), 95); + assert!(parse_errno("no errno here\n").is_err()); + assert!(parse_errno("errno=banana\n").is_err()); + } + + #[test] + fn connect_failure_names_the_socket() { + let config = config::parse(SAMPLE).unwrap(); + let missing = Path::new("/nonexistent/torad/wg0.sock"); + let err = format!("{:#}", configure(missing, &config).unwrap_err()); + assert!(err.contains("wg0.sock"), "got: {err}"); + } +}