Compare commits

...

3 Commits

Author SHA1 Message Date
Alexander b489ff7135 feat(torad): route source fetches through the tunnel and package VPN mode
Three things, all tail end of VPN mode.

Source fetches are HTTP, not BitTorrent, so librqbit's SO_BINDTODEVICE
never covered them. SourceResolver now holds two clients and picks one
per URL: remote indexer and .torrent fetches go through a client bound
to wg0, so a future route change cannot quietly send them around the
tunnel; loopback URLs keep the unbound client, because pasta splices the
namespace's loopback to the host's and that is how a self-hosted Jackett
stays reachable. That traffic never leaves the machine, so keeping it off
the tunnel is deliberate.

This replaces the planned request-time URL rewriting, which turned out to
be unnecessary: measured, pasta reaches host services on 127.0.0.1 from
inside the namespace even when they bind after the namespace starts, so
neither Jackett URLs nor a loopback DATABASE_URL need touching.

Second, a defect the packaging work surfaced: killing the pid in the pid
file killed pasta but left torad running, reparented to init, with a dead
tap interface -- the daemon outliving the only documented way to stop it.
The re-executed process now sets PR_SET_PDEATHSIG so it dies with pasta.

Third, packaging: passt, wireguard-go and iproute2 in both devenv files,
and the module documentation states the Linux-only, leech-only and
pinned-endpoint limitations along with what happens when the tunnel drops.
wireguard-tools is deliberately absent -- the device is configured over
UAPI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:09:03 +02:00
Alexander 62db1e2a9e feat(torad): bring up a WireGuard tunnel in user space from a config file
`--wireguard-config` is now the only thing an operator supplies. torad
re-executes itself inside a private user+network namespace, brings up a
userspace WireGuard datapath, and binds every torrent socket to it. No
root, no wg-quick, no pre-created interface, no kernel module, no one-time
host setup.

The mechanism differs from what was planned, because the plan's
assumptions did not survive being checked against the actual binaries:

- pasta already creates an unprivileged user+net namespace with full
  capabilities and a configured tap interface, so the hand-rolled
  unshare(CLONE_NEWUSER|CLONE_NEWNET) with uid_map, gid_map and
  setgroups=deny is gone. torad re-execs itself under pasta instead.

- WG_SOCKET_DIRECTORY does not exist; wireguard-go's socket directory is
  a build-time linker variable. WG_UAPI_FD does not avoid it either,
  because UAPIListen inotify-watches that path even when handed a
  pre-bound socket. Since /var/run is a symlink to /run, a tmpfs over
  /var inside a private mount namespace makes /var/run/wireguard
  writable while leaving the real /run visible -- which matters, because
  torad's gRPC socket lives under /run/user and the aggregator connects
  to it from the host.

- The peer endpoint needs a host route via pasta's gateway before the
  default route moves to wg0, or WireGuard's own handshake is routed
  into the tunnel it is trying to establish.

The device is configured by speaking WireGuard's UAPI protocol over its
unix socket, so wireguard-tools is not a dependency either.

If the datapath exits, torad shuts down instead of routing around it: a
live torad with a dead tunnel is the failure mode that leaks.

pasta also creates a PID namespace, so the pid file is written on the
host before the re-exec and not again inside -- otherwise it would
record PID 1, which names init when read from the host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:01:33 +02:00
Alexander 6e5d27497e feat(torad): validate WireGuard config at parse time
The parser stored every value as a raw String and only ever rejected a
missing key. Those values are about to be handed to `ip addr add`, route
setup, and the wireguard-go UAPI socket in Wave 3, none of which give a
diagnostic worth reading: a mistyped address comes back as a bare EINVAL
or a UAPI errno with no hint of which config line caused it.

Parse into typed values instead — Key, IpCidr, Endpoint — and report
failures with a line number and the offending value.

Key validation was not in the original scope, but the fixture in this
file's own tests was a 41-character "private key" that parsed clean:
long enough to look right, short enough to fail at the UAPI socket three
waves later. Decoding here also yields the hex encoding that UAPI
expects, so it replaces work rather than adding it. Errors on the
private key deliberately describe only the shape of the problem, never
the input, and there is a test that fails if the value leaks.

Address and DNS become lists because wg-quick allows comma-separated
values and dual-stack providers emit them; parsing only the first would
have silently dropped the IPv6 address.

Also reject what used to be accepted in silence: a second [Peer] or
[Interface] section, a repeated key within a section, an unknown
section, and a key appearing before any section header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:33:41 +02:00
13 changed files with 1885 additions and 231 deletions
+1 -1
View File
@@ -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
View File
@@ -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");
},
}
}
+4 -4
View File
@@ -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)
+4 -4
View File
@@ -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
View File
@@ -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
));
}
}
+5 -2
View File
@@ -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
View File
@@ -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));
}
}
+85 -1
View File
@@ -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};
+264 -17
View File
@@ -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());
}
}
+313
View File
@@ -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])]);
}
}
+249
View File
@@ -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
View File
@@ -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"
'';
+5
View 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"