Compare commits
10 Commits
6309b87323
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b489ff7135 | |||
| 62db1e2a9e | |||
| 6e5d27497e | |||
| e321300f67 | |||
| 7103184c8e | |||
| dd41f1961f | |||
| 7842b5aeba | |||
| 3f3a6d354a | |||
| 9c2499a4a3 | |||
| e1d35f81bc |
Generated
+344
-87
@@ -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"
|
||||
@@ -3200,12 +3403,14 @@ name = "tora"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"colored",
|
||||
"hyper-util",
|
||||
"nix",
|
||||
"nix 0.31.3",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tonic",
|
||||
"tonic-health",
|
||||
"tora-proto",
|
||||
@@ -3232,6 +3437,7 @@ dependencies = [
|
||||
"clap",
|
||||
"librqbit",
|
||||
"librqbit-core",
|
||||
"nix 0.31.3",
|
||||
"reqwest",
|
||||
"sha1 0.10.7",
|
||||
"sqlx",
|
||||
@@ -3655,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"
|
||||
@@ -3668,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"
|
||||
@@ -3696,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"
|
||||
@@ -3748,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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -173,6 +173,37 @@ pub mod torrents_client {
|
||||
.insert(GrpcMethod::new("torrent.Torrents", "Resume"));
|
||||
self.inner.unary(req, path, codec).await
|
||||
}
|
||||
pub async fn notifications(
|
||||
&mut self,
|
||||
request: impl tonic::IntoRequest<super::NotificationsRequest>,
|
||||
) -> std::result::Result<
|
||||
tonic::Response<tonic::codec::Streaming<super::Notification>>,
|
||||
tonic::Status,
|
||||
> {
|
||||
self.inner.ready().await.map_err(|e| {
|
||||
tonic::Status::unknown(format!("Service was not ready: {}", e.into()))
|
||||
})?;
|
||||
let codec = tonic_prost::ProstCodec::default();
|
||||
let path = http::uri::PathAndQuery::from_static("/torrent.Torrents/Notifications");
|
||||
let mut req = request.into_request();
|
||||
req.extensions_mut()
|
||||
.insert(GrpcMethod::new("torrent.Torrents", "Notifications"));
|
||||
self.inner.server_streaming(req, path, codec).await
|
||||
}
|
||||
pub async fn files(
|
||||
&mut self,
|
||||
request: impl tonic::IntoRequest<super::FilesRequest>,
|
||||
) -> std::result::Result<tonic::Response<super::FilesResponse>, tonic::Status> {
|
||||
self.inner.ready().await.map_err(|e| {
|
||||
tonic::Status::unknown(format!("Service was not ready: {}", e.into()))
|
||||
})?;
|
||||
let codec = tonic_prost::ProstCodec::default();
|
||||
let path = http::uri::PathAndQuery::from_static("/torrent.Torrents/Files");
|
||||
let mut req = request.into_request();
|
||||
req.extensions_mut()
|
||||
.insert(GrpcMethod::new("torrent.Torrents", "Files"));
|
||||
self.inner.unary(req, path, codec).await
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Generated server implementations.
|
||||
@@ -212,6 +243,19 @@ pub mod torrents_server {
|
||||
&self,
|
||||
request: tonic::Request<super::ResumeRequest>,
|
||||
) -> std::result::Result<tonic::Response<super::ResumeResponse>, tonic::Status>;
|
||||
/// Server streaming response type for the Notifications method.
|
||||
type NotificationsStream: tonic::codegen::tokio_stream::Stream<
|
||||
Item = std::result::Result<super::Notification, tonic::Status>,
|
||||
> + std::marker::Send
|
||||
+ 'static;
|
||||
async fn notifications(
|
||||
&self,
|
||||
request: tonic::Request<super::NotificationsRequest>,
|
||||
) -> std::result::Result<tonic::Response<Self::NotificationsStream>, tonic::Status>;
|
||||
async fn files(
|
||||
&self,
|
||||
request: tonic::Request<super::FilesRequest>,
|
||||
) -> std::result::Result<tonic::Response<super::FilesResponse>, tonic::Status>;
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct TorrentsServer<T> {
|
||||
@@ -508,6 +552,87 @@ pub mod torrents_server {
|
||||
};
|
||||
Box::pin(fut)
|
||||
}
|
||||
"/torrent.Torrents/Notifications" => {
|
||||
#[allow(non_camel_case_types)]
|
||||
struct NotificationsSvc<T: Torrents>(pub Arc<T>);
|
||||
impl<T: Torrents>
|
||||
tonic::server::ServerStreamingService<super::NotificationsRequest>
|
||||
for NotificationsSvc<T>
|
||||
{
|
||||
type Response = super::Notification;
|
||||
type ResponseStream = T::NotificationsStream;
|
||||
type Future =
|
||||
BoxFuture<tonic::Response<Self::ResponseStream>, tonic::Status>;
|
||||
fn call(
|
||||
&mut self,
|
||||
request: tonic::Request<super::NotificationsRequest>,
|
||||
) -> Self::Future {
|
||||
let inner = Arc::clone(&self.0);
|
||||
let fut = async move {
|
||||
<T as Torrents>::notifications(&inner, request).await
|
||||
};
|
||||
Box::pin(fut)
|
||||
}
|
||||
}
|
||||
let accept_compression_encodings = self.accept_compression_encodings;
|
||||
let send_compression_encodings = self.send_compression_encodings;
|
||||
let max_decoding_message_size = self.max_decoding_message_size;
|
||||
let max_encoding_message_size = self.max_encoding_message_size;
|
||||
let inner = self.inner.clone();
|
||||
let fut = async move {
|
||||
let method = NotificationsSvc(inner);
|
||||
let codec = tonic_prost::ProstCodec::default();
|
||||
let mut grpc = tonic::server::Grpc::new(codec)
|
||||
.apply_compression_config(
|
||||
accept_compression_encodings,
|
||||
send_compression_encodings,
|
||||
)
|
||||
.apply_max_message_size_config(
|
||||
max_decoding_message_size,
|
||||
max_encoding_message_size,
|
||||
);
|
||||
let res = grpc.server_streaming(method, req).await;
|
||||
Ok(res)
|
||||
};
|
||||
Box::pin(fut)
|
||||
}
|
||||
"/torrent.Torrents/Files" => {
|
||||
#[allow(non_camel_case_types)]
|
||||
struct FilesSvc<T: Torrents>(pub Arc<T>);
|
||||
impl<T: Torrents> tonic::server::UnaryService<super::FilesRequest> for FilesSvc<T> {
|
||||
type Response = super::FilesResponse;
|
||||
type Future = BoxFuture<tonic::Response<Self::Response>, tonic::Status>;
|
||||
fn call(
|
||||
&mut self,
|
||||
request: tonic::Request<super::FilesRequest>,
|
||||
) -> Self::Future {
|
||||
let inner = Arc::clone(&self.0);
|
||||
let fut = async move { <T as Torrents>::files(&inner, request).await };
|
||||
Box::pin(fut)
|
||||
}
|
||||
}
|
||||
let accept_compression_encodings = self.accept_compression_encodings;
|
||||
let send_compression_encodings = self.send_compression_encodings;
|
||||
let max_decoding_message_size = self.max_decoding_message_size;
|
||||
let max_encoding_message_size = self.max_encoding_message_size;
|
||||
let inner = self.inner.clone();
|
||||
let fut = async move {
|
||||
let method = FilesSvc(inner);
|
||||
let codec = tonic_prost::ProstCodec::default();
|
||||
let mut grpc = tonic::server::Grpc::new(codec)
|
||||
.apply_compression_config(
|
||||
accept_compression_encodings,
|
||||
send_compression_encodings,
|
||||
)
|
||||
.apply_max_message_size_config(
|
||||
max_decoding_message_size,
|
||||
max_encoding_message_size,
|
||||
);
|
||||
let res = grpc.unary(method, req).await;
|
||||
Ok(res)
|
||||
};
|
||||
Box::pin(fut)
|
||||
}
|
||||
_ => Box::pin(async move {
|
||||
let mut response = http::Response::new(tonic::body::Body::default());
|
||||
let headers = response.headers_mut();
|
||||
|
||||
@@ -2,9 +2,12 @@ pub mod torrent {
|
||||
include!("generated/torrent/torrent.rs");
|
||||
}
|
||||
|
||||
pub use torrent::notification as notification_detail;
|
||||
pub use torrent::{
|
||||
AddRequest, AddResponse, ListRequest, ListResponse, PauseRequest, PauseResponse, RemoveRequest,
|
||||
RemoveResponse, ResumeRequest, ResumeResponse, State, StatusRequest, TorrentStatus,
|
||||
AddRequest, AddResponse, AddedInfo, FileState, FilesRequest, FilesResponse, ListRequest,
|
||||
ListResponse, Notification, NotificationKind, NotificationsRequest, PauseRequest,
|
||||
PauseResponse, ProgressMark, RemoveRequest, RemoveResponse, RemovedInfo, ResumeRequest,
|
||||
ResumeResponse, State, StateChanged, StatusRequest, TorrentFile, TorrentStatus,
|
||||
torrents_client::TorrentsClient,
|
||||
torrents_server::{Torrents, TorrentsServer},
|
||||
};
|
||||
|
||||
@@ -5,12 +5,14 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.103"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
clap = { version = "4.6.1", features = ["derive", "env"] }
|
||||
colored = "3"
|
||||
hyper-util = { version = "0.1.20", features = ["tokio"] }
|
||||
nix = { version = "0.31", features = ["signal"] }
|
||||
serde_json = "1.0.140"
|
||||
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "net"] }
|
||||
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "net", "signal"] }
|
||||
tokio-stream = "0.1.18"
|
||||
tonic = "0.14.6"
|
||||
tonic-health = "0.14.6"
|
||||
tora-proto = { version = "0.1.0", path = "../proto" }
|
||||
|
||||
+43
-2
@@ -1,6 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use tora_proto::NotificationKind;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
@@ -43,6 +44,31 @@ pub enum SortKey {
|
||||
Added,
|
||||
}
|
||||
|
||||
/// CLI-facing mirror of `NotificationKind` so `clap`'s `ValueEnum` derive can
|
||||
/// enumerate the values. Mapped back to the proto enum via `to_proto`.
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
pub enum KindFilter {
|
||||
/// `TORRENT_ADDED`
|
||||
Added,
|
||||
/// `TORRENT_REMOVED`
|
||||
Removed,
|
||||
/// `STATE_CHANGED` (includes DOWNLOADING <-> STALE, * -> FINISHED, etc.)
|
||||
StateChanged,
|
||||
/// `PROGRESS` (one event per integer percent advance)
|
||||
Progress,
|
||||
}
|
||||
|
||||
impl KindFilter {
|
||||
pub fn to_proto(self) -> i32 {
|
||||
match self {
|
||||
KindFilter::Added => NotificationKind::TorrentAdded as i32,
|
||||
KindFilter::Removed => NotificationKind::TorrentRemoved as i32,
|
||||
KindFilter::StateChanged => NotificationKind::StateChanged as i32,
|
||||
KindFilter::Progress => NotificationKind::Progress as i32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Command {
|
||||
/// Check whether torad is reachable and healthy.
|
||||
@@ -51,9 +77,13 @@ pub enum Command {
|
||||
Download {
|
||||
/// Magnet link, HTTP(S) URL, or path to a .torrent file.
|
||||
source: String,
|
||||
/// Directory to save the torrent into. Defaults to torad's configured download directory.
|
||||
/// Folder to download into. Defaults to torad's `<download_dir>/<torrent_name>/`.
|
||||
#[arg(long)]
|
||||
output_dir: Option<String>,
|
||||
download_folder: Option<String>,
|
||||
/// Folder to move the torrent into once it finishes downloading.
|
||||
/// Defaults to torad's `<download_dir>/completed/<torrent_name>/`.
|
||||
#[arg(long)]
|
||||
move_after_completion_folder: Option<String>,
|
||||
},
|
||||
/// Show the status of a single torrent.
|
||||
Status {
|
||||
@@ -95,4 +125,15 @@ pub enum Command {
|
||||
},
|
||||
/// Stop the torad daemon.
|
||||
Stop,
|
||||
/// Stream torrent notifications live from torad. Runs until interrupted
|
||||
/// (Ctrl-C) or the daemon disconnects.
|
||||
Watch {
|
||||
/// Filter to these torrent ids (UUIDs or unique prefixes). Repeatable.
|
||||
/// Omit to observe all torrents.
|
||||
#[arg(long = "id", value_name = "ID")]
|
||||
ids: Vec<String>,
|
||||
/// Only emit these event kinds. Repeatable. Omit for all kinds.
|
||||
#[arg(long = "kind", value_enum)]
|
||||
kinds: Vec<KindFilter>,
|
||||
},
|
||||
}
|
||||
|
||||
+64
-6
@@ -6,17 +6,18 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Command, SortKey};
|
||||
use cli::{Cli, Command, KindFilter, SortKey};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use output::Formatter;
|
||||
use tokio::net::UnixStream;
|
||||
use tokio_stream::StreamExt;
|
||||
use tonic::transport::{Channel, Endpoint, Uri};
|
||||
use tonic_health::pb::HealthCheckRequest;
|
||||
use tonic_health::pb::health_check_response::ServingStatus;
|
||||
use tonic_health::pb::health_client::HealthClient;
|
||||
use tora_proto::{
|
||||
AddRequest, ListRequest, PauseRequest, RemoveRequest, ResumeRequest, StatusRequest,
|
||||
TorrentsClient,
|
||||
AddRequest, ListRequest, NotificationsRequest, PauseRequest, RemoveRequest, ResumeRequest,
|
||||
StatusRequest, TorrentsClient,
|
||||
};
|
||||
use tower::service_fn;
|
||||
|
||||
@@ -40,7 +41,20 @@ async fn main() -> Result<()> {
|
||||
|
||||
match cli.command {
|
||||
Command::Health => health(&socket, f).await,
|
||||
Command::Download { source, output_dir } => download(&socket, source, output_dir, f).await,
|
||||
Command::Download {
|
||||
source,
|
||||
download_folder,
|
||||
move_after_completion_folder,
|
||||
} => {
|
||||
download(
|
||||
&socket,
|
||||
source,
|
||||
download_folder,
|
||||
move_after_completion_folder,
|
||||
f,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Command::Status { id } => status(&socket, id, f).await,
|
||||
Command::List { sort } => list(&socket, sort, f).await,
|
||||
Command::Remove { id, delete_files } => remove(&socket, id, delete_files, f).await,
|
||||
@@ -51,6 +65,7 @@ async fn main() -> Result<()> {
|
||||
download_dir,
|
||||
} => start_daemon(&socket, &pid_file, &database_url, download_dir, f).await,
|
||||
Command::Stop => stop_daemon(&socket, &pid_file, f).await,
|
||||
Command::Watch { ids, kinds } => watch(&socket, ids, kinds, f).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +114,8 @@ async fn health(socket: &Path, f: &dyn Formatter) -> Result<()> {
|
||||
async fn download(
|
||||
socket: &Path,
|
||||
source: String,
|
||||
output_dir: Option<String>,
|
||||
download_folder: Option<String>,
|
||||
move_after_completion_folder: Option<String>,
|
||||
f: &dyn Formatter,
|
||||
) -> Result<()> {
|
||||
let channel = connect(socket).await?;
|
||||
@@ -109,7 +125,8 @@ async fn download(
|
||||
RPC_TIMEOUT,
|
||||
client.add(AddRequest {
|
||||
magnet: source,
|
||||
output_dir: output_dir.unwrap_or_default(),
|
||||
download_folder: download_folder.unwrap_or_default(),
|
||||
move_after_completion_folder: move_after_completion_folder.unwrap_or_default(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -269,3 +286,44 @@ async fn stop_daemon(socket: &Path, pid_file: &Path, f: &dyn Formatter) -> Resul
|
||||
Err(err) => Err(err).context("failed to send SIGTERM to torad"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn watch(
|
||||
socket: &Path,
|
||||
ids: Vec<String>,
|
||||
kinds: Vec<KindFilter>,
|
||||
f: &dyn Formatter,
|
||||
) -> Result<()> {
|
||||
let channel = connect(socket).await?;
|
||||
let mut client = TorrentsClient::new(channel);
|
||||
|
||||
let request = NotificationsRequest {
|
||||
ids,
|
||||
kinds: kinds.iter().map(|k| k.to_proto()).collect(),
|
||||
};
|
||||
|
||||
let stream = client
|
||||
.notifications(request)
|
||||
.await
|
||||
.context("Notifications RPC failed")?
|
||||
.into_inner();
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
let stdout = std::io::stdout();
|
||||
while let Some(message) = stream.next().await {
|
||||
match message {
|
||||
Ok(notification) => {
|
||||
let line = f.watch(¬ification)?;
|
||||
let mut guard = stdout.lock();
|
||||
use std::io::Write;
|
||||
writeln!(guard, "{line}")?;
|
||||
guard.flush()?;
|
||||
}
|
||||
Err(status) => {
|
||||
anyhow::bail!("stream error: {status}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+109
-1
@@ -1,8 +1,12 @@
|
||||
use std::fmt::Write;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{Local, TimeZone};
|
||||
use colored::Colorize;
|
||||
use tora_proto::{State, TorrentStatus};
|
||||
use tora_proto::{
|
||||
Notification, NotificationKind, ProgressMark, State, StateChanged, TorrentStatus,
|
||||
notification_detail::Detail,
|
||||
};
|
||||
|
||||
use crate::cli::OutputFormat;
|
||||
|
||||
@@ -18,6 +22,7 @@ pub trait Formatter {
|
||||
fn resume(&self) -> Result<String>;
|
||||
fn start(&self) -> Result<String>;
|
||||
fn stop(&self) -> Result<String>;
|
||||
fn watch(&self, event: &Notification) -> Result<String>;
|
||||
}
|
||||
|
||||
pub fn from_format(format: OutputFormat) -> Box<dyn Formatter> {
|
||||
@@ -77,6 +82,10 @@ impl Formatter for LongFormatter {
|
||||
fn stop(&self) -> Result<String> {
|
||||
Ok(format!(" {} torad stopped", "●".yellow()))
|
||||
}
|
||||
|
||||
fn watch(&self, event: &Notification) -> Result<String> {
|
||||
Ok(format_event_long(event))
|
||||
}
|
||||
}
|
||||
|
||||
struct JsonFormatter;
|
||||
@@ -130,6 +139,10 @@ impl Formatter for JsonFormatter {
|
||||
&serde_json::json!({ "stopped": true }),
|
||||
)?)
|
||||
}
|
||||
|
||||
fn watch(&self, event: &Notification) -> Result<String> {
|
||||
Ok(serde_json::to_string(event)?)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_status_long(status: &TorrentStatus) -> String {
|
||||
@@ -216,6 +229,99 @@ fn format_status_long(status: &TorrentStatus) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn format_event_long(event: &Notification) -> String {
|
||||
let time = Local
|
||||
.timestamp_millis_opt(event.observed_at_unix_millis as i64)
|
||||
.single()
|
||||
.map(|t| t.format("%H:%M:%S").to_string())
|
||||
.unwrap_or_else(|| "??".to_string());
|
||||
|
||||
let kind = NotificationKind::try_from(event.kind).unwrap_or(NotificationKind::Unspecified);
|
||||
let torrent = event.torrent.as_ref();
|
||||
let id = torrent.map(|t| short_id(&t.id)).unwrap_or_default();
|
||||
let name = torrent
|
||||
.map(|t| display_name(&t.name))
|
||||
.unwrap_or_else(|| "(unknown)".to_string());
|
||||
let state = torrent
|
||||
.and_then(|t| State::try_from(t.state).ok())
|
||||
.unwrap_or(State::Unspecified);
|
||||
|
||||
let id_w = 8usize;
|
||||
let name_w = 30usize;
|
||||
|
||||
let (icon, suffix) = match (&kind, event.detail.as_ref()) {
|
||||
(NotificationKind::TorrentAdded, _) => ("+".green(), String::new()),
|
||||
(NotificationKind::TorrentRemoved, Some(Detail::Removed(r))) => {
|
||||
let note = if r.files_deleted {
|
||||
" (files deleted)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
("−".red(), format!("{}{}", colored_state(state), note.red()))
|
||||
}
|
||||
(NotificationKind::TorrentRemoved, _) => ("−".red(), colored_state(state).to_string()),
|
||||
(
|
||||
NotificationKind::StateChanged,
|
||||
Some(Detail::StateChanged(StateChanged { current, .. })),
|
||||
) => {
|
||||
let cur = State::try_from(*current).unwrap_or(State::Unspecified);
|
||||
let icon = state_icon(cur);
|
||||
(icon, colored_state(cur).to_string())
|
||||
}
|
||||
(
|
||||
NotificationKind::Progress,
|
||||
Some(Detail::Progress(ProgressMark {
|
||||
percent,
|
||||
bytes_delta,
|
||||
})),
|
||||
) => {
|
||||
let delta = humanize_bytes(*bytes_delta);
|
||||
let pct_bar = format_pct_inline(*percent);
|
||||
(
|
||||
"↑".yellow(),
|
||||
format!(
|
||||
"{} {} {}",
|
||||
pct_bar.yellow(),
|
||||
format!("{}%", percent).bold(),
|
||||
format!("(Δ {delta})").dimmed()
|
||||
),
|
||||
)
|
||||
}
|
||||
(_, _) => ("·".dimmed(), colored_state(state).to_string()),
|
||||
};
|
||||
|
||||
format!(
|
||||
" {} {} {} {} {}",
|
||||
time.dimmed(),
|
||||
icon,
|
||||
pad(&id, id_w).dimmed(),
|
||||
pad(&name, name_w),
|
||||
suffix,
|
||||
)
|
||||
}
|
||||
|
||||
fn state_icon(state: State) -> colored::ColoredString {
|
||||
match state {
|
||||
State::Finished => "✓".green(),
|
||||
State::Downloading => "▶".yellow(),
|
||||
State::Paused => "⏸".cyan(),
|
||||
State::Error => "✗".red(),
|
||||
State::Stale => "⚠".red(),
|
||||
State::Pending | State::Unspecified => "·".dimmed(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_pct_inline(percent: u32) -> String {
|
||||
let filled = (percent as usize * BAR_WIDTH) / 100;
|
||||
let empty = BAR_WIDTH - filled;
|
||||
format!(
|
||||
"{}{} {:>3}%",
|
||||
"█".repeat(filled),
|
||||
"░".repeat(empty),
|
||||
percent
|
||||
)
|
||||
}
|
||||
|
||||
fn format_list_long(torrents: &[TorrentStatus]) -> String {
|
||||
if torrents.is_empty() {
|
||||
return " no torrents".dimmed().to_string();
|
||||
@@ -301,6 +407,7 @@ fn colored_state(state: State) -> colored::ColoredString {
|
||||
State::Downloading => s.yellow(),
|
||||
State::Paused => s.cyan(),
|
||||
State::Error => s.red().bold(),
|
||||
State::Stale => s.red(),
|
||||
State::Pending | State::Unspecified => s.dimmed(),
|
||||
}
|
||||
}
|
||||
@@ -312,6 +419,7 @@ fn pad_colored(s: &str, width: usize, state: State) -> colored::ColoredString {
|
||||
State::Downloading => padded.yellow(),
|
||||
State::Paused => padded.cyan(),
|
||||
State::Error => padded.red().bold(),
|
||||
State::Stale => padded.red(),
|
||||
State::Pending | State::Unspecified => padded.dimmed(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", "mount"] }
|
||||
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"] }
|
||||
|
||||
+43
-15
@@ -23,6 +23,7 @@ pub enum TorrentState {
|
||||
Paused,
|
||||
Finished,
|
||||
Error,
|
||||
Stale,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
@@ -31,7 +32,9 @@ pub struct TorrentRow {
|
||||
pub info_hash: String,
|
||||
pub name: Option<String>,
|
||||
pub source: String,
|
||||
pub output_path: String,
|
||||
pub output_path: Option<String>,
|
||||
pub download_folder: Option<String>,
|
||||
pub move_after_completion_folder: Option<String>,
|
||||
pub total_bytes: Option<i64>,
|
||||
pub downloaded_bytes: i64,
|
||||
pub state: TorrentState,
|
||||
@@ -42,17 +45,20 @@ pub async fn insert_pending(
|
||||
pool: &PgPool,
|
||||
info_hash: &str,
|
||||
source: &str,
|
||||
output_path: &str,
|
||||
download_folder: Option<&str>,
|
||||
move_after_completion_folder: Option<&str>,
|
||||
) -> Result<TorrentRow> {
|
||||
let row = sqlx::query_as::<_, TorrentRow>(
|
||||
"INSERT INTO torrents (info_hash, source, output_path)
|
||||
VALUES ($1, $2, $3)
|
||||
"INSERT INTO torrents (info_hash, source, download_folder, move_after_completion_folder)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (info_hash) DO UPDATE SET updated_at = now()
|
||||
RETURNING id, info_hash, name, source, output_path, total_bytes, downloaded_bytes, state, error_message",
|
||||
RETURNING id, info_hash, name, source, output_path, download_folder, \
|
||||
move_after_completion_folder, total_bytes, downloaded_bytes, state, error_message",
|
||||
)
|
||||
.bind(info_hash)
|
||||
.bind(source)
|
||||
.bind(output_path)
|
||||
.bind(download_folder)
|
||||
.bind(move_after_completion_folder)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
@@ -60,7 +66,8 @@ pub async fn insert_pending(
|
||||
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Option<TorrentRow>> {
|
||||
let row = sqlx::query_as::<_, TorrentRow>(
|
||||
"SELECT id, info_hash, name, source, output_path, total_bytes, downloaded_bytes, state, error_message
|
||||
"SELECT id, info_hash, name, source, output_path, download_folder, \
|
||||
move_after_completion_folder, total_bytes, downloaded_bytes, state, error_message
|
||||
FROM torrents WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
@@ -71,7 +78,8 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<Option<TorrentRow>> {
|
||||
|
||||
pub async fn list(pool: &PgPool) -> Result<Vec<TorrentRow>> {
|
||||
let rows = sqlx::query_as::<_, TorrentRow>(
|
||||
"SELECT id, info_hash, name, source, output_path, total_bytes, downloaded_bytes, state, error_message
|
||||
"SELECT id, info_hash, name, source, output_path, download_folder, \
|
||||
move_after_completion_folder, total_bytes, downloaded_bytes, state, error_message
|
||||
FROM torrents ORDER BY added_at DESC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
@@ -82,7 +90,8 @@ pub async fn list(pool: &PgPool) -> Result<Vec<TorrentRow>> {
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<Option<TorrentRow>> {
|
||||
let row = sqlx::query_as::<_, TorrentRow>(
|
||||
"DELETE FROM torrents WHERE id = $1
|
||||
RETURNING id, info_hash, name, source, output_path, total_bytes, downloaded_bytes, state, error_message",
|
||||
RETURNING id, info_hash, name, source, output_path, download_folder, \
|
||||
move_after_completion_folder, total_bytes, downloaded_bytes, state, error_message",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
@@ -92,7 +101,8 @@ pub async fn delete(pool: &PgPool, id: Uuid) -> Result<Option<TorrentRow>> {
|
||||
|
||||
pub async fn list_pending(pool: &PgPool) -> Result<Vec<TorrentRow>> {
|
||||
let rows = sqlx::query_as::<_, TorrentRow>(
|
||||
"SELECT id, info_hash, name, source, output_path, total_bytes, downloaded_bytes, state, error_message
|
||||
"SELECT id, info_hash, name, source, output_path, download_folder, \
|
||||
move_after_completion_folder, total_bytes, downloaded_bytes, state, error_message
|
||||
FROM torrents WHERE state = 'pending' ORDER BY added_at",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
@@ -118,6 +128,18 @@ pub async fn set_state(pool: &PgPool, id: Uuid, state: TorrentState) -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Used by the poller to record the actual output path once librqbit
|
||||
/// resolves metadata and creates the directory (or after a post-completion
|
||||
/// move). Leaves all other columns untouched.
|
||||
pub async fn update_output_path(pool: &PgPool, id: Uuid, output_path: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE torrents SET output_path = $2, updated_at = now() WHERE id = $1")
|
||||
.bind(id)
|
||||
.bind(output_path)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct Progress<'a> {
|
||||
pub name: Option<&'a str>,
|
||||
pub total_bytes: i64,
|
||||
@@ -126,8 +148,12 @@ pub struct Progress<'a> {
|
||||
pub error_message: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn update_progress(pool: &PgPool, id: Uuid, progress: Progress<'_>) -> Result<()> {
|
||||
sqlx::query(
|
||||
pub async fn update_progress(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
progress: Progress<'_>,
|
||||
) -> Result<TorrentRow> {
|
||||
let row = sqlx::query_as::<_, TorrentRow>(
|
||||
"UPDATE torrents
|
||||
SET name = COALESCE($2, name),
|
||||
total_bytes = $3,
|
||||
@@ -136,7 +162,9 @@ pub async fn update_progress(pool: &PgPool, id: Uuid, progress: Progress<'_>) ->
|
||||
error_message = $6,
|
||||
updated_at = now(),
|
||||
completed_at = CASE WHEN $5 = 'finished' THEN now() ELSE completed_at END
|
||||
WHERE id = $1",
|
||||
WHERE id = $1
|
||||
RETURNING id, info_hash, name, source, output_path, download_folder, \
|
||||
move_after_completion_folder, total_bytes, downloaded_bytes, state, error_message",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(progress.name)
|
||||
@@ -144,7 +172,7 @@ pub async fn update_progress(pool: &PgPool, id: Uuid, progress: Progress<'_>) ->
|
||||
.bind(progress.downloaded_bytes)
|
||||
.bind(progress.state)
|
||||
.bind(progress.error_message)
|
||||
.execute(pool)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
+132
-11
@@ -1,6 +1,7 @@
|
||||
mod db;
|
||||
mod source;
|
||||
mod torrents;
|
||||
mod vpn;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
@@ -39,6 +40,22 @@ 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.
|
||||
///
|
||||
/// Ignored when --wireguard-config is given, which binds to its own tunnel.
|
||||
#[arg(long, env = "TORAD_BIND_DEVICE")]
|
||||
bind_device: Option<String>,
|
||||
|
||||
/// Path to a wg-quick style WireGuard configuration file.
|
||||
///
|
||||
/// When set, torad re-executes itself inside a private user+network
|
||||
/// namespace, brings the tunnel up in user space, and binds all torrent
|
||||
/// traffic to it. Requires no root and no host setup. Linux only.
|
||||
#[arg(long, env = "TORAD_WIREGUARD_CONFIG")]
|
||||
wireguard_config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn default_socket_path() -> PathBuf {
|
||||
@@ -56,27 +73,102 @@ fn default_download_dir() -> PathBuf {
|
||||
.join("Downloads")
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
/// Deliberately not `#[tokio::main]`.
|
||||
///
|
||||
/// VPN mode re-executes this process inside a network namespace, and nothing
|
||||
/// survives `execvpe` — a runtime built before that point would leave worker
|
||||
/// threads and their fds behind in the old image. So the namespace bootstrap
|
||||
/// runs first, on a bare thread, and the runtime is built afterwards.
|
||||
fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let args = Args::parse();
|
||||
let socket = args.socket.unwrap_or_else(default_socket_path);
|
||||
let pid_file = args.pid_file.unwrap_or_else(default_pid_file_path);
|
||||
let download_dir = args.download_dir.unwrap_or_else(default_download_dir);
|
||||
|
||||
let tunnel = match &args.wireguard_config {
|
||||
// First launch with VPN mode requested: check the environment, prove
|
||||
// the config parses before throwing the process away, then re-exec.
|
||||
Some(path) if !vpn::namespace::already_inside() => {
|
||||
vpn::namespace::preflight()?;
|
||||
load_wireguard_config(path)?;
|
||||
// Written here, on the host, and deliberately not again inside.
|
||||
// pasta puts torad in a PID namespace, so the inner process sees
|
||||
// itself as PID 1 and would record a number that names init on the
|
||||
// host. `execvpe` keeps this PID for pasta, and killing pasta tears
|
||||
// the namespace down with everything in it — so this is both the
|
||||
// correct number and the right process to signal.
|
||||
write_pid_file(&pid_file_path(&args), std::process::id())?;
|
||||
let never = vpn::namespace::reexec_under_pasta()?;
|
||||
match never {}
|
||||
}
|
||||
// The re-executed process: we are inside the namespace now.
|
||||
Some(path) => Some(bring_up_tunnel(path)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("failed to build the tokio runtime")?
|
||||
.block_on(run(args, tunnel))
|
||||
}
|
||||
|
||||
fn pid_file_path(args: &Args) -> PathBuf {
|
||||
args.pid_file.clone().unwrap_or_else(default_pid_file_path)
|
||||
}
|
||||
|
||||
fn write_pid_file(path: &std::path::Path, pid: u32) -> Result<()> {
|
||||
std::fs::write(path, pid.to_string())
|
||||
.with_context(|| format!("failed to write pid file at {}", path.display()))
|
||||
}
|
||||
|
||||
fn load_wireguard_config(path: &std::path::Path) -> Result<vpn::WireguardConfig> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read WireGuard config at {}", path.display()))?;
|
||||
vpn::parse(&text).with_context(|| format!("failed to parse {}", path.display()))
|
||||
}
|
||||
|
||||
/// Enter a mount namespace and bring the tunnel up. Runs in the re-executed
|
||||
/// process, which already holds `CAP_NET_ADMIN` in its own user namespace.
|
||||
fn bring_up_tunnel(path: &std::path::Path) -> Result<vpn::Tunnel> {
|
||||
let config = load_wireguard_config(path)?;
|
||||
let socket_dir = vpn::namespace::enter_mount_namespace()?;
|
||||
vpn::tunnel::up(&config, socket_dir)
|
||||
}
|
||||
|
||||
async fn run(args: Args, tunnel: Option<vpn::Tunnel>) -> Result<()> {
|
||||
let socket = args.socket.clone().unwrap_or_else(default_socket_path);
|
||||
let pid_file = pid_file_path(&args);
|
||||
let download_dir = args
|
||||
.download_dir
|
||||
.clone()
|
||||
.unwrap_or_else(default_download_dir);
|
||||
|
||||
let pool = db::connect(&args.database_url)
|
||||
.await
|
||||
.context("failed to connect to Postgres")?;
|
||||
info!("connected to Postgres");
|
||||
|
||||
let manager = TorrentManager::new(pool.clone(), download_dir)
|
||||
// In VPN mode the tunnel is the only interface torrent traffic may use, so
|
||||
// it overrides --bind-device rather than competing with it.
|
||||
let bind_device = if tunnel.is_some() {
|
||||
if args.bind_device.is_some() {
|
||||
info!("--bind-device is ignored in VPN mode; binding to the tunnel instead");
|
||||
}
|
||||
Some(vpn::INTERFACE.to_owned())
|
||||
} else {
|
||||
args.bind_device
|
||||
};
|
||||
|
||||
let manager = TorrentManager::new(pool.clone(), download_dir, bind_device)
|
||||
.await
|
||||
.context("failed to start torrent manager")?;
|
||||
manager.clone().spawn_poller();
|
||||
|
||||
std::fs::write(&pid_file, std::process::id().to_string())
|
||||
.with_context(|| format!("failed to write pid file at {}", pid_file.display()))?;
|
||||
// In VPN mode the host-visible pid was already recorded before the
|
||||
// re-exec; overwriting it here would replace it with this process's
|
||||
// namespace-local PID 1.
|
||||
if !vpn::namespace::already_inside() {
|
||||
write_pid_file(&pid_file, std::process::id())?;
|
||||
}
|
||||
|
||||
if socket.exists() {
|
||||
std::fs::remove_file(&socket)
|
||||
@@ -91,10 +183,24 @@ async fn main() -> Result<()> {
|
||||
|
||||
info!(socket = %socket.display(), "torad listening");
|
||||
|
||||
// A live torad with a dead tunnel is the failure mode that leaks, so the
|
||||
// datapath exiting is treated exactly like SIGTERM.
|
||||
let tunnel_down = tunnel.map(|mut tunnel| {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
std::thread::spawn(move || {
|
||||
let reason = match tunnel.wait() {
|
||||
Ok(status) => format!("wireguard-go exited with {status}"),
|
||||
Err(err) => format!("lost track of wireguard-go: {err:#}"),
|
||||
};
|
||||
let _ = tx.send(reason);
|
||||
});
|
||||
rx
|
||||
});
|
||||
|
||||
Server::builder()
|
||||
.add_service(health_service)
|
||||
.add_service(TorrentsServer::new(GrpcTorrents::new(manager)))
|
||||
.serve_with_incoming_shutdown(uds_stream, shutdown_signal())
|
||||
.serve_with_incoming_shutdown(uds_stream, shutdown_signal(tunnel_down))
|
||||
.await
|
||||
.context("gRPC server error")?;
|
||||
|
||||
@@ -124,14 +230,29 @@ async fn poll_db_health(pool: sqlx::PgPool, reporter: HealthReporter) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
async fn shutdown_signal(tunnel_down: Option<tokio::sync::oneshot::Receiver<String>>) {
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
|
||||
let mut sigint = signal(SignalKind::interrupt()).expect("failed to install SIGINT handler");
|
||||
let mut sigterm = signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
|
||||
|
||||
// Without a tunnel there is nothing to watch, so this branch must never
|
||||
// complete — otherwise it would fire immediately and shut torad down.
|
||||
let tunnel = async {
|
||||
match tunnel_down {
|
||||
Some(rx) => match rx.await {
|
||||
Ok(reason) => reason,
|
||||
Err(_) => std::future::pending().await,
|
||||
},
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
_ = sigint.recv() => {},
|
||||
_ = sigterm.recv() => {},
|
||||
reason = tunnel => {
|
||||
error!(%reason, "VPN tunnel went down; shutting down rather than routing around it");
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+207
-10
@@ -48,20 +48,99 @@ 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.
|
||||
///
|
||||
/// `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();
|
||||
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> {
|
||||
// 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")?;
|
||||
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),
|
||||
],
|
||||
})
|
||||
@@ -76,3 +155,121 @@ 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(None);
|
||||
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");
|
||||
}
|
||||
|
||||
#[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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+871
-85
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,797 @@
|
||||
//! Parser for `wg-quick`-style WireGuard configuration files.
|
||||
//!
|
||||
//! Everything here is validated at parse time and stored as a typed value, not
|
||||
//! a string. The values flow into `ip addr add`, route setup, and the
|
||||
//! `wireguard-go` UAPI socket, none of which give a usable diagnostic when
|
||||
//! handed garbage — a mistyped address surfaces there as a bare `EINVAL` or a
|
||||
//! UAPI `errno` with no indication of which config line caused it. Catching it
|
||||
//! here means the operator gets a file, a line number, and the offending value.
|
||||
|
||||
use std::fmt;
|
||||
use std::net::{IpAddr, Ipv6Addr};
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
/// A WireGuard key: exactly 32 bytes, written in the config as 44 base64
|
||||
/// characters.
|
||||
///
|
||||
/// The original base64 text is retained so the key can be echoed back in the
|
||||
/// form the operator wrote it, and the decoded bytes so Wave 3 can render the
|
||||
/// hex encoding the UAPI protocol expects without re-parsing.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct Key {
|
||||
bytes: [u8; 32],
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl Key {
|
||||
/// Decode a 44-character base64 WireGuard key.
|
||||
///
|
||||
/// Errors deliberately describe only the *shape* of the problem, never the
|
||||
/// input: this runs on private keys, and the error text ends up in logs.
|
||||
pub fn from_base64(s: &str) -> Result<Self> {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() != 44 {
|
||||
bail!(
|
||||
"expected 44 base64 characters (a 32-byte key), got {}",
|
||||
bytes.len()
|
||||
);
|
||||
}
|
||||
if bytes[43] != b'=' {
|
||||
bail!("expected the 44th character to be '=' padding");
|
||||
}
|
||||
|
||||
let mut out = [0u8; 32];
|
||||
let mut acc: u32 = 0;
|
||||
let mut acc_bits = 0u32;
|
||||
let mut written = 0usize;
|
||||
for (i, &c) in bytes[..43].iter().enumerate() {
|
||||
let sextet = base64_value(c)
|
||||
.with_context(|| format!("invalid base64 character at position {}", i + 1))?;
|
||||
acc = (acc << 6) | u32::from(sextet);
|
||||
acc_bits += 6;
|
||||
if acc_bits >= 8 {
|
||||
acc_bits -= 8;
|
||||
out[written] = ((acc >> acc_bits) & 0xff) as u8;
|
||||
written += 1;
|
||||
}
|
||||
}
|
||||
debug_assert_eq!(written, 32);
|
||||
|
||||
Ok(Self {
|
||||
bytes: out,
|
||||
text: s.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The key as base64, exactly as it appeared in the config.
|
||||
///
|
||||
/// Calling this on a private key puts it somewhere; that is the point of
|
||||
/// making it an explicit call rather than a `Display` impl.
|
||||
pub fn as_base64(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// Lowercase hex, the encoding the WireGuard UAPI protocol expects.
|
||||
pub fn to_hex(&self) -> String {
|
||||
use fmt::Write;
|
||||
self.bytes
|
||||
.iter()
|
||||
.fold(String::with_capacity(64), |mut s, b| {
|
||||
let _ = write!(s, "{b:02x}");
|
||||
s
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Redacted unconditionally: `Key` is used for both private and public keys,
|
||||
/// and a `Debug` that printed the bytes would leak the private one through any
|
||||
/// `{:?}` on a containing struct. Public keys are surfaced deliberately by
|
||||
/// [`PeerSection`]'s `Debug`, which calls [`Key::as_base64`].
|
||||
impl fmt::Debug for Key {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("Key(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
fn base64_value(c: u8) -> Result<u8> {
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
ALPHABET
|
||||
.iter()
|
||||
.position(|&b| b == c)
|
||||
.map(|p| p as u8)
|
||||
.context("not a base64 character")
|
||||
}
|
||||
|
||||
/// An IP address with a prefix length, e.g. `10.2.0.2/32` or `::/0`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct IpCidr {
|
||||
pub addr: IpAddr,
|
||||
pub prefix_len: u8,
|
||||
}
|
||||
|
||||
impl FromStr for IpCidr {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
/// A bare address with no `/` is accepted and takes the full prefix length
|
||||
/// (`/32` or `/128`), matching what `wg` itself does with `AllowedIPs`.
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
let (addr_part, prefix_part) = match s.split_once('/') {
|
||||
Some((a, p)) => (a.trim(), Some(p.trim())),
|
||||
None => (s.trim(), None),
|
||||
};
|
||||
|
||||
let addr: IpAddr = addr_part
|
||||
.parse()
|
||||
.with_context(|| format!("invalid IP address {addr_part:?}"))?;
|
||||
let max_prefix = if addr.is_ipv4() { 32 } else { 128 };
|
||||
|
||||
let prefix_len = match prefix_part {
|
||||
Some(p) => {
|
||||
let parsed: u8 = p
|
||||
.parse()
|
||||
.with_context(|| format!("invalid prefix length {p:?}"))?;
|
||||
if parsed > max_prefix {
|
||||
bail!("prefix length /{parsed} exceeds the maximum /{max_prefix} for {addr}");
|
||||
}
|
||||
parsed
|
||||
}
|
||||
None => max_prefix,
|
||||
};
|
||||
|
||||
Ok(Self { addr, prefix_len })
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for IpCidr {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}/{}", self.addr, self.prefix_len)
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders as the config wrote it. The derived form
|
||||
/// (`IpCidr { addr: 10.2.0.2, prefix_len: 32 }`) makes a list of them unreadable
|
||||
/// in a log line, which is where these are most often seen.
|
||||
impl fmt::Debug for IpCidr {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{self}")
|
||||
}
|
||||
}
|
||||
|
||||
/// A peer endpoint: a host (IP literal or DNS name) and a port.
|
||||
///
|
||||
/// The host is not resolved here. WireGuard resolves endpoint names at
|
||||
/// handshake time and re-resolves on roaming, so pinning an address at parse
|
||||
/// time would be wrong; this only rejects shapes that can never resolve.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct Endpoint {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl FromStr for Endpoint {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
let s = s.trim();
|
||||
|
||||
let (host, port_part) = if let Some(rest) = s.strip_prefix('[') {
|
||||
let (host, after) = rest
|
||||
.split_once(']')
|
||||
.context("unterminated IPv6 literal; expected [address]:port")?;
|
||||
host.parse::<Ipv6Addr>()
|
||||
.with_context(|| format!("invalid IPv6 address {host:?}"))?;
|
||||
let port = after
|
||||
.strip_prefix(':')
|
||||
.context("missing ':port' after the IPv6 literal")?;
|
||||
(host, port)
|
||||
} else {
|
||||
let (host, port) = s
|
||||
.rsplit_once(':')
|
||||
.with_context(|| format!("missing ':port' in endpoint {s:?}"))?;
|
||||
if host.contains(':') {
|
||||
bail!("a bare IPv6 address must be bracketed, e.g. [{host}]:{port}");
|
||||
}
|
||||
(host, port)
|
||||
};
|
||||
|
||||
if host.is_empty() {
|
||||
bail!("endpoint host must not be empty");
|
||||
}
|
||||
if host.parse::<IpAddr>().is_err()
|
||||
&& host.contains(|c: char| c.is_whitespace() || c == '/' || c == '@')
|
||||
{
|
||||
bail!("invalid endpoint host {host:?}");
|
||||
}
|
||||
|
||||
let port: u16 = port_part
|
||||
.parse()
|
||||
.with_context(|| format!("invalid port {port_part:?}"))?;
|
||||
if port == 0 {
|
||||
bail!("endpoint port must not be 0");
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
host: host.to_owned(),
|
||||
port,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Endpoint {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
if self.host.parse::<Ipv6Addr>().is_ok() {
|
||||
write!(f, "[{}]:{}", self.host, self.port)
|
||||
} else {
|
||||
write!(f, "{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// See [`IpCidr`]'s `Debug`: same reasoning, and it keeps the IPv6 bracketing
|
||||
/// visible so a malformed endpoint reads the way `wg` would print it.
|
||||
impl fmt::Debug for Endpoint {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{self}")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct WireguardConfig {
|
||||
pub interface: InterfaceSection,
|
||||
pub peer: PeerSection,
|
||||
}
|
||||
|
||||
pub struct InterfaceSection {
|
||||
pub private_key: Key,
|
||||
/// One or more addresses to assign to the tunnel interface. `wg-quick`
|
||||
/// allows a comma-separated list, and dual-stack providers use it.
|
||||
pub addresses: Vec<IpCidr>,
|
||||
/// Resolvers to write into the namespace's `resolv.conf`. Empty when the
|
||||
/// config has no `DNS` line.
|
||||
pub dns: Vec<IpAddr>,
|
||||
}
|
||||
|
||||
/// Hand-written so the private key can never reach a log through a `{:?}`
|
||||
/// format on this struct or anything containing it. [`Key`]'s own `Debug` is
|
||||
/// already redacted; this is the second layer, and it also keeps the field
|
||||
/// visibly marked as a secret at the point where it is most likely to be read.
|
||||
impl fmt::Debug for InterfaceSection {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("InterfaceSection")
|
||||
.field("private_key", &"<redacted>")
|
||||
.field("addresses", &self.addresses)
|
||||
.field("dns", &self.dns)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PeerSection {
|
||||
pub public_key: Key,
|
||||
pub allowed_ips: Vec<IpCidr>,
|
||||
pub endpoint: Endpoint,
|
||||
pub persistent_keepalive: Option<u16>,
|
||||
}
|
||||
|
||||
/// Hand-written only to undo [`Key`]'s blanket redaction for the public key,
|
||||
/// which is not a secret and is the first thing worth seeing when a handshake
|
||||
/// fails against the wrong peer.
|
||||
impl fmt::Debug for PeerSection {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("PeerSection")
|
||||
.field("public_key", &self.public_key.as_base64())
|
||||
.field("allowed_ips", &self.allowed_ips)
|
||||
.field("endpoint", &self.endpoint)
|
||||
.field("persistent_keepalive", &self.persistent_keepalive)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Section {
|
||||
None,
|
||||
Interface,
|
||||
Peer,
|
||||
}
|
||||
|
||||
pub fn parse(text: &str) -> Result<WireguardConfig> {
|
||||
let mut section = Section::None;
|
||||
let mut seen_interface = false;
|
||||
let mut seen_peer = false;
|
||||
|
||||
let mut private_key = None;
|
||||
let mut addresses = 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 (index, raw_line) in text.lines().enumerate() {
|
||||
let lineno = index + 1;
|
||||
let line = strip_comment(raw_line).trim();
|
||||
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix('[') {
|
||||
let name = rest
|
||||
.strip_suffix(']')
|
||||
.with_context(|| format!("line {lineno}: unterminated section header {line:?}"))?;
|
||||
section = match name.trim() {
|
||||
"Interface" => {
|
||||
if seen_interface {
|
||||
bail!("line {lineno}: duplicate [Interface] section");
|
||||
}
|
||||
seen_interface = true;
|
||||
Section::Interface
|
||||
}
|
||||
"Peer" => {
|
||||
if seen_peer {
|
||||
// A second [Peer] previously overwrote the first in
|
||||
// silence. torad brings up one tunnel with one peer;
|
||||
// saying so beats picking a peer at random.
|
||||
bail!(
|
||||
"line {lineno}: multiple [Peer] sections are not supported \
|
||||
(torad configures a single tunnel with one peer)"
|
||||
);
|
||||
}
|
||||
seen_peer = true;
|
||||
Section::Peer
|
||||
}
|
||||
other => bail!("line {lineno}: unknown section [{other}]"),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
bail!("line {lineno}: expected `Key = Value`, got {line:?}");
|
||||
};
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
|
||||
match section {
|
||||
Section::Interface => match key {
|
||||
"PrivateKey" => set_once(
|
||||
&mut private_key,
|
||||
// No value in the context: this is the secret.
|
||||
Key::from_base64(value)
|
||||
.with_context(|| format!("line {lineno}: invalid PrivateKey"))?,
|
||||
key,
|
||||
lineno,
|
||||
)?,
|
||||
"Address" => set_once(
|
||||
&mut addresses,
|
||||
parse_cidr_list(value)
|
||||
.with_context(|| format!("line {lineno}: invalid Address {value:?}"))?,
|
||||
key,
|
||||
lineno,
|
||||
)?,
|
||||
"DNS" => set_once(
|
||||
&mut dns,
|
||||
parse_dns_list(value)
|
||||
.with_context(|| format!("line {lineno}: invalid DNS {value:?}"))?,
|
||||
key,
|
||||
lineno,
|
||||
)?,
|
||||
_ => {}
|
||||
},
|
||||
Section::Peer => match key {
|
||||
"PublicKey" => set_once(
|
||||
&mut public_key,
|
||||
Key::from_base64(value)
|
||||
.with_context(|| format!("line {lineno}: invalid PublicKey {value:?}"))?,
|
||||
key,
|
||||
lineno,
|
||||
)?,
|
||||
"AllowedIPs" => set_once(
|
||||
&mut allowed_ips,
|
||||
parse_cidr_list(value)
|
||||
.with_context(|| format!("line {lineno}: invalid AllowedIPs {value:?}"))?,
|
||||
key,
|
||||
lineno,
|
||||
)?,
|
||||
"Endpoint" => set_once(
|
||||
&mut endpoint,
|
||||
value
|
||||
.parse::<Endpoint>()
|
||||
.with_context(|| format!("line {lineno}: invalid Endpoint {value:?}"))?,
|
||||
key,
|
||||
lineno,
|
||||
)?,
|
||||
"PersistentKeepalive" => set_once(
|
||||
&mut persistent_keepalive,
|
||||
value.parse::<u16>().with_context(|| {
|
||||
format!("line {lineno}: invalid PersistentKeepalive {value:?}")
|
||||
})?,
|
||||
key,
|
||||
lineno,
|
||||
)?,
|
||||
_ => {}
|
||||
},
|
||||
Section::None => {
|
||||
bail!("line {lineno}: key {key:?} appears before any section header")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !seen_interface {
|
||||
bail!("missing [Interface] section");
|
||||
}
|
||||
if !seen_peer {
|
||||
bail!("missing [Peer] section");
|
||||
}
|
||||
|
||||
let interface = InterfaceSection {
|
||||
private_key: private_key
|
||||
.context("missing required key 'PrivateKey' in [Interface] section")?,
|
||||
addresses: addresses.context("missing required key 'Address' in [Interface] section")?,
|
||||
dns: dns.unwrap_or_default(),
|
||||
};
|
||||
|
||||
let peer = PeerSection {
|
||||
public_key: public_key.context("missing required key 'PublicKey' in [Peer] section")?,
|
||||
allowed_ips: allowed_ips.context("missing required key 'AllowedIPs' in [Peer] section")?,
|
||||
endpoint: endpoint.context("missing required key 'Endpoint' in [Peer] section")?,
|
||||
persistent_keepalive,
|
||||
};
|
||||
|
||||
Ok(WireguardConfig { interface, peer })
|
||||
}
|
||||
|
||||
/// Assign to a slot that must not already be occupied.
|
||||
///
|
||||
/// A repeated key used to overwrite silently, so a config carrying two
|
||||
/// `Endpoint` lines would connect to whichever came last with no hint that the
|
||||
/// other existed.
|
||||
fn set_once<T>(slot: &mut Option<T>, value: T, key: &str, lineno: usize) -> Result<()> {
|
||||
if slot.is_some() {
|
||||
bail!("line {lineno}: duplicate key {key:?}");
|
||||
}
|
||||
*slot = Some(value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_cidr_list(value: &str) -> Result<Vec<IpCidr>> {
|
||||
value
|
||||
.split(',')
|
||||
.map(|part| {
|
||||
let part = part.trim();
|
||||
if part.is_empty() {
|
||||
bail!("empty entry in comma-separated list");
|
||||
}
|
||||
part.parse::<IpCidr>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_dns_list(value: &str) -> Result<Vec<IpAddr>> {
|
||||
value
|
||||
.split(',')
|
||||
.map(|part| {
|
||||
let part = part.trim();
|
||||
part.parse::<IpAddr>().with_context(|| {
|
||||
format!(
|
||||
"{part:?} is not an IP address \
|
||||
(search-domain entries in DNS are not supported)"
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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('#')
|
||||
&& (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::*;
|
||||
|
||||
/// Valid 32-byte keys. The previous fixtures (`aaaa`, and a 41-character
|
||||
/// private key) are exactly what parse-time validation now rejects.
|
||||
const PRIVATE_KEY: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
|
||||
const PUBLIC_KEY: &str = "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=";
|
||||
|
||||
fn minimal() -> String {
|
||||
format!(
|
||||
"[Interface]\n\
|
||||
PrivateKey = {PRIVATE_KEY}\n\
|
||||
Address = 10.0.0.2/32\n\
|
||||
\n\
|
||||
[Peer]\n\
|
||||
PublicKey = {PUBLIC_KEY}\n\
|
||||
AllowedIPs = 0.0.0.0/0\n\
|
||||
Endpoint = 1.2.3.4:51820\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn v4(a: u8, b: u8, c: u8, d: u8, prefix_len: u8) -> IpCidr {
|
||||
IpCidr {
|
||||
addr: IpAddr::from([a, b, c, d]),
|
||||
prefix_len,
|
||||
}
|
||||
}
|
||||
|
||||
fn protonvpn_sample() -> String {
|
||||
format!(
|
||||
"[Interface]\n\
|
||||
# Key for nixarr\n\
|
||||
# Bouncing = 1\n\
|
||||
# NAT-PMP (Port Forwarding) = on\n\
|
||||
PrivateKey = {PRIVATE_KEY}\n\
|
||||
Address = 10.2.0.2/32\n\
|
||||
DNS = 10.2.0.1\n\
|
||||
\n\
|
||||
[Peer]\n\
|
||||
# UA#44\n\
|
||||
PublicKey = {PUBLIC_KEY}\n\
|
||||
AllowedIPs = 0.0.0.0/0, ::/0\n\
|
||||
Endpoint = 156.146.50.5:51820\n\
|
||||
PersistentKeepalive = 25\n"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_protonvpn_sample() {
|
||||
let cfg = parse(&protonvpn_sample()).unwrap();
|
||||
assert_eq!(cfg.interface.private_key.as_base64(), PRIVATE_KEY);
|
||||
assert_eq!(cfg.interface.addresses, vec![v4(10, 2, 0, 2, 32)]);
|
||||
assert_eq!(cfg.interface.dns, vec![IpAddr::from([10, 2, 0, 1])]);
|
||||
assert_eq!(cfg.peer.public_key.as_base64(), PUBLIC_KEY);
|
||||
assert_eq!(cfg.peer.allowed_ips.len(), 2);
|
||||
assert_eq!(cfg.peer.allowed_ips[0], v4(0, 0, 0, 0, 0));
|
||||
assert_eq!(cfg.peer.allowed_ips[1].to_string(), "::/0");
|
||||
assert_eq!(cfg.peer.endpoint.host, "156.146.50.5");
|
||||
assert_eq!(cfg.peer.endpoint.port, 51820);
|
||||
assert_eq!(cfg.peer.persistent_keepalive, Some(25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_config_without_dns_or_keepalive() {
|
||||
let cfg = parse(&minimal()).unwrap();
|
||||
assert!(cfg.interface.dns.is_empty());
|
||||
assert!(cfg.peer.persistent_keepalive.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multiple_addresses_and_allowed_ips() {
|
||||
let text = minimal()
|
||||
.replace(
|
||||
"Address = 10.0.0.2/32",
|
||||
"Address = 10.0.0.2/32, fd00::2/128",
|
||||
)
|
||||
.replace(
|
||||
"AllowedIPs = 0.0.0.0/0",
|
||||
"AllowedIPs = 0.0.0.0/0, ::/0, 192.168.0.0/16",
|
||||
);
|
||||
let cfg = parse(&text).unwrap();
|
||||
assert_eq!(cfg.interface.addresses.len(), 2);
|
||||
assert_eq!(cfg.interface.addresses[1].to_string(), "fd00::2/128");
|
||||
assert_eq!(cfg.peer.allowed_ips.len(), 3);
|
||||
assert_eq!(cfg.peer.allowed_ips[2], v4(192, 168, 0, 0, 16));
|
||||
}
|
||||
|
||||
/// `wg` treats a prefixless `AllowedIPs` entry as a host route; so do we.
|
||||
#[test]
|
||||
fn bare_address_takes_the_full_prefix() {
|
||||
let text = minimal().replace("AllowedIPs = 0.0.0.0/0", "AllowedIPs = 8.8.8.8, fd00::1");
|
||||
let cfg = parse(&text).unwrap();
|
||||
assert_eq!(cfg.peer.allowed_ips[0], v4(8, 8, 8, 8, 32));
|
||||
assert_eq!(cfg.peer.allowed_ips[1].prefix_len, 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_bracketed_ipv6_endpoint() {
|
||||
let text = minimal().replace("Endpoint = 1.2.3.4:51820", "Endpoint = [2001:db8::1]:51820");
|
||||
let cfg = parse(&text).unwrap();
|
||||
assert_eq!(cfg.peer.endpoint.host, "2001:db8::1");
|
||||
assert_eq!(cfg.peer.endpoint.port, 51820);
|
||||
assert_eq!(cfg.peer.endpoint.to_string(), "[2001:db8::1]:51820");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_hostname_endpoint() {
|
||||
let text = minimal().replace(
|
||||
"Endpoint = 1.2.3.4:51820",
|
||||
"Endpoint = node-ua-44.protonvpn.net:51820",
|
||||
);
|
||||
let cfg = parse(&text).unwrap();
|
||||
assert_eq!(cfg.peer.endpoint.host, "node-ua-44.protonvpn.net");
|
||||
assert_eq!(
|
||||
cfg.peer.endpoint.to_string(),
|
||||
"node-ua-44.protonvpn.net:51820"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_comment_lines_and_strips_inline_comments() {
|
||||
let text = minimal().replace(
|
||||
"Address = 10.0.0.2/32",
|
||||
"# a comment\nAddress = 10.0.0.2/32 # the VPN address",
|
||||
);
|
||||
let cfg = parse(&text).unwrap();
|
||||
assert_eq!(cfg.interface.addresses, vec![v4(10, 0, 0, 2, 32)]);
|
||||
}
|
||||
|
||||
fn parse_err(text: &str) -> String {
|
||||
// `{:#}` renders the whole anyhow chain, so assertions can match on the
|
||||
// inner cause as well as the line-number context wrapped around it.
|
||||
format!("{:#}", parse(text).unwrap_err())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_required_keys() {
|
||||
let no_private = minimal().replace(&format!("PrivateKey = {PRIVATE_KEY}\n"), "");
|
||||
assert!(parse_err(&no_private).contains("PrivateKey"));
|
||||
|
||||
let no_peer_section =
|
||||
format!("[Interface]\nPrivateKey = {PRIVATE_KEY}\nAddress = 10.0.0.2/32\n");
|
||||
assert!(parse_err(&no_peer_section).contains("[Peer]"));
|
||||
|
||||
let no_endpoint = minimal().replace("Endpoint = 1.2.3.4:51820\n", "");
|
||||
assert!(parse_err(&no_endpoint).contains("Endpoint"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_line_that_is_not_key_equals_value() {
|
||||
let text = minimal().replace("Address = 10.0.0.2/32", "Address 10.0.0.2/32");
|
||||
let err = parse_err(&text);
|
||||
assert!(err.contains("line 3"), "want the line number, got: {err}");
|
||||
assert!(err.contains("Key = Value"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_second_peer_section() {
|
||||
let text = format!("{}\n[Peer]\nPublicKey = {PUBLIC_KEY}\n", minimal());
|
||||
let err = parse_err(&text);
|
||||
assert!(err.contains("line 10"), "want the line number, got: {err}");
|
||||
assert!(err.contains("multiple [Peer]"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_second_interface_section() {
|
||||
let text = format!("{}\n[Interface]\nAddress = 10.0.0.3/32\n", minimal());
|
||||
assert!(parse_err(&text).contains("duplicate [Interface]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_duplicate_key() {
|
||||
let text = minimal().replace(
|
||||
"Endpoint = 1.2.3.4:51820",
|
||||
"Endpoint = 1.2.3.4:51820\nEndpoint = 5.6.7.8:51820",
|
||||
);
|
||||
let err = parse_err(&text);
|
||||
assert!(err.contains("duplicate key"), "got: {err}");
|
||||
assert!(err.contains("Endpoint"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_an_unknown_section() {
|
||||
let text = minimal().replace("[Peer]", "[Peers]");
|
||||
assert!(parse_err(&text).contains("unknown section [Peers]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_key_before_any_section() {
|
||||
let text = format!("Address = 10.0.0.2/32\n{}", minimal());
|
||||
assert!(parse_err(&text).contains("before any section header"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_addresses() {
|
||||
for bad in [
|
||||
"10.0.0.256/32", // octet out of range
|
||||
"10.0.0.2/33", // prefix too long for v4
|
||||
"fd00::1/129", // prefix too long for v6
|
||||
"10.0.0.2/", // empty prefix
|
||||
"not-an-ip/32",
|
||||
"10.0.0.2, ", // empty trailing entry
|
||||
] {
|
||||
let text = minimal().replace("Address = 10.0.0.2/32", &format!("Address = {bad}"));
|
||||
assert!(
|
||||
parse(&text).is_err(),
|
||||
"expected {bad:?} to be rejected as an Address"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_endpoints() {
|
||||
for bad in [
|
||||
"1.2.3.4", // no port
|
||||
"1.2.3.4:0", // reserved port
|
||||
"1.2.3.4:70000", // port out of range
|
||||
"1.2.3.4:http", // non-numeric port
|
||||
"2001:db8::1:51820", // unbracketed IPv6
|
||||
"[2001:db8::1]", // bracketed, no port
|
||||
"[2001:db8::zz]:51820", // bad IPv6 literal
|
||||
":51820", // empty host
|
||||
] {
|
||||
let text = minimal().replace("Endpoint = 1.2.3.4:51820", &format!("Endpoint = {bad}"));
|
||||
assert!(
|
||||
parse(&text).is_err(),
|
||||
"expected {bad:?} to be rejected as an Endpoint"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_non_ip_dns_entry() {
|
||||
let text = minimal().replace(
|
||||
"Address = 10.0.0.2/32",
|
||||
"Address = 10.0.0.2/32\nDNS = example.com",
|
||||
);
|
||||
assert!(parse_err(&text).contains("search-domain"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_keys() {
|
||||
// A 41-character key: long enough to look right at a glance, which is
|
||||
// precisely why it needs to fail here rather than at the UAPI socket.
|
||||
let short = "aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq=";
|
||||
assert_eq!(short.len(), 41);
|
||||
for bad in [short, "aaaa", "", &"A".repeat(44)] {
|
||||
let text = minimal().replace(
|
||||
&format!("PublicKey = {PUBLIC_KEY}"),
|
||||
&format!("PublicKey = {bad}"),
|
||||
);
|
||||
assert!(parse(&text).is_err(), "expected key {bad:?} to be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_key_never_appears_in_an_error() {
|
||||
let text = minimal().replace(
|
||||
&format!("PrivateKey = {PRIVATE_KEY}"),
|
||||
"PrivateKey = SECRETSECRETSECRETSECRETSECRETSECRETSECRET=",
|
||||
);
|
||||
let err = parse_err(&text);
|
||||
assert!(!err.contains("SECRET"), "private key leaked: {err}");
|
||||
assert!(err.contains("PrivateKey"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_redacts_the_private_key_but_not_the_public_one() {
|
||||
let cfg = parse(&protonvpn_sample()).unwrap();
|
||||
let rendered = format!("{cfg:?}");
|
||||
assert!(
|
||||
!rendered.contains(PRIVATE_KEY),
|
||||
"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(PUBLIC_KEY), "got: {rendered}");
|
||||
assert!(rendered.contains("10.2.0.2/32"), "got: {rendered}");
|
||||
}
|
||||
|
||||
/// A bare `Key` must stay redacted wherever it is printed, since the same
|
||||
/// type carries private keys.
|
||||
#[test]
|
||||
fn key_debug_is_redacted() {
|
||||
let key = Key::from_base64(PRIVATE_KEY).unwrap();
|
||||
assert_eq!(format!("{key:?}"), "Key(<redacted>)");
|
||||
}
|
||||
|
||||
/// Wave 3 sends keys to the `wireguard-go` UAPI socket as hex.
|
||||
#[test]
|
||||
fn key_round_trips_base64_to_hex() {
|
||||
let key = Key::from_base64(PRIVATE_KEY).unwrap();
|
||||
// PRIVATE_KEY is base64 of the bytes 0x00..=0x1f.
|
||||
let expected: String = (0u8..32).map(|b| format!("{b:02x}")).collect();
|
||||
assert_eq!(key.to_hex(), expected);
|
||||
assert_eq!(key.to_hex().len(), 64);
|
||||
|
||||
let all_ones = Key::from_base64("//////////////////////////////////////////8=").unwrap();
|
||||
assert_eq!(all_ones.to_hex(), "ff".repeat(32));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! VPN mode: bring up a WireGuard tunnel from a config file, entirely in user
|
||||
//! space, and confine every torrent socket to it.
|
||||
//!
|
||||
//! # 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;
|
||||
pub mod tunnel;
|
||||
pub mod uapi;
|
||||
|
||||
pub use config::{WireguardConfig, parse};
|
||||
pub use tunnel::{INTERFACE, Tunnel};
|
||||
@@ -0,0 +1,331 @@
|
||||
//! Namespace bootstrap for VPN mode.
|
||||
//!
|
||||
//! torad must run inside its own network namespace so that a WireGuard
|
||||
//! interface can be created and every socket confined to it, without root and
|
||||
//! without any one-time host setup.
|
||||
//!
|
||||
//! It does not build that namespace by hand. `pasta` (from passt) already
|
||||
//! creates an unprivileged user+network namespace, configures a tap interface
|
||||
//! with the host's addressing, and executes a command inside it — so torad
|
||||
//! re-executes *itself* under pasta and continues from there. Measured inside
|
||||
//! that namespace:
|
||||
//!
|
||||
//! ```text
|
||||
//! uid_map -> 0 1000 1 (we are root in the namespace, uid 1000 outside)
|
||||
//! CapEff -> 000001ffffffffff (all capabilities, including CAP_NET_ADMIN)
|
||||
//! ns/net -> distinct from the host's
|
||||
//! /dev/net/tun writable
|
||||
//! ```
|
||||
//!
|
||||
//! What pasta does not give us is a private *mount* namespace, and one is
|
||||
//! required: `wireguard-go` hardcodes its UAPI socket directory to
|
||||
//! `/var/run/wireguard` (it is a build-time linker variable with no environment
|
||||
//! override, and `WG_UAPI_FD` does not avoid it — `UAPIListen` inotify-watches
|
||||
//! that path even when handed a pre-bound socket). We hold `CAP_SYS_ADMIN` in
|
||||
//! our own user namespace, so [`enter_mount_namespace`] unshares one and mounts
|
||||
//! a tmpfs over `/var`.
|
||||
//!
|
||||
//! `/var` and not `/run`: `/var/run` is a symlink to `/run`, so a tmpfs on
|
||||
//! `/var` yields a fresh writable `/var/run/wireguard` while leaving the real
|
||||
//! `/run` visible. That matters — torad's gRPC socket lives under
|
||||
//! `/run/user/<uid>` and the aggregator connects to it from the host.
|
||||
|
||||
use std::ffi::{CString, OsString};
|
||||
use std::fs;
|
||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use nix::mount::{MsFlags, mount};
|
||||
use nix::sched::{CloneFlags, unshare};
|
||||
|
||||
/// 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";
|
||||
|
||||
/// Set on the re-executed process so it knows not to re-exec again.
|
||||
pub const IN_NETNS_ENV: &str = "TORAD_IN_NETNS";
|
||||
|
||||
/// Address at which pasta makes the host's loopback reachable.
|
||||
///
|
||||
/// Note that plain `127.0.0.1` also reaches the *host's* loopback from inside
|
||||
/// the namespace — pasta splices namespace loopback to host loopback by
|
||||
/// default, including for services bound after the namespace starts. So
|
||||
/// loopback URLs for Jackett and Postgres need no rewriting. This address is
|
||||
/// the explicit, documented route to the same place, and exists as a fallback
|
||||
/// if that default is ever turned off.
|
||||
pub const HOST_LOOPBACK: &str = "169.254.1.1";
|
||||
|
||||
/// Directory `wireguard-go` hardcodes for its UAPI sockets.
|
||||
const WIREGUARD_SOCKET_DIR: &str = "/var/run/wireguard";
|
||||
|
||||
/// True when this process is already the re-executed one running inside pasta.
|
||||
pub fn already_inside() -> bool {
|
||||
std::env::var_os(IN_NETNS_ENV).is_some_and(|v| v == "1")
|
||||
}
|
||||
|
||||
/// Preflight: confirm the kernel and the environment can support VPN mode.
|
||||
///
|
||||
/// Runs before the re-exec so a missing dependency fails with an explanation
|
||||
/// rather than halfway through namespace setup.
|
||||
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"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(e).with_context(|| format!("failed to read {MAX_USER_NAMESPACES_PROC}"));
|
||||
}
|
||||
}
|
||||
|
||||
for (binary, purpose) in [
|
||||
("pasta", "creates the unprivileged network namespace"),
|
||||
("wireguard-go", "provides the userspace WireGuard datapath"),
|
||||
("ip", "assigns the tunnel address and routes"),
|
||||
] {
|
||||
which(binary).with_context(|| {
|
||||
format!("VPN mode requires `{binary}` on PATH ({purpose}), and it was not found")
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
/// Locate a binary on `PATH`, mirroring what `execvp` will do moments later.
|
||||
fn which(binary: &str) -> Result<std::path::PathBuf> {
|
||||
let path = std::env::var_os("PATH").context("PATH is not set")?;
|
||||
std::env::split_paths(&path)
|
||||
.map(|dir| dir.join(binary))
|
||||
.find(|candidate| candidate.is_file())
|
||||
.with_context(|| format!("{binary} not found on PATH"))
|
||||
}
|
||||
|
||||
/// Replace this process with itself running inside a pasta namespace.
|
||||
///
|
||||
/// Returns only on failure: on success the process image is gone. It must be
|
||||
/// called before any resource is acquired — before the Postgres pool, the pid
|
||||
/// file, the listening socket, and any tokio runtime — because none of that
|
||||
/// survives `execvpe`, and a half-opened socket would be inherited into the
|
||||
/// new image as a stray fd.
|
||||
pub fn reexec_under_pasta() -> Result<std::convert::Infallible> {
|
||||
let exe = std::env::current_exe().context("failed to determine torad's own path")?;
|
||||
|
||||
let mut argv: Vec<OsString> = vec![
|
||||
"pasta".into(),
|
||||
// Configure the tap interface, address and routes inside the namespace.
|
||||
"--config-net".into(),
|
||||
// Reach host services at a stable address regardless of the host's
|
||||
// own addressing.
|
||||
"--map-host-loopback".into(),
|
||||
HOST_LOOPBACK.into(),
|
||||
// pasta's informational chatter would otherwise interleave with
|
||||
// torad's structured logs on the same stderr.
|
||||
"--quiet".into(),
|
||||
"--".into(),
|
||||
exe.into_os_string(),
|
||||
];
|
||||
// Skip argv[0] and pass the original arguments through verbatim, so the
|
||||
// re-executed process sees exactly the command line the operator typed.
|
||||
argv.extend(std::env::args_os().skip(1));
|
||||
|
||||
let argv: Vec<CString> = argv
|
||||
.into_iter()
|
||||
.map(|arg| CString::new(arg.as_bytes()).context("argument contained an interior NUL"))
|
||||
.collect::<Result<_>>()?;
|
||||
|
||||
let mut env: Vec<CString> = std::env::vars_os()
|
||||
.filter(|(key, _)| key != IN_NETNS_ENV)
|
||||
.map(|(key, value)| {
|
||||
let mut entry = key.into_vec();
|
||||
entry.push(b'=');
|
||||
entry.extend_from_slice(value.as_bytes());
|
||||
CString::new(entry).context("environment entry contained an interior NUL")
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
env.push(CString::new(format!("{IN_NETNS_ENV}=1")).expect("literal has no NUL"));
|
||||
|
||||
let program = CString::new("pasta").expect("literal has no NUL");
|
||||
tracing::info!("re-executing under pasta to enter a private network namespace");
|
||||
|
||||
nix::unistd::execvpe(&program, &argv, &env)
|
||||
.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",
|
||||
)?;
|
||||
|
||||
// Without this our tmpfs could propagate back into the host's mount tree.
|
||||
mount(
|
||||
Some("none"),
|
||||
"/",
|
||||
None::<&str>,
|
||||
MsFlags::MS_REC | MsFlags::MS_PRIVATE,
|
||||
None::<&str>,
|
||||
)
|
||||
.context("failed to make the mount namespace private")?;
|
||||
|
||||
mount(
|
||||
Some("tmpfs"),
|
||||
"/var",
|
||||
Some("tmpfs"),
|
||||
MsFlags::empty(),
|
||||
Some("mode=0755"),
|
||||
)
|
||||
.context("failed to mount a tmpfs over /var")?;
|
||||
|
||||
fs::create_dir_all(WIREGUARD_SOCKET_DIR)
|
||||
.with_context(|| format!("failed to create {WIREGUARD_SOCKET_DIR}"))?;
|
||||
|
||||
Ok(Path::new(WIREGUARD_SOCKET_DIR))
|
||||
}
|
||||
|
||||
/// Point the namespace's resolver at the tunnel's DNS servers.
|
||||
///
|
||||
/// Must be called *after* the peer endpoint has been resolved: the tunnel DNS
|
||||
/// is typically only reachable through the tunnel, so installing it first would
|
||||
/// leave nothing able to resolve the endpoint hostname.
|
||||
///
|
||||
/// The file is bind-mounted rather than written in place because `/etc` belongs
|
||||
/// to root on the host and our uid maps outside the namespace.
|
||||
pub fn install_resolv_conf(runtime_dir: &Path, nameservers: &[std::net::IpAddr]) -> Result<()> {
|
||||
if nameservers.is_empty() {
|
||||
tracing::info!("config has no DNS entry; leaving the host resolver in place");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let contents: String = nameservers
|
||||
.iter()
|
||||
.map(|ip| format!("nameserver {ip}\n"))
|
||||
.collect();
|
||||
let source = runtime_dir.join("resolv.conf");
|
||||
fs::write(&source, contents)
|
||||
.with_context(|| format!("failed to write {}", source.display()))?;
|
||||
|
||||
mount(
|
||||
Some(&source),
|
||||
"/etc/resolv.conf",
|
||||
None::<&str>,
|
||||
MsFlags::MS_BIND,
|
||||
None::<&str>,
|
||||
)
|
||||
.context("failed to bind-mount the tunnel's resolv.conf over /etc/resolv.conf")?;
|
||||
|
||||
tracing::info!(count = nameservers.len(), "installed tunnel DNS");
|
||||
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());
|
||||
}
|
||||
|
||||
/// `evaluate` must agree with the sysctl on this host, where it is present
|
||||
/// and non-zero. `preflight` itself additionally requires pasta and
|
||||
/// wireguard-go on PATH, which is asserted separately below.
|
||||
#[test]
|
||||
fn evaluate_agrees_with_the_host_sysctl() {
|
||||
if let Ok(contents) = fs::read_to_string(MAX_USER_NAMESPACES_PROC) {
|
||||
assert!(evaluate(&contents).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn which_finds_a_binary_that_exists_and_not_one_that_does_not() {
|
||||
assert!(which("sh").is_ok(), "sh must be on PATH");
|
||||
assert!(which("torad-definitely-not-a-real-binary").is_err());
|
||||
}
|
||||
|
||||
/// A missing dependency must say which binary and why, not fail later with
|
||||
/// a bare ENOENT from execvp.
|
||||
#[test]
|
||||
fn preflight_error_names_the_missing_binary() {
|
||||
// Only meaningful when the dependency really is absent; when it is
|
||||
// installed, preflight should simply pass.
|
||||
match preflight() {
|
||||
Ok(()) => {
|
||||
assert!(which("pasta").is_ok());
|
||||
assert!(which("wireguard-go").is_ok());
|
||||
}
|
||||
Err(err) => {
|
||||
let text = format!("{err:#}");
|
||||
assert!(
|
||||
text.contains("pasta") || text.contains("wireguard-go") || text.contains("ip"),
|
||||
"error should name the missing binary, got: {text}"
|
||||
);
|
||||
assert!(text.contains("PATH"), "got: {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_inside_reads_the_marker_env_var() {
|
||||
// The variable is not set in the test process.
|
||||
assert!(!already_inside());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Bringing up the WireGuard tunnel inside the namespace.
|
||||
//!
|
||||
//! Order matters here and is not arbitrary:
|
||||
//!
|
||||
//! 1. Read pasta's default route *before* touching routing, because step 4
|
||||
//! replaces it and we need the original gateway to pin the endpoint.
|
||||
//! 2. Resolve the peer endpoint while the host resolver still works.
|
||||
//! 3. Pin the endpoint to the pasta gateway with a host route. Without this,
|
||||
//! installing `default dev wg0` in step 4 would send WireGuard's own
|
||||
//! handshake into the tunnel it is trying to establish. `wg-quick` solves
|
||||
//! this with fwmark plus policy routing; a host route is the simpler
|
||||
//! equivalent for a single fixed peer.
|
||||
//! 4. Only then bring up wg0 and move the default route onto it.
|
||||
//! 5. Install tunnel DNS last, since it is typically only reachable through
|
||||
//! the tunnel that now exists.
|
||||
|
||||
use std::net::{IpAddr, ToSocketAddrs};
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
use super::config::WireguardConfig;
|
||||
use super::{namespace, uapi};
|
||||
|
||||
/// Interface name for the tunnel. Fixed rather than configurable: it is also
|
||||
/// what librqbit is told to bind every socket to, and nothing outside this
|
||||
/// process needs to name it.
|
||||
pub const INTERFACE: &str = "wg0";
|
||||
|
||||
/// How long to wait for `wireguard-go` to create its UAPI socket.
|
||||
const UAPI_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const UAPI_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// A running tunnel. Dropping this kills `wireguard-go` and takes the
|
||||
/// interface with it, which is the intended behaviour: no interface means
|
||||
/// librqbit's bound sockets fail rather than falling back to the host route.
|
||||
pub struct Tunnel {
|
||||
process: Child,
|
||||
}
|
||||
|
||||
impl Tunnel {
|
||||
/// Wait for the datapath to exit. Returns the status it exited with.
|
||||
///
|
||||
/// A live torad with a dead tunnel is the failure mode that leaks, so the
|
||||
/// caller is expected to shut down when this returns.
|
||||
pub fn wait(&mut self) -> Result<std::process::ExitStatus> {
|
||||
self.process
|
||||
.wait()
|
||||
.context("failed to wait on the wireguard-go process")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tunnel {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.process.kill();
|
||||
let _ = self.process.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring up the tunnel. Returns once traffic can flow through [`INTERFACE`].
|
||||
pub fn up(config: &WireguardConfig, socket_dir: &Path) -> Result<Tunnel> {
|
||||
let gateway = default_route().context(
|
||||
"failed to read the namespace's default route; \
|
||||
pasta should have installed one with --config-net",
|
||||
)?;
|
||||
tracing::info!(gateway = %gateway.via, device = %gateway.device, "namespace default route");
|
||||
|
||||
let endpoints = resolve_endpoint(config)?;
|
||||
let tunnel = start_datapath(socket_dir)?;
|
||||
|
||||
uapi::configure(&socket_dir.join(format!("{INTERFACE}.sock")), config)?;
|
||||
|
||||
// Pin the peer outside the tunnel before the tunnel can capture it.
|
||||
for endpoint in &endpoints {
|
||||
let host_route = match endpoint {
|
||||
IpAddr::V4(addr) => format!("{addr}/32"),
|
||||
IpAddr::V6(addr) => format!("{addr}/128"),
|
||||
};
|
||||
ip(&[
|
||||
"route",
|
||||
"add",
|
||||
&host_route,
|
||||
"via",
|
||||
&gateway.via,
|
||||
"dev",
|
||||
&gateway.device,
|
||||
])
|
||||
.with_context(|| format!("failed to pin peer endpoint {endpoint} to the host route"))?;
|
||||
tracing::info!(%endpoint, "pinned peer endpoint outside the tunnel");
|
||||
}
|
||||
|
||||
for address in &config.interface.addresses {
|
||||
ip(&["address", "add", &address.to_string(), "dev", INTERFACE])
|
||||
.with_context(|| format!("failed to assign {address} to {INTERFACE}"))?;
|
||||
}
|
||||
ip(&["link", "set", INTERFACE, "up"])
|
||||
.with_context(|| format!("failed to bring {INTERFACE} up"))?;
|
||||
|
||||
for allowed in &config.peer.allowed_ips {
|
||||
let destination = allowed.to_string();
|
||||
// A default route must replace pasta's, not collide with it. Narrower
|
||||
// routes are added, so a malformed config surfaces as an error rather
|
||||
// than silently clobbering something.
|
||||
let is_default = allowed.prefix_len == 0;
|
||||
let verb = if is_default { "replace" } else { "add" };
|
||||
let target = if is_default { "default" } else { &destination };
|
||||
let family = if allowed.addr.is_ipv4() { "-4" } else { "-6" };
|
||||
ip(&[family, "route", verb, target, "dev", INTERFACE])
|
||||
.with_context(|| format!("failed to route {destination} through {INTERFACE}"))?;
|
||||
tracing::info!(route = %destination, "routed through the tunnel");
|
||||
}
|
||||
|
||||
namespace::install_resolv_conf(socket_dir, &config.interface.dns)?;
|
||||
|
||||
tracing::info!(interface = INTERFACE, "tunnel is up");
|
||||
Ok(tunnel)
|
||||
}
|
||||
|
||||
/// Spawn `wireguard-go` and wait for its UAPI socket to appear.
|
||||
fn start_datapath(socket_dir: &Path) -> Result<Tunnel> {
|
||||
let mut process = Command::new("wireguard-go")
|
||||
// Stay in the foreground so the process is ours to supervise; the
|
||||
// default is to daemonise, which would leave nothing to wait on.
|
||||
.arg("-f")
|
||||
.arg(INTERFACE)
|
||||
.env("WG_PROCESS_FOREGROUND", "1")
|
||||
.stdin(Stdio::null())
|
||||
.spawn()
|
||||
.context("failed to spawn wireguard-go")?;
|
||||
|
||||
let socket = socket_dir.join(format!("{INTERFACE}.sock"));
|
||||
let deadline = Instant::now() + UAPI_TIMEOUT;
|
||||
while Instant::now() < deadline {
|
||||
if socket.exists() {
|
||||
// Prove the device answers, not merely that the socket file exists.
|
||||
if let Err(err) = uapi::get(&socket) {
|
||||
let _ = process.kill();
|
||||
let _ = process.wait();
|
||||
return Err(err).context("wireguard-go created its socket but did not respond");
|
||||
}
|
||||
return Ok(Tunnel { process });
|
||||
}
|
||||
if let Some(status) = process
|
||||
.try_wait()
|
||||
.context("failed to poll the wireguard-go process")?
|
||||
{
|
||||
bail!("wireguard-go exited before creating its UAPI socket (status {status})");
|
||||
}
|
||||
std::thread::sleep(UAPI_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
let _ = process.kill();
|
||||
let _ = process.wait();
|
||||
bail!(
|
||||
"wireguard-go did not create {} within {:?}",
|
||||
socket.display(),
|
||||
UAPI_TIMEOUT
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DefaultRoute {
|
||||
via: String,
|
||||
device: String,
|
||||
}
|
||||
|
||||
/// Read the namespace's IPv4 default route, as installed by `pasta --config-net`.
|
||||
fn default_route() -> Result<DefaultRoute> {
|
||||
let output = Command::new("ip")
|
||||
.args(["-4", "route", "show", "default"])
|
||||
.output()
|
||||
.context("failed to run `ip route show default`")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"`ip route show default` failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
parse_default_route(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
/// Pull the gateway and device out of `ip route show default` output.
|
||||
fn parse_default_route(text: &str) -> Result<DefaultRoute> {
|
||||
let line = text
|
||||
.lines()
|
||||
.find(|line| line.starts_with("default "))
|
||||
.context("no default route in the namespace")?;
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
|
||||
let via = field_after(&fields, "via")
|
||||
.context("default route has no `via` gateway")?
|
||||
.to_owned();
|
||||
let device = field_after(&fields, "dev")
|
||||
.context("default route has no `dev` interface")?
|
||||
.to_owned();
|
||||
|
||||
Ok(DefaultRoute { via, device })
|
||||
}
|
||||
|
||||
fn field_after<'a>(fields: &[&'a str], key: &str) -> Option<&'a str> {
|
||||
fields
|
||||
.iter()
|
||||
.position(|field| *field == key)
|
||||
.and_then(|index| fields.get(index + 1))
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// Resolve the peer endpoint to concrete addresses.
|
||||
///
|
||||
/// Done once, at setup. WireGuard would normally re-resolve a roaming endpoint
|
||||
/// on its own, but the host route pinned in [`up`] is what keeps the handshake
|
||||
/// out of the tunnel, and that route is per-address — so an endpoint that
|
||||
/// changes address mid-session is not followed. Documented as a limitation
|
||||
/// rather than solved with policy routing.
|
||||
fn resolve_endpoint(config: &WireguardConfig) -> Result<Vec<IpAddr>> {
|
||||
let endpoint = &config.peer.endpoint;
|
||||
if let Ok(addr) = endpoint.host.parse::<IpAddr>() {
|
||||
return Ok(vec![addr]);
|
||||
}
|
||||
|
||||
let addresses: Vec<IpAddr> = (endpoint.host.as_str(), endpoint.port)
|
||||
.to_socket_addrs()
|
||||
.with_context(|| format!("failed to resolve peer endpoint host {:?}", endpoint.host))?
|
||||
.map(|socket| socket.ip())
|
||||
.collect();
|
||||
|
||||
if addresses.is_empty() {
|
||||
bail!(
|
||||
"peer endpoint host {:?} resolved to no addresses",
|
||||
endpoint.host
|
||||
);
|
||||
}
|
||||
tracing::info!(host = %endpoint.host, count = addresses.len(), "resolved peer endpoint");
|
||||
Ok(addresses)
|
||||
}
|
||||
|
||||
/// Run `ip` with the given arguments, failing with its stderr attached.
|
||||
fn ip(args: &[&str]) -> Result<()> {
|
||||
let output = Command::new("ip")
|
||||
.args(args)
|
||||
.output()
|
||||
.with_context(|| format!("failed to run `ip {}`", args.join(" ")))?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"`ip {}` failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_a_pasta_default_route() {
|
||||
// Exactly what `pasta --config-net` installs, as observed in the probe.
|
||||
let text = "default via 192.168.100.1 dev enp7s0f3u1u2 proto dhcp metric 100 \n";
|
||||
let route = parse_default_route(text).unwrap();
|
||||
assert_eq!(route.via, "192.168.100.1");
|
||||
assert_eq!(route.device, "enp7s0f3u1u2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_default_routes() {
|
||||
let text = "192.168.100.0/24 dev eth0 proto kernel scope link\n\
|
||||
default via 10.0.0.1 dev eth0\n";
|
||||
let route = parse_default_route(text).unwrap();
|
||||
assert_eq!(route.via, "10.0.0.1");
|
||||
assert_eq!(route.device, "eth0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_or_incomplete_default_routes() {
|
||||
assert!(parse_default_route("").is_err());
|
||||
assert!(parse_default_route("192.168.1.0/24 dev eth0\n").is_err());
|
||||
// An on-link default has no gateway to pin the endpoint to.
|
||||
let err = parse_default_route("default dev eth0\n")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("via"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_after_finds_values_and_handles_edges() {
|
||||
let fields = vec!["default", "via", "10.0.0.1", "dev", "eth0"];
|
||||
assert_eq!(field_after(&fields, "via"), Some("10.0.0.1"));
|
||||
assert_eq!(field_after(&fields, "dev"), Some("eth0"));
|
||||
assert_eq!(field_after(&fields, "proto"), None);
|
||||
// Key present but nothing follows it.
|
||||
assert_eq!(field_after(&["default", "via"], "via"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_a_literal_endpoint_without_dns() {
|
||||
let config = crate::vpn::config::parse(
|
||||
"[Interface]\n\
|
||||
PrivateKey = AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=\n\
|
||||
Address = 10.2.0.2/32\n\
|
||||
[Peer]\n\
|
||||
PublicKey = ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=\n\
|
||||
AllowedIPs = 0.0.0.0/0\n\
|
||||
Endpoint = 156.146.50.5:51820\n",
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = resolve_endpoint(&config).unwrap();
|
||||
assert_eq!(resolved, vec![IpAddr::from([156, 146, 50, 5])]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Client for WireGuard's UAPI protocol.
|
||||
//!
|
||||
//! `wireguard-go` exposes a unix socket speaking a line-oriented text protocol
|
||||
//! instead of the netlink interface the kernel module uses. Talking to it
|
||||
//! directly means torad needs no `wg` binary at all — `wireguard-tools` is not
|
||||
//! a dependency of VPN mode.
|
||||
//!
|
||||
//! The protocol: send an operation line (`set=1`), then `key=value` lines, then
|
||||
//! a blank line. The device replies with its own `key=value` lines (empty for
|
||||
//! `set`), an `errno=N` line, and a blank line. `errno=0` means success.
|
||||
//! Keys are lowercase hex, not the base64 used in config files.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
use super::config::WireguardConfig;
|
||||
|
||||
/// Push a parsed config into a running `wireguard-go` device.
|
||||
///
|
||||
/// `replace_peers` and `replace_allowed_ips` make this idempotent: applying the
|
||||
/// same config twice leaves exactly one peer rather than accumulating them.
|
||||
pub fn configure(socket_path: &Path, config: &WireguardConfig) -> Result<()> {
|
||||
let mut request = String::from("set=1\n");
|
||||
request.push_str(&format!(
|
||||
"private_key={}\n",
|
||||
config.interface.private_key.to_hex()
|
||||
));
|
||||
request.push_str("replace_peers=true\n");
|
||||
request.push_str(&format!("public_key={}\n", config.peer.public_key.to_hex()));
|
||||
request.push_str(&format!("endpoint={}\n", config.peer.endpoint));
|
||||
if let Some(keepalive) = config.peer.persistent_keepalive {
|
||||
request.push_str(&format!("persistent_keepalive_interval={keepalive}\n"));
|
||||
}
|
||||
request.push_str("replace_allowed_ips=true\n");
|
||||
for cidr in &config.peer.allowed_ips {
|
||||
request.push_str(&format!("allowed_ip={cidr}\n"));
|
||||
}
|
||||
request.push('\n');
|
||||
|
||||
let response = round_trip(socket_path, &request)
|
||||
// The request body contains the private key in hex, so it must never
|
||||
// appear in an error. Only the socket path does.
|
||||
.with_context(|| format!("UAPI set=1 failed on {}", socket_path.display()))?;
|
||||
|
||||
match parse_errno(&response)? {
|
||||
0 => Ok(()),
|
||||
errno => bail!(
|
||||
"wireguard-go rejected the configuration (UAPI errno={errno}). \
|
||||
The config parsed cleanly, so this is most likely a key the device \
|
||||
would not accept."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read back the device state. Used by the readiness check and by tests.
|
||||
pub fn get(socket_path: &Path) -> Result<String> {
|
||||
round_trip(socket_path, "get=1\n\n")
|
||||
.with_context(|| format!("UAPI get=1 failed on {}", socket_path.display()))
|
||||
}
|
||||
|
||||
fn round_trip(socket_path: &Path, request: &str) -> Result<String> {
|
||||
let mut stream =
|
||||
UnixStream::connect(socket_path).context("failed to connect to UAPI socket")?;
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.context("failed to write UAPI request")?;
|
||||
stream.flush().context("failed to flush UAPI request")?;
|
||||
|
||||
// The device answers with lines terminated by a blank line. Reading to EOF
|
||||
// would work too, but wireguard-go keeps the connection open after `set`,
|
||||
// so stop at the terminator instead of blocking.
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut response = String::new();
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let read = reader
|
||||
.read_line(&mut line)
|
||||
.context("failed to read UAPI response")?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
if line == "\n" {
|
||||
break;
|
||||
}
|
||||
response.push_str(&line);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Extract the `errno=N` line the device terminates every response with.
|
||||
fn parse_errno(response: &str) -> Result<i64> {
|
||||
response
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("errno="))
|
||||
.context("UAPI response contained no errno line")?
|
||||
.trim()
|
||||
.parse()
|
||||
.context("UAPI errno was not a number")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::vpn::config;
|
||||
use std::io::Read;
|
||||
use std::os::unix::net::UnixListener;
|
||||
|
||||
const SAMPLE: &str = "[Interface]\n\
|
||||
PrivateKey = AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=\n\
|
||||
Address = 10.2.0.2/32\n\
|
||||
DNS = 10.2.0.1\n\
|
||||
\n\
|
||||
[Peer]\n\
|
||||
PublicKey = ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=\n\
|
||||
AllowedIPs = 0.0.0.0/0, ::/0\n\
|
||||
Endpoint = 156.146.50.5:51820\n\
|
||||
PersistentKeepalive = 25\n";
|
||||
|
||||
/// Stands in for wireguard-go: accepts one connection, records the request,
|
||||
/// and replies with the given canned response.
|
||||
fn spawn_fake_device(
|
||||
dir: &Path,
|
||||
reply: &'static str,
|
||||
) -> (std::path::PathBuf, std::thread::JoinHandle<String>) {
|
||||
let path = dir.join("wg0.sock");
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = String::new();
|
||||
// The client writes a blank-line-terminated block and then waits,
|
||||
// so read until that terminator rather than to EOF.
|
||||
let mut byte = [0u8; 1];
|
||||
while stream.read(&mut byte).unwrap() == 1 {
|
||||
request.push(byte[0] as char);
|
||||
if request.ends_with("\n\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
stream.write_all(reply.as_bytes()).unwrap();
|
||||
stream.flush().unwrap();
|
||||
request
|
||||
});
|
||||
(path, handle)
|
||||
}
|
||||
|
||||
/// Per-test directory. These tests bind a unix socket at a fixed name
|
||||
/// inside it, and the test harness runs them concurrently, so sharing one
|
||||
/// directory would race on both `bind` and the teardown `remove_dir_all`.
|
||||
fn tempdir(test_name: &str) -> std::path::PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("torad-uapi-{}-{test_name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sends_a_wellformed_set_request() {
|
||||
let dir = tempdir("set-request");
|
||||
let (path, handle) = spawn_fake_device(&dir, "errno=0\n\n");
|
||||
let config = config::parse(SAMPLE).unwrap();
|
||||
|
||||
configure(&path, &config).expect("errno=0 must be accepted");
|
||||
let request = handle.join().unwrap();
|
||||
|
||||
assert!(request.starts_with("set=1\n"), "got: {request}");
|
||||
assert!(request.ends_with("\n\n"), "must end with a blank line");
|
||||
// Hex, not the base64 from the file: PRIVATE_KEY is bytes 0x00..=0x1f.
|
||||
let private_hex: String = (0u8..32).map(|b| format!("{b:02x}")).collect();
|
||||
assert!(
|
||||
request.contains(&format!("private_key={private_hex}\n")),
|
||||
"got: {request}"
|
||||
);
|
||||
let public_hex: String = (32u8..64).map(|b| format!("{b:02x}")).collect();
|
||||
assert!(
|
||||
request.contains(&format!("public_key={public_hex}\n")),
|
||||
"got: {request}"
|
||||
);
|
||||
assert!(
|
||||
request.contains("endpoint=156.146.50.5:51820\n"),
|
||||
"got: {request}"
|
||||
);
|
||||
assert!(
|
||||
request.contains("persistent_keepalive_interval=25\n"),
|
||||
"got: {request}"
|
||||
);
|
||||
assert!(request.contains("allowed_ip=0.0.0.0/0\n"), "got: {request}");
|
||||
assert!(request.contains("allowed_ip=::/0\n"), "got: {request}");
|
||||
// Without these, re-applying a config would stack duplicate peers.
|
||||
assert!(request.contains("replace_peers=true\n"), "got: {request}");
|
||||
assert!(
|
||||
request.contains("replace_allowed_ips=true\n"),
|
||||
"got: {request}"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_nonzero_errno() {
|
||||
let dir = tempdir("nonzero-errno");
|
||||
let (path, handle) = spawn_fake_device(&dir, "errno=22\n\n");
|
||||
let config = config::parse(SAMPLE).unwrap();
|
||||
|
||||
let err = configure(&path, &config).unwrap_err();
|
||||
handle.join().unwrap();
|
||||
let text = format!("{err:#}");
|
||||
assert!(text.contains("errno=22"), "got: {text}");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// The request carries the private key in hex; a failure must not echo it.
|
||||
#[test]
|
||||
fn errors_never_contain_the_private_key() {
|
||||
let dir = tempdir("no-key-leak");
|
||||
let (path, handle) = spawn_fake_device(&dir, "errno=22\n\n");
|
||||
let config = config::parse(SAMPLE).unwrap();
|
||||
|
||||
let err = configure(&path, &config).unwrap_err();
|
||||
handle.join().unwrap();
|
||||
|
||||
let private_hex: String = (0u8..32).map(|b| format!("{b:02x}")).collect();
|
||||
let text = format!("{err:#}");
|
||||
assert!(!text.contains(&private_hex), "private key leaked: {text}");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_errno_from_a_full_response() {
|
||||
let response = "private_key=00\nlisten_port=51820\npublic_key=11\nerrno=0\n";
|
||||
assert_eq!(parse_errno(response).unwrap(), 0);
|
||||
assert_eq!(parse_errno("errno=95\n").unwrap(), 95);
|
||||
assert!(parse_errno("no errno here\n").is_err());
|
||||
assert!(parse_errno("errno=banana\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_failure_names_the_socket() {
|
||||
let config = config::parse(SAMPLE).unwrap();
|
||||
let missing = Path::new("/nonexistent/torad/wg0.sock");
|
||||
let err = format!("{:#}", configure(missing, &config).unwrap_err());
|
||||
assert!(err.contains("wg0.sock"), "got: {err}");
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -3,7 +3,8 @@ CREATE TYPE torrent_state AS ENUM (
|
||||
'downloading',
|
||||
'paused',
|
||||
'finished',
|
||||
'error'
|
||||
'error',
|
||||
'stale'
|
||||
);
|
||||
|
||||
CREATE TABLE torrents (
|
||||
@@ -11,7 +12,15 @@ CREATE TABLE torrents (
|
||||
info_hash TEXT NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
source TEXT NOT NULL,
|
||||
output_path TEXT NOT NULL,
|
||||
-- Set by the poller once librqbit resolves metadata and creates the
|
||||
-- output directory. NULL until then (magnet links take time to resolve).
|
||||
output_path TEXT,
|
||||
-- Per-torrent override of the download location. NULL = use the daemon
|
||||
-- default (<download_dir>/<torrent_name>/ for multi-file torrents).
|
||||
download_folder TEXT,
|
||||
-- Per-torrent override of the post-completion move target. NULL = use
|
||||
-- the daemon default (<download_dir>/completed/<torrent_name>/).
|
||||
move_after_completion_folder TEXT,
|
||||
total_bytes BIGINT,
|
||||
downloaded_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
state torrent_state NOT NULL DEFAULT 'pending',
|
||||
|
||||
+24
@@ -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,14 +92,26 @@ 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"
|
||||
'';
|
||||
# Waits for postgres's own readiness probe (pg_isready + SELECT 1), not just process start.
|
||||
after = [ "devenv:processes:postgres" ];
|
||||
# torad speaks gRPC over a Unix socket and does not register reflection,
|
||||
# so grpcurl would need proto files. Instead use the `tora health` CLI
|
||||
# (already knows the proto, reads TORAD_SOCKET env), which exits 0 iff
|
||||
# torad reports SERVING. torad's poll_db_health flips the status to
|
||||
# NotServing when postgres ping fails, so this also gates on a live DB.
|
||||
ready.exec = "${config.outputs.tora}/bin/tora health";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
torad = cargoNix.workspaceMembers.torad.build;
|
||||
tora = cargoNix.workspaceMembers.tora.build;
|
||||
};
|
||||
}
|
||||
|
||||
+109
-2
@@ -15,12 +15,25 @@ service Torrents {
|
||||
rpc Pause(PauseRequest) returns (PauseResponse);
|
||||
// Resume a paused torrent.
|
||||
rpc Resume(ResumeRequest) returns (ResumeResponse);
|
||||
// Subscribe to a live stream of torrent notifications. Events are delivered
|
||||
// from the subscribe point onward — no history is replayed. New subscribers
|
||||
// only see events that fire after they connect.
|
||||
rpc Notifications(NotificationsRequest) returns (stream Notification);
|
||||
|
||||
// List the files inside a torrent with their download progress. Returns
|
||||
// NOT_FOUND if the torrent is unknown or its metadata has not resolved yet
|
||||
// (e.g. a magnet link still fetching peers).
|
||||
rpc Files(FilesRequest) returns (FilesResponse);
|
||||
}
|
||||
|
||||
message AddRequest {
|
||||
string magnet = 1;
|
||||
// Optional output directory override. Empty means use torad's default.
|
||||
string output_dir = 2;
|
||||
// Folder to download into. Empty = daemon default
|
||||
// (<download_dir>/<torrent_name>/ for multi-file torrents).
|
||||
string download_folder = 2;
|
||||
// Folder to move the torrent into after completion. Empty = daemon default
|
||||
// (<download_dir>/completed/<torrent_name>/).
|
||||
string move_after_completion_folder = 3;
|
||||
}
|
||||
|
||||
message AddResponse {
|
||||
@@ -56,6 +69,94 @@ message ResumeRequest {
|
||||
|
||||
message ResumeResponse {}
|
||||
|
||||
message NotificationsRequest {
|
||||
// If non-empty, only events for these torrent ids (or id prefixes, as
|
||||
// accepted by `Status`/`Remove`) are delivered.
|
||||
repeated string ids = 1;
|
||||
// If non-empty, only these event kinds are delivered. An empty list means
|
||||
// all kinds.
|
||||
repeated NotificationKind kinds = 2;
|
||||
}
|
||||
|
||||
message FilesRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
enum FileState {
|
||||
FILE_STATE_UNSPECIFIED = 0;
|
||||
// File is partially downloaded.
|
||||
IN_PROGRESS = 1;
|
||||
// File's full byte range has been verified.
|
||||
READY = 2;
|
||||
}
|
||||
|
||||
message TorrentFile {
|
||||
// Path relative to the torrent root, as declared in the .torrent metadata.
|
||||
// May contain forward slashes for multi-file torrents.
|
||||
string name = 1;
|
||||
uint64 length = 2;
|
||||
uint64 downloaded_bytes = 3;
|
||||
FileState state = 4;
|
||||
}
|
||||
|
||||
message FilesResponse {
|
||||
repeated TorrentFile files = 1;
|
||||
}
|
||||
|
||||
message Notification {
|
||||
// Wall-clock time the daemon observed the change, in milliseconds since the
|
||||
// Unix epoch.
|
||||
uint64 observed_at_unix_millis = 1;
|
||||
// What changed. Use this to dispatch; use the `oneof detail` for
|
||||
// kind-specific payload.
|
||||
NotificationKind kind = 2;
|
||||
// Full snapshot of the torrent *after* the change. Always populated so
|
||||
// consumers don't need to fan out to `Status` for context.
|
||||
TorrentStatus torrent = 3;
|
||||
oneof detail {
|
||||
StateChanged state_changed = 10;
|
||||
ProgressMark progress = 11;
|
||||
AddedInfo added = 12;
|
||||
RemovedInfo removed = 13;
|
||||
}
|
||||
}
|
||||
|
||||
enum NotificationKind {
|
||||
NOTIFICATION_KIND_UNSPECIFIED = 0;
|
||||
// Emitted once when a torrent is added via `Add`.
|
||||
TORRENT_ADDED = 1;
|
||||
// Emitted once when a torrent is removed via `Remove`.
|
||||
TORRENT_REMOVED = 2;
|
||||
// Emitted on any `State` transition, including PAUSED <-> DOWNLOADING,
|
||||
// DOWNLOADING <-> STALE, * -> FINISHED, * -> ERROR.
|
||||
STATE_CHANGED = 3;
|
||||
// Emitted when the integer download percentage changes (rate-limited).
|
||||
// No event is sent if the percentage did not advance since the last tick.
|
||||
PROGRESS = 4;
|
||||
}
|
||||
|
||||
message StateChanged {
|
||||
State previous = 1;
|
||||
State current = 2;
|
||||
}
|
||||
|
||||
message ProgressMark {
|
||||
// Current integer percentage 0..=100.
|
||||
uint32 percent = 1;
|
||||
// Bytes downloaded since the previous PROGRESS event for this torrent.
|
||||
uint64 bytes_delta = 2;
|
||||
}
|
||||
|
||||
message AddedInfo {
|
||||
// Source string as provided to `Add` (magnet, URL, or file path).
|
||||
string source = 1;
|
||||
}
|
||||
|
||||
message RemovedInfo {
|
||||
// True if `Remove` was called with `delete_files = true`.
|
||||
bool files_deleted = 1;
|
||||
}
|
||||
|
||||
enum State {
|
||||
STATE_UNSPECIFIED = 0;
|
||||
PENDING = 1;
|
||||
@@ -63,6 +164,12 @@ enum State {
|
||||
PAUSED = 3;
|
||||
FINISHED = 4;
|
||||
ERROR = 5;
|
||||
// Live per librqbit, but the daemon has detected a stall: no byte progress
|
||||
// for several ticks AND zero connected peers. The torrent is still
|
||||
// considered live by the underlying engine; this state is an application-
|
||||
// level overlay. Recovers to DOWNLOADING automatically when bytes advance
|
||||
// or a peer connects.
|
||||
STALE = 6;
|
||||
}
|
||||
|
||||
message TorrentStatus {
|
||||
|
||||
@@ -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