Compare commits

...

3 Commits

Author SHA1 Message Date
Alexander e321300f67 feat(torad): add WireGuard config parser and user-namespace preflight
Groundwork for VPN mode; nothing calls it yet, so the module is dead code
until the namespace bootstrap lands.

vpn::config parses the ProtonVPN-style WireGuard INI into typed sections.
`InterfaceSection` gets a hand-written Debug that redacts the private
key — the derived one would print it verbatim, and this struct is about
to start flowing through error contexts during interface setup.

vpn::namespace::preflight checks user.max_user_namespaces and fails with
the sysctl name and its remediation when unprivileged user namespaces are
disabled. The decision logic is split into `evaluate` so the tests
exercise the real code path rather than a copy of it — the /proc file
itself cannot be mocked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:17:33 +02:00
Alexander 7103184c8e fix(torad): bypass system proxy env vars when fetching .torrent sources
reqwest honours HTTP_PROXY/ALL_PROXY from the environment by default. A
self-hosted Jackett is normally reached over loopback, and routing
loopback through an inherited proxy breaks it — which matters once torad
runs in an environment where those vars are set for VPN reasons.

Extracts the builder into `build_http_client` so the behaviour is
directly testable, and adds a test that serves one response from a
loopback listener while ALL_PROXY points at a dead port. The test is
falsifiable: removing .no_proxy() makes it fail with ConnectionRefused
against the proxy address, which was verified before committing.

