From b489ff71355501b55f871f5a116f28018802563b Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 17 Aug 2026 23:09:03 +0200 Subject: [PATCH] feat(torad): route source fetches through the tunnel and package VPN mode Three things, all tail end of VPN mode. Source fetches are HTTP, not BitTorrent, so librqbit's SO_BINDTODEVICE never covered them. SourceResolver now holds two clients and picks one per URL: remote indexer and .torrent fetches go through a client bound to wg0, so a future route change cannot quietly send them around the tunnel; loopback URLs keep the unbound client, because pasta splices the namespace's loopback to the host's and that is how a self-hosted Jackett stays reachable. That traffic never leaves the machine, so keeping it off the tunnel is deliberate. This replaces the planned request-time URL rewriting, which turned out to be unnecessary: measured, pasta reaches host services on 127.0.0.1 from inside the namespace even when they bind after the namespace starts, so neither Jackett URLs nor a loopback DATABASE_URL need touching. Second, a defect the packaging work surfaced: killing the pid in the pid file killed pasta but left torad running, reparented to init, with a dead tap interface -- the daemon outliving the only documented way to stop it. The re-executed process now sets PR_SET_PDEATHSIG so it dies with pasta. Third, packaging: passt, wireguard-go and iproute2 in both devenv files, and the module documentation states the Linux-only, leech-only and pinned-endpoint limitations along with what happens when the tunnel drops. wireguard-tools is deliberately absent -- the device is configured over UAPI. Co-Authored-By: Claude Opus 5 --- crates/torad/src/source/http_torrent.rs | 8 +- crates/torad/src/source/jackett.rs | 8 +- crates/torad/src/source/mod.rs | 147 ++++++++++++++++++++++-- crates/torad/src/torrents.rs | 7 +- crates/torad/src/vpn/mod.rs | 81 ++++++++++++- crates/torad/src/vpn/namespace.rs | 18 +++ devenv.nix | 17 +++ rustfmt.toml | 5 + 8 files changed, 267 insertions(+), 24 deletions(-) create mode 100644 rustfmt.toml diff --git a/crates/torad/src/source/http_torrent.rs b/crates/torad/src/source/http_torrent.rs index cf51754..1725f4f 100644 --- a/crates/torad/src/source/http_torrent.rs +++ b/crates/torad/src/source/http_torrent.rs @@ -11,12 +11,12 @@ use super::info_hash; use super::{Resolved, SourceHandler}; pub(crate) struct HttpTorrentHandler { - client: reqwest::Client, + clients: std::sync::Arc, } impl HttpTorrentHandler { - pub(crate) fn new(client: reqwest::Client) -> Self { - Self { client } + pub(crate) fn new(clients: std::sync::Arc) -> Self { + Self { clients } } } @@ -27,7 +27,7 @@ impl SourceHandler for HttpTorrentHandler { } async fn resolve(&self, source: &str) -> Result { - let outcome = http_fetch::fetch(&self.client, source).await?; + let outcome = http_fetch::fetch(self.clients.for_url(source), source).await?; match outcome { HttpFetchOutcome::Magnet(magnet) => { let parsed = Magnet::parse(&magnet) diff --git a/crates/torad/src/source/jackett.rs b/crates/torad/src/source/jackett.rs index d023538..63b17ce 100644 --- a/crates/torad/src/source/jackett.rs +++ b/crates/torad/src/source/jackett.rs @@ -26,12 +26,12 @@ use super::{Resolved, SourceHandler}; const JACKETT_MARKER: &str = "jackett_apikey="; pub(crate) struct JackettHandler { - client: reqwest::Client, + clients: std::sync::Arc, } impl JackettHandler { - pub(crate) fn new(client: reqwest::Client) -> Self { - Self { client } + pub(crate) fn new(clients: std::sync::Arc) -> Self { + Self { clients } } } @@ -43,7 +43,7 @@ impl SourceHandler for JackettHandler { } async fn resolve(&self, source: &str) -> Result { - let outcome = http_fetch::fetch(&self.client, source).await?; + let outcome = http_fetch::fetch(self.clients.for_url(source), source).await?; match outcome { HttpFetchOutcome::Magnet(magnet) => { let parsed = Magnet::parse(&magnet) diff --git a/crates/torad/src/source/mod.rs b/crates/torad/src/source/mod.rs index 620ddb0..8ab95bf 100644 --- a/crates/torad/src/source/mod.rs +++ b/crates/torad/src/source/mod.rs @@ -60,22 +60,87 @@ pub(crate) struct SourceResolver { /// loopback, and routing loopback through an inherited proxy breaks it. Note /// this client also serves arbitrary remote `.torrent` URLs via /// [`http_torrent`], so the setting applies to those too. -fn build_http_client() -> Result { - reqwest::Client::builder() +/// +/// `bind_device` binds the client's sockets to one interface, mirroring what +/// librqbit does for BitTorrent traffic. +fn build_http_client(bind_device: Option<&str>) -> Result { + let builder = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) - .no_proxy() - .build() - .context("failed to build HTTP client") + .no_proxy(); + let builder = match bind_device { + Some(device) => builder.interface(device), + None => builder, + }; + builder.build().context("failed to build HTTP client") +} + +/// The two clients a source fetch may need. +/// +/// In VPN mode a remote `.torrent` URL must leave through the tunnel, and the +/// default route already sends it there — but routing is a property of the +/// namespace's route table, while `SO_BINDTODEVICE` is enforced by the kernel +/// per socket. Binding means a future route change cannot silently reroute an +/// indexer fetch around the tunnel. +/// +/// Loopback sources must *not* be bound. pasta splices the namespace's loopback +/// to the host's, which is how a self-hosted Jackett at +/// `http://localhost:9117/...` stays reachable; binding those sockets to the +/// tunnel would break them, and that traffic is deliberately off the tunnel +/// anyway since it never leaves the machine. +pub(crate) struct HttpClients { + direct: reqwest::Client, + tunnel: Option, +} + +impl HttpClients { + fn build(bind_device: Option<&str>) -> Result { + Ok(Self { + direct: build_http_client(None)?, + tunnel: bind_device + .map(|device| build_http_client(Some(device))) + .transpose()?, + }) + } + + /// Pick the client this URL should be fetched with. + pub(super) fn for_url(&self, url: &str) -> &reqwest::Client { + match &self.tunnel { + Some(tunnel) if !is_loopback_url(url) => tunnel, + _ => &self.direct, + } + } +} + +/// Whether a URL points at this machine. +/// +/// A URL that cannot be parsed is treated as non-loopback: in VPN mode the safe +/// default is the tunnel, since guessing "local" for something remote would +/// send it out unbound. +fn is_loopback_url(url: &str) -> bool { + let Ok(parsed) = reqwest::Url::parse(url) else { + return false; + }; + let Some(host) = parsed.host_str() else { + return false; + }; + if host.eq_ignore_ascii_case("localhost") { + return true; + } + // `host_str` keeps IPv6 literals in their bracketed form. + let host = host.strip_prefix('[').unwrap_or(host); + let host = host.strip_suffix(']').unwrap_or(host); + host.parse::() + .is_ok_and(|ip| ip.is_loopback()) } impl SourceResolver { - pub(crate) fn new() -> Result { - let http_client = build_http_client()?; + pub(crate) fn new(bind_device: Option<&str>) -> Result { + let clients = std::sync::Arc::new(HttpClients::build(bind_device)?); Ok(Self { handlers: vec![ Box::new(magnet::MagnetHandler), - Box::new(jackett::JackettHandler::new(http_client.clone())), - Box::new(http_torrent::HttpTorrentHandler::new(http_client)), + Box::new(jackett::JackettHandler::new(clients.clone())), + Box::new(http_torrent::HttpTorrentHandler::new(clients)), Box::new(file::FileHandler), ], }) @@ -133,7 +198,7 @@ mod tests { let addr = spawn_oneshot_server().await; unsafe { std::env::set_var("ALL_PROXY", "http://127.0.0.1:1") }; - let client = build_http_client(); + let client = build_http_client(None); unsafe { std::env::remove_var("ALL_PROXY") }; let response = client @@ -145,4 +210,66 @@ mod tests { assert_eq!(response.status(), reqwest::StatusCode::OK); assert_eq!(response.text().await.unwrap(), "hi"); } + + #[test] + fn recognises_loopback_urls() { + for url in [ + "http://localhost:9117/dl/x", + "http://LOCALHOST/dl/x", + "http://127.0.0.1:9117/dl/x", + // The whole 127.0.0.0/8 block, not just .0.1. + "http://127.1.2.3/dl/x", + "http://[::1]:9117/dl/x", + ] { + assert!(is_loopback_url(url), "{url} should be loopback"); + } + + for url in [ + "http://example.com/x.torrent", + "https://tracker.example.org:8080/dl", + // pasta's host-loopback alias is reachable without the tunnel, but + // it is not itself a loopback address and does not need to be: + // it is not on the tunnel's route either way. + "http://169.254.1.1:5432/", + "http://10.0.0.5/x.torrent", + "magnet:?xt=urn:btih:0000", + "not a url at all", + ] { + assert!(!is_loopback_url(url), "{url} should not be loopback"); + } + } + + /// Without a bind device there is one client and it is used for everything. + #[test] + fn unbound_mode_uses_the_direct_client_for_every_url() { + let clients = HttpClients::build(None).unwrap(); + let remote = clients.for_url("http://example.com/x.torrent"); + let local = clients.for_url("http://localhost:9117/dl/x"); + // Same client instance for both. + assert!(std::ptr::eq(remote, local)); + assert!(std::ptr::eq(remote, &clients.direct)); + } + + /// With a bind device, remote fetches are enforced onto it and loopback + /// fetches are deliberately left off it. + #[test] + fn bound_mode_sends_only_remote_urls_through_the_tunnel() { + // `lo` rather than `wg0`: it exists on any host, so the client can + // actually be built. Only the selection logic is under test. + let clients = HttpClients::build(Some("lo")).unwrap(); + let tunnel = clients.tunnel.as_ref().expect("tunnel client must exist"); + + assert!(std::ptr::eq( + clients.for_url("http://example.com/x.torrent"), + tunnel + )); + assert!(std::ptr::eq( + clients.for_url("http://localhost:9117/dl/x"), + &clients.direct + )); + assert!(std::ptr::eq( + clients.for_url("http://127.0.0.1:9117/dl/x"), + &clients.direct + )); + } } diff --git a/crates/torad/src/torrents.rs b/crates/torad/src/torrents.rs index 69f7ac1..8aa4e57 100644 --- a/crates/torad/src/torrents.rs +++ b/crates/torad/src/torrents.rs @@ -78,13 +78,16 @@ impl TorrentManager { let session = Session::new_with_opts( download_dir.clone(), SessionOptions { - bind_device_name: bind_device, + bind_device_name: bind_device.clone(), ..Default::default() }, ) .await .context("failed to create librqbit session")?; - let source = SourceResolver::new().context("failed to build source resolver")?; + // Source fetches are HTTP, not BitTorrent, so librqbit's binding does + // not cover them; the resolver binds its own client to the same device. + let source = SourceResolver::new(bind_device.as_deref()) + .context("failed to build source resolver")?; let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY); Ok(Arc::new(Self { pool, diff --git a/crates/torad/src/vpn/mod.rs b/crates/torad/src/vpn/mod.rs index d863bb4..e94a5f2 100644 --- a/crates/torad/src/vpn/mod.rs +++ b/crates/torad/src/vpn/mod.rs @@ -1,10 +1,83 @@ //! 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. +//! # Using it +//! +//! ```text +//! torad --wireguard-config /path/to/wg0.conf ... +//! ``` +//! +//! That is the whole interface. No `sudo`, no `wg-quick`, no pre-created +//! interface, no kernel module, and no one-time host setup. `--bind-device` is +//! ignored when this is given; the tunnel is the bind device. +//! +//! Requires `pasta` (from passt), `wireguard-go`, and `ip` on `PATH`. +//! [`namespace::preflight`] checks for all three before anything is torn down, +//! so a missing one fails immediately with a message naming it. +//! +//! # How it works +//! +//! ```text +//! host │ torad's user+network namespace +//! ────────────────────────────────┼──────────────────────────────────────── +//! aggregator ──unix socket───────────► torad gRPC server +//! postgres, jackett ◄────────────┐ (mount ns shares the path, so the +//! │ host still reaches the socket) +//! │ +//! │ pasta ── tap iface, host addressing +//! │ ├─ 127.0.0.1 → host loopback +//! │ └─ 169.254.1.1 → host loopback +//! │ +//! │ wireguard-go ── wg0 (TUN) +//! │ └─ default route ──► internet +//! │ +//! │ librqbit: SO_BINDTODEVICE(wg0) +//! │ peers, trackers, DHT, LSD +//! ``` +//! +//! 1. torad re-executes itself under `pasta`, which supplies an unprivileged +//! user+network namespace with `CAP_NET_ADMIN` ([`namespace`]). +//! 2. It unshares a mount namespace so `wireguard-go` has a writable socket +//! directory ([`namespace::enter_mount_namespace`]). +//! 3. It starts `wireguard-go` and configures the device over its UAPI socket +//! ([`uapi`]) — no `wg` binary involved, so `wireguard-tools` is not a +//! dependency. +//! 4. It assigns the address, pins the peer endpoint outside the tunnel, moves +//! the default route onto `wg0`, and installs the tunnel's DNS ([`tunnel`]). +//! 5. librqbit binds every socket to `wg0` via `SO_BINDTODEVICE`. +//! +//! Host services stay reachable without any URL rewriting: pasta splices the +//! namespace's loopback to the host's, so `http://localhost:9117/...` for +//! Jackett and a loopback `--database-url` for Postgres work unchanged, and +//! deliberately do not traverse the tunnel. +//! +//! # When the tunnel drops +//! +//! Two independent mechanisms, both fail-closed: +//! +//! - `SO_BINDTODEVICE` is kernel-enforced. If `wg0` goes away, librqbit's +//! sockets error rather than falling back to the host route. +//! - torad supervises `wireguard-go` and shuts down if it exits. A live torad +//! with a dead tunnel is the failure mode that leaks. +//! +//! DNS is inside the tunnel too, so a dead tunnel means tracker hostnames stop +//! resolving rather than being resolved by the host's resolver. +//! +//! # Limitations +//! +//! - **Linux only.** User namespaces, `SO_BINDTODEVICE`, and pasta are all +//! Linux-specific. +//! - **Leech-only.** librqbit runs with `listen: None`, so there is no +//! listening socket and no uTP in either direction. Incoming peer +//! connections would need NAT-PMP port forwarding through the provider, +//! which is not implemented. +//! - **The peer endpoint is pinned at startup.** It is resolved once and given +//! a host route so the handshake does not enter its own tunnel; an endpoint +//! that roams to a new address mid-session is not followed. Restart to pick +//! up a new one. +//! - **Userspace crypto.** Throughput is lower than the kernel module's. +//! - **One peer.** [`config`] rejects configs with more than one `[Peer]` +//! rather than silently using the last. pub mod config; pub mod namespace; diff --git a/crates/torad/src/vpn/namespace.rs b/crates/torad/src/vpn/namespace.rs index ed53ccb..5ffc086 100644 --- a/crates/torad/src/vpn/namespace.rs +++ b/crates/torad/src/vpn/namespace.rs @@ -173,10 +173,28 @@ pub fn reexec_under_pasta() -> Result { .context("failed to execute pasta; VPN mode cannot continue") } +/// Ask the kernel to signal this process when pasta exits. +/// +/// pasta does not kill the command it spawned when it is itself killed: the +/// namespace loses its tap interface but torad keeps running, orphaned and +/// unreachable through the pid file that named pasta. Since that pid file is +/// the documented way to stop torad, torad has to die with pasta. +/// +/// `PR_SET_PDEATHSIG` survives into this process because it was set after the +/// `execvpe`, and it fires across the PID namespace boundary — the signal is +/// tied to the parent task exiting, not to what pasta's PID looks like from in +/// here (from inside the namespace it is not visible at all). +fn die_with_parent() -> Result<()> { + nix::sys::prctl::set_pdeathsig(nix::sys::signal::Signal::SIGTERM) + .context("failed to arrange for torad to exit when pasta does") +} + /// 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> { + die_with_parent()?; + unshare(CloneFlags::CLONE_NEWNS).context( "failed to unshare a mount namespace; \ VPN mode needs one to give wireguard-go a writable socket directory", diff --git a/devenv.nix b/devenv.nix index 34cd8e6..eaf5c8a 100644 --- a/devenv.nix +++ b/devenv.nix @@ -55,6 +55,18 @@ in buf grpcurl + # VPN mode (torad --wireguard-config). Both are runtime dependencies torad + # execs, not build inputs. + # passt -> `pasta`, which creates the unprivileged user+network + # namespace torad re-executes itself into. + # wireguard-go -> the userspace WireGuard datapath. + # `wireguard-tools` is deliberately absent: torad configures the device by + # speaking the UAPI protocol over its socket, so no `wg` binary is needed. + # `iproute2` provides the `ip` used for the tunnel address and routes. + passt + wireguard-go + iproute2 + # Protobuf generators protoc-gen-prost-crate protoc-gen-prost-serde @@ -80,6 +92,11 @@ in env.TORAD_PID_FILE = "${config.env.DEVENV_RUNTIME}/torad.pid"; processes.torad = { + # VPN mode is opt-in so the default `devenv up` is unchanged: set + # TORAD_WIREGUARD_CONFIG to a wg-quick config path and torad will re-exec + # itself into a namespace and route all torrent traffic through the tunnel. + # torad reads that variable itself (clap `env`), so nothing is needed here + # beyond leaving it unset by default. exec = '' ${config.outputs.torad}/bin/torad --socket "$TORAD_SOCKET" --pid-file "$TORAD_PID_FILE" ''; diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..3d78d3d --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,5 @@ +# Declares the edition for `rustfmt` when it parses buffers via stdin +# (e.g. emacs `rust-format-buffer` / DOOM `format-all`). rustfmt does not +# consult Cargo.toml for stdin input, so without this it defaults to 2015 +# and rejects `async fn` with E0670. Must match edition in Cargo.toml. +edition = "2024"