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"] }
|
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 = { 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 }
|
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"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "tls-rustls", "uuid"] }
|
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).
|
/// Network device to bind torrent sockets to (Linux: SO_BINDTODEVICE).
|
||||||
/// When set, all BT peer, tracker, and DHT traffic egresses only through
|
/// When set, all BT peer, tracker, and DHT traffic egresses only through
|
||||||
/// this interface. When unset, librqbit uses default routing.
|
/// 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")]
|
#[arg(long, env = "TORAD_BIND_DEVICE")]
|
||||||
bind_device: Option<String>,
|
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 {
|
fn default_socket_path() -> PathBuf {
|
||||||
@@ -63,27 +73,102 @@ fn default_download_dir() -> PathBuf {
|
|||||||
.join("Downloads")
|
.join("Downloads")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
/// Deliberately not `#[tokio::main]`.
|
||||||
async fn main() -> Result<()> {
|
///
|
||||||
|
/// 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();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
let args = Args::parse();
|
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 tunnel = match &args.wireguard_config {
|
||||||
let download_dir = args.download_dir.unwrap_or_else(default_download_dir);
|
// 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)
|
let pool = db::connect(&args.database_url)
|
||||||
.await
|
.await
|
||||||
.context("failed to connect to Postgres")?;
|
.context("failed to connect to Postgres")?;
|
||||||
info!("connected 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
|
.await
|
||||||
.context("failed to start torrent manager")?;
|
.context("failed to start torrent manager")?;
|
||||||
manager.clone().spawn_poller();
|
manager.clone().spawn_poller();
|
||||||
|
|
||||||
std::fs::write(&pid_file, std::process::id().to_string())
|
// In VPN mode the host-visible pid was already recorded before the
|
||||||
.with_context(|| format!("failed to write pid file at {}", pid_file.display()))?;
|
// 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() {
|
if socket.exists() {
|
||||||
std::fs::remove_file(&socket)
|
std::fs::remove_file(&socket)
|
||||||
@@ -98,10 +183,24 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
info!(socket = %socket.display(), "torad listening");
|
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()
|
Server::builder()
|
||||||
.add_service(health_service)
|
.add_service(health_service)
|
||||||
.add_service(TorrentsServer::new(GrpcTorrents::new(manager)))
|
.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
|
.await
|
||||||
.context("gRPC server error")?;
|
.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};
|
use tokio::signal::unix::{SignalKind, signal};
|
||||||
|
|
||||||
let mut sigint = signal(SignalKind::interrupt()).expect("failed to install SIGINT handler");
|
let mut sigint = signal(SignalKind::interrupt()).expect("failed to install SIGINT handler");
|
||||||
let mut sigterm = signal(SignalKind::terminate()).expect("failed to install SIGTERM 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! {
|
tokio::select! {
|
||||||
_ = sigint.recv() => {},
|
_ = sigint.recv() => {},
|
||||||
_ = sigterm.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};
|
use super::{Resolved, SourceHandler};
|
||||||
|
|
||||||
pub(crate) struct HttpTorrentHandler {
|
pub(crate) struct HttpTorrentHandler {
|
||||||
client: reqwest::Client,
|
clients: std::sync::Arc<super::HttpClients>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HttpTorrentHandler {
|
impl HttpTorrentHandler {
|
||||||
pub(crate) fn new(client: reqwest::Client) -> Self {
|
pub(crate) fn new(clients: std::sync::Arc<super::HttpClients>) -> Self {
|
||||||
Self { client }
|
Self { clients }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ impl SourceHandler for HttpTorrentHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve(&self, source: &str) -> Result<Resolved> {
|
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 {
|
match outcome {
|
||||||
HttpFetchOutcome::Magnet(magnet) => {
|
HttpFetchOutcome::Magnet(magnet) => {
|
||||||
let parsed = Magnet::parse(&magnet)
|
let parsed = Magnet::parse(&magnet)
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ use super::{Resolved, SourceHandler};
|
|||||||
const JACKETT_MARKER: &str = "jackett_apikey=";
|
const JACKETT_MARKER: &str = "jackett_apikey=";
|
||||||
|
|
||||||
pub(crate) struct JackettHandler {
|
pub(crate) struct JackettHandler {
|
||||||
client: reqwest::Client,
|
clients: std::sync::Arc<super::HttpClients>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JackettHandler {
|
impl JackettHandler {
|
||||||
pub(crate) fn new(client: reqwest::Client) -> Self {
|
pub(crate) fn new(clients: std::sync::Arc<super::HttpClients>) -> Self {
|
||||||
Self { client }
|
Self { clients }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ impl SourceHandler for JackettHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve(&self, source: &str) -> Result<Resolved> {
|
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 {
|
match outcome {
|
||||||
HttpFetchOutcome::Magnet(magnet) => {
|
HttpFetchOutcome::Magnet(magnet) => {
|
||||||
let parsed = Magnet::parse(&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
|
/// loopback, and routing loopback through an inherited proxy breaks it. Note
|
||||||
/// this client also serves arbitrary remote `.torrent` URLs via
|
/// this client also serves arbitrary remote `.torrent` URLs via
|
||||||
/// [`http_torrent`], so the setting applies to those too.
|
/// [`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())
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
.no_proxy()
|
.no_proxy();
|
||||||
.build()
|
let builder = match bind_device {
|
||||||
.context("failed to build HTTP client")
|
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 {
|
impl SourceResolver {
|
||||||
pub(crate) fn new() -> Result<Self> {
|
pub(crate) fn new(bind_device: Option<&str>) -> Result<Self> {
|
||||||
let http_client = build_http_client()?;
|
let clients = std::sync::Arc::new(HttpClients::build(bind_device)?);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
handlers: vec![
|
handlers: vec![
|
||||||
Box::new(magnet::MagnetHandler),
|
Box::new(magnet::MagnetHandler),
|
||||||
Box::new(jackett::JackettHandler::new(http_client.clone())),
|
Box::new(jackett::JackettHandler::new(clients.clone())),
|
||||||
Box::new(http_torrent::HttpTorrentHandler::new(http_client)),
|
Box::new(http_torrent::HttpTorrentHandler::new(clients)),
|
||||||
Box::new(file::FileHandler),
|
Box::new(file::FileHandler),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -133,7 +198,7 @@ mod tests {
|
|||||||
let addr = spawn_oneshot_server().await;
|
let addr = spawn_oneshot_server().await;
|
||||||
|
|
||||||
unsafe { std::env::set_var("ALL_PROXY", "http://127.0.0.1:1") };
|
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") };
|
unsafe { std::env::remove_var("ALL_PROXY") };
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
@@ -145,4 +210,66 @@ mod tests {
|
|||||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||||
assert_eq!(response.text().await.unwrap(), "hi");
|
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(
|
let session = Session::new_with_opts(
|
||||||
download_dir.clone(),
|
download_dir.clone(),
|
||||||
SessionOptions {
|
SessionOptions {
|
||||||
bind_device_name: bind_device,
|
bind_device_name: bind_device.clone(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.context("failed to create librqbit session")?;
|
.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);
|
let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||||
Ok(Arc::new(Self {
|
Ok(Arc::new(Self {
|
||||||
pool,
|
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)]
|
#[derive(Debug)]
|
||||||
pub struct WireguardConfig {
|
pub struct WireguardConfig {
|
||||||
@@ -9,33 +244,50 @@ pub struct WireguardConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct InterfaceSection {
|
pub struct InterfaceSection {
|
||||||
pub private_key: String,
|
pub private_key: Key,
|
||||||
pub address: String,
|
/// One or more addresses to assign to the tunnel interface. `wg-quick`
|
||||||
pub dns: Option<String>,
|
/// 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 `{:?}`
|
/// 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
|
/// format on this struct or anything containing it. [`Key`]'s own `Debug` is
|
||||||
/// print it verbatim, and this value flows through error contexts during
|
/// already redacted; this is the second layer, and it also keeps the field
|
||||||
/// interface setup.
|
/// visibly marked as a secret at the point where it is most likely to be read.
|
||||||
impl fmt::Debug for InterfaceSection {
|
impl fmt::Debug for InterfaceSection {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
f.debug_struct("InterfaceSection")
|
f.debug_struct("InterfaceSection")
|
||||||
.field("private_key", &"<redacted>")
|
.field("private_key", &"<redacted>")
|
||||||
.field("address", &self.address)
|
.field("addresses", &self.addresses)
|
||||||
.field("dns", &self.dns)
|
.field("dns", &self.dns)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct PeerSection {
|
pub struct PeerSection {
|
||||||
pub public_key: String,
|
pub public_key: Key,
|
||||||
pub allowed_ips: Vec<String>,
|
pub allowed_ips: Vec<IpCidr>,
|
||||||
pub endpoint: String,
|
pub endpoint: Endpoint,
|
||||||
pub persistent_keepalive: Option<u16>,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum Section {
|
enum Section {
|
||||||
None,
|
None,
|
||||||
@@ -43,99 +295,200 @@ enum Section {
|
|||||||
Peer,
|
Peer,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse(text: &str) -> anyhow::Result<WireguardConfig> {
|
pub fn parse(text: &str) -> Result<WireguardConfig> {
|
||||||
let mut section = Section::None;
|
let mut section = Section::None;
|
||||||
|
let mut seen_interface = false;
|
||||||
|
let mut seen_peer = false;
|
||||||
|
|
||||||
let mut private_key = None;
|
let mut private_key = None;
|
||||||
let mut address = None;
|
let mut addresses = None;
|
||||||
let mut dns = None;
|
let mut dns = None;
|
||||||
let mut public_key = None;
|
let mut public_key = None;
|
||||||
let mut allowed_ips = None;
|
let mut allowed_ips = None;
|
||||||
let mut endpoint = None;
|
let mut endpoint = None;
|
||||||
let mut persistent_keepalive = 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();
|
let line = strip_comment(raw_line).trim();
|
||||||
|
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if line == "[Interface]" {
|
if let Some(rest) = line.strip_prefix('[') {
|
||||||
section = Section::Interface;
|
let name = rest
|
||||||
continue;
|
.strip_suffix(']')
|
||||||
}
|
.with_context(|| format!("line {lineno}: unterminated section header {line:?}"))?;
|
||||||
if line == "[Peer]" {
|
section = match name.trim() {
|
||||||
section = Section::Peer;
|
"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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some((key, value)) = line.split_once('=') else {
|
let Some((key, value)) = line.split_once('=') else {
|
||||||
continue;
|
bail!("line {lineno}: expected `Key = Value`, got {line:?}");
|
||||||
};
|
};
|
||||||
let key = key.trim();
|
let key = key.trim();
|
||||||
let value = value.trim();
|
let value = value.trim();
|
||||||
|
|
||||||
match section {
|
match section {
|
||||||
Section::Interface => match key {
|
Section::Interface => match key {
|
||||||
"PrivateKey" => private_key = Some(value.to_owned()),
|
"PrivateKey" => set_once(
|
||||||
"Address" => address = Some(value.to_owned()),
|
&mut private_key,
|
||||||
"DNS" => dns = Some(value.to_owned()),
|
// 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 {
|
Section::Peer => match key {
|
||||||
"PublicKey" => public_key = Some(value.to_owned()),
|
"PublicKey" => set_once(
|
||||||
"AllowedIPs" => allowed_ips = Some(value),
|
&mut public_key,
|
||||||
"Endpoint" => endpoint = Some(value.to_owned()),
|
Key::from_base64(value)
|
||||||
"PersistentKeepalive" => {
|
.with_context(|| format!("line {lineno}: invalid PublicKey {value:?}"))?,
|
||||||
let v: u16 = value
|
key,
|
||||||
.parse()
|
lineno,
|
||||||
.with_context(|| format!("invalid PersistentKeepalive value: {value}"))?;
|
)?,
|
||||||
persistent_keepalive = Some(v);
|
"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 {
|
if !seen_interface {
|
||||||
private_key: private_key.ok_or_else(|| {
|
bail!("missing [Interface] section");
|
||||||
anyhow::anyhow!("missing required key 'PrivateKey' in [Interface] section")
|
}
|
||||||
})?,
|
if !seen_peer {
|
||||||
address: address.ok_or_else(|| {
|
bail!("missing [Peer] section");
|
||||||
anyhow::anyhow!("missing required key 'Address' in [Interface] section")
|
|
||||||
})?,
|
|
||||||
dns,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Peer section must have been entered.
|
|
||||||
if public_key.is_none() && allowed_ips.is_none() && endpoint.is_none() {
|
|
||||||
anyhow::bail!("missing [Peer] section");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let 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 {
|
let peer = PeerSection {
|
||||||
public_key: public_key
|
public_key: public_key.context("missing required key 'PublicKey' in [Peer] section")?,
|
||||||
.ok_or_else(|| anyhow::anyhow!("missing required key 'PublicKey' in [Peer] section"))?,
|
allowed_ips: allowed_ips.context("missing required key 'AllowedIPs' in [Peer] section")?,
|
||||||
allowed_ips: allowed_ips
|
endpoint: endpoint.context("missing required key 'Endpoint' in [Peer] section")?,
|
||||||
.ok_or_else(|| anyhow::anyhow!("missing required key 'AllowedIPs' in [Peer] section"))?
|
|
||||||
.split(',')
|
|
||||||
.map(|s| s.trim().to_owned())
|
|
||||||
.collect(),
|
|
||||||
endpoint: endpoint
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("missing required key 'Endpoint' in [Peer] section"))?,
|
|
||||||
persistent_keepalive,
|
persistent_keepalive,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(WireguardConfig { interface, peer })
|
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.
|
/// Strip inline comments (# ...) but preserve the rest.
|
||||||
/// Only strips when `#` is preceded by whitespace or is at column 0.
|
/// Only strips when `#` is preceded by whitespace or is at column 0.
|
||||||
fn strip_comment(line: &str) -> &str {
|
fn strip_comment(line: &str) -> &str {
|
||||||
if let Some(pos) = line.find('#') {
|
if let Some(pos) = line.find('#')
|
||||||
if pos == 0 || line.as_bytes()[pos - 1] == b' ' || line.as_bytes()[pos - 1] == b'\t' {
|
&& (pos == 0 || line.as_bytes()[pos - 1] == b' ' || line.as_bytes()[pos - 1] == b'\t')
|
||||||
return &line[..pos];
|
{
|
||||||
}
|
return &line[..pos];
|
||||||
}
|
}
|
||||||
line
|
line
|
||||||
}
|
}
|
||||||
@@ -144,159 +497,301 @@ fn strip_comment(line: &str) -> &str {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
const PROTONVPN_SAMPLE: &str = r#"[Interface]
|
/// Valid 32-byte keys. The previous fixtures (`aaaa`, and a 41-character
|
||||||
# Key for nixarr
|
/// private key) are exactly what parse-time validation now rejects.
|
||||||
# Bouncing = 1
|
const PRIVATE_KEY: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
|
||||||
# NetShield = 1
|
const PUBLIC_KEY: &str = "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=";
|
||||||
# Moderate NAT = off
|
|
||||||
# NAT-PMP (Port Forwarding) = on
|
|
||||||
# VPN Accelerator = on
|
|
||||||
PrivateKey = aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq=
|
|
||||||
Address = 10.2.0.2/32
|
|
||||||
DNS = 10.2.0.1
|
|
||||||
|
|
||||||
[Peer]
|
fn minimal() -> String {
|
||||||
# UA#44
|
format!(
|
||||||
PublicKey = eqjhoqO6K1nLiej026+RkpSTHloVrOHLlMQaB0Tl5GM=
|
"[Interface]\n\
|
||||||
AllowedIPs = 0.0.0.0/0, ::/0
|
PrivateKey = {PRIVATE_KEY}\n\
|
||||||
Endpoint = 156.146.50.5:51820
|
Address = 10.0.0.2/32\n\
|
||||||
PersistentKeepalive = 25
|
\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]
|
#[test]
|
||||||
fn parses_protonvpn_sample() {
|
fn parses_protonvpn_sample() {
|
||||||
let cfg = parse(PROTONVPN_SAMPLE).unwrap();
|
let cfg = parse(&protonvpn_sample()).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(cfg.interface.private_key.as_base64(), PRIVATE_KEY);
|
||||||
cfg.interface.private_key,
|
assert_eq!(cfg.interface.addresses, vec![v4(10, 2, 0, 2, 32)]);
|
||||||
"aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq="
|
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.interface.address, "10.2.0.2/32");
|
assert_eq!(cfg.peer.allowed_ips.len(), 2);
|
||||||
assert_eq!(cfg.interface.dns.as_deref(), Some("10.2.0.1"));
|
assert_eq!(cfg.peer.allowed_ips[0], v4(0, 0, 0, 0, 0));
|
||||||
assert_eq!(
|
assert_eq!(cfg.peer.allowed_ips[1].to_string(), "::/0");
|
||||||
cfg.peer.public_key,
|
assert_eq!(cfg.peer.endpoint.host, "156.146.50.5");
|
||||||
"eqjhoqO6K1nLiej026+RkpSTHloVrOHLlMQaB0Tl5GM="
|
assert_eq!(cfg.peer.endpoint.port, 51820);
|
||||||
);
|
|
||||||
assert_eq!(cfg.peer.allowed_ips, vec!["0.0.0.0/0", "::/0"]);
|
|
||||||
assert_eq!(cfg.peer.endpoint, "156.146.50.5:51820");
|
|
||||||
assert_eq!(cfg.peer.persistent_keepalive, Some(25));
|
assert_eq!(cfg.peer.persistent_keepalive, Some(25));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_config_without_dns() {
|
fn parses_config_without_dns_or_keepalive() {
|
||||||
let text = r#"[Interface]
|
let cfg = parse(&minimal()).unwrap();
|
||||||
PrivateKey = aaaa
|
assert!(cfg.interface.dns.is_empty());
|
||||||
Address = 10.0.0.2/32
|
|
||||||
|
|
||||||
[Peer]
|
|
||||||
PublicKey = bbbb
|
|
||||||
AllowedIPs = 0.0.0.0/0
|
|
||||||
Endpoint = 1.2.3.4:51820
|
|
||||||
"#;
|
|
||||||
let cfg = parse(text).unwrap();
|
|
||||||
assert!(cfg.interface.dns.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_config_without_keepalive() {
|
|
||||||
let text = r#"[Interface]
|
|
||||||
PrivateKey = aaaa
|
|
||||||
Address = 10.0.0.2/32
|
|
||||||
|
|
||||||
[Peer]
|
|
||||||
PublicKey = bbbb
|
|
||||||
AllowedIPs = 0.0.0.0/0
|
|
||||||
Endpoint = 1.2.3.4:51820
|
|
||||||
"#;
|
|
||||||
let cfg = parse(text).unwrap();
|
|
||||||
assert!(cfg.peer.persistent_keepalive.is_none());
|
assert!(cfg.peer.persistent_keepalive.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ignores_comments_only_lines() {
|
fn parses_multiple_addresses_and_allowed_ips() {
|
||||||
let text = r#"[Interface]
|
let text = minimal()
|
||||||
# This is a comment
|
.replace(
|
||||||
# Another = comment
|
"Address = 10.0.0.2/32",
|
||||||
PrivateKey = aaaa
|
"Address = 10.0.0.2/32, fd00::2/128",
|
||||||
Address = 10.0.0.2/32
|
)
|
||||||
|
.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]
|
/// `wg` treats a prefixless `AllowedIPs` entry as a host route; so do we.
|
||||||
# Peer comment
|
#[test]
|
||||||
PublicKey = bbbb
|
fn bare_address_takes_the_full_prefix() {
|
||||||
AllowedIPs = 0.0.0.0/0
|
let text = minimal().replace("AllowedIPs = 0.0.0.0/0", "AllowedIPs = 8.8.8.8, fd00::1");
|
||||||
Endpoint = 1.2.3.4:51820
|
let cfg = parse(&text).unwrap();
|
||||||
"#;
|
assert_eq!(cfg.peer.allowed_ips[0], v4(8, 8, 8, 8, 32));
|
||||||
assert!(parse(text).is_ok());
|
assert_eq!(cfg.peer.allowed_ips[1].prefix_len, 128);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_missing_private_key() {
|
fn parses_bracketed_ipv6_endpoint() {
|
||||||
let text = r#"[Interface]
|
let text = minimal().replace("Endpoint = 1.2.3.4:51820", "Endpoint = [2001:db8::1]:51820");
|
||||||
Address = 10.0.0.2/32
|
let cfg = parse(&text).unwrap();
|
||||||
|
assert_eq!(cfg.peer.endpoint.host, "2001:db8::1");
|
||||||
[Peer]
|
assert_eq!(cfg.peer.endpoint.port, 51820);
|
||||||
PublicKey = bbbb
|
assert_eq!(cfg.peer.endpoint.to_string(), "[2001:db8::1]:51820");
|
||||||
AllowedIPs = 0.0.0.0/0
|
|
||||||
Endpoint = 1.2.3.4:51820
|
|
||||||
"#;
|
|
||||||
let err = parse(text).unwrap_err().to_string();
|
|
||||||
assert!(err.contains("PrivateKey"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_missing_peer_section() {
|
fn parses_hostname_endpoint() {
|
||||||
let text = r#"[Interface]
|
let text = minimal().replace(
|
||||||
PrivateKey = aaaa
|
"Endpoint = 1.2.3.4:51820",
|
||||||
Address = 10.0.0.2/32
|
"Endpoint = node-ua-44.protonvpn.net:51820",
|
||||||
"#;
|
);
|
||||||
let err = parse(text).unwrap_err().to_string();
|
let cfg = parse(&text).unwrap();
|
||||||
assert!(err.contains("[Peer]"));
|
assert_eq!(cfg.peer.endpoint.host, "node-ua-44.protonvpn.net");
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_multiple_allowed_ips() {
|
|
||||||
let text = r#"[Interface]
|
|
||||||
PrivateKey = aaaa
|
|
||||||
Address = 10.0.0.2/32
|
|
||||||
|
|
||||||
[Peer]
|
|
||||||
PublicKey = bbbb
|
|
||||||
AllowedIPs = 0.0.0.0/0, ::/0, 192.168.0.0/16
|
|
||||||
Endpoint = 1.2.3.4:51820
|
|
||||||
"#;
|
|
||||||
let cfg = parse(text).unwrap();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.peer.allowed_ips,
|
cfg.peer.endpoint.to_string(),
|
||||||
vec!["0.0.0.0/0", "::/0", "192.168.0.0/16"]
|
"node-ua-44.protonvpn.net:51820"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn debug_redacts_private_key() {
|
fn ignores_comment_lines_and_strips_inline_comments() {
|
||||||
let cfg = parse(PROTONVPN_SAMPLE).unwrap();
|
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:?}");
|
let rendered = format!("{cfg:?}");
|
||||||
assert!(
|
assert!(
|
||||||
!rendered.contains("aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq="),
|
!rendered.contains(PRIVATE_KEY),
|
||||||
"private key leaked into Debug output: {rendered}"
|
"private key leaked into Debug output: {rendered}"
|
||||||
);
|
);
|
||||||
assert!(rendered.contains("<redacted>"), "got: {rendered}");
|
assert!(rendered.contains("<redacted>"), "got: {rendered}");
|
||||||
// The rest of the config must still be inspectable.
|
// 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}");
|
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]
|
#[test]
|
||||||
fn strips_inline_comments() {
|
fn key_debug_is_redacted() {
|
||||||
let text = r#"[Interface]
|
let key = Key::from_base64(PRIVATE_KEY).unwrap();
|
||||||
PrivateKey = aaaa
|
assert_eq!(format!("{key:?}"), "Key(<redacted>)");
|
||||||
Address = 10.2.0.2/32 # the VPN address
|
}
|
||||||
DNS = 1.1.1.1
|
|
||||||
|
|
||||||
[Peer]
|
/// Wave 3 sends keys to the `wireguard-go` UAPI socket as hex.
|
||||||
PublicKey = bbbb
|
#[test]
|
||||||
AllowedIPs = 0.0.0.0/0
|
fn key_round_trips_base64_to_hex() {
|
||||||
Endpoint = 1.2.3.4:51820
|
let key = Key::from_base64(PRIVATE_KEY).unwrap();
|
||||||
"#;
|
// PRIVATE_KEY is base64 of the bytes 0x00..=0x1f.
|
||||||
let cfg = parse(text).unwrap();
|
let expected: String = (0u8..32).map(|b| format!("{b:02x}")).collect();
|
||||||
assert_eq!(cfg.interface.address, "10.2.0.2/32");
|
assert_eq!(key.to_hex(), expected);
|
||||||
assert_eq!(cfg.interface.dns.as_deref(), Some("1.1.1.1"));
|
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 config;
|
||||||
pub mod namespace;
|
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)`)
|
//! torad must run inside its own network namespace so that a WireGuard
|
||||||
//! give torad `CAP_NET_ADMIN` and `CAP_NET_RAW` scoped to a new namespace, allowing
|
//! interface can be created and every socket confined to it, without root and
|
||||||
//! WireGuard interface creation and `SO_BINDTODEVICE` without ever needing root.
|
//! 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 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.
|
/// 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";
|
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
|
/// Note that plain `127.0.0.1` also reaches the *host's* loopback from inside
|
||||||
/// are disabled and torad's VPN mode cannot function. If the file is missing entirely,
|
/// the namespace — pasta splices namespace loopback to host loopback by
|
||||||
/// treats it as supported (default on most distros).
|
/// 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<()> {
|
pub fn preflight() -> Result<()> {
|
||||||
match fs::read_to_string(MAX_USER_NAMESPACES_PROC) {
|
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 => {
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
path = MAX_USER_NAMESPACES_PROC,
|
path = MAX_USER_NAMESPACES_PROC,
|
||||||
"sysctl file not found; assuming unprivileged user namespaces are enabled"
|
"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.
|
/// Decides whether the sysctl's contents permit unprivileged user namespaces.
|
||||||
@@ -48,6 +113,153 @@ fn evaluate(contents: &str) -> Result<()> {
|
|||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -73,12 +285,47 @@ mod tests {
|
|||||||
assert!(evaluate("not-a-number\n").is_err());
|
assert!(evaluate("not-a-number\n").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The real entry point must agree with `evaluate` on this host, where the
|
/// `evaluate` must agree with the sysctl on this host, where it is present
|
||||||
/// sysctl is present and non-zero.
|
/// and non-zero. `preflight` itself additionally requires pasta and
|
||||||
|
/// wireguard-go on PATH, which is asserted separately below.
|
||||||
#[test]
|
#[test]
|
||||||
fn preflight_succeeds_on_a_supported_host() {
|
fn evaluate_agrees_with_the_host_sysctl() {
|
||||||
if let Ok(contents) = std::fs::read_to_string(MAX_USER_NAMESPACES_PROC) {
|
if let Ok(contents) = fs::read_to_string(MAX_USER_NAMESPACES_PROC) {
|
||||||
assert_eq!(preflight().is_ok(), evaluate(&contents).is_ok());
|
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
|
buf
|
||||||
grpcurl
|
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
|
# Protobuf generators
|
||||||
protoc-gen-prost-crate
|
protoc-gen-prost-crate
|
||||||
protoc-gen-prost-serde
|
protoc-gen-prost-serde
|
||||||
@@ -80,6 +92,11 @@ in
|
|||||||
env.TORAD_PID_FILE = "${config.env.DEVENV_RUNTIME}/torad.pid";
|
env.TORAD_PID_FILE = "${config.env.DEVENV_RUNTIME}/torad.pid";
|
||||||
|
|
||||||
processes.torad = {
|
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 = ''
|
exec = ''
|
||||||
${config.outputs.torad}/bin/torad --socket "$TORAD_SOCKET" --pid-file "$TORAD_PID_FILE"
|
${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