Files
opsx-vpn/client/src-tauri/src/tunnel.rs
T

349 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 本机 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<PathBuf, String> {
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<TunnelRecord, String> {
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<PathBuf, String> {
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::<std::net::IpAddr>() else {
return false;
};
let Ok(prefix) = mask.parse::<u8>() 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::<std::net::IpAddr>().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::<u16>().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<String, String> {
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<PathBuf, String> {
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);
}
}