// 本机 WireGuard 隧道:密钥生成、配置渲染与提权启停。 // // 私钥只在本机生成、只保存在本机数据目录(0600),绝不上传。 // 启停需要系统权限:Linux 走 pkexec,macOS 走 osascript 管理员授权; // Windows 暂无受控提权通道,明确报错而不是静默失败。 use std::path::{Path, PathBuf}; use base64::Engine; use serde::{Deserialize, Serialize}; use crate::api::DeviceConfig; /// 生成 WireGuard 密钥对(X25519),返回 (私钥, 公钥),均为 base64。 pub fn generate_keypair() -> Result<(String, String), String> { use x25519_dalek::{PublicKey, StaticSecret}; let secret = StaticSecret::random_from_rng(rand_core::OsRng); let public = PublicKey::from(&secret); let b64 = base64::engine::general_purpose::STANDARD; Ok((b64.encode(secret.to_bytes()), b64.encode(public.to_bytes()))) } /// 用设备私钥 + 服务端下发的参数渲染 wg-quick 配置文本。 pub fn render_config(private_key: &str, cfg: &DeviceConfig) -> String { let mut out = format!( "[Interface]\nPrivateKey = {private_key}\nAddress = {}\n", cfg.client_address ); if !cfg.dns.is_empty() { out.push_str(&format!("DNS = {}\n", cfg.dns)); } if cfg.mtu > 0 { out.push_str(&format!("MTU = {}\n", cfg.mtu)); } out.push_str(&format!( "\n[Peer]\nPublicKey = {}\nPresharedKey = {}\nEndpoint = {}\nAllowedIPs = {}\nPersistentKeepalive = {}\n", cfg.server_public_key, cfg.preshared_key, cfg.endpoint, cfg.allowed_ips, cfg.keepalive )); out } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TunnelRecord { pub private_key: String, pub config: DeviceConfig, } /// 把含私钥的隧道记录写入本机数据目录(0600)。 pub fn save_record(dir: &Path, record: &TunnelRecord) -> Result { std::fs::create_dir_all(dir).map_err(|err| format!("无法创建数据目录:{err}"))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) .map_err(|err| format!("无法限制数据目录权限:{err}"))?; } let path = record_path(dir, record.config.tunnel_id); let raw = serde_json::to_string_pretty(record).map_err(|err| err.to_string())?; write_private(&path, raw.as_bytes())?; Ok(path) } fn record_path(dir: &Path, tunnel_id: u64) -> PathBuf { dir.join(format!("tunnel-{tunnel_id}.json")) } /// 不同服务器、用户的设备密钥各自保存,切换线路不会遗失原设备登记。 pub fn device_dir(dir: &Path, base_url: &str, username: &str) -> PathBuf { use sha2::{Digest, Sha256}; let digest = Sha256::digest(format!("{base_url}\0{username}").as_bytes()); let key: String = digest.iter().map(|b| format!("{b:02x}")).collect(); dir.join("devices").join(key) } pub fn load_record_for(dir: &Path, tunnel_id: u64) -> Result { let raw = std::fs::read_to_string(record_path(dir, tunnel_id)) .map_err(|_| "本机没有已登记的隧道记录".to_string())?; serde_json::from_str(&raw).map_err(|err| format!("本机隧道记录损坏:{err}")) } pub fn clear_record_for(dir: &Path, tunnel_id: u64) { let _ = std::fs::remove_file(record_path(dir, tunnel_id)); } pub fn clear_record(dir: &Path) { let _ = std::fs::remove_file(dir.join("tunnel.json")); let _ = std::fs::remove_file(dir.join("opsx-wg.conf")); } #[cfg(unix)] fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> { use std::os::unix::fs::OpenOptionsExt; let mut file = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .mode(0o600) .open(path) .map_err(|err| format!("无法写入 {}:{err}", path.display()))?; use std::os::unix::fs::PermissionsExt; file.set_permissions(std::fs::Permissions::from_mode(0o600)) .map_err(|err| format!("无法限制文件权限:{err}"))?; use std::io::Write; file.write_all(bytes) .map_err(|err| format!("写入失败:{err}")) } #[cfg(not(unix))] fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> { std::fs::write(path, bytes).map_err(|err| format!("无法写入 {}:{err}", path.display())) } fn write_conf(dir: &Path, text: &str) -> Result { std::fs::create_dir_all(dir).map_err(|err| format!("无法创建数据目录:{err}"))?; let path = dir.join("opsx-wg.conf"); write_private(&path, text.as_bytes())?; Ok(path) } fn valid_key(value: &str) -> bool { use base64::Engine; base64::engine::general_purpose::STANDARD .decode(value) .is_ok_and(|v| v.len() == 32) } fn valid_cidrs(value: &str) -> bool { !value.is_empty() && value.split(',').all(|part| { let Some((ip, mask)) = part.trim().split_once('/') else { return false; }; let Ok(address) = ip.parse::() else { return false; }; let Ok(prefix) = mask.parse::() else { return false; }; prefix <= if address.is_ipv4() { 32 } else { 128 } }) } fn validate_config(private_key: &str, cfg: &DeviceConfig) -> Result<(), String> { if !valid_key(private_key) || !valid_key(&cfg.server_public_key) || !valid_key(&cfg.preshared_key) { return Err("WireGuard 密钥格式无效".into()); } if !valid_cidrs(&cfg.client_address) || !valid_cidrs(&cfg.allowed_ips) { return Err("服务端下发的地址或路由无效".into()); } if !cfg.dns.is_empty() && !cfg .dns .split(',') .all(|ip| ip.trim().parse::().is_ok()) { return Err("服务端下发的 DNS 地址无效".into()); } let Some((host, port)) = cfg.endpoint.rsplit_once(':') else { return Err("缺少 VPN 公网端点".into()); }; if host.is_empty() || host .chars() .any(|c| !(c.is_ascii_alphanumeric() || ".-[]:".contains(c))) || port.parse::().unwrap_or(0) == 0 { return Err("VPN 公网端点格式无效".into()); } if (cfg.mtu != 0 && !(576..=9000).contains(&cfg.mtu)) || cfg.keepalive > 65535 { return Err("服务端下发的 MTU 或保活参数无效".into()); } Ok(()) } fn find_binary(name: &str) -> Result { std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) .map(|dir| dir.join(name)) .find(|p| p.is_file()) .map(|p| p.to_string_lossy().to_string()) .ok_or_else(|| format!("未在本机 PATH 找到 {name},请先安装 wireguard-tools")) } /// 以系统授权方式执行 wg-quick up/down。 /// 命令参数全部由本机生成,不拼接任何用户输入。 fn run_wg_quick(action: &str, conf: &Path) -> Result<(), String> { let wg_quick = find_binary("wg-quick")?; let conf_str = conf.to_string_lossy().to_string(); #[cfg(target_os = "linux")] { let pkexec = find_binary("pkexec").map_err(|_| { "未找到 pkexec,无法安全提权;请安装 polkit 或手动配置 sudo 权限".to_string() })?; let status = std::process::Command::new(pkexec) .args([&wg_quick, action, &conf_str]) .status() .map_err(|err| format!("无法启动提权授权:{err}"))?; return if status.success() { Ok(()) } else { Err(format!( "wg-quick {action} 执行失败(可能被取消授权或系统策略拒绝)" )) }; } #[cfg(target_os = "macos")] { if conf_str.chars().any(char::is_control) || wg_quick.chars().any(char::is_control) { return Err("配置路径包含非法字符".into()); } let command = format!( "{} {} {}", shell_escape(&wg_quick), action, shell_escape(&conf_str) ); let literal = command.replace('\\', "\\\\").replace('"', "\\\""); let script = format!("do shell script \"{literal}\" with administrator privileges"); let status = std::process::Command::new("osascript") .args(["-e", &script]) .status() .map_err(|err| format!("无法请求管理员授权:{err}"))?; return if status.success() { Ok(()) } else { Err(format!("wg-quick {action} 执行失败(可能被取消授权)")) }; } #[cfg(target_os = "windows")] { let _ = (wg_quick, conf_str); Err( "Windows 隧道提权通道尚未实现,请使用 WireGuard 官方客户端导入配置,或等待后续版本" .to_string(), ) } } #[cfg(target_os = "macos")] fn shell_escape(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } /// 建立隧道:渲染配置 → 0600 落盘 → 提权 wg-quick up。 pub fn up(dir: &Path, private_key: &str, cfg: &DeviceConfig) -> Result { validate_config(private_key, cfg)?; let path = write_conf(dir, &render_config(private_key, cfg))?; // 重复连接时先尽力清理旧接口,忽略失败(接口可能本就不存在) let _ = run_wg_quick("down", &path); run_wg_quick("up", &path)?; Ok(path) } /// 拆除隧道。配置文件可能已被清理,尽力而为。 pub fn down(dir: &Path) -> Result<(), String> { let path = dir.join("opsx-wg.conf"); if !path.is_file() { return Ok(()); } run_wg_quick("down", &path) } #[cfg(test)] mod tests { use super::*; fn sample_config() -> DeviceConfig { DeviceConfig { device_id: 1, device_name: "测试设备".to_string(), tunnel_id: 4, tunnel_name: "测试专线".to_string(), peer_id: "dev-1".to_string(), client_address: "10.8.0.7/32".to_string(), server_public_key: "serverpub".to_string(), preshared_key: "psk".to_string(), endpoint: "vpn.example.com:51820".to_string(), allowed_ips: "0.0.0.0/0, ::/0".to_string(), dns: "223.5.5.5".to_string(), mtu: 1420, keepalive: 25, } } #[test] fn generated_keypair_is_base64_x25519() { let b64 = base64::engine::general_purpose::STANDARD; let (private_key, public_key) = generate_keypair().unwrap(); assert_eq!(b64.decode(&private_key).unwrap().len(), 32); assert_eq!(b64.decode(&public_key).unwrap().len(), 32); // 同一私钥每次派生相同公钥,且两把私钥的公钥不同 let (private2, public2) = generate_keypair().unwrap(); assert_ne!(public_key, public2); assert_ne!(private_key, private2); } #[test] fn rendered_config_contains_all_fields() { let conf = render_config("private-key", &sample_config()); for needle in [ "PrivateKey = private-key", "Address = 10.8.0.7/32", "DNS = 223.5.5.5", "MTU = 1420", "PublicKey = serverpub", "PresharedKey = psk", "Endpoint = vpn.example.com:51820", "AllowedIPs = 0.0.0.0/0, ::/0", "PersistentKeepalive = 25", ] { assert!(conf.contains(needle), "配置缺少 {needle}:\n{conf}"); } } #[test] fn rejects_injected_server_config_before_root_helper() { let key = base64::engine::general_purpose::STANDARD.encode([7_u8; 32]); let mut cfg = sample_config(); cfg.server_public_key = key.clone(); cfg.preshared_key = key.clone(); assert!(validate_config(&key, &cfg).is_ok()); cfg.dns = "1.1.1.1\nPostUp = touch /tmp/unsafe".into(); assert!(validate_config(&key, &cfg).is_err()); cfg.dns = "1.1.1.1".into(); cfg.allowed_ips = "0.0.0.0/0\nPostUp = touch /tmp/unsafe".into(); assert!(validate_config(&key, &cfg).is_err()); cfg.allowed_ips = "0.0.0.0/0".into(); cfg.endpoint = "vpn.example.com:51820\n[Interface]".into(); assert!(validate_config(&key, &cfg).is_err()); } #[test] fn record_roundtrip_and_clear() { let dir = std::env::temp_dir().join(format!("opsx-test-{}", std::process::id())); let record = TunnelRecord { private_key: "private".to_string(), config: sample_config(), }; save_record(&dir, &record).unwrap(); let loaded = load_record_for(&dir, sample_config().tunnel_id).unwrap(); assert_eq!(loaded.private_key, "private"); assert_eq!(loaded.config.client_address, "10.8.0.7/32"); clear_record_for(&dir, sample_config().tunnel_id); assert!(load_record_for(&dir, sample_config().tunnel_id).is_err()); let _ = std::fs::remove_dir_all(&dir); } }