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:
@@ -1,6 +1,7 @@
|
||||
mod db;
|
||||
mod source;
|
||||
mod torrents;
|
||||
mod vpn;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod config;
|
||||
pub mod namespace;
|
||||
|
||||
pub use config::{InterfaceSection, PeerSection, WireguardConfig, parse};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user