Refine WireGuard client routing and authentication UI

This commit is contained in:
opsx committed 2026-10-21 21:10:53 +08:00
1 parent eabb3e4472
commit 41fb9d5631
33 files changed
+1709 -469

No files matched your search

+18 -7
View File
@@ -269,12 +269,13 @@ func authorizedLines(username, password, otp string) ([]model.VPNUser, bool, err
}
type clientLine struct {
TunnelID uint `json:"tunnel_id"`
Name string `json:"name"`
Remark string `json:"remark"`
State string `json:"state"`
Endpoint string `json:"endpoint"`
ExpireAt *time.Time `json:"expire_at"`
TunnelID uint `json:"tunnel_id"`
Name string `json:"name"`
Remark string `json:"remark"`
State string `json:"state"`
Endpoint string `json:"endpoint"`
RoutingMode string `json:"routing_mode"`
ExpireAt *time.Time `json:"expire_at"`
}
// linesForUsers 把授权拨号记录映射为 WireGuard 线路列表。
@@ -290,15 +291,25 @@ func linesForUsers(users []model.VPNUser) []clientLine {
if err != nil || t.Protocol != "wireguard" || (t.ExpireAt != nil && time.Now().After(*t.ExpireAt)) {
continue
}
line := clientLine{TunnelID: t.ID, Name: t.Name, Remark: t.Remark, State: t.State, ExpireAt: t.ExpireAt}
line := clientLine{TunnelID: t.ID, Name: t.Name, Remark: t.Remark, State: t.State, ExpireAt: t.ExpireAt, RoutingMode: "split"}
if cfg, err := store.TunnelConfig(t); err == nil {
line.Endpoint = wireguardEndpoint(cfg)
line.RoutingMode = routingModeForAllowedIPs(fmt.Sprint(cfg["allowed_ips"]))
}
lines = append(lines, line)
}
return lines
}
func routingModeForAllowedIPs(value string) string {
for _, entry := range strings.Split(value, ",") {
if strings.TrimSpace(entry) == "0.0.0.0/0" {
return "global_ipv4"
}
}
return "split"
}
func wireguardEndpoint(cfg map[string]interface{}) string {
if ep, ok := cfg["public_endpoint"].(string); ok && ep != "" {
return ep
@@ -0,0 +1,16 @@
package api
import "testing"
func TestRoutingModeReflectsOnlyAuthorizedDefaultRoute(t *testing.T) {
for _, tc := range []struct{ allowed, want string }{
{"10.88.0.0/24", "split"},
{"10.88.0.0/24, 0.0.0.0/0", "global_ipv4"},
{"0.0.0.0/00", "split"},
{"", "split"},
} {
if got := routingModeForAllowedIPs(tc.allowed); got != tc.want {
t.Errorf("%q: got %q, want %q", tc.allowed, got, tc.want)
}
}
}
@@ -265,6 +265,7 @@ func (s *Service) DeviceConnectionParams(t *model.Tunnel, peerID string) (map[st
"endpoint": service.StrOr(cfg, "public_endpoint", fmt.Sprintf("your-server-ip:%d", int(service.Num(cfg, "listen_port", defaultPort)))),
"listen_port": int(service.Num(cfg, "listen_port", defaultPort)),
"allowed_ips": allowed,
"latency_probe": service.Str(cfg, "latency_probe"),
"dns": service.StrOr(cfg, "dns", service.DefaultDNS()),
"mtu": int(service.Num(cfg, "mtu", 0)),
"keepalive": int(service.Num(cfg, "keepalive", 25)),
@@ -106,6 +106,11 @@ func isWireGuardKey(key string) bool {
}
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
// Static peers still authenticate by their key; this setting only describes
// how the managed desktop client obtains its device grant.
if mode := service.Str(cfg, "auth_mode"); mode != "" && mode != "password" {
return nil, fmt.Errorf("WireGuard 尚不支持第三方 OAuth2/OIDC 登录,请使用客户端账号与设备密钥")
}
if err := service.MustValidate(cfg, "address"); err != nil {
return nil, err
}
@@ -127,6 +132,13 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
if err := validateCIDRList(service.Str(out, "allowed_ips")); err != nil {
return nil, fmt.Errorf("allowed_ips: %w", err)
}
if target := service.Str(out, "latency_probe"); target != "" {
host, port, err := net.SplitHostPort(target)
probePort, parseErr := strconv.Atoi(port)
if err != nil || parseErr != nil || net.ParseIP(host) == nil || probePort < 1 || probePort > 65535 {
return nil, fmt.Errorf("latency_probe 必须是可经 VPN 访问的 IP:端口")
}
}
if service.Str(out, "dns") == "" {
out["dns"] = service.DefaultDNS()
}
@@ -83,6 +83,32 @@ func TestValidateRejectsUnsafeNATAndInvalidPeerAddress(t *testing.T) {
}
}
func TestValidateWireGuardAuthMode(t *testing.T) {
for _, mode := range []string{"", "password"} {
if _, err := New().Validate(map[string]interface{}{"address": "10.8.0.1/24", "auth_mode": mode}); err != nil {
t.Fatalf("legacy/password mode %q rejected: %v", mode, err)
}
}
for _, mode := range []string{"oidc", "unknown"} {
if _, err := New().Validate(map[string]interface{}{"address": "10.8.0.1/24", "auth_mode": mode}); err == nil {
t.Fatalf("unsupported authentication mode %q accepted", mode)
}
}
}
func TestValidateLatencyProbeTarget(t *testing.T) {
for _, target := range []string{"10.88.0.1:22", "[2001:db8::1]:443", ""} {
if _, err := New().Validate(map[string]interface{}{"address": "10.8.0.1/24", "latency_probe": target}); err != nil {
t.Fatalf("valid target %q rejected: %v", target, err)
}
}
for _, target := range []string{"example.com:443", "10.88.0.1:0", "10.88.0.1:65536", "invalid"} {
if _, err := New().Validate(map[string]interface{}{"address": "10.8.0.1/24", "latency_probe": target}); err == nil {
t.Fatalf("invalid target %q accepted", target)
}
}
}
func TestNATOnlyAppliesToWireGuardSubnet(t *testing.T) {
conf := renderServerConfig(map[string]interface{}{
"address": "10.88.0.1/24", "listen_port": 51820,
+9 -3
View File
@@ -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`、无额外口令,其他本机进程也能使用;切换账号、撤销本机设备、退出登录或退出应用时会停止代理。客户端意外退出后代理最多约两秒内检测到父进程消失并退出。
+39 -7
View File
@@ -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
View File
@@ -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();
}
}
+212
View File
@@ -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());
}
}
+10 -46
View File
@@ -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);
}
}
}
+121
View File
@@ -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(&current) || (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()) }
+2 -156
View File
@@ -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
View File
@@ -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' : ''}`}>● &nbsp;{connected ? '已连接' : connecting ? '连接中' : '未连接'}</span>
<span className={`preview-pill ${connected ? 'online' : ''}`}>● &nbsp;{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">●&nbsp;{connected ? `已连接 · ${currentLineName}` : connecting ? '连接中…' : '本客户端未连接'}</span>
<div><span className="overline">WORKSPACE / OVERVIEW</span><h1>连接概览</h1><p>选择线路,启动节点代理。</p></div>
<span className="status-tag">●&nbsp;{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>
+67
View File
@@ -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
View File
@@ -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()
}
+7 -2
View File
@@ -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
View File
@@ -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 {
+88
View File
@@ -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 {
+142
View File
@@ -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
}
+17
View File
@@ -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
}
+32
View File
@@ -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
}
+21
View File
@@ -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))
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !darwin
package main
import (
"errors"
"net/netip"
)
func physicalExitFor(_ netip.Addr) (physicalRoute, error) {
return physicalRoute{}, errors.New("当前平台尚不支持系统隧道网络配置")
}
+1 -1
View File
@@ -11,7 +11,7 @@
## 首次构建与启动
```bash
git clone https://gitee.com/opsx-tools/opsx-vpn.git
git clone https://gitea.91ai.io/rlwj-lab/opsx-vpn.git
cd opsx-vpn
cd frontend && npm ci && npm run build && cd ..
go build -ldflags="-s -w" -o opsx-vpn ./backend
+37 -11
View File
@@ -2,27 +2,28 @@
| 字段 | 内容 |
|---|---|
| 版本 / 变更级别 | 0.1 / S3(系统隧道、路由、DNS 与特权执行) |
| 版本 / 变更级别 | 0.3 / S3(内置 Go 系统隧道、路由、DNS 与特权执行) |
| 设计状态 | Draft,已核对本地实现与 opsxdb / opsxapi 的桌面打包方式 |
| 实现状态 | 已引入可打包 Go 版标准 WireGuard 无特权探针和显式启停的本机 SOCKS5 模式;系统 TUN/路由/DNS 尚未完成 |
| 实现状态 | Go 探针、SOCKS5 与 macOS 原生 `utun` 数据面、Rust 受限子进程桥接代码已实现;系统工具调用路径已移除,内置直连尚不可用;拨号用户可自动登记至代理启动;macOS 系统 SOCKS 开关已实现,真机验收待办;系统接口地址/路由/DNS 和恢复尚未完成 |
| 负责人 / 日期 | opsx-vpn / 2026-10-21 |
| 关联需求 | 企业员工安装 OpsX VPN 即可拨号,无需 Homebrew 或手动安装 wireguard-tools |
| 批准状态 | 用户已要求先评估设计后开发;具体平台发布签名尚待配置 |
## 1. 背景、目标与范围
- **当前行为与证据**:`client/src-tauri/src/tunnel.rs` 使用本机 `wg-quick` 创建接口,macOS 通过管理员授权执行,Linux 依赖 `pkexec`;`system.rs` 只检查工具位置;Windows 返回未实现。`client/src-tauri/tauri.conf.json` 未打包隧道组件。`../opsxdb/src-tauri/tauri.conf.json` 使用 `externalBin` 打包 Go sidecar,`../opsxdb/run.sh` 按目标 triple 复制二进制;`../opsxapi/src-tauri/src/lib.rs` 则在 Rust 内直接实现网络业务。两者均未实现 VPN 特权 helper。现有 `docs/specs/SDD-002-desktop-wireguard-client.md` 中普通拨号用户和设备 API 已进入第二阶段,目标 OS 的真实握手仍待验。
- **当前行为与证据**:`client/src-tauri/src/tunnel.rs` 只管理本机密钥与设备记录;此前的 `wg-quick` 执行和 PATH 检测已按用户要求移除。`system.rs` 在特权网络服务完成前始终报告直连未就绪;Go 探针及应用代理仍可用。`client/src-tauri/tauri.conf.json` 打包 Go sidecar;`../opsxdb/src-tauri/tauri.conf.json` 使用 `externalBin` 打包 Go sidecar。现有 `docs/specs/SDD-002-desktop-wireguard-client.md` 中普通拨号用户和设备 API 已进入第二阶段,目标 OS 的真实系统隧道仍待验。
- **目标**:Rust 保持唯一的客户端控制面及本机私钥拥有者;按 OS 引入可随应用签名分发的隧道执行层。macOS 优先;用户不必安装 Homebrew 或 WireGuard 命令。Linux 复用内核 WireGuard,但打包必要工具与权限策略;Windows 使用专门的 Windows WireGuard 驱动/服务方案,不把 macOS 的 Go TUN 实现当通用驱动。
- **本轮实施边界**:实现可打包的 Go 用户空间 WireGuard netstack 探针和明确标注的本机 SOCKS5 应用代理;Rust 执行层仍明确报告系统工具依赖。完成可签名的特权 helper、路由/DNS 回滚和目标 OS 握手验收之前,不能在 UI 上宣称已实现免依赖系统拨号。
- **本轮实施边界**:保留可打包的 Go netstack 探针与本机 SOCKS5 应用代理;客户端不再执行系统 WireGuard 命令。完成特权 helper、路由/DNS 回滚和目标 OS 握手验收之前,UI 只报告内置直连未就绪。
- **保留行为**:普通用户认证、设备公钥注册、配置领取及服务端 WireGuard UDP 协议不改;前端不接收私钥,服务端不接收客户端私钥。
## 2. 需求与约束
- FR-01:启用免安装分发通道的发布版仅在安装包内存在**正确目标架构、可校验版本**的隧道组件且系统适配就绪时报告“内置引擎就绪”;缺失时应明确报错,绝不悄悄运行 PATH 中同名程序。当前过渡版明确标记为“系统工具模式”,不得作为免安装版分发。
- FR-02:开发模式仍可使用系统 `wg-quick`,并明确标记为“系统工具模式”;二者的就绪检测和实际连接路径必须一致。
- FR-01:启用免安装分发通道的发布版仅在安装包内存在**正确目标架构、可校验版本**的隧道组件且系统适配就绪时报告“内置引擎就绪”;缺失时应明确报错,绝不运行 PATH 中同名程序。
- FR-02:所有模式均不调用本机 `wg` / `wg-quick`;内置网络服务未就绪时直连明确不可用,应用代理单独可用。
- FR-03:随包组件只接受固定动作及受约束的本机配置路径,不接受来自 WebView 的任意命令或路径;日志不可含私钥、令牌、完整配置。
- FR-04:macOS 正式接入必须完成受控提权、TUN、路由与 DNS 原值保存和恢复;授权取消、进程崩溃、升级或断开均须保持可恢复性。系统辅助程序的签名与安装按目标发行证书验收。
- FR-05:Windows/Linux 按各自系统 API 和权限交付;未验收的平台应明确“尚不支持内置隧道”,不能用模拟连接替代。
- FR-06:无系统工具的用户登录后可从概览启动 Go 应用代理:先校验最新线路授权、自动登记/刷新本机设备、使用实际 WireGuard 参数启动本机 SOCKS5。UI 必须将代理状态与整机 VPN 状态分开;退出、撤销设备、进程停止后不可仍报代理在线。
- NFR-01:正式发布不得从远程地址动态下载可执行文件、不得通过 shell 插入远端配置、不得使用旧飞连魔改 WireGuard 协议。
- NFR-02:非管理员安装包、系统提权、代码签名、升级与卸载必须分别测试;不能将可执行文件放进用户可写目录再让 root 执行。
@@ -30,8 +31,8 @@
```text
React → Tauri invoke → Rust(登录、设备私钥、连接状态)
├─ 开发过渡:系统 wg-quick
└─ 发布引擎:签名的特权隧道组件
├─ 当前:Go 应用代理;直连未就绪
└─ 目标:签名的特权隧道组件
├─ macOS:WireGuard Go 引擎 + 受控网络配置/恢复
├─ Linux:内核 WireGuard + 受控配置服务
└─ Windows:WireGuardNT/官方隧道服务适配
@@ -46,9 +47,30 @@ React → Tauri invoke → Rust(登录、设备私钥、连接状态)
## 4. 接口、数据与配置契约
- `get_system_readiness` 向前兼容既有 `platform/wg_found/wg_quick_found`,新增 `engine_mode`(`bundled` / `system_tools` / `unsupported`)及可读 `engine_detail`;由 Rust 而非 UI 决定。`bundled` 仅可在实际引擎、权限和系统配置均通过验收后返回。
### 无 `wg` / `wg-quick` 的 macOS 直连实现
- Go 子进程在用户权限下使用上游 `tun.CreateTUN("utun", mtu)` 创建接口、启动 WireGuard,并通过一次性 stdout 消息把系统分配的 `utunN` 回传 Rust;本机配置保持 0600。Go 子进程不能仅因为接口创建成功就报告已联网;Rust 完成 IP、路由、DNS 设置后才允许控制面查询真实握手。
- 对照 `../demo/clash-verge-rev`:它的 `config/clash.rs` 为 Go 核心 Mihomo 配置 `tun.auto-route/auto-detect-interface/dns-hijack`,`core/service.rs` 由经管理员确认的安装器将核心发布到服务拥有的路径,再用受限 IPC 启动;`feat/tun.rs` 在服务未就绪时停用 TUN。其“rule/global”是 Mihomo 的流量策略,不等于 WireGuard 的 AllowedIPs。OpsX 不直接复用 Mihomo 的代理规则或服务 IPC 协议。
- 其他参考边界:corplink-rs 的飞连私有协议字段不适用于本项目标准 WireGuard;Tailscale/NetBird 可以参考服务化与网络生命周期,但不能据此假设它们的路由/DNS 实现已经被本项目核对或移植;AmneziaWG-go 的混淆需服务端同时支持,当前服务端未采用该协议。本机只核对过 Clash Verge Rev 的源码,其他项目目前属于候选参考。
- 已核对本地 `../demo/vpn/wireguard-go`(提交 `2631ce9`),与客户端 `go.mod` 锁定的上游模块版本一致。其 `tun/tun_darwin.go` 中 `CreateTUN("utun", mtu)` 让内核分配真实接口名,且可选环境变量 `WG_TUN_NAME_FILE` 会写文件;本项目禁用这一额外写入,仅通过 JSON 回传真实名称。其 `main.go` 用 `device.Wait`、终止信号和 `device.Close` 管理生命周期,但没有替应用设置地址、路由或 DNS;`device/uapi.go` 的 `IpcGet` 会包含私钥,必须在 Go 进程内过滤后才返回 Rust。
- Go `tun` 命令仅在 macOS 运行,读取 0600 本机记录,创建接口后只输出 `interface_created` 和系统分配的 `utunN`;其标准输入仅接受 `status`、`stop`,关闭输入、父进程退出或接口关闭时终止。`status` 仅输出握手秒数与收发字节数,不透传包含私钥的 WireGuard UAPI 原文;首次接口事件**不是**握手或路由就绪。普通登录进程不得因为收到这个消息就更新 UI 为已连接。
- `interface_created` 同时携带不含密钥的 IPv4 路由计划:先在原物理出口固定网关 endpoint `/32`(如果授权路由覆盖它),再按授权网段安装 TUN 路由;`0.0.0.0/0` 展开为两个 `/1`。域名 endpoint 在动路由前解析成一个 IP,WireGuard Device 使用同一个解析结果,防止多个 DNS 地址导致绕行规则与实际发送目标不一致。若需网关绕行,Go 在启动前只读调用 `/sbin/route -n get` 并提取物理接口和网关;已走 `utun`、无接口或格式非法则拒绝。此结果是快照,特权服务实际安装路由前仍须复核物理出口,防止检查后网络切换。当前只规划 IPv4,出现 IPv6 授权立即拒绝,不把未接管的 IPv6 流量误称为全局模式。仍需特权服务安全执行和恢复,此计划本身不会改变系统路由。
- `client/src-tauri/src/native_tunnel.rs` 已能在 macOS 启动 Go 子进程、限制启动与查询等待时长、解析非机密计划与握手计数,断开或 Rust 对象销毁时终止子进程;字段在 Rust 侧再次校验。该模块先保持脱离 UI 的直连路径,待特权服务完成网络事务与恢复并通过握手/流量验收才启用,防止接口创建事件被 UI 当作可联网状态。
- Rust 只允许经校验的 IPv4、合法 `utunN`、服务端 `AllowedIPs` 及固定动作传给受控系统服务;服务安装到管理员拥有的固定位置,验签/校验所有权,使用受限本地 IPC 更新路由和 DNS,不能从用户可写路径提权运行 Go 核心,也不通过临时拼接的 AppleScript/shell 命令充当发布版特权服务。专线 endpoint 需为物理网络出口保留路由,防止全局路由自吞。
- 系统配置事务顺序:解析 endpoint 的实际地址并记录原物理出口与 DNS 状态;先持久化带有接口名和拥有者标识的恢复记录,再设置接口地址和路由、受控 DNS;核对网络栈实际状态、服务端新握手和授权目标连通性后才发布连接状态。全局路由应避开 VPN endpoint,网络切换时重新计算出口。若启动中途失败,按逆序恢复,只撤回自身仍持有的设置;恢复失败保留记录并提示人工修复,不覆盖后续第三方改动。服务重启时扫描自己的未完成事务并恢复。
- 路由/DNS 变更前写入只属于本应用的恢复记录。部分失败立即回滚;断开、注销、设备撤销、Go 进程意外退出、应用退出或下次启动重试恢复,只清理与当前接口/目标一致的配置。恢复失败时明确报告,并阻止把连接显示为成功。网络切换、休眠唤醒、DNS 原值和抢占冲突需要 macOS 真机验证。
- 本轮优先 macOS;Linux/Windows 需要独立系统权限与适配,未实现时不得显示内置直连可用。现有 SOCKS5 应用代理保留;任何平台均不回退到系统 WireGuard 工具。
- `get_system_readiness` 报告平台、Go 代理组件、`engine_mode` 和可读 `engine_detail`;不再检测或展示系统 WireGuard 命令。`bundled` 仅可在实际引擎、权限和系统配置均通过验收后返回。
- 内置组件的目标 triple 路径和版本由构建产物显式声明;发布流程核对哈希、签名和可执行权限。不能从用户可写数据目录或 PATH 寻找发布 helper。
- 服务端 API、数据库及客户端设备密钥数据模型不变;未来 helper 通过最小化本地 IPC 收到连接参数,不接触用户口令或管理令牌。
- 本轮 Go 代理继续显式开启,不自动修改系统代理、路由或 DNS。Rust 将设备准备与整机拨号分离;代理路径复用同一设备授权流程,启动时重新查授权,失败时不遗留假在线状态。试验用户的 `AllowedIPs=10.88.0.0/24` 只可代理该网段,不能宣称可经 VPN 浏览公网。
### macOS 系统代理扩展(S3,2026-10-21)
- 用户显式启动应用代理后可单独启用系统 SOCKS 代理。仅当设备配置实际包含 `0.0.0.0/0` 时允许开启;当前私网试验专线应拒绝,避免把普通网页发送到不允许公网访问的隧道。系统代理只影响遵循 macOS 代理设置的应用,不等同于整机 TUN,也不能保证所有 UDP 或 DNS 走隧道。
- 启用前枚举网络服务、检查 HTTP/HTTPS/SOCKS/PAC/自动发现是否已有代理;已有代理时拒绝接管。持久记录将要更改的服务与原 SOCKS 配置;启用失败逐项回滚。关闭时只恢复仍指向自身监听端口的服务,绝不覆盖其他程序随后修改的代理。进程意外退出或下次启动应尝试清理残留;无法安全恢复时给出错误并保留恢复记录。网络服务新增或切换不能自动把未授权服务纳入代理范围。
- 启用、关闭、注销、退出与隧道切换均需串行操作。恢复失败不得悄悄关闭仍被系统代理引用的本地监听;手动改动代理后状态显示为冲突。验收包括私网路由拒绝、已有代理拒绝、启停还原、部分失败回滚、异常退出后重启恢复及更换网络服务。正式真机验收前不把此能力描述为跨平台整机 VPN。
## 5. 风险与验收
@@ -61,16 +83,20 @@ React → Tauri invoke → Rust(登录、设备私钥、连接状态)
| AC | 对应需求 | 前置条件与操作 | 预期结果 | 验证方式 | 结果 |
|---|---|---|---|---|---|
| AC-01 | FR-01, FR-02 | 无 Homebrew 的 macOS 打开当前过渡版/未来免安装版 | 过渡版明确显示依赖;免安装版仅在组件和权限实际就绪时显示内置可用 | 单测、构建、真机 | 当前过渡版选择器单测通过;免安装版 Not Run |
| AC-01 | FR-01, FR-02 | macOS/Linux 上有或没有系统 WireGuard 命令时打开客户端 | 两种环境均报告内置直连未就绪;客户端不运行系统命令,Go 代理独立可用 | Rust 单测、构建、静态调用核查、真机 | Rust 单测与静态核查通过;真机 Not Run |
| AC-01a | FR-01, NFR-01 | 构建固定版本 Go 组件,输入授权路由内/外目标 | 仅支持授权内目标,不输出密钥,打包客户端可定位正确架构组件 | Go 单测、构建和实际出口探测 | Go 单测、macOS arm64 构建、临时授权公网出口与 SOCKS5h 网页请求通过;正式安装包签名 Not Run |
| AC-02 | FR-03, NFR-01/02 | 尝试替换组件、传非法路径、控制字符 | 拒绝执行,机密不记录 | 隔离测试、签名检查 | Not Run |
| AC-03 | FR-04 | macOS 连接、断开、撤销授权、强制退出及重启 | 真实握手;路由/DNS 保持或恢复 | 两台目标机人工/自动验收 | Not Run |
| AC-03a | FR-03/04 | 数据面收到格式错误、过大、符号链接设备记录;模拟全局/分流路由、物理出口与 `utun` 出口;父进程退出或接口意外关闭 | 拒绝错误输入与非物理出口;进程退出并释放接口;接口创建事件不冒充联网完成 | Go 单测、跨平台构建、macOS 故障注入 | 输入与物理出口解析单测、构建通过;macOS 故障注入 Not Run |
| AC-04 | FR-05 | Windows/Linux 干净系统安装、拨号、卸载 | 无独立工具安装步骤,未支持平台不假装就绪 | 各平台测试 | Not Run |
| AC-05 | FR-06 | 无 `wg-quick`、有拨号用户授权时点击“应用代理” | 自动登记设备并启动 Go 代理;界面展示应用代理地址且整机 VPN 仍未连接;撤销/退出后代理停止 | Rust/Go 单测、macOS 实机检查 | Rust 模拟服务端首次登记/刷新测试通过;Go 私网探测成功,本机代理监听/私网 SOCKS 握手通过;GUI 拨号用户与撤销验收待运行 |
| AC-06 | macOS 系统代理扩展 | 私网路由/已有代理拒绝;全局路由下启停、断网、异常退出 | 已占用设置不覆盖;仅恢复自身代理;不把 SOCKS 宣称为所有流量经过 VPN | macOS 真机逐项验收 | Rust 构建/静态状态解析通过;系统设置与公网网页真机验收 Not Run |
## 6. 发布与恢复
- 第一阶段仅交付设计与隔离执行层的基础代码,不默认启用内置模式;持续提供现有 `wg-quick` 开发通道。切换正式引擎前收集目标架构及代码签名资料。
- 第一阶段关闭系统工具直连,仅交付隔离执行层和 Go 代理;特权网络服务与恢复通过验收后才启用内置直连。切换正式引擎前收集目标架构及代码签名资料。
- 2026-10-21 用户明确授权后,使用 0600 临时设备配置将 AllowedIPs 扩为全路由,在服务器仅为 `10.88.0.0/24` 添加临时出口 NAT;Go 数据面实际连接 `223.5.5.5:443` 成功,通过 SOCKS5h 获取 `http://www.baidu.com` 返回 HTTP 200。原始设备记录仍只允许 `10.88.0.0/24`;验证后已删除唯一带 `opsx-vpn-wg4-pilot` 标记的临时 NAT 规则。服务器自身访问 `1.1.1.1:443` 也超时,客户端探测目标改为可达的 `223.5.5.5:443`。这些验证不证明系统 VPN 或路由/DNS 恢复完成。
- 2026-10-21 只读复核运行中的 wg4、IPv4 转发与 NAT:IPv4 转发已开启,POSTROUTING 仅有 OpenVPN `10.10.0.0/24` 规则,WireGuard `10.88.0.0/24` 没有持久出口 NAT。全局线路上线需先通过管理后台启用该专线的出口 NAT 并下发经授权的全局 IPv4 路由,再实测浏览器访问;当前不能标为公网验收通过。
- 先在独立试验机测试安装与签名,再验证路由/DNS 回滚与握手,然后分批发版;不能仅靠 `cargo check` 或浏览器预览宣称可分发。
- 出现网络配置残留时用保存的系统快照恢复并停用有问题的组件;保留旧安装包与开发模式供排障。服务端不变,无数据库回滚。
@@ -0,0 +1,63 @@
# SDD-004:客户端企业入口与线路选择
| 字段 | 内容 |
|---|---|
| 版本 / 级别 | 0.2 / S3(客户端交互、认证配置安全边界与列表展示) |
| 设计状态 | 实施中 |
| 实现状态 | 客户端企业入口、能力读取、路由标签、实测隧道探测与信号格、WireGuard 认证配置边界和专线列表已实现;WireGuard 浏览器 OIDC 仍未实现 |
| 日期 | 2026-10-21 |
| 关联需求 | 企业选择、飞书登录入口、模式和节点延迟;WireGuard 认证方式与专线列表操作 |
| 批准状态 | 用户提供竞品截图并要求按现有项目继续实现 |
## 1. 背景、目标与范围
- 当前 `client/src/App.tsx` 只有服务端地址和密码表单、线路平铺列表。`backend/internal/api/api.go` 的匿名 `/api/client/capabilities` 返回 `oidc: false`;OpenVPN 的 `openvpn-auth-oauth2` 与 WireGuard 客户端会话互不兼容。`backend/internal/api/client.go` 的线路含 `endpoint`,不含节点 RTT;`client_probe` 是经 WireGuard 到固定公网 TCP 目标的诊断。
- 目标:以 OpsX 自己的品牌做企业入口、认证选择和节点选择弹窗;用户配置的服务器才是“单位”,不预填竞品单位。只给实测线路显示延迟/颜色和信号格。
- 本次不新增身份映射、全局路由覆盖或系统 DNS 修改。登录方式与网络模式必须与真实服务端/本机能力一致。保留已有 HTTP 地址支持、密码/MFA 登录、登录会话、设备私钥和连接状态语义。
## 2. 需求与约束
- FR-01:登录页标为“选择接入点”,填写服务端地址并自动带入上次使用的地址;验证真实 `/api/health` 后进入认证界面。切换接入点清除账号口令,不显示历史地址下拉。
- FR-02:根据服务端 `/api/client/capabilities` 的 `password/oidc` 能力决定展示入口;`password:false` 不呈现密码表单,`oidc:false` 不呈现飞书入口。`oidc:true` 但客户端浏览器回调未实现时明确禁用,不重定向到 OpenVPN 回调;两者均不可用时提示管理员。未来开启浏览器登录须实现授权码+PKCE、回调一次性交换与稳定 `issuer/sub` 绑定 VPNUser,禁止按邮箱自动授予线路。
- FR-03:线路选择展示服务端真实授权线路;极速/全局用线路下发的路由策略判断。未有可切换策略时模式只是当前策略标记,不保存虚构偏好或擅自修改 AllowedIPs。
- FR-04:延迟数据必须来自对应线路的实际 Go WireGuard 探针。探测失败/未登记设备时显示不可测,不得用控制面健康请求替代节点 RTT;阈值只用于实测后的色彩映射。
- NFR-01:无效授权与离线不应误报已登录、已连接或高质量节点;浏览器预览不调用原生接口。
- FR-05:WireGuard 创建和编辑页展示认证方式:现有“客户端账号 + 设备密钥”可选;第三方 OAuth2/OIDC 显示接入说明且不可选。服务端对显式提交未知模式或 oidc 拒绝保存;旧配置无字段保持原行为。静态 Peer 的已有密钥能力不受此选项保护,页面应如实提示。
- FR-06:专线列表和详情显示认证方式;列表启动/停止按钮固定可见,并依据实际运行状态切换;其他协议不臆测认证类型。
## 3. 架构与方案
前端负责向导和显示;Rust 验证服务端地址、取得 `password/oidc` 能力并调用当前已有的客户端 API;Go 组件执行真实 TCP 出口探测。按用户选择的运行中线路拨号,后端继续按 `VPNUser` 授权隔离。节点列表使用现有 `/api/client/lines`,不造地理位置/服务器名称。OpenVPN OIDC 配置不作为客户端 OIDC 能力来源。
## 4. 接口、数据与配置契约
- 匿名 `/api/client/capabilities` 既有 `password/oidc/wireguard` 布尔值;可选 `oidc_provider` 用于将飞书/Lark 与其他 OIDC 提供商区分,旧服务端缺字段按普通“第三方认证”显示。`oidc:false` 时隐藏第三方认证入口,`password:false` 时隐藏密码表单;老服务端返回 HTML 则不进入登录步骤。
- `client_probe` 在原结果基础上增加本次成功连接耗时 `latency_ms`(含隧道启动与握手);未成功时不提供延迟。前端只为对应线路显示该值,重新选择单位或重新登录后清除。可选 `latency_probe` 为 VPN 路由内的 IP:端口;未配置时用 `223.5.5.5:443`。线路接口增加由 `AllowedIPs` 推断的 `routing_mode`(`split` / `global_ipv4`);旧服务端缺字段时客户端不推断全局模式。
- 不修改 SQLite、设备配置或 WireGuard Peer 授权范围。不新增第三方 logo、虚构地区和用户协议链接。
- WireGuard 配置新增可选 `auth_mode=password`;旧记录缺失时按既有客户端账号+设备密钥行为显示。只有 WireGuard 新增服务端枚举校验,`oidc` 不能写入配置;列表从 JSON 配置读取既有 OpenVPN `auth_mode`,未知配置显示未指定。启动/停止使用原有 API。
## 5. 风险与验收
| 风险 | 防护 / 恢复 | 可观察信号 |
|---|---|---|
| 将 OpenVPN OAuth 当 WireGuard 登录 | 飞书入口根据客户端能力禁用;文案区分两者 | `oidc:false` 下无法点击浏览器授权 |
| 非法 WireGuard OIDC 配置被误认为安全登录 | 下拉禁选且后端验证拒绝;保留原设备密钥与静态 Peer 流程 | 创建/编辑传 `oidc` 返回校验错误 |
| 将 HTTP 或隧道探针误称为节点 RTT | 清楚标注“隧道 TCP 建链耗时”,未测为中性 | 无数据时没有绿色/黄色毫秒值 |
| 选择全局模式导致错误路由 | 根据专线真实 AllowedIPs 显示当前策略,未具备切换条件则不可切换 | 不更改系统路由与专线配置 |
| AC | 对应需求 | 操作与预期 | 验证方式 | 结果 |
|---|---|---|---|---|
| AC-01 | FR-01 | 新用户配置地址、检测成功、进入认证;切换单位清空表单 | 客户端 UI 构建、桌面窗口启动;真实登录需账号验收 | 构建/启动通过,真实登录待验收 |
| AC-02 | FR-02 | `oidc:false` 隐藏第三方入口;`password:false` 隐藏密码入口;`oidc_provider=feishu` 显示飞书标签但保持禁用 | 客户端 UI 构建、能力契约单测;实际账号登录待验收 | 构建与契约单测通过,实际登录待验收 |
| AC-03 | FR-03 | 运行中线路可选;全局模式不在私网路由上假激活 | 路由推断 Go 单测、客户端 UI 构建 | 通过 |
| AC-04 | FR-04 | 真实探测显示毫秒数;失败显示不可测 | Go/Rust 测试、真机试验 | 测试通过,真机延迟/线路切换待验收 |
| AC-05 | FR-05 | WireGuard 创建/编辑显示两种方式,其中 OIDC 不可选;手工请求 `auth_mode=oidc` 被拒;无字段旧配置可验证 | 后台构建、Go 单测 | 通过;界面手工验收待进行 |
| AC-06 | FR-06 | 不同协议列表认证标签正确;运行和停止状态有清晰按钮 | 后台构建、代码检查 | 构建通过;界面手工验收待进行 |
## 6. 发布与恢复
先验证 Go 服务端再发布前端及桌面客户端;本轮不修改生产网关。若需回退,恢复原服务端/前端构建与桌面客户端构建;新 `auth_mode=password` 对旧服务端为普通附加字段,旧配置仍可读取。无需迁移数据库或撤销现有授权。
## 7. 待决问题
正式飞书 OIDC 需要 HTTPS issuer、应用凭据与回调域名,以及管理员确认的飞书 `issuer/sub` 对 VPNUser 授权映射;没有这些配置不得显示可用的一键登录。全局 VPN 需后续系统隧道权限、NAT 与 DNS 恢复验收。
+36
View File
@@ -0,0 +1,36 @@
# 通过管理 API 创建 WireGuard 专线
当前后端已经提供 `POST /api/tunnels`,管理后台的“创建专线”向导也调用这个接口;只有 `admin` / `operator` 管理会话有权创建。该接口用于**管理员配置网关**,桌面客户端不能持有管理令牌或直接调用它。创建专线不等于完成终端用户认证、设备授权或桌面拨号。
## 已创建的停止状态测试专线
2026-10-21 经用户明确授权,通过 SSH 登录服务器本机管理 API,按下列请求创建测试专线 `opsx-client-pilot`(ID `4`)。创建后再次读取 API 确认 `state: stopped`、`auto_start: false`、监听端口 `51820`、公网端点 `121.4.111.208:51820`,并生成托管测试 Peer `client-1`(`10.88.0.2/32`)。只读系统检查显示 UDP `51820` 未监听、`10.88.0.0/24` 未进入路由表;**没有启动专线,也没有完成云安全组或真实客户端握手测试**。启用前需确认云安全组、业务网段路由和服务器网络策略。`allowed_ips` 只包含测试隧道网段,不会默认接管所有客户端流量。
```json
{
"name": "opsx-client-pilot",
"protocol": "wireguard",
"remark": "桌面客户端标准 WireGuard 测试专线",
"auto_start": false,
"config": {
"address": "10.88.0.1/24",
"client_address": "10.88.0.2/32",
"listen_port": 51820,
"public_endpoint": "121.4.111.208:51820",
"allowed_ips": "10.88.0.0/24",
"dns": "223.5.5.5",
"enable_nat": false,
"keepalive": 25
}
}
```
`POST /api/tunnels` 成功返回专线记录(含 `id`、`state`);设为 `auto_start: false` 时返回 `state: stopped`,不会启动网卡。响应含配置但不应含服务器私钥。失败会返回 `error`,例如地址不合法或端口被占用。服务端创建时会生成首个托管 Peer;这是管理端旧流程,下载的客户端配置包含服务端生成的私钥,**不得分发给未来的桌面设备自注册接口**。创建后公开配置中的旧 `client_address` 字段迁移到 `config.peers[0].client_address`,其值为 `10.88.0.2/32`。
## 认证、验证与启动边界
管理 API 登录使用 `POST /api/login`,请求体为 `username`、`password`、按配置需要的 `mfa_code`;返回的 Bearer token 在 `Authorization` 请求头中发送。只通过可信 HTTPS 或已明确授权的加密管理通道提交凭据,不把密码、token 写入仓库、客户端前端代码或命令示例。`http://121.4.111.208:18080` 是明文 HTTP,**不要向公网该地址发送管理员密码或 token**。
创建前,以管理账号调用 `GET /api/tunnels?protocol=wireguard` 核对既有配置与实际 UDP 端口,并检查网段、系统路由及公网 UDP 放行。创建后用 `GET /api/tunnels/:id` 确认 `state`、`public_endpoint` 和 Peer;需要启用时由管理员单独调用 `POST /api/tunnels/:id/start`,再检查 `GET /api/tunnels/:id/status`、系统 WireGuard 握手和业务路由。`POST /api/tunnels/:id/wireguard/peers` 也是 operator 以上权限,当前实现更新运行中 Peer 会停止再启动隧道,不适合作为桌面设备自动注册接口。
桌面客户端后续应使用单独的短期用户/设备令牌和客户端生成的公钥,通过专用 API 领取地址、网关公钥、端点与路由;服务器只追加对应 Peer 的公钥,不回传服务器或其他 Peer 的私钥。具体身份映射、撤销和在线更新设计见 [SDD-002](specs/SDD-002-desktop-wireguard-client.md)。
+16
View File
@@ -129,6 +129,10 @@
"noTunnels": "No tunnels yet",
"expireTime": "Expiry Time",
"localNode": "Local",
"authMethod": "Authentication",
"authUnknown": "Unspecified",
"wgAuthPassword": "Client account + device key",
"wgAuthHint": "The client account obtains device authorization; WireGuard authenticates using keys. Manually added static peers still connect by key.",
"start": "Start",
"stop": "Stop",
"started": "Started",
@@ -768,6 +772,14 @@
"label": "Server VPN Subnet Address",
"tip": "Server's address within the VPN subnet (CIDR)"
},
"auth_mode": {
"label": "Client Authentication",
"tip": "Account login grants a device; the tunnel uses key authentication. Static peers still connect by key. Browser OIDC requires a client callback and explicit user grants.",
"options": {
"password": "Client account + device key",
"oidc": "Third-party OAuth2 / OIDC (not available yet)"
}
},
"listen_port": {
"label": "Listen Port"
},
@@ -786,6 +798,10 @@
"label": "Client AllowedIPs",
"tip": "Use 0.0.0.0/0 for full tunnel; specify internal CIDRs for split tunnel"
},
"latency_probe": {
"label": "Tunnel probe target",
"tip": "Reachable IP:port through this line, e.g. 10.88.0.1:22; defaults to 223.5.5.5:443. Measures TCP setup time."
},
"keepalive": {
"label": "Keepalive (sec)",
"tip": "Recommended 15–25 for clients behind NAT"
+16
View File
@@ -129,6 +129,10 @@
"noTunnels": "还没有专线",
"expireTime": "到期时间",
"localNode": "本机节点",
"authMethod": "认证方式",
"authUnknown": "未指定",
"wgAuthPassword": "客户端账号 + 设备密钥",
"wgAuthHint": "客户端账号用于领取设备授权;WireGuard 用密钥握手。手动添加的静态 Peer 仍凭密钥接入。",
"start": "启动",
"stop": "停止",
"started": "已启动",
@@ -768,6 +772,14 @@
"label": "服务端 VPN 网段地址",
"tip": "服务端在 VPN 网段中的地址(CIDR)"
},
"auth_mode": {
"label": "客户端认证方式",
"tip": "账号验证后领取设备授权,隧道使用密钥握手。静态 Peer 仍凭密钥接入;飞书/OIDC 需独立实现客户端回调和用户授权映射。",
"options": {
"password": "客户端账号 + 设备密钥",
"oidc": "第三方 OAuth2 / OIDC(尚未接入)"
}
},
"listen_port": {
"label": "监听端口"
},
@@ -786,6 +798,10 @@
"label": "客户端 AllowedIPs",
"tip": "全流量代理用 0.0.0.0/0;仅内网填内网网段"
},
"latency_probe": {
"label": "隧道测速目标",
"tip": "可经该专线访问的 IP:端口,如 10.88.0.1:22;未设置时测试公网 223.5.5.5:443。记录 TCP 建链耗时。"
},
"keepalive": {
"label": "Keepalive(秒)",
"tip": "NAT 后客户端建议 15–25"
+5
View File
@@ -9,6 +9,10 @@ export const PROTOCOL_META = {
icon: 'wireguard',
scenes: 'protocols.wireguard.scenes',
fields: [
{ key: 'auth_mode', label: 'protocols.wireguard.fields.auth_mode.label', type: 'select', options: [
{ value: 'password', label: 'protocols.wireguard.fields.auth_mode.options.password' },
{ value: 'oidc', label: 'protocols.wireguard.fields.auth_mode.options.oidc', disabled: true }
], default: 'password', tip: 'protocols.wireguard.fields.auth_mode.tip', group: 'basic' },
{ key: 'address', label: 'protocols.wireguard.fields.address.label', placeholder: '10.8.0.1/24', required: true, tip: 'protocols.wireguard.fields.address.tip', group: 'basic' },
{ key: 'listen_port', label: 'protocols.wireguard.fields.listen_port.label', type: 'number', default: 51820, group: 'basic' },
{ key: 'client_address', label: 'protocols.wireguard.fields.client_address.label', placeholder: '10.8.0.2/32', tip: 'protocols.wireguard.fields.client_address.tip', group: 'basic' },
@@ -17,6 +21,7 @@ export const PROTOCOL_META = {
{ key: 'allowed_ips', label: 'protocols.wireguard.fields.allowed_ips.label', default: '0.0.0.0/0, ::/0', tip: 'protocols.wireguard.fields.allowed_ips.tip', group: 'routing' },
{ key: 'keepalive', label: 'protocols.wireguard.fields.keepalive.label', type: 'number', default: 25, tip: 'protocols.wireguard.fields.keepalive.tip', group: 'advanced' },
{ key: 'mtu', label: 'protocols.wireguard.fields.mtu.label', type: 'number', default: 0, tip: 'protocols.wireguard.fields.mtu.tip', group: 'advanced' },
{ key: 'latency_probe', label: 'protocols.wireguard.fields.latency_probe.label', placeholder: '10.88.0.1:22', tip: 'protocols.wireguard.fields.latency_probe.tip', group: 'advanced' },
{ key: 'enable_nat', label: 'protocols.wireguard.fields.enable_nat.label', type: 'switch', default: false, tip: 'protocols.wireguard.fields.enable_nat.tip', group: 'routing' },
{ key: 'egress_interface', label: 'protocols.wireguard.fields.egress_interface.label', placeholder: 'eth0', tip: 'protocols.wireguard.fields.egress_interface.tip', showIf: { enable_nat: true }, group: 'routing' }
]
+2 -2
View File
@@ -22,7 +22,7 @@
<el-input-number v-if="field.type === 'number'" v-model="form.config[field.key]" :min="0" :max="65535" />
<el-switch v-else-if="field.type === 'switch'" v-model="form.config[field.key]" />
<el-select v-else-if="field.type === 'select'" v-model="form.config[field.key]" style="width:100%">
<el-option v-for="option in field.options" :key="optionValue(option)" :label="$t(optionLabel(option))" :value="optionValue(option)" />
<el-option v-for="option in field.options" :key="optionValue(option)" :label="$t(optionLabel(option))" :value="optionValue(option)" :disabled="option.disabled" />
</el-select>
<el-input v-else-if="field.type === 'password'" v-model="form.config[field.key]" type="password"
show-password :placeholder="field.placeholder || $t('tunnelEditor.keepOriginal')" />
@@ -49,7 +49,7 @@
<el-input-number v-if="field.type === 'number'" v-model="form.config[field.key]" :min="0" :max="65535" />
<el-switch v-else-if="field.type === 'switch'" v-model="form.config[field.key]" />
<el-select v-else-if="field.type === 'select'" v-model="form.config[field.key]" style="width:100%">
<el-option v-for="option in field.options" :key="optionValue(option)" :label="$t(optionLabel(option))" :value="optionValue(option)" />
<el-option v-for="option in field.options" :key="optionValue(option)" :label="$t(optionLabel(option))" :value="optionValue(option)" :disabled="option.disabled" />
</el-select>
<el-input v-else-if="field.type === 'password'" v-model="form.config[field.key]" type="password"
show-password :placeholder="field.placeholder || $t('tunnelEditor.keepOriginal')" />
+2 -2
View File
@@ -56,7 +56,7 @@
<el-input-number v-if="f.type === 'number'" v-model="form.config[f.key]" :min="0" :max="65535" />
<el-switch v-else-if="f.type === 'switch'" v-model="form.config[f.key]" />
<el-select v-else-if="f.type === 'select'" v-model="form.config[f.key]" style="width:100%">
<el-option v-for="o in f.options" :key="optionValue(o)" :label="$t(optionLabel(o))" :value="optionValue(o)" />
<el-option v-for="o in f.options" :key="optionValue(o)" :label="$t(optionLabel(o))" :value="optionValue(o)" :disabled="o.disabled" />
</el-select>
<el-input v-else-if="f.type === 'password'" v-model="form.config[f.key]" type="password"
show-password :placeholder="f.placeholder" />
@@ -86,7 +86,7 @@
<el-input-number v-if="f.type === 'number'" v-model="form.config[f.key]" :min="0" :max="65535" />
<el-switch v-else-if="f.type === 'switch'" v-model="form.config[f.key]" />
<el-select v-else-if="f.type === 'select'" v-model="form.config[f.key]" style="width:100%">
<el-option v-for="o in f.options" :key="optionValue(o)" :label="$t(optionLabel(o))" :value="optionValue(o)" />
<el-option v-for="o in f.options" :key="optionValue(o)" :label="$t(optionLabel(o))" :value="optionValue(o)" :disabled="o.disabled" />
</el-select>
<el-input v-else-if="f.type === 'password'" v-model="form.config[f.key]" type="password"
show-password :placeholder="f.placeholder" />
+24 -4
View File
@@ -46,6 +46,14 @@
</span>
</template>
</el-table-column>
<el-table-column :label="$t('tunnels.authMethod')" min-width="170">
<template #default="{ row }">
<el-tooltip v-if="row.protocol === 'wireguard'" :content="$t('tunnels.wgAuthHint')" placement="top">
<span class="auth-cell">{{ authMethod(row) }}</span>
</el-tooltip>
<span v-else class="auth-cell">{{ authMethod(row) }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('common.status')" width="110">
<template #default="{ row }">
<span :style="{ color: stateColor(row) }">{{ stateText(row) }}</span>
@@ -67,10 +75,10 @@
<el-table-column :label="$t('tunnels.node')" width="110">
<template #default="{ row }">{{ row.node?.name || $t('tunnels.localNode') }}</template>
</el-table-column>
<el-table-column :label="$t('common.operation')" width="240" fixed="right">
<el-table-column :label="$t('common.operation')" width="285" fixed="right">
<template #default="{ row }">
<el-button v-if="stateOf(row) !== 'running'" type="success" size="small" link @click="start(row)">{{ $t('tunnels.start') }}</el-button>
<el-button v-else type="warning" size="small" link @click="stop(row)">{{ $t('tunnels.stop') }}</el-button>
<el-button v-if="stateOf(row) !== 'running'" type="success" size="small" plain @click="start(row)">{{ $t('tunnels.start') }}</el-button>
<el-button v-else type="warning" size="small" plain @click="stop(row)">{{ $t('tunnels.stop') }}</el-button>
<el-button size="small" link @click="showDetail(row)">{{ $t('tunnels.detail') }}</el-button>
<el-button size="small" link @click="$router.push(`/tunnels/${row.id}/edit`)">{{ $t('common.edit') }}</el-button>
<el-dropdown trigger="click" @command="cmd => onMoreCommand(cmd, row)" style="margin-left:8px">
@@ -84,7 +92,7 @@
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button type="danger" size="small" link style="margin-left:12px" @click="remove(row)">{{ $t('common.delete') }}</el-button>
<el-button type="danger" size="small" link @click="remove(row)">{{ $t('common.delete') }}</el-button>
</template>
</el-table-column>
<template #empty>
@@ -99,6 +107,7 @@
<template v-if="detail">
<el-descriptions :column="1" border size="small">
<el-descriptions-item :label="$t('common.protocol')">{{ $t(protocolDisplay(detail.tunnel.protocol)) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.authMethod')">{{ authMethod(detail.tunnel) }}</el-descriptions-item>
<el-descriptions-item :label="$t('common.status')">{{ stateText(detail.tunnel) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.createTime')">{{ fmtTime(detail.tunnel.created_at) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.autoStart')">{{ detail.tunnel.auto_start ? $t('common.yes') : $t('common.no') }}</el-descriptions-item>
@@ -301,6 +310,17 @@ function stateColor(row) {
return { running: '#67c23a', stopped: '#909399', error: '#f56c6c', undeployed: '#e6a23c' }[stateOf(row)]
}
function stateOf(row) { return row.runtime?.state || row.state }
function authMethod(row) {
let config = {}
try { config = typeof row.config === 'string' ? JSON.parse(row.config) : (row.config || {}) } catch { /* old malformed config */ }
if (row.protocol === 'wireguard') return !config.auth_mode || config.auth_mode === 'password' ? t('tunnels.wgAuthPassword') : t('tunnels.authUnknown')
if (row.protocol === 'openvpn') {
const mode = config.auth_mode || 'password'
const key = `protocols.openvpn.fields.auth_mode.options.${mode}`
return ['certificate', 'password', 'certificate+password', 'oidc'].includes(mode) ? t(key) : t('tunnels.authUnknown')
}
return t('tunnels.authUnknown')
}
function prettyConfig(c) {
try { return JSON.stringify(JSON.parse(c), null, 2) } catch { return c }
}