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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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"
|
||||
'';
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user