feat(torad): upgrade librqbit to 9.0.0-rc.0 and add --bind-device

Pinned exactly (=9.0.0-rc.0) — no v9 stable exists yet, so a caret range
would silently drift onto a future prerelease.

Two source changes were needed for the upgrade:

- `torrent_from_bytes_ext` moved to `librqbit_core::torrent_metainfo::
  torrent_from_bytes` and no longer wraps the result in a `meta` field.
- `peer_stats.live` / `.not_needed` are now `u32`, so the casts are
  redundant.

v9 also adds `SessionOptions::bind_device_name`, which is the reason for
the upgrade: it applies SO_BINDTODEVICE to every librqbit socket — peer
connections, trackers, DHT and LSD. Binding those to a VPN interface
means that when the interface goes away the sockets error out instead of
falling back to the host route, giving a kernel-enforced kill switch.
Exposed as --bind-device / TORAD_BIND_DEVICE; unset reproduces today's
behaviour exactly.

Note that `listen` stays at its default of None, so there is no listener
and no uTP socket in either direction — torad is TCP-only and leech-only.
That is unchanged from v8 but now written down, since incoming
connections need NAT-PMP port forwarding that we have not built.

Also drops torad's `nix` pin from 0.29 to 0.31.3 to match the sibling
tora crate; the workspace was carrying three copies.

Verified end-to-end against a real swarm rather than by compiling alone:
a 755 MiB torrent added via HTTP .torrent URL, driven through
pending -> downloading -> finished, with pause/resume on an active
torrent, remove, and the notification stream all exercised. Evidence in
.omo/evidence/task-5-torad-vpn-namespace.txt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-08-15 00:17:09 +02:00
parent 7842b5aeba
commit dd41f1961f
5 changed files with 384 additions and 100 deletions
+7 -1
View File
@@ -39,6 +39,12 @@ struct Args {
/// (e.g. `kill $(cat /tmp/torad.pid)`).
#[arg(long, env = "TORAD_PID_FILE")]
pid_file: Option<PathBuf>,
/// 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.
#[arg(long, env = "TORAD_BIND_DEVICE")]
bind_device: Option<String>,
}
fn default_socket_path() -> PathBuf {
@@ -70,7 +76,7 @@ async fn main() -> Result<()> {
.context("failed to connect to Postgres")?;
info!("connected to Postgres");
let manager = TorrentManager::new(pool.clone(), download_dir)
let manager = TorrentManager::new(pool.clone(), download_dir, args.bind_device)
.await
.context("failed to start torrent manager")?;
manager.clone().spawn_poller();
+3 -3
View File
@@ -13,14 +13,14 @@
//! parsing it) yields the correct hash even for spec-violating files.
use anyhow::{Context, Result};
use librqbit::torrent_from_bytes_ext;
use librqbit_core::torrent_metainfo::torrent_from_bytes;
use sha1::{Digest, Sha1};
/// Extracts the v1 info-hash from `.torrent` bytes.
pub(super) fn from_bytes(bytes: &[u8]) -> Result<String> {
// Fast path: spec-compliant torrent, librqbit's parser handles it.
if let Ok(parsed) = torrent_from_bytes_ext::<Vec<u8>>(bytes) {
return Ok(parsed.meta.info_hash.as_string());
if let Ok(parsed) = torrent_from_bytes(bytes) {
return Ok(parsed.info_hash.as_string());
}
// Fallback: locate the `info` value's byte span and SHA1 it directly.
let span = locate_info_value(bytes)
+29 -7
View File
@@ -5,7 +5,9 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use librqbit::api::TorrentIdOrHash;
use librqbit::{AddTorrent, AddTorrentOptions, ManagedTorrent, Session, TorrentStatsState};
use librqbit::{
AddTorrent, AddTorrentOptions, ManagedTorrent, Session, SessionOptions, TorrentStatsState,
};
use librqbit_core::Id20;
use sqlx::PgPool;
use tokio::sync::{Mutex, broadcast};
@@ -52,16 +54,36 @@ pub struct TorrentManager {
}
impl TorrentManager {
pub async fn new(pool: PgPool, download_dir: PathBuf) -> Result<Arc<Self>> {
pub async fn new(
pool: PgPool,
download_dir: PathBuf,
bind_device: Option<String>,
) -> Result<Arc<Self>> {
std::fs::create_dir_all(&download_dir).with_context(|| {
format!(
"failed to create download directory {}",
download_dir.display()
)
})?;
let session = Session::new(download_dir.clone())
.await
.context("failed to create librqbit session")?;
// `bind_device` scopes every librqbit socket — peers, trackers, DHT, LSD —
// to one interface via SO_BINDTODEVICE. When the interface goes away those
// sockets error instead of falling back to the host route, which is the
// kill switch VPN mode relies on. `None` reproduces the default behaviour:
// `Session::new` is itself `new_with_opts(dir, SessionOptions::default())`.
//
// `listen` stays at its default of `None`, so there is no listener and no
// uTP socket in either direction — torad is TCP-only and leech-only. That
// is a deliberate limitation for now; incoming connections need NAT-PMP
// port forwarding, which is deferred.
let session = Session::new_with_opts(
download_dir.clone(),
SessionOptions {
bind_device_name: bind_device,
..Default::default()
},
)
.await
.context("failed to create librqbit session")?;
let source = SourceResolver::new().context("failed to build source resolver")?;
let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
Ok(Arc::new(Self {
@@ -654,8 +676,8 @@ fn live_stats_from_stats(stats: &librqbit::TorrentStats) -> LiveTorrentStats {
eta_seconds: eta,
uploaded_bytes,
upload_speed_bps: upload_bps,
peers: live.snapshot.peer_stats.live as u32,
seeds: live.snapshot.peer_stats.not_needed as u32,
peers: live.snapshot.peer_stats.live,
seeds: live.snapshot.peer_stats.not_needed,
}
}