feat(client): add authorized WireGuard device enrollment and desktop connection

This commit is contained in:
opsx committed 2026-10-21 14:53:55 +08:00
1 parent d2f1f5040e
commit 6dd3c7dd45
25 files changed
+3274 -131

No files matched your search

+348
View File
@@ -0,0 +1,348 @@
// 本机 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);
}
}