Compare commits
3 Commits
e321300f67
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b489ff7135 | |||
| 62db1e2a9e | |||
| 6e5d27497e |
@@ -9,7 +9,7 @@ async-trait = "0.1"
|
||||
clap = { version = "4.6.1", features = ["derive", "env"] }
|
||||
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"] }
|
||||
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"] }
|
||||
|
||||
+125
-11
@@ -44,8 +44,18 @@ struct Args {
|
||||
/// 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 {
|
||||
@@ -63,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, args.bind_device)
|
||||
// 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)
|
||||
@@ -98,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")?;
|
||||
|
||||
@@ -131,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)
|
||||
|
||||
@@ -26,12 +26,12 @@ use super::{Resolved, SourceHandler};
|
||||
const JACKETT_MARKER: &str = "jackett_apikey=";
|
||||
|
||||
pub(crate) struct JackettHandler {
|
||||
client: reqwest::Client,
|
||||
clients: std::sync::Arc<super::HttpClients>,
|
||||
}
|
||||
|
||||
impl JackettHandler {
|
||||
pub(crate) fn new(client: reqwest::Client) -> Self {
|
||||
Self { client }
|
||||
pub(crate) fn new(clients: std::sync::Arc<super::HttpClients>) -> Self {
|
||||
Self { clients }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ impl SourceHandler for JackettHandler {
|
||||
}
|
||||
|
||||
async fn resolve(&self, source: &str) -> Result<Resolved> {
|
||||
let outcome = http_fetch::fetch(&self.client, source).await?;
|
||||
let outcome = http_fetch::fetch(self.clients.for_url(source), source).await?;
|
||||
match outcome {
|
||||
HttpFetchOutcome::Magnet(magnet) => {
|
||||
let parsed = Magnet::parse(&magnet)
|
||||
|
||||
+137
-10
@@ -60,22 +60,87 @@ pub(crate) struct SourceResolver {
|
||||
/// loopback, and routing loopback through an inherited proxy breaks it. Note
|
||||
/// this client also serves arbitrary remote `.torrent` URLs via
|
||||
/// [`http_torrent`], so the setting applies to those too.
|
||||
fn build_http_client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
///
|
||||
/// `bind_device` binds the client's sockets to one interface, mirroring what
|
||||
/// librqbit does for BitTorrent traffic.
|
||||
fn build_http_client(bind_device: Option<&str>) -> Result<reqwest::Client> {
|
||||
let builder = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.no_proxy()
|
||||
.build()
|
||||
.context("failed to build HTTP client")
|
||||
.no_proxy();
|
||||
let builder = match bind_device {
|
||||
Some(device) => builder.interface(device),
|
||||
None => builder,
|
||||
};
|
||||
builder.build().context("failed to build HTTP client")
|
||||
}
|
||||
|
||||
/// The two clients a source fetch may need.
|
||||
///
|
||||
/// In VPN mode a remote `.torrent` URL must leave through the tunnel, and the
|
||||
/// default route already sends it there — but routing is a property of the
|
||||
/// namespace's route table, while `SO_BINDTODEVICE` is enforced by the kernel
|
||||
/// per socket. Binding means a future route change cannot silently reroute an
|
||||
/// indexer fetch around the tunnel.
|
||||
///
|
||||
/// Loopback sources must *not* be bound. pasta splices the namespace's loopback
|
||||
/// to the host's, which is how a self-hosted Jackett at
|
||||
/// `http://localhost:9117/...` stays reachable; binding those sockets to the
|
||||
/// tunnel would break them, and that traffic is deliberately off the tunnel
|
||||
/// anyway since it never leaves the machine.
|
||||
pub(crate) struct HttpClients {
|
||||
direct: reqwest::Client,
|
||||
tunnel: Option<reqwest::Client>,
|
||||
}
|
||||
|
||||
impl HttpClients {
|
||||
fn build(bind_device: Option<&str>) -> Result<Self> {
|
||||
Ok(Self {
|
||||
direct: build_http_client(None)?,
|
||||
tunnel: bind_device
|
||||
.map(|device| build_http_client(Some(device)))
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pick the client this URL should be fetched with.
|
||||
pub(super) fn for_url(&self, url: &str) -> &reqwest::Client {
|
||||
match &self.tunnel {
|
||||
Some(tunnel) if !is_loopback_url(url) => tunnel,
|
||||
_ => &self.direct,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a URL points at this machine.
|
||||
///
|
||||
/// A URL that cannot be parsed is treated as non-loopback: in VPN mode the safe
|
||||
/// default is the tunnel, since guessing "local" for something remote would
|
||||
/// send it out unbound.
|
||||
fn is_loopback_url(url: &str) -> bool {
|
||||
let Ok(parsed) = reqwest::Url::parse(url) else {
|
||||
return false;
|
||||
};
|
||||
let Some(host) = parsed.host_str() else {
|
||||
return false;
|
||||
};
|
||||
if host.eq_ignore_ascii_case("localhost") {
|
||||
return true;
|
||||
}
|
||||
// `host_str` keeps IPv6 literals in their bracketed form.
|
||||
let host = host.strip_prefix('[').unwrap_or(host);
|
||||
let host = host.strip_suffix(']').unwrap_or(host);
|
||||
host.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|ip| ip.is_loopback())
|
||||
}
|
||||
|
||||
impl SourceResolver {
|
||||
pub(crate) fn new() -> Result<Self> {
|
||||
let http_client = build_http_client()?;
|
||||
pub(crate) fn new(bind_device: Option<&str>) -> Result<Self> {
|
||||
let clients = std::sync::Arc::new(HttpClients::build(bind_device)?);
|
||||
Ok(Self {
|
||||
handlers: vec![
|
||||
Box::new(magnet::MagnetHandler),
|
||||
Box::new(jackett::JackettHandler::new(http_client.clone())),
|
||||
Box::new(http_torrent::HttpTorrentHandler::new(http_client)),
|
||||
Box::new(jackett::JackettHandler::new(clients.clone())),
|
||||
Box::new(http_torrent::HttpTorrentHandler::new(clients)),
|
||||
Box::new(file::FileHandler),
|
||||
],
|
||||
})
|
||||
@@ -133,7 +198,7 @@ mod tests {
|
||||
let addr = spawn_oneshot_server().await;
|
||||
|
||||
unsafe { std::env::set_var("ALL_PROXY", "http://127.0.0.1:1") };
|
||||
let client = build_http_client();
|
||||
let client = build_http_client(None);
|
||||
unsafe { std::env::remove_var("ALL_PROXY") };
|
||||
|
||||
let response = client
|
||||
@@ -145,4 +210,66 @@ mod tests {
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(response.text().await.unwrap(), "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognises_loopback_urls() {
|
||||
for url in [
|
||||
"http://localhost:9117/dl/x",
|
||||
"http://LOCALHOST/dl/x",
|
||||
"http://127.0.0.1:9117/dl/x",
|
||||
// The whole 127.0.0.0/8 block, not just .0.1.
|
||||
"http://127.1.2.3/dl/x",
|
||||
"http://[::1]:9117/dl/x",
|
||||
] {
|
||||
assert!(is_loopback_url(url), "{url} should be loopback");
|
||||
}
|
||||
|
||||
for url in [
|
||||
"http://example.com/x.torrent",
|
||||
"https://tracker.example.org:8080/dl",
|
||||
// pasta's host-loopback alias is reachable without the tunnel, but
|
||||
// it is not itself a loopback address and does not need to be:
|
||||
// it is not on the tunnel's route either way.
|
||||
"http://169.254.1.1:5432/",
|
||||
"http://10.0.0.5/x.torrent",
|
||||
"magnet:?xt=urn:btih:0000",
|
||||
"not a url at all",
|
||||
] {
|
||||
assert!(!is_loopback_url(url), "{url} should not be loopback");
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a bind device there is one client and it is used for everything.
|
||||
#[test]
|
||||
fn unbound_mode_uses_the_direct_client_for_every_url() {
|
||||
let clients = HttpClients::build(None).unwrap();
|
||||
let remote = clients.for_url("http://example.com/x.torrent");
|
||||
let local = clients.for_url("http://localhost:9117/dl/x");
|
||||
// Same client instance for both.
|
||||
assert!(std::ptr::eq(remote, local));
|
||||
assert!(std::ptr::eq(remote, &clients.direct));
|
||||
}
|
||||
|
||||
/// With a bind device, remote fetches are enforced onto it and loopback
|
||||
/// fetches are deliberately left off it.
|
||||
#[test]
|
||||
fn bound_mode_sends_only_remote_urls_through_the_tunnel() {
|
||||
// `lo` rather than `wg0`: it exists on any host, so the client can
|
||||
// actually be built. Only the selection logic is under test.
|
||||
let clients = HttpClients::build(Some("lo")).unwrap();
|
||||
let tunnel = clients.tunnel.as_ref().expect("tunnel client must exist");
|
||||
|
||||
assert!(std::ptr::eq(
|
||||
clients.for_url("http://example.com/x.torrent"),
|
||||
tunnel
|
||||
));
|
||||
assert!(std::ptr::eq(
|
||||
clients.for_url("http://localhost:9117/dl/x"),
|
||||
&clients.direct
|
||||
));
|
||||
assert!(std::ptr::eq(
|
||||
clients.for_url("http://127.0.0.1:9117/dl/x"),
|
||||
&clients.direct
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,13 +78,16 @@ impl TorrentManager {
|
||||
let session = Session::new_with_opts(
|
||||
download_dir.clone(),
|
||||
SessionOptions {
|
||||
bind_device_name: bind_device,
|
||||
bind_device_name: bind_device.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("failed to create librqbit session")?;
|
||||
let source = SourceResolver::new().context("failed to build source resolver")?;
|
||||
// Source fetches are HTTP, not BitTorrent, so librqbit's binding does
|
||||
// not cover them; the resolver binds its own client to the same device.
|
||||
let source = SourceResolver::new(bind_device.as_deref())
|
||||
.context("failed to build source resolver")?;
|
||||
let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||
Ok(Arc::new(Self {
|
||||
pool,
|
||||
|
||||
+676
-181
@@ -1,6 +1,241 @@
|
||||
use std::fmt;
|
||||
//! 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 anyhow::Context;
|
||||
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 {
|
||||
@@ -9,33 +244,50 @@ pub struct WireguardConfig {
|
||||
}
|
||||
|
||||
pub struct InterfaceSection {
|
||||
pub private_key: String,
|
||||
pub address: String,
|
||||
pub dns: Option<String>,
|
||||
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. The derived impl would
|
||||
/// print it verbatim, and this value flows through error contexts during
|
||||
/// interface setup.
|
||||
/// 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("address", &self.address)
|
||||
.field("addresses", &self.addresses)
|
||||
.field("dns", &self.dns)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PeerSection {
|
||||
pub public_key: String,
|
||||
pub allowed_ips: Vec<String>,
|
||||
pub endpoint: String,
|
||||
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,
|
||||
@@ -43,99 +295,200 @@ enum Section {
|
||||
Peer,
|
||||
}
|
||||
|
||||
pub fn parse(text: &str) -> anyhow::Result<WireguardConfig> {
|
||||
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 address = 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 raw_line in text.lines() {
|
||||
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 line == "[Interface]" {
|
||||
section = Section::Interface;
|
||||
continue;
|
||||
}
|
||||
if line == "[Peer]" {
|
||||
section = Section::Peer;
|
||||
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 {
|
||||
continue;
|
||||
bail!("line {lineno}: expected `Key = Value`, got {line:?}");
|
||||
};
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
|
||||
match section {
|
||||
Section::Interface => match key {
|
||||
"PrivateKey" => private_key = Some(value.to_owned()),
|
||||
"Address" => address = Some(value.to_owned()),
|
||||
"DNS" => dns = Some(value.to_owned()),
|
||||
"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" => public_key = Some(value.to_owned()),
|
||||
"AllowedIPs" => allowed_ips = Some(value),
|
||||
"Endpoint" => endpoint = Some(value.to_owned()),
|
||||
"PersistentKeepalive" => {
|
||||
let v: u16 = value
|
||||
.parse()
|
||||
.with_context(|| format!("invalid PersistentKeepalive value: {value}"))?;
|
||||
persistent_keepalive = Some(v);
|
||||
}
|
||||
"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 => {}
|
||||
Section::None => {
|
||||
bail!("line {lineno}: key {key:?} appears before any section header")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let interface = InterfaceSection {
|
||||
private_key: private_key.ok_or_else(|| {
|
||||
anyhow::anyhow!("missing required key 'PrivateKey' in [Interface] section")
|
||||
})?,
|
||||
address: address.ok_or_else(|| {
|
||||
anyhow::anyhow!("missing required key 'Address' in [Interface] section")
|
||||
})?,
|
||||
dns,
|
||||
};
|
||||
|
||||
// Peer section must have been entered.
|
||||
if public_key.is_none() && allowed_ips.is_none() && endpoint.is_none() {
|
||||
anyhow::bail!("missing [Peer] section");
|
||||
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
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required key 'PublicKey' in [Peer] section"))?,
|
||||
allowed_ips: allowed_ips
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required key 'AllowedIPs' in [Peer] section"))?
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_owned())
|
||||
.collect(),
|
||||
endpoint: endpoint
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required key 'Endpoint' in [Peer] section"))?,
|
||||
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('#') {
|
||||
if pos == 0 || line.as_bytes()[pos - 1] == b' ' || line.as_bytes()[pos - 1] == b'\t' {
|
||||
return &line[..pos];
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -144,159 +497,301 @@ fn strip_comment(line: &str) -> &str {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PROTONVPN_SAMPLE: &str = r#"[Interface]
|
||||
# Key for nixarr
|
||||
# Bouncing = 1
|
||||
# NetShield = 1
|
||||
# Moderate NAT = off
|
||||
# NAT-PMP (Port Forwarding) = on
|
||||
# VPN Accelerator = on
|
||||
PrivateKey = aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq=
|
||||
Address = 10.2.0.2/32
|
||||
DNS = 10.2.0.1
|
||||
/// 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=";
|
||||
|
||||
[Peer]
|
||||
# UA#44
|
||||
PublicKey = eqjhoqO6K1nLiej026+RkpSTHloVrOHLlMQaB0Tl5GM=
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
Endpoint = 156.146.50.5:51820
|
||||
PersistentKeepalive = 25
|
||||
"#;
|
||||
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,
|
||||
"aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq="
|
||||
);
|
||||
assert_eq!(cfg.interface.address, "10.2.0.2/32");
|
||||
assert_eq!(cfg.interface.dns.as_deref(), Some("10.2.0.1"));
|
||||
assert_eq!(
|
||||
cfg.peer.public_key,
|
||||
"eqjhoqO6K1nLiej026+RkpSTHloVrOHLlMQaB0Tl5GM="
|
||||
);
|
||||
assert_eq!(cfg.peer.allowed_ips, vec!["0.0.0.0/0", "::/0"]);
|
||||
assert_eq!(cfg.peer.endpoint, "156.146.50.5:51820");
|
||||
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() {
|
||||
let text = r#"[Interface]
|
||||
PrivateKey = aaaa
|
||||
Address = 10.0.0.2/32
|
||||
|
||||
[Peer]
|
||||
PublicKey = bbbb
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 1.2.3.4:51820
|
||||
"#;
|
||||
let cfg = parse(text).unwrap();
|
||||
assert!(cfg.interface.dns.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_config_without_keepalive() {
|
||||
let text = r#"[Interface]
|
||||
PrivateKey = aaaa
|
||||
Address = 10.0.0.2/32
|
||||
|
||||
[Peer]
|
||||
PublicKey = bbbb
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 1.2.3.4:51820
|
||||
"#;
|
||||
let cfg = parse(text).unwrap();
|
||||
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 ignores_comments_only_lines() {
|
||||
let text = r#"[Interface]
|
||||
# This is a comment
|
||||
# Another = comment
|
||||
PrivateKey = aaaa
|
||||
Address = 10.0.0.2/32
|
||||
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));
|
||||
}
|
||||
|
||||
[Peer]
|
||||
# Peer comment
|
||||
PublicKey = bbbb
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 1.2.3.4:51820
|
||||
"#;
|
||||
assert!(parse(text).is_ok());
|
||||
/// `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 rejects_missing_private_key() {
|
||||
let text = r#"[Interface]
|
||||
Address = 10.0.0.2/32
|
||||
|
||||
[Peer]
|
||||
PublicKey = bbbb
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 1.2.3.4:51820
|
||||
"#;
|
||||
let err = parse(text).unwrap_err().to_string();
|
||||
assert!(err.contains("PrivateKey"));
|
||||
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 rejects_missing_peer_section() {
|
||||
let text = r#"[Interface]
|
||||
PrivateKey = aaaa
|
||||
Address = 10.0.0.2/32
|
||||
"#;
|
||||
let err = parse(text).unwrap_err().to_string();
|
||||
assert!(err.contains("[Peer]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multiple_allowed_ips() {
|
||||
let text = r#"[Interface]
|
||||
PrivateKey = aaaa
|
||||
Address = 10.0.0.2/32
|
||||
|
||||
[Peer]
|
||||
PublicKey = bbbb
|
||||
AllowedIPs = 0.0.0.0/0, ::/0, 192.168.0.0/16
|
||||
Endpoint = 1.2.3.4:51820
|
||||
"#;
|
||||
let cfg = parse(text).unwrap();
|
||||
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.allowed_ips,
|
||||
vec!["0.0.0.0/0", "::/0", "192.168.0.0/16"]
|
||||
cfg.peer.endpoint.to_string(),
|
||||
"node-ua-44.protonvpn.net:51820"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_redacts_private_key() {
|
||||
let cfg = parse(PROTONVPN_SAMPLE).unwrap();
|
||||
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("aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq="),
|
||||
!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 strips_inline_comments() {
|
||||
let text = r#"[Interface]
|
||||
PrivateKey = aaaa
|
||||
Address = 10.2.0.2/32 # the VPN address
|
||||
DNS = 1.1.1.1
|
||||
fn key_debug_is_redacted() {
|
||||
let key = Key::from_base64(PRIVATE_KEY).unwrap();
|
||||
assert_eq!(format!("{key:?}"), "Key(<redacted>)");
|
||||
}
|
||||
|
||||
[Peer]
|
||||
PublicKey = bbbb
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 1.2.3.4:51820
|
||||
"#;
|
||||
let cfg = parse(text).unwrap();
|
||||
assert_eq!(cfg.interface.address, "10.2.0.2/32");
|
||||
assert_eq!(cfg.interface.dns.as_deref(), Some("1.1.1.1"));
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +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::{InterfaceSection, PeerSection, WireguardConfig, parse};
|
||||
pub use config::{WireguardConfig, parse};
|
||||
pub use tunnel::{INTERFACE, Tunnel};
|
||||
|
||||
@@ -1,32 +1,97 @@
|
||||
//! Network namespace setup for VPN mode.
|
||||
//! Namespace bootstrap for VPN mode.
|
||||
//!
|
||||
//! Unprivileged user+network namespaces (via `unshare(CLONE_NEWUSER | CLONE_NEWNET)`)
|
||||
//! give torad `CAP_NET_ADMIN` and `CAP_NET_RAW` scoped to a new namespace, allowing
|
||||
//! WireGuard interface creation and `SO_BINDTODEVICE` without ever needing root.
|
||||
//! 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 std::fs;
|
||||
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";
|
||||
|
||||
/// Preflight check: confirm the kernel allows unprivileged 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.
|
||||
///
|
||||
/// Reads `/proc/sys/user/max_user_namespaces`. If `0`, unprivileged user namespaces
|
||||
/// are disabled and torad's VPN mode cannot function. If the file is missing entirely,
|
||||
/// treats it as supported (default on most distros).
|
||||
/// 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),
|
||||
Ok(s) => evaluate(&s)?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
tracing::warn!(
|
||||
path = MAX_USER_NAMESPACES_PROC,
|
||||
"sysctl file not found; assuming unprivileged user namespaces are enabled"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e).with_context(|| format!("failed to read {MAX_USER_NAMESPACES_PROC}")),
|
||||
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.
|
||||
@@ -48,6 +113,153 @@ fn evaluate(contents: &str) -> Result<()> {
|
||||
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::*;
|
||||
@@ -73,12 +285,47 @@ mod tests {
|
||||
assert!(evaluate("not-a-number\n").is_err());
|
||||
}
|
||||
|
||||
/// The real entry point must agree with `evaluate` on this host, where the
|
||||
/// sysctl is present and non-zero.
|
||||
/// `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 preflight_succeeds_on_a_supported_host() {
|
||||
if let Ok(contents) = std::fs::read_to_string(MAX_USER_NAMESPACES_PROC) {
|
||||
assert_eq!(preflight().is_ok(), evaluate(&contents).is_ok());
|
||||
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}");
|
||||
}
|
||||
}
|
||||
+17
@@ -55,6 +55,18 @@ in
|
||||
buf
|
||||
grpcurl
|
||||
|
||||
# VPN mode (torad --wireguard-config). Both are runtime dependencies torad
|
||||
# execs, not build inputs.
|
||||
# passt -> `pasta`, which creates the unprivileged user+network
|
||||
# namespace torad re-executes itself into.
|
||||
# wireguard-go -> the userspace WireGuard datapath.
|
||||
# `wireguard-tools` is deliberately absent: torad configures the device by
|
||||
# speaking the UAPI protocol over its socket, so no `wg` binary is needed.
|
||||
# `iproute2` provides the `ip` used for the tunnel address and routes.
|
||||
passt
|
||||
wireguard-go
|
||||
iproute2
|
||||
|
||||
# Protobuf generators
|
||||
protoc-gen-prost-crate
|
||||
protoc-gen-prost-serde
|
||||
@@ -80,6 +92,11 @@ in
|
||||
env.TORAD_PID_FILE = "${config.env.DEVENV_RUNTIME}/torad.pid";
|
||||
|
||||
processes.torad = {
|
||||
# VPN mode is opt-in so the default `devenv up` is unchanged: set
|
||||
# TORAD_WIREGUARD_CONFIG to a wg-quick config path and torad will re-exec
|
||||
# itself into a namespace and route all torrent traffic through the tunnel.
|
||||
# torad reads that variable itself (clap `env`), so nothing is needed here
|
||||
# beyond leaving it unset by default.
|
||||
exec = ''
|
||||
${config.outputs.torad}/bin/torad --socket "$TORAD_SOCKET" --pid-file "$TORAD_PID_FILE"
|
||||
'';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Declares the edition for `rustfmt` when it parses buffers via stdin
|
||||
# (e.g. emacs `rust-format-buffer` / DOOM `format-all`). rustfmt does not
|
||||
# consult Cargo.toml for stdin input, so without this it defaults to 2015
|
||||
# and rejects `async fn` with E0670. Must match edition in Cargo.toml.
|
||||
edition = "2024"
|
||||
Reference in New Issue
Block a user