feat(torad): add WireGuard config parser and user-namespace preflight

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 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-08-15 00:17:33 +02:00
parent 7103184c8e
commit e321300f67
4 changed files with 391 additions and 0 deletions
+1
View File
@@ -1,6 +1,7 @@
mod db;
mod source;
mod torrents;
mod vpn;
use std::path::PathBuf;
use std::time::Duration;
+302
View File
@@ -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<String>,
}
/// 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", &"<redacted>")
.field("address", &self.address)
.field("dns", &self.dns)
.finish()
}
}
#[derive(Debug)]
pub struct PeerSection {
pub public_key: String,
pub allowed_ips: Vec<String>,
pub endpoint: String,
pub persistent_keepalive: Option<u16>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Section {
None,
Interface,
Peer,
}
pub fn parse(text: &str) -> anyhow::Result<WireguardConfig> {
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("<redacted>"), "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"));
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod config;
pub mod namespace;
pub use config::{InterfaceSection, PeerSection, WireguardConfig, parse};
+84
View File
@@ -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());
}
}
}