From e321300f677d0b493d9d745127b762e5b01efe61 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 15 Aug 2026 00:17:33 +0200 Subject: [PATCH] feat(torad): add WireGuard config parser and user-namespace preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for VPN mode; nothing calls it yet, so the module is dead code until the namespace bootstrap lands. vpn::config parses the ProtonVPN-style WireGuard INI into typed sections. `InterfaceSection` gets a hand-written Debug that redacts the private key — the derived one would print it verbatim, and this struct is about to start flowing through error contexts during interface setup. vpn::namespace::preflight checks user.max_user_namespaces and fails with the sysctl name and its remediation when unprivileged user namespaces are disabled. The decision logic is split into `evaluate` so the tests exercise the real code path rather than a copy of it — the /proc file itself cannot be mocked. Co-Authored-By: Claude Opus 5 --- crates/torad/src/main.rs | 1 + crates/torad/src/vpn/config.rs | 302 ++++++++++++++++++++++++++++++ crates/torad/src/vpn/mod.rs | 4 + crates/torad/src/vpn/namespace.rs | 84 +++++++++ 4 files changed, 391 insertions(+) create mode 100644 crates/torad/src/vpn/config.rs create mode 100644 crates/torad/src/vpn/mod.rs create mode 100644 crates/torad/src/vpn/namespace.rs diff --git a/crates/torad/src/main.rs b/crates/torad/src/main.rs index ac446c5..f2687d9 100644 --- a/crates/torad/src/main.rs +++ b/crates/torad/src/main.rs @@ -1,6 +1,7 @@ mod db; mod source; mod torrents; +mod vpn; use std::path::PathBuf; use std::time::Duration; diff --git a/crates/torad/src/vpn/config.rs b/crates/torad/src/vpn/config.rs new file mode 100644 index 0000000..4f3736a --- /dev/null +++ b/crates/torad/src/vpn/config.rs @@ -0,0 +1,302 @@ +use std::fmt; + +use anyhow::Context; + +#[derive(Debug)] +pub struct WireguardConfig { + pub interface: InterfaceSection, + pub peer: PeerSection, +} + +pub struct InterfaceSection { + pub private_key: String, + pub address: String, + pub dns: Option, +} + +/// Hand-written so the private key can never reach a log through a `{:?}` +/// format on this struct or anything containing it. The derived impl would +/// print it verbatim, and this value flows through error contexts during +/// interface setup. +impl fmt::Debug for InterfaceSection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("InterfaceSection") + .field("private_key", &"") + .field("address", &self.address) + .field("dns", &self.dns) + .finish() + } +} + +#[derive(Debug)] +pub struct PeerSection { + pub public_key: String, + pub allowed_ips: Vec, + pub endpoint: String, + pub persistent_keepalive: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Section { + None, + Interface, + Peer, +} + +pub fn parse(text: &str) -> anyhow::Result { + let mut section = Section::None; + let mut private_key = None; + let mut address = None; + let mut dns = None; + let mut public_key = None; + let mut allowed_ips = None; + let mut endpoint = None; + let mut persistent_keepalive = None; + + for raw_line in text.lines() { + let line = strip_comment(raw_line).trim(); + + if line.is_empty() { + continue; + } + + if line == "[Interface]" { + section = Section::Interface; + continue; + } + if line == "[Peer]" { + section = Section::Peer; + continue; + } + + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + let value = value.trim(); + + match section { + Section::Interface => match key { + "PrivateKey" => private_key = Some(value.to_owned()), + "Address" => address = Some(value.to_owned()), + "DNS" => dns = Some(value.to_owned()), + _ => {} + }, + Section::Peer => match key { + "PublicKey" => public_key = Some(value.to_owned()), + "AllowedIPs" => allowed_ips = Some(value), + "Endpoint" => endpoint = Some(value.to_owned()), + "PersistentKeepalive" => { + let v: u16 = value + .parse() + .with_context(|| format!("invalid PersistentKeepalive value: {value}"))?; + persistent_keepalive = Some(v); + } + _ => {} + }, + Section::None => {} + } + } + + let interface = InterfaceSection { + private_key: private_key.ok_or_else(|| { + anyhow::anyhow!("missing required key 'PrivateKey' in [Interface] section") + })?, + address: address.ok_or_else(|| { + anyhow::anyhow!("missing required key 'Address' in [Interface] section") + })?, + dns, + }; + + // Peer section must have been entered. + if public_key.is_none() && allowed_ips.is_none() && endpoint.is_none() { + anyhow::bail!("missing [Peer] section"); + } + + let peer = PeerSection { + public_key: public_key + .ok_or_else(|| anyhow::anyhow!("missing required key 'PublicKey' in [Peer] section"))?, + allowed_ips: allowed_ips + .ok_or_else(|| anyhow::anyhow!("missing required key 'AllowedIPs' in [Peer] section"))? + .split(',') + .map(|s| s.trim().to_owned()) + .collect(), + endpoint: endpoint + .ok_or_else(|| anyhow::anyhow!("missing required key 'Endpoint' in [Peer] section"))?, + persistent_keepalive, + }; + + Ok(WireguardConfig { interface, peer }) +} + +/// Strip inline comments (# ...) but preserve the rest. +/// Only strips when `#` is preceded by whitespace or is at column 0. +fn strip_comment(line: &str) -> &str { + if let Some(pos) = line.find('#') { + if pos == 0 || line.as_bytes()[pos - 1] == b' ' || line.as_bytes()[pos - 1] == b'\t' { + return &line[..pos]; + } + } + line +} + +#[cfg(test)] +mod tests { + use super::*; + + const PROTONVPN_SAMPLE: &str = r#"[Interface] +# Key for nixarr +# Bouncing = 1 +# NetShield = 1 +# Moderate NAT = off +# NAT-PMP (Port Forwarding) = on +# VPN Accelerator = on +PrivateKey = aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq= +Address = 10.2.0.2/32 +DNS = 10.2.0.1 + +[Peer] +# UA#44 +PublicKey = eqjhoqO6K1nLiej026+RkpSTHloVrOHLlMQaB0Tl5GM= +AllowedIPs = 0.0.0.0/0, ::/0 +Endpoint = 156.146.50.5:51820 +PersistentKeepalive = 25 +"#; + + #[test] + fn parses_protonvpn_sample() { + let cfg = parse(PROTONVPN_SAMPLE).unwrap(); + assert_eq!( + cfg.interface.private_key, + "aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq=" + ); + assert_eq!(cfg.interface.address, "10.2.0.2/32"); + assert_eq!(cfg.interface.dns.as_deref(), Some("10.2.0.1")); + assert_eq!( + cfg.peer.public_key, + "eqjhoqO6K1nLiej026+RkpSTHloVrOHLlMQaB0Tl5GM=" + ); + assert_eq!(cfg.peer.allowed_ips, vec!["0.0.0.0/0", "::/0"]); + assert_eq!(cfg.peer.endpoint, "156.146.50.5:51820"); + assert_eq!(cfg.peer.persistent_keepalive, Some(25)); + } + + #[test] + fn parses_config_without_dns() { + let text = r#"[Interface] +PrivateKey = aaaa +Address = 10.0.0.2/32 + +[Peer] +PublicKey = bbbb +AllowedIPs = 0.0.0.0/0 +Endpoint = 1.2.3.4:51820 +"#; + let cfg = parse(text).unwrap(); + assert!(cfg.interface.dns.is_none()); + } + + #[test] + fn parses_config_without_keepalive() { + let text = r#"[Interface] +PrivateKey = aaaa +Address = 10.0.0.2/32 + +[Peer] +PublicKey = bbbb +AllowedIPs = 0.0.0.0/0 +Endpoint = 1.2.3.4:51820 +"#; + let cfg = parse(text).unwrap(); + assert!(cfg.peer.persistent_keepalive.is_none()); + } + + #[test] + fn ignores_comments_only_lines() { + let text = r#"[Interface] +# This is a comment +# Another = comment +PrivateKey = aaaa +Address = 10.0.0.2/32 + +[Peer] +# Peer comment +PublicKey = bbbb +AllowedIPs = 0.0.0.0/0 +Endpoint = 1.2.3.4:51820 +"#; + assert!(parse(text).is_ok()); + } + + #[test] + fn rejects_missing_private_key() { + let text = r#"[Interface] +Address = 10.0.0.2/32 + +[Peer] +PublicKey = bbbb +AllowedIPs = 0.0.0.0/0 +Endpoint = 1.2.3.4:51820 +"#; + let err = parse(text).unwrap_err().to_string(); + assert!(err.contains("PrivateKey")); + } + + #[test] + fn rejects_missing_peer_section() { + let text = r#"[Interface] +PrivateKey = aaaa +Address = 10.0.0.2/32 +"#; + let err = parse(text).unwrap_err().to_string(); + assert!(err.contains("[Peer]")); + } + + #[test] + fn parses_multiple_allowed_ips() { + let text = r#"[Interface] +PrivateKey = aaaa +Address = 10.0.0.2/32 + +[Peer] +PublicKey = bbbb +AllowedIPs = 0.0.0.0/0, ::/0, 192.168.0.0/16 +Endpoint = 1.2.3.4:51820 +"#; + let cfg = parse(text).unwrap(); + assert_eq!( + cfg.peer.allowed_ips, + vec!["0.0.0.0/0", "::/0", "192.168.0.0/16"] + ); + } + + #[test] + fn debug_redacts_private_key() { + let cfg = parse(PROTONVPN_SAMPLE).unwrap(); + let rendered = format!("{cfg:?}"); + assert!( + !rendered.contains("aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq="), + "private key leaked into Debug output: {rendered}" + ); + assert!(rendered.contains(""), "got: {rendered}"); + // The rest of the config must still be inspectable. + assert!(rendered.contains("10.2.0.2/32"), "got: {rendered}"); + } + + #[test] + fn strips_inline_comments() { + let text = r#"[Interface] +PrivateKey = aaaa +Address = 10.2.0.2/32 # the VPN address +DNS = 1.1.1.1 + +[Peer] +PublicKey = bbbb +AllowedIPs = 0.0.0.0/0 +Endpoint = 1.2.3.4:51820 +"#; + let cfg = parse(text).unwrap(); + assert_eq!(cfg.interface.address, "10.2.0.2/32"); + assert_eq!(cfg.interface.dns.as_deref(), Some("1.1.1.1")); + } +} diff --git a/crates/torad/src/vpn/mod.rs b/crates/torad/src/vpn/mod.rs new file mode 100644 index 0000000..5b99a70 --- /dev/null +++ b/crates/torad/src/vpn/mod.rs @@ -0,0 +1,4 @@ +pub mod config; +pub mod namespace; + +pub use config::{InterfaceSection, PeerSection, WireguardConfig, parse}; diff --git a/crates/torad/src/vpn/namespace.rs b/crates/torad/src/vpn/namespace.rs new file mode 100644 index 0000000..0182b0f --- /dev/null +++ b/crates/torad/src/vpn/namespace.rs @@ -0,0 +1,84 @@ +//! Network namespace setup for VPN mode. +//! +//! Unprivileged user+network namespaces (via `unshare(CLONE_NEWUSER | CLONE_NEWNET)`) +//! give torad `CAP_NET_ADMIN` and `CAP_NET_RAW` scoped to a new namespace, allowing +//! WireGuard interface creation and `SO_BINDTODEVICE` without ever needing root. + +use anyhow::{Context, Result, bail}; +use std::fs; + +/// Path to the kernel sysctl controlling how many user namespaces may be created. +const MAX_USER_NAMESPACES_PROC: &str = "/proc/sys/user/max_user_namespaces"; + +/// Preflight check: confirm the kernel allows unprivileged user namespaces. +/// +/// Reads `/proc/sys/user/max_user_namespaces`. If `0`, unprivileged user namespaces +/// are disabled and torad's VPN mode cannot function. If the file is missing entirely, +/// treats it as supported (default on most distros). +pub fn preflight() -> Result<()> { + match fs::read_to_string(MAX_USER_NAMESPACES_PROC) { + Ok(s) => evaluate(&s), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + tracing::warn!( + path = MAX_USER_NAMESPACES_PROC, + "sysctl file not found; assuming unprivileged user namespaces are enabled" + ); + Ok(()) + } + Err(e) => Err(e).with_context(|| format!("failed to read {MAX_USER_NAMESPACES_PROC}")), + } +} + +/// Decides whether the sysctl's contents permit unprivileged user namespaces. +/// +/// Split out from [`preflight`] so tests exercise the real logic rather than a +/// copy of it — the file itself can't be mocked. +fn evaluate(contents: &str) -> Result<()> { + let trimmed = contents.trim(); + let count: u64 = trimmed.parse().with_context(|| { + format!("failed to parse {MAX_USER_NAMESPACES_PROC} as number: {trimmed:?}") + })?; + if count == 0 { + bail!( + "unprivileged user namespaces are disabled ({MAX_USER_NAMESPACES_PROC} = 0). \ + VPN mode requires them. Remediation: \ + `sudo sysctl -w user.max_user_namespaces=125445`, then re-run." + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_positive_count() { + assert!(evaluate("125445\n").is_ok()); + assert!(evaluate("125445").is_ok()); + assert!(evaluate("1\n").is_ok()); + } + + #[test] + fn rejects_zero_count() { + let err = evaluate("0\n").unwrap_err().to_string(); + assert!( + err.contains("user.max_user_namespaces"), + "error should name the sysctl and its remediation, got: {err}" + ); + } + + #[test] + fn rejects_non_numeric() { + assert!(evaluate("not-a-number\n").is_err()); + } + + /// The real entry point must agree with `evaluate` on this host, where the + /// sysctl is present and non-zero. + #[test] + fn preflight_succeeds_on_a_supported_host() { + if let Ok(contents) = std::fs::read_to_string(MAX_USER_NAMESPACES_PROC) { + assert_eq!(preflight().is_ok(), evaluate(&contents).is_ok()); + } + } +}