349 lines
13 KiB
Rust
349 lines
13 KiB
Rust
// 本机 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);
|
||
}
|
||
}
|