The proxy URL is deliberately http:// rather than socks5:// — reqwest is
built here without its `socks` feature, so a SOCKS proxy would be ignored
and the test would pass either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:17:19 +02:00
Alexander dd41f1961f 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>
2026-08-15 00:17:09 +02:00
9 changed files with 852 additions and 107 deletions
Generated
+342 -87
View File
@@ -97,6 +97,12 @@ dependencies = [
"rustversion",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "assert_cfg"
version = "0.1.0"
@@ -208,6 +214,8 @@ dependencies = [
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
@@ -216,9 +224,11 @@ dependencies = [
"pin-project-lite",
"serde_core",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -237,17 +247,43 @@ dependencies = [
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "backoff"
version = "0.4.0"
name = "axum-extra"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1"
checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
dependencies = [
"getrandom 0.2.17",
"instant",
"rand 0.8.6",
"axum",
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"serde_core",
"serde_html_form",
"serde_path_to_error",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "backon"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"
dependencies = [
"fastrand",
"gloo-timers",
"tokio",
]
[[package]]
@@ -256,15 +292,6 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]]
name = "bincode"
version = "2.0.1"
@@ -397,6 +424,17 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "chardetng"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea"
dependencies = [
"cfg-if",
"encoding_rs",
"memchr",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -724,6 +762,18 @@ dependencies = [
"syn",
]
[[package]]
name = "dontfrag"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c117949f5a8b25ba471b4dfea927a07a2f8730c585532a2eb4294e18f5155397"
dependencies = [
"cfg-if",
"libc",
"tokio",
"windows-sys 0.52.0",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
@@ -751,6 +801,15 @@ dependencies = [
"serde",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -788,6 +847,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1011,6 +1076,18 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "gloo-timers"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
dependencies = [
"futures-channel",
"futures-core",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "governor"
version = "0.10.4"
@@ -1405,15 +1482,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "instant"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
]
[[package]]
name = "intervaltree"
version = "0.2.7"
@@ -1505,18 +1573,19 @@ dependencies = [
[[package]]
name = "librqbit"
version = "8.1.1"
version = "9.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dadca8f521242010a4c846ef5f224c217009c92e272709cdc08ba9cdabe62983"
checksum = "b9451def41c18d1b8e7b68c097bcb0c9a0579cdc9b779803368a65b86cae03d2"
dependencies = [
"anyhow",
"arc-swap",
"async-compression",
"async-stream",
"async-trait",
"backoff",
"axum-extra",
"backon",
"base64",
"bincode 2.0.1",
"bincode",
"bitvec",
"byteorder",
"bytes",
@@ -1532,22 +1601,29 @@ dependencies = [
"librqbit-clone-to-owned",
"librqbit-core",
"librqbit-dht",
"librqbit-dualstack-sockets",
"librqbit-lsd",
"librqbit-peer-protocol",
"librqbit-sha1-wrapper",
"librqbit-tracker-comms",
"librqbit-upnp",
"librqbit-utp",
"memmap2",
"mime_guess",
"nix 0.30.1",
"parking_lot",
"rand 0.9.4",
"regex",
"reqwest",
"rlimit",
"serde",
"serde_derive",
"serde_json",
"serde_urlencoded",
"serde_with",
"size_format",
"socket2",
"thiserror 2.0.18",
"tokio",
"tokio-socks",
"tokio-stream",
@@ -1557,61 +1633,71 @@ dependencies = [
"urlencoding",
"uuid",
"walkdir",
"windows",
]
[[package]]
name = "librqbit-bencode"
version = "3.1.0"
version = "9.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "606dff526ba81e3eca33e2bb28b53afa2bc0b2c41d252333fa44e6c11abb37da"
checksum = "97490fb9b35fb575f498cc1e804b23be6bbf3603d5a2358733e5b867d1ee52e8"
dependencies = [
"anyhow",
"arrayvec",
"atoi",
"bytes",
"librqbit-buffers",
"librqbit-clone-to-owned",
"librqbit-sha1-wrapper",
"serde",
"serde_derive",
"thiserror 2.0.18",
]
[[package]]
name = "librqbit-buffers"
version = "4.2.0"
version = "9.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c78b907d6171a7191c162b2b60db46d254ebde6a95282b77372af556c1463"
checksum = "e845dbd14174712471c3430c958d5c25e391561c2dd31f39f895bb60f63c46f0"
dependencies = [
"bytes",
"librqbit-clone-to-owned",
"serde",
"serde_derive",
]
[[package]]
name = "librqbit-clone-to-owned"
version = "3.0.1"
version = "9.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbd1e66d773ba9c475ff89286dc1d6f9d167cbb898603797467dd0ea6844c445"
checksum = "76ef698eb8d7745f425f2b9a93e6a9d946bcb8bf84a559303e0fc3f01e8ad07c"
dependencies = [
"bytes",
]
[[package]]
name = "librqbit-core"
version = "5.0.0"
version = "9.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55a02cc6fce6743ad38661ccd6fafc6cf1ae5e0106a9922836b0524dbe752378"
checksum = "a38e805aad201d96024849649174015d48a575348b35bbd927d99b47464e665e"
dependencies = [
"anyhow",
"assert_cfg",
"bytes",
"chardetng",
"data-encoding",
"directories",
"encoding_rs",
"hex",
"itertools",
"librqbit-bencode",
"librqbit-buffers",
"librqbit-clone-to-owned",
"librqbit-sha1-wrapper",
"memchr",
"parking_lot",
"rand 0.9.4",
"serde",
"serde_derive",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"tracing",
@@ -1620,27 +1706,29 @@ dependencies = [
[[package]]
name = "librqbit-dht"
version = "5.3.1"
version = "9.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7cc129194337771a86b0399956c4d9bf1cd97c5f24d14a50be38e170f76a54b"
checksum = "d59a9374b33a52a065171641ae518db97969d3db849a57824c72c53d2109918b"
dependencies = [
"anyhow",
"backoff",
"byteorder",
"backon",
"bytes",
"chrono",
"dashmap",
"futures",
"hex",
"indexmap 2.14.0",
"leaky-bucket",
"librqbit-bencode",
"librqbit-buffers",
"librqbit-clone-to-owned",
"librqbit-core",
"librqbit-dualstack-sockets",
"parking_lot",
"rand 0.9.4",
"serde",
"serde_derive",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tokio-util",
@@ -1648,13 +1736,49 @@ dependencies = [
]
[[package]]
name = "librqbit-peer-protocol"
version = "4.3.0"
name = "librqbit-dualstack-sockets"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a73129497b500505f33d1dc0426319b6a6a208f13fdfaae56224ab8c2346a773"
checksum = "f7189f1ff66ca75055137a38b062ba7df9691d071b7172e5090997c62eb4b4de"
dependencies = [
"axum",
"backon",
"futures",
"libc",
"network-interface",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]]
name = "librqbit-lsd"
version = "9.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e65ddc3b64392a71378944712c9830aac8ef98f860b180419dd42b4596e7faa"
dependencies = [
"anyhow",
"atoi",
"bstr",
"futures",
"httparse",
"librqbit-core",
"librqbit-dualstack-sockets",
"parking_lot",
"rand 0.9.4",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "librqbit-peer-protocol"
version = "9.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ade1bbea8cf3e7eb51978b32a549787d17bbf872f3f0ed47d7c85e7377705a"
dependencies = [
"anyhow",
"bincode 1.3.3",
"bitvec",
"byteorder",
"bytes",
@@ -1664,13 +1788,16 @@ dependencies = [
"librqbit-clone-to-owned",
"librqbit-core",
"serde",
"serde_derive",
"thiserror 2.0.18",
"tracing",
]
[[package]]
name = "librqbit-sha1-wrapper"
version = "4.1.0"
version = "9.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79373a02db73159e4de7ca5d27b6eeae2d540df66c6801db2b01c5513d087524"
checksum = "eeb119e5c05f1aa4011cf2e4dc19f83403064d2210804e2f35f06abac4e5b710"
dependencies = [
"assert_cfg",
"aws-lc-rs",
@@ -1678,21 +1805,26 @@ dependencies = [
[[package]]
name = "librqbit-tracker-comms"
version = "3.0.0"
version = "9.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08204944c5be677a5de8e1230e0249fce5c14abef23048e26452c6fb03f1b260"
checksum = "0ae0c520cc5f028fb50d746eaa6ba2aa082228db96c2baf1a312f9cacfceeb58"
dependencies = [
"anyhow",
"async-stream",
"backon",
"byteorder",
"futures",
"itertools",
"librqbit-bencode",
"librqbit-buffers",
"librqbit-core",
"librqbit-dualstack-sockets",
"parking_lot",
"rand 0.9.4",
"reqwest",
"serde",
"serde_derive",
"serde_with",
"tokio",
"tokio-util",
"tracing",
@@ -1702,23 +1834,49 @@ dependencies = [
[[package]]
name = "librqbit-upnp"
version = "1.0.0"
version = "9.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "545aad6124c97201055983137e12a19f34acad565120c3cd30596cbd72e8fa86"
checksum = "058af0d5e2a99c567b73369455e3852c48addcf751836f08cfc25ca89dd16cc3"
dependencies = [
"anyhow",
"bstr",
"futures",
"httparse",
"librqbit-dualstack-sockets",
"network-interface",
"quick-xml",
"reqwest",
"serde",
"serde_derive",
"socket2",
"tokio",
"tracing",
"url",
]
[[package]]
name = "librqbit-utp"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f3bfdc73944bc76cab24d5690a98816770040a654c449edf5ff2b9ba22626aa"
dependencies = [
"bitvec",
"dontfrag",
"lazy_static",
"libc",
"librqbit-dualstack-sockets",
"metrics",
"parking_lot",
"rand 0.9.4",
"ringbuf",
"rustc-hash",
"socket2",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "libsqlite3-sys"
version = "0.37.0"
@@ -1787,6 +1945,16 @@ dependencies = [
"libc",
]
[[package]]
name = "metrics"
version = "0.24.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2"
dependencies = [
"portable-atomic",
"rapidhash",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -1836,6 +2004,18 @@ dependencies = [
"winapi",
]
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nix"
version = "0.31.3"
@@ -2033,6 +2213,15 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2106,9 +2295,9 @@ dependencies = [
[[package]]
name = "quick-xml"
version = "0.37.5"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
"serde",
@@ -2196,24 +2385,13 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
"rand_chacha",
"rand_core 0.9.5",
]
@@ -2228,16 +2406,6 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
@@ -2248,15 +2416,6 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
@@ -2272,6 +2431,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rapidhash"
version = "4.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e"
dependencies = [
"rustversion",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
@@ -2405,6 +2573,17 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "ringbuf"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe47b720588c8702e34b5979cb3271a8b1842c7cb6f57408efa70c779363488c"
dependencies = [
"crossbeam-utils",
"portable-atomic",
"portable-atomic-util",
]
[[package]]
name = "rlimit"
version = "0.10.2"
@@ -2536,6 +2715,19 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_html_form"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f"
dependencies = [
"form_urlencoded",
"indexmap 2.14.0",
"itoa",
"ryu",
"serde_core",
]
[[package]]
name = "serde_json"
version = "1.0.150"
@@ -2549,6 +2741,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -3204,7 +3407,7 @@ dependencies = [
"clap",
"colored",
"hyper-util",
"nix",
"nix 0.31.3",
"serde_json",
"tokio",
"tokio-stream",
@@ -3234,6 +3437,7 @@ dependencies = [
"clap",
"librqbit",
"librqbit-core",
"nix 0.31.3",
"reqwest",
"sha1 0.10.7",
"sqlx",
@@ -3657,6 +3861,27 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
]
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -3670,6 +3895,17 @@ dependencies = [
"windows-strings",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
@@ -3698,6 +3934,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -3750,6 +3996,15 @@ dependencies = [
"windows_x86_64_msvc",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
+3 -2
View File
@@ -7,8 +7,9 @@ edition = "2024"
anyhow = "1.0.103"
async-trait = "0.1"
clap = { version = "4.6.1", features = ["derive", "env"] }
librqbit = { version = "8.1.1", default-features = false, features = ["rust-tls", "http-api-client"] }
librqbit-core = { version = "5.0.0", default-features = false }
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"] }
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"] }
+8 -1
View File
@@ -1,6 +1,7 @@
mod db;
mod source;
mod torrents;
mod vpn;
use std::path::PathBuf;
use std::time::Duration;
@@ -39,6 +40,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 +77,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)
+77 -7
View File
@@ -48,15 +48,29 @@ pub(crate) struct SourceResolver {
handlers: Vec<Box<dyn SourceHandler>>,
}
/// Builds the HTTP client shared by the Jackett and generic-`.torrent` handlers.
///
/// Two non-default settings, both load-bearing:
///
/// - **Manual redirect policy.** Jackett 302-redirects to `magnet:` URIs that
/// reqwest refuses to follow; we inspect `Location` ourselves so the magnet
/// handler can take over.
/// - **No system proxy.** reqwest otherwise honours `HTTP_PROXY`/`ALL_PROXY`
/// from the environment. A self-hosted Jackett is typically reached over
/// 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()
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.build()
.context("failed to build HTTP client")
}
impl SourceResolver {
pub(crate) fn new() -> Result<Self> {
// Manual redirect policy: Jackett 302-redirects to `magnet:` URIs that
// reqwest would refuse to follow; we want to inspect `Location`
// ourselves so the magnet handler can take over.
let http_client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.context("failed to build HTTP client")?;
let http_client = build_http_client()?;
Ok(Self {
handlers: vec![
Box::new(magnet::MagnetHandler),
@@ -76,3 +90,59 @@ impl SourceResolver {
anyhow::bail!("no handler matched source: {source}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
/// Serves exactly one minimal HTTP/1.1 response, then stops. Returns the
/// bound address so the test can build a URL for it.
async fn spawn_oneshot_server() -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
if let Ok((mut sock, _)) = listener.accept().await {
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf).await;
let _ = sock
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi")
.await;
let _ = sock.flush().await;
}
});
addr
}
/// Regression for [`build_http_client`]'s `.no_proxy()`: reqwest reads
/// `ALL_PROXY` at build time, so without it this request would be sent to a
/// dead proxy instead of the loopback server and fail.
///
/// The proxy URL is deliberately `http://`, not `socks5://` — reqwest is
/// built here without its `socks` feature, so a SOCKS proxy would simply be
/// ignored and the test would pass with or without `.no_proxy()`.
///
/// Env vars are process-global and `set_var` is `unsafe` under the 2024
/// edition. This is the only test in the crate that touches the
/// environment, and it restores it before yielding, so nothing else here
/// can observe the change — but it is not safe to add a second such test
/// without serialising them.
#[tokio::test]
async fn http_client_ignores_system_proxy() {
let addr = spawn_oneshot_server().await;
unsafe { std::env::set_var("ALL_PROXY", "http://127.0.0.1:1") };
let client = build_http_client();
unsafe { std::env::remove_var("ALL_PROXY") };
let response = client
.expect("client should build")
.get(format!("http://{addr}/"))
.send()
.await
.expect("request must reach the loopback server, not the dead proxy");
assert_eq!(response.status(), reqwest::StatusCode::OK);
assert_eq!(response.text().await.unwrap(), "hi");
}
}
+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,
}
}
+302
View File
@@ -0,0 +1,302 @@
use std::fmt;
use anyhow::Context;
#[derive(Debug)]
pub struct WireguardConfig {
pub interface: InterfaceSection,
pub peer: PeerSection,
}
pub struct InterfaceSection {
pub private_key: String,
pub address: String,
pub dns: Option<String>,
}
/// Hand-written so the private key can never reach a log through a `{:?}`
/// format on this struct or anything containing it. The derived impl would
/// print it verbatim, and this value flows through error contexts during
/// interface setup.
impl fmt::Debug for InterfaceSection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("InterfaceSection")
.field("private_key", &"<redacted>")
.field("address", &self.address)
.field("dns", &self.dns)
.finish()
}
}
#[derive(Debug)]
pub struct PeerSection {
pub public_key: String,
pub allowed_ips: Vec<String>,
pub endpoint: String,
pub persistent_keepalive: Option<u16>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Section {
None,
Interface,
Peer,
}
pub fn parse(text: &str) -> anyhow::Result<WireguardConfig> {
let mut section = Section::None;
let mut private_key = None;
let mut address = None;
let mut dns = None;
let mut public_key = None;
let mut allowed_ips = None;
let mut endpoint = None;
let mut persistent_keepalive = None;
for raw_line in text.lines() {
let line = strip_comment(raw_line).trim();
if line.is_empty() {
continue;
}
if line == "[Interface]" {
section = Section::Interface;
continue;
}
if line == "[Peer]" {
section = Section::Peer;
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim();
match section {
Section::Interface => match key {
"PrivateKey" => private_key = Some(value.to_owned()),
"Address" => address = Some(value.to_owned()),
"DNS" => dns = Some(value.to_owned()),
_ => {}
},
Section::Peer => match key {
"PublicKey" => public_key = Some(value.to_owned()),
"AllowedIPs" => allowed_ips = Some(value),
"Endpoint" => endpoint = Some(value.to_owned()),
"PersistentKeepalive" => {
let v: u16 = value
.parse()
.with_context(|| format!("invalid PersistentKeepalive value: {value}"))?;
persistent_keepalive = Some(v);
}
_ => {}
},
Section::None => {}
}
}
let interface = InterfaceSection {
private_key: private_key.ok_or_else(|| {
anyhow::anyhow!("missing required key 'PrivateKey' in [Interface] section")
})?,
address: address.ok_or_else(|| {
anyhow::anyhow!("missing required key 'Address' in [Interface] section")
})?,
dns,
};
// Peer section must have been entered.
if public_key.is_none() && allowed_ips.is_none() && endpoint.is_none() {
anyhow::bail!("missing [Peer] section");
}
let peer = PeerSection {
public_key: public_key
.ok_or_else(|| anyhow::anyhow!("missing required key 'PublicKey' in [Peer] section"))?,
allowed_ips: allowed_ips
.ok_or_else(|| anyhow::anyhow!("missing required key 'AllowedIPs' in [Peer] section"))?
.split(',')
.map(|s| s.trim().to_owned())
.collect(),
endpoint: endpoint
.ok_or_else(|| anyhow::anyhow!("missing required key 'Endpoint' in [Peer] section"))?,
persistent_keepalive,
};
Ok(WireguardConfig { interface, peer })
}
/// Strip inline comments (# ...) but preserve the rest.
/// Only strips when `#` is preceded by whitespace or is at column 0.
fn strip_comment(line: &str) -> &str {
if let Some(pos) = line.find('#') {
if pos == 0 || line.as_bytes()[pos - 1] == b' ' || line.as_bytes()[pos - 1] == b'\t' {
return &line[..pos];
}
}
line
}
#[cfg(test)]
mod tests {
use super::*;
const PROTONVPN_SAMPLE: &str = r#"[Interface]
# Key for nixarr
# Bouncing = 1
# NetShield = 1
# Moderate NAT = off
# NAT-PMP (Port Forwarding) = on
# VPN Accelerator = on
PrivateKey = aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq=
Address = 10.2.0.2/32
DNS = 10.2.0.1
[Peer]
# UA#44
PublicKey = eqjhoqO6K1nLiej026+RkpSTHloVrOHLlMQaB0Tl5GM=
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = 156.146.50.5:51820
PersistentKeepalive = 25
"#;
#[test]
fn parses_protonvpn_sample() {
let cfg = parse(PROTONVPN_SAMPLE).unwrap();
assert_eq!(
cfg.interface.private_key,
"aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq="
);
assert_eq!(cfg.interface.address, "10.2.0.2/32");
assert_eq!(cfg.interface.dns.as_deref(), Some("10.2.0.1"));
assert_eq!(
cfg.peer.public_key,
"eqjhoqO6K1nLiej026+RkpSTHloVrOHLlMQaB0Tl5GM="
);
assert_eq!(cfg.peer.allowed_ips, vec!["0.0.0.0/0", "::/0"]);
assert_eq!(cfg.peer.endpoint, "156.146.50.5:51820");
assert_eq!(cfg.peer.persistent_keepalive, Some(25));
}
#[test]
fn parses_config_without_dns() {
let text = r#"[Interface]
PrivateKey = aaaa
Address = 10.0.0.2/32
[Peer]
PublicKey = bbbb
AllowedIPs = 0.0.0.0/0
Endpoint = 1.2.3.4:51820
"#;
let cfg = parse(text).unwrap();
assert!(cfg.interface.dns.is_none());
}
#[test]
fn parses_config_without_keepalive() {
let text = r#"[Interface]
PrivateKey = aaaa
Address = 10.0.0.2/32
[Peer]
PublicKey = bbbb
AllowedIPs = 0.0.0.0/0
Endpoint = 1.2.3.4:51820
"#;
let cfg = parse(text).unwrap();
assert!(cfg.peer.persistent_keepalive.is_none());
}
#[test]
fn ignores_comments_only_lines() {
let text = r#"[Interface]
# This is a comment
# Another = comment
PrivateKey = aaaa
Address = 10.0.0.2/32
[Peer]
# Peer comment
PublicKey = bbbb
AllowedIPs = 0.0.0.0/0
Endpoint = 1.2.3.4:51820
"#;
assert!(parse(text).is_ok());
}
#[test]
fn rejects_missing_private_key() {
let text = r#"[Interface]
Address = 10.0.0.2/32
[Peer]
PublicKey = bbbb
AllowedIPs = 0.0.0.0/0
Endpoint = 1.2.3.4:51820
"#;
let err = parse(text).unwrap_err().to_string();
assert!(err.contains("PrivateKey"));
}
#[test]
fn rejects_missing_peer_section() {
let text = r#"[Interface]
PrivateKey = aaaa
Address = 10.0.0.2/32
"#;
let err = parse(text).unwrap_err().to_string();
assert!(err.contains("[Peer]"));
}
#[test]
fn parses_multiple_allowed_ips() {
let text = r#"[Interface]
PrivateKey = aaaa
Address = 10.0.0.2/32
[Peer]
PublicKey = bbbb
AllowedIPs = 0.0.0.0/0, ::/0, 192.168.0.0/16
Endpoint = 1.2.3.4:51820
"#;
let cfg = parse(text).unwrap();
assert_eq!(
cfg.peer.allowed_ips,
vec!["0.0.0.0/0", "::/0", "192.168.0.0/16"]
);
}
#[test]
fn debug_redacts_private_key() {
let cfg = parse(PROTONVPN_SAMPLE).unwrap();
let rendered = format!("{cfg:?}");
assert!(
!rendered.contains("aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq="),
"private key leaked into Debug output: {rendered}"
);
assert!(rendered.contains("<redacted>"), "got: {rendered}");
// The rest of the config must still be inspectable.
assert!(rendered.contains("10.2.0.2/32"), "got: {rendered}");
}
#[test]
fn strips_inline_comments() {
let text = r#"[Interface]
PrivateKey = aaaa
Address = 10.2.0.2/32 # the VPN address
DNS = 1.1.1.1
[Peer]
PublicKey = bbbb
AllowedIPs = 0.0.0.0/0
Endpoint = 1.2.3.4:51820
"#;
let cfg = parse(text).unwrap();
assert_eq!(cfg.interface.address, "10.2.0.2/32");
assert_eq!(cfg.interface.dns.as_deref(), Some("1.1.1.1"));
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod config;
pub mod namespace;
pub use config::{InterfaceSection, PeerSection, WireguardConfig, parse};
+84
View File
@@ -0,0 +1,84 @@
//! Network namespace setup 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.
use anyhow::{Context, Result, bail};
use std::fs;
/// 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.
///
/// 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).
pub fn preflight() -> Result<()> {
match fs::read_to_string(MAX_USER_NAMESPACES_PROC) {
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}")),
}
}
/// Decides whether the sysctl's contents permit unprivileged user namespaces.
///
/// Split out from [`preflight`] so tests exercise the real logic rather than a
/// copy of it — the file itself can't be mocked.
fn evaluate(contents: &str) -> Result<()> {
let trimmed = contents.trim();
let count: u64 = trimmed.parse().with_context(|| {
format!("failed to parse {MAX_USER_NAMESPACES_PROC} as number: {trimmed:?}")
})?;
if count == 0 {
bail!(
"unprivileged user namespaces are disabled ({MAX_USER_NAMESPACES_PROC} = 0). \
VPN mode requires them. Remediation: \
`sudo sysctl -w user.max_user_namespaces=125445`, then re-run."
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_positive_count() {
assert!(evaluate("125445\n").is_ok());
assert!(evaluate("125445").is_ok());
assert!(evaluate("1\n").is_ok());
}
#[test]
fn rejects_zero_count() {
let err = evaluate("0\n").unwrap_err().to_string();
assert!(
err.contains("user.max_user_namespaces"),
"error should name the sysctl and its remediation, got: {err}"
);
}
#[test]
fn rejects_non_numeric() {
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.
#[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());
}
}
}