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}"); + } +}