feat(torad): validate WireGuard config at parse time
The parser stored every value as a raw String and only ever rejected a missing key. Those values are about to be handed to `ip addr add`, route setup, and the wireguard-go UAPI socket in Wave 3, none of which give a diagnostic worth reading: a mistyped address comes back as a bare EINVAL or a UAPI errno with no hint of which config line caused it. Parse into typed values instead — Key, IpCidr, Endpoint — and report failures with a line number and the offending value. Key validation was not in the original scope, but the fixture in this file's own tests was a 41-character "private key" that parsed clean: long enough to look right, short enough to fail at the UAPI socket three waves later. Decoding here also yields the hex encoding that UAPI expects, so it replaces work rather than adding it. Errors on the private key deliberately describe only the shape of the problem, never the input, and there is a test that fails if the value leaks. Address and DNS become lists because wg-quick allows comma-separated values and dual-stack providers emit them; parsing only the first would have silently dropped the IPv6 address. Also reject what used to be accepted in silence: a second [Peer] or [Interface] section, a repeated key within a section, an unknown section, and a key appearing before any section header. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+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)]
|
||||
pub struct WireguardConfig {
|
||||
@@ -9,33 +244,50 @@ pub struct WireguardConfig {
|
||||
}
|
||||
|
||||
pub struct InterfaceSection {
|
||||
pub private_key: String,
|
||||
pub address: String,
|
||||
pub dns: Option<String>,
|
||||
pub private_key: Key,
|
||||
/// One or more addresses to assign to the tunnel interface. `wg-quick`
|
||||
/// allows a comma-separated list, and dual-stack providers use it.
|
||||
pub addresses: Vec<IpCidr>,
|
||||
/// Resolvers to write into the namespace's `resolv.conf`. Empty when the
|
||||
/// config has no `DNS` line.
|
||||
pub dns: Vec<IpAddr>,
|
||||
}
|
||||
|
||||
/// Hand-written so the private key can never reach a log through a `{:?}`
|
||||
/// format on this struct or anything containing it. The derived impl would
|
||||
/// print it verbatim, and this value flows through error contexts during
|
||||
/// interface setup.
|
||||
/// format on this struct or anything containing it. [`Key`]'s own `Debug` is
|
||||
/// already redacted; this is the second layer, and it also keeps the field
|
||||
/// visibly marked as a secret at the point where it is most likely to be read.
|
||||
impl fmt::Debug for InterfaceSection {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("InterfaceSection")
|
||||
.field("private_key", &"<redacted>")
|
||||
.field("address", &self.address)
|
||||
.field("addresses", &self.addresses)
|
||||
.field("dns", &self.dns)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PeerSection {
|
||||
pub public_key: String,
|
||||
pub allowed_ips: Vec<String>,
|
||||
pub endpoint: String,
|
||||
pub public_key: Key,
|
||||
pub allowed_ips: Vec<IpCidr>,
|
||||
pub endpoint: Endpoint,
|
||||
pub persistent_keepalive: Option<u16>,
|
||||
}
|
||||
|
||||
/// Hand-written only to undo [`Key`]'s blanket redaction for the public key,
|
||||
/// which is not a secret and is the first thing worth seeing when a handshake
|
||||
/// fails against the wrong peer.
|
||||
impl fmt::Debug for PeerSection {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("PeerSection")
|
||||
.field("public_key", &self.public_key.as_base64())
|
||||
.field("allowed_ips", &self.allowed_ips)
|
||||
.field("endpoint", &self.endpoint)
|
||||
.field("persistent_keepalive", &self.persistent_keepalive)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Section {
|
||||
None,
|
||||
@@ -43,99 +295,200 @@ enum Section {
|
||||
Peer,
|
||||
}
|
||||
|
||||
pub fn parse(text: &str) -> anyhow::Result<WireguardConfig> {
|
||||
pub fn parse(text: &str) -> Result<WireguardConfig> {
|
||||
let mut section = Section::None;
|
||||
let mut seen_interface = false;
|
||||
let mut seen_peer = false;
|
||||
|
||||
let mut private_key = None;
|
||||
let mut address = None;
|
||||
let mut addresses = None;
|
||||
let mut dns = None;
|
||||
let mut public_key = None;
|
||||
let mut allowed_ips = None;
|
||||
let mut endpoint = None;
|
||||
let mut persistent_keepalive = None;
|
||||
|
||||
for raw_line in text.lines() {
|
||||
for (index, raw_line) in text.lines().enumerate() {
|
||||
let lineno = index + 1;
|
||||
let line = strip_comment(raw_line).trim();
|
||||
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if line == "[Interface]" {
|
||||
section = Section::Interface;
|
||||
continue;
|
||||
}
|
||||
if line == "[Peer]" {
|
||||
section = Section::Peer;
|
||||
if let Some(rest) = line.strip_prefix('[') {
|
||||
let name = rest
|
||||
.strip_suffix(']')
|
||||
.with_context(|| format!("line {lineno}: unterminated section header {line:?}"))?;
|
||||
section = match name.trim() {
|
||||
"Interface" => {
|
||||
if seen_interface {
|
||||
bail!("line {lineno}: duplicate [Interface] section");
|
||||
}
|
||||
seen_interface = true;
|
||||
Section::Interface
|
||||
}
|
||||
"Peer" => {
|
||||
if seen_peer {
|
||||
// A second [Peer] previously overwrote the first in
|
||||
// silence. torad brings up one tunnel with one peer;
|
||||
// saying so beats picking a peer at random.
|
||||
bail!(
|
||||
"line {lineno}: multiple [Peer] sections are not supported \
|
||||
(torad configures a single tunnel with one peer)"
|
||||
);
|
||||
}
|
||||
seen_peer = true;
|
||||
Section::Peer
|
||||
}
|
||||
other => bail!("line {lineno}: unknown section [{other}]"),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
bail!("line {lineno}: expected `Key = Value`, got {line:?}");
|
||||
};
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
|
||||
match section {
|
||||
Section::Interface => match key {
|
||||
"PrivateKey" => private_key = Some(value.to_owned()),
|
||||
"Address" => address = Some(value.to_owned()),
|
||||
"DNS" => dns = Some(value.to_owned()),
|
||||
"PrivateKey" => set_once(
|
||||
&mut private_key,
|
||||
// No value in the context: this is the secret.
|
||||
Key::from_base64(value)
|
||||
.with_context(|| format!("line {lineno}: invalid PrivateKey"))?,
|
||||
key,
|
||||
lineno,
|
||||
)?,
|
||||
"Address" => set_once(
|
||||
&mut addresses,
|
||||
parse_cidr_list(value)
|
||||
.with_context(|| format!("line {lineno}: invalid Address {value:?}"))?,
|
||||
key,
|
||||
lineno,
|
||||
)?,
|
||||
"DNS" => set_once(
|
||||
&mut dns,
|
||||
parse_dns_list(value)
|
||||
.with_context(|| format!("line {lineno}: invalid DNS {value:?}"))?,
|
||||
key,
|
||||
lineno,
|
||||
)?,
|
||||
_ => {}
|
||||
},
|
||||
Section::Peer => match key {
|
||||
"PublicKey" => public_key = Some(value.to_owned()),
|
||||
"AllowedIPs" => allowed_ips = Some(value),
|
||||
"Endpoint" => endpoint = Some(value.to_owned()),
|
||||
"PersistentKeepalive" => {
|
||||
let v: u16 = value
|
||||
.parse()
|
||||
.with_context(|| format!("invalid PersistentKeepalive value: {value}"))?;
|
||||
persistent_keepalive = Some(v);
|
||||
}
|
||||
"PublicKey" => set_once(
|
||||
&mut public_key,
|
||||
Key::from_base64(value)
|
||||
.with_context(|| format!("line {lineno}: invalid PublicKey {value:?}"))?,
|
||||
key,
|
||||
lineno,
|
||||
)?,
|
||||
"AllowedIPs" => set_once(
|
||||
&mut allowed_ips,
|
||||
parse_cidr_list(value)
|
||||
.with_context(|| format!("line {lineno}: invalid AllowedIPs {value:?}"))?,
|
||||
key,
|
||||
lineno,
|
||||
)?,
|
||||
"Endpoint" => set_once(
|
||||
&mut endpoint,
|
||||
value
|
||||
.parse::<Endpoint>()
|
||||
.with_context(|| format!("line {lineno}: invalid Endpoint {value:?}"))?,
|
||||
key,
|
||||
lineno,
|
||||
)?,
|
||||
"PersistentKeepalive" => set_once(
|
||||
&mut persistent_keepalive,
|
||||
value.parse::<u16>().with_context(|| {
|
||||
format!("line {lineno}: invalid PersistentKeepalive {value:?}")
|
||||
})?,
|
||||
key,
|
||||
lineno,
|
||||
)?,
|
||||
_ => {}
|
||||
},
|
||||
Section::None => {}
|
||||
Section::None => {
|
||||
bail!("line {lineno}: key {key:?} appears before any section header")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let interface = InterfaceSection {
|
||||
private_key: private_key.ok_or_else(|| {
|
||||
anyhow::anyhow!("missing required key 'PrivateKey' in [Interface] section")
|
||||
})?,
|
||||
address: address.ok_or_else(|| {
|
||||
anyhow::anyhow!("missing required key 'Address' in [Interface] section")
|
||||
})?,
|
||||
dns,
|
||||
};
|
||||
|
||||
// Peer section must have been entered.
|
||||
if public_key.is_none() && allowed_ips.is_none() && endpoint.is_none() {
|
||||
anyhow::bail!("missing [Peer] section");
|
||||
if !seen_interface {
|
||||
bail!("missing [Interface] section");
|
||||
}
|
||||
if !seen_peer {
|
||||
bail!("missing [Peer] section");
|
||||
}
|
||||
|
||||
let interface = InterfaceSection {
|
||||
private_key: private_key
|
||||
.context("missing required key 'PrivateKey' in [Interface] section")?,
|
||||
addresses: addresses.context("missing required key 'Address' in [Interface] section")?,
|
||||
dns: dns.unwrap_or_default(),
|
||||
};
|
||||
|
||||
let peer = PeerSection {
|
||||
public_key: public_key
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required key 'PublicKey' in [Peer] section"))?,
|
||||
allowed_ips: allowed_ips
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required key 'AllowedIPs' in [Peer] section"))?
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_owned())
|
||||
.collect(),
|
||||
endpoint: endpoint
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required key 'Endpoint' in [Peer] section"))?,
|
||||
public_key: public_key.context("missing required key 'PublicKey' in [Peer] section")?,
|
||||
allowed_ips: allowed_ips.context("missing required key 'AllowedIPs' in [Peer] section")?,
|
||||
endpoint: endpoint.context("missing required key 'Endpoint' in [Peer] section")?,
|
||||
persistent_keepalive,
|
||||
};
|
||||
|
||||
Ok(WireguardConfig { interface, peer })
|
||||
}
|
||||
|
||||
/// Assign to a slot that must not already be occupied.
|
||||
///
|
||||
/// A repeated key used to overwrite silently, so a config carrying two
|
||||
/// `Endpoint` lines would connect to whichever came last with no hint that the
|
||||
/// other existed.
|
||||
fn set_once<T>(slot: &mut Option<T>, value: T, key: &str, lineno: usize) -> Result<()> {
|
||||
if slot.is_some() {
|
||||
bail!("line {lineno}: duplicate key {key:?}");
|
||||
}
|
||||
*slot = Some(value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_cidr_list(value: &str) -> Result<Vec<IpCidr>> {
|
||||
value
|
||||
.split(',')
|
||||
.map(|part| {
|
||||
let part = part.trim();
|
||||
if part.is_empty() {
|
||||
bail!("empty entry in comma-separated list");
|
||||
}
|
||||
part.parse::<IpCidr>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_dns_list(value: &str) -> Result<Vec<IpAddr>> {
|
||||
value
|
||||
.split(',')
|
||||
.map(|part| {
|
||||
let part = part.trim();
|
||||
part.parse::<IpAddr>().with_context(|| {
|
||||
format!(
|
||||
"{part:?} is not an IP address \
|
||||
(search-domain entries in DNS are not supported)"
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Strip inline comments (# ...) but preserve the rest.
|
||||
/// Only strips when `#` is preceded by whitespace or is at column 0.
|
||||
fn strip_comment(line: &str) -> &str {
|
||||
if let Some(pos) = line.find('#') {
|
||||
if pos == 0 || line.as_bytes()[pos - 1] == b' ' || line.as_bytes()[pos - 1] == b'\t' {
|
||||
return &line[..pos];
|
||||
}
|
||||
if let Some(pos) = line.find('#')
|
||||
&& (pos == 0 || line.as_bytes()[pos - 1] == b' ' || line.as_bytes()[pos - 1] == b'\t')
|
||||
{
|
||||
return &line[..pos];
|
||||
}
|
||||
line
|
||||
}
|
||||
@@ -144,159 +497,301 @@ fn strip_comment(line: &str) -> &str {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PROTONVPN_SAMPLE: &str = r#"[Interface]
|
||||
# Key for nixarr
|
||||
# Bouncing = 1
|
||||
# NetShield = 1
|
||||
# Moderate NAT = off
|
||||
# NAT-PMP (Port Forwarding) = on
|
||||
# VPN Accelerator = on
|
||||
PrivateKey = aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq=
|
||||
Address = 10.2.0.2/32
|
||||
DNS = 10.2.0.1
|
||||
/// Valid 32-byte keys. The previous fixtures (`aaaa`, and a 41-character
|
||||
/// private key) are exactly what parse-time validation now rejects.
|
||||
const PRIVATE_KEY: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
|
||||
const PUBLIC_KEY: &str = "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=";
|
||||
|
||||
[Peer]
|
||||
# UA#44
|
||||
PublicKey = eqjhoqO6K1nLiej026+RkpSTHloVrOHLlMQaB0Tl5GM=
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
Endpoint = 156.146.50.5:51820
|
||||
PersistentKeepalive = 25
|
||||
"#;
|
||||
fn minimal() -> String {
|
||||
format!(
|
||||
"[Interface]\n\
|
||||
PrivateKey = {PRIVATE_KEY}\n\
|
||||
Address = 10.0.0.2/32\n\
|
||||
\n\
|
||||
[Peer]\n\
|
||||
PublicKey = {PUBLIC_KEY}\n\
|
||||
AllowedIPs = 0.0.0.0/0\n\
|
||||
Endpoint = 1.2.3.4:51820\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn v4(a: u8, b: u8, c: u8, d: u8, prefix_len: u8) -> IpCidr {
|
||||
IpCidr {
|
||||
addr: IpAddr::from([a, b, c, d]),
|
||||
prefix_len,
|
||||
}
|
||||
}
|
||||
|
||||
fn protonvpn_sample() -> String {
|
||||
format!(
|
||||
"[Interface]\n\
|
||||
# Key for nixarr\n\
|
||||
# Bouncing = 1\n\
|
||||
# NAT-PMP (Port Forwarding) = on\n\
|
||||
PrivateKey = {PRIVATE_KEY}\n\
|
||||
Address = 10.2.0.2/32\n\
|
||||
DNS = 10.2.0.1\n\
|
||||
\n\
|
||||
[Peer]\n\
|
||||
# UA#44\n\
|
||||
PublicKey = {PUBLIC_KEY}\n\
|
||||
AllowedIPs = 0.0.0.0/0, ::/0\n\
|
||||
Endpoint = 156.146.50.5:51820\n\
|
||||
PersistentKeepalive = 25\n"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_protonvpn_sample() {
|
||||
let cfg = parse(PROTONVPN_SAMPLE).unwrap();
|
||||
assert_eq!(
|
||||
cfg.interface.private_key,
|
||||
"aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq="
|
||||
);
|
||||
assert_eq!(cfg.interface.address, "10.2.0.2/32");
|
||||
assert_eq!(cfg.interface.dns.as_deref(), Some("10.2.0.1"));
|
||||
assert_eq!(
|
||||
cfg.peer.public_key,
|
||||
"eqjhoqO6K1nLiej026+RkpSTHloVrOHLlMQaB0Tl5GM="
|
||||
);
|
||||
assert_eq!(cfg.peer.allowed_ips, vec!["0.0.0.0/0", "::/0"]);
|
||||
assert_eq!(cfg.peer.endpoint, "156.146.50.5:51820");
|
||||
let cfg = parse(&protonvpn_sample()).unwrap();
|
||||
assert_eq!(cfg.interface.private_key.as_base64(), PRIVATE_KEY);
|
||||
assert_eq!(cfg.interface.addresses, vec![v4(10, 2, 0, 2, 32)]);
|
||||
assert_eq!(cfg.interface.dns, vec![IpAddr::from([10, 2, 0, 1])]);
|
||||
assert_eq!(cfg.peer.public_key.as_base64(), PUBLIC_KEY);
|
||||
assert_eq!(cfg.peer.allowed_ips.len(), 2);
|
||||
assert_eq!(cfg.peer.allowed_ips[0], v4(0, 0, 0, 0, 0));
|
||||
assert_eq!(cfg.peer.allowed_ips[1].to_string(), "::/0");
|
||||
assert_eq!(cfg.peer.endpoint.host, "156.146.50.5");
|
||||
assert_eq!(cfg.peer.endpoint.port, 51820);
|
||||
assert_eq!(cfg.peer.persistent_keepalive, Some(25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_config_without_dns() {
|
||||
let text = r#"[Interface]
|
||||
PrivateKey = aaaa
|
||||
Address = 10.0.0.2/32
|
||||
|
||||
[Peer]
|
||||
PublicKey = bbbb
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 1.2.3.4:51820
|
||||
"#;
|
||||
let cfg = parse(text).unwrap();
|
||||
assert!(cfg.interface.dns.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_config_without_keepalive() {
|
||||
let text = r#"[Interface]
|
||||
PrivateKey = aaaa
|
||||
Address = 10.0.0.2/32
|
||||
|
||||
[Peer]
|
||||
PublicKey = bbbb
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 1.2.3.4:51820
|
||||
"#;
|
||||
let cfg = parse(text).unwrap();
|
||||
fn parses_config_without_dns_or_keepalive() {
|
||||
let cfg = parse(&minimal()).unwrap();
|
||||
assert!(cfg.interface.dns.is_empty());
|
||||
assert!(cfg.peer.persistent_keepalive.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_comments_only_lines() {
|
||||
let text = r#"[Interface]
|
||||
# This is a comment
|
||||
# Another = comment
|
||||
PrivateKey = aaaa
|
||||
Address = 10.0.0.2/32
|
||||
fn parses_multiple_addresses_and_allowed_ips() {
|
||||
let text = minimal()
|
||||
.replace(
|
||||
"Address = 10.0.0.2/32",
|
||||
"Address = 10.0.0.2/32, fd00::2/128",
|
||||
)
|
||||
.replace(
|
||||
"AllowedIPs = 0.0.0.0/0",
|
||||
"AllowedIPs = 0.0.0.0/0, ::/0, 192.168.0.0/16",
|
||||
);
|
||||
let cfg = parse(&text).unwrap();
|
||||
assert_eq!(cfg.interface.addresses.len(), 2);
|
||||
assert_eq!(cfg.interface.addresses[1].to_string(), "fd00::2/128");
|
||||
assert_eq!(cfg.peer.allowed_ips.len(), 3);
|
||||
assert_eq!(cfg.peer.allowed_ips[2], v4(192, 168, 0, 0, 16));
|
||||
}
|
||||
|
||||
[Peer]
|
||||
# Peer comment
|
||||
PublicKey = bbbb
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 1.2.3.4:51820
|
||||
"#;
|
||||
assert!(parse(text).is_ok());
|
||||
/// `wg` treats a prefixless `AllowedIPs` entry as a host route; so do we.
|
||||
#[test]
|
||||
fn bare_address_takes_the_full_prefix() {
|
||||
let text = minimal().replace("AllowedIPs = 0.0.0.0/0", "AllowedIPs = 8.8.8.8, fd00::1");
|
||||
let cfg = parse(&text).unwrap();
|
||||
assert_eq!(cfg.peer.allowed_ips[0], v4(8, 8, 8, 8, 32));
|
||||
assert_eq!(cfg.peer.allowed_ips[1].prefix_len, 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_private_key() {
|
||||
let text = r#"[Interface]
|
||||
Address = 10.0.0.2/32
|
||||
|
||||
[Peer]
|
||||
PublicKey = bbbb
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 1.2.3.4:51820
|
||||
"#;
|
||||
let err = parse(text).unwrap_err().to_string();
|
||||
assert!(err.contains("PrivateKey"));
|
||||
fn parses_bracketed_ipv6_endpoint() {
|
||||
let text = minimal().replace("Endpoint = 1.2.3.4:51820", "Endpoint = [2001:db8::1]:51820");
|
||||
let cfg = parse(&text).unwrap();
|
||||
assert_eq!(cfg.peer.endpoint.host, "2001:db8::1");
|
||||
assert_eq!(cfg.peer.endpoint.port, 51820);
|
||||
assert_eq!(cfg.peer.endpoint.to_string(), "[2001:db8::1]:51820");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_peer_section() {
|
||||
let text = r#"[Interface]
|
||||
PrivateKey = aaaa
|
||||
Address = 10.0.0.2/32
|
||||
"#;
|
||||
let err = parse(text).unwrap_err().to_string();
|
||||
assert!(err.contains("[Peer]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multiple_allowed_ips() {
|
||||
let text = r#"[Interface]
|
||||
PrivateKey = aaaa
|
||||
Address = 10.0.0.2/32
|
||||
|
||||
[Peer]
|
||||
PublicKey = bbbb
|
||||
AllowedIPs = 0.0.0.0/0, ::/0, 192.168.0.0/16
|
||||
Endpoint = 1.2.3.4:51820
|
||||
"#;
|
||||
let cfg = parse(text).unwrap();
|
||||
fn parses_hostname_endpoint() {
|
||||
let text = minimal().replace(
|
||||
"Endpoint = 1.2.3.4:51820",
|
||||
"Endpoint = node-ua-44.protonvpn.net:51820",
|
||||
);
|
||||
let cfg = parse(&text).unwrap();
|
||||
assert_eq!(cfg.peer.endpoint.host, "node-ua-44.protonvpn.net");
|
||||
assert_eq!(
|
||||
cfg.peer.allowed_ips,
|
||||
vec!["0.0.0.0/0", "::/0", "192.168.0.0/16"]
|
||||
cfg.peer.endpoint.to_string(),
|
||||
"node-ua-44.protonvpn.net:51820"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_redacts_private_key() {
|
||||
let cfg = parse(PROTONVPN_SAMPLE).unwrap();
|
||||
fn ignores_comment_lines_and_strips_inline_comments() {
|
||||
let text = minimal().replace(
|
||||
"Address = 10.0.0.2/32",
|
||||
"# a comment\nAddress = 10.0.0.2/32 # the VPN address",
|
||||
);
|
||||
let cfg = parse(&text).unwrap();
|
||||
assert_eq!(cfg.interface.addresses, vec![v4(10, 0, 0, 2, 32)]);
|
||||
}
|
||||
|
||||
fn parse_err(text: &str) -> String {
|
||||
// `{:#}` renders the whole anyhow chain, so assertions can match on the
|
||||
// inner cause as well as the line-number context wrapped around it.
|
||||
format!("{:#}", parse(text).unwrap_err())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_required_keys() {
|
||||
let no_private = minimal().replace(&format!("PrivateKey = {PRIVATE_KEY}\n"), "");
|
||||
assert!(parse_err(&no_private).contains("PrivateKey"));
|
||||
|
||||
let no_peer_section =
|
||||
format!("[Interface]\nPrivateKey = {PRIVATE_KEY}\nAddress = 10.0.0.2/32\n");
|
||||
assert!(parse_err(&no_peer_section).contains("[Peer]"));
|
||||
|
||||
let no_endpoint = minimal().replace("Endpoint = 1.2.3.4:51820\n", "");
|
||||
assert!(parse_err(&no_endpoint).contains("Endpoint"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_line_that_is_not_key_equals_value() {
|
||||
let text = minimal().replace("Address = 10.0.0.2/32", "Address 10.0.0.2/32");
|
||||
let err = parse_err(&text);
|
||||
assert!(err.contains("line 3"), "want the line number, got: {err}");
|
||||
assert!(err.contains("Key = Value"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_second_peer_section() {
|
||||
let text = format!("{}\n[Peer]\nPublicKey = {PUBLIC_KEY}\n", minimal());
|
||||
let err = parse_err(&text);
|
||||
assert!(err.contains("line 10"), "want the line number, got: {err}");
|
||||
assert!(err.contains("multiple [Peer]"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_second_interface_section() {
|
||||
let text = format!("{}\n[Interface]\nAddress = 10.0.0.3/32\n", minimal());
|
||||
assert!(parse_err(&text).contains("duplicate [Interface]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_duplicate_key() {
|
||||
let text = minimal().replace(
|
||||
"Endpoint = 1.2.3.4:51820",
|
||||
"Endpoint = 1.2.3.4:51820\nEndpoint = 5.6.7.8:51820",
|
||||
);
|
||||
let err = parse_err(&text);
|
||||
assert!(err.contains("duplicate key"), "got: {err}");
|
||||
assert!(err.contains("Endpoint"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_an_unknown_section() {
|
||||
let text = minimal().replace("[Peer]", "[Peers]");
|
||||
assert!(parse_err(&text).contains("unknown section [Peers]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_key_before_any_section() {
|
||||
let text = format!("Address = 10.0.0.2/32\n{}", minimal());
|
||||
assert!(parse_err(&text).contains("before any section header"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_addresses() {
|
||||
for bad in [
|
||||
"10.0.0.256/32", // octet out of range
|
||||
"10.0.0.2/33", // prefix too long for v4
|
||||
"fd00::1/129", // prefix too long for v6
|
||||
"10.0.0.2/", // empty prefix
|
||||
"not-an-ip/32",
|
||||
"10.0.0.2, ", // empty trailing entry
|
||||
] {
|
||||
let text = minimal().replace("Address = 10.0.0.2/32", &format!("Address = {bad}"));
|
||||
assert!(
|
||||
parse(&text).is_err(),
|
||||
"expected {bad:?} to be rejected as an Address"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_endpoints() {
|
||||
for bad in [
|
||||
"1.2.3.4", // no port
|
||||
"1.2.3.4:0", // reserved port
|
||||
"1.2.3.4:70000", // port out of range
|
||||
"1.2.3.4:http", // non-numeric port
|
||||
"2001:db8::1:51820", // unbracketed IPv6
|
||||
"[2001:db8::1]", // bracketed, no port
|
||||
"[2001:db8::zz]:51820", // bad IPv6 literal
|
||||
":51820", // empty host
|
||||
] {
|
||||
let text = minimal().replace("Endpoint = 1.2.3.4:51820", &format!("Endpoint = {bad}"));
|
||||
assert!(
|
||||
parse(&text).is_err(),
|
||||
"expected {bad:?} to be rejected as an Endpoint"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_non_ip_dns_entry() {
|
||||
let text = minimal().replace(
|
||||
"Address = 10.0.0.2/32",
|
||||
"Address = 10.0.0.2/32\nDNS = example.com",
|
||||
);
|
||||
assert!(parse_err(&text).contains("search-domain"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_keys() {
|
||||
// A 41-character key: long enough to look right at a glance, which is
|
||||
// precisely why it needs to fail here rather than at the UAPI socket.
|
||||
let short = "aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq=";
|
||||
assert_eq!(short.len(), 41);
|
||||
for bad in [short, "aaaa", "", &"A".repeat(44)] {
|
||||
let text = minimal().replace(
|
||||
&format!("PublicKey = {PUBLIC_KEY}"),
|
||||
&format!("PublicKey = {bad}"),
|
||||
);
|
||||
assert!(parse(&text).is_err(), "expected key {bad:?} to be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_key_never_appears_in_an_error() {
|
||||
let text = minimal().replace(
|
||||
&format!("PrivateKey = {PRIVATE_KEY}"),
|
||||
"PrivateKey = SECRETSECRETSECRETSECRETSECRETSECRETSECRET=",
|
||||
);
|
||||
let err = parse_err(&text);
|
||||
assert!(!err.contains("SECRET"), "private key leaked: {err}");
|
||||
assert!(err.contains("PrivateKey"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_redacts_the_private_key_but_not_the_public_one() {
|
||||
let cfg = parse(&protonvpn_sample()).unwrap();
|
||||
let rendered = format!("{cfg:?}");
|
||||
assert!(
|
||||
!rendered.contains("aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq="),
|
||||
!rendered.contains(PRIVATE_KEY),
|
||||
"private key leaked into Debug output: {rendered}"
|
||||
);
|
||||
assert!(rendered.contains("<redacted>"), "got: {rendered}");
|
||||
// The rest of the config must still be inspectable.
|
||||
assert!(rendered.contains(PUBLIC_KEY), "got: {rendered}");
|
||||
assert!(rendered.contains("10.2.0.2/32"), "got: {rendered}");
|
||||
}
|
||||
|
||||
/// A bare `Key` must stay redacted wherever it is printed, since the same
|
||||
/// type carries private keys.
|
||||
#[test]
|
||||
fn strips_inline_comments() {
|
||||
let text = r#"[Interface]
|
||||
PrivateKey = aaaa
|
||||
Address = 10.2.0.2/32 # the VPN address
|
||||
DNS = 1.1.1.1
|
||||
fn key_debug_is_redacted() {
|
||||
let key = Key::from_base64(PRIVATE_KEY).unwrap();
|
||||
assert_eq!(format!("{key:?}"), "Key(<redacted>)");
|
||||
}
|
||||
|
||||
[Peer]
|
||||
PublicKey = bbbb
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 1.2.3.4:51820
|
||||
"#;
|
||||
let cfg = parse(text).unwrap();
|
||||
assert_eq!(cfg.interface.address, "10.2.0.2/32");
|
||||
assert_eq!(cfg.interface.dns.as_deref(), Some("1.1.1.1"));
|
||||
/// Wave 3 sends keys to the `wireguard-go` UAPI socket as hex.
|
||||
#[test]
|
||||
fn key_round_trips_base64_to_hex() {
|
||||
let key = Key::from_base64(PRIVATE_KEY).unwrap();
|
||||
// PRIVATE_KEY is base64 of the bytes 0x00..=0x1f.
|
||||
let expected: String = (0u8..32).map(|b| format!("{b:02x}")).collect();
|
||||
assert_eq!(key.to_hex(), expected);
|
||||
assert_eq!(key.to_hex().len(), 64);
|
||||
|
||||
let all_ones = Key::from_base64("//////////////////////////////////////////8=").unwrap();
|
||||
assert_eq!(all_ones.to_hex(), "ff".repeat(32));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user