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 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-08-17 23:09:03 +02:00
parent 62db1e2a9e
commit b489ff7135
8 changed files with 267 additions and 24 deletions
+4 -4
View File
@@ -11,12 +11,12 @@ use super::info_hash;
use super::{Resolved, SourceHandler};
pub(crate) struct HttpTorrentHandler {
client: reqwest::Client,
clients: std::sync::Arc<super::HttpClients>,
}
impl HttpTorrentHandler {
pub(crate) fn new(client: reqwest::Client) -> Self {
Self { client }
pub(crate) fn new(clients: std::sync::Arc<super::HttpClients>) -> Self {
Self { clients }
}
}
@@ -27,7 +27,7 @@ impl SourceHandler for HttpTorrentHandler {
}
async fn resolve(&self, source: &str) -> Result<Resolved> {
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)
+4 -4
View File
@@ -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<super::HttpClients>,
}
impl JackettHandler {
pub(crate) fn new(client: reqwest::Client) -> Self {
Self { client }
pub(crate) fn new(clients: std::sync::Arc<super::HttpClients>) -> Self {
Self { clients }
}
}
@@ -43,7 +43,7 @@ impl SourceHandler for JackettHandler {
}
async fn resolve(&self, source: &str) -> Result<Resolved> {
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)
+137 -10
View File
@@ -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> {
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<reqwest::Client> {
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<reqwest::Client>,
}
impl HttpClients {
fn build(bind_device: Option<&str>) -> Result<Self> {
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::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
}
impl SourceResolver {
pub(crate) fn new() -> Result<Self> {
let http_client = build_http_client()?;
pub(crate) fn new(bind_device: Option<&str>) -> Result<Self> {
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
));
}
}
+5 -2
View File
@@ -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,
+77 -4
View File
@@ -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;
+18
View File
@@ -173,10 +173,28 @@ pub fn reexec_under_pasta() -> Result<std::convert::Infallible> {
.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",
+17
View File
@@ -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"
'';
+5
View File
@@ -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"