Refine WireGuard client routing and authentication UI
This commit is contained in:
1 parent
eabb3e4472
commit
41fb9d5631
33 files changed
+1709
-469
No files matched your search
+9
-3
@@ -4,13 +4,19 @@
|
||||
|
||||
客户端通过独立的 `/api/client/*` 接口登录拨号用户、获取授权线路、自动登记设备公钥并领取配置;私钥保存在本机 Rust 数据目录,前端不接触令牌和私钥。登录页允许 HTTP 和 HTTPS 地址,HTTP 明确提示明文风险。概览每隔 30 秒检查控制面可达性;该往返耗时不是 VPN 节点延时,连接状态以网关握手为准。
|
||||
|
||||
当前 macOS/Linux 系统级拨号仍使用本机 `wg` 和 `wg-quick`,Windows 内置隧道尚未实现。设置页报告“系统工具模式”或“当前不可用”,与 Rust 连接路径一致;工具存在不等于路由、DNS 和实际握手正常。浏览器界面预览不会调用本机检查。安装包附带官方 `wireguard-go` 的 Go 网络栈组件,支持无特权出口探测和设置页手动启动的本机 SOCKS5 代理 `socks5h://127.0.0.1:18743`。仅主动配置该代理的应用流量会经过 VPN,域名由隧道内 DNS 解析;不会创建系统 TUN 或修改系统路由,**尚不能代替系统级 VPN 拨号**。系统隧道设计、平台验收与发布门槛见 [SDD-003](../docs/specs/SDD-003-bundled-wireguard-client.md)。
|
||||
登录入口只需填写接入点的服务端地址(自动带入上次使用的地址),验证服务端健康与客户端能力后按服务端 `password/oidc` 能力展示对应入口。当前 WireGuard 客户端接口只支持密码/MFA;飞书 / Lark 仅在服务端声明 OIDC 能力时展示接入状态,浏览器一键登录按钮暂不可用。OpenVPN 的 `openvpn-auth-oauth2` 不能直接签发桌面 WireGuard 客户端会话,不能仅根据邮箱将飞书身份关联拨号用户。
|
||||
|
||||
客户端不调用本机 `wg` 或 `wg-quick`,也不会在内置直连未就绪时回退到系统工具。浏览器界面预览不会调用本机检查。安装包附带官方 `wireguard-go` 的 Go 网络栈组件,支持无特权出口探测和本机 SOCKS5 代理 `socks5h://127.0.0.1:18743`;概览和线路页可显式启动代理,并自动登记拨号用户的本机设备。macOS 上在设备配置包含 `0.0.0.0/0` 时,还可以手动启用系统 SOCKS 代理,使遵循系统代理的应用无需逐个设置;不修改系统路由,不保证所有应用或 UDP 经过节点。已有系统代理/PAC 时拒绝接管,关闭时只还原自身设置,客户端异常结束后下次启动会清理残留。实际 macOS 代理权限、浏览器 DNS 行为与故障恢复仍需真机验收。系统隧道设计、平台验收与发布门槛见 [SDD-003](../docs/specs/SDD-003-bundled-wireguard-client.md)。
|
||||
|
||||
内置 Go 原生 TUN 目前已具备 macOS 接口创建、网关物理出口只读检查、IPv4 路由计划和握手计数查询;Rust 侧有受限的子进程桥接,但尚未挂载连接按钮。系统地址、路由、DNS 的特权配置和异常恢复仍需实现并通过真机验收,现阶段不能将接口创建视为可联网。
|
||||
|
||||
## 使用与验收
|
||||
|
||||
1. 在桌面客户端使用**拨号用户**登录(管理后台的 `admin` 不是拨号用户),选择已运行、已授权的 WireGuard 专线。首次接入时客户端自动生成设备私钥并向服务端登记公钥,无需下载 `.conf`。
|
||||
2. 需要整机 VPN 时,macOS/Linux 须先安装系统 `wg` 和 `wg-quick`,在概览页连接并确认服务端握手;当前版本尚不提供免安装的系统级 VPN。
|
||||
3. 不安装系统工具时,可在设置页启动本机应用代理,再在需要访问的应用中设置 `socks5h://127.0.0.1:18743`。例如在终端验证 `curl --proxy socks5h://127.0.0.1:18743 http://www.baidu.com`。设置页“测试 VPN 出口”会连接 `223.5.5.5:443`;握手或测试成功不代表整机路由已切换。
|
||||
2. 当前版本的内置直连尚未完成,不能通过安装系统 WireGuard 工具启用直连。
|
||||
3. 可在设置页启动本机应用代理,再在需要访问的应用中设置 `socks5h://127.0.0.1:18743`。例如在终端验证 `curl --proxy socks5h://127.0.0.1:18743 http://www.baidu.com`。设置页“测试 VPN 出口”会连接 `223.5.5.5:443`;握手或测试成功不代表整机路由已切换。
|
||||
|
||||
概览的“选择线路”弹窗和线路页只列出该账号真正获授权的 WireGuard 专线;极速/全局按钮按专线的 `AllowedIPs` 选择具备该路由能力的线路。没有该模式的授权线路时按钮不可选;同一线路不能由客户端私自扩大授权路由。每条线路的“测速”使用已登记设备经 WireGuard 到管理员配置的 `latency_probe`(可访问的 IP:端口,缺省 `223.5.5.5:443`)的 TCP 建链时间,包含隧道启动与握手,并非节点 RTT;绿色表示 ≤120 ms,黄色表示较高耗时,失败或未测量时保持灰色。专线若未授权探测目标或网关未启用所需转发,测速不可用,不能把控制面健康请求当作节点延迟。
|
||||
|
||||
公网代理需要管理员为该专线下发覆盖目的地和 DNS 服务器的 `AllowedIPs`,并在 VPN 网关开启 IPv4 转发与仅匹配 WireGuard 网段的出口 NAT;现有 `opsx-client-pilot` 测试专线仍只授权 `10.88.0.0/24`,**不能直接用于公网代理**。本机 SOCKS5 监听仅绑定 `127.0.0.1`、无额外口令,其他本机进程也能使用;切换账号、撤销本机设备、退出登录或退出应用时会停止代理。客户端意外退出后代理最多约两秒内检测到父进程消失并退出。
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ pub struct Line {
|
||||
pub remark: String,
|
||||
pub state: String,
|
||||
pub endpoint: String,
|
||||
#[serde(default)]
|
||||
pub routing_mode: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -21,6 +23,15 @@ pub struct LoginResult {
|
||||
pub lines: Vec<Line>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClientCapabilities {
|
||||
pub password: bool,
|
||||
pub oidc: bool,
|
||||
#[serde(default)]
|
||||
pub oidc_provider: Option<String>,
|
||||
pub wireguard: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceConfig {
|
||||
pub device_id: u64,
|
||||
@@ -33,6 +44,8 @@ pub struct DeviceConfig {
|
||||
pub preshared_key: String,
|
||||
pub endpoint: String,
|
||||
pub allowed_ips: String,
|
||||
#[serde(default)]
|
||||
pub latency_probe: String,
|
||||
pub dns: String,
|
||||
pub mtu: u32,
|
||||
pub keepalive: u32,
|
||||
@@ -143,13 +156,8 @@ pub async fn login(
|
||||
device_name: &str,
|
||||
) -> Result<LoginResult, ApiError> {
|
||||
// 旧版服务端会把未知 /api/client/* 路径返回 200 HTML;先确认能力,避免发送密码。
|
||||
let capabilities = client()?
|
||||
.get(api_url(base_url, "/api/client/capabilities")?)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
let supported: serde_json::Value = parse_response(capabilities).await?;
|
||||
if supported.get("password").and_then(|v| v.as_bool()) != Some(true) {
|
||||
let supported = capabilities(base_url).await?;
|
||||
if !supported.password {
|
||||
return Err(ApiError::Transport("服务端尚未启用客户端密码登录".into()));
|
||||
}
|
||||
let resp = client()?
|
||||
@@ -167,6 +175,15 @@ pub async fn login(
|
||||
parse_response(resp).await
|
||||
}
|
||||
|
||||
pub async fn capabilities(base_url: &str) -> Result<ClientCapabilities, ApiError> {
|
||||
let response = client()?
|
||||
.get(api_url(base_url, "/api/client/capabilities")?)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn logout(base_url: &str, token: &str) -> Result<(), ApiError> {
|
||||
let resp = client()?
|
||||
.post(api_url(base_url, "/api/client/logout")?)
|
||||
@@ -292,4 +309,19 @@ mod tests {
|
||||
);
|
||||
assert!(api_url("ftp://vpn.example.com", "/api/client/lines").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_auth_capabilities_accept_legacy_and_provider_specific_responses() {
|
||||
let current: ClientCapabilities = serde_json::from_str(
|
||||
r#"{"password":true,"oidc":false,"wireguard":true}"#,
|
||||
).unwrap();
|
||||
assert!(current.password);
|
||||
assert!(!current.oidc);
|
||||
assert!(current.oidc_provider.is_none());
|
||||
let future: ClientCapabilities = serde_json::from_str(
|
||||
r#"{"password":false,"oidc":true,"oidc_provider":"feishu","wireguard":true}"#,
|
||||
).unwrap();
|
||||
assert!(!future.password);
|
||||
assert_eq!(future.oidc_provider.as_deref(), Some("feishu"));
|
||||
}
|
||||
}
|
||||
+196
-125
@@ -1,7 +1,10 @@
|
||||
mod api;
|
||||
mod localstore;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_tunnel;
|
||||
mod server;
|
||||
mod system;
|
||||
mod system_proxy;
|
||||
mod tunnel;
|
||||
|
||||
use std::sync::Mutex;
|
||||
@@ -13,7 +16,7 @@ use tauri::{
|
||||
AppHandle, Manager,
|
||||
};
|
||||
|
||||
use api::{DeviceConfig, DeviceInfo, DeviceStatus, Line};
|
||||
use api::{DeviceInfo, DeviceStatus, Line};
|
||||
|
||||
// ---- 全局连接状态(UI 与托盘共用同一事实来源)----
|
||||
|
||||
@@ -53,20 +56,29 @@ impl Default for ConnState {
|
||||
static CONN: Mutex<Option<ConnState>> = Mutex::new(None);
|
||||
static TRAY_MENU: Mutex<Option<tauri::menu::Menu<tauri::Wry>>> = Mutex::new(None);
|
||||
static PROXY: Mutex<Option<std::process::Child>> = Mutex::new(None);
|
||||
static PROXY_DIR: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);
|
||||
static PROXY_ROUTE: Mutex<Option<String>> = Mutex::new(None);
|
||||
// Serialize launches and teardown: two simultaneous clicks must never orphan a sidecar.
|
||||
static PROXY_OPERATION: Mutex<()> = Mutex::new(());
|
||||
const PROXY_ADDRESS: &str = "127.0.0.1:18743";
|
||||
|
||||
fn stop_proxy_inner() {
|
||||
fn proxy_dir() -> Result<std::path::PathBuf, String> {
|
||||
PROXY_DIR.lock().unwrap().clone().ok_or("无法定位系统代理恢复目录".into())
|
||||
}
|
||||
|
||||
fn stop_proxy_inner() -> Result<(), String> {
|
||||
if let Ok(dir) = proxy_dir() { system_proxy::restore(&dir)?; }
|
||||
if let Some(mut child) = PROXY.lock().unwrap().take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
*PROXY_ROUTE.lock().unwrap() = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop_proxy() {
|
||||
fn stop_proxy() -> Result<(), String> {
|
||||
let _operation = PROXY_OPERATION.lock().unwrap();
|
||||
stop_proxy_inner();
|
||||
stop_proxy_inner()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -77,12 +89,45 @@ fn client_proxy_status() -> bool {
|
||||
if child.try_wait().ok().flatten().is_none() { return true; }
|
||||
}
|
||||
*guard = None;
|
||||
if let Ok(dir) = proxy_dir() {
|
||||
if let Err(err) = system_proxy::restore(&dir) { eprintln!("代理进程停止后无法恢复系统设置:{err}"); }
|
||||
}
|
||||
*PROXY_ROUTE.lock().unwrap() = None;
|
||||
false
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn client_proxy_stop() {
|
||||
stop_proxy();
|
||||
fn client_proxy_stop() -> Result<(), String> {
|
||||
stop_proxy()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn client_system_proxy_status() -> Result<bool, String> {
|
||||
let _operation = PROXY_OPERATION.lock().unwrap();
|
||||
system_proxy::active(&proxy_dir()?)
|
||||
}
|
||||
|
||||
fn has_global_ipv4_route(route: &str) -> bool {
|
||||
route.split(',').any(|cidr| cidr.trim() == "0.0.0.0/0")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn client_system_proxy_enable() -> Result<(), String> {
|
||||
let _operation = PROXY_OPERATION.lock().unwrap();
|
||||
let route = PROXY_ROUTE.lock().unwrap().clone().ok_or("请先启动 Go 应用代理".to_string())?;
|
||||
if !has_global_ipv4_route(&route) {
|
||||
return Err("当前线路只授权内网;系统代理需要全局 IPv4 路由,否则普通网页会无法访问".into());
|
||||
}
|
||||
if !PROXY.lock().unwrap().as_mut().is_some_and(|child| child.try_wait().ok().flatten().is_none()) {
|
||||
return Err("本地代理已停止,请重新启动".into());
|
||||
}
|
||||
system_proxy::enable(&proxy_dir()?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn client_system_proxy_disable() -> Result<(), String> {
|
||||
let _operation = PROXY_OPERATION.lock().unwrap();
|
||||
system_proxy::restore(&proxy_dir()?)
|
||||
}
|
||||
|
||||
fn conn_state() -> ConnState {
|
||||
@@ -165,6 +210,12 @@ struct GoProbeResult {
|
||||
connected: bool,
|
||||
detail: String,
|
||||
target: String,
|
||||
latency_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn client_capabilities(base_url: String) -> Result<api::ClientCapabilities, String> {
|
||||
api::capabilities(&base_url).await.map_err(|err| err.message())
|
||||
}
|
||||
|
||||
fn go_probe_binary(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
@@ -194,8 +245,8 @@ fn go_probe_binary(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
.ok_or_else(|| "安装包缺少 Go 隧道组件,请重新安装客户端".into())
|
||||
}
|
||||
|
||||
/// 独立探测 WireGuard 数据面及公网 TCP,不更改系统路由/DNS。
|
||||
/// 只接收已登录账号的设备记录,探测目标固定为公共地址。
|
||||
/// 独立探测 WireGuard 数据面及已配置目标的 TCP,不更改系统路由/DNS。
|
||||
/// 只接收已登录账号的设备记录;目标由服务端下发,否则使用默认公网地址。
|
||||
#[tauri::command]
|
||||
async fn client_probe(app: AppHandle, tunnel_id: u64) -> Result<GoProbeResult, String> {
|
||||
let session = require_session(&app)?;
|
||||
@@ -213,9 +264,14 @@ async fn client_probe(app: AppHandle, tunnel_id: u64) -> Result<GoProbeResult, S
|
||||
}
|
||||
let path = tunnel::save_record(&record_dir, &record)?;
|
||||
let helper = go_probe_binary(&app)?;
|
||||
let target = if record.config.latency_probe.is_empty() {
|
||||
"223.5.5.5:443".to_string()
|
||||
} else {
|
||||
record.config.latency_probe.clone()
|
||||
};
|
||||
let output = tauri::async_runtime::spawn_blocking(move || {
|
||||
std::process::Command::new(helper)
|
||||
.arg("probe").arg(path).arg("223.5.5.5:443")
|
||||
.arg("probe").arg(path).arg(target)
|
||||
.output()
|
||||
.map_err(|err| format!("无法启动 Go 隧道探针:{err}"))
|
||||
}).await.map_err(|err| format!("Go 隧道探针执行失败:{err}"))??;
|
||||
@@ -226,19 +282,28 @@ async fn client_probe(app: AppHandle, tunnel_id: u64) -> Result<GoProbeResult, S
|
||||
/// Explicit opt-in local proxy. This has no system routing or DNS side effects.
|
||||
#[tauri::command]
|
||||
async fn client_proxy_start(app: AppHandle, tunnel_id: u64) -> Result<String, String> {
|
||||
if conn_state().phase != ConnPhase::Disconnected {
|
||||
return Err("请先断开整机 VPN,再启动应用代理,避免同一设备密钥同时建立两条隧道".into());
|
||||
}
|
||||
let session = require_session(&app)?;
|
||||
let dir = tunnel::device_dir(&data_dir(&app)?, &session.base_url, &session.username);
|
||||
let mut record = tunnel::load_record_for(&dir, tunnel_id)?;
|
||||
record.config = api::device_config(&session.base_url, &session.token, record.config.device_id)
|
||||
let lines = api::lines(&session.base_url, &session.token)
|
||||
.await.map_err(|err| err.message())?;
|
||||
if record.config.tunnel_id != tunnel_id { return Err("服务端设备线路不匹配".into()); }
|
||||
tunnel::validate_config(&record.private_key, &record.config)?;
|
||||
let line = lines.iter().find(|line| line.tunnel_id == tunnel_id)
|
||||
.ok_or("没有该线路的接入授权")?;
|
||||
if line.state != "running" {
|
||||
return Err(format!("线路「{}」未在运行,请联系管理员启动", line.name));
|
||||
}
|
||||
let dir = tunnel::device_dir(&data_dir(&app)?, &session.base_url, &session.username);
|
||||
let record = prepare_device(&dir, &session, tunnel_id, &session.device_name).await?;
|
||||
let path = tunnel::save_record(&dir, &record)?;
|
||||
let helper = go_probe_binary(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
use std::io::BufRead;
|
||||
let _operation = PROXY_OPERATION.lock().unwrap();
|
||||
stop_proxy_inner();
|
||||
if conn_state().phase != ConnPhase::Disconnected {
|
||||
return Err("整机 VPN 已在连接中,请先断开再启动应用代理".into());
|
||||
}
|
||||
stop_proxy_inner()?;
|
||||
let mut child = std::process::Command::new(helper)
|
||||
.arg("proxy").arg(path).arg(PROXY_ADDRESS)
|
||||
.stdin(std::process::Stdio::null())
|
||||
@@ -256,6 +321,7 @@ async fn client_proxy_start(app: AppHandle, tunnel_id: u64) -> Result<String, St
|
||||
Ok((Ok(_), line)) if serde_json::from_str::<serde_json::Value>(&line)
|
||||
.ok().and_then(|v| v.get("status")?.as_str().map(str::to_owned)).as_deref() == Some("ready") => {
|
||||
*PROXY.lock().unwrap() = Some(child);
|
||||
*PROXY_ROUTE.lock().unwrap() = Some(record.config.allowed_ips.clone());
|
||||
Ok(format!("socks5h://{PROXY_ADDRESS}"))
|
||||
}
|
||||
_ => {
|
||||
@@ -300,7 +366,7 @@ async fn client_login(
|
||||
device_name: device_name.clone(),
|
||||
lines: result.lines.clone(),
|
||||
};
|
||||
stop_proxy();
|
||||
stop_proxy()?;
|
||||
localstore::save(&data_dir(&app)?, &session)?;
|
||||
Ok(SessionView {
|
||||
username: result.username,
|
||||
@@ -334,20 +400,7 @@ async fn client_resume(app: AppHandle) -> Result<Option<SessionView>, String> {
|
||||
}
|
||||
Err(api::ApiError::Message(reqwest::StatusCode::UNAUTHORIZED, _, _)) => {
|
||||
// 令牌失效/被撤销:清除本地会话
|
||||
stop_proxy();
|
||||
if let Err(err) = tunnel::down(&dir) {
|
||||
let mut state = conn_state();
|
||||
state.phase = ConnPhase::Connecting; // 保留 UI 的断开入口
|
||||
state.detail = format!("登录已失效,但本机隧道未拆除:{err}");
|
||||
set_conn(Some(&app), state);
|
||||
return Ok(Some(SessionView {
|
||||
username: session.username,
|
||||
display_name: session.display_name,
|
||||
device_name: session.device_name,
|
||||
lines: vec![],
|
||||
offline: true,
|
||||
}));
|
||||
}
|
||||
stop_proxy()?;
|
||||
set_conn(Some(&app), ConnState::default());
|
||||
localstore::clear(&dir);
|
||||
Ok(None)
|
||||
@@ -364,13 +417,9 @@ async fn client_resume(app: AppHandle) -> Result<Option<SessionView>, String> {
|
||||
|
||||
#[tauri::command]
|
||||
async fn client_logout(app: AppHandle) -> Result<(), String> {
|
||||
stop_proxy();
|
||||
stop_proxy()?;
|
||||
let dir = data_dir(&app)?;
|
||||
if conn_state().phase != ConnPhase::Disconnected {
|
||||
// 保留记录与会话,确保拆隧道失败后可再次操作。
|
||||
tunnel::down(&dir)?;
|
||||
set_conn(Some(&app), ConnState::default());
|
||||
}
|
||||
set_conn(Some(&app), ConnState::default());
|
||||
if let Some(session) = localstore::load(&dir) {
|
||||
let _ = api::logout(&session.base_url, &session.token).await;
|
||||
}
|
||||
@@ -387,83 +436,28 @@ async fn client_lines(app: AppHandle) -> Result<Vec<Line>, String> {
|
||||
.map_err(|err| err.message())
|
||||
}
|
||||
|
||||
/// 一键连接:已登记设备则刷新参数,否则生成密钥对并注册;随后提权起隧道。
|
||||
/// 直连路径尚未接入特权网络服务;禁止回退到系统工具。
|
||||
#[tauri::command]
|
||||
async fn client_connect(app: AppHandle, tunnel_id: u64) -> Result<ConnState, String> {
|
||||
let session = require_session(&app)?;
|
||||
let fresh_lines = api::lines(&session.base_url, &session.token)
|
||||
.await
|
||||
.map_err(|err| err.message())?;
|
||||
let line = fresh_lines
|
||||
.iter()
|
||||
.find(|l| l.tunnel_id == tunnel_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| "没有该线路的接入授权".to_string())?;
|
||||
if line.state != "running" {
|
||||
return Err(format!("线路「{}」未在运行,请联系管理员启动", line.name));
|
||||
}
|
||||
let dir = data_dir(&app)?;
|
||||
if conn_state().phase != ConnPhase::Disconnected {
|
||||
// 切换线路前先拆旧接口;拆除失败时保持原连接和本机记录。
|
||||
tunnel::down(&dir)?;
|
||||
set_conn(Some(&app), ConnState::default());
|
||||
}
|
||||
|
||||
let mut state = conn_state();
|
||||
state.phase = ConnPhase::Connecting;
|
||||
state.detail = "正在准备设备接入参数".to_string();
|
||||
state.tunnel_name = line.name.clone();
|
||||
set_conn(Some(&app), state);
|
||||
|
||||
let result = do_connect(&dir, &session, tunnel_id, &session.device_name).await;
|
||||
match result {
|
||||
Ok(config) => {
|
||||
let next = ConnState {
|
||||
phase: ConnPhase::Connecting, // 只有服务端观测到握手后才转为 Connected
|
||||
detail: "隧道已建立,等待握手确认".to_string(),
|
||||
tunnel_name: config.tunnel_name.clone(),
|
||||
client_address: config.client_address.clone(),
|
||||
endpoint: config.endpoint.clone(),
|
||||
device_id: config.device_id,
|
||||
since_unix: now_unix(),
|
||||
};
|
||||
set_conn(Some(&app), next.clone());
|
||||
Ok(next)
|
||||
}
|
||||
Err(err) => {
|
||||
let mut back = conn_state();
|
||||
back.phase = ConnPhase::Disconnected;
|
||||
back.detail = err.clone();
|
||||
set_conn(Some(&app), back);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
async fn client_connect(_app: AppHandle, _tunnel_id: u64) -> Result<ConnState, String> {
|
||||
Err("内置直连尚未完成系统网络配置;当前可使用 Go 应用代理".into())
|
||||
}
|
||||
|
||||
async fn do_connect(
|
||||
dir: &std::path::Path,
|
||||
// 拨号用户授权后取得设备配置,供 Go 应用代理使用。
|
||||
async fn prepare_device(
|
||||
record_dir: &std::path::Path,
|
||||
session: &localstore::Session,
|
||||
tunnel_id: u64,
|
||||
device_name: &str,
|
||||
) -> Result<DeviceConfig, String> {
|
||||
// 已登记同线路设备:刷新连接参数;否则生成新密钥对并注册
|
||||
let record_dir = tunnel::device_dir(dir, &session.base_url, &session.username);
|
||||
let record = tunnel::load_record_for(&record_dir, tunnel_id).ok();
|
||||
let config = match record {
|
||||
) -> Result<tunnel::TunnelRecord, String> {
|
||||
let record = tunnel::load_record_for(record_dir, tunnel_id).ok();
|
||||
let prepared = match record {
|
||||
Some(rec) if rec.config.tunnel_id == tunnel_id => {
|
||||
let cfg = api::device_config(&session.base_url, &session.token, rec.config.device_id)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
format!("刷新设备参数失败(设备可能已被撤销):{}", err.message())
|
||||
})?;
|
||||
tunnel::save_record(
|
||||
&record_dir,
|
||||
&tunnel::TunnelRecord {
|
||||
private_key: rec.private_key,
|
||||
config: cfg.clone(),
|
||||
},
|
||||
)?;
|
||||
cfg
|
||||
tunnel::TunnelRecord { private_key: rec.private_key, config: cfg }
|
||||
}
|
||||
_ => {
|
||||
let (private_key, public_key) = tunnel::generate_keypair()?;
|
||||
@@ -476,28 +470,19 @@ async fn do_connect(
|
||||
)
|
||||
.await
|
||||
.map_err(|err| err.message())?;
|
||||
tunnel::save_record(
|
||||
&record_dir,
|
||||
&tunnel::TunnelRecord {
|
||||
private_key,
|
||||
config: cfg.clone(),
|
||||
},
|
||||
)?;
|
||||
cfg
|
||||
tunnel::TunnelRecord { private_key, config: cfg }
|
||||
}
|
||||
};
|
||||
tunnel::up(
|
||||
dir,
|
||||
&tunnel::load_record_for(&record_dir, tunnel_id)?.private_key,
|
||||
&config,
|
||||
)?;
|
||||
Ok(config)
|
||||
if prepared.config.tunnel_id != tunnel_id {
|
||||
return Err("服务端设备线路不匹配".into());
|
||||
}
|
||||
tunnel::validate_config(&prepared.private_key, &prepared.config)?;
|
||||
tunnel::save_record(record_dir, &prepared)?;
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn client_disconnect(app: AppHandle) -> Result<(), String> {
|
||||
let dir = data_dir(&app)?;
|
||||
tunnel::down(&dir)?;
|
||||
set_conn(Some(&app), ConnState::default());
|
||||
Ok(())
|
||||
}
|
||||
@@ -575,7 +560,6 @@ async fn client_revoke_device(app: AppHandle, device_id: u64) -> Result<(), Stri
|
||||
.is_some_and(|r| r.config.device_id == device_id)
|
||||
&& conn_state().device_id == device_id
|
||||
{
|
||||
tunnel::down(&dir)?;
|
||||
set_conn(Some(&app), ConnState::default());
|
||||
}
|
||||
api::revoke_device(&session.base_url, &session.token, device_id)
|
||||
@@ -585,7 +569,7 @@ async fn client_revoke_device(app: AppHandle, device_id: u64) -> Result<(), Stri
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.config.device_id == device_id)
|
||||
{
|
||||
stop_proxy();
|
||||
stop_proxy()?;
|
||||
tunnel::clear_record_for(&record_dir, device.tunnel_id);
|
||||
}
|
||||
Ok(())
|
||||
@@ -612,6 +596,17 @@ fn now_unix() -> i64 {
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
if let Ok(dir) = app.path().app_config_dir() {
|
||||
*PROXY_DIR.lock().unwrap() = Some(dir.clone());
|
||||
if let Err(err) = system_proxy::restore(&dir) {
|
||||
eprintln!("无法恢复系统代理:{err}");
|
||||
}
|
||||
}
|
||||
std::thread::spawn(|| loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
// Covers an unexpected Go process exit even with no open WebView.
|
||||
let _ = client_proxy_status();
|
||||
});
|
||||
let status = MenuItem::with_id(app, "status", "VPN:尚未连接", false, None::<&str>)?;
|
||||
let open = MenuItem::with_id(app, "open", "打开 OpsX VPN", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
||||
@@ -633,10 +628,9 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
stop_proxy();
|
||||
// 退出前尽力拆除本机隧道,避免残留接口与路由
|
||||
if let Ok(dir) = app.path().app_config_dir() {
|
||||
let _ = tunnel::down(&dir);
|
||||
if let Err(err) = stop_proxy() {
|
||||
eprintln!("无法关闭系统代理,客户端保持运行:{err}");
|
||||
return;
|
||||
}
|
||||
app.exit(0);
|
||||
}
|
||||
@@ -649,6 +643,7 @@ pub fn run() {
|
||||
server::check_server,
|
||||
system::get_system_readiness,
|
||||
client_login,
|
||||
client_capabilities,
|
||||
client_resume,
|
||||
client_logout,
|
||||
client_lines,
|
||||
@@ -659,11 +654,87 @@ pub fn run() {
|
||||
client_proxy_start,
|
||||
client_proxy_stop,
|
||||
client_proxy_status,
|
||||
client_system_proxy_status,
|
||||
client_system_proxy_enable,
|
||||
client_system_proxy_disable,
|
||||
client_devices,
|
||||
client_revoke_device,
|
||||
client_device_name,
|
||||
client_conn_state,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("failed to start OpsX VPN client");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("failed to build OpsX VPN client")
|
||||
.run(|_, event| {
|
||||
if let tauri::RunEvent::ExitRequested { api, .. } = event {
|
||||
if let Err(err) = stop_proxy() {
|
||||
eprintln!("无法恢复系统代理,暂缓退出:{err}");
|
||||
api.prevent_exit();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod device_flow_tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
#[test]
|
||||
fn system_proxy_requires_an_actual_global_route() {
|
||||
assert!(!has_global_ipv4_route("10.88.0.0/24"));
|
||||
assert!(!has_global_ipv4_route("10.88.0.0/24, 0.0.0.0/00"));
|
||||
assert!(has_global_ipv4_route("10.88.0.0/24, 0.0.0.0/0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_proxy_can_register_then_refresh_a_device_without_system_tools() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (_, public_key) = tunnel::generate_keypair().unwrap();
|
||||
let (_, psk) = tunnel::generate_keypair().unwrap();
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let response = serde_json::json!({
|
||||
"device_id": 11, "device_name": "test-mac", "tunnel_id": 4,
|
||||
"tunnel_name": "test", "peer_id": "dev-11", "client_address": "10.8.0.7/32",
|
||||
"server_public_key": public_key, "preshared_key": psk,
|
||||
"endpoint": "127.0.0.1:51820", "allowed_ips": "10.8.0.0/24",
|
||||
"dns": "10.8.0.1", "mtu": 1420, "keepalive": 25
|
||||
}).to_string();
|
||||
let server = std::thread::spawn(move || {
|
||||
for (index, expected) in ["POST /api/client/devices ", "GET /api/client/devices/11/config "].iter().enumerate() {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_secs(3))).unwrap();
|
||||
let mut request = vec![];
|
||||
let mut buffer = [0u8; 4096];
|
||||
loop {
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
assert!(len > 0);
|
||||
request.extend_from_slice(&buffer[..len]);
|
||||
if request.windows(4).any(|part| part == b"\r\n\r\n") { break; }
|
||||
}
|
||||
let raw = String::from_utf8_lossy(&request);
|
||||
assert!(raw.starts_with(expected), "request #{index} used wrong endpoint");
|
||||
assert!(raw.contains("authorization: Bearer test-token") || raw.contains("Authorization: Bearer test-token"));
|
||||
sender.send(raw.into_owned()).unwrap();
|
||||
write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", response.len(), response).unwrap();
|
||||
}
|
||||
});
|
||||
let session = localstore::Session {
|
||||
base_url: format!("http://{address}"), token: "test-token".into(),
|
||||
username: "alice".into(), display_name: "Alice".into(),
|
||||
device_name: "test-mac".into(), lines: vec![],
|
||||
};
|
||||
let dir = std::env::temp_dir().join(format!("opsx-device-flow-{}", std::process::id()));
|
||||
let first = tauri::async_runtime::block_on(prepare_device(&dir, &session, 4, "test-mac")).unwrap();
|
||||
let second = tauri::async_runtime::block_on(prepare_device(&dir, &session, 4, "test-mac")).unwrap();
|
||||
assert_eq!(first.private_key, second.private_key);
|
||||
assert_eq!(first.config.device_id, 11);
|
||||
assert_eq!(tunnel::load_record_for(&dir, 4).unwrap().private_key, first.private_key);
|
||||
for request in receiver.try_iter() {
|
||||
assert!(!request.contains(&first.private_key), "private key sent to server");
|
||||
}
|
||||
server.join().unwrap();
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//! The unprivileged Go data-plane process. This module deliberately does not
|
||||
//! configure OS routes or report an application-level connected state.
|
||||
#![cfg(target_os = "macos")]
|
||||
#![allow(dead_code)] // Connected after the privileged network helper is installed.
|
||||
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
path::Path,
|
||||
process::{Child, ChildStdin, Command, Stdio},
|
||||
sync::mpsc::{self, Receiver},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct NativePlan {
|
||||
pub interface: String,
|
||||
pub client_address: String,
|
||||
pub routes: Vec<String>,
|
||||
pub endpoint: String,
|
||||
pub endpoint_bypass: bool,
|
||||
pub physical_exit: Option<PhysicalExit>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct PhysicalExit {
|
||||
pub interface: String,
|
||||
pub gateway: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct NativeCounters {
|
||||
pub handshake_unix: u64,
|
||||
pub rx_bytes: u64,
|
||||
pub tx_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Event {
|
||||
status: String,
|
||||
plan: Option<NativePlan>,
|
||||
handshake_unix: Option<u64>,
|
||||
rx_bytes: Option<u64>,
|
||||
tx_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
pub(crate) struct NativeProcess {
|
||||
child: Child,
|
||||
input: ChildStdin,
|
||||
events: Receiver<String>,
|
||||
}
|
||||
|
||||
impl NativeProcess {
|
||||
pub(crate) fn start(binary: &Path, record: &Path) -> Result<(Self, NativePlan), String> {
|
||||
let mut child = Command::new(binary)
|
||||
.arg("tun")
|
||||
.arg(record)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
// Upstream errors might mention addresses; never forward process output to the UI.
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|_| "无法启动内置 Go 隧道组件".to_string())?;
|
||||
let input = child.stdin.take().ok_or("无法控制 Go 隧道组件")?;
|
||||
let output = child.stdout.take().ok_or("无法读取 Go 隧道组件")?;
|
||||
let (sender, events) = mpsc::sync_channel(4);
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(output).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if sender.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let process = Self {
|
||||
child,
|
||||
input,
|
||||
events,
|
||||
};
|
||||
let line = process
|
||||
.events
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.map_err(|_| "内置隧道没有及时创建本机接口".to_string())?;
|
||||
let plan = decode_plan(&line)?;
|
||||
Ok((process, plan))
|
||||
}
|
||||
|
||||
pub(crate) fn counters(&mut self) -> Result<NativeCounters, String> {
|
||||
if self
|
||||
.child
|
||||
.try_wait()
|
||||
.map_err(|_| "无法检查内置隧道状态")?
|
||||
.is_some()
|
||||
{
|
||||
return Err("内置 Go 隧道已退出".into());
|
||||
}
|
||||
self.input
|
||||
.write_all(b"status\n")
|
||||
.map_err(|_| "内置隧道已断开")?;
|
||||
let line = self
|
||||
.events
|
||||
.recv_timeout(Duration::from_secs(3))
|
||||
.map_err(|_| "无法获取内置隧道握手状态".to_string())?;
|
||||
decode_counters(&line)
|
||||
}
|
||||
|
||||
pub(crate) fn stop(mut self) {
|
||||
let _ = self.input.write_all(b"stop\n");
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
if self.child.try_wait().ok().flatten().is_some() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NativeProcess {
|
||||
fn drop(&mut self) {
|
||||
if self.child.try_wait().ok().flatten().is_none() {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_plan(line: &str) -> Result<NativePlan, String> {
|
||||
let event: Event = serde_json::from_str(line).map_err(|_| "内置隧道接口事件无效")?;
|
||||
if event.status != "interface_created" {
|
||||
return Err("内置隧道未创建接口".into());
|
||||
}
|
||||
let plan = event.plan.ok_or("内置隧道没有路由计划")?;
|
||||
if !plan.interface.starts_with("utun")
|
||||
|| plan.interface[4..].is_empty()
|
||||
|| !plan.interface[4..].bytes().all(|b| b.is_ascii_digit())
|
||||
|| !ipv4_cidr(&plan.client_address)
|
||||
|| plan.routes.is_empty()
|
||||
|| !plan.routes.iter().all(|r| ipv4_cidr(r))
|
||||
|| !ipv4(&plan.endpoint)
|
||||
|| (plan.endpoint_bypass && plan.physical_exit.is_none())
|
||||
{
|
||||
return Err("内置隧道网络计划无效".into());
|
||||
}
|
||||
if let Some(exit) = &plan.physical_exit {
|
||||
if exit.interface.starts_with("utun")
|
||||
|| exit.interface == "lo0"
|
||||
|| exit.interface.len() < 2
|
||||
|| exit.interface.len() > 16
|
||||
|| !exit.interface.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||
|| exit.gateway.as_ref().is_some_and(|gateway| !ipv4(gateway))
|
||||
{
|
||||
return Err("内置隧道物理出口无效".into());
|
||||
}
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
fn ipv4(value: &str) -> bool {
|
||||
value.parse::<std::net::Ipv4Addr>().is_ok()
|
||||
}
|
||||
|
||||
fn ipv4_cidr(value: &str) -> bool {
|
||||
let Some((ip, prefix)) = value.split_once('/') else {
|
||||
return false;
|
||||
};
|
||||
ipv4(ip) && prefix.parse::<u8>().is_ok_and(|bits| bits <= 32)
|
||||
}
|
||||
|
||||
fn decode_counters(line: &str) -> Result<NativeCounters, String> {
|
||||
let event: Event = serde_json::from_str(line).map_err(|_| "内置隧道状态无效")?;
|
||||
if event.status != "device_status" {
|
||||
return Err("内置隧道状态类型无效".into());
|
||||
}
|
||||
Ok(NativeCounters {
|
||||
handshake_unix: event.handshake_unix.ok_or("缺少握手时间")?,
|
||||
rx_bytes: event.rx_bytes.ok_or("缺少接收计数")?,
|
||||
tx_bytes: event.tx_bytes.ok_or("缺少发送计数")?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn interface_event_does_not_claim_connection() {
|
||||
let json = r#"{"status":"interface_created","plan":{"interface":"utun12","client_address":"10.88.0.3/32","routes":["10.88.0.0/24"],"endpoint":"121.4.111.208","endpoint_bypass":false}}"#;
|
||||
let plan = decode_plan(json).unwrap();
|
||||
assert_eq!(plan.interface, "utun12");
|
||||
assert!(decode_counters(json).is_err());
|
||||
assert!(decode_plan(&json.replace("utun12", "utun1;whoami")).is_err());
|
||||
assert!(decode_plan(&json.replace("10.88.0.0/24", "::/0")).is_err());
|
||||
assert!(decode_plan(r#"{"status":"ready"}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_and_bytes_require_explicit_status_event() {
|
||||
let status = decode_counters(
|
||||
r#"{"status":"device_status","handshake_unix":123,"rx_bytes":2,"tx_bytes":3}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(status.handshake_unix, status.rx_bytes, status.tx_bytes),
|
||||
(123, 2, 3)
|
||||
);
|
||||
assert!(decode_counters(r#"{"status":"device_status","handshake_unix":123}"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,7 @@ use serde::Serialize;
|
||||
#[derive(Serialize)]
|
||||
pub struct SystemReadiness {
|
||||
platform: &'static str,
|
||||
wg_found: bool,
|
||||
wg_quick_found: bool,
|
||||
app_proxy_available: bool,
|
||||
engine_mode: EngineMode,
|
||||
engine_detail: &'static str,
|
||||
}
|
||||
@@ -12,60 +11,26 @@ pub struct SystemReadiness {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum EngineMode {
|
||||
SystemTools,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
fn engine_for(platform: &str, wg: bool, wg_quick: bool) -> (EngineMode, &'static str) {
|
||||
fn engine_for(platform: &str) -> (EngineMode, &'static str) {
|
||||
match platform {
|
||||
"windows" => (EngineMode::Unsupported, "Windows 内置隧道尚未实现"),
|
||||
"macos" | "linux" if wg && wg_quick => (
|
||||
EngineMode::SystemTools,
|
||||
"当前使用系统 WireGuard 工具;免安装引擎尚未交付",
|
||||
),
|
||||
"macos" | "linux" => (
|
||||
EngineMode::Unsupported,
|
||||
"当前版本需要本机 WireGuard 工具;免安装引擎尚未交付",
|
||||
"内置直连暂不可用",
|
||||
),
|
||||
_ => (EngineMode::Unsupported, "当前系统尚不支持连接"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn active_engine() -> Result<EngineMode, &'static str> {
|
||||
let (mode, detail) = engine_for(
|
||||
std::env::consts::OS,
|
||||
binary_path("wg").is_some(),
|
||||
binary_path("wg-quick").is_some(),
|
||||
);
|
||||
if mode == EngineMode::Unsupported { Err(detail) } else { Ok(mode) }
|
||||
}
|
||||
|
||||
pub(crate) fn binary_path(name: &str) -> Option<std::path::PathBuf> {
|
||||
let mut directories: Vec<_> = std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect();
|
||||
// Finder/Dock 启动的 macOS 应用通常没有 Homebrew 的 PATH。
|
||||
#[cfg(target_os = "macos")]
|
||||
directories.extend(["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"].map(std::path::PathBuf::from));
|
||||
directories.into_iter().find_map(|directory| {
|
||||
let path = directory.join(name);
|
||||
if path.is_file() { return Some(path); }
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let exe = directory.join(format!("{name}.exe"));
|
||||
if exe.is_file() { return Some(exe); }
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_system_readiness() -> SystemReadiness {
|
||||
let wg_found = binary_path("wg").is_some();
|
||||
let wg_quick_found = binary_path("wg-quick").is_some();
|
||||
let (engine_mode, engine_detail) = engine_for(std::env::consts::OS, wg_found, wg_quick_found);
|
||||
pub fn get_system_readiness(app: tauri::AppHandle) -> SystemReadiness {
|
||||
let (engine_mode, engine_detail) = engine_for(std::env::consts::OS);
|
||||
SystemReadiness {
|
||||
platform: std::env::consts::OS,
|
||||
wg_found,
|
||||
wg_quick_found,
|
||||
app_proxy_available: crate::go_probe_binary(&app).is_ok(),
|
||||
engine_mode,
|
||||
engine_detail,
|
||||
}
|
||||
@@ -76,10 +41,9 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_tools_never_report_a_bundled_engine() {
|
||||
assert_eq!(engine_for("macos", false, false).0, EngineMode::Unsupported);
|
||||
assert_eq!(engine_for("linux", true, false).0, EngineMode::Unsupported);
|
||||
assert_eq!(engine_for("macos", true, true).0, EngineMode::SystemTools);
|
||||
assert_eq!(engine_for("windows", true, true).0, EngineMode::Unsupported);
|
||||
fn direct_connection_stays_unavailable_until_bundled_engine_is_ready() {
|
||||
for platform in ["macos", "linux", "windows"] {
|
||||
assert_eq!(engine_for(platform).0, EngineMode::Unsupported);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! macOS SOCKS proxy ownership. Never clear a setting we no longer own.
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac {
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::Write;
|
||||
use std::process::Command;
|
||||
|
||||
const HOST: &str = "127.0.0.1";
|
||||
const PORT: &str = "18743";
|
||||
const RECORD: &str = "system-proxy.json";
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Record { services: Vec<String> }
|
||||
|
||||
fn networksetup(args: &[&str]) -> Result<String, String> {
|
||||
let result = Command::new("/usr/sbin/networksetup").args(args).output()
|
||||
.map_err(|e| format!("无法读取系统网络设置:{e}"))?;
|
||||
if !result.status.success() {
|
||||
return Err(format!("无法更新系统代理:{}", String::from_utf8_lossy(&result.stderr).trim()));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&result.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn enabled(value: &str) -> bool {
|
||||
value.lines().any(|line| line.trim().eq_ignore_ascii_case("Enabled: Yes") || line.trim().eq_ignore_ascii_case("Auto Proxy Discovery: On"))
|
||||
}
|
||||
|
||||
fn services() -> Result<Vec<String>, String> {
|
||||
let result = networksetup(&["-listallnetworkservices"])?;
|
||||
Ok(result.lines().skip(1).filter(|s| !s.starts_with('*') && !s.trim().is_empty())
|
||||
.map(str::to_owned).collect())
|
||||
}
|
||||
|
||||
fn ours(service: &str) -> Result<bool, String> {
|
||||
let value = networksetup(&["-getsocksfirewallproxy", service])?;
|
||||
Ok(enabled(&value) && value.lines().any(|v| v.trim() == format!("Server: {HOST}"))
|
||||
&& value.lines().any(|v| v.trim() == format!("Port: {PORT}")))
|
||||
}
|
||||
|
||||
fn record_path(dir: &Path) -> std::path::PathBuf { dir.join(RECORD) }
|
||||
|
||||
pub fn active(dir: &Path) -> Result<bool, String> {
|
||||
if !record_path(dir).exists() { return Ok(false); }
|
||||
let record: Record = serde_json::from_slice(&std::fs::read(record_path(dir)).map_err(|e| e.to_string())?)
|
||||
.map_err(|_| "系统代理恢复记录损坏,请检查网络设置".to_string())?;
|
||||
for service in &record.services {
|
||||
if ours(service)? { return Ok(true); }
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn restore(dir: &Path) -> Result<(), String> {
|
||||
let path = record_path(dir);
|
||||
if !path.exists() { return Ok(()); }
|
||||
let record: Record = serde_json::from_slice(&std::fs::read(&path).map_err(|e| e.to_string())?)
|
||||
.map_err(|_| "系统代理恢复记录损坏,请检查网络设置".to_string())?;
|
||||
for service in &record.services {
|
||||
if ours(service)? {
|
||||
networksetup(&["-setsocksfirewallproxystate", service, "off"])?;
|
||||
}
|
||||
}
|
||||
std::fs::remove_file(path).map_err(|e| format!("无法清理系统代理恢复记录:{e}"))
|
||||
}
|
||||
|
||||
pub fn enable(dir: &Path) -> Result<(), String> {
|
||||
// A stale record may mean a prior process crashed. Never overwrite it.
|
||||
restore(dir)?;
|
||||
let list = services()?;
|
||||
if list.is_empty() { return Err("没有可用的 macOS 网络服务".into()); }
|
||||
for service in &list {
|
||||
for kind in ["-getwebproxy", "-getsecurewebproxy", "-getsocksfirewallproxy", "-getautoproxyurl", "-getproxyautodiscovery"] {
|
||||
let current = networksetup(&[kind, service])?;
|
||||
if enabled(¤t) || (kind == "-getsocksfirewallproxy"
|
||||
&& current.lines().any(|line| line.starts_with("Server:")
|
||||
&& line.trim() != "Server:" && line.trim() != format!("Server: {HOST}"))) {
|
||||
return Err(format!("网络服务「{service}」已有代理设置,请先关闭其他代理再启用"));
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
// Record ownership before the first OS write, so interrupted startup can recover.
|
||||
let path = record_path(dir);
|
||||
let data = serde_json::to_vec(&Record { services: list.clone() }).map_err(|e| e.to_string())?;
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path)
|
||||
.map_err(|e| format!("无法保存系统代理恢复记录:{e}"))?;
|
||||
#[cfg(unix)] {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
file.write_all(&data).and_then(|_| file.sync_all()).map_err(|e| format!("无法持久化系统代理恢复记录:{e}"))?;
|
||||
for service in &list {
|
||||
if let Err(err) = networksetup(&["-setsocksfirewallproxy", service, HOST, PORT]) {
|
||||
return match restore(dir) { Ok(()) => Err(err), Err(recovery) => Err(format!("{err};恢复失败:{recovery}")) };
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::enabled;
|
||||
#[test]
|
||||
fn parses_networksetup_state() {
|
||||
assert!(enabled("Enabled: Yes\nServer: 127.0.0.1"));
|
||||
assert!(!enabled("Enabled: No\nServer: 127.0.0.1"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use mac::{active, enable, restore};
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn active(_: &Path) -> Result<bool, String> { Ok(false) }
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn restore(_: &Path) -> Result<(), String> { Ok(()) }
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn enable(_: &Path) -> Result<(), String> { Err("系统代理目前仅支持 macOS".into()) }
|
||||
@@ -1,8 +1,4 @@
|
||||
// 本机 WireGuard 隧道:密钥生成、配置渲染与提权启停。
|
||||
//
|
||||
// 私钥只在本机生成、只保存在本机数据目录(0600),绝不上传。
|
||||
// 启停需要系统权限:Linux 走 pkexec,macOS 走 osascript 管理员授权;
|
||||
// Windows 暂无受控提权通道,明确报错而不是静默失败。
|
||||
// 本机设备密钥与配置记录。内置网络引擎未就绪前不启动系统隧道。
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use base64::Engine;
|
||||
@@ -19,25 +15,6 @@ pub fn generate_keypair() -> Result<(String, String), String> {
|
||||
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,
|
||||
@@ -83,7 +60,6 @@ pub fn clear_record_for(dir: &Path, tunnel_id: u64) {
|
||||
|
||||
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)]
|
||||
@@ -109,13 +85,6 @@ 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
|
||||
@@ -174,112 +143,6 @@ pub(crate) fn validate_config(private_key: &str, cfg: &DeviceConfig) -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_binary(name: &str) -> Result<String, String> {
|
||||
crate::system::binary_path(name)
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.ok_or_else(|| format!("未找到 {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!(
|
||||
"PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH; export PATH; {} {} {}",
|
||||
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}"))?;
|
||||
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('\'', "'\\''"))
|
||||
}
|
||||
|
||||
/// 所有引擎必须提供同一启停契约;以后内置引擎在此实现而非改动控制面。
|
||||
trait TunnelEngine {
|
||||
fn up(&self, dir: &Path, private_key: &str, cfg: &DeviceConfig) -> Result<PathBuf, String>;
|
||||
fn down(&self, dir: &Path) -> Result<(), String>;
|
||||
}
|
||||
|
||||
struct SystemToolsEngine;
|
||||
|
||||
impl TunnelEngine for SystemToolsEngine {
|
||||
fn up(&self, 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)
|
||||
}
|
||||
|
||||
fn down(&self, dir: &Path) -> Result<(), String> {
|
||||
let path = dir.join("opsx-wg.conf");
|
||||
if !path.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
run_wg_quick("down", &path)
|
||||
}
|
||||
}
|
||||
|
||||
/// 建立隧道:仅使用检测到的执行层,不能回退到 PATH 中的其他二进制。
|
||||
pub fn up(dir: &Path, private_key: &str, cfg: &DeviceConfig) -> Result<PathBuf, String> {
|
||||
match crate::system::active_engine().map_err(str::to_string)? {
|
||||
crate::system::EngineMode::SystemTools => SystemToolsEngine.up(dir, private_key, cfg),
|
||||
crate::system::EngineMode::Unsupported => Err("当前系统不支持连接".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 拆除已建立的接口,即使连接后本机工具被移走也要尝试清理。
|
||||
pub fn down(dir: &Path) -> Result<(), String> {
|
||||
SystemToolsEngine.down(dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -296,6 +159,7 @@ mod tests {
|
||||
preshared_key: "psk".to_string(),
|
||||
endpoint: "vpn.example.com:51820".to_string(),
|
||||
allowed_ips: "0.0.0.0/0, ::/0".to_string(),
|
||||
latency_probe: "".to_string(),
|
||||
dns: "223.5.5.5".to_string(),
|
||||
mtu: 1420,
|
||||
keepalive: 25,
|
||||
@@ -314,24 +178,6 @@ mod tests {
|
||||
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]);
|
||||
|
||||
+234
-91
@@ -1,15 +1,28 @@
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
checkServer, clientConnect, clientDeviceName, clientDevices, clientDisconnect, clientProbe, clientProxyStart, clientProxyStop, clientProxyStatus,
|
||||
checkServer, clientCapabilities, clientDeviceName, clientDevices, clientDisconnect, clientProbe, clientProxyStart, clientProxyStop, clientProxyStatus, clientSystemProxyStatus, clientSystemProxyEnable, clientSystemProxyDisable,
|
||||
clientLines, clientLogin, clientLogout, clientResume, clientRevokeDevice, clientStatus,
|
||||
getSystemReadiness,
|
||||
type ConnState, type DeviceInfo, type Line, type SessionView, type StatusView,
|
||||
type ClientCapabilities, type ConnState, type DeviceInfo, type Line, type ProbeResult, type SessionView, type StatusView,
|
||||
type SystemReadiness,
|
||||
} from './platform'
|
||||
import './system.css'
|
||||
import './entry.css'
|
||||
|
||||
type Page = 'overview' | 'routes' | 'settings'
|
||||
|
||||
function unitLabel(address: string) {
|
||||
try { return new URL(address).host } catch { return address }
|
||||
}
|
||||
|
||||
function Signal({ ms }: { ms?: number }) {
|
||||
const quality = ms === undefined ? 'unknown' : ms <= 120 ? 'good' : 'slow'
|
||||
return <span className={`node-signal ${quality}`} aria-label={ms === undefined ? '尚未测速' : `VPN 隧道 TCP 建链 ${ms} 毫秒`}>
|
||||
<span className="signal-bars" aria-hidden="true"><i /><i /><i /></span>
|
||||
<span>{ms === undefined ? '未测速' : `${ms} ms`}</span>
|
||||
</span>
|
||||
}
|
||||
|
||||
function Icon({ name, size = 22 }: { name: 'home' | 'globe' | 'settings' | 'power' | 'shield' | 'server' | 'check' | 'close' | 'user' | 'device'; size?: number }) {
|
||||
const paths = {
|
||||
home: <path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1z" />,
|
||||
@@ -48,12 +61,22 @@ export default function App() {
|
||||
const [conn, setConn] = useState<ConnState | null>(null)
|
||||
const [status, setStatus] = useState<StatusView | null>(null)
|
||||
const [selectedLine, setSelectedLine] = useState<number | null>(null)
|
||||
const [selectedMode, setSelectedMode] = useState<'split' | 'global_ipv4'>('split')
|
||||
const [lines, setLines] = useState<Line[]>([])
|
||||
const [actionError, setActionError] = useState('')
|
||||
const [actionBusy, setActionBusy] = useState(false)
|
||||
const actionLock = useRef(false)
|
||||
const [readiness, setReadiness] = useState<SystemReadiness | null>(null)
|
||||
const [now, setNow] = useState(() => Math.floor(Date.now() / 1000))
|
||||
const [nodePickerOpen, setNodePickerOpen] = useState(false)
|
||||
const [latencies, setLatencies] = useState<Record<number, number>>({})
|
||||
const [probeBusyLine, setProbeBusyLine] = useState<number | null>(null)
|
||||
const [probeMessage, setProbeMessage] = useState('')
|
||||
const [proxyRunning, setProxyRunning] = useState(false)
|
||||
const [systemProxyOn, setSystemProxyOn] = useState(false)
|
||||
const [proxyBusy, setProxyBusy] = useState(false)
|
||||
const [proxyError, setProxyError] = useState('')
|
||||
const proxyActionLock = useRef(false)
|
||||
|
||||
// 启动时恢复本机会话
|
||||
useEffect(() => {
|
||||
@@ -108,6 +131,25 @@ export default function App() {
|
||||
getSystemReadiness().then(setReadiness).catch(() => undefined)
|
||||
}, [session])
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
let active = true
|
||||
const refresh = () => {
|
||||
clientProxyStatus().then(running => { if (active) setProxyRunning(running) }).catch(() => undefined)
|
||||
clientSystemProxyStatus().then(on => { if (active) setSystemProxyOn(on) }).catch(error => { if (active) setProxyError(String(error)) })
|
||||
}
|
||||
void refresh()
|
||||
const timer = window.setInterval(refresh, 5000)
|
||||
return () => { active = false; window.clearInterval(timer) }
|
||||
}, [session])
|
||||
|
||||
useEffect(() => {
|
||||
if (!nodePickerOpen) return
|
||||
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') setNodePickerOpen(false) }
|
||||
window.addEventListener('keydown', closeOnEscape)
|
||||
return () => window.removeEventListener('keydown', closeOnEscape)
|
||||
}, [nodePickerOpen])
|
||||
|
||||
function beginAction() {
|
||||
if (actionLock.current) return false
|
||||
actionLock.current = true
|
||||
@@ -120,25 +162,6 @@ export default function App() {
|
||||
setActionBusy(false)
|
||||
}
|
||||
|
||||
async function handleConnect(tunnelId?: number) {
|
||||
if (!beginAction()) return
|
||||
setActionError('')
|
||||
const target = tunnelId ?? selectedLine ?? lines.find(l => l.state === 'running')?.tunnel_id ?? lines[0]?.tunnel_id
|
||||
if (!target) {
|
||||
setActionError('没有可用线路,请先在“线路”页查看授权。')
|
||||
endAction()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const next = await clientConnect(target)
|
||||
setConn(next)
|
||||
setSelectedLine(target)
|
||||
setPage('overview')
|
||||
} catch (cause) {
|
||||
setActionError(cause instanceof Error ? cause.message : String(cause))
|
||||
} finally { endAction() }
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
if (!beginAction()) return
|
||||
setActionError('')
|
||||
@@ -151,6 +174,59 @@ export default function App() {
|
||||
} finally { endAction() }
|
||||
}
|
||||
|
||||
async function measureLine(line: Line) {
|
||||
if (probeBusyLine !== null) return
|
||||
setProbeBusyLine(line.tunnel_id)
|
||||
setProbeMessage('')
|
||||
try {
|
||||
const result: ProbeResult = await clientProbe(line.tunnel_id)
|
||||
if (!result.connected || result.latency_ms === undefined) throw new Error(result.detail)
|
||||
setLatencies(previous => ({ ...previous, [line.tunnel_id]: result.latency_ms! }))
|
||||
} catch (cause) {
|
||||
setLatencies(previous => { const next = { ...previous }; delete next[line.tunnel_id]; return next })
|
||||
setProbeMessage(`${line.name}:${cause instanceof Error ? cause.message : String(cause)}`)
|
||||
} finally { setProbeBusyLine(null) }
|
||||
}
|
||||
|
||||
async function toggleProxy(tunnelId?: number) {
|
||||
if (proxyActionLock.current) return
|
||||
proxyActionLock.current = true
|
||||
setProxyBusy(true)
|
||||
setProxyError('')
|
||||
try {
|
||||
if (await clientProxyStatus() && tunnelId === undefined) {
|
||||
await clientProxyStop()
|
||||
setProxyRunning(false)
|
||||
setSystemProxyOn(false)
|
||||
} else {
|
||||
if (await clientProxyStatus()) { await clientProxyStop(); setProxyRunning(false); setSystemProxyOn(false) }
|
||||
const target = tunnelId ?? selectedLine ?? lines.find(line => line.state === 'running')?.tunnel_id
|
||||
const line = lines.find(item => item.tunnel_id === target && item.state === 'running')
|
||||
if (!line) throw new Error('请先选择一条正在运行的授权线路。')
|
||||
await clientProxyStart(line.tunnel_id)
|
||||
const running = await clientProxyStatus()
|
||||
setProxyRunning(running)
|
||||
if (!running) throw new Error('Go 应用代理启动后已退出,请检查专线和本机端口。')
|
||||
setSelectedLine(line.tunnel_id)
|
||||
setSelectedMode(line.routing_mode === 'global_ipv4' ? 'global_ipv4' : 'split')
|
||||
}
|
||||
} catch (cause) { setProxyError(cause instanceof Error ? cause.message : String(cause)) }
|
||||
finally { proxyActionLock.current = false; setProxyBusy(false) }
|
||||
}
|
||||
|
||||
async function toggleSystemProxy() {
|
||||
if (proxyActionLock.current) return
|
||||
proxyActionLock.current = true
|
||||
setProxyBusy(true)
|
||||
setProxyError('')
|
||||
try {
|
||||
if (systemProxyOn) await clientSystemProxyDisable()
|
||||
else await clientSystemProxyEnable()
|
||||
setSystemProxyOn(await clientSystemProxyStatus())
|
||||
} catch (cause) { setProxyError(cause instanceof Error ? cause.message : String(cause)) }
|
||||
finally { proxyActionLock.current = false; setProxyBusy(false) }
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
if (!beginAction()) return
|
||||
try { await clientLogout() } catch (cause) {
|
||||
@@ -163,6 +239,10 @@ export default function App() {
|
||||
setStatus(null)
|
||||
setLines([])
|
||||
setSelectedLine(null)
|
||||
setSelectedMode('split')
|
||||
setNodePickerOpen(false)
|
||||
setLatencies({})
|
||||
setProxyRunning(false)
|
||||
setPage('overview')
|
||||
endAction()
|
||||
}
|
||||
@@ -172,13 +252,14 @@ export default function App() {
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return <LoginView onLoggedIn={view => { setSession(view); setLines(view.lines) }} />
|
||||
return <LoginView onLoggedIn={view => { setSession(view); setLines(view.lines); setLatencies({}) }} />
|
||||
}
|
||||
|
||||
const phase = conn?.phase ?? 'disconnected'
|
||||
const connected = phase === 'connected'
|
||||
const connecting = phase === 'connecting'
|
||||
const currentLineName = conn?.tunnel_name || lines.find(l => l.tunnel_id === selectedLine)?.name || ''
|
||||
const currentLine = lines.find(l => l.tunnel_id === selectedLine) ?? lines.find(l => l.state === 'running')
|
||||
|
||||
return <div className="app-shell">
|
||||
<aside className="sidebar" aria-label="主导航">
|
||||
@@ -192,37 +273,42 @@ export default function App() {
|
||||
<div className="workspace">
|
||||
<header className="topbar">
|
||||
<div className="topbar-brand"><span className="brand-dot" />OpsX <strong>VPN</strong><span className="divider" />{session.display_name || session.username}</div>
|
||||
<span className={`preview-pill ${connected ? 'online' : ''}`}>● {connected ? '已连接' : connecting ? '连接中' : '未连接'}</span>
|
||||
<span className={`preview-pill ${connected ? 'online' : ''}`}>● {connected ? '已连接' : connecting ? '连接中' : proxyRunning ? systemProxyOn ? '系统代理已启用' : '应用代理已启动' : '本客户端未连接'}</span>
|
||||
</header>
|
||||
|
||||
{page === 'overview' && <main className="page">
|
||||
<div className="section-intro">
|
||||
<div><span className="overline">WORKSPACE / OVERVIEW</span><h1>连接概览</h1><p>一键接入企业网络;连接状态以网关真实握手为准。</p></div>
|
||||
<span className="status-tag">● {connected ? `已连接 · ${currentLineName}` : connecting ? '连接中…' : '本客户端未连接'}</span>
|
||||
<div><span className="overline">WORKSPACE / OVERVIEW</span><h1>连接概览</h1><p>选择线路,启动节点代理。</p></div>
|
||||
<span className="status-tag">● {connected ? `已连接 · ${currentLineName}` : connecting ? '连接中…' : proxyRunning ? systemProxyOn ? '系统代理已启用' : '应用代理已启动' : '本客户端未连接'}</span>
|
||||
</div>
|
||||
<div className="overview-grid">
|
||||
<section className="connection-card" aria-label="VPN 连接状态">
|
||||
<div className="card-top"><div><span className="kicker">安全连接</span><h2>{currentLineName || '企业网络'}</h2></div><span className="protocol"><Icon name="shield" size={15} /> WIREGUARD</span></div>
|
||||
<div className="connect-visual">
|
||||
<div className="orbit outer" /><div className="orbit inner" />
|
||||
<button type="button" className={`power-display ${connected ? 'on' : ''} ${connecting ? 'busy' : ''}`}
|
||||
aria-label={connected || connecting ? '断开 VPN 连接' : '连接 VPN'}
|
||||
disabled={actionBusy || (!connected && !connecting && readiness?.engine_mode === 'unsupported')}
|
||||
onClick={() => connected || connecting ? void handleDisconnect() : void handleConnect()}>
|
||||
<button type="button" className={`power-display ${connected || proxyRunning ? 'on' : ''} ${connecting ? 'busy' : ''}`}
|
||||
aria-label={connected || connecting ? '断开连接' : proxyRunning ? '停止节点代理' : '启动节点代理'}
|
||||
disabled={actionBusy || proxyBusy || (!connected && !connecting && !proxyRunning && !readiness?.app_proxy_available)}
|
||||
onClick={() => connected || connecting ? void handleDisconnect() : void toggleProxy()}>
|
||||
<Icon name="power" size={42} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="connection-message">
|
||||
<strong>{connected ? '已连接' : connecting ? '连接中…' : '本客户端未连接'}</strong>
|
||||
<strong>{connected ? '已连接' : connecting ? '连接中…' : proxyRunning ? systemProxyOn ? '系统代理已启用' : '应用代理已启动' : '本客户端未连接'}</strong>
|
||||
<span>{connected
|
||||
? `${conn?.client_address ?? ''} · 已连接 ${formatDuration(conn?.since_unix ?? 0, now)}`
|
||||
: connecting ? (conn?.detail || '正在建立隧道') : readiness?.engine_mode === 'unsupported'
|
||||
? '本机隧道环境尚未就绪'
|
||||
? '点击按钮启动节点代理'
|
||||
: (actionError || conn?.detail || '点击上方按钮接入企业网络')}</span>
|
||||
</div>
|
||||
{readiness?.engine_mode === 'unsupported' && <div className="connection-guide" role="status">
|
||||
{readiness.engine_detail}
|
||||
<button type="button" onClick={() => setPage('settings')}>查看本机环境与安装方法 →</button>
|
||||
{readiness?.engine_mode === 'unsupported' && <div className="connection-guide app-proxy-guide">
|
||||
{currentLine?.routing_mode === 'split' && <p>当前线路只允许授权网段,应用代理无法访问未授权的公网地址。</p>}
|
||||
<button type="button" className="secondary-action" disabled={proxyBusy || actionBusy || (!proxyRunning && (!readiness.app_proxy_available || !lines.some(line => line.state === 'running')))} onClick={() => void toggleProxy()}>{proxyBusy ? '处理中…' : proxyRunning ? '停止应用代理' : '启动应用代理'}</button>
|
||||
{!readiness.app_proxy_available && <small role="status">安装包缺少 Go 隧道组件,请重新安装桌面客户端。</small>}
|
||||
{proxyRunning && <small>代理地址:socks5h://127.0.0.1:18743</small>}
|
||||
{(proxyRunning || systemProxyOn) && <button type="button" className="secondary-action" disabled={proxyBusy || readiness.platform !== 'macos' || (!systemProxyOn && currentLine?.routing_mode !== 'global_ipv4')} onClick={() => void toggleSystemProxy()}>{systemProxyOn ? '关闭系统代理' : '启用系统代理'}</button>}
|
||||
{systemProxyOn && <small>已启用 macOS 系统 SOCKS 设置;遵循系统代理的应用会通过节点访问网络。</small>}
|
||||
{proxyError && <small role="alert">{proxyError}</small>}
|
||||
</div>}
|
||||
{connected && status?.server && <div className="traffic-grid">
|
||||
<span>下行 {formatBytes(status.server.rx_bytes)}</span>
|
||||
@@ -231,10 +317,11 @@ export default function App() {
|
||||
</div>}
|
||||
{actionError && !connecting && readiness?.engine_mode !== 'unsupported' && <div className="connection-guide" role="alert">{actionError}</div>}
|
||||
{status?.server?.detail && <div className="connection-guide" role="status">{status.server.detail}</div>}
|
||||
<button type="button" className="node-selector" onClick={() => setPage('routes')}>
|
||||
<button type="button" className="node-selector" onClick={() => { if (currentLine) { setSelectedLine(currentLine.tunnel_id); setSelectedMode(currentLine.routing_mode === 'global_ipv4' ? 'global_ipv4' : 'split') } setNodePickerOpen(true) }}>
|
||||
<span className="node-icon"><Icon name="globe" size={19} /></span>
|
||||
<span className="node-selector-copy"><small>当前线路</small><strong>{currentLineName || (lines[0]?.name ?? '暂无授权线路')}</strong></span>
|
||||
<small className="node-switch">切换 →</small>
|
||||
<Signal ms={currentLine ? latencies[currentLine.tunnel_id] : undefined} />
|
||||
<small className="node-switch">选择 →</small>
|
||||
</button>
|
||||
</section>
|
||||
<div className="right-column">
|
||||
@@ -268,15 +355,50 @@ export default function App() {
|
||||
<p>{line.remark || line.endpoint || '—'}</p>
|
||||
</div>
|
||||
<span className={`service-state ${line.state === 'running' ? 'reachable' : 'error'}`}>{line.state === 'running' ? '运行中' : '未运行'}</span>
|
||||
<button className="secondary-action" disabled={line.state !== 'running' || actionBusy}
|
||||
onClick={() => { setSelectedLine(line.tunnel_id); void handleConnect(line.tunnel_id) }}>
|
||||
{connected && conn?.tunnel_name === line.name ? '已连接' : '连接'}
|
||||
<Signal ms={latencies[line.tunnel_id]} />
|
||||
<button type="button" className="latency-action" disabled={probeBusyLine !== null || line.state !== 'running'} onClick={() => void measureLine(line)}>{probeBusyLine === line.tunnel_id ? '探测中…' : '测隧道'}</button>
|
||||
<button className="secondary-action" disabled={line.state !== 'running' || !readiness?.app_proxy_available || proxyBusy || actionBusy}
|
||||
onClick={() => { setSelectedLine(line.tunnel_id); void toggleProxy(line.tunnel_id) }}>
|
||||
{proxyRunning && selectedLine === line.tunnel_id ? '切换代理线路' : '启动节点代理'}
|
||||
</button>
|
||||
</section>)}
|
||||
{probeMessage && <p className="hint" role="status">{probeMessage}</p>}
|
||||
{proxyRunning && <div className="connection-guide">节点代理已启动:socks5h://127.0.0.1:18743。{systemProxyOn ? 'macOS 系统代理已启用。' : '需要使用它的应用手动设置代理。'}<button type="button" disabled={proxyBusy} onClick={() => void toggleProxy()}>停止代理</button></div>}
|
||||
{proxyError && <p className="hint" role="alert">{proxyError}</p>}
|
||||
</main>}
|
||||
|
||||
{page === 'settings' && <SettingsPage session={session} lines={lines} onLogout={handleLogout} actionBusy={actionBusy} onReadinessChange={setReadiness} />}
|
||||
{page === 'settings' && <SettingsPage session={session} lines={lines} onLogout={handleLogout} actionBusy={actionBusy} onReadinessChange={setReadiness} proxyRunning={proxyRunning} proxyBusy={proxyBusy} proxyError={proxyError} onToggleProxy={toggleProxy} onProxyStatusChange={setProxyRunning} systemProxyOn={systemProxyOn} onToggleSystemProxy={toggleSystemProxy} />}
|
||||
</div>
|
||||
{nodePickerOpen && <div className="node-dialog-backdrop" onClick={() => setNodePickerOpen(false)}>
|
||||
<section className="node-dialog" role="dialog" aria-modal="true" aria-labelledby="node-dialog-title" onClick={event => event.stopPropagation()}>
|
||||
<button type="button" className="dialog-close" aria-label="关闭" onClick={() => setNodePickerOpen(false)}>×</button>
|
||||
<span className="overline">VPN / LINES</span><h2 id="node-dialog-title">选择服务器节点</h2>
|
||||
<p className="node-dialog-intro">只显示当前账号获授权的真实线路。耗时为经该线路到管理员设置的目标(默认公网)的 TCP 建链时间,包含隧道启动与握手。</p>
|
||||
<div className="node-dialog-grid">
|
||||
<div className="mode-column"><span className="dialog-column-label">网络模式</span>
|
||||
{(['split', 'global_ipv4'] as const).map(mode => {
|
||||
const available = lines.find(line => line.state === 'running' && (line.routing_mode === 'global_ipv4' ? 'global_ipv4' : 'split') === mode)
|
||||
return <button type="button" key={mode} className={`mode-option ${selectedMode === mode ? 'selected' : ''}`} disabled={!available} onClick={() => { setSelectedMode(mode); setSelectedLine(available!.tunnel_id) }}><span className={`mode-mark ${mode === 'global_ipv4' ? 'globe' : ''}`}>{mode === 'split' ? '↗' : '◎'}</span><span><strong>{mode === 'split' ? '极速模式' : '全局模式'}</strong><small>{available ? mode === 'split' ? '选择授权内网线路' : '选择提供公网出口的线路' : '暂无此模式的授权线路'}</small></span></button>
|
||||
})}
|
||||
<p className="mode-help">模式随授权线路切换;同一线路的路由由服务端下发。</p>
|
||||
</div>
|
||||
<div className="dialog-lines"><div className="dialog-list-head"><span>接入服务器</span><span>隧道探测</span></div>
|
||||
{lines.length === 0 && <p className="hint">当前没有获授权线路。</p>}
|
||||
{lines.map(line => <div key={line.tunnel_id} className={`dialog-line ${selectedLine === line.tunnel_id ? 'picked' : ''}`}>
|
||||
<button type="button" className="dialog-line-select" disabled={line.state !== 'running'} onClick={() => { setSelectedLine(line.tunnel_id); setSelectedMode(line.routing_mode === 'global_ipv4' ? 'global_ipv4' : 'split') }}>
|
||||
<span className="selection-radio" aria-hidden="true">{selectedLine === line.tunnel_id ? '✓' : ''}</span>
|
||||
<span className="dialog-line-text"><strong>{line.name}</strong><small>{line.state === 'running' ? (line.routing_mode === 'global_ipv4' ? '全局 IPv4' : '按线路分流') : '暂未运行'}</small></span>
|
||||
</button>
|
||||
<Signal ms={latencies[line.tunnel_id]} />
|
||||
<button type="button" className="latency-action" disabled={probeBusyLine !== null || line.state !== 'running'} onClick={() => void measureLine(line)}>{probeBusyLine === line.tunnel_id ? '检测中' : '测速'}</button>
|
||||
</div>)}
|
||||
{probeMessage && <p className="hint" role="status">{probeMessage}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dialog-actions"><button type="button" className="dialog-cancel" onClick={() => setNodePickerOpen(false)}>取消</button><button type="button" className="dialog-confirm" disabled={!lines.some(line => line.tunnel_id === selectedLine && line.state === 'running') || actionBusy || proxyBusy || !readiness?.app_proxy_available} onClick={() => { setNodePickerOpen(false); void toggleProxy(selectedLine ?? undefined) }}>启动节点代理</button></div>
|
||||
{readiness?.engine_mode === 'unsupported' && <p className="mode-help">当前使用内置节点代理;全局线路可在概览启用系统代理。</p>}
|
||||
</section>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -284,6 +406,8 @@ export default function App() {
|
||||
|
||||
function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void }) {
|
||||
const [address, setAddress] = useState(() => localStorage.getItem('opsx_server_address') || '')
|
||||
const [stage, setStage] = useState<'unit' | 'auth'>('unit')
|
||||
const [capabilities, setCapabilities] = useState<ClientCapabilities | null>(null)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [otp, setOtp] = useState('')
|
||||
@@ -295,13 +419,42 @@ function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void })
|
||||
|
||||
useEffect(() => { clientDeviceName().then(setDeviceName).catch(() => undefined) }, [])
|
||||
|
||||
async function handleContinue(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const healthResult = await checkServer(address)
|
||||
if (!healthResult.reachable) throw new Error('服务端未就绪')
|
||||
const offered = await clientCapabilities(healthResult.address)
|
||||
if (!offered.wireguard) throw new Error('该服务器尚未提供 WireGuard 客户端接入')
|
||||
setAddress(healthResult.address)
|
||||
localStorage.setItem('opsx_server_address', healthResult.address)
|
||||
setCapabilities(offered)
|
||||
setHealth('reachable')
|
||||
setStage('auth')
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
function changeUnit() {
|
||||
setStage('unit')
|
||||
setCapabilities(null)
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
setOtp('')
|
||||
setMfaRequired(false)
|
||||
setError('')
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
const view = await clientLogin(address, username, password, otp, deviceName)
|
||||
localStorage.setItem('opsx_server_address', address.trim().replace(/\/$/, ''))
|
||||
setPassword('')
|
||||
setOtp('')
|
||||
onLoggedIn(view)
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause)
|
||||
@@ -326,27 +479,36 @@ function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void })
|
||||
<div className="login-intro-content">
|
||||
<span className="login-eyebrow">SECURE ACCESS / 企业网络</span>
|
||||
<h2>连接工作网络,<br />从这里开始。</h2>
|
||||
<p>选择您的企业服务端,用已授权的 VPN 账号安全接入。</p>
|
||||
<p>选择您所属企业的服务端,用管理员开通的账号安全接入。</p>
|
||||
<div className="login-feature"><Icon name="shield" size={19} /><span>独立设备身份 · WireGuard 加密隧道</span></div>
|
||||
</div>
|
||||
<small className="login-intro-foot">OPSX / PRIVATE NETWORK</small>
|
||||
</div>
|
||||
<main className="login-card">
|
||||
<div className="login-card-head"><span className="login-step">账号登录</span><span className="login-card-icon"><Icon name="shield" size={21} /></span></div>
|
||||
<h1>欢迎使用 OpsX VPN</h1>
|
||||
<p className="login-description">请使用管理员开通的 VPN 拨号账号登录</p>
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
<label htmlFor="login-server">服务端地址</label>
|
||||
<div className="input-row">
|
||||
<input id="login-server" type="url" required placeholder="https://vpn.example.com" value={address}
|
||||
onChange={e => { setAddress(e.target.value); setHealth('idle') }} />
|
||||
<button type="button" className="save-button" onClick={() => void handleCheck()} disabled={health === 'checking'}>
|
||||
{health === 'checking' ? '检测中…' : '检测'}
|
||||
</button>
|
||||
</div>
|
||||
{health === 'reachable' && <p className="saved-address">✓ 服务可达</p>}
|
||||
{health === 'error' && <p className="http-warning">服务不可达,请确认地址与网络。</p>}
|
||||
{address.trim().toLowerCase().startsWith('http://') && <p className="http-warning">HTTP 为明文传输,建议使用可信 HTTPS 域名。</p>}
|
||||
<div className="login-card-head"><span className="login-step">{stage === 'unit' ? '01 / 选择接入点' : '02 / 身份认证'}</span><span className="login-card-icon"><Icon name="shield" size={21} /></span></div>
|
||||
{stage === 'unit' ? <>
|
||||
<h1>选择接入点</h1>
|
||||
<p className="login-description">输入企业提供的服务器地址,继续使用 VPN 拨号用户登录。</p>
|
||||
<form onSubmit={handleContinue} className="login-form">
|
||||
<label htmlFor="login-server">企业服务端地址</label>
|
||||
<div className="input-row"><input id="login-server" type="url" required placeholder="https://vpn.example.com" value={address} onChange={event => { setAddress(event.target.value); setHealth('idle') }} />
|
||||
<button type="button" className="save-button" onClick={() => void handleCheck()} disabled={health === 'checking'}>{health === 'checking' ? '检测中…' : '检测'}</button></div>
|
||||
{health === 'reachable' && <p className="saved-address">✓ 服务可达</p>}
|
||||
{health === 'error' && <p className="http-warning">服务不可达,请确认地址与网络。</p>}
|
||||
{address.trim().toLowerCase().startsWith('http://') && <p className="http-warning">HTTP 为明文传输,建议使用可信 HTTPS 域名。</p>}
|
||||
{error && <div className="result failure" role="alert"><Icon name="close" size={17} /><span>{error}</span></div>}
|
||||
<button className="login-submit" type="submit" disabled={busy}>{busy ? '正在验证…' : '继续认证'}</button>
|
||||
<p className="login-footnote">浏览器预览无法执行本机认证;请使用桌面客户端继续。</p>
|
||||
</form>
|
||||
</> : <>
|
||||
<button type="button" className="login-back" onClick={changeUnit}>← 切换接入点</button>
|
||||
<h1>登录 {unitLabel(address)}</h1>
|
||||
<p className="login-description">使用该接入点提供的认证方式登录 VPN 拨号用户</p>
|
||||
{capabilities?.oidc && <div className="lark-login-card"><span className="lark-mark" aria-hidden="true"><i /><i /><i /></span><div><strong>{['feishu', 'lark'].includes(capabilities.oidc_provider?.toLowerCase() || '') ? '飞书 / Lark · ' : '第三方认证 · '}OAuth2 / OIDC</strong><small>服务端支持 OIDC;客户端浏览器回调与拨号用户授权映射尚未接通</small></div><button type="button" disabled aria-label="第三方浏览器登录尚未开放">待接入</button></div>}
|
||||
{capabilities?.password && <>
|
||||
{capabilities.oidc && <div className="login-divider"><span>使用账号密码登录</span></div>}
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
{address.startsWith('http://') && <p className="http-warning">HTTP 为明文传输,建议使用可信 HTTPS 域名。</p>}
|
||||
<label htmlFor="login-username">用户名</label>
|
||||
<div className="input-row"><input id="login-username" required autoComplete="username" value={username} onChange={e => setUsername(e.target.value)} /></div>
|
||||
<label htmlFor="login-password">密码</label>
|
||||
@@ -361,6 +523,9 @@ function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void })
|
||||
<button className="login-submit" type="submit" disabled={busy}>{busy ? '登录中…' : '登录'}</button>
|
||||
<p className="login-footnote">首次连接时会自动登记本机设备</p>
|
||||
</form>
|
||||
</>}
|
||||
{!capabilities?.password && <p className="login-description" role="status">{capabilities?.oidc ? '此接入点暂未开放客户端浏览器登录,请联系管理员启用密码登录。' : '此接入点暂未提供可用的客户端认证方式。'}</p>}
|
||||
</>}
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
@@ -400,33 +565,13 @@ function ServerCard({ baseUrl }: { baseUrl?: string }) {
|
||||
|
||||
// ---- 设置页 ----
|
||||
|
||||
function SettingsPage({ session, lines, onLogout, actionBusy, onReadinessChange }: { session: SessionView; lines: Line[]; onLogout: () => void; actionBusy: boolean; onReadinessChange: (value: SystemReadiness) => void }) {
|
||||
function SettingsPage({ session, lines, onLogout, actionBusy, onReadinessChange, proxyRunning, proxyBusy, proxyError, onToggleProxy, onProxyStatusChange, systemProxyOn, onToggleSystemProxy }: { session: SessionView; lines: Line[]; onLogout: () => void; actionBusy: boolean; onReadinessChange: (value: SystemReadiness) => void; proxyRunning: boolean; proxyBusy: boolean; proxyError: string; onToggleProxy: (tunnelId?: number) => Promise<void>; onProxyStatusChange: (running: boolean) => void; systemProxyOn: boolean; onToggleSystemProxy: () => Promise<void> }) {
|
||||
const [readiness, setReadiness] = useState<SystemReadiness | null>(null)
|
||||
const [readinessError, setReadinessError] = useState('')
|
||||
const [devices, setDevices] = useState<DeviceInfo[]>([])
|
||||
const [deviceError, setDeviceError] = useState('')
|
||||
const [probeBusy, setProbeBusy] = useState(false)
|
||||
const [probeResult, setProbeResult] = useState('')
|
||||
const [proxyRunning, setProxyRunning] = useState(false)
|
||||
const [proxyBusy, setProxyBusy] = useState(false)
|
||||
const [proxyError, setProxyError] = useState('')
|
||||
|
||||
useEffect(() => { clientProxyStatus().then(setProxyRunning).catch(() => undefined) }, [])
|
||||
|
||||
async function toggleProxy() {
|
||||
setProxyBusy(true)
|
||||
setProxyError('')
|
||||
try {
|
||||
if (proxyRunning) { await clientProxyStop(); setProxyRunning(false) }
|
||||
else {
|
||||
const line = lines.find(l => l.state === 'running')
|
||||
if (!line) throw new Error('当前没有正在运行的授权线路。')
|
||||
await clientProxyStart(line.tunnel_id)
|
||||
setProxyRunning(true)
|
||||
}
|
||||
} catch (cause) { setProxyError(cause instanceof Error ? cause.message : String(cause)) }
|
||||
finally { setProxyBusy(false) }
|
||||
}
|
||||
|
||||
async function runProbe() {
|
||||
const line = lines.find(l => l.state === 'running')
|
||||
@@ -459,6 +604,7 @@ function SettingsPage({ session, lines, onLogout, actionBusy, onReadinessChange
|
||||
setDeviceError('')
|
||||
try {
|
||||
await clientRevokeDevice(id)
|
||||
clientProxyStatus().then(onProxyStatusChange).catch(() => undefined)
|
||||
loadDevices()
|
||||
} catch (cause) {
|
||||
setDeviceError(cause instanceof Error ? cause.message : String(cause))
|
||||
@@ -494,28 +640,25 @@ function SettingsPage({ session, lines, onLogout, actionBusy, onReadinessChange
|
||||
<div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>本机环境</h2><p>只读检查,不代表已具备拨号能力</p></div></div>
|
||||
{readiness ? <div className="system-grid">
|
||||
<span>操作系统</span><strong>{readiness.platform}</strong>
|
||||
<span>隧道引擎</span><strong>{readiness.engine_mode === 'system_tools' ? '系统工具模式' : '当前不可用'}</strong>
|
||||
<span>wg 命令</span><strong>{readiness.wg_found ? '已找到' : '未找到'}</strong>
|
||||
<span>wg-quick 命令</span><strong>{readiness.wg_quick_found ? '已找到' : '未找到'}</strong>
|
||||
<span>内置直连</span><strong>尚未就绪</strong>
|
||||
<span>Go 应用代理组件</span><strong>{readiness.app_proxy_available ? '已找到' : '缺失'}</strong>
|
||||
</div> : <p className="hint" role="status">{readinessError || '正在检测本机环境…'}</p>}
|
||||
{readiness && <p className="hint" role="status">{readiness.engine_detail}</p>}
|
||||
{readiness && (!readiness.wg_found || !readiness.wg_quick_found) && readiness.platform !== 'windows' && <div className="install-guide">
|
||||
<strong>安装 WireGuard 工具</strong>
|
||||
<p>{readiness.platform === 'macos' ? '在终端运行 brew install wireguard-tools。' : '请按发行版安装 wireguard-tools(Ubuntu / Debian:sudo apt install wireguard-tools)。'}</p>
|
||||
<button type="button" className="secondary-action" onClick={() => { setReadiness(null); getSystemReadiness().then(result => { setReadiness(result); onReadinessChange(result) }).catch(() => setReadinessError('本机环境检测失败')) }}>重新检测</button>
|
||||
</div>}
|
||||
<p className="hint">建立隧道时系统会弹出提权授权;Windows 桌面隧道暂不支持,请使用 WireGuard 官方客户端。</p>
|
||||
<p className="hint">内置直连尚需系统路由与 DNS 配置;当前可使用下方 Go 节点代理。</p>
|
||||
<div className="install-guide probe-guide">
|
||||
<strong>Go 隧道出口诊断</strong>
|
||||
<p>通过当前设备的 WireGuard 参数连接公网 TCP,不修改本机路由或 DNS;这项检查不代表系统 VPN 已连接。测试线路需授权公网路由,网关需开启出口 NAT。</p>
|
||||
<p>通过当前设备的 WireGuard 参数连接管理员设置的探测目标(默认公网 TCP),不修改本机路由或 DNS;这项检查不代表系统 VPN 已连接。目标必须在授权路由内。</p>
|
||||
<button type="button" className="secondary-action" disabled={probeBusy} onClick={() => void runProbe()}>{probeBusy ? '检测中…' : '测试 VPN 出口'}</button>
|
||||
{probeResult && <p role="status">{probeResult}</p>}
|
||||
</div>
|
||||
<div className="install-guide probe-guide">
|
||||
<strong>本地 SOCKS5 代理(试用)</strong>
|
||||
<p>通过内置 Go WireGuard 隧道为已配置代理的应用提供连接,不修改系统路由或 DNS。应用代理地址:socks5h://127.0.0.1:18743;使用 socks5h 才会经 VPN 解析域名。线路须允许目标公网路由并开启网关 NAT。</p>
|
||||
<button type="button" className="secondary-action" disabled={proxyBusy} onClick={() => void toggleProxy()}>{proxyBusy ? '处理中…' : proxyRunning ? '停止本地代理' : '启动本地代理'}</button>
|
||||
{proxyRunning && <p role="status">本地代理正在运行;只有主动配置此代理的应用会走 VPN。</p>}
|
||||
<p>通过内置 Go WireGuard 提供 SOCKS5 连接,不修改系统路由或 DNS。手动配置地址:socks5h://127.0.0.1:18743;使用 socks5h 才会经 VPN 解析域名。目标地址必须在专线授权路由内;公网还需网关转发和 NAT。</p>
|
||||
<button type="button" className="secondary-action" disabled={proxyBusy || (!proxyRunning && (!readiness?.app_proxy_available || !lines.some(line => line.state === 'running')))} onClick={() => void onToggleProxy()}>{proxyBusy ? '处理中…' : proxyRunning ? '停止本地代理' : '启动本地代理'}</button>
|
||||
{proxyRunning && <p role="status">本地代理正在运行;可手动配置应用,也可在 macOS 上启用系统代理。</p>}
|
||||
{(proxyRunning || systemProxyOn) && <button type="button" className="secondary-action" disabled={proxyBusy || (readiness?.platform !== 'macos' && !systemProxyOn)} onClick={() => void onToggleSystemProxy()}>{systemProxyOn ? '关闭系统代理' : '启用 macOS 系统代理'}</button>}
|
||||
{proxyRunning && <p>系统代理只适用于遵循 macOS 设置的应用,需要线路允许全局 IPv4;部分软件和 UDP 流量不会使用它。</p>}
|
||||
{systemProxyOn && <p role="status">系统 SOCKS 代理已启用。</p>}
|
||||
{proxyError && <p role="alert">{proxyError}</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/* Enterprise entry: the address is the only trustworthy unit identity today. */
|
||||
.login-form input:focus-visible { outline: 2px solid #3977eb; outline-offset: 2px; }
|
||||
.login-back { align-self: flex-start; border: 0; padding: 6px 0; margin: 18px 0 0; background: none; color: #3974e3; cursor: pointer; font-weight: 650; }
|
||||
.lark-login-card { display: flex; align-items: center; gap: 13px; margin-top: 23px; padding: 16px; border: 1px solid #dfe8f6; border-radius: 14px; background: #fbfcff; }
|
||||
.lark-login-card > div { display: grid; gap: 4px; flex: 1; min-width: 0; }
|
||||
.lark-login-card strong { font-size: 14px; color: #263956; }
|
||||
.lark-login-card small { color: #8190a7; font-size: 11px; line-height: 1.5; }
|
||||
.lark-login-card button { border: 0; border-radius: 8px; padding: 9px 12px; color: #8595af; background: #e9eef7; font-weight: 700; white-space: nowrap; cursor: not-allowed; }
|
||||
.lark-mark { position: relative; width: 42px; height: 42px; flex: none; border-radius: 12px; background: #f0f5ff; overflow: hidden; }
|
||||
.lark-mark i { position: absolute; width: 22px; height: 11px; border-radius: 12px 2px 12px 2px; transform: rotate(-35deg); }
|
||||
.lark-mark i:nth-child(1) { background: #2868f5; left: 9px; top: 21px; }
|
||||
.lark-mark i:nth-child(2) { background: #16c6b1; left: 13px; top: 11px; }
|
||||
.lark-mark i:nth-child(3) { background: #17479c; left: 19px; top: 22px; width: 13px; }
|
||||
.login-divider { display: flex; align-items: center; gap: 13px; color: #99a6ba; font-size: 11px; margin-top: 25px; }
|
||||
.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: #e5eaf3; }
|
||||
|
||||
/* Measured TCP setup through the selected WireGuard line, never a fabricated RTT. */
|
||||
.node-signal { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; font-size: 12px; font-variant-numeric: tabular-nums; color: #18a464; }
|
||||
.node-signal.slow { color: #d68819; }
|
||||
.node-signal.unknown { color: #9ba8b9; }
|
||||
.signal-bars { display: flex; align-items: end; gap: 2px; height: 15px; }
|
||||
.signal-bars i { display: block; width: 3px; border-radius: 2px 2px 0 0; background: currentColor; }
|
||||
.signal-bars i:nth-child(1) { height: 6px; }
|
||||
.signal-bars i:nth-child(2) { height: 10px; }
|
||||
.signal-bars i:nth-child(3) { height: 15px; }
|
||||
.node-signal.unknown .signal-bars i:nth-child(n+2) { opacity: .35; }
|
||||
.node-signal.slow .signal-bars i:nth-child(3) { opacity: .4; }
|
||||
.latency-action { border: 0; background: transparent; color: #3876e9; padding: 7px 3px; font-size: 11px; white-space: nowrap; cursor: pointer; }
|
||||
.latency-action:disabled { color: #a2aec0; cursor: default; }
|
||||
|
||||
.node-dialog-backdrop { position: fixed; z-index: 20; inset: 0; display: grid; place-items: center; padding: 20px; background: #1b304c88; backdrop-filter: blur(3px); }
|
||||
.node-dialog { position: relative; width: min(820px, 100%); max-height: min(740px, calc(100vh - 40px)); overflow: auto; padding: 34px; border-radius: 22px; background: white; box-shadow: 0 26px 80px #14283c55; }
|
||||
.node-dialog h2 { margin: 8px 0 7px; color: #182e51; letter-spacing: -.03em; }
|
||||
.node-dialog-intro { margin: 0 0 25px; color: #75869f; line-height: 1.65; font-size: 12px; }
|
||||
.dialog-close { position: absolute; top: 20px; right: 24px; width: 30px; height: 30px; border: 0; background: #f0f4fa; border-radius: 50%; font-size: 21px; color: #788ba7; cursor: pointer; }
|
||||
.node-dialog-grid { display: grid; grid-template-columns: minmax(210px, 32%) 1fr; gap: 24px; }
|
||||
.dialog-column-label, .dialog-list-head { color: #9aabc0; font-size: 11px; font-weight: 700; }
|
||||
.mode-column { display: grid; align-content: start; gap: 10px; }
|
||||
.mode-column .dialog-column-label { margin-bottom: 8px; }
|
||||
.mode-option { display: flex; align-items: center; gap: 11px; padding: 13px; border: 1px solid transparent; border-radius: 12px; color: #75859b; }
|
||||
.mode-option:is(button) { width: 100%; text-align: left; background: transparent; font: inherit; cursor: pointer; }
|
||||
.mode-option:is(button):hover:not(:disabled) { background: #f6f9ff; }
|
||||
.mode-option:is(button):disabled { opacity: .52; cursor: not-allowed; }
|
||||
.mode-option.selected { border-color: #dbe7fb; background: #f6f9ff; color: #2353ac; }
|
||||
.mode-option > span:last-child { display: grid; gap: 3px; }
|
||||
.mode-option strong { font-size: 13px; color: #263953; }
|
||||
.mode-option small, .mode-help { font-size: 10px; line-height: 1.6; color: #91a0b5; }
|
||||
.mode-mark { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 11px; background: #e8effe; font-size: 21px; }
|
||||
.mode-mark.globe { background: #eef1f6; }
|
||||
.mode-help { margin: 8px 0; }
|
||||
.dialog-lines { min-width: 0; }
|
||||
.dialog-list-head { display: flex; justify-content: space-between; padding: 0 64px 14px 12px; }
|
||||
.dialog-line { display: flex; align-items: center; gap: 11px; min-height: 61px; padding: 5px 11px; border: 1px solid transparent; border-radius: 12px; }
|
||||
.dialog-line.picked { border-color: #d8e7ff; background: #f7faff; }
|
||||
.dialog-line-select { display: flex; flex: 1; align-items: center; gap: 11px; min-width: 0; text-align: left; border: 0; padding: 5px 0; background: transparent; cursor: pointer; }
|
||||
.dialog-line-select:disabled { opacity: .55; cursor: default; }
|
||||
.selection-radio { width: 19px; height: 19px; display: grid; place-items: center; flex: none; border: 1px solid #d6deea; border-radius: 50%; color: white; font-size: 12px; }
|
||||
.picked .selection-radio { border-color: #3476f5; background: #3476f5; }
|
||||
.dialog-line-text { min-width: 0; display: grid; gap: 3px; }
|
||||
.dialog-line-text strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #293e5b; }
|
||||
.dialog-line-text small { color: #95a5b8; font-size: 10px; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 28px; }
|
||||
.dialog-actions button { min-width: 108px; padding: 11px 17px; border-radius: 10px; cursor: pointer; font-weight: 700; }
|
||||
.dialog-cancel { border: 0; background: #f2f4f8; color: #3e526f; }
|
||||
.dialog-confirm { border: 0; background: #3476f5; color: #fff; }
|
||||
.dialog-confirm:disabled { opacity: .5; cursor: not-allowed; }
|
||||
@media (max-width: 720px) { .node-dialog { padding: 24px 18px; } .node-dialog-grid { grid-template-columns: 1fr; gap: 14px; } .mode-column { grid-template-columns: 1fr 1fr; } .mode-column .dialog-column-label, .mode-column .mode-help { grid-column: 1 / -1; } .dialog-line { flex-wrap: wrap; } }
|
||||
+21
-8
@@ -3,9 +3,8 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
export type ServerHealth = { address: string; reachable: boolean }
|
||||
export type SystemReadiness = {
|
||||
platform: string
|
||||
wg_found: boolean
|
||||
wg_quick_found: boolean
|
||||
engine_mode: 'system_tools' | 'unsupported'
|
||||
app_proxy_available: boolean
|
||||
engine_mode: 'unsupported'
|
||||
engine_detail: string
|
||||
}
|
||||
|
||||
@@ -15,6 +14,7 @@ export type Line = {
|
||||
remark: string
|
||||
state: string
|
||||
endpoint: string
|
||||
routing_mode?: 'split' | 'global_ipv4'
|
||||
}
|
||||
|
||||
export type SessionView = {
|
||||
@@ -46,7 +46,8 @@ export type DeviceStatus = {
|
||||
detail?: string | null
|
||||
}
|
||||
|
||||
export type ProbeResult = { connected: boolean; detail: string; target: string }
|
||||
export type ProbeResult = { connected: boolean; detail: string; target: string; latency_ms?: number }
|
||||
export type ClientCapabilities = { password: boolean; oidc: boolean; oidc_provider?: string | null; wireguard: boolean }
|
||||
|
||||
export type StatusView = { conn: ConnState; server: DeviceStatus | null }
|
||||
|
||||
@@ -89,10 +90,6 @@ export function clientLines(): Promise<Line[]> {
|
||||
return isDesktop() ? invoke<Line[]>('client_lines') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientConnect(tunnelId: number): Promise<ConnState> {
|
||||
return isDesktop() ? invoke<ConnState>('client_connect', { tunnelId }) : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientDisconnect(): Promise<void> {
|
||||
return isDesktop() ? invoke<void>('client_disconnect') : desktopOnly()
|
||||
}
|
||||
@@ -105,6 +102,10 @@ export function clientProbe(tunnelId: number): Promise<ProbeResult> {
|
||||
return isDesktop() ? invoke<ProbeResult>('client_probe', { tunnelId }) : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientCapabilities(baseUrl: string): Promise<ClientCapabilities> {
|
||||
return isDesktop() ? invoke<ClientCapabilities>('client_capabilities', { baseUrl }) : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientProxyStart(tunnelId: number): Promise<string> {
|
||||
return isDesktop() ? invoke<string>('client_proxy_start', { tunnelId }) : desktopOnly()
|
||||
}
|
||||
@@ -117,6 +118,18 @@ export function clientProxyStatus(): Promise<boolean> {
|
||||
return isDesktop() ? invoke<boolean>('client_proxy_status') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientSystemProxyStatus(): Promise<boolean> {
|
||||
return isDesktop() ? invoke<boolean>('client_system_proxy_status') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientSystemProxyEnable(): Promise<void> {
|
||||
return isDesktop() ? invoke<void>('client_system_proxy_enable') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientSystemProxyDisable(): Promise<void> {
|
||||
return isDesktop() ? invoke<void>('client_system_proxy_disable') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientDevices(): Promise<DeviceInfo[]> {
|
||||
return isDesktop() ? invoke<DeviceInfo[]>('client_devices') : desktopOnly()
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ body { min-width: 0; }
|
||||
.http-warning { background: #fff7e6; color: #a0691b; border: 1px solid #fae5be; border-radius: 8px; padding: 10px; font-size: 12px; line-height: 1.6; }
|
||||
.saved-address { color: #168861; font-size: 12px; margin: 9px 0 0; overflow-wrap: anywhere; }
|
||||
.power-display { border: 0; cursor: pointer; position: relative; z-index: 1; }
|
||||
.power-display:hover { filter: brightness(1.08); transform: translateY(-2px); }
|
||||
.power-display:not(:disabled):hover { filter: brightness(1.08); transform: translateY(-2px); }
|
||||
.power-display:focus-visible { outline: 3px solid #144db6; outline-offset: 4px; }
|
||||
.connection-guide { margin: -11px 0 20px; padding: 11px 13px; border: 1px solid #dae7ff; border-radius: 9px; background: #f3f7ff; color: #43618f; font-size: 12px; line-height: 1.6; }
|
||||
.connection-guide button { display: block; padding: 3px 0 0; background: none; border: 0; color: #306eee; font-weight: 600; }
|
||||
@@ -71,7 +71,12 @@ body { min-width: 0; }
|
||||
/* ---- 连接状态 ---- */
|
||||
.power-display.on { background: linear-gradient(145deg, #2fce88, #17a268); box-shadow: 0 16px 30px #17a26849, inset 0 1px 2px #ffffff66; }
|
||||
.power-display.busy { animation: pulse 1.4s ease-in-out infinite; }
|
||||
.power-display:disabled { opacity: .6; cursor: wait; }
|
||||
.power-display:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.power-display.busy:disabled { cursor: progress; }
|
||||
.connection-guide .secondary-action { display: inline-flex; align-items: center; margin: 9px 0 0; border: 1px solid #d9e6ff; border-radius: 9px; background: #e8f0ff; color: #285fd0; padding: 9px 12px; cursor: pointer; }
|
||||
.connection-guide .secondary-action:disabled { cursor: not-allowed; }
|
||||
.app-proxy-guide p { margin: 6px 0; }
|
||||
.app-proxy-guide small { display: block; margin-top: 7px; }
|
||||
@keyframes pulse { 50% { transform: scale(.96); } }
|
||||
.preview-pill.online { border-color: #bdebd6; background: #ebf8f2; color: #168861; }
|
||||
.traffic-grid { display: flex; justify-content: center; gap: 18px; margin: -8px 0 18px; font-size: 11px; color: #63738a; }
|
||||
|
||||
+204
-4
@@ -4,6 +4,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -22,6 +24,7 @@ import (
|
||||
|
||||
"golang.zx2c4.com/wireguard/conn"
|
||||
"golang.zx2c4.com/wireguard/device"
|
||||
"golang.zx2c4.com/wireguard/tun"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
|
||||
@@ -45,20 +48,80 @@ type result struct {
|
||||
Connected bool `json:"connected"`
|
||||
Detail string `json:"detail"`
|
||||
Target string `json:"target,omitempty"`
|
||||
LatencyMS *int64 `json:"latency_ms,omitempty"`
|
||||
}
|
||||
|
||||
// Only these counters may cross the Go/Rust process boundary. IpcGet also
|
||||
// contains the device private key, so never serialize its raw response.
|
||||
type tunnelStatus struct {
|
||||
Status string `json:"status"`
|
||||
HandshakeUnix uint64 `json:"handshake_unix"`
|
||||
RXBytes uint64 `json:"rx_bytes"`
|
||||
TXBytes uint64 `json:"tx_bytes"`
|
||||
}
|
||||
|
||||
func sanitizedStatus(raw string) (tunnelStatus, error) {
|
||||
status := tunnelStatus{Status: "device_status"}
|
||||
seenPeer := false
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if key == "public_key" {
|
||||
if seenPeer {
|
||||
return tunnelStatus{}, errors.New("设备返回多个 Peer")
|
||||
}
|
||||
seenPeer = true
|
||||
}
|
||||
if !seenPeer {
|
||||
continue
|
||||
}
|
||||
var field *uint64
|
||||
switch key {
|
||||
case "last_handshake_time_sec":
|
||||
field = &status.HandshakeUnix
|
||||
case "rx_bytes":
|
||||
field = &status.RXBytes
|
||||
case "tx_bytes":
|
||||
field = &status.TXBytes
|
||||
default:
|
||||
continue
|
||||
}
|
||||
parsed, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil {
|
||||
return tunnelStatus{}, errors.New("设备状态计数器无效")
|
||||
}
|
||||
*field = parsed
|
||||
}
|
||||
if !seenPeer {
|
||||
return tunnelStatus{}, errors.New("设备没有可用的 Peer")
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func readRecord(path string) (*record, error) {
|
||||
info, err := os.Lstat(path)
|
||||
// Open the same inode that we check: Lstat followed by ReadFile would let
|
||||
// another process replace the path with a symlink between those calls.
|
||||
file, err := openRecord(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法读取本机设备配置: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法检查本机设备配置: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 {
|
||||
return nil, errors.New("本机设备配置权限必须为 0600")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := io.ReadAll(io.LimitReader(file, 64*1024+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法读取本机设备配置: %w", err)
|
||||
}
|
||||
if len(data) > 64*1024 {
|
||||
return nil, errors.New("本机设备配置超过大小限制")
|
||||
}
|
||||
var r record
|
||||
if err := json.Unmarshal(data, &r); err != nil {
|
||||
return nil, errors.New("本机设备配置格式无效")
|
||||
@@ -227,6 +290,9 @@ func serveSOCKS(r *record, address string) error {
|
||||
}
|
||||
defer listener.Close()
|
||||
parentPID := os.Getppid()
|
||||
if parentPID <= 1 {
|
||||
return errors.New("代理控制进程已退出")
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
@@ -261,6 +327,121 @@ func serveSOCKS(r *record, address string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// serveTUN creates a native interface. The parent owns IP/routes/DNS and must
|
||||
// not report a usable connection until those OS changes have succeeded.
|
||||
func serveTUN(r *record) error {
|
||||
if runtime.GOOS != "darwin" {
|
||||
return errors.New("原生 TUN 目前仅实现 macOS 数据面,系统网络配置尚未接入")
|
||||
}
|
||||
// Capture before initialization: a parent dying while the device starts
|
||||
// must not be mistaken for a new, healthy parent (typically launchd).
|
||||
parentPID := os.Getppid()
|
||||
if parentPID <= 1 {
|
||||
return errors.New("隧道控制进程已退出")
|
||||
}
|
||||
endpoint, port, err := resolveEndpoint(r.Config.Endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolved := *r
|
||||
resolved.Config.Endpoint = net.JoinHostPort(endpoint.String(), port)
|
||||
settings, _, err := uapi(&resolved, netip.Addr{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mtu := int(r.Config.MTU)
|
||||
if mtu == 0 {
|
||||
mtu = 1420
|
||||
}
|
||||
if mtu < 576 || mtu > 9000 {
|
||||
return errors.New("MTU 无效")
|
||||
}
|
||||
// Upstream's macOS CreateTUN optionally writes the assigned interface name
|
||||
// to WG_TUN_NAME_FILE. Our JSON handshake already returns the name; ignore
|
||||
// inherited environment so a launcher's variable cannot trigger a file write.
|
||||
_ = os.Unsetenv("WG_TUN_NAME_FILE")
|
||||
interfaceDevice, err := tun.CreateTUN("utun", mtu)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法创建本机网络接口: %w", err)
|
||||
}
|
||||
name, err := interfaceDevice.Name()
|
||||
if err != nil {
|
||||
_ = interfaceDevice.Close()
|
||||
return fmt.Errorf("无法获取本机网络接口: %w", err)
|
||||
}
|
||||
plan, err := planNativeRoutes(r, name, endpoint)
|
||||
if err != nil {
|
||||
_ = interfaceDevice.Close()
|
||||
return err
|
||||
}
|
||||
if plan.EndpointBypass {
|
||||
exit, err := physicalExitFor(endpoint)
|
||||
if err != nil {
|
||||
_ = interfaceDevice.Close()
|
||||
return err
|
||||
}
|
||||
plan.PhysicalExit = &exit
|
||||
}
|
||||
dev := device.NewDevice(interfaceDevice, conn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
|
||||
defer dev.Close()
|
||||
if err := dev.IpcSet(settings); err != nil {
|
||||
return errors.New("WireGuard 连接参数无效")
|
||||
}
|
||||
if err := dev.Up(); err != nil {
|
||||
return fmt.Errorf("Go 隧道启动失败: %w", err)
|
||||
}
|
||||
// Never include configuration, tokens, or keys in the readiness protocol.
|
||||
if err := json.NewEncoder(os.Stdout).Encode(struct {
|
||||
Status string `json:"status"`
|
||||
Plan networkPlan `json:"plan"`
|
||||
}{Status: "interface_created", Plan: plan}); err != nil {
|
||||
return fmt.Errorf("无法向控制面报告本机接口: %w", err)
|
||||
}
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
defer signal.Stop(stop)
|
||||
commands := make(chan string)
|
||||
go func() {
|
||||
defer close(commands)
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
for scanner.Scan() {
|
||||
commands <- scanner.Text()
|
||||
}
|
||||
}()
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return nil
|
||||
case command, ok := <-commands:
|
||||
if !ok || command == "stop" {
|
||||
return nil
|
||||
}
|
||||
if command != "status" {
|
||||
return errors.New("未知隧道控制指令")
|
||||
}
|
||||
raw, err := dev.IpcGet()
|
||||
if err != nil {
|
||||
return errors.New("无法读取 WireGuard 状态")
|
||||
}
|
||||
status, err := sanitizedStatus(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.NewEncoder(os.Stdout).Encode(status); err != nil {
|
||||
return errors.New("无法报告 WireGuard 状态")
|
||||
}
|
||||
case <-dev.Wait():
|
||||
return errors.New("WireGuard 接口意外关闭")
|
||||
case <-ticker.C:
|
||||
if os.Getppid() != parentPID || parentPID <= 1 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleSOCKS(client net.Conn, r *record, stack *netstack.Net) {
|
||||
defer client.Close()
|
||||
_ = client.SetDeadline(time.Now().Add(15 * time.Second))
|
||||
@@ -375,13 +556,21 @@ func main() {
|
||||
fmt.Println("opsx-tunnel-go " + version)
|
||||
return
|
||||
}
|
||||
if len(os.Args) != 4 || (os.Args[1] != "probe" && os.Args[1] != "proxy") {
|
||||
fmt.Fprintln(os.Stderr, "usage: opsx-tunnel-go <probe|proxy> <local-record> <IP:port|loopback:port>")
|
||||
if (len(os.Args) != 4 || (os.Args[1] != "probe" && os.Args[1] != "proxy")) &&
|
||||
(len(os.Args) != 3 || os.Args[1] != "tun") {
|
||||
fmt.Fprintln(os.Stderr, "usage: opsx-tunnel-go <probe|proxy> <local-record> <IP:port|loopback:port> | tun <local-record>")
|
||||
os.Exit(2)
|
||||
}
|
||||
// stdout only contains a non-secret, machine-readable result. Never echo the
|
||||
// configuration, keys, token, or arbitrary upstream error strings.
|
||||
r, err := readRecord(os.Args[2])
|
||||
if err == nil && os.Args[1] == "tun" {
|
||||
if err = serveTUN(r); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil && os.Args[1] == "proxy" {
|
||||
err = serveSOCKS(r, os.Args[3])
|
||||
if err != nil {
|
||||
@@ -390,12 +579,23 @@ func main() {
|
||||
}
|
||||
return
|
||||
}
|
||||
var latency *int64
|
||||
if err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
err = probe(ctx, r, os.Args[3])
|
||||
if err == nil {
|
||||
elapsed := time.Since(started).Milliseconds()
|
||||
if elapsed < 1 {
|
||||
elapsed = 1
|
||||
}
|
||||
// Assignment occurs below, after the result is constructed.
|
||||
latency = &elapsed
|
||||
}
|
||||
}
|
||||
res := result{Connected: err == nil, Target: os.Args[3]}
|
||||
res.LatencyMS = latency
|
||||
if err == nil {
|
||||
res.Detail = "WireGuard 出口 TCP 已连通"
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -64,6 +65,93 @@ func TestRejectSymlinkedKeyFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectOversizedRecord(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "record.json")
|
||||
if err := os.WriteFile(path, []byte(strings.Repeat("x", 64*1024+1)), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readRecord(path); err == nil || !strings.Contains(err.Error(), "大小限制") {
|
||||
t.Fatalf("oversized record accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusReturnsOnlyNonSecretCounters(t *testing.T) {
|
||||
raw := "private_key=secret\npublic_key=peer\npreshared_key=secret\nendpoint=192.0.2.1:51820\n" +
|
||||
"last_handshake_time_sec=123\nrx_bytes=456\ntx_bytes=789\nallowed_ip=0.0.0.0/0\n"
|
||||
status, err := sanitizedStatus(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.Status != "device_status" || status.HandshakeUnix != 123 || status.RXBytes != 456 || status.TXBytes != 789 {
|
||||
t.Fatalf("unexpected status: %+v", status)
|
||||
}
|
||||
data, err := json.Marshal(status)
|
||||
if err != nil || strings.Contains(string(data), "secret") || strings.Contains(string(data), "192.0.2.1") {
|
||||
t.Fatalf("status leaked configuration: %s, %v", data, err)
|
||||
}
|
||||
for _, malformed := range []string{"private_key=secret", "public_key=peer\nrx_bytes=bad", "public_key=one\npublic_key=two"} {
|
||||
if _, err := sanitizedStatus(malformed); err == nil {
|
||||
t.Fatalf("malformed device status accepted: %q", malformed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeRoutePlanGuardsPhysicalEndpoint(t *testing.T) {
|
||||
r := testRecord()
|
||||
ip, port, err := resolveEndpoint(r.Config.Endpoint)
|
||||
if err != nil || ip.String() != "121.4.111.208" || port != "51820" {
|
||||
t.Fatalf("endpoint resolution failed: %s, %s, %v", ip, port, err)
|
||||
}
|
||||
r.Config.AllowedIPs = "0.0.0.0/0"
|
||||
plan, err := planNativeRoutes(r, "utun12", ip)
|
||||
if err != nil || !plan.EndpointBypass || len(plan.Routes) != 2 ||
|
||||
plan.Routes[0].String() != "0.0.0.0/1" || plan.Routes[1].String() != "128.0.0.0/1" {
|
||||
t.Fatalf("full route missing endpoint bypass: %+v, %v", plan, err)
|
||||
}
|
||||
r.Config.AllowedIPs = "10.88.0.0/24, 10.88.0.0/24"
|
||||
plan, err = planNativeRoutes(r, "utun2", netip.MustParseAddr("121.4.111.208"))
|
||||
if err != nil || plan.EndpointBypass || len(plan.Routes) != 1 || plan.Routes[0].String() != "10.88.0.0/24" {
|
||||
t.Fatalf("split route not normalized: %+v, %v", plan, err)
|
||||
}
|
||||
r.Config.AllowedIPs = "::/0, 0.0.0.0/0"
|
||||
if _, err := planNativeRoutes(r, "utun2", netip.MustParseAddr("121.4.111.208")); err == nil {
|
||||
t.Fatal("unsupported IPv6 route accepted")
|
||||
}
|
||||
for _, name := range []string{"utun", "utun1;route", "eth0", "utun-1"} {
|
||||
if _, err := planNativeRoutes(r, name, netip.MustParseAddr("121.4.111.208")); err == nil {
|
||||
t.Fatalf("unsafe interface accepted: %q", name)
|
||||
}
|
||||
}
|
||||
for _, endpoint := range []string{"127.0.0.1:51820", "[::1]:51820", "missing-port"} {
|
||||
if _, _, err := resolveEndpoint(endpoint); err == nil {
|
||||
t.Fatalf("invalid endpoint accepted: %q", endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhysicalRouteParsingRejectsVPNAndInjection(t *testing.T) {
|
||||
output := " route to: 121.4.111.208\n gateway: 192.168.80.1\n interface: en0\n"
|
||||
exit, err := parsePhysicalRoute(output)
|
||||
if err != nil || exit.Interface != "en0" || exit.Gateway == nil || exit.Gateway.String() != "192.168.80.1" {
|
||||
t.Fatalf("physical gateway rejected: %+v, %v", exit, err)
|
||||
}
|
||||
direct, err := parsePhysicalRoute("interface: en4\n")
|
||||
if err != nil || direct.Gateway != nil {
|
||||
t.Fatalf("direct physical link rejected: %+v, %v", direct, err)
|
||||
}
|
||||
for _, raw := range []string{
|
||||
"interface: utun2\ngateway: 10.0.0.1\n",
|
||||
"interface: en0;touch /tmp/injected\n",
|
||||
"interface: lo0\n",
|
||||
"gateway: 192.168.80.1\n",
|
||||
"interface: en0\ngateway: invalid\n",
|
||||
} {
|
||||
if _, err := parsePhysicalRoute(raw); err == nil {
|
||||
t.Fatalf("unsafe route accepted: %q", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyCannotBindPublicInterface(t *testing.T) {
|
||||
for _, address := range []string{"0.0.0.0:19090", "192.0.2.1:19090", "127.0.0.1:0", "garbage"} {
|
||||
if err := serveSOCKS(testRecord(), address); err == nil {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func resolveEndpoint(raw string) (netip.Addr, string, error) {
|
||||
host, port, err := net.SplitHostPort(raw)
|
||||
if err != nil || host == "" || port == "" {
|
||||
return netip.Addr{}, "", errors.New("VPN 公网端点格式无效")
|
||||
}
|
||||
if ip, err := netip.ParseAddr(host); err == nil {
|
||||
if !ip.Is4() || !ip.IsGlobalUnicast() {
|
||||
return netip.Addr{}, "", errors.New("VPN 公网端点需要有效 IPv4 地址")
|
||||
}
|
||||
return ip, port, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip4", host)
|
||||
if err != nil {
|
||||
return netip.Addr{}, "", errors.New("无法在连接前解析 VPN 公网端点")
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if address.Is4() && address.IsGlobalUnicast() {
|
||||
return address, port, nil
|
||||
}
|
||||
}
|
||||
return netip.Addr{}, "", errors.New("VPN 公网端点没有可用 IPv4 地址")
|
||||
}
|
||||
|
||||
// networkPlan contains only public addressing information. The privileged
|
||||
// network adapter still needs to discover the physical gateway and persist a
|
||||
// rollback journal before applying any of these routes.
|
||||
type networkPlan struct {
|
||||
Interface string `json:"interface"`
|
||||
ClientAddress netip.Prefix `json:"client_address"`
|
||||
Routes []netip.Prefix `json:"routes"`
|
||||
Endpoint netip.Addr `json:"endpoint"`
|
||||
EndpointBypass bool `json:"endpoint_bypass"`
|
||||
PhysicalExit *physicalRoute `json:"physical_exit,omitempty"`
|
||||
}
|
||||
|
||||
type physicalRoute struct {
|
||||
Interface string `json:"interface"`
|
||||
Gateway *netip.Addr `json:"gateway,omitempty"`
|
||||
}
|
||||
|
||||
// macOS route -n get output is only an observation, never an executable
|
||||
// command. Reject another VPN's utun as the physical endpoint exit.
|
||||
func parsePhysicalRoute(output string) (physicalRoute, error) {
|
||||
var exit physicalRoute
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(line), ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
switch key {
|
||||
case "interface":
|
||||
exit.Interface = value
|
||||
case "gateway":
|
||||
gateway, err := netip.ParseAddr(value)
|
||||
if err != nil || !gateway.Is4() || gateway.IsUnspecified() || gateway.IsLoopback() {
|
||||
return physicalRoute{}, errors.New("物理网关地址无效")
|
||||
}
|
||||
exit.Gateway = &gateway
|
||||
}
|
||||
}
|
||||
if len(exit.Interface) < 2 || len(exit.Interface) > 16 || strings.HasPrefix(exit.Interface, "utun") || exit.Interface == "lo0" {
|
||||
return physicalRoute{}, errors.New("VPN 网关当前未经过物理网络出口")
|
||||
}
|
||||
for _, char := range exit.Interface {
|
||||
if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9')) {
|
||||
return physicalRoute{}, errors.New("物理出口接口名称无效")
|
||||
}
|
||||
}
|
||||
return exit, nil
|
||||
}
|
||||
|
||||
func planNativeRoutes(r *record, interfaceName string, endpoint netip.Addr) (networkPlan, error) {
|
||||
if !strings.HasPrefix(interfaceName, "utun") || len(interfaceName) < 5 {
|
||||
return networkPlan{}, errors.New("本机接口名称无效")
|
||||
}
|
||||
for _, digit := range interfaceName[4:] {
|
||||
if digit < '0' || digit > '9' {
|
||||
return networkPlan{}, errors.New("本机接口名称无效")
|
||||
}
|
||||
}
|
||||
address, err := netip.ParsePrefix(r.Config.ClientAddress)
|
||||
if err != nil || !address.Addr().Is4() || address.Addr().IsUnspecified() {
|
||||
return networkPlan{}, errors.New("本机 IPv4 地址无效")
|
||||
}
|
||||
if !endpoint.IsValid() || !endpoint.Is4() || !endpoint.IsGlobalUnicast() {
|
||||
return networkPlan{}, errors.New("VPN 公网端点必须解析为可达的 IPv4 地址")
|
||||
}
|
||||
plan := networkPlan{Interface: interfaceName, ClientAddress: address, Endpoint: endpoint, Routes: []netip.Prefix{}}
|
||||
seen := make(map[netip.Prefix]bool)
|
||||
for _, part := range strings.Split(r.Config.AllowedIPs, ",") {
|
||||
prefix, err := netip.ParsePrefix(strings.TrimSpace(part))
|
||||
if err != nil {
|
||||
return networkPlan{}, errors.New("WireGuard 授权路由无效")
|
||||
}
|
||||
if !prefix.Addr().Is4() {
|
||||
return networkPlan{}, errors.New("IPv6 系统路由尚未实现,请在专线中仅授权 IPv4 路由")
|
||||
}
|
||||
prefix = prefix.Masked()
|
||||
if prefix.Bits() == 0 {
|
||||
// These /1 routes override the physical default while retaining it
|
||||
// for explicit endpoint /32 bypass. The helper must install the bypass.
|
||||
for _, half := range []string{"0.0.0.0/1", "128.0.0.0/1"} {
|
||||
p := netip.MustParsePrefix(half)
|
||||
if !seen[p] {
|
||||
plan.Routes = append(plan.Routes, p)
|
||||
seen[p] = true
|
||||
}
|
||||
}
|
||||
plan.EndpointBypass = true
|
||||
continue
|
||||
}
|
||||
if !seen[prefix] {
|
||||
plan.Routes = append(plan.Routes, prefix)
|
||||
seen[prefix] = true
|
||||
}
|
||||
if prefix.Contains(endpoint) {
|
||||
plan.EndpointBypass = true
|
||||
}
|
||||
}
|
||||
if plan.EndpointBypass {
|
||||
// A physical /32 must be installed before any matching VPN route.
|
||||
// Otherwise a full-tunnel VPN can attempt to reach itself through TUN.
|
||||
if endpoint == address.Addr() {
|
||||
return networkPlan{}, errors.New("VPN 端点与本机地址冲突")
|
||||
}
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func openRecord(path string) (*os.File, error) {
|
||||
fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.NewFile(uintptr(fd), path), nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func openRecord(path string) (*os.File, error) {
|
||||
name, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, err := windows.CreateFile(name, windows.GENERIC_READ, windows.FILE_SHARE_READ,
|
||||
nil, windows.OPEN_EXISTING, windows.FILE_FLAG_OPEN_REPARSE_POINT, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &data); err != nil {
|
||||
windows.CloseHandle(handle)
|
||||
return nil, err
|
||||
}
|
||||
if data.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
windows.CloseHandle(handle)
|
||||
return nil, syscall.EINVAL
|
||||
}
|
||||
return os.NewFile(uintptr(handle), path), nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
func physicalExitFor(endpoint netip.Addr) (physicalRoute, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
output, err := exec.CommandContext(ctx, "/sbin/route", "-n", "get", endpoint.String()).Output()
|
||||
if err != nil {
|
||||
return physicalRoute{}, errors.New("无法读取 VPN 网关的当前物理出口")
|
||||
}
|
||||
return parsePhysicalRoute(string(output))
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
func physicalExitFor(_ netip.Addr) (physicalRoute, error) {
|
||||
return physicalRoute{}, errors.New("当前平台尚不支持系统隧道网络配置")
|
||||
}
|
||||
Reference in new issue
Block a user