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

+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 {