Files
donutbrowser/src-tauri/src/vpn/config.rs
T

341 lines
9.3 KiB
Rust

//! VPN configuration types and parsing.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
/// VPN-related errors
#[derive(Error, Debug)]
pub enum VpnError {
#[error("Unknown VPN config format")]
UnknownFormat,
#[error("Invalid WireGuard config: {0}")]
InvalidWireGuard(String),
#[error("Storage error: {0}")]
Storage(String),
#[error("Connection error: {0}")]
Connection(String),
#[error("Encryption error: {0}")]
Encryption(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("VPN not found: {0}")]
NotFound(String),
#[error("Tunnel error: {0}")]
Tunnel(String),
}
/// The type of VPN configuration
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VpnType {
WireGuard,
}
impl std::fmt::Display for VpnType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VpnType::WireGuard => write!(f, "WireGuard"),
}
}
}
/// A stored VPN configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VpnConfig {
pub id: String,
pub name: String,
pub vpn_type: VpnType,
pub config_data: String, // Raw config content (encrypted at rest)
pub created_at: i64,
pub last_used: Option<i64>,
#[serde(default)]
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
}
/// Parsed WireGuard configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WireGuardConfig {
pub private_key: String,
pub address: String,
pub dns: Option<String>,
pub mtu: Option<u16>,
pub peer_public_key: String,
pub peer_endpoint: String,
pub allowed_ips: Vec<String>,
pub persistent_keepalive: Option<u16>,
pub preshared_key: Option<String>,
}
/// Result of importing a VPN configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VpnImportResult {
pub success: bool,
pub vpn_id: Option<String>,
pub vpn_type: Option<VpnType>,
pub name: String,
pub error: Option<String>,
}
/// VPN connection status
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VpnStatus {
pub connected: bool,
pub vpn_id: String,
pub connected_at: Option<i64>,
pub bytes_sent: Option<u64>,
pub bytes_received: Option<u64>,
pub last_handshake: Option<i64>,
}
/// Detect the VPN type from file content and filename
pub fn detect_vpn_type(content: &str, filename: &str) -> Result<VpnType, VpnError> {
let filename_lower = filename.to_lowercase();
if filename_lower.ends_with(".conf")
&& content.contains("[Interface]")
&& content.contains("[Peer]")
{
return Ok(VpnType::WireGuard);
}
if content.contains("[Interface]") && content.contains("PrivateKey") && content.contains("[Peer]")
{
return Ok(VpnType::WireGuard);
}
Err(VpnError::UnknownFormat)
}
/// Parse a WireGuard configuration file
pub fn parse_wireguard_config(content: &str) -> Result<WireGuardConfig, VpnError> {
let mut interface: HashMap<String, String> = HashMap::new();
let mut peer: HashMap<String, String> = HashMap::new();
let mut current_section: Option<&str> = None;
// Strip a UTF-8 BOM if present — some editors/tools emit one and it would
// otherwise prepend invisible bytes to the first section header
let content = content.strip_prefix('\u{feff}').unwrap_or(content);
for line in content.lines() {
let line = line.trim();
// Skip empty lines and comments
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
// Check for section headers
if line == "[Interface]" {
current_section = Some("interface");
continue;
}
if line == "[Peer]" {
current_section = Some("peer");
continue;
}
// Parse key-value pairs (split on the first `=` so base64 padding is preserved)
if let Some((key, value)) = line.split_once('=') {
let key = key.trim().to_string();
let value = value.trim().to_string();
match current_section {
Some("interface") => {
interface.insert(key, value);
}
Some("peer") => {
peer.insert(key, value);
}
_ => {}
}
}
}
// Validate required fields
let private_key = interface
.get("PrivateKey")
.ok_or_else(|| VpnError::InvalidWireGuard("Missing PrivateKey in [Interface]".to_string()))?
.clone();
validate_wireguard_key(&private_key, "PrivateKey")?;
let address = interface
.get("Address")
.ok_or_else(|| VpnError::InvalidWireGuard("Missing Address in [Interface]".to_string()))?
.clone();
let peer_public_key = peer
.get("PublicKey")
.ok_or_else(|| VpnError::InvalidWireGuard("Missing PublicKey in [Peer]".to_string()))?
.clone();
validate_wireguard_key(&peer_public_key, "PublicKey")?;
let peer_endpoint = peer
.get("Endpoint")
.ok_or_else(|| VpnError::InvalidWireGuard("Missing Endpoint in [Peer]".to_string()))?
.clone();
let allowed_ips = peer
.get("AllowedIPs")
.map(|s| s.split(',').map(|ip| ip.trim().to_string()).collect())
.unwrap_or_else(|| vec!["0.0.0.0/0".to_string()]);
let persistent_keepalive = peer.get("PersistentKeepalive").and_then(|s| s.parse().ok());
let dns = interface.get("DNS").cloned();
let mtu = interface.get("MTU").and_then(|s| s.parse().ok());
let preshared_key = peer.get("PresharedKey").cloned();
if let Some(ref psk) = preshared_key {
validate_wireguard_key(psk, "PresharedKey")?;
}
Ok(WireGuardConfig {
private_key,
address,
dns,
mtu,
peer_public_key,
peer_endpoint,
allowed_ips,
persistent_keepalive,
preshared_key,
})
}
/// Validate that a WireGuard key is a base64-encoded 32-byte value.
/// Reports the field name and a short preview of the bad value so users can
/// see exactly what went wrong (e.g. a redacted/masked key).
fn validate_wireguard_key(key: &str, field: &str) -> Result<(), VpnError> {
use base64::Engine;
let decoded = base64::engine::general_purpose::STANDARD
.decode(key)
.map_err(|e| {
let preview: String = key.chars().take(8).collect();
VpnError::InvalidWireGuard(format!(
"{field} is not valid base64 (starts with {preview:?}): {e}. \
Expected a 32-byte base64-encoded key (44 chars ending with '=')."
))
})?;
if decoded.len() != 32 {
return Err(VpnError::InvalidWireGuard(format!(
"{field} decoded to {} bytes (expected 32). The config may be truncated or malformed.",
decoded.len()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_wireguard_by_extension() {
let content = "[Interface]\nPrivateKey = test\n[Peer]\nPublicKey = test";
assert_eq!(
detect_vpn_type(content, "test.conf").unwrap(),
VpnType::WireGuard
);
}
#[test]
fn test_detect_wireguard_by_content() {
let content = "[Interface]\nPrivateKey = testkey123\nAddress = 10.0.0.2/24\n\n[Peer]\nPublicKey = peerkey456\nEndpoint = vpn.example.com:51820";
assert_eq!(
detect_vpn_type(content, "config").unwrap(),
VpnType::WireGuard
);
}
#[test]
fn test_detect_unknown_format() {
let content = "random text that is not a vpn config";
assert!(detect_vpn_type(content, "random.txt").is_err());
}
#[test]
fn test_reject_openvpn_content() {
let content = "client\ndev tun\nproto udp\nremote vpn.example.com 1194";
assert!(detect_vpn_type(content, "test.ovpn").is_err());
assert!(detect_vpn_type(content, "config").is_err());
}
#[test]
fn test_parse_wireguard_config() {
let content = r#"
[Interface]
PrivateKey = YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=
Address = 10.0.0.2/24
DNS = 1.1.1.1
MTU = 1420
[Peer]
PublicKey = YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI=
Endpoint = vpn.example.com:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25
"#;
let config = parse_wireguard_config(content).unwrap();
assert_eq!(
config.private_key,
"YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="
);
assert_eq!(config.address, "10.0.0.2/24");
assert_eq!(config.dns, Some("1.1.1.1".to_string()));
assert_eq!(config.mtu, Some(1420));
assert_eq!(
config.peer_public_key,
"YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI="
);
assert_eq!(config.peer_endpoint, "vpn.example.com:51820");
assert_eq!(config.allowed_ips, vec!["0.0.0.0/0", "::/0"]);
assert_eq!(config.persistent_keepalive, Some(25));
}
#[test]
fn test_parse_wireguard_config_minimal() {
let content = r#"
[Interface]
PrivateKey = YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=
Address = 10.0.0.2/32
[Peer]
PublicKey = YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI=
Endpoint = 1.2.3.4:51820
"#;
let config = parse_wireguard_config(content).unwrap();
assert_eq!(
config.private_key,
"YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="
);
assert_eq!(config.address, "10.0.0.2/32");
assert!(config.dns.is_none());
assert!(config.mtu.is_none());
assert_eq!(
config.peer_public_key,
"YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI="
);
assert_eq!(config.peer_endpoint, "1.2.3.4:51820");
}
#[test]
fn test_parse_wireguard_missing_private_key() {
let content = r#"
[Interface]
Address = 10.0.0.2/24
[Peer]
PublicKey = key
Endpoint = 1.2.3.4:51820
"#;
let result = parse_wireguard_config(content);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("PrivateKey"));
}
}