Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fd29fa5b2 | ||
|
|
dc3e9beb14 | ||
|
|
9d7d4c8287 | ||
|
|
469106d502 | ||
|
|
e61d4fedd8 | ||
|
|
1886d9b07b | ||
|
|
b81b702433 | ||
|
|
7febed50ac | ||
|
|
4caaacb68f | ||
|
|
9b1cb668b1 | ||
|
|
b52db70015 | ||
|
|
1c4906c266 | ||
|
|
ebe082500f | ||
|
|
7c49ae4d72 | ||
|
|
7b4289ae00 | ||
|
|
15af9ef72c | ||
|
|
96b1a8839c | ||
|
|
c56fc0c21c | ||
|
|
bb4e4552d8 | ||
|
|
3e7df0f4d8 | ||
|
|
bf4744bb1d |
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 wuwenfengmi1998
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -11,12 +11,18 @@ GO = go
|
||||
CGO_ENABLED = 1
|
||||
WINDRES ?= x86_64-w64-mingw32-windres
|
||||
MINGW_CC ?= x86_64-w64-mingw32-gcc
|
||||
SEMVER ?= 0.3.9
|
||||
SEMVER ?= 0.6.9
|
||||
GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
VERSION = $(SEMVER)-$(GIT_HASH)
|
||||
LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION)
|
||||
|
||||
.PHONY: all build app run daemon clean vet tidy fmt icon icon-windows build-windows installer-windows
|
||||
# Code signing (optional). Set these to sign the .app bundle with a
|
||||
# Developer ID certificate, enabling biometric (Touch ID) keychain access.
|
||||
# Example: make app CODESIGN_IDENTITY="Developer ID Application: Name (ABCDE12345)" TEAM_ID=ABCDE12345
|
||||
CODESIGN_IDENTITY ?=
|
||||
TEAM_ID ?=
|
||||
|
||||
.PHONY: all build app run daemon clean vet tidy fmt icon icon-windows build-windows installer-windows sign
|
||||
|
||||
## all: build the .app bundle (default)
|
||||
all: app
|
||||
@@ -41,8 +47,39 @@ app: build
|
||||
@if [ -f resources/icon.icns ]; then \
|
||||
cp resources/icon.icns $(APP_BUNDLE)/Contents/Resources/icon.icns; \
|
||||
else echo " (no icon.icns found, skipping icon)"; fi
|
||||
@if [ -n "$(CODESIGN_IDENTITY)" ]; then $(MAKE) sign; \
|
||||
else echo " (skipping code signing - set CODESIGN_IDENTITY and TEAM_ID to enable Touch ID keychain)"; fi
|
||||
@echo "Built $(APP_BUNDLE)"
|
||||
|
||||
## sign: code-sign the .app bundle with a Developer ID certificate
|
||||
## Requires CODESIGN_IDENTITY and TEAM_ID to be set.
|
||||
sign:
|
||||
@if [ -z "$(CODESIGN_IDENTITY)" ]; then \
|
||||
echo "ERROR: CODESIGN_IDENTITY not set."; \
|
||||
echo "Usage: make sign CODESIGN_IDENTITY='Developer ID Application: Name (TEAMID)' TEAM_ID=TEAMID"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if [ -z "$(TEAM_ID)" ]; then \
|
||||
echo "ERROR: TEAM_ID not set."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@tmp=$$(mktemp LMVPN.entitlements.XXXXXX); \
|
||||
sed 's/\$$(AppIdentifierPrefix)/$(TEAM_ID)./' resources/LMVPN.entitlements > $$tmp; \
|
||||
codesign --force --options runtime \
|
||||
--sign "$(CODESIGN_IDENTITY)" \
|
||||
--entitlements $$tmp \
|
||||
$(APP_BUNDLE)/Contents/MacOS/$(GUI_BIN); \
|
||||
codesign --force --options runtime \
|
||||
--sign "$(CODESIGN_IDENTITY)" \
|
||||
--entitlements $$tmp \
|
||||
$(APP_BUNDLE)/Contents/MacOS/$(DAEMON_BIN); \
|
||||
codesign --force --options runtime \
|
||||
--sign "$(CODESIGN_IDENTITY)" \
|
||||
--entitlements $$tmp \
|
||||
$(APP_BUNDLE); \
|
||||
rm -f $$tmp
|
||||
@echo "Signed $(APP_BUNDLE) with identity: $(CODESIGN_IDENTITY)"
|
||||
|
||||
## icon: generate icon.icns from resources/icon.png (or resources/logo.svg)
|
||||
icon:
|
||||
@if [ -f resources/logo.svg ] && [ ! -f resources/icon.png -o resources/logo.svg -nt resources/icon.png ]; then \
|
||||
|
||||
@@ -30,7 +30,11 @@ LMVPN 是一个基于 WebSocket 隧道与 TUN 虚拟网卡的**三层(网络
|
||||
|
||||
- **双进程架构**:GUI(`lmvpn`,普通用户)+ 守护进程(`lmvpnd`,root/管理员),自动拉起与生命周期管理
|
||||
- **多种认证**:JWT 令牌 / 用户名密码
|
||||
- **隧道模式**:全量隧道、分流隧道(按目标绕过)、自定义隧道
|
||||
- **隧道模式**:全隧道、代理 CIDR(指定 CIDR 走隧道)、绕过 CIDR(指定 CIDR 绕过隧道),支持 IPv4/IPv6 分开配置与 URL 动态获取 CIDR 列表
|
||||
- **URL 获取时机**:代理前(直连获取,适用于 GitHub 等外部源)或代理后(通过隧道获取,适用于 VPN 服务器可达的源)
|
||||
- **注意**:绕过 CIDR 模式下"代理后获取"可能失败——/1 覆盖路由会将 HTTP 请求导入隧道,若 VPN 服务器无法访问目标 URL 则超时。建议将 GitHub 等外部源设置为"代理前获取"
|
||||
- **CIDR 聚合**:自动合并相邻 CIDR 块以减少路由数量,配合批量脚本并行执行加速路由添加
|
||||
- **实时统计**:状态栏显示路由模式、CIDR 命中数、加载进度;支持手动刷新 CIDR 列表
|
||||
- **多服务器管理**:配置文件 + SQLite 存储多个服务器配置(Profile)
|
||||
- **国际化**:中文(简体)、英文,跟随系统语言
|
||||
- **安全存储**:macOS Keychain / Windows Credential Manager 加密保存凭据
|
||||
@@ -274,7 +278,7 @@ lmvpn_client/
|
||||
│ ├── model/ # 数据模型
|
||||
│ ├── paths/ # 平台路径解析(darwin/windows/other)
|
||||
│ ├── protocol/ # 与服务端的 WebSocket 协议
|
||||
│ ├── route/ # 路由管理(全量/分流/自定义)
|
||||
│ ├── route/ # 路由管理(全隧道/代理CIDR/绕过CIDR)
|
||||
│ ├── stats/ # 流量统计
|
||||
│ ├── transport/ # WebSocket 传输层
|
||||
│ ├── tun/ # TUN 虚拟网卡(darwin/linux/windows)
|
||||
|
||||
+31
-3
@@ -6,12 +6,16 @@ package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lmvpn/internal/transport"
|
||||
)
|
||||
|
||||
// LoginResult holds the response from a successful /api/login call.
|
||||
@@ -41,15 +45,39 @@ type errorResponse struct {
|
||||
// baseURL should be the HTTP(S) origin derived from the WebSocket URL
|
||||
// (e.g. "http://localhost:8080" for ws://, "https://vpn.example.com"
|
||||
// for wss://). See WSURLToHTTP.
|
||||
func Login(baseURL, username, password string) (*LoginResult, error) {
|
||||
//
|
||||
// tlsCfg, if non-nil, is used as the TLS configuration for the HTTP
|
||||
// client. This is essential when connecting via CDN edge IPs: the URL
|
||||
// host will be an IP address, but the certificate must be verified
|
||||
// against the real hostname (set tlsCfg.ServerName).
|
||||
//
|
||||
// ipPreference controls which IP address families are used when
|
||||
// resolving the server hostname ("auto", "v4", "v6").
|
||||
//
|
||||
// ctx allows cancellation of the HTTP request (e.g. when the VPN
|
||||
// session is disconnected while login is in flight).
|
||||
func Login(ctx context.Context, baseURL, username, password string, tlsCfg *tls.Config, ipPreference string) (*LoginResult, error) {
|
||||
body, err := json.Marshal(loginRequest{Username: username, Password: password})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := strings.TrimRight(baseURL, "/") + "/api/login"
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Post(url, "application/json", bytes.NewReader(body))
|
||||
httpTransport := &http.Transport{
|
||||
DialContext: transport.NewRaceDialer(ipPreference),
|
||||
}
|
||||
if tlsCfg != nil {
|
||||
httpTransport.TLSClientConfig = tlsCfg
|
||||
}
|
||||
client := &http.Client{Timeout: 15 * time.Second, Transport: httpTransport}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create login request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("login request: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// Package cidrsource fetches CIDR lists from URLs. CIDR lists can be
|
||||
// fetched before routing is applied (via a direct connection) or after
|
||||
// the tunnel is established (via the tunnel itself).
|
||||
//
|
||||
// The expected format is one CIDR per line. Empty lines and lines
|
||||
// starting with '#' are treated as comments and skipped. Invalid CIDR
|
||||
// entries are silently ignored (with a debug log entry).
|
||||
package cidrsource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lmvpn/internal/log"
|
||||
"lmvpn/internal/model"
|
||||
)
|
||||
|
||||
// fetchTimeout is the total timeout for all before-proxy fetches
|
||||
// combined. It must be well under the server's 30s ReadyTimeout since
|
||||
// before-proxy fetching happens before the WS handshake begins.
|
||||
const fetchTimeout = 5 * time.Second
|
||||
|
||||
// FetchBeforeProxy fetches all CIDR URL sources with FetchTiming ==
|
||||
// "before". These are fetched via the system's direct connection
|
||||
// (before routing is applied), so no special proxy handling is needed.
|
||||
// The context allows cancellation (e.g. session teardown).
|
||||
//
|
||||
// All matching sources are fetched concurrently with a shared 5s
|
||||
// deadline so a single slow URL does not block the entire session.
|
||||
func FetchBeforeProxy(ctx context.Context, sources []model.CIDRURLSource) ([]string, error) {
|
||||
return fetchSources(ctx, sources, model.FetchBefore)
|
||||
}
|
||||
|
||||
// FetchAfterProxy fetches all CIDR URL sources with FetchTiming ==
|
||||
// "after". These are fetched via the tunnel after the data plane is up.
|
||||
// The HTTP client uses the default dialer, which respects the system
|
||||
// routing table - so when full-tunnel or bypass routes are in effect,
|
||||
// the request goes through the TUN interface.
|
||||
//
|
||||
// Sources are fetched concurrently with a 15s deadline (the tunnel is
|
||||
// already up, so there is no ReadyTimeout pressure).
|
||||
func FetchAfterProxy(ctx context.Context, sources []model.CIDRURLSource) ([]string, error) {
|
||||
return fetchSources(ctx, sources, model.FetchAfter)
|
||||
}
|
||||
|
||||
// fetchSources fetches all sources matching the given timing
|
||||
// concurrently, bounded by a shared deadline derived from fetchTimeout
|
||||
// (for "before") or 15s (for "after").
|
||||
func fetchSources(ctx context.Context, sources []model.CIDRURLSource, timing model.FetchTiming) ([]string, error) {
|
||||
// Collect matching sources.
|
||||
var matching []model.CIDRURLSource
|
||||
for _, src := range sources {
|
||||
if src.FetchTiming == timing {
|
||||
matching = append(matching, src)
|
||||
}
|
||||
}
|
||||
if len(matching) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Shared deadline for all fetches.
|
||||
deadline := fetchTimeout
|
||||
if timing == model.FetchAfter {
|
||||
deadline = 15 * time.Second
|
||||
}
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, deadline)
|
||||
defer cancel()
|
||||
|
||||
type result struct {
|
||||
cidrs []string
|
||||
err error
|
||||
url string
|
||||
}
|
||||
results := make(chan result, len(matching))
|
||||
|
||||
for _, src := range matching {
|
||||
go func(s model.CIDRURLSource) {
|
||||
cidrs, err := fetchOne(fetchCtx, s.URL)
|
||||
results <- result{cidrs: cidrs, err: err, url: s.URL}
|
||||
}(src)
|
||||
}
|
||||
|
||||
var allCIDRs []string
|
||||
var errs []string
|
||||
for range matching {
|
||||
r := <-results
|
||||
if r.err != nil {
|
||||
label := "before-proxy"
|
||||
if timing == model.FetchAfter {
|
||||
label = "after-proxy"
|
||||
}
|
||||
log.L().Error("fetch "+label+" CIDR list failed (continuing)",
|
||||
"url", r.url, "error", r.err)
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", r.url, r.err))
|
||||
continue
|
||||
}
|
||||
label := "before-proxy"
|
||||
if timing == model.FetchAfter {
|
||||
label = "after-proxy"
|
||||
}
|
||||
log.L().Info("fetched "+label+" CIDR list",
|
||||
"url", r.url, "count", len(r.cidrs))
|
||||
allCIDRs = append(allCIDRs, r.cidrs...)
|
||||
}
|
||||
|
||||
if len(allCIDRs) == 0 && len(errs) > 0 {
|
||||
return nil, fmt.Errorf("all CIDR URL fetches failed: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return allCIDRs, nil
|
||||
}
|
||||
|
||||
// fetchOne fetches a single URL and parses the response as a CIDR list.
|
||||
func fetchOne(ctx context.Context, url string) ([]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("http %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB max
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
return ParseCIDRList(string(body)), nil
|
||||
}
|
||||
|
||||
// ParseCIDRList parses text into a list of valid CIDR strings. Each
|
||||
// line is treated as a separate CIDR entry. Empty lines and lines
|
||||
// starting with '#' are skipped. Invalid CIDR entries are silently
|
||||
// ignored.
|
||||
func ParseCIDRList(text string) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
c := strings.TrimSpace(line)
|
||||
if c == "" || strings.HasPrefix(c, "#") {
|
||||
continue
|
||||
}
|
||||
_, _, err := net.ParseCIDR(c)
|
||||
if err != nil {
|
||||
log.L().Debug("ignoring invalid CIDR entry", "cidr", c)
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ClassifyCIDRs splits a list of CIDR strings into IPv4 and IPv6 lists.
|
||||
func ClassifyCIDRs(cidrs []string) (v4, v6 []string) {
|
||||
for _, cidr := range cidrs {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ipNet.IP.To4() != nil {
|
||||
v4 = append(v4, cidr)
|
||||
} else {
|
||||
v6 = append(v6, cidr)
|
||||
}
|
||||
}
|
||||
return v4, v6
|
||||
}
|
||||
+68
-14
@@ -19,6 +19,7 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"lmvpn/internal/ipc"
|
||||
@@ -92,6 +93,7 @@ func chownToUser(path string, uid, gid int) {
|
||||
|
||||
type daemon struct {
|
||||
server *ipc.Server
|
||||
mu sync.Mutex
|
||||
session *vpn.SessionManager
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
@@ -109,11 +111,24 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) {
|
||||
d.server.Close()
|
||||
os.Exit(0)
|
||||
case ipc.CmdStats:
|
||||
if d.session != nil {
|
||||
snap := d.session.Stats().Snapshot()
|
||||
d.mu.Lock()
|
||||
sess := d.session
|
||||
d.mu.Unlock()
|
||||
if sess != nil {
|
||||
snap := sess.Stats().Snapshot()
|
||||
d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &snap})
|
||||
}
|
||||
_ = ipc.WriteOK(conn)
|
||||
case ipc.CmdRefreshCIDR:
|
||||
d.mu.Lock()
|
||||
sess := d.session
|
||||
d.mu.Unlock()
|
||||
if sess != nil {
|
||||
go sess.RefreshCIDRs()
|
||||
_ = ipc.WriteOK(conn)
|
||||
} else {
|
||||
_ = ipc.WriteErr(conn, "no active session")
|
||||
}
|
||||
case ipc.CmdVersion:
|
||||
_ = ipc.WriteVersion(conn, version.Version)
|
||||
default:
|
||||
@@ -122,32 +137,42 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) {
|
||||
}
|
||||
|
||||
func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
|
||||
d.mu.Lock()
|
||||
if req.Config == nil {
|
||||
d.mu.Unlock()
|
||||
_ = ipc.WriteErr(conn, "missing config")
|
||||
return
|
||||
}
|
||||
if d.session != nil {
|
||||
d.stopSession()
|
||||
d.stopSessionLocked()
|
||||
}
|
||||
|
||||
cfg := vpn.SessionConfig{
|
||||
ServerURL: req.Config.ServerURL,
|
||||
SNIHost: req.Config.SNIHost,
|
||||
ServerIPs: req.Config.ServerIPs,
|
||||
Username: req.Config.Username,
|
||||
Password: req.Config.Password,
|
||||
Token: req.Config.Token,
|
||||
AuthMode: model.AuthMode(req.Config.AuthMode),
|
||||
RoutingMode: ipc.RoutingModeFromIPC(req.Config.RoutingMode),
|
||||
CustomCIDRs: req.Config.CustomCIDRs,
|
||||
MTUOverride: req.Config.MTUOverride,
|
||||
ServerURL: req.Config.ServerURL,
|
||||
SNIHost: req.Config.SNIHost,
|
||||
ServerIPs: req.Config.ServerIPs,
|
||||
Username: req.Config.Username,
|
||||
Password: req.Config.Password,
|
||||
Token: req.Config.Token,
|
||||
AuthMode: model.AuthMode(req.Config.AuthMode),
|
||||
RoutingMode: ipc.RoutingModeFromIPC(req.Config.RoutingMode),
|
||||
CIDRV4: req.Config.CIDRV4,
|
||||
CIDRV6: req.Config.CIDRV6,
|
||||
CIDRV4URLs: convertIPCSources(req.Config.CIDRV4URLs),
|
||||
CIDRV6URLs: convertIPCSources(req.Config.CIDRV6URLs),
|
||||
MTUOverride: req.Config.MTUOverride,
|
||||
TLSCACert: req.Config.TLSCACert,
|
||||
TLSCAPath: req.Config.TLSCAPath,
|
||||
TLSInsecure: req.Config.TLSInsecure,
|
||||
TLSPinnedHash: req.Config.TLSPinnedHash,
|
||||
IPPreference: req.Config.IPPreference,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
d.cancel = cancel
|
||||
d.session = vpn.New(
|
||||
func(s stats.State) {
|
||||
d.server.Broadcast(ipc.Event{Event: ipc.EvState, State: string(s)})
|
||||
d.server.Broadcast(ipc.Event{Event: ipc.EvState, State: string(s), ConnectStep: d.session.Stats().ConnectStep()})
|
||||
},
|
||||
func(snap stats.Snapshot) {
|
||||
s := snap
|
||||
@@ -157,15 +182,29 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
|
||||
d.server.Broadcast(ipc.Event{Event: ipc.EvError, Code: code, Message: msg})
|
||||
},
|
||||
)
|
||||
// Release the lock before Connect. Connect is non-blocking (it
|
||||
// starts a goroutine), but holding the lock across it would
|
||||
// serialize all IPC commands (CmdStats, CmdStop, etc.) behind the
|
||||
// session startup.
|
||||
d.mu.Unlock()
|
||||
|
||||
if err := d.session.Connect(ctx, cfg); err != nil {
|
||||
_ = ipc.WriteErr(conn, "connect: "+err.Error())
|
||||
d.mu.Lock()
|
||||
d.session = nil
|
||||
d.cancel = nil
|
||||
d.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (d *daemon) stopSession() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.stopSessionLocked()
|
||||
}
|
||||
|
||||
func (d *daemon) stopSessionLocked() {
|
||||
if d.cancel != nil {
|
||||
d.cancel()
|
||||
d.cancel = nil
|
||||
@@ -175,3 +214,18 @@ func (d *daemon) stopSession() {
|
||||
d.session = nil
|
||||
}
|
||||
}
|
||||
|
||||
// convertIPCSources converts IPC CIDRURLSource values to model.CIDRURLSource.
|
||||
func convertIPCSources(srcs []ipc.CIDRURLSource) []model.CIDRURLSource {
|
||||
if len(srcs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]model.CIDRURLSource, len(srcs))
|
||||
for i, s := range srcs {
|
||||
out[i] = model.CIDRURLSource{
|
||||
URL: s.URL,
|
||||
FetchTiming: model.FetchTiming(s.FetchTiming),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+155
-1
@@ -8,6 +8,7 @@ package db
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"lmvpn/internal/paths"
|
||||
@@ -48,7 +49,16 @@ func (s *Store) migrate() error {
|
||||
if err := s.migrateV2(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.migrateV3()
|
||||
if err := s.migrateV3(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.migrateV4(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.migrateV5(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.migrateV6()
|
||||
}
|
||||
|
||||
func (s *Store) migrateV2() error {
|
||||
@@ -181,6 +191,145 @@ func (s *Store) migrateV3() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV4 adds TLS certificate verification columns to
|
||||
// server_profiles for custom CA, insecure mode, and cert pinning.
|
||||
// Idempotent: skips columns that already exist.
|
||||
func (s *Store) migrateV4() error {
|
||||
cols := []struct {
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{"tls_ca_cert", "ALTER TABLE server_profiles ADD COLUMN tls_ca_cert TEXT NOT NULL DEFAULT ''"},
|
||||
{"tls_ca_path", "ALTER TABLE server_profiles ADD COLUMN tls_ca_path TEXT NOT NULL DEFAULT ''"},
|
||||
{"tls_insecure", "ALTER TABLE server_profiles ADD COLUMN tls_insecure INTEGER NOT NULL DEFAULT 0"},
|
||||
{"tls_pinned_hash", "ALTER TABLE server_profiles ADD COLUMN tls_pinned_hash TEXT NOT NULL DEFAULT ''"},
|
||||
}
|
||||
for _, c := range cols {
|
||||
if !columnExists(s.db, "server_profiles", c.name) {
|
||||
if _, err := s.db.Exec(c.sql); err != nil {
|
||||
return fmt.Errorf("migrate v4 add %s: %w", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV5 replaces the single custom_cidrs column with separate
|
||||
// cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls columns. It migrates
|
||||
// existing routing modes: 'custom' -> 'proxy', 'split' -> 'full'.
|
||||
// Existing custom_cidrs are split into v4/v6 based on address family.
|
||||
// Idempotent: skips columns that already exist.
|
||||
func (s *Store) migrateV5() error {
|
||||
cols := []struct {
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{"cidr_v4", "ALTER TABLE server_profiles ADD COLUMN cidr_v4 TEXT NOT NULL DEFAULT ''"},
|
||||
{"cidr_v6", "ALTER TABLE server_profiles ADD COLUMN cidr_v6 TEXT NOT NULL DEFAULT ''"},
|
||||
{"cidr_v4_urls", "ALTER TABLE server_profiles ADD COLUMN cidr_v4_urls TEXT NOT NULL DEFAULT ''"},
|
||||
{"cidr_v6_urls", "ALTER TABLE server_profiles ADD COLUMN cidr_v6_urls TEXT NOT NULL DEFAULT ''"},
|
||||
}
|
||||
needMigration := false
|
||||
for _, c := range cols {
|
||||
if !columnExists(s.db, "server_profiles", c.name) {
|
||||
needMigration = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !needMigration {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, c := range cols {
|
||||
if !columnExists(s.db, "server_profiles", c.name) {
|
||||
if _, err := s.db.Exec(c.sql); err != nil {
|
||||
return fmt.Errorf("migrate v5 add %s: %w", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate existing custom_cidrs into cidr_v4 / cidr_v6 and update
|
||||
// routing mode codes. Only process rows that still have a non-empty
|
||||
// custom_cidrs or an old routing mode.
|
||||
rows, err := s.db.Query(`SELECT id, routing_mode, custom_cidrs FROM server_profiles`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate v5 read rows: %w", err)
|
||||
}
|
||||
type row struct {
|
||||
id int64
|
||||
routingMode string
|
||||
customCIDRs string
|
||||
}
|
||||
var toUpdate []row
|
||||
for rows.Next() {
|
||||
var r row
|
||||
if err := rows.Scan(&r.id, &r.routingMode, &r.customCIDRs); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("migrate v5 scan: %w", err)
|
||||
}
|
||||
toUpdate = append(toUpdate, r)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, r := range toUpdate {
|
||||
newMode := r.routingMode
|
||||
switch newMode {
|
||||
case "custom":
|
||||
newMode = "proxy"
|
||||
case "split":
|
||||
newMode = "full"
|
||||
}
|
||||
|
||||
v4CIDRs, v6CIDRs := splitCIDRsByFamily(r.customCIDRs)
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE server_profiles SET routing_mode = ?, cidr_v4 = ?, cidr_v6 = ? WHERE id = ?`,
|
||||
newMode, v4CIDRs, v6CIDRs, r.id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate v5 update row %d: %w", r.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV6 adds the ip_preference column to server_profiles for
|
||||
// controlling IPv4/IPv6 address selection when connecting by hostname.
|
||||
// Idempotent: skips if the column already exists.
|
||||
func (s *Store) migrateV6() error {
|
||||
if columnExists(s.db, "server_profiles", "ip_preference") {
|
||||
return nil
|
||||
}
|
||||
_, err := s.db.Exec(`ALTER TABLE server_profiles ADD COLUMN ip_preference TEXT NOT NULL DEFAULT 'auto'`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate v6 add ip_preference: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitCIDRsByFamily splits a comma-separated CIDR string into IPv4 and
|
||||
// IPv6 parts. Used for migration from the old custom_cidrs column.
|
||||
func splitCIDRsByFamily(customCIDRs string) (v4, v6 string) {
|
||||
if customCIDRs == "" {
|
||||
return "", ""
|
||||
}
|
||||
var v4Parts, v6Parts []string
|
||||
for _, part := range strings.Split(customCIDRs, ",") {
|
||||
c := strings.TrimSpace(part)
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
if _, ipNet, err := net.ParseCIDR(c); err == nil {
|
||||
if ipNet.IP.To4() != nil {
|
||||
v4Parts = append(v4Parts, c)
|
||||
} else {
|
||||
v6Parts = append(v6Parts, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(v4Parts, ", "), strings.Join(v6Parts, ", ")
|
||||
}
|
||||
|
||||
// columnExists reports whether a column exists on a table.
|
||||
func columnExists(db *sql.DB, table, column string) bool {
|
||||
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
@@ -216,8 +365,13 @@ CREATE TABLE IF NOT EXISTS server_profiles (
|
||||
auth_mode TEXT NOT NULL DEFAULT 'both',
|
||||
routing_mode TEXT NOT NULL DEFAULT 'full',
|
||||
custom_cidrs TEXT NOT NULL DEFAULT '',
|
||||
cidr_v4 TEXT NOT NULL DEFAULT '',
|
||||
cidr_v6 TEXT NOT NULL DEFAULT '',
|
||||
cidr_v4_urls TEXT NOT NULL DEFAULT '',
|
||||
cidr_v6_urls TEXT NOT NULL DEFAULT '',
|
||||
mtu_override INTEGER NOT NULL DEFAULT 0,
|
||||
auto_connect INTEGER NOT NULL DEFAULT 0,
|
||||
ip_preference TEXT NOT NULL DEFAULT 'auto',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_connected_at DATETIME
|
||||
);
|
||||
|
||||
+36
-10
@@ -14,11 +14,17 @@ func (s *Store) CreateProfile(p *model.ServerProfile) (int64, error) {
|
||||
`INSERT INTO server_profiles
|
||||
(name, protocol, host, server_ips, port, path,
|
||||
username, auth_mode, routing_mode,
|
||||
custom_cidrs, mtu_override, auto_connect)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls,
|
||||
mtu_override, auto_connect,
|
||||
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
|
||||
ip_preference)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path,
|
||||
p.Username, p.AuthMode, p.RoutingMode,
|
||||
p.CustomCIDRs, p.MTUOverride, p.AutoConnect,
|
||||
p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs,
|
||||
p.MTUOverride, p.AutoConnect,
|
||||
p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash,
|
||||
p.IPPreference,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert profile: %w", err)
|
||||
@@ -36,11 +42,18 @@ func (s *Store) GetProfile(id int64) (*model.ServerProfile, error) {
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, name, protocol, host, server_ips, port, path,
|
||||
username, auth_mode, routing_mode,
|
||||
custom_cidrs, mtu_override, auto_connect, created_at, last_connected_at
|
||||
cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls,
|
||||
mtu_override, auto_connect,
|
||||
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
|
||||
ip_preference,
|
||||
created_at, last_connected_at
|
||||
FROM server_profiles WHERE id = ?`, id,
|
||||
).Scan(&p.ID, &p.Name, &p.Protocol, &p.Host, &p.ServerIPs, &p.Port, &p.Path,
|
||||
&p.Username, &p.AuthMode, &p.RoutingMode, &p.CustomCIDRs, &p.MTUOverride,
|
||||
&p.AutoConnect, &p.CreatedAt, &last)
|
||||
&p.Username, &p.AuthMode, &p.RoutingMode,
|
||||
&p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs,
|
||||
&p.MTUOverride, &p.AutoConnect,
|
||||
&p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash,
|
||||
&p.IPPreference, &p.CreatedAt, &last)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get profile %d: %w", id, err)
|
||||
}
|
||||
@@ -55,7 +68,11 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, name, protocol, host, server_ips, port, path,
|
||||
username, auth_mode, routing_mode,
|
||||
custom_cidrs, mtu_override, auto_connect, created_at, last_connected_at
|
||||
cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls,
|
||||
mtu_override, auto_connect,
|
||||
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
|
||||
ip_preference,
|
||||
created_at, last_connected_at
|
||||
FROM server_profiles ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list profiles: %w", err)
|
||||
@@ -68,7 +85,10 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) {
|
||||
var last sql.NullTime
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Protocol, &p.Host, &p.ServerIPs,
|
||||
&p.Port, &p.Path, &p.Username, &p.AuthMode, &p.RoutingMode,
|
||||
&p.CustomCIDRs, &p.MTUOverride, &p.AutoConnect, &p.CreatedAt, &last); err != nil {
|
||||
&p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs,
|
||||
&p.MTUOverride, &p.AutoConnect,
|
||||
&p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash,
|
||||
&p.IPPreference, &p.CreatedAt, &last); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if last.Valid {
|
||||
@@ -85,11 +105,17 @@ func (s *Store) UpdateProfile(p *model.ServerProfile) error {
|
||||
`UPDATE server_profiles SET
|
||||
name = ?, protocol = ?, host = ?, server_ips = ?, port = ?, path = ?,
|
||||
username = ?, auth_mode = ?, routing_mode = ?,
|
||||
custom_cidrs = ?, mtu_override = ?, auto_connect = ?
|
||||
cidr_v4 = ?, cidr_v6 = ?, cidr_v4_urls = ?, cidr_v6_urls = ?,
|
||||
mtu_override = ?, auto_connect = ?,
|
||||
tls_ca_cert = ?, tls_ca_path = ?, tls_insecure = ?, tls_pinned_hash = ?,
|
||||
ip_preference = ?
|
||||
WHERE id = ?`,
|
||||
p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path,
|
||||
p.Username, p.AuthMode, p.RoutingMode,
|
||||
p.CustomCIDRs, p.MTUOverride, p.AutoConnect, p.ID)
|
||||
p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs,
|
||||
p.MTUOverride, p.AutoConnect,
|
||||
p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash,
|
||||
p.IPPreference, p.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update profile %d: %w", p.ID, err)
|
||||
}
|
||||
|
||||
+52
-5
@@ -37,6 +37,20 @@ TxV6Zero = "↑ 0 B 0 bps"
|
||||
RxTotalZero = "Total ↓ 0 B 0 bps"
|
||||
TxTotalZero = "↑ 0 B 0 bps"
|
||||
|
||||
StatusRoutingMode = "Routing Mode: {{.mode}}"
|
||||
CIDRHit = "hit"
|
||||
CIDRConfigured = "configured"
|
||||
CIDRUnmatched = "unmatched"
|
||||
CIDRDestinations = "destinations"
|
||||
RouteLoading = "loading routes"
|
||||
CIDRLoading = "loading"
|
||||
StepFetchCIDRs = "fetching CIDR lists"
|
||||
StepConnecting = "connecting"
|
||||
StepLoadRoutes = "loading routes"
|
||||
|
||||
BtnRefreshCIDR = "Refresh CIDR"
|
||||
CIDRFetchError = "fetch failed"
|
||||
|
||||
DlgNoProfileTitle = "No Profile"
|
||||
DlgNoProfileEditMsg = "Select a profile to edit."
|
||||
DlgNoProfileConnectMsg = "Please select or create a profile first."
|
||||
@@ -45,6 +59,7 @@ DlgDeleteProfileMsg = 'Delete profile "{{.name}}" and its stored credentials?'
|
||||
DlgProfileTitle = "Profile"
|
||||
DlgValidationTitle = "Validation"
|
||||
DlgValidationMsg = "Name, Host, and Username are required."
|
||||
DlgInvalidIPMsg = "Invalid IP address(es): {{.ips}}"
|
||||
DlgDaemonError = "Daemon Error"
|
||||
DlgCredentialError = "Credential Error"
|
||||
DlgCredentialErrorMsg = "No password stored for this profile. Edit the profile to set it."
|
||||
@@ -87,18 +102,50 @@ FieldUsername = "Username"
|
||||
FieldPassword = "Password"
|
||||
FieldAuthMode = "Auth Mode"
|
||||
FieldRoutingMode = "Routing Mode"
|
||||
FieldCustomCIDRs = "Custom CIDRs (comma-separated)"
|
||||
FieldCIDRV4 = "IPv4 CIDRs (comma-separated)"
|
||||
FieldCIDRV6 = "IPv6 CIDRs (comma-separated)"
|
||||
FieldCIDRV4URLs = "IPv4 CIDR URL Sources"
|
||||
FieldCIDRV6URLs = "IPv6 CIDR URL Sources"
|
||||
FieldMTUOverride = "MTU Override"
|
||||
|
||||
PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12"
|
||||
PlaceholderCIDRV4 = "10.0.0.0/8, 172.16.0.0/12"
|
||||
PlaceholderCIDRV6 = "fd00::/8, 2001:db8::/32"
|
||||
PlaceholderCIDRURL = "https://example.com/cidrs.txt"
|
||||
PlaceholderMTU = "0 = use server MTU"
|
||||
PlaceholderPasswordUnchanged = "(unchanged)"
|
||||
PlaceholderServerIPs = "e.g. 1.2.3.4, 5.6.7.8"
|
||||
PlaceholderServerIPs = "IPv4/IPv6 supported, e.g. 1.2.3.4, 5.6.7.8"
|
||||
|
||||
AuthModeBoth = "Both (JWT + Password)"
|
||||
AuthModeJWT = "JWT"
|
||||
AuthModePassword = "Password"
|
||||
|
||||
RoutingModeFull = "Full Tunnel"
|
||||
RoutingModeSplit = "Split Tunnel"
|
||||
RoutingModeCustom = "Custom"
|
||||
RoutingModeProxy = "Proxy CIDR"
|
||||
RoutingModeBypass = "Bypass CIDR"
|
||||
|
||||
FetchTimingBefore = "Before Proxy"
|
||||
FetchTimingAfter = "After Proxy"
|
||||
|
||||
FieldIPPreference = "IP Preference"
|
||||
IPPrefAuto = "Auto (Race)"
|
||||
IPPrefV4 = "IPv4 Only"
|
||||
IPPrefV6 = "IPv6 Only"
|
||||
|
||||
BtnAddURL = "Add URL"
|
||||
BtnRemoveURL = "Remove"
|
||||
|
||||
FieldTLS = "TLS Certificate"
|
||||
FieldTLSCACert = "CA Certificate (PEM)"
|
||||
FieldTLSCAPath = "CA Certificate Path"
|
||||
HintTLSCAReplacesSystem = "Setting a custom CA replaces system root certificates. Public CA servers (e.g. Let's Encrypt) will fail verification."
|
||||
FieldTLSInsecure = "Skip Verification (Insecure)"
|
||||
FieldTLSPinnedHash = "Certificate Pin (SHA-256)"
|
||||
PlaceholderTLSCACert = "-----BEGIN CERTIFICATE-----..."
|
||||
PlaceholderTLSCAPath = "/path/to/ca.crt"
|
||||
PlaceholderTLSPinnedHash = "sha256:a1b2c3..."
|
||||
BtnBrowse = "Browse..."
|
||||
DlgTLSError = "TLS Certificate Error"
|
||||
TLSErrorVerification = "Server certificate verification failed."
|
||||
|
||||
DlgBiometricCanceled = "Authentication was canceled."
|
||||
TouchIDPrompt = "authenticate to access your VPN password"
|
||||
|
||||
@@ -37,6 +37,20 @@ TxV6Zero = "↑ 0 B 0 bps"
|
||||
RxTotalZero = "合计 ↓ 0 B 0 bps"
|
||||
TxTotalZero = "↑ 0 B 0 bps"
|
||||
|
||||
StatusRoutingMode = "路由模式: {{.mode}}"
|
||||
CIDRHit = "命中"
|
||||
CIDRConfigured = "已配置"
|
||||
CIDRUnmatched = "未命中"
|
||||
CIDRDestinations = "目的地址"
|
||||
RouteLoading = "加载路由中"
|
||||
CIDRLoading = "加载中"
|
||||
StepFetchCIDRs = "获取CIDR列表"
|
||||
StepConnecting = "建立连接"
|
||||
StepLoadRoutes = "加载路由"
|
||||
|
||||
BtnRefreshCIDR = "刷新 CIDR"
|
||||
CIDRFetchError = "获取失败"
|
||||
|
||||
DlgNoProfileTitle = "无配置"
|
||||
DlgNoProfileEditMsg = "请选择要编辑的配置。"
|
||||
DlgNoProfileConnectMsg = "请先选择或创建一个配置。"
|
||||
@@ -45,6 +59,7 @@ DlgDeleteProfileMsg = '删除配置"{{.name}}"及其存储的凭据?'
|
||||
DlgProfileTitle = "配置"
|
||||
DlgValidationTitle = "验证"
|
||||
DlgValidationMsg = "名称、主机名和用户名为必填项。"
|
||||
DlgInvalidIPMsg = "以下 IP 地址格式无效:{{.ips}}"
|
||||
DlgDaemonError = "守护进程错误"
|
||||
DlgCredentialError = "凭据错误"
|
||||
DlgCredentialErrorMsg = "此配置未存储密码。请编辑配置以设置密码。"
|
||||
@@ -87,18 +102,50 @@ FieldUsername = "用户名"
|
||||
FieldPassword = "密码"
|
||||
FieldAuthMode = "认证方式"
|
||||
FieldRoutingMode = "路由模式"
|
||||
FieldCustomCIDRs = "自定义 CIDR(逗号分隔)"
|
||||
FieldCIDRV4 = "IPv4 CIDR(逗号分隔)"
|
||||
FieldCIDRV6 = "IPv6 CIDR(逗号分隔)"
|
||||
FieldCIDRV4URLs = "IPv4 CIDR URL 来源"
|
||||
FieldCIDRV6URLs = "IPv6 CIDR URL 来源"
|
||||
FieldMTUOverride = "MTU 覆盖"
|
||||
|
||||
PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12"
|
||||
PlaceholderCIDRV4 = "10.0.0.0/8, 172.16.0.0/12"
|
||||
PlaceholderCIDRV6 = "fd00::/8, 2001:db8::/32"
|
||||
PlaceholderCIDRURL = "https://example.com/cidrs.txt"
|
||||
PlaceholderMTU = "0 = 使用服务器 MTU"
|
||||
PlaceholderPasswordUnchanged = "(未更改)"
|
||||
PlaceholderServerIPs = "例: 1.2.3.4, 5.6.7.8"
|
||||
PlaceholderServerIPs = "支持 IPv4/IPv6,例: 1.2.3.4, 5.6.7.8"
|
||||
|
||||
AuthModeBoth = "全部(JWT + 密码)"
|
||||
AuthModeJWT = "JWT"
|
||||
AuthModePassword = "密码"
|
||||
|
||||
RoutingModeFull = "全隧道"
|
||||
RoutingModeSplit = "分离隧道"
|
||||
RoutingModeCustom = "自定义"
|
||||
RoutingModeProxy = "代理 CIDR"
|
||||
RoutingModeBypass = "绕过 CIDR"
|
||||
|
||||
FetchTimingBefore = "代理前获取"
|
||||
FetchTimingAfter = "代理后获取"
|
||||
|
||||
FieldIPPreference = "IP 偏好"
|
||||
IPPrefAuto = "自动(竞速)"
|
||||
IPPrefV4 = "仅 IPv4"
|
||||
IPPrefV6 = "仅 IPv6"
|
||||
|
||||
BtnAddURL = "添加 URL"
|
||||
BtnRemoveURL = "删除"
|
||||
|
||||
FieldTLS = "TLS 证书"
|
||||
FieldTLSCACert = "CA 证书 (PEM)"
|
||||
FieldTLSCAPath = "CA 证书路径"
|
||||
HintTLSCAReplacesSystem = "设置自定义 CA 将替换系统根证书,使用公共 CA(如 Let's Encrypt)的服务器将验证失败。"
|
||||
FieldTLSInsecure = "跳过验证 (不安全)"
|
||||
FieldTLSPinnedHash = "证书固定 (SHA-256)"
|
||||
PlaceholderTLSCACert = "-----BEGIN CERTIFICATE-----..."
|
||||
PlaceholderTLSCAPath = "/path/to/ca.crt"
|
||||
PlaceholderTLSPinnedHash = "sha256:a1b2c3..."
|
||||
BtnBrowse = "浏览..."
|
||||
DlgTLSError = "TLS 证书错误"
|
||||
TLSErrorVerification = "服务器证书验证失败。"
|
||||
|
||||
DlgBiometricCanceled = "认证已取消。"
|
||||
TouchIDPrompt = "验证指纹以访问 VPN 密码"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Package instance implements single-instance enforcement for the GUI
|
||||
// process. The first instance to start acquires the lock by listening
|
||||
// on a dedicated endpoint; subsequent instances dial the endpoint
|
||||
// (signalling "bring to front") and exit immediately.
|
||||
//
|
||||
// The lock is self-cleaning: a crashed process releases the listener
|
||||
// automatically (the kernel closes the socket). On unix a stale socket
|
||||
// file may remain; Acquire probes it with a dial before removing.
|
||||
package instance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"lmvpn/internal/paths"
|
||||
)
|
||||
|
||||
// ErrAlreadyRunning is returned by Acquire when another GUI instance
|
||||
// is already running. The caller should exit silently.
|
||||
var ErrAlreadyRunning = errors.New("another instance is already running")
|
||||
|
||||
// Acquire attempts to become the sole GUI instance. On success it
|
||||
// returns a channel that receives a value every time another instance
|
||||
// signals (the caller should bring its window to the front). The
|
||||
// listener is closed when the channel is closed, which happens never
|
||||
// during normal operation - the process simply exits and the OS
|
||||
// releases the socket.
|
||||
//
|
||||
// On failure it returns ErrAlreadyRunning (the caller should exit) or
|
||||
// another error (the caller may continue without single-instance
|
||||
// protection, logging the issue).
|
||||
func Acquire() (<-chan struct{}, error) {
|
||||
netType := paths.GUILockNetwork()
|
||||
addr := paths.GUILockAddress()
|
||||
|
||||
l, err := net.Listen(netType, addr)
|
||||
if err == nil {
|
||||
ch := make(chan struct{}, 1)
|
||||
go acceptLoop(l, ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Listen failed - check whether another instance is alive.
|
||||
if c, derr := net.Dial(netType, addr); derr == nil {
|
||||
c.Close()
|
||||
return nil, ErrAlreadyRunning
|
||||
}
|
||||
|
||||
// Nobody is listening. On unix this is likely a stale socket file
|
||||
// left by a crashed process; remove it and retry once.
|
||||
if netType == "unix" {
|
||||
_ = os.Remove(addr)
|
||||
l, err = net.Listen(netType, addr)
|
||||
if err == nil {
|
||||
ch := make(chan struct{}, 1)
|
||||
go acceptLoop(l, ch)
|
||||
return ch, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("acquire instance lock: %w", err)
|
||||
}
|
||||
|
||||
// acceptLoop accepts connections from second instances. Each connection
|
||||
// is a "focus" signal; the handler closes it immediately and notifies
|
||||
// the caller via ch.
|
||||
func acceptLoop(l net.Listener, ch chan<- struct{}) {
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
-25
@@ -25,11 +25,12 @@ import (
|
||||
|
||||
// Command types sent from GUI to daemon.
|
||||
const (
|
||||
CmdStart = "start"
|
||||
CmdStop = "stop"
|
||||
CmdShutdown = "shutdown"
|
||||
CmdStats = "stats"
|
||||
CmdVersion = "version" // query daemon build version
|
||||
CmdStart = "start"
|
||||
CmdStop = "stop"
|
||||
CmdShutdown = "shutdown"
|
||||
CmdStats = "stats"
|
||||
CmdVersion = "version" // query daemon build version
|
||||
CmdRefreshCIDR = "refresh_cidr" // re-fetch CIDR URL sources and update routes
|
||||
)
|
||||
|
||||
// Event types sent from daemon to GUI.
|
||||
@@ -49,25 +50,42 @@ type Request struct {
|
||||
// vpn.SessionConfig but is kept separate to avoid importing the vpn
|
||||
// package (which needs root-only TUN) into the GUI.
|
||||
type ClientConfig struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN
|
||||
ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Token string `json:"token"`
|
||||
AuthMode string `json:"auth_mode"`
|
||||
RoutingMode string `json:"routing_mode"`
|
||||
CustomCIDRs []string `json:"custom_cidrs"`
|
||||
MTUOverride int `json:"mtu_override"`
|
||||
ServerURL string `json:"server_url"`
|
||||
SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN
|
||||
ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Token string `json:"token"`
|
||||
AuthMode string `json:"auth_mode"`
|
||||
RoutingMode string `json:"routing_mode"` // "full", "proxy", "bypass"
|
||||
CIDRV4 []string `json:"cidr_v4"` // static IPv4 CIDRs
|
||||
CIDRV6 []string `json:"cidr_v6"` // static IPv6 CIDRs
|
||||
CIDRV4URLs []CIDRURLSource `json:"cidr_v4_urls"` // IPv4 CIDR URL sources
|
||||
CIDRV6URLs []CIDRURLSource `json:"cidr_v6_urls"` // IPv6 CIDR URL sources
|
||||
MTUOverride int `json:"mtu_override"`
|
||||
TLSCACert string `json:"tls_ca_cert"` // inline CA cert PEM (wss only)
|
||||
TLSCAPath string `json:"tls_ca_path"` // CA cert file path (wss only)
|
||||
TLSInsecure bool `json:"tls_insecure"` // skip cert verification (wss only)
|
||||
TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 cert pin (wss only)
|
||||
IPPreference string `json:"ip_preference"` // "auto", "v4", "v6" (hostname mode only)
|
||||
}
|
||||
|
||||
// CIDRURLSource describes a URL that provides a CIDR list. It mirrors
|
||||
// model.CIDRURLSource but is kept here to avoid importing the model
|
||||
// package into the IPC wire format.
|
||||
type CIDRURLSource struct {
|
||||
URL string `json:"url"`
|
||||
FetchTiming string `json:"fetch_timing"` // "before" or "after"
|
||||
}
|
||||
|
||||
// Event is a notification from the daemon to the GUI.
|
||||
type Event struct {
|
||||
Event string `json:"event"`
|
||||
State string `json:"state,omitempty"`
|
||||
Stats *stats.Snapshot `json:"stats,omitempty"`
|
||||
Code string `json:"code,omitempty"` // stable auth-error code for EvError
|
||||
Message string `json:"message,omitempty"`
|
||||
Event string `json:"event"`
|
||||
State string `json:"state,omitempty"`
|
||||
ConnectStep string `json:"connect_step,omitempty"` // current connection step (for EvState)
|
||||
Stats *stats.Snapshot `json:"stats,omitempty"`
|
||||
Code string `json:"code,omitempty"` // stable auth-error code for EvError
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// --- Wire helpers ---
|
||||
@@ -105,8 +123,16 @@ func NewServer() (*Server, error) {
|
||||
netType := paths.IPCNetwork()
|
||||
addr := paths.IPCAddress()
|
||||
|
||||
// Clean up stale unix socket file (not needed for TCP).
|
||||
// Guard against a second daemon instance on unix: probe the
|
||||
// socket before removing it. If the dial succeeds, another
|
||||
// daemon is alive and owns the socket - refuse to start so we
|
||||
// don't silently orphan it. Only remove the file when the dial
|
||||
// fails (stale socket from a crashed process).
|
||||
if netType == "unix" {
|
||||
if c, derr := net.Dial(netType, addr); derr == nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("daemon already running on %s", addr)
|
||||
}
|
||||
_ = os.Remove(addr)
|
||||
}
|
||||
|
||||
@@ -241,6 +267,11 @@ func SendStop(c *Client) error {
|
||||
return c.Send(Request{Cmd: CmdStop})
|
||||
}
|
||||
|
||||
// SendRefreshCIDR is a convenience helper for sending a refresh CIDR command.
|
||||
func SendRefreshCIDR(c *Client) error {
|
||||
return c.Send(Request{Cmd: CmdRefreshCIDR})
|
||||
}
|
||||
|
||||
// SendShutdown is a convenience helper for sending a shutdown command.
|
||||
func SendShutdown(c *Client) error {
|
||||
return c.Send(Request{Cmd: CmdShutdown})
|
||||
@@ -273,10 +304,10 @@ func RoutingModeFromIPC(s string) route.Mode {
|
||||
switch s {
|
||||
case "full":
|
||||
return route.ModeFull
|
||||
case "split":
|
||||
return route.ModeSplit
|
||||
case "custom":
|
||||
return route.ModeCustom
|
||||
case "proxy":
|
||||
return route.ModeProxy
|
||||
case "bypass":
|
||||
return route.ModeBypass
|
||||
default:
|
||||
return route.ModeFull
|
||||
}
|
||||
|
||||
@@ -19,6 +19,17 @@ const ServiceName = paths.BundleID
|
||||
// ErrNotFound is returned when a secret is not present in the store.
|
||||
var ErrNotFound = fmt.Errorf("secret not found")
|
||||
|
||||
// ErrSecMissingEntitlement is returned when the biometric keychain path
|
||||
// fails because the app lacks the required code-signing entitlements
|
||||
// (errSecMissingEntitlement, OSStatus -34018). Callers should fall back
|
||||
// to the non-biometric keychain path when this error is encountered.
|
||||
var ErrSecMissingEntitlement = fmt.Errorf("missing keychain entitlement")
|
||||
|
||||
// ErrUserCanceled is returned when the user cancels a biometric
|
||||
// authentication prompt (e.g. Touch ID) or the system cannot complete
|
||||
// authentication.
|
||||
var ErrUserCanceled = fmt.Errorf("user canceled authentication")
|
||||
|
||||
// Store is the secret storage interface.
|
||||
type Store interface {
|
||||
SetPassword(profileName, password string) error
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/keybase/go-keychain"
|
||||
)
|
||||
@@ -14,22 +16,68 @@ const (
|
||||
tokenAccountPrefix = "token:"
|
||||
)
|
||||
|
||||
// touchIDPrompt is the localized prompt shown in the Touch ID dialog.
|
||||
// It is set by the UI layer at startup via SetTouchIDPrompt.
|
||||
var touchIDPrompt = "authenticate to access your VPN password"
|
||||
|
||||
// SetTouchIDPrompt sets the localized prompt text shown in the Touch ID
|
||||
// dialog when retrieving secrets from the keychain.
|
||||
func SetTouchIDPrompt(prompt string) {
|
||||
if prompt != "" {
|
||||
touchIDPrompt = prompt
|
||||
}
|
||||
}
|
||||
|
||||
// biometricDisabled tracks whether the biometric keychain path has been
|
||||
// disabled at runtime. This happens when a biometric operation fails with
|
||||
// errSecMissingEntitlement (-34018), indicating the app lacks the required
|
||||
// code-signing entitlements. Once disabled, all subsequent operations use
|
||||
// the non-biometric file-based keychain path.
|
||||
var (
|
||||
biometricDisabled bool
|
||||
biometricDisabledMu sync.Mutex
|
||||
)
|
||||
|
||||
// shouldUseBiometric reports whether the biometric keychain path should be
|
||||
// used. It returns false if biometric storage has been disabled at runtime
|
||||
// (e.g. due to missing entitlements on an ad-hoc signed build).
|
||||
func shouldUseBiometric() bool {
|
||||
if !biometricAvailable() {
|
||||
return false
|
||||
}
|
||||
biometricDisabledMu.Lock()
|
||||
defer biometricDisabledMu.Unlock()
|
||||
return !biometricDisabled
|
||||
}
|
||||
|
||||
// disableBiometric disables the biometric keychain path for all subsequent
|
||||
// operations, forcing a fallback to the non-biometric file-based keychain.
|
||||
func disableBiometric() {
|
||||
biometricDisabledMu.Lock()
|
||||
defer biometricDisabledMu.Unlock()
|
||||
biometricDisabled = true
|
||||
}
|
||||
|
||||
// DarwinStore implements Store using the macOS Keychain.
|
||||
type DarwinStore struct{}
|
||||
|
||||
// New returns a macOS Keychain-backed Store.
|
||||
// New returns a macOS Keychain-backed Store. On Macs with Touch ID,
|
||||
// secrets are stored with biometric (Touch ID) protection; on older
|
||||
// Macs without a biometric sensor, the standard keychain accessibility
|
||||
// is used as a fallback.
|
||||
func New() Store {
|
||||
return DarwinStore{}
|
||||
}
|
||||
|
||||
func (DarwinStore) setItem(account, secret string) error {
|
||||
// setItemPlain stores a secret in the file-based keychain (no biometric
|
||||
// protection) using the keybase/go-keychain library.
|
||||
func setItemPlain(account, secret string) error {
|
||||
item := keychain.NewItem()
|
||||
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||
item.SetService(ServiceName)
|
||||
item.SetAccount(account)
|
||||
item.SetData([]byte(secret))
|
||||
item.SetAccessible(keychain.AccessibleAfterFirstUnlock)
|
||||
// Delete any existing item first (Add fails on duplicates).
|
||||
_ = keychain.DeleteItem(item)
|
||||
if err := keychain.AddItem(item); err != nil {
|
||||
return fmt.Errorf("keychain add %s: %w", account, err)
|
||||
@@ -37,7 +85,8 @@ func (DarwinStore) setItem(account, secret string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (DarwinStore) getItem(account string) (string, error) {
|
||||
// getItemPlain retrieves a secret from the file-based keychain.
|
||||
func getItemPlain(account string) (string, error) {
|
||||
item := keychain.NewItem()
|
||||
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||
item.SetService(ServiceName)
|
||||
@@ -54,7 +103,8 @@ func (DarwinStore) getItem(account string) (string, error) {
|
||||
return string(results[0].Data), nil
|
||||
}
|
||||
|
||||
func (DarwinStore) deleteItem(account string) error {
|
||||
// deleteItemPlain deletes a secret from the file-based keychain.
|
||||
func deleteItemPlain(account string) error {
|
||||
item := keychain.NewItem()
|
||||
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||
item.SetService(ServiceName)
|
||||
@@ -62,6 +112,57 @@ func (DarwinStore) deleteItem(account string) error {
|
||||
return keychain.DeleteItem(item)
|
||||
}
|
||||
|
||||
func (DarwinStore) setItem(account, secret string) error {
|
||||
if shouldUseBiometric() {
|
||||
err := storeBiometricItemGo(ServiceName, account, secret)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, ErrSecMissingEntitlement) {
|
||||
disableBiometric()
|
||||
// Fall through to non-biometric path.
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return setItemPlain(account, secret)
|
||||
}
|
||||
|
||||
func (DarwinStore) getItem(account string) (string, error) {
|
||||
if shouldUseBiometric() {
|
||||
secret, err := getBiometricItemGo(ServiceName, account, touchIDPrompt)
|
||||
if err == nil {
|
||||
return secret, nil
|
||||
}
|
||||
if errors.Is(err, ErrSecMissingEntitlement) {
|
||||
disableBiometric()
|
||||
// Fall through to non-biometric path.
|
||||
} else if errors.Is(err, ErrNotFound) {
|
||||
// Item not in the Data Protection Keychain; it may have
|
||||
// been stored via the non-biometric path. Fall through.
|
||||
} else {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return getItemPlain(account)
|
||||
}
|
||||
|
||||
func (DarwinStore) deleteItem(account string) error {
|
||||
if shouldUseBiometric() {
|
||||
err := deleteBiometricItemGo(ServiceName, account)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, ErrSecMissingEntitlement) {
|
||||
disableBiometric()
|
||||
// Fall through to non-biometric path.
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return deleteItemPlain(account)
|
||||
}
|
||||
|
||||
func (s DarwinStore) SetPassword(profileName, password string) error {
|
||||
return s.setItem(passwordAccountPrefix+profileName, password)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//go:build darwin
|
||||
|
||||
package keychain
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -framework CoreFoundation -framework Security -framework LocalAuthentication -framework Foundation
|
||||
|
||||
#include <CoreFoundation/CoreFoundation.h>
|
||||
#include <Security/Security.h>
|
||||
#include <objc/objc.h>
|
||||
#include <objc/runtime.h>
|
||||
#include <objc/message.h>
|
||||
|
||||
// LAPolicyDeviceOwnerAuthenticationWithBiometrics = 1
|
||||
|
||||
// biometricAvailable returns 1 if Touch ID is available, 0 otherwise.
|
||||
// Uses the Objective-C runtime to call LAContext without including
|
||||
// Objective-C headers (which cannot be parsed by the C compiler).
|
||||
static int biometricAvailable(void) {
|
||||
Class cls = objc_getClass("LAContext");
|
||||
if (!cls) return 0;
|
||||
|
||||
id alloc = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("alloc"));
|
||||
if (!alloc) return 0;
|
||||
id context = ((id (*)(id, SEL))objc_msgSend)(alloc, sel_getUid("init"));
|
||||
if (!context) {
|
||||
((void (*)(id, SEL))objc_msgSend)(alloc, sel_getUid("release"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// canEvaluatePolicy:error: returns BOOL, takes (NSInteger, NSError**)
|
||||
// LAPolicyDeviceOwnerAuthenticationWithBiometrics = 1
|
||||
BOOL result = ((BOOL (*)(id, SEL, long, void *))objc_msgSend)(
|
||||
context, sel_getUid("canEvaluatePolicy:error:"), 1, NULL);
|
||||
|
||||
((void (*)(id, SEL))objc_msgSend)(context, sel_getUid("release"));
|
||||
return result ? 1 : 0;
|
||||
}
|
||||
|
||||
// secAccessControlCreateBiometric creates a SecAccessControlRef with
|
||||
// kSecAccessControlBiometryAny. Must be released with CFRelease by caller.
|
||||
static SecAccessControlRef secAccessControlCreateBiometric(void) {
|
||||
SecAccessControlRef acl = SecAccessControlCreateWithFlags(
|
||||
kCFAllocatorDefault,
|
||||
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
|
||||
kSecAccessControlBiometryAny,
|
||||
NULL);
|
||||
return acl;
|
||||
}
|
||||
|
||||
// storeBiometricItem stores data in the keychain with Touch ID protection.
|
||||
// Returns 0 on success, or a non-zero OSStatus error code on failure.
|
||||
static int storeBiometricItem(CFStringRef service, CFStringRef account, CFDataRef data) {
|
||||
SecAccessControlRef acl = secAccessControlCreateBiometric();
|
||||
if (!acl) {
|
||||
return errSecAllocate;
|
||||
}
|
||||
|
||||
const void *keys[] = {
|
||||
kSecClass,
|
||||
kSecAttrService,
|
||||
kSecAttrAccount,
|
||||
kSecValueData,
|
||||
kSecAttrAccessControl,
|
||||
};
|
||||
const void *values[] = {
|
||||
kSecClassGenericPassword,
|
||||
service,
|
||||
account,
|
||||
data,
|
||||
acl,
|
||||
};
|
||||
CFDictionaryRef query = CFDictionaryCreate(
|
||||
kCFAllocatorDefault,
|
||||
keys, values, sizeof(keys) / sizeof(keys[0]),
|
||||
&kCFTypeDictionaryKeyCallBacks,
|
||||
&kCFTypeDictionaryValueCallBacks);
|
||||
|
||||
// Delete any existing item first (Add fails on duplicates).
|
||||
SecItemDelete(query);
|
||||
|
||||
OSStatus status = SecItemAdd(query, NULL);
|
||||
CFRelease(query);
|
||||
CFRelease(acl);
|
||||
return (int)status;
|
||||
}
|
||||
|
||||
// getBiometricItem retrieves data from the keychain, prompting for Touch ID
|
||||
// via an LAContext with a localized reason. Returns:
|
||||
// 0 on success (dataOut filled, caller must CFRelease)
|
||||
// -1 if item not found
|
||||
// -128 (errSecUserCanceled) if user canceled
|
||||
// other positive OSStatus error codes on failure
|
||||
//
|
||||
// prompt is the localized Touch ID prompt string (CFStringRef).
|
||||
static int getBiometricItem(CFStringRef service, CFStringRef account, CFStringRef prompt, CFDataRef *dataOut) {
|
||||
// Create an LAContext and set its localizedReason for the Touch ID
|
||||
// prompt. We use the objc runtime to avoid including Objective-C
|
||||
// headers. CFStringRef is toll-free bridged to NSString*.
|
||||
id laContext = NULL;
|
||||
if (prompt) {
|
||||
Class cls = objc_getClass("LAContext");
|
||||
if (cls) {
|
||||
id alloc = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("alloc"));
|
||||
if (alloc) {
|
||||
laContext = ((id (*)(id, SEL))objc_msgSend)(alloc, sel_getUid("init"));
|
||||
}
|
||||
}
|
||||
if (laContext) {
|
||||
((void (*)(id, SEL, id))objc_msgSend)(
|
||||
laContext, sel_getUid("setLocalizedReason:"), (id)prompt);
|
||||
}
|
||||
}
|
||||
|
||||
CFDictionaryRef query;
|
||||
if (laContext) {
|
||||
const void *keys[] = {
|
||||
kSecClass,
|
||||
kSecAttrService,
|
||||
kSecAttrAccount,
|
||||
kSecMatchLimit,
|
||||
kSecReturnData,
|
||||
kSecUseAuthenticationContext,
|
||||
};
|
||||
const void *values[] = {
|
||||
kSecClassGenericPassword,
|
||||
service,
|
||||
account,
|
||||
kSecMatchLimitOne,
|
||||
kCFBooleanTrue,
|
||||
laContext,
|
||||
};
|
||||
query = CFDictionaryCreate(
|
||||
kCFAllocatorDefault,
|
||||
keys, values, sizeof(keys) / sizeof(keys[0]),
|
||||
&kCFTypeDictionaryKeyCallBacks,
|
||||
&kCFTypeDictionaryValueCallBacks);
|
||||
} else {
|
||||
// Fallback: no LAContext (e.g. LAContext class not available).
|
||||
const void *keys[] = {
|
||||
kSecClass,
|
||||
kSecAttrService,
|
||||
kSecAttrAccount,
|
||||
kSecMatchLimit,
|
||||
kSecReturnData,
|
||||
};
|
||||
const void *values[] = {
|
||||
kSecClassGenericPassword,
|
||||
service,
|
||||
account,
|
||||
kSecMatchLimitOne,
|
||||
kCFBooleanTrue,
|
||||
};
|
||||
query = CFDictionaryCreate(
|
||||
kCFAllocatorDefault,
|
||||
keys, values, sizeof(keys) / sizeof(keys[0]),
|
||||
&kCFTypeDictionaryKeyCallBacks,
|
||||
&kCFTypeDictionaryValueCallBacks);
|
||||
}
|
||||
|
||||
CFTypeRef result = NULL;
|
||||
OSStatus status = SecItemCopyMatching(query, &result);
|
||||
CFRelease(query);
|
||||
|
||||
if (laContext) {
|
||||
((void (*)(id, SEL))objc_msgSend)(laContext, sel_getUid("release"));
|
||||
}
|
||||
|
||||
if (status == errSecItemNotFound) {
|
||||
return -1;
|
||||
}
|
||||
if (status != errSecSuccess) {
|
||||
return (int)status;
|
||||
}
|
||||
*dataOut = (CFDataRef)result;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// deleteBiometricItem deletes a keychain item by service+account.
|
||||
// Returns 0 on success (including "not found"), or OSStatus on error.
|
||||
static int deleteBiometricItem(CFStringRef service, CFStringRef account) {
|
||||
const void *keys[] = {
|
||||
kSecClass,
|
||||
kSecAttrService,
|
||||
kSecAttrAccount,
|
||||
};
|
||||
const void *values[] = {
|
||||
kSecClassGenericPassword,
|
||||
service,
|
||||
account,
|
||||
};
|
||||
CFDictionaryRef query = CFDictionaryCreate(
|
||||
kCFAllocatorDefault,
|
||||
keys, values, sizeof(keys) / sizeof(keys[0]),
|
||||
&kCFTypeDictionaryKeyCallBacks,
|
||||
&kCFTypeDictionaryValueCallBacks);
|
||||
|
||||
OSStatus status = SecItemDelete(query);
|
||||
CFRelease(query);
|
||||
if (status == errSecItemNotFound) {
|
||||
return 0;
|
||||
}
|
||||
return (int)status;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// errSecUserCanceled is the macOS Security framework error code returned
|
||||
// when the user cancels a Touch ID / password prompt (-128).
|
||||
const errSecUserCanceled = -128
|
||||
|
||||
// errSecMissingEntitlementCode is errSecMissingEntitlement (-34018),
|
||||
// returned by the Data Protection Keychain when the app is not properly
|
||||
// code-signed with keychain-access-groups entitlement.
|
||||
const errSecMissingEntitlementCode = -34018
|
||||
|
||||
var (
|
||||
biometricCacheOnce sync.Once
|
||||
biometricCacheVal bool
|
||||
)
|
||||
|
||||
// biometricAvailable reports whether Touch ID is available on this Mac.
|
||||
// The result is computed once and cached.
|
||||
func biometricAvailable() bool {
|
||||
biometricCacheOnce.Do(func() {
|
||||
biometricCacheVal = C.biometricAvailable() == 1
|
||||
})
|
||||
return biometricCacheVal
|
||||
}
|
||||
|
||||
// cfRelease releases a CFTypeRef (CFString, CFData, etc.).
|
||||
func cfRelease(ref C.CFTypeRef) {
|
||||
if ref != 0 {
|
||||
C.CFRelease(ref)
|
||||
}
|
||||
}
|
||||
|
||||
// cfString creates a CFStringRef from a Go string. The caller must
|
||||
// CFRelease the result.
|
||||
func cfString(s string) (C.CFStringRef, error) {
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
cs := C.CFStringCreateWithBytes(
|
||||
C.kCFAllocatorDefault,
|
||||
(*C.UInt8)(unsafe.Pointer(&[]byte(s)[0])),
|
||||
C.CFIndex(len(s)),
|
||||
C.kCFStringEncodingUTF8,
|
||||
C.false)
|
||||
if cs == 0 {
|
||||
return 0, fmt.Errorf("CFStringCreateWithBytes failed for %q", s)
|
||||
}
|
||||
return cs, nil
|
||||
}
|
||||
|
||||
// cfData creates a CFDataRef from a byte slice. The caller must
|
||||
// CFRelease the result.
|
||||
func cfData(b []byte) (C.CFDataRef, error) {
|
||||
var p *C.UInt8
|
||||
if len(b) > 0 {
|
||||
p = (*C.UInt8)(unsafe.Pointer(&b[0]))
|
||||
}
|
||||
cd := C.CFDataCreate(C.kCFAllocatorDefault, p, C.CFIndex(len(b)))
|
||||
if cd == 0 {
|
||||
return 0, fmt.Errorf("CFDataCreate failed")
|
||||
}
|
||||
return cd, nil
|
||||
}
|
||||
|
||||
// storeBiometricItemGo stores a secret in the keychain with Touch ID
|
||||
// protection. It first deletes any existing item (which may or may not
|
||||
// have biometric protection), then adds a new biometric-protected item.
|
||||
func storeBiometricItemGo(service, account, secret string) error {
|
||||
svcRef, err := cfString(service)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keychain biometric store %s: %w", account, err)
|
||||
}
|
||||
defer cfRelease(C.CFTypeRef(svcRef))
|
||||
|
||||
accRef, err := cfString(account)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keychain biometric store %s: %w", account, err)
|
||||
}
|
||||
defer cfRelease(C.CFTypeRef(accRef))
|
||||
|
||||
secretBytes := []byte(secret)
|
||||
dataRef, err := cfData(secretBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keychain biometric store %s: %w", account, err)
|
||||
}
|
||||
defer cfRelease(C.CFTypeRef(dataRef))
|
||||
|
||||
status := C.storeBiometricItem(svcRef, accRef, dataRef)
|
||||
if status != 0 {
|
||||
if status == errSecMissingEntitlementCode {
|
||||
return fmt.Errorf("keychain biometric store %s: %w", account, ErrSecMissingEntitlement)
|
||||
}
|
||||
return fmt.Errorf("keychain biometric store %s: OSStatus %d", account, status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getBiometricItemGo retrieves a secret from the keychain, prompting
|
||||
// for Touch ID if the item is biometric-protected. prompt is the
|
||||
// localized text shown in the Touch ID dialog.
|
||||
func getBiometricItemGo(service, account, prompt string) (string, error) {
|
||||
svcRef, err := cfString(service)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("keychain biometric get %s: %w", account, err)
|
||||
}
|
||||
defer cfRelease(C.CFTypeRef(svcRef))
|
||||
|
||||
accRef, err := cfString(account)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("keychain biometric get %s: %w", account, err)
|
||||
}
|
||||
defer cfRelease(C.CFTypeRef(accRef))
|
||||
|
||||
promptRef, err := cfString(prompt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("keychain biometric get %s: %w", account, err)
|
||||
}
|
||||
defer cfRelease(C.CFTypeRef(promptRef))
|
||||
|
||||
var dataOut C.CFDataRef
|
||||
status := C.getBiometricItem(svcRef, accRef, promptRef, &dataOut)
|
||||
if status == -1 {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if status == errSecUserCanceled {
|
||||
return "", ErrUserCanceled
|
||||
}
|
||||
if status != 0 {
|
||||
if status == errSecMissingEntitlementCode {
|
||||
return "", fmt.Errorf("keychain biometric get %s: %w", account, ErrSecMissingEntitlement)
|
||||
}
|
||||
return "", fmt.Errorf("keychain biometric get %s: OSStatus %d", account, status)
|
||||
}
|
||||
defer cfRelease(C.CFTypeRef(dataOut))
|
||||
|
||||
bytes := C.GoBytes(unsafe.Pointer(C.CFDataGetBytePtr(dataOut)), C.int(C.CFDataGetLength(dataOut)))
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// deleteBiometricItemGo deletes a keychain item by service+account.
|
||||
// It works regardless of whether the item has biometric protection.
|
||||
func deleteBiometricItemGo(service, account string) error {
|
||||
svcRef, err := cfString(service)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keychain biometric delete %s: %w", account, err)
|
||||
}
|
||||
defer cfRelease(C.CFTypeRef(svcRef))
|
||||
|
||||
accRef, err := cfString(account)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keychain biometric delete %s: %w", account, err)
|
||||
}
|
||||
defer cfRelease(C.CFTypeRef(accRef))
|
||||
|
||||
status := C.deleteBiometricItem(svcRef, accRef)
|
||||
if status != 0 {
|
||||
if status == errSecMissingEntitlementCode {
|
||||
return fmt.Errorf("keychain biometric delete %s: %w", account, ErrSecMissingEntitlement)
|
||||
}
|
||||
return fmt.Errorf("keychain biometric delete %s: OSStatus %d", account, status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+86
-11
@@ -3,7 +3,9 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -17,15 +19,40 @@ const (
|
||||
AuthModePassword AuthMode = "password" // {type:auth} first message
|
||||
)
|
||||
|
||||
// IPPreference controls which IP address family is used when connecting
|
||||
// to a server by hostname (ServerIPs empty).
|
||||
type IPPreference string
|
||||
|
||||
const (
|
||||
IPPrefAuto IPPreference = "auto" // race all resolved addresses (v4 + v6)
|
||||
IPPrefV4 IPPreference = "v4" // only IPv4 addresses
|
||||
IPPrefV6 IPPreference = "v6" // only IPv6 addresses
|
||||
)
|
||||
|
||||
// RoutingMode selects which traffic goes through the VPN tunnel.
|
||||
type RoutingMode string
|
||||
|
||||
const (
|
||||
RoutingFull RoutingMode = "full" // 0.0.0.0/0 via tunnel
|
||||
RoutingSplit RoutingMode = "split" // only VPN subnet via tunnel
|
||||
RoutingCustom RoutingMode = "custom" // user-specified CIDRs
|
||||
RoutingFull RoutingMode = "full" // 全隧道: all traffic via tunnel
|
||||
RoutingProxy RoutingMode = "proxy" // 代理CIDR: only specified CIDRs via tunnel
|
||||
RoutingBypass RoutingMode = "bypass" // 绕过CIDR: all traffic via tunnel except specified CIDRs
|
||||
)
|
||||
|
||||
// FetchTiming specifies when a CIDR URL source is fetched.
|
||||
type FetchTiming string
|
||||
|
||||
const (
|
||||
FetchBefore FetchTiming = "before" // before proxy: fetched via direct connection before routing is applied
|
||||
FetchAfter FetchTiming = "after" // after proxy: fetched via the tunnel after the data plane is up
|
||||
)
|
||||
|
||||
// CIDRURLSource describes a URL that provides a CIDR list. The list
|
||||
// is fetched at FetchTiming and merged into the routing configuration.
|
||||
type CIDRURLSource struct {
|
||||
URL string `json:"url"`
|
||||
FetchTiming FetchTiming `json:"fetch_timing"` // "before" or "after"
|
||||
}
|
||||
|
||||
// ServerProfile is a saved VPN server configuration.
|
||||
type ServerProfile struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -38,9 +65,17 @@ type ServerProfile struct {
|
||||
Username string `json:"username"`
|
||||
AuthMode AuthMode `json:"auth_mode"`
|
||||
RoutingMode RoutingMode `json:"routing_mode"`
|
||||
CustomCIDRs string `json:"custom_cidrs"` // comma-separated, for RoutingCustom
|
||||
CIDRV4 string `json:"cidr_v4"` // comma-separated static IPv4 CIDRs
|
||||
CIDRV6 string `json:"cidr_v6"` // comma-separated static IPv6 CIDRs
|
||||
CIDRV4URLs string `json:"cidr_v4_urls"` // JSON array of CIDRURLSource for IPv4
|
||||
CIDRV6URLs string `json:"cidr_v6_urls"` // JSON array of CIDRURLSource for IPv6
|
||||
MTUOverride int `json:"mtu_override"` // 0 = use server MTU
|
||||
AutoConnect bool `json:"auto_connect"`
|
||||
TLSCACert string `json:"tls_ca_cert"` // inline CA certificate PEM (wss only)
|
||||
TLSCAPath string `json:"tls_ca_path"` // path to CA certificate file (wss only)
|
||||
TLSInsecure bool `json:"tls_insecure"` // skip certificate verification (wss only)
|
||||
TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 fingerprint of server leaf cert (wss only)
|
||||
IPPreference string `json:"ip_preference"` // "auto", "v4", "v6" (hostname mode only)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastConnectedAt *time.Time `json:"last_connected_at"`
|
||||
}
|
||||
@@ -81,17 +116,57 @@ func (p *ServerProfile) BuildServerURL(ip ...string) string {
|
||||
return fmt.Sprintf("%s://%s:%d%s", protocol, host, port, path)
|
||||
}
|
||||
|
||||
// GetServerIPList parses ServerIPs into a string slice.
|
||||
func (p *ServerProfile) GetServerIPList() []string {
|
||||
// ValidateServerIPs parses ServerIPs, returning valid IP addresses
|
||||
// and any invalid entries (for UI error reporting).
|
||||
func (p *ServerProfile) ValidateServerIPs() (valid []string, invalid []string) {
|
||||
if p.ServerIPs == "" {
|
||||
return nil, nil
|
||||
}
|
||||
for _, part := range strings.Split(p.ServerIPs, ",") {
|
||||
s := strings.TrimSpace(part)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if net.ParseIP(s) != nil {
|
||||
valid = append(valid, s)
|
||||
} else {
|
||||
invalid = append(invalid, s)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetServerIPList returns only valid IP addresses from ServerIPs,
|
||||
// silently filtering out any malformed entries.
|
||||
func (p *ServerProfile) GetServerIPList() []string {
|
||||
valid, _ := p.ValidateServerIPs()
|
||||
return valid
|
||||
}
|
||||
|
||||
// ParseCIDRURLs decodes a JSON-encoded CIDRURLSource array. Returns an
|
||||
// empty slice if the string is empty or unparseable.
|
||||
func ParseCIDRURLs(jsonStr string) []CIDRURLSource {
|
||||
if jsonStr == "" {
|
||||
return nil
|
||||
}
|
||||
var sources []CIDRURLSource
|
||||
if err := json.Unmarshal([]byte(jsonStr), &sources); err != nil {
|
||||
return nil
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
// SplitCIDRs splits a comma-separated CIDR string into a slice,
|
||||
// trimming whitespace from each entry and skipping empty ones.
|
||||
func SplitCIDRs(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(p.ServerIPs, ",")
|
||||
var out []string
|
||||
for _, part := range parts {
|
||||
s := strings.TrimSpace(part)
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
c := strings.TrimSpace(part)
|
||||
if c != "" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -56,5 +56,3 @@ func LogFile() string { return Paths.Log + "/lmvpn.log" }
|
||||
|
||||
// DaemonLogFile returns the path to the daemon log file.
|
||||
func DaemonLogFile() string { return Paths.Log + "/lmvpn-daemon.log" }
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ func IPCNetwork() string { return "unix" }
|
||||
// IPCAddress returns the listen/dial address for GUI <-> daemon IPC.
|
||||
func IPCAddress() string { return "/tmp/lmvpn.sock" }
|
||||
|
||||
// GUILockNetwork returns the transport for the GUI single-instance lock.
|
||||
func GUILockNetwork() string { return "unix" }
|
||||
|
||||
// GUILockAddress returns the address for the GUI single-instance lock.
|
||||
func GUILockAddress() string { return Paths.Data + "/gui.sock" }
|
||||
|
||||
func init() {
|
||||
home, _ := os.UserHomeDir()
|
||||
recomputePaths(home)
|
||||
|
||||
@@ -13,6 +13,12 @@ func IPCNetwork() string { return "unix" }
|
||||
// IPCAddress returns the listen/dial address for GUI <-> daemon IPC.
|
||||
func IPCAddress() string { return "/tmp/lmvpn.sock" }
|
||||
|
||||
// GUILockNetwork returns the transport for the GUI single-instance lock.
|
||||
func GUILockNetwork() string { return "unix" }
|
||||
|
||||
// GUILockAddress returns the address for the GUI single-instance lock.
|
||||
func GUILockAddress() string { return Paths.Data + "/gui.sock" }
|
||||
|
||||
func init() {
|
||||
home, _ := os.UserHomeDir()
|
||||
recomputePaths(home)
|
||||
|
||||
@@ -20,6 +20,13 @@ const ipcPort = "18923"
|
||||
|
||||
func IPCAddress() string { return "127.0.0.1:" + ipcPort }
|
||||
|
||||
// GUILockNetwork returns the transport for the GUI single-instance lock.
|
||||
// Windows uses TCP (same reason as IPC: AF_UNIX integrity-level checks).
|
||||
func GUILockNetwork() string { return "tcp" }
|
||||
|
||||
// GUILockAddress returns the address for the GUI single-instance lock.
|
||||
func GUILockAddress() string { return "127.0.0.1:18924" }
|
||||
|
||||
func init() {
|
||||
home, _ := os.UserHomeDir()
|
||||
recomputePaths(home)
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// cidrmerge.go merges adjacent CIDR blocks to reduce the total number
|
||||
// of routes. For example, 1.0.1.0/24 + 1.0.2.0/23 -> 1.0.0.0/22.
|
||||
// This is critical for large lists like chnroute (8786 entries) where
|
||||
// many blocks can be merged, cutting the route count by 50-70%.
|
||||
package route
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// mergeCIDRs takes a list of CIDR strings, parses them, merges
|
||||
// adjacent blocks, and returns a deduplicated, minimized list.
|
||||
// Invalid CIDR strings are silently skipped.
|
||||
func mergeCIDRs(cidrs []string) []string {
|
||||
if len(cidrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var nets []netEntry
|
||||
for _, s := range cidrs {
|
||||
_, n, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ones, _ := n.Mask.Size()
|
||||
isV6 := n.IP.To4() == nil
|
||||
if isV6 {
|
||||
nets = append(nets, netEntry{ip: n.IP.To16(), bits: ones})
|
||||
} else {
|
||||
nets = append(nets, netEntry{ip: n.IP.To4(), bits: ones})
|
||||
}
|
||||
}
|
||||
|
||||
// Split into v4 and v6, merge separately.
|
||||
var v4nets, v6nets []netEntry
|
||||
for _, n := range nets {
|
||||
if n.ip.To4() != nil && len(n.ip) == 4 {
|
||||
v4nets = append(v4nets, n)
|
||||
} else if n.ip.To4() == nil && len(n.ip) == 16 {
|
||||
v6nets = append(v6nets, n)
|
||||
}
|
||||
}
|
||||
|
||||
mergedV4 := mergeNets(v4nets, 32)
|
||||
mergedV6 := mergeNets(v6nets, 128)
|
||||
|
||||
var result []string
|
||||
for _, n := range mergedV4 {
|
||||
result = append(result, n.ip.String()+"/"+itoa(n.bits))
|
||||
}
|
||||
for _, n := range mergedV6 {
|
||||
result = append(result, n.ip.String()+"/"+itoa(n.bits))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type netEntry struct {
|
||||
ip net.IP
|
||||
bits int
|
||||
}
|
||||
|
||||
type sortableNet struct {
|
||||
ip []byte
|
||||
bits int
|
||||
}
|
||||
|
||||
func mergeNets(nets []netEntry, maxBits int) []netEntry {
|
||||
if len(nets) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert to sortable form.
|
||||
var sn []sortableNet
|
||||
for _, n := range nets {
|
||||
sn = append(sn, sortableNet{ip: []byte(n.ip), bits: n.bits})
|
||||
}
|
||||
|
||||
// Sort by IP then by prefix length (more specific first).
|
||||
sort.Slice(sn, func(i, j int) bool {
|
||||
return cmpIP(sn[i].ip, sn[j].ip) < 0 ||
|
||||
(cmpIP(sn[i].ip, sn[j].ip) == 0 && sn[i].bits < sn[j].bits)
|
||||
})
|
||||
|
||||
// Merge: repeatedly try to combine pairs into supernets.
|
||||
// Two adjacent /n networks can merge into a /(n-1) if:
|
||||
// 1. Both have the same prefix length n
|
||||
// 2. Their network addresses differ only in bit n (i.e. one ends in 0, the other in 1 at position n)
|
||||
// 3. The merged address has bit n cleared
|
||||
changed := true
|
||||
for changed {
|
||||
changed = false
|
||||
var merged []sortableNet
|
||||
i := 0
|
||||
for i < len(sn) {
|
||||
if i+1 < len(sn) && canMerge(sn[i], sn[i+1], maxBits) {
|
||||
mergedNet := mergePair(sn[i], maxBits)
|
||||
merged = append(merged, mergedNet)
|
||||
i += 2
|
||||
changed = true
|
||||
} else {
|
||||
merged = append(merged, sn[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
sn = merged
|
||||
// Re-sort after merge (merged pairs may be out of order).
|
||||
if changed {
|
||||
sort.Slice(sn, func(i, j int) bool {
|
||||
return cmpIP(sn[i].ip, sn[j].ip) < 0 ||
|
||||
(cmpIP(sn[i].ip, sn[j].ip) == 0 && sn[i].bits < sn[j].bits)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Convert back.
|
||||
var result []netEntry
|
||||
for _, n := range sn {
|
||||
result = append(result, netEntry{ip: net.IP(n.ip), bits: n.bits})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func canMerge(a, b sortableNet, maxBits int) bool {
|
||||
if a.bits != b.bits || a.bits == 0 {
|
||||
return false
|
||||
}
|
||||
if len(a.ip) != len(b.ip) {
|
||||
return false
|
||||
}
|
||||
// Check that they share the same prefix up to bit (a.bits - 1).
|
||||
prefixBits := a.bits - 1
|
||||
byteIdx := prefixBits / 8
|
||||
bitIdx := uint(7 - (prefixBits % 8))
|
||||
|
||||
// All bytes before byteIdx must be equal.
|
||||
for k := 0; k < byteIdx; k++ {
|
||||
if a.ip[k] != b.ip[k] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// In byteIdx, bits above bitIdx must be equal.
|
||||
mask := byte(0xFF << (bitIdx + 1))
|
||||
if a.ip[byteIdx]&mask != b.ip[byteIdx]&mask {
|
||||
return false
|
||||
}
|
||||
// The bit at bitIdx must differ (one is 0, one is 1).
|
||||
bitA := (a.ip[byteIdx] >> bitIdx) & 1
|
||||
bitB := (b.ip[byteIdx] >> bitIdx) & 1
|
||||
if bitA == bitB {
|
||||
return false
|
||||
}
|
||||
// The lower bits of both must be zero (network address).
|
||||
lowerMask := byte(1<<bitIdx) - 1
|
||||
if a.ip[byteIdx]&lowerMask != 0 || b.ip[byteIdx]&lowerMask != 0 {
|
||||
return false
|
||||
}
|
||||
// All bytes after byteIdx must be zero.
|
||||
for k := byteIdx + 1; k < len(a.ip); k++ {
|
||||
if a.ip[k] != 0 || b.ip[k] != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func mergePair(a sortableNet, maxBits int) sortableNet {
|
||||
result := make([]byte, len(a.ip))
|
||||
copy(result, a.ip)
|
||||
// Clear the bit at position (a.bits - 1).
|
||||
prefixBits := a.bits - 1
|
||||
byteIdx := prefixBits / 8
|
||||
bitIdx := uint(7 - (prefixBits % 8))
|
||||
result[byteIdx] &^= 1 << bitIdx
|
||||
return sortableNet{ip: result, bits: a.bits - 1}
|
||||
}
|
||||
|
||||
func cmpIP(a, b []byte) int {
|
||||
for i := 0; i < len(a) && i < len(b); i++ {
|
||||
if a[i] < b[i] {
|
||||
return -1
|
||||
}
|
||||
if a[i] > b[i] {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return len(a) - len(b)
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
pos := len(buf)
|
||||
negative := n < 0
|
||||
if negative {
|
||||
n = -n
|
||||
}
|
||||
for n > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if negative {
|
||||
pos--
|
||||
buf[pos] = '-'
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
+351
-95
@@ -5,8 +5,18 @@
|
||||
// interface, with bypass routes for the server's
|
||||
// public IP (v4 and v6) so the WebSocket connection
|
||||
// stays on the physical NIC
|
||||
// - Split tunnel: only the VPN virtual subnet (v4 and v6) via TUN
|
||||
// - Custom: user-specified CIDRs via the TUN interface
|
||||
// - Proxy CIDR: only the specified CIDRs (v4 and v6) via TUN
|
||||
// - Bypass CIDR: all traffic via TUN except the specified CIDRs,
|
||||
// which are routed via the original gateway
|
||||
//
|
||||
// Routing is applied in two phases to avoid blocking the server's
|
||||
// ReadyTimeout:
|
||||
//
|
||||
// - Apply (essential): server bypass + /1 cover routes (4-6 commands,
|
||||
// <1s). Runs inside the handshake before "ready" is sent.
|
||||
// - ApplyDeferred: user CIDR routes (potentially thousands). Runs in
|
||||
// a background goroutine after the tunnel is up. Uses batch script
|
||||
// execution to minimize process creation overhead.
|
||||
//
|
||||
// IPv6 routes are applied automatically when the server assigned an
|
||||
// IPv6 address (Config.VPNIP6 != ""). All routes are tracked so they
|
||||
@@ -14,9 +24,14 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lmvpn/internal/log"
|
||||
)
|
||||
|
||||
// Mode selects which traffic goes through the VPN tunnel.
|
||||
@@ -24,8 +39,8 @@ type Mode string
|
||||
|
||||
const (
|
||||
ModeFull Mode = "full"
|
||||
ModeSplit Mode = "split"
|
||||
ModeCustom Mode = "custom"
|
||||
ModeProxy Mode = "proxy"
|
||||
ModeBypass Mode = "bypass"
|
||||
)
|
||||
|
||||
// Config describes the desired routing configuration.
|
||||
@@ -37,15 +52,20 @@ type Config struct {
|
||||
VPNIP6 string // assigned tunnel IPv6 (empty = v4-only)
|
||||
VPNPrefix6 int // IPv6 subnet prefix
|
||||
ServerHost string // server hostname/IP (for full-tunnel bypass)
|
||||
CustomCIDRs []string // for ModeCustom
|
||||
CIDRs []string // CIDR list for ModeProxy and ModeBypass
|
||||
}
|
||||
|
||||
// Manager applies and removes routes. It tracks all added routes so
|
||||
// they can be cleaned up deterministically.
|
||||
// they can be cleaned up deterministically. All methods are safe for
|
||||
// concurrent use (Apply/ApplyDeferred may run in one goroutine while
|
||||
// Cleanup runs in another).
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
cfg Config
|
||||
addedRoutes []string // v4 route specs added, for deletion
|
||||
addedRoutes6 []string // v6 route specs added, for deletion
|
||||
addedRoutes []string // v4 route specs added via TUN, for deletion
|
||||
addedRoutes6 []string // v6 route specs added via TUN, for deletion
|
||||
bypassRoutes []string // v4 bypass route specs added via gateway
|
||||
bypassRoutes6 []string // v6 bypass route specs added via gateway
|
||||
serverBypass bool
|
||||
serverBypass6 bool
|
||||
originalGateway string
|
||||
@@ -59,54 +79,226 @@ func NewManager(cfg Config) *Manager {
|
||||
return &Manager{cfg: cfg}
|
||||
}
|
||||
|
||||
// Apply adds routes according to the configured mode.
|
||||
// Apply adds essential routes that must be in place before the VPN
|
||||
// tunnel "ready" signal is sent. This completes in <1 second (4-6
|
||||
// route commands). User CIDR routes are deferred to ApplyDeferred.
|
||||
func (m *Manager) Apply() error {
|
||||
switch m.cfg.Mode {
|
||||
case ModeFull:
|
||||
return m.applyFull()
|
||||
case ModeSplit:
|
||||
return m.applySplit()
|
||||
case ModeCustom:
|
||||
return m.applyCustom()
|
||||
case ModeProxy:
|
||||
return m.applyProxyEssential()
|
||||
case ModeBypass:
|
||||
return m.applyBypassEssential()
|
||||
default:
|
||||
return fmt.Errorf("unknown routing mode: %s", m.cfg.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup removes all routes that were added by Apply.
|
||||
func (m *Manager) Cleanup() error {
|
||||
// ApplyDeferred adds user CIDR routes that were deferred from Apply.
|
||||
// This should be called in a background goroutine after the tunnel is
|
||||
// up. For full-tunnel mode this is a no-op. For proxy/bypass modes it
|
||||
// uses batch script execution to handle potentially thousands of CIDRs
|
||||
// efficiently.
|
||||
func (m *Manager) ApplyDeferred() error {
|
||||
switch m.cfg.Mode {
|
||||
case ModeFull:
|
||||
return nil
|
||||
case ModeProxy:
|
||||
return m.applyProxyDeferred()
|
||||
case ModeBypass:
|
||||
return m.applyBypassDeferred()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// AddRoutes dynamically adds routes for additional CIDRs after the
|
||||
// initial Apply/ApplyDeferred. This is used for CIDRs fetched from URLs
|
||||
// after the tunnel is established. Uses batch execution.
|
||||
func (m *Manager) AddRoutes(cidrs []string) error {
|
||||
if len(cidrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Merge CIDRs to reduce route count.
|
||||
merged := mergeCIDRs(cidrs)
|
||||
logRouteMerge(len(cidrs), len(merged))
|
||||
|
||||
// Split CIDRs by family.
|
||||
var v4, v6 []string
|
||||
for _, cidr := range merged {
|
||||
cidr = strings.TrimSpace(cidr)
|
||||
if cidr == "" {
|
||||
continue
|
||||
}
|
||||
if isIPv6CIDR(cidr) {
|
||||
v6 = append(v6, cidr)
|
||||
} else {
|
||||
v4 = append(v4, cidr)
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
var errs []string
|
||||
for _, r := range m.addedRoutes {
|
||||
if err := deleteRoute(r, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
|
||||
if m.cfg.Mode == ModeBypass {
|
||||
if len(v4) > 0 && m.originalGateway != "" {
|
||||
if err := addRoutesViaBatch(v4, m.originalGateway); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
m.bypassRoutes = append(m.bypassRoutes, v4...)
|
||||
}
|
||||
}
|
||||
if len(v6) > 0 && m.originalGateway6 != "" {
|
||||
if err := addRoutesVia6Batch(v6, m.originalGateway6); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
m.bypassRoutes6 = append(m.bypassRoutes6, v6...)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if len(v4) > 0 {
|
||||
if err := addRoutesBatch(v4, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
m.addedRoutes = append(m.addedRoutes, v4...)
|
||||
}
|
||||
}
|
||||
if len(v6) > 0 {
|
||||
if err := addRoutes6Batch(v6, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
m.addedRoutes6 = append(m.addedRoutes6, v6...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("add routes errors: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasOriginalGateway reports whether the manager captured an original
|
||||
// default gateway (v4 or v6) during Apply.
|
||||
func (m *Manager) HasOriginalGateway() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.originalGateway != "" || m.originalGateway6 != ""
|
||||
}
|
||||
|
||||
// OriginalGatewayV4 returns the captured IPv4 default gateway, if any.
|
||||
func (m *Manager) OriginalGatewayV4() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.originalGateway
|
||||
}
|
||||
|
||||
// OriginalGatewayV6 returns the captured IPv6 default gateway, if any.
|
||||
func (m *Manager) OriginalGatewayV6() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.originalGateway6
|
||||
}
|
||||
|
||||
// Cleanup removes all routes that were added by Apply, ApplyDeferred,
|
||||
// or AddRoutes. Safe to call concurrently with ApplyDeferred.
|
||||
func (m *Manager) Cleanup() error {
|
||||
m.mu.Lock()
|
||||
// Snapshot all route lists under the lock, then clear them.
|
||||
addedRoutes := m.addedRoutes
|
||||
addedRoutes6 := m.addedRoutes6
|
||||
bypassRoutes := m.bypassRoutes
|
||||
bypassRoutes6 := m.bypassRoutes6
|
||||
serverBypass := m.serverBypass
|
||||
serverBypass6 := m.serverBypass6
|
||||
originalGateway := m.originalGateway
|
||||
originalGateway6 := m.originalGateway6
|
||||
serverIP := m.serverIP
|
||||
serverIP6 := m.serverIP6
|
||||
m.addedRoutes = nil
|
||||
for _, r := range m.addedRoutes6 {
|
||||
if err := deleteRoute6(r, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
m.addedRoutes6 = nil
|
||||
if m.serverBypass {
|
||||
if err := m.deleteServerBypass(); err != nil {
|
||||
m.bypassRoutes = nil
|
||||
m.bypassRoutes6 = nil
|
||||
m.serverBypass = false
|
||||
m.serverBypass6 = false
|
||||
m.mu.Unlock()
|
||||
|
||||
var errs []string
|
||||
|
||||
// Delete user CIDR routes. Use batch for large lists.
|
||||
if len(addedRoutes) > 3 {
|
||||
if err := deleteRoutesBatch(addedRoutes, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
m.serverBypass = false
|
||||
} else {
|
||||
for _, r := range addedRoutes {
|
||||
if err := deleteRoute(r, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.serverBypass6 {
|
||||
if err := m.deleteServerBypass6(); err != nil {
|
||||
if len(addedRoutes6) > 3 {
|
||||
if err := deleteRoutes6Batch(addedRoutes6, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
m.serverBypass6 = false
|
||||
} else {
|
||||
for _, r := range addedRoutes6 {
|
||||
if err := deleteRoute6(r, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete bypass routes via gateway.
|
||||
if len(bypassRoutes) > 3 {
|
||||
if err := deleteRoutesViaBatch(bypassRoutes, originalGateway); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
} else {
|
||||
for _, r := range bypassRoutes {
|
||||
if err := deleteRouteVia(r, originalGateway); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(bypassRoutes6) > 3 {
|
||||
if err := deleteRoutesVia6Batch(bypassRoutes6, originalGateway6); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
} else {
|
||||
for _, r := range bypassRoutes6 {
|
||||
if err := deleteRouteVia6(r, originalGateway6); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete server bypass routes.
|
||||
if serverBypass && serverIP != "" {
|
||||
if err := deleteRouteVia(serverIP+"/32", originalGateway); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
if serverBypass6 && serverIP6 != "" && originalGateway6 != "" {
|
||||
if err := deleteRouteVia6(serverIP6+"/128", originalGateway6); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("cleanup errors: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) applyFull() error {
|
||||
// captureGatewaysAndBypass resolves the server host and adds bypass
|
||||
// routes for the server's public IPs via the original gateway. This
|
||||
// is shared by applyFull and applyBypassEssential.
|
||||
func (m *Manager) captureGatewaysAndBypass() error {
|
||||
// Capture the current default gateways before modifying routes.
|
||||
gw, err := defaultGateway()
|
||||
if err != nil {
|
||||
@@ -114,8 +306,7 @@ func (m *Manager) applyFull() error {
|
||||
}
|
||||
m.originalGateway = gw
|
||||
|
||||
// IPv6 default gateway is best-effort: it may be absent on v4-only
|
||||
// networks, in which case v6 bypass/routing is skipped.
|
||||
// IPv6 default gateway is best-effort.
|
||||
gw6, _ := defaultGateway6()
|
||||
m.originalGateway6 = gw6
|
||||
|
||||
@@ -127,8 +318,7 @@ func (m *Manager) applyFull() error {
|
||||
m.serverIP = v4
|
||||
m.serverIP6 = v6
|
||||
|
||||
// Bypass: server's public IPv4 via the original gateway (so the WS
|
||||
// connection doesn't loop through the tunnel).
|
||||
// Bypass: server's public IPv4 via the original gateway.
|
||||
if v4 != "" {
|
||||
bypassSpec := v4 + "/32"
|
||||
if err := addRouteVia(bypassSpec, gw); err != nil {
|
||||
@@ -138,7 +328,6 @@ func (m *Manager) applyFull() error {
|
||||
}
|
||||
|
||||
// Bypass: server's public IPv6 via the original v6 gateway.
|
||||
// Non-fatal: if this fails, full-tunnel routes are still added.
|
||||
if v6 != "" && gw6 != "" {
|
||||
bypassSpec := v6 + "/128"
|
||||
if err := addRouteVia6(bypassSpec, gw6); err != nil {
|
||||
@@ -147,10 +336,15 @@ func (m *Manager) applyFull() error {
|
||||
m.serverBypass6 = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Two /1 routes cover the entire IPv4 space and are more specific
|
||||
// than the default route (0.0.0.0/0), so they take precedence
|
||||
// without removing the original default.
|
||||
func (m *Manager) applyFull() error {
|
||||
if err := m.captureGatewaysAndBypass(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Two /1 routes cover the entire IPv4 space.
|
||||
for _, cidr := range []string{"0.0.0.0/1", "128.0.0.0/1"} {
|
||||
if err := addRoute(cidr, m.cfg.InterfaceName); err != nil {
|
||||
return fmt.Errorf("add route %s: %w", cidr, err)
|
||||
@@ -158,9 +352,7 @@ func (m *Manager) applyFull() error {
|
||||
m.addedRoutes = append(m.addedRoutes, cidr)
|
||||
}
|
||||
|
||||
// IPv6 full tunnel: ::/1 + 8000::/1 cover the entire IPv6 space,
|
||||
// more specific than ::/0. Only applied when the server assigned
|
||||
// an IPv6 address.
|
||||
// IPv6 full tunnel cover routes.
|
||||
if m.cfg.VPNIP6 != "" {
|
||||
for _, cidr := range []string{"::/1", "8000::/1"} {
|
||||
if err := addRoute6(cidr, m.cfg.InterfaceName); err != nil {
|
||||
@@ -172,76 +364,132 @@ func (m *Manager) applyFull() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) applySplit() error {
|
||||
subnet := vpnSubnet(m.cfg.VPNIP, m.cfg.VPNPrefix, false)
|
||||
if err := addRoute(subnet, m.cfg.InterfaceName); err != nil {
|
||||
return fmt.Errorf("add split route %s: %w", subnet, err)
|
||||
}
|
||||
m.addedRoutes = append(m.addedRoutes, subnet)
|
||||
|
||||
if m.cfg.VPNIP6 != "" {
|
||||
subnet6 := vpnSubnet(m.cfg.VPNIP6, m.cfg.VPNPrefix6, true)
|
||||
if err := addRoute6(subnet6, m.cfg.InterfaceName); err != nil {
|
||||
return fmt.Errorf("add split route6 %s: %w", subnet6, err)
|
||||
}
|
||||
m.addedRoutes6 = append(m.addedRoutes6, subnet6)
|
||||
}
|
||||
// applyProxyEssential does nothing - proxy mode has no essential
|
||||
// routes. All user CIDR routes are deferred.
|
||||
func (m *Manager) applyProxyEssential() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) applyCustom() error {
|
||||
for _, cidr := range m.cfg.CustomCIDRs {
|
||||
// applyProxyDeferred adds all user CIDR routes via the TUN interface
|
||||
// using batch execution. CIDRs are merged to minimize route count.
|
||||
func (m *Manager) applyProxyDeferred() error {
|
||||
merged := mergeCIDRs(m.cfg.CIDRs)
|
||||
logRouteMerge(len(m.cfg.CIDRs), len(merged))
|
||||
|
||||
var v4, v6 []string
|
||||
for _, cidr := range merged {
|
||||
cidr = strings.TrimSpace(cidr)
|
||||
if cidr == "" {
|
||||
continue
|
||||
}
|
||||
if isIPv6CIDR(cidr) {
|
||||
v6 = append(v6, cidr)
|
||||
} else {
|
||||
v4 = append(v4, cidr)
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
var errs []string
|
||||
if len(v4) > 0 {
|
||||
if err := addRoutesBatch(v4, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
m.addedRoutes = append(m.addedRoutes, v4...)
|
||||
}
|
||||
}
|
||||
if len(v6) > 0 {
|
||||
if err := addRoutes6Batch(v6, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
m.addedRoutes6 = append(m.addedRoutes6, v6...)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("proxy deferred: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyBypassEssential adds server bypass + /1 cover routes (full
|
||||
// tunnel effect). User bypass CIDRs are deferred to allow the "ready"
|
||||
// signal to be sent promptly.
|
||||
func (m *Manager) applyBypassEssential() error {
|
||||
if err := m.captureGatewaysAndBypass(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Two /1 routes cover the entire IPv4 space (full tunnel).
|
||||
for _, cidr := range []string{"0.0.0.0/1", "128.0.0.0/1"} {
|
||||
if err := addRoute(cidr, m.cfg.InterfaceName); err != nil {
|
||||
return fmt.Errorf("add route %s: %w", cidr, err)
|
||||
}
|
||||
m.addedRoutes = append(m.addedRoutes, cidr)
|
||||
}
|
||||
|
||||
// IPv6 full tunnel cover routes.
|
||||
if m.cfg.VPNIP6 != "" {
|
||||
for _, cidr := range []string{"::/1", "8000::/1"} {
|
||||
if err := addRoute6(cidr, m.cfg.InterfaceName); err != nil {
|
||||
return fmt.Errorf("add custom route6 %s: %w", cidr, err)
|
||||
return fmt.Errorf("add route6 %s: %w", cidr, err)
|
||||
}
|
||||
m.addedRoutes6 = append(m.addedRoutes6, cidr)
|
||||
} else {
|
||||
if err := addRoute(cidr, m.cfg.InterfaceName); err != nil {
|
||||
return fmt.Errorf("add custom route %s: %w", cidr, err)
|
||||
}
|
||||
m.addedRoutes = append(m.addedRoutes, cidr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) deleteServerBypass() error {
|
||||
if m.serverIP == "" {
|
||||
return nil
|
||||
}
|
||||
return deleteRouteVia(m.serverIP+"/32", m.originalGateway)
|
||||
}
|
||||
// applyBypassDeferred adds user bypass CIDR routes via the original
|
||||
// gateway using batch execution. CIDRs are merged to minimize route
|
||||
// count.
|
||||
func (m *Manager) applyBypassDeferred() error {
|
||||
merged := mergeCIDRs(m.cfg.CIDRs)
|
||||
logRouteMerge(len(m.cfg.CIDRs), len(merged))
|
||||
|
||||
func (m *Manager) deleteServerBypass6() error {
|
||||
if m.serverIP6 == "" || m.originalGateway6 == "" {
|
||||
return nil
|
||||
var v4, v6 []string
|
||||
for _, cidr := range merged {
|
||||
cidr = strings.TrimSpace(cidr)
|
||||
if cidr == "" {
|
||||
continue
|
||||
}
|
||||
if isIPv6CIDR(cidr) {
|
||||
if m.originalGateway6 != "" {
|
||||
v6 = append(v6, cidr)
|
||||
}
|
||||
} else {
|
||||
v4 = append(v4, cidr)
|
||||
}
|
||||
}
|
||||
return deleteRouteVia6(m.serverIP6+"/128", m.originalGateway6)
|
||||
}
|
||||
|
||||
// vpnSubnet computes the network CIDR from an IP and prefix.
|
||||
func vpnSubnet(ipStr string, prefix int, ipv6 bool) string {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return ipStr + "/" + fmt.Sprint(prefix)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
var errs []string
|
||||
if len(v4) > 0 {
|
||||
if err := addRoutesViaBatch(v4, m.originalGateway); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
m.bypassRoutes = append(m.bypassRoutes, v4...)
|
||||
}
|
||||
}
|
||||
bits := 32
|
||||
if ipv6 {
|
||||
bits = 128
|
||||
if len(v6) > 0 {
|
||||
if err := addRoutesVia6Batch(v6, m.originalGateway6); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
m.bypassRoutes6 = append(m.bypassRoutes6, v6...)
|
||||
}
|
||||
}
|
||||
mask := net.CIDRMask(prefix, bits)
|
||||
network := ip.Mask(mask)
|
||||
return fmt.Sprintf("%s/%d", network.String(), prefix)
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("bypass deferred: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveHosts resolves a hostname to its first IPv4 and IPv6 addresses.
|
||||
// If host is already an IP literal, it is returned directly. Either
|
||||
// result may be empty if no address of that family is available.
|
||||
// If host is already an IP literal, it is returned directly. The DNS
|
||||
// lookup is bounded to 5 seconds.
|
||||
func resolveHosts(host string) (v4, v6 string, err error) {
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if ip.To4() != nil {
|
||||
@@ -249,19 +497,20 @@ func resolveHosts(host string) (v4, v6 string, err error) {
|
||||
}
|
||||
return "", ip.String(), nil
|
||||
}
|
||||
// Strip port if present.
|
||||
if h, _, e := net.SplitHostPort(host); e == nil {
|
||||
host = h
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil || len(ips) == 0 {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil || len(addrs) == 0 {
|
||||
return "", "", fmt.Errorf("lookup %s: %w", host, err)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if v4 == "" && ip.To4() != nil {
|
||||
v4 = ip.String()
|
||||
} else if v6 == "" && ip.To4() == nil {
|
||||
v6 = ip.String()
|
||||
for _, addr := range addrs {
|
||||
if v4 == "" && addr.IP.To4() != nil {
|
||||
v4 = addr.IP.String()
|
||||
} else if v6 == "" && addr.IP.To4() == nil {
|
||||
v6 = addr.IP.String()
|
||||
}
|
||||
}
|
||||
if v4 == "" && v6 == "" {
|
||||
@@ -278,3 +527,10 @@ func isIPv6CIDR(cidr string) bool {
|
||||
}
|
||||
return ipNet.IP.To4() == nil
|
||||
}
|
||||
|
||||
// logRouteMerge logs CIDR merge statistics if any reduction occurred.
|
||||
func logRouteMerge(before, after int) {
|
||||
if before > after {
|
||||
log.L().Info("CIDR merge", "before", before, "after", after, "reduced", before-after)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,48 +4,50 @@ package route
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// addRoute adds a route via a network interface (macOS route command).
|
||||
//
|
||||
// route add -inet -net <cidr> -interface <iface>
|
||||
// route add -inet <cidr> -interface <iface>
|
||||
func addRoute(cidr, iface string) error {
|
||||
return runRoute("add", "-inet", "-net", cidr, "-interface", iface)
|
||||
return runRoute("add", "-inet", cidr, "-interface", iface)
|
||||
}
|
||||
|
||||
// deleteRoute removes a route via a network interface.
|
||||
func deleteRoute(cidr, iface string) error {
|
||||
return runRoute("delete", "-inet", "-net", cidr, "-interface", iface)
|
||||
return runRoute("delete", "-inet", cidr, "-interface", iface)
|
||||
}
|
||||
|
||||
// addRouteVia adds a route via a gateway IP.
|
||||
func addRouteVia(cidr, gateway string) error {
|
||||
return runRoute("add", "-inet", "-net", cidr, gateway)
|
||||
return runRoute("add", "-inet", cidr, gateway)
|
||||
}
|
||||
|
||||
// deleteRouteVia removes a route via a gateway IP.
|
||||
func deleteRouteVia(cidr, gateway string) error {
|
||||
return runRoute("delete", "-inet", "-net", cidr, gateway)
|
||||
return runRoute("delete", "-inet", cidr, gateway)
|
||||
}
|
||||
|
||||
// --- IPv6 variants ---
|
||||
|
||||
func addRoute6(cidr, iface string) error {
|
||||
return runRoute("add", "-inet6", "-net", cidr, "-interface", iface)
|
||||
return runRoute("add", "-inet6", cidr, "-interface", iface)
|
||||
}
|
||||
|
||||
func deleteRoute6(cidr, iface string) error {
|
||||
return runRoute("delete", "-inet6", "-net", cidr, "-interface", iface)
|
||||
return runRoute("delete", "-inet6", cidr, "-interface", iface)
|
||||
}
|
||||
|
||||
func addRouteVia6(cidr, gateway string) error {
|
||||
return runRoute("add", "-inet6", "-net", cidr, gateway)
|
||||
return runRoute("add", "-inet6", cidr, gateway)
|
||||
}
|
||||
|
||||
func deleteRouteVia6(cidr, gateway string) error {
|
||||
return runRoute("delete", "-inet6", "-net", cidr, gateway)
|
||||
return runRoute("delete", "-inet6", cidr, gateway)
|
||||
}
|
||||
|
||||
// defaultGateway returns the current IPv4 default gateway IP.
|
||||
@@ -89,3 +91,136 @@ func runRoute(args ...string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Batch functions ---
|
||||
|
||||
// runBatchScript writes commands to temporary shell scripts and
|
||||
// executes them in parallel (up to 4 concurrent scripts). Each line
|
||||
// uses "|| true" so that individual route failures don't abort the
|
||||
// batch.
|
||||
func runBatchScript(lines []string) error {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Split lines into up to 4 chunks for parallel execution.
|
||||
const maxChunks = 4
|
||||
chunkSize := (len(lines) + maxChunks - 1) / maxChunks
|
||||
var chunks [][]string
|
||||
for i := 0; i < len(lines); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(lines) {
|
||||
end = len(lines)
|
||||
}
|
||||
chunks = append(chunks, lines[i:end])
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(chunks))
|
||||
for i, chunk := range chunks {
|
||||
wg.Add(1)
|
||||
go func(idx int, c []string) {
|
||||
defer wg.Done()
|
||||
errs[idx] = runSingleScript(c)
|
||||
}(i, chunk)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var errStrs []string
|
||||
for _, e := range errs {
|
||||
if e != nil {
|
||||
errStrs = append(errStrs, e.Error())
|
||||
}
|
||||
}
|
||||
if len(errStrs) > 0 {
|
||||
return fmt.Errorf("batch script: %s", strings.Join(errStrs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSingleScript(lines []string) error {
|
||||
f, err := os.CreateTemp("", "lmvpn-routes-*.sh")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create batch script: %w", err)
|
||||
}
|
||||
tmpFile := f.Name()
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
for _, line := range lines {
|
||||
if _, err := fmt.Fprintln(f, line, "|| true"); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("write batch script: %w", err)
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
|
||||
cmd := exec.Command("sh", tmpFile)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("batch: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addRoutesBatch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route add -inet %s -interface %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesBatch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route delete -inet %s -interface %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutes6Batch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route add -inet6 %s -interface %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutes6Batch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route delete -inet6 %s -interface %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutesViaBatch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route add -inet %s %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesViaBatch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route delete -inet %s %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutesVia6Batch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route add -inet6 %s %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesVia6Batch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route delete -inet6 %s %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ package route
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func addRoute(cidr, iface string) error {
|
||||
@@ -75,3 +77,133 @@ func runCmd(name string, args ...string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Batch functions ---
|
||||
|
||||
// runBatchScript writes commands to temporary shell scripts and
|
||||
// executes them in parallel (up to 4 concurrent scripts).
|
||||
func runBatchScript(lines []string) error {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
const maxChunks = 4
|
||||
chunkSize := (len(lines) + maxChunks - 1) / maxChunks
|
||||
var chunks [][]string
|
||||
for i := 0; i < len(lines); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(lines) {
|
||||
end = len(lines)
|
||||
}
|
||||
chunks = append(chunks, lines[i:end])
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(chunks))
|
||||
for i, chunk := range chunks {
|
||||
wg.Add(1)
|
||||
go func(idx int, c []string) {
|
||||
defer wg.Done()
|
||||
errs[idx] = runSingleScript(c)
|
||||
}(i, chunk)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var errStrs []string
|
||||
for _, e := range errs {
|
||||
if e != nil {
|
||||
errStrs = append(errStrs, e.Error())
|
||||
}
|
||||
}
|
||||
if len(errStrs) > 0 {
|
||||
return fmt.Errorf("batch script: %s", strings.Join(errStrs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSingleScript(lines []string) error {
|
||||
f, err := os.CreateTemp("", "lmvpn-routes-*.sh")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create batch script: %w", err)
|
||||
}
|
||||
tmpFile := f.Name()
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
for _, line := range lines {
|
||||
if _, err := fmt.Fprintln(f, line, "|| true"); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("write batch script: %w", err)
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
|
||||
cmd := exec.Command("sh", tmpFile)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("batch: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addRoutesBatch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("ip route add %s dev %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesBatch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("ip route del %s dev %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutes6Batch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("ip -6 route add %s dev %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutes6Batch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("ip -6 route del %s dev %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutesViaBatch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("ip route add %s via %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesViaBatch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("ip route del %s via %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutesVia6Batch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("ip -6 route add %s via %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesVia6Batch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("ip -6 route del %s via %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ package route
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// cidrToNetworkMask splits a CIDR string into network address and
|
||||
@@ -171,3 +173,177 @@ func runCmd(name string, args ...string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Batch functions ---
|
||||
|
||||
// runBatchScript writes commands to temporary .bat files and executes
|
||||
// them in parallel (up to 4 concurrent scripts).
|
||||
func runBatchScript(lines []string) error {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
const maxChunks = 4
|
||||
chunkSize := (len(lines) + maxChunks - 1) / maxChunks
|
||||
var chunks [][]string
|
||||
for i := 0; i < len(lines); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(lines) {
|
||||
end = len(lines)
|
||||
}
|
||||
chunks = append(chunks, lines[i:end])
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(chunks))
|
||||
for i, chunk := range chunks {
|
||||
wg.Add(1)
|
||||
go func(idx int, c []string) {
|
||||
defer wg.Done()
|
||||
errs[idx] = runSingleScript(c)
|
||||
}(i, chunk)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var errStrs []string
|
||||
for _, e := range errs {
|
||||
if e != nil {
|
||||
errStrs = append(errStrs, e.Error())
|
||||
}
|
||||
}
|
||||
if len(errStrs) > 0 {
|
||||
return fmt.Errorf("batch script: %s", strings.Join(errStrs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSingleScript(lines []string) error {
|
||||
f, err := os.CreateTemp("", "lmvpn-routes-*.bat")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create batch script: %w", err)
|
||||
}
|
||||
tmpFile := f.Name()
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
for _, line := range lines {
|
||||
if _, err := fmt.Fprintln(f, line); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("write batch script: %w", err)
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
|
||||
cmd := exec.Command("cmd", "/c", tmpFile)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("batch: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addRoutesBatch(cidrs []string, iface string) error {
|
||||
idx, err := ifaceIndex(iface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"route add %s mask %s 0.0.0.0 if %d metric 1", network, mask, idx))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesBatch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("route delete %s mask %s", network, mask))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutes6Batch(cidrs []string, iface string) error {
|
||||
idx, err := ifaceIndex(iface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"route -6 add %s/%s :: if %d metric 1", network, prefix, idx))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutes6Batch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("route -6 delete %s/%s ::", network, prefix))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutesViaBatch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"route add %s mask %s %s metric 1", network, mask, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesViaBatch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("route delete %s mask %s %s", network, mask, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutesVia6Batch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"route -6 add %s/%s %s metric 1", network, prefix, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesVia6Batch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("route -6 delete %s/%s %s", network, prefix, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
+85
-11
@@ -26,14 +26,19 @@ const (
|
||||
// counters in Snapshot, so callers that only need the total still
|
||||
// work.
|
||||
type Stats struct {
|
||||
RxBytesV4 atomic.Int64
|
||||
RxBytesV6 atomic.Int64
|
||||
TxBytesV4 atomic.Int64
|
||||
TxBytesV6 atomic.Int64
|
||||
ConnectedAt atomic.Int64 // unix timestamp, 0 = not connected
|
||||
state atomic.Value // State
|
||||
assignedIP atomic.Value // string (IPv4)
|
||||
assignedIP6 atomic.Value // string (IPv6, may be empty)
|
||||
RxBytesV4 atomic.Int64
|
||||
RxBytesV6 atomic.Int64
|
||||
TxBytesV4 atomic.Int64
|
||||
TxBytesV6 atomic.Int64
|
||||
ConnectedAt atomic.Int64 // unix timestamp, 0 = not connected
|
||||
state atomic.Value // State
|
||||
assignedIP atomic.Value // string (IPv4)
|
||||
assignedIP6 atomic.Value // string (IPv6, may be empty)
|
||||
serverHost atomic.Value // string (server hostname or IP from URL)
|
||||
connectedIP atomic.Value // string (actual remote IP of the connection)
|
||||
routeLoading atomic.Bool // true while deferred routes are being applied
|
||||
connectStep atomic.Value // string (human-readable connection step)
|
||||
cidrError atomic.Value // string (CIDR fetch error message, empty = no error)
|
||||
}
|
||||
|
||||
// AddRx records a downloaded packet (WebSocket → TUN) of length n,
|
||||
@@ -76,6 +81,10 @@ func New() *Stats {
|
||||
s.state.Store(StateDisconnected)
|
||||
s.assignedIP.Store("")
|
||||
s.assignedIP6.Store("")
|
||||
s.serverHost.Store("")
|
||||
s.connectedIP.Store("")
|
||||
s.connectStep.Store("")
|
||||
s.cidrError.Store("")
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -85,12 +94,16 @@ func (s *Stats) SetState(st State) { s.state.Store(st) }
|
||||
// State returns the current state.
|
||||
func (s *Stats) State() State { return s.state.Load().(State) }
|
||||
|
||||
// SetConnected marks the session as connected, recording the time and
|
||||
// assigned IP addresses. ip6 may be empty for an IPv4-only server.
|
||||
func (s *Stats) SetConnected(ip, ip6 string) {
|
||||
// SetConnected marks the session as connected, recording the time,
|
||||
// assigned IP addresses, and server connection info. ip6 may be empty
|
||||
// for an IPv4-only server. serverHost is the hostname/IP from the
|
||||
// server URL; connectedIP is the actual remote IP of the connection.
|
||||
func (s *Stats) SetConnected(ip, ip6, serverHost, connectedIP string) {
|
||||
s.ConnectedAt.Store(time.Now().Unix())
|
||||
s.assignedIP.Store(ip)
|
||||
s.assignedIP6.Store(ip6)
|
||||
s.serverHost.Store(serverHost)
|
||||
s.connectedIP.Store(connectedIP)
|
||||
s.state.Store(StateConnected)
|
||||
}
|
||||
|
||||
@@ -99,7 +112,45 @@ func (s *Stats) SetDisconnected() {
|
||||
s.ConnectedAt.Store(0)
|
||||
s.assignedIP.Store("")
|
||||
s.assignedIP6.Store("")
|
||||
s.serverHost.Store("")
|
||||
s.connectedIP.Store("")
|
||||
s.state.Store(StateDisconnected)
|
||||
s.routeLoading.Store(false)
|
||||
s.connectStep.Store("")
|
||||
s.cidrError.Store("")
|
||||
}
|
||||
|
||||
// SetRouteLoading sets whether deferred routes are currently being
|
||||
// applied (e.g. thousands of CIDR bypass routes being added in the
|
||||
// background after the tunnel is up).
|
||||
func (s *Stats) SetRouteLoading(loading bool) { s.routeLoading.Store(loading) }
|
||||
|
||||
// RouteLoading returns whether deferred routes are being applied.
|
||||
func (s *Stats) RouteLoading() bool { return s.routeLoading.Load() }
|
||||
|
||||
// SetConnectStep sets a human-readable description of the current
|
||||
// connection step (e.g. "fetching CIDR lists"). Empty clears it.
|
||||
func (s *Stats) SetConnectStep(step string) { s.connectStep.Store(step) }
|
||||
|
||||
// ConnectStep returns the current connection step description.
|
||||
func (s *Stats) ConnectStep() string {
|
||||
v := s.connectStep.Load()
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return v.(string)
|
||||
}
|
||||
|
||||
// SetCIDRError sets a CIDR fetch error message (empty = no error).
|
||||
func (s *Stats) SetCIDRError(msg string) { s.cidrError.Store(msg) }
|
||||
|
||||
// CIDRError returns the current CIDR fetch error message.
|
||||
func (s *Stats) CIDRError() string {
|
||||
v := s.cidrError.Load()
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return v.(string)
|
||||
}
|
||||
|
||||
// AssignedIP returns the server-assigned tunnel IPv4.
|
||||
@@ -108,6 +159,12 @@ func (s *Stats) AssignedIP() string { return s.assignedIP.Load().(string) }
|
||||
// AssignedIP6 returns the server-assigned tunnel IPv6 (may be empty).
|
||||
func (s *Stats) AssignedIP6() string { return s.assignedIP6.Load().(string) }
|
||||
|
||||
// ServerHost returns the server hostname/IP from the connection URL.
|
||||
func (s *Stats) ServerHost() string { return s.serverHost.Load().(string) }
|
||||
|
||||
// ConnectedIP returns the actual remote IP of the connection.
|
||||
func (s *Stats) ConnectedIP() string { return s.connectedIP.Load().(string) }
|
||||
|
||||
// Snapshot returns a point-in-time copy of all counters.
|
||||
//
|
||||
// Per-family byte counters are read directly. The combined RxBytes/
|
||||
@@ -133,8 +190,20 @@ type Snapshot struct {
|
||||
ConnectedAt time.Time
|
||||
AssignedIP string
|
||||
AssignedIP6 string
|
||||
ServerHost string `json:"server_host,omitempty"` // server hostname/IP from URL
|
||||
ConnectedIP string `json:"connected_ip,omitempty"` // actual remote IP of the connection
|
||||
State State
|
||||
Uptime time.Duration
|
||||
|
||||
// Routing info (filled by SessionManager.reportStats).
|
||||
RoutingMode string `json:"routing_mode,omitempty"` // "full", "proxy", "bypass"
|
||||
CIDRV4Total int `json:"cidr_v4_total,omitempty"`
|
||||
CIDRV4Hits int `json:"cidr_v4_hits,omitempty"`
|
||||
CIDRV6Total int `json:"cidr_v6_total,omitempty"`
|
||||
CIDRV6Hits int `json:"cidr_v6_hits,omitempty"`
|
||||
RouteLoading bool `json:"route_loading,omitempty"` // deferred routes being applied
|
||||
ConnectStep string `json:"connect_step,omitempty"` // current connection step
|
||||
CIDRError string `json:"cidr_error,omitempty"` // CIDR fetch error message
|
||||
}
|
||||
|
||||
// Snapshot returns a point-in-time copy of the statistics.
|
||||
@@ -152,6 +221,8 @@ func (s *Stats) Snapshot() Snapshot {
|
||||
TxBytes: txv4 + txv6,
|
||||
AssignedIP: s.AssignedIP(),
|
||||
AssignedIP6: s.AssignedIP6(),
|
||||
ServerHost: s.ServerHost(),
|
||||
ConnectedIP: s.ConnectedIP(),
|
||||
State: s.State(),
|
||||
}
|
||||
ts := s.ConnectedAt.Load()
|
||||
@@ -159,5 +230,8 @@ func (s *Stats) Snapshot() Snapshot {
|
||||
snap.ConnectedAt = time.Unix(ts, 0)
|
||||
snap.Uptime = time.Since(snap.ConnectedAt)
|
||||
}
|
||||
snap.RouteLoading = s.routeLoading.Load()
|
||||
snap.ConnectStep = s.ConnectStep()
|
||||
snap.CIDRError = s.CIDRError()
|
||||
return snap
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// Package tlsconfig builds *tls.Config instances from user-supplied
|
||||
// verification settings (custom CA, insecure mode, certificate
|
||||
// pinning). It is shared by the WebSocket transport and the HTTP
|
||||
// login client so both paths enforce identical TLS policy.
|
||||
package tlsconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds TLS verification settings for a server connection.
|
||||
type Config struct {
|
||||
ServerName string // TLS SNI hostname (for CDN edge IP connections)
|
||||
CACertPEM string // inline CA certificate PEM content
|
||||
CACertPath string // path to a CA certificate file
|
||||
InsecureSkipVerify bool // skip chain verification entirely
|
||||
PinnedCertHash string // SHA-256 fingerprint of the leaf cert (hex, optional "sha256:" prefix)
|
||||
}
|
||||
|
||||
// Build creates a *tls.Config from the given settings.
|
||||
//
|
||||
// Verification behaviour matrix:
|
||||
//
|
||||
// Pin set | Insecure | Behaviour
|
||||
// --------+----------+-------------------------------------------
|
||||
// no | no | Normal chain + hostname verification (Go default)
|
||||
// no | yes | All verification skipped
|
||||
// yes | no | Normal chain verification, then pin check
|
||||
// yes | yes | Chain skipped, pin check only
|
||||
func Build(cfg Config) (*tls.Config, error) {
|
||||
tlsCfg := &tls.Config{
|
||||
ServerName: cfg.ServerName,
|
||||
}
|
||||
|
||||
if cfg.CACertPEM != "" || cfg.CACertPath != "" {
|
||||
pool, err := buildCertPool(cfg.CACertPEM, cfg.CACertPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load CA cert: %w", err)
|
||||
}
|
||||
tlsCfg.RootCAs = pool
|
||||
}
|
||||
|
||||
var pin []byte
|
||||
if cfg.PinnedCertHash != "" {
|
||||
var err error
|
||||
pin, err = decodePin(cfg.PinnedCertHash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid pinned cert hash: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if pin != nil {
|
||||
if cfg.InsecureSkipVerify {
|
||||
tlsCfg.InsecureSkipVerify = true
|
||||
}
|
||||
tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
||||
return verifyPin(rawCerts, pin)
|
||||
}
|
||||
} else if cfg.InsecureSkipVerify {
|
||||
tlsCfg.InsecureSkipVerify = true
|
||||
}
|
||||
|
||||
return tlsCfg, nil
|
||||
}
|
||||
|
||||
// PinMismatchError indicates the server leaf certificate does not
|
||||
// match the pinned SHA-256 fingerprint.
|
||||
type PinMismatchError struct {
|
||||
Expected []byte
|
||||
Got []byte
|
||||
}
|
||||
|
||||
func (e *PinMismatchError) Error() string {
|
||||
return fmt.Sprintf("certificate pin mismatch: expected %x, got %x", e.Expected, e.Got)
|
||||
}
|
||||
|
||||
// IsTLSError reports whether err represents a TLS certificate
|
||||
// verification failure that will not resolve on retry.
|
||||
func IsTLSError(err error) bool {
|
||||
var certInvalid *x509.CertificateInvalidError
|
||||
if errors.As(err, &certInvalid) {
|
||||
return true
|
||||
}
|
||||
var hostErr *x509.HostnameError
|
||||
if errors.As(err, &hostErr) {
|
||||
return true
|
||||
}
|
||||
var unknownAuth *x509.UnknownAuthorityError
|
||||
if errors.As(err, &unknownAuth) {
|
||||
return true
|
||||
}
|
||||
var verifyErr *tls.CertificateVerificationError
|
||||
if errors.As(err, &verifyErr) {
|
||||
return true
|
||||
}
|
||||
var pinErr *PinMismatchError
|
||||
if errors.As(err, &pinErr) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildCertPool(pemData, path string) (*x509.CertPool, error) {
|
||||
pool := x509.NewCertPool()
|
||||
|
||||
if pemData != "" {
|
||||
if !pool.AppendCertsFromPEM([]byte(pemData)) {
|
||||
return nil, errors.New("failed to parse inline CA certificate PEM")
|
||||
}
|
||||
}
|
||||
|
||||
if path != "" {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CA cert file %s: %w", path, err)
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(data) {
|
||||
return nil, errors.New("failed to parse CA certificate file")
|
||||
}
|
||||
}
|
||||
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func decodePin(s string) ([]byte, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "sha256:") {
|
||||
s = s[len("sha256:"):]
|
||||
}
|
||||
pin, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid hex: %w", err)
|
||||
}
|
||||
if len(pin) != sha256.Size {
|
||||
return nil, fmt.Errorf("expected %d bytes, got %d", sha256.Size, len(pin))
|
||||
}
|
||||
return pin, nil
|
||||
}
|
||||
|
||||
func verifyPin(rawCerts [][]byte, pin []byte) error {
|
||||
if len(rawCerts) == 0 {
|
||||
return errors.New("no server certificate provided")
|
||||
}
|
||||
h := sha256.Sum256(rawCerts[0])
|
||||
if !bytes.Equal(h[:], pin) {
|
||||
return &PinMismatchError{Expected: pin, Got: h[:]}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NewRaceDialer returns a dial function suitable for use as a
|
||||
// websocket.Dialer.NetDialContext or http.Transport.DialContext.
|
||||
//
|
||||
// When the host in addr is a domain name, it resolves all A/AAAA
|
||||
// records and dials them concurrently, returning the first successful
|
||||
// connection (true parallel racing, not Happy Eyeballs staggered start).
|
||||
//
|
||||
// preference controls which address families participate:
|
||||
// - "auto": race all resolved addresses (v4 + v6)
|
||||
// - "v4": only IPv4 addresses
|
||||
// - "v6": only IPv6 addresses
|
||||
//
|
||||
// When the host is an IP literal (CDN failover or direct IP mode), it
|
||||
// dials directly without racing — there is only one target.
|
||||
func NewRaceDialer(preference string) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("split host port: %w", err)
|
||||
}
|
||||
|
||||
// IP literal: dial directly, no racing.
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
d := &net.Dialer{}
|
||||
return d.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
// Domain name: resolve and race.
|
||||
ips, err := resolveAndFilter(ctx, host, preference)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(ips) == 1 {
|
||||
d := &net.Dialer{}
|
||||
return d.DialContext(ctx, network, net.JoinHostPort(ips[0], port))
|
||||
}
|
||||
|
||||
return raceDial(ctx, network, ips, port)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAndFilter resolves a hostname and filters by preference.
|
||||
func resolveAndFilter(ctx context.Context, host, preference string) ([]string, error) {
|
||||
resolveCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(resolveCtx, host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup %s: %w", host, err)
|
||||
}
|
||||
|
||||
var ips []string
|
||||
for _, a := range addrs {
|
||||
isV4 := a.IP.To4() != nil
|
||||
switch preference {
|
||||
case "v4":
|
||||
if isV4 {
|
||||
ips = append(ips, a.IP.String())
|
||||
}
|
||||
case "v6":
|
||||
if !isV4 {
|
||||
ips = append(ips, a.IP.String())
|
||||
}
|
||||
default: // "auto" or unspecified
|
||||
ips = append(ips, a.IP.String())
|
||||
}
|
||||
}
|
||||
|
||||
if len(ips) == 0 {
|
||||
return nil, fmt.Errorf("lookup %s: no addresses for preference %q", host, preference)
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// raceDial dials all IPs concurrently and returns the first successful
|
||||
// connection. Losing connections are closed and their errors discarded.
|
||||
func raceDial(ctx context.Context, network string, ips []string, port string) (net.Conn, error) {
|
||||
type result struct {
|
||||
conn net.Conn
|
||||
err error
|
||||
}
|
||||
|
||||
raceCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
resultCh := make(chan result, len(ips))
|
||||
|
||||
for _, ip := range ips {
|
||||
go func(target string) {
|
||||
d := &net.Dialer{}
|
||||
c, err := d.DialContext(raceCtx, network, net.JoinHostPort(target, port))
|
||||
// Always send the result: resultCh is buffered (len(ips)),
|
||||
// so this never blocks. Do NOT use a select with
|
||||
// <-raceCtx.Done() here - when the context is cancelled
|
||||
// both cases would be ready and Go's random select pick
|
||||
// could skip the send, causing the drain loop below to
|
||||
// block forever.
|
||||
resultCh <- result{conn: c, err: err}
|
||||
}(ip)
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
for i := 0; i < len(ips); i++ {
|
||||
r := <-resultCh
|
||||
if r.err == nil {
|
||||
// Winner. Cancel remaining dialers; drain and close
|
||||
// any late successful connections.
|
||||
cancel()
|
||||
for j := i + 1; j < len(ips); j++ {
|
||||
late := <-resultCh
|
||||
if late.conn != nil {
|
||||
late.conn.Close()
|
||||
}
|
||||
}
|
||||
return r.conn, nil
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = r.err
|
||||
}
|
||||
}
|
||||
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("all dial attempts failed")
|
||||
}
|
||||
return nil, firstErr
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestRaceDialNoDeadlock is a regression test for a select race
|
||||
// condition that caused raceDial to deadlock ~50% of the time when
|
||||
// multiple IPs were raced and the first succeeded. The bug was a
|
||||
// select with two simultaneously-ready cases (send result vs
|
||||
// <-raceCtx.Done()); Go's random selection could skip the send,
|
||||
// leaving the drain loop blocked forever.
|
||||
//
|
||||
// We run many iterations because the bug was probabilistic.
|
||||
func TestRaceDialNoDeadlock(t *testing.T) {
|
||||
const iterations = 200
|
||||
|
||||
for n := 0; n < iterations; n++ {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Accept both connections in background (both IPs dial the
|
||||
// same listener since raceDial uses a single port).
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
port := portOf(ln.Addr())
|
||||
ips := []string{"127.0.0.1", "127.0.0.1"}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
conn, err := raceDial(ctx, "tcp", ips, port)
|
||||
cancel()
|
||||
if err == nil && conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
ln.Close()
|
||||
|
||||
// If we get here without the 5s timeout, the iteration passed.
|
||||
// A deadlock would trigger the test-wide 60s timeout.
|
||||
}
|
||||
|
||||
// If we reach here, no iteration deadlocked.
|
||||
}
|
||||
|
||||
// TestRaceDialAllFail verifies that when all dials fail, raceDial
|
||||
// returns an error instead of blocking.
|
||||
func TestRaceDialAllFail(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Port 1: connection refused on most systems.
|
||||
ips := []string{"127.0.0.1", "127.0.0.1"}
|
||||
_, err := raceDial(ctx, "tcp", ips, "1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when all dials fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRaceDialSingleIP verifies the single-IP path still works.
|
||||
func TestRaceDialSingleIP(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c, _ := ln.Accept()
|
||||
if c != nil {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := raceDial(ctx, "tcp", []string{"127.0.0.1"}, portOf(ln.Addr()))
|
||||
if err != nil {
|
||||
t.Fatalf("raceDial single IP: %v", err)
|
||||
}
|
||||
conn.Close()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestRaceDialContextCancelled ensures raceDial returns promptly when
|
||||
// the parent context is cancelled while dials are in flight.
|
||||
func TestRaceDialContextCancelled(t *testing.T) {
|
||||
// Dial a non-routable address so the dial hangs until context
|
||||
// cancellation.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := raceDial(ctx, "tcp", []string{"10.255.255.1", "10.255.255.2"}, "80")
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("expected error on cancelled context")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("raceDial did not return after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
// portOf extracts the port from a net.Addr.
|
||||
func portOf(addr net.Addr) string {
|
||||
_, port, _ := net.SplitHostPort(addr.String())
|
||||
return port
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
@@ -30,12 +31,14 @@ import (
|
||||
|
||||
// HandshakeConfig configures a single connection attempt.
|
||||
type HandshakeConfig struct {
|
||||
ServerURL string // e.g. wss://vpn.example.com/ws
|
||||
SNIHost string // TLS SNI hostname for CDN edge connections
|
||||
Token string // JWT; if non-empty, used via ?token= (method A)
|
||||
Username string // for password auth (method B), or fallback
|
||||
Password string // for password auth (method B), or fallback
|
||||
OnInit func(protocol.InitMessage) error // configure TUN; nil = auto-ready
|
||||
ServerURL string // e.g. wss://vpn.example.com/ws
|
||||
SNIHost string // TLS SNI hostname for CDN edge connections
|
||||
Token string // JWT; if non-empty, used via ?token= (method A)
|
||||
Username string // for password auth (method B), or fallback
|
||||
Password string // for password auth (method B), or fallback
|
||||
OnInit func(protocol.InitMessage) error // configure TUN; nil = auto-ready
|
||||
TLSConfig *tls.Config // pre-built TLS config (nil = derive from SNIHost)
|
||||
IPPreference string // "auto" (default), "v4", "v6" - hostname mode only
|
||||
}
|
||||
|
||||
// Conn is an established VPN tunnel connection.
|
||||
@@ -52,12 +55,15 @@ type Conn struct {
|
||||
// error occurs.
|
||||
func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
|
||||
dialer := websocket.Dialer{
|
||||
NetDialContext: NewRaceDialer(cfg.IPPreference),
|
||||
HandshakeTimeout: 15 * time.Second,
|
||||
ReadBufferSize: 4096, // match server (handler.go:17)
|
||||
WriteBufferSize: 4096, // match server (handler.go:18)
|
||||
}
|
||||
|
||||
if cfg.SNIHost != "" {
|
||||
if cfg.TLSConfig != nil {
|
||||
dialer.TLSClientConfig = cfg.TLSConfig
|
||||
} else if cfg.SNIHost != "" {
|
||||
dialer.TLSClientConfig = &tls.Config{
|
||||
ServerName: cfg.SNIHost,
|
||||
}
|
||||
@@ -86,12 +92,26 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
|
||||
|
||||
ws.SetReadLimit(protocol.MaxMessageSize)
|
||||
|
||||
// Watch ctx and close the WS if it is cancelled. This unblocks all
|
||||
// ReadMessage/WriteMessage calls (they only use SetReadDeadline,
|
||||
// not ctx). The goroutine exits when connectDone is closed (on
|
||||
// either success or failure) to avoid leaking.
|
||||
connectDone := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ws.Close()
|
||||
case <-connectDone:
|
||||
}
|
||||
}()
|
||||
|
||||
conn := &Conn{ws: ws}
|
||||
|
||||
// Step 2: authenticate (only for password mode; JWT is validated
|
||||
// during the WS upgrade).
|
||||
if cfg.Token == "" {
|
||||
if err := conn.passwordAuth(cfg.Username, cfg.Password); err != nil {
|
||||
close(connectDone)
|
||||
ws.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -100,6 +120,7 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
|
||||
// Step 3: receive init (or error/auth_err).
|
||||
initMsg, err := conn.readInit()
|
||||
if err != nil {
|
||||
close(connectDone)
|
||||
ws.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -108,6 +129,7 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
|
||||
// Step 4: configure TUN via callback.
|
||||
if cfg.OnInit != nil {
|
||||
if err := cfg.OnInit(initMsg); err != nil {
|
||||
close(connectDone)
|
||||
ws.Close()
|
||||
return nil, fmt.Errorf("tun configure: %w", err)
|
||||
}
|
||||
@@ -115,10 +137,15 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
|
||||
|
||||
// Step 5: send ready.
|
||||
if err := conn.sendReady(); err != nil {
|
||||
close(connectDone)
|
||||
ws.Close()
|
||||
return nil, fmt.Errorf("send ready: %w", err)
|
||||
}
|
||||
|
||||
// Handshake complete - stop the ctx watcher so it doesn't close
|
||||
// the connection after we return it to the caller.
|
||||
close(connectDone)
|
||||
|
||||
// Step 6: set up keepalive — reset read deadline on each server
|
||||
// ping, and auto-respond with pong (allowed via WriteControl in
|
||||
// handler — see gorilla/websocket docs).
|
||||
@@ -245,6 +272,20 @@ func (c *Conn) AssignedIP() string { return c.init.IP }
|
||||
// AssignedIP6 returns the IPv6 assigned by the server (empty if none).
|
||||
func (c *Conn) AssignedIP6() string { return c.init.IP6 }
|
||||
|
||||
// RemoteIP returns the remote IP address of the underlying TCP
|
||||
// connection (the actual server/CDN IP the WebSocket is connected to).
|
||||
// Returns an empty string if the connection is not established.
|
||||
func (c *Conn) RemoteIP() string {
|
||||
if c.ws == nil {
|
||||
return ""
|
||||
}
|
||||
addr := c.ws.RemoteAddr()
|
||||
if tcpAddr, ok := addr.(*net.TCPAddr); ok {
|
||||
return tcpAddr.IP.String()
|
||||
}
|
||||
return addr.String()
|
||||
}
|
||||
|
||||
// Close terminates the connection.
|
||||
func (c *Conn) Close() error {
|
||||
c.mu.Lock()
|
||||
|
||||
@@ -58,8 +58,8 @@ func createTUN(name string) (Device, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *wintunDevice) Name() string { return d.name }
|
||||
func (d *wintunDevice) Close() error { d.session.End(); return d.adapter.Close() }
|
||||
func (d *wintunDevice) Name() string { return d.name }
|
||||
func (d *wintunDevice) Close() error { d.session.End(); return d.adapter.Close() }
|
||||
|
||||
func (d *wintunDevice) Read(p []byte) (int, error) {
|
||||
for {
|
||||
@@ -153,5 +153,3 @@ func execCmd(name string, arg ...string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
+129
-32
@@ -1,12 +1,14 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"lmvpn/internal/config"
|
||||
"lmvpn/internal/db"
|
||||
"lmvpn/internal/i18n"
|
||||
"lmvpn/internal/instance"
|
||||
"lmvpn/internal/ipc"
|
||||
"lmvpn/internal/keychain"
|
||||
"lmvpn/internal/log"
|
||||
@@ -33,26 +35,32 @@ type App struct {
|
||||
listSelectedIndex int
|
||||
|
||||
// UI widgets
|
||||
profileSelect *widget.Select
|
||||
stateLabel *widget.Label
|
||||
ipLabel *widget.Label
|
||||
ip6Label *widget.Label
|
||||
uptimeLabel *widget.Label
|
||||
rxV4Label *widget.Label
|
||||
txV4Label *widget.Label
|
||||
rxV6Label *widget.Label
|
||||
txV6Label *widget.Label
|
||||
rxTotalLabel *widget.Label
|
||||
txTotalLabel *widget.Label
|
||||
connectBtn *widget.Button
|
||||
disconnectBtn *widget.Button
|
||||
profileSelect *widget.Select
|
||||
stateLabel *widget.Label
|
||||
ipLabel *widget.Label
|
||||
ip6Label *widget.Label
|
||||
uptimeLabel *widget.Label
|
||||
rxV4Label *widget.Label
|
||||
txV4Label *widget.Label
|
||||
rxV6Label *widget.Label
|
||||
txV6Label *widget.Label
|
||||
rxTotalLabel *widget.Label
|
||||
txTotalLabel *widget.Label
|
||||
routingModeLabel *widget.Label
|
||||
cidrV4Label *widget.Label
|
||||
cidrV6Label *widget.Label
|
||||
refreshCIDRBtn *widget.Button
|
||||
connectBtn *widget.Button
|
||||
disconnectBtn *widget.Button
|
||||
|
||||
// State
|
||||
mu sync.Mutex
|
||||
ipcClient *ipc.Client
|
||||
profiles []model.ServerProfile
|
||||
currentProfile *model.ServerProfile
|
||||
langSetting string
|
||||
mu sync.Mutex
|
||||
ipcClient *ipc.Client
|
||||
profiles []model.ServerProfile
|
||||
currentProfile *model.ServerProfile
|
||||
defaultProfileID int64
|
||||
langSetting string
|
||||
windowHidden bool
|
||||
}
|
||||
|
||||
// Run initialises and starts the GUI application.
|
||||
@@ -65,6 +73,17 @@ func Run() {
|
||||
// Logging.
|
||||
log.Init(log.RoleGUI, paths.LogFile())
|
||||
|
||||
// Single-instance enforcement: the first instance holds the lock;
|
||||
// a second instance signals "focus" to the first and exits.
|
||||
focusCh, err := instance.Acquire()
|
||||
if err != nil {
|
||||
if errors.Is(err, instance.ErrAlreadyRunning) {
|
||||
log.L().Info("another instance is running, exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
log.L().Warn("instance lock failed, continuing", "error", err)
|
||||
}
|
||||
|
||||
// Database.
|
||||
store, err := db.Open()
|
||||
if err != nil {
|
||||
@@ -79,11 +98,15 @@ func Run() {
|
||||
log.L().Error("init i18n", "error", err)
|
||||
}
|
||||
|
||||
// Set the localized Touch ID prompt for keychain access (macOS).
|
||||
setTouchIDPromptFromI18n()
|
||||
|
||||
a := &App{
|
||||
fyneApp: app.NewWithID(paths.BundleID),
|
||||
db: store,
|
||||
kc: keychain.New(),
|
||||
langSetting: cfg.Language,
|
||||
defaultProfileID: cfg.DefaultProfileID,
|
||||
listSelectedIndex: -1,
|
||||
}
|
||||
|
||||
@@ -99,6 +122,44 @@ func Run() {
|
||||
// System tray.
|
||||
a.setupTray()
|
||||
|
||||
// Listen for "focus" signals from second instances and bring the
|
||||
// window to the front.
|
||||
if focusCh != nil {
|
||||
go func() {
|
||||
for range focusCh {
|
||||
fyne.Do(func() {
|
||||
a.windowHidden = false
|
||||
activateApp()
|
||||
a.window.Show()
|
||||
a.window.RequestFocus()
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Register a Cocoa handler so that re-opening the .app bundle
|
||||
// (which does NOT start a new process on macOS) brings the hidden
|
||||
// window back. On other platforms this is a no-op. This must be
|
||||
// deferred until after the Fyne/GLFW event loop has started,
|
||||
// because GLFWApplicationDelegate is not created until glfw.Init()
|
||||
// runs inside runGL().
|
||||
a.fyneApp.Lifecycle().SetOnStarted(func() {
|
||||
onAppActive = func() {
|
||||
if a.windowHidden {
|
||||
a.windowHidden = false
|
||||
activateApp()
|
||||
fyne.Do(func() {
|
||||
if a.windowHidden {
|
||||
return
|
||||
}
|
||||
a.window.Show()
|
||||
a.window.RequestFocus()
|
||||
})
|
||||
}
|
||||
}
|
||||
registerReopenHandler()
|
||||
})
|
||||
|
||||
// Auto-connect if configured.
|
||||
if cfg.AutoConnect && a.currentProfile != nil {
|
||||
go func() {
|
||||
@@ -109,7 +170,7 @@ func Run() {
|
||||
|
||||
a.window.SetCloseIntercept(func() {
|
||||
if cfg.CloseToTray {
|
||||
hideDockIcon()
|
||||
a.windowHidden = true
|
||||
a.window.Hide()
|
||||
} else {
|
||||
a.quit()
|
||||
@@ -144,8 +205,21 @@ func (a *App) loadProfiles() {
|
||||
names := a.profileNames()
|
||||
a.profileSelect.Options = names
|
||||
if len(names) > 0 {
|
||||
a.profileSelect.SetSelectedIndex(0)
|
||||
a.selectProfileByName(names[0])
|
||||
selected := false
|
||||
if a.defaultProfileID > 0 {
|
||||
for i := range a.profiles {
|
||||
if a.profiles[i].ID == a.defaultProfileID {
|
||||
a.profileSelect.SetSelectedIndex(i)
|
||||
a.selectProfileByName(a.profiles[i].Name)
|
||||
selected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !selected {
|
||||
a.profileSelect.SetSelectedIndex(0)
|
||||
a.selectProfileByName(names[0])
|
||||
}
|
||||
} else {
|
||||
a.currentProfile = nil
|
||||
a.profileSelect.SetSelected("")
|
||||
@@ -182,6 +256,26 @@ func (a *App) selectProfileByName(name string) {
|
||||
}
|
||||
}
|
||||
|
||||
// saveDefaultProfile persists the currently selected profile ID to the
|
||||
// config file so it can be restored on the next launch.
|
||||
func (a *App) saveDefaultProfile() {
|
||||
if a.currentProfile == nil {
|
||||
return
|
||||
}
|
||||
a.defaultProfileID = a.currentProfile.ID
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
cfg = config.Default()
|
||||
}
|
||||
if cfg.DefaultProfileID == a.currentProfile.ID {
|
||||
return
|
||||
}
|
||||
cfg.DefaultProfileID = a.currentProfile.ID
|
||||
if err := config.Save(cfg); err != nil {
|
||||
log.L().Error("save default profile", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// onAddProfile shows a dialog to create a new profile.
|
||||
func (a *App) onAddProfile() {
|
||||
a.showProfileDialog(nil)
|
||||
@@ -231,17 +325,17 @@ func (a *App) onResetDB() {
|
||||
// Reset UI state.
|
||||
a.currentProfile = nil
|
||||
a.loadProfiles()
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.ipLabel.SetText(i18n.T("IpNone"))
|
||||
a.ip6Label.SetText(i18n.T("Ip6None"))
|
||||
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
|
||||
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
|
||||
a.txV4Label.SetText(i18n.T("TxV4Zero"))
|
||||
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
|
||||
a.txV6Label.SetText(i18n.T("TxV6Zero"))
|
||||
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
|
||||
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
|
||||
a.setConnButtons(true, false)
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.ipLabel.SetText(i18n.T("IpNone"))
|
||||
a.ip6Label.SetText(i18n.T("Ip6None"))
|
||||
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
|
||||
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
|
||||
a.txV4Label.SetText(i18n.T("TxV4Zero"))
|
||||
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
|
||||
a.txV6Label.SetText(i18n.T("TxV6Zero"))
|
||||
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
|
||||
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
|
||||
a.setConnButtons(true, false)
|
||||
}, a.window).Show()
|
||||
}
|
||||
|
||||
@@ -264,6 +358,9 @@ func (a *App) changeLanguage(lang string) {
|
||||
// Switch the active localizer.
|
||||
i18n.SetLanguage(lang)
|
||||
|
||||
// Update the Touch ID prompt text for the new language.
|
||||
setTouchIDPromptFromI18n()
|
||||
|
||||
// Rebuild everything that holds cached strings.
|
||||
a.rebuildUI()
|
||||
}
|
||||
|
||||
@@ -86,5 +86,3 @@ func ensureDaemon() (*ipc.Client, error) {
|
||||
}
|
||||
return nil, fmt.Errorf("daemon did not become reachable (check %s)", logFile)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build darwin
|
||||
|
||||
package ui
|
||||
|
||||
import "C"
|
||||
|
||||
// onAppActive is called from the Cocoa main thread when the app
|
||||
// becomes active (e.g. the user re-opens the .app bundle while the
|
||||
// process is still running). It is set once in Run() before the
|
||||
// event loop starts, so no synchronisation is needed.
|
||||
var onAppActive func()
|
||||
|
||||
//export cmAppBecameActive
|
||||
func cmAppBecameActive() {
|
||||
if onAppActive != nil {
|
||||
onAppActive()
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,45 @@ package ui
|
||||
#include <objc/runtime.h>
|
||||
#include <objc/message.h>
|
||||
|
||||
static void setDockIconVisible(int visible) {
|
||||
// Forward declaration: Go callback (defined via //export in
|
||||
// dock_callback_darwin.go). Called when the user re-opens the .app
|
||||
// bundle while the process is still running (e.g. after closing the
|
||||
// window to tray).
|
||||
extern void cmAppBecameActive(void);
|
||||
|
||||
// appShouldHandleReopen is the IMP added to GLFWApplicationDelegate
|
||||
// for the applicationShouldHandleReopen:hasVisibleWindows: selector.
|
||||
// This is the delegate method macOS calls when the user re-opens an
|
||||
// already-running .app bundle. We return YES so macOS proceeds with
|
||||
// its default activation, and invoke the Go callback to show the
|
||||
// hidden window.
|
||||
static BOOL appShouldHandleReopen(id self, SEL cmd, id sender, BOOL flag) {
|
||||
cmAppBecameActive();
|
||||
return YES;
|
||||
}
|
||||
|
||||
static void cmActivateApp(void) {
|
||||
Class cls = objc_getClass("NSApplication");
|
||||
id app = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("sharedApplication"));
|
||||
((void (*)(id, SEL, long))objc_msgSend)(app, sel_getUid("setActivationPolicy:"), visible ? 0 : 1);
|
||||
((void (*)(id, SEL, BOOL))objc_msgSend)(app, sel_getUid("activateIgnoringOtherApps:"), YES);
|
||||
}
|
||||
|
||||
// cmRegisterReopenHandler adds applicationShouldHandleReopen:
|
||||
// hasVisibleWindows: to the GLFWApplicationDelegate class at runtime
|
||||
// (class_addMethod). This lets us detect when macOS re-opens the
|
||||
// .app bundle without starting a new process (which bypasses our IPC
|
||||
// single-instance mechanism). If the class already implements the
|
||||
// method, this is a no-op.
|
||||
static void cmRegisterReopenHandler(void) {
|
||||
Class cls = objc_getClass("GLFWApplicationDelegate");
|
||||
if (!cls) return;
|
||||
SEL sel = sel_getUid("applicationShouldHandleReopen:hasVisibleWindows:");
|
||||
if (class_getInstanceMethod(cls, sel)) return;
|
||||
class_addMethod(cls, sel, (IMP)appShouldHandleReopen, "B@:@B");
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
func showDockIcon() { C.setDockIconVisible(1) }
|
||||
func hideDockIcon() { C.setDockIconVisible(0) }
|
||||
func activateApp() { C.cmActivateApp() }
|
||||
|
||||
func registerReopenHandler() { C.cmRegisterReopenHandler() }
|
||||
|
||||
@@ -2,5 +2,8 @@
|
||||
|
||||
package ui
|
||||
|
||||
func showDockIcon() {}
|
||||
func hideDockIcon() {}
|
||||
var onAppActive func()
|
||||
|
||||
func activateApp() {}
|
||||
|
||||
func registerReopenHandler() {}
|
||||
|
||||
@@ -29,7 +29,7 @@ func launchElevated(exe, daemonBin, home string, uid, gid int) error {
|
||||
}
|
||||
|
||||
// shellQuote wraps a string in single quotes for shell safety.
|
||||
// Embedded single quotes are escaped using the '\'' pattern.
|
||||
// Embedded single quotes are escaped using the '\” pattern.
|
||||
func shellQuote(s string) string {
|
||||
result := "'"
|
||||
for _, r := range s {
|
||||
|
||||
+294
-26
@@ -1,8 +1,10 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"lmvpn/internal/i18n"
|
||||
"lmvpn/internal/model"
|
||||
@@ -18,9 +20,13 @@ import (
|
||||
// mapped back to the codes stored in the database.
|
||||
var (
|
||||
authCodes = []string{string(model.AuthModeBoth), string(model.AuthModeJWT), string(model.AuthModePassword)}
|
||||
routeCodes = []string{string(model.RoutingFull), string(model.RoutingSplit), string(model.RoutingCustom)}
|
||||
routeCodes = []string{string(model.RoutingFull), string(model.RoutingProxy), string(model.RoutingBypass)}
|
||||
|
||||
protoCodes = []string{"wss", "ws"}
|
||||
|
||||
fetchTimingCodes = []string{string(model.FetchBefore), string(model.FetchAfter)}
|
||||
|
||||
ipPrefCodes = []string{string(model.IPPrefAuto), string(model.IPPrefV4), string(model.IPPrefV6)}
|
||||
)
|
||||
|
||||
func authModeLabels() []string {
|
||||
@@ -28,13 +34,21 @@ func authModeLabels() []string {
|
||||
}
|
||||
|
||||
func routeModeLabels() []string {
|
||||
return []string{i18n.T("RoutingModeFull"), i18n.T("RoutingModeSplit"), i18n.T("RoutingModeCustom")}
|
||||
return []string{i18n.T("RoutingModeFull"), i18n.T("RoutingModeProxy"), i18n.T("RoutingModeBypass")}
|
||||
}
|
||||
|
||||
func protoLabels() []string {
|
||||
return []string{"wss", "ws"}
|
||||
}
|
||||
|
||||
func fetchTimingLabels() []string {
|
||||
return []string{i18n.T("FetchTimingBefore"), i18n.T("FetchTimingAfter")}
|
||||
}
|
||||
|
||||
func ipPrefLabels() []string {
|
||||
return []string{i18n.T("IPPrefAuto"), i18n.T("IPPrefV4"), i18n.T("IPPrefV6")}
|
||||
}
|
||||
|
||||
// codeIndex returns the position of code in codes, or 0 if not found.
|
||||
func codeIndex(codes []string, code string) int {
|
||||
for i, c := range codes {
|
||||
@@ -53,6 +67,100 @@ func selectedCode(codes []string, idx int) string {
|
||||
return codes[idx]
|
||||
}
|
||||
|
||||
// urlEntryRow holds the widgets for a single CIDR URL source row.
|
||||
type urlEntryRow struct {
|
||||
urlEntry *widget.Entry
|
||||
timingSelect *widget.Select
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
// cidrURLList manages a dynamic list of CIDR URL source rows.
|
||||
type cidrURLList struct {
|
||||
rows []*urlEntryRow
|
||||
container *fyne.Container
|
||||
parent fyne.Window
|
||||
}
|
||||
|
||||
func newCIDRURLList(parent fyne.Window) *cidrURLList {
|
||||
cl := &cidrURLList{
|
||||
container: container.NewVBox(),
|
||||
parent: parent,
|
||||
}
|
||||
return cl
|
||||
}
|
||||
|
||||
func (cl *cidrURLList) addRow(url string, timingIdx int) {
|
||||
urlEntry := widget.NewEntry()
|
||||
urlEntry.Wrapping = fyne.TextWrapOff
|
||||
urlEntry.Scroll = container.ScrollHorizontalOnly
|
||||
urlEntry.SetPlaceHolder(i18n.T("PlaceholderCIDRURL"))
|
||||
urlEntry.SetText(url)
|
||||
|
||||
timingSelect := widget.NewSelect(fetchTimingLabels(), nil)
|
||||
if timingIdx >= 0 && timingIdx < len(fetchTimingCodes) {
|
||||
timingSelect.SetSelectedIndex(timingIdx)
|
||||
} else {
|
||||
timingSelect.SetSelectedIndex(0)
|
||||
}
|
||||
|
||||
row := &urlEntryRow{
|
||||
urlEntry: urlEntry,
|
||||
timingSelect: timingSelect,
|
||||
}
|
||||
|
||||
removeBtn := widget.NewButton(i18n.T("BtnRemoveURL"), func() {
|
||||
cl.removeRow(row)
|
||||
})
|
||||
|
||||
row.container = container.NewBorder(nil, nil, nil, removeBtn,
|
||||
container.NewVBox(urlEntry, timingSelect))
|
||||
|
||||
cl.rows = append(cl.rows, row)
|
||||
cl.container.Add(row.container)
|
||||
}
|
||||
|
||||
func (cl *cidrURLList) removeRow(row *urlEntryRow) {
|
||||
for i, r := range cl.rows {
|
||||
if r == row {
|
||||
cl.rows = append(cl.rows[:i], cl.rows[i+1:]...)
|
||||
cl.container.Remove(row.container)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cl *cidrURLList) loadFromJSON(jsonStr string) {
|
||||
for _, r := range cl.rows {
|
||||
cl.container.Remove(r.container)
|
||||
}
|
||||
cl.rows = nil
|
||||
|
||||
sources := model.ParseCIDRURLs(jsonStr)
|
||||
for _, s := range sources {
|
||||
timingIdx := codeIndex(fetchTimingCodes, string(s.FetchTiming))
|
||||
cl.addRow(s.URL, timingIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func (cl *cidrURLList) toSources() []model.CIDRURLSource {
|
||||
var sources []model.CIDRURLSource
|
||||
for _, r := range cl.rows {
|
||||
url := strings.TrimSpace(r.urlEntry.Text)
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, model.CIDRURLSource{
|
||||
URL: url,
|
||||
FetchTiming: model.FetchTiming(selectedCode(fetchTimingCodes, r.timingSelect.SelectedIndex())),
|
||||
})
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func (cl *cidrURLList) toJSON() string {
|
||||
return marshalCIDRURLs(cl.toSources())
|
||||
}
|
||||
|
||||
// showProfileDialog opens a separate window for editing a server profile.
|
||||
// If editing is nil, a new profile is created. If a profile window is
|
||||
// already open, it is brought to the front instead of opening a second one.
|
||||
@@ -96,18 +204,97 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
|
||||
authSelect := widget.NewSelect(authModeLabels(), nil)
|
||||
routeSelect := widget.NewSelect(routeModeLabels(), nil)
|
||||
ipPrefSelect := widget.NewSelect(ipPrefLabels(), nil)
|
||||
|
||||
cidrEntry := widget.NewMultiLineEntry()
|
||||
cidrEntry.Wrapping = fyne.TextWrapOff
|
||||
cidrEntry.Scroll = container.ScrollNone
|
||||
cidrEntry.SetMinRowsVisible(4)
|
||||
cidrEntry.SetPlaceHolder(i18n.T("PlaceholderCIDRs"))
|
||||
cidrV4Entry := widget.NewMultiLineEntry()
|
||||
cidrV4Entry.Wrapping = fyne.TextWrapOff
|
||||
cidrV4Entry.Scroll = container.ScrollNone
|
||||
cidrV4Entry.SetMinRowsVisible(3)
|
||||
cidrV4Entry.SetPlaceHolder(i18n.T("PlaceholderCIDRV4"))
|
||||
|
||||
cidrV6Entry := widget.NewMultiLineEntry()
|
||||
cidrV6Entry.Wrapping = fyne.TextWrapOff
|
||||
cidrV6Entry.Scroll = container.ScrollNone
|
||||
cidrV6Entry.SetMinRowsVisible(3)
|
||||
cidrV6Entry.SetPlaceHolder(i18n.T("PlaceholderCIDRV6"))
|
||||
|
||||
mtuEntry := widget.NewEntry()
|
||||
mtuEntry.Wrapping = fyne.TextWrapOff
|
||||
mtuEntry.Scroll = container.ScrollNone
|
||||
mtuEntry.SetPlaceHolder(i18n.T("PlaceholderMTU"))
|
||||
|
||||
tlsCaPEMEntry := widget.NewMultiLineEntry()
|
||||
tlsCaPEMEntry.Wrapping = fyne.TextWrapOff
|
||||
tlsCaPEMEntry.Scroll = container.ScrollNone
|
||||
tlsCaPEMEntry.SetMinRowsVisible(4)
|
||||
tlsCaPEMEntry.SetPlaceHolder(i18n.T("PlaceholderTLSCACert"))
|
||||
|
||||
tlsCaPathEntry := widget.NewEntry()
|
||||
tlsCaPathEntry.Wrapping = fyne.TextWrapOff
|
||||
tlsCaPathEntry.Scroll = container.ScrollNone
|
||||
tlsCaPathEntry.SetPlaceHolder(i18n.T("PlaceholderTLSCAPath"))
|
||||
|
||||
tlsInsecureCheck := widget.NewCheck(i18n.T("FieldTLSInsecure"), nil)
|
||||
|
||||
tlsPinnedHashEntry := widget.NewEntry()
|
||||
tlsPinnedHashEntry.Wrapping = fyne.TextWrapOff
|
||||
tlsPinnedHashEntry.Scroll = container.ScrollNone
|
||||
tlsPinnedHashEntry.SetPlaceHolder(i18n.T("PlaceholderTLSPinnedHash"))
|
||||
|
||||
var profileWin fyne.Window
|
||||
|
||||
browseBtn := widget.NewButton(i18n.T("BtnBrowse"), func() {
|
||||
dialog.NewFileOpen(func(reader fyne.URIReadCloser, err error) {
|
||||
if err != nil || reader == nil {
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
tlsCaPathEntry.SetText(reader.URI().Path())
|
||||
}, profileWin).Show()
|
||||
})
|
||||
|
||||
setTLSEnabled := func(enabled bool) {
|
||||
if enabled {
|
||||
tlsCaPEMEntry.Enable()
|
||||
tlsCaPathEntry.Enable()
|
||||
browseBtn.Enable()
|
||||
tlsInsecureCheck.Enable()
|
||||
tlsPinnedHashEntry.Enable()
|
||||
} else {
|
||||
tlsCaPEMEntry.Disable()
|
||||
tlsCaPathEntry.Disable()
|
||||
browseBtn.Disable()
|
||||
tlsInsecureCheck.Disable()
|
||||
tlsPinnedHashEntry.Disable()
|
||||
}
|
||||
}
|
||||
|
||||
// CIDR URL lists for IPv4 and IPv6.
|
||||
v4URLList := newCIDRURLList(profileWin)
|
||||
v6URLList := newCIDRURLList(profileWin)
|
||||
|
||||
v4AddURLBtn := widget.NewButton(i18n.T("BtnAddURL"), func() {
|
||||
v4URLList.addRow("", 0)
|
||||
})
|
||||
v6AddURLBtn := widget.NewButton(i18n.T("BtnAddURL"), func() {
|
||||
v6URLList.addRow("", 0)
|
||||
})
|
||||
|
||||
// CIDR section visibility: hide when routing mode is "full".
|
||||
cidrV4EntryBox := container.NewVBox(widget.NewLabel(i18n.T("FieldCIDRV4")), cidrV4Entry)
|
||||
cidrV6EntryBox := container.NewVBox(widget.NewLabel(i18n.T("FieldCIDRV6")), cidrV6Entry)
|
||||
v4URLBox := container.NewVBox(widget.NewLabel(i18n.T("FieldCIDRV4URLs")), v4URLList.container, v4AddURLBtn)
|
||||
v6URLBox := container.NewVBox(widget.NewLabel(i18n.T("FieldCIDRV6URLs")), v6URLList.container, v6AddURLBtn)
|
||||
cidrSection := container.NewVBox(cidrV4EntryBox, cidrV6EntryBox, v4URLBox, v6URLBox)
|
||||
|
||||
setCIDRVisible := func(mode string) {
|
||||
if mode == string(model.RoutingFull) {
|
||||
cidrSection.Hide()
|
||||
} else {
|
||||
cidrSection.Show()
|
||||
}
|
||||
}
|
||||
|
||||
if !isNew {
|
||||
nameEntry.SetText(editing.Name)
|
||||
protoSelect.SetSelectedIndex(codeIndex(protoCodes, editing.Protocol))
|
||||
@@ -120,8 +307,16 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
userEntry.SetText(editing.Username)
|
||||
authSelect.SetSelectedIndex(codeIndex(authCodes, string(editing.AuthMode)))
|
||||
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(editing.RoutingMode)))
|
||||
cidrEntry.SetText(editing.CustomCIDRs)
|
||||
ipPrefSelect.SetSelectedIndex(codeIndex(ipPrefCodes, editing.IPPreference))
|
||||
cidrV4Entry.SetText(editing.CIDRV4)
|
||||
cidrV6Entry.SetText(editing.CIDRV6)
|
||||
v4URLList.loadFromJSON(editing.CIDRV4URLs)
|
||||
v6URLList.loadFromJSON(editing.CIDRV6URLs)
|
||||
mtuEntry.SetText(fmtInt(editing.MTUOverride))
|
||||
tlsCaPEMEntry.SetText(editing.TLSCACert)
|
||||
tlsCaPathEntry.SetText(editing.TLSCAPath)
|
||||
tlsInsecureCheck.SetChecked(editing.TLSInsecure)
|
||||
tlsPinnedHashEntry.SetText(editing.TLSPinnedHash)
|
||||
passEntry.SetPlaceHolder(i18n.T("PlaceholderPasswordUnchanged"))
|
||||
} else {
|
||||
protoSelect.SetSelectedIndex(0) // wss
|
||||
@@ -129,9 +324,20 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
pathEntry.SetText("/ws")
|
||||
authSelect.SetSelectedIndex(codeIndex(authCodes, string(model.AuthModeBoth)))
|
||||
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(model.RoutingFull)))
|
||||
ipPrefSelect.SetSelectedIndex(0) // auto
|
||||
mtuEntry.SetText("0")
|
||||
}
|
||||
|
||||
protoSelect.OnChanged = func(value string) {
|
||||
setTLSEnabled(value == "wss")
|
||||
}
|
||||
setTLSEnabled(selectedCode(protoCodes, protoSelect.SelectedIndex()) == "wss")
|
||||
|
||||
routeSelect.OnChanged = func(_ string) {
|
||||
setCIDRVisible(selectedCode(routeCodes, routeSelect.SelectedIndex()))
|
||||
}
|
||||
setCIDRVisible(selectedCode(routeCodes, routeSelect.SelectedIndex()))
|
||||
|
||||
form := container.NewVBox(
|
||||
widget.NewLabel(i18n.T("FieldName")),
|
||||
nameEntry,
|
||||
@@ -152,18 +358,34 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
container.NewVBox(widget.NewLabel(i18n.T("FieldPassword")), passEntry),
|
||||
),
|
||||
|
||||
container.NewGridWithColumns(2,
|
||||
container.NewGridWithColumns(3,
|
||||
container.NewVBox(widget.NewLabel(i18n.T("FieldAuthMode")), authSelect),
|
||||
container.NewVBox(widget.NewLabel(i18n.T("FieldRoutingMode")), routeSelect),
|
||||
container.NewVBox(widget.NewLabel(i18n.T("FieldIPPreference")), ipPrefSelect),
|
||||
),
|
||||
|
||||
widget.NewLabel(i18n.T("FieldCustomCIDRs")), cidrEntry,
|
||||
cidrSection,
|
||||
|
||||
widget.NewLabel(i18n.T("FieldMTUOverride")), mtuEntry,
|
||||
|
||||
widget.NewLabel(i18n.T("FieldTLS")),
|
||||
widget.NewLabel(i18n.T("FieldTLSCAPath")),
|
||||
container.NewBorder(nil, nil, nil, browseBtn, tlsCaPathEntry),
|
||||
widget.NewLabel(i18n.T("FieldTLSCACert")),
|
||||
tlsCaPEMEntry,
|
||||
widget.NewLabel(i18n.T("HintTLSCAReplacesSystem")),
|
||||
tlsInsecureCheck,
|
||||
widget.NewLabel(i18n.T("FieldTLSPinnedHash")),
|
||||
tlsPinnedHashEntry,
|
||||
)
|
||||
|
||||
profileWin := a.fyneApp.NewWindow(i18n.T("DlgProfileTitle"))
|
||||
profileWin = a.fyneApp.NewWindow(i18n.T("DlgProfileTitle"))
|
||||
a.profileWindow = profileWin
|
||||
|
||||
// Update parent references now that the window exists.
|
||||
v4URLList.parent = profileWin
|
||||
v6URLList.parent = profileWin
|
||||
|
||||
profileWin.SetOnClosed(func() {
|
||||
a.profileWindow = nil
|
||||
})
|
||||
@@ -177,7 +399,13 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
userEntry.Text, passEntry.Text,
|
||||
selectedCode(authCodes, authSelect.SelectedIndex()),
|
||||
selectedCode(routeCodes, routeSelect.SelectedIndex()),
|
||||
cidrEntry.Text, mtuEntry.Text, isNew) {
|
||||
cidrV4Entry.Text, cidrV6Entry.Text,
|
||||
v4URLList.toJSON(), v6URLList.toJSON(),
|
||||
mtuEntry.Text,
|
||||
tlsCaPEMEntry.Text, tlsCaPathEntry.Text,
|
||||
tlsPinnedHashEntry.Text, tlsInsecureCheck.Checked,
|
||||
isNew,
|
||||
selectedCode(ipPrefCodes, ipPrefSelect.SelectedIndex())) {
|
||||
profileWin.Close()
|
||||
}
|
||||
})
|
||||
@@ -188,35 +416,57 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
})
|
||||
|
||||
profileWin.SetContent(container.NewBorder(nil, container.NewHBox(saveBtn, cancelBtn), nil, nil, container.NewVScroll(form)))
|
||||
profileWin.Resize(fyne.NewSize(460, 560))
|
||||
profileWin.Resize(fyne.NewSize(460, 860))
|
||||
profileWin.Show()
|
||||
}
|
||||
|
||||
// saveProfile creates or updates a profile and stores credentials.
|
||||
// Returns true on success, false if validation or DB operation failed.
|
||||
func (a *App) saveProfile(editing *model.ServerProfile,
|
||||
name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode, cidrs, mtuStr string, isNew bool) bool {
|
||||
name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode,
|
||||
cidrV4, cidrV6, cidrV4URLs, cidrV6URLs, mtuStr,
|
||||
tlsCaPEM, tlsCaPath, tlsPinnedHash string, tlsInsecure, isNew bool,
|
||||
ipPreference string) bool {
|
||||
if name == "" || host == "" || user == "" {
|
||||
showError(i18n.T("DlgValidationTitle"), i18n.T("DlgValidationMsg"), a.window)
|
||||
return false
|
||||
}
|
||||
|
||||
if ips != "" {
|
||||
tmp := &model.ServerProfile{ServerIPs: ips}
|
||||
_, invalid := tmp.ValidateServerIPs()
|
||||
if len(invalid) > 0 {
|
||||
showError(i18n.T("DlgValidationTitle"),
|
||||
fmt.Sprintf(i18n.T("DlgInvalidIPMsg"), strings.Join(invalid, ", ")),
|
||||
a.window)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
port := parseIntDefault(portStr, 443)
|
||||
mtu := parseIntDefault(mtuStr, 0)
|
||||
|
||||
if isNew {
|
||||
p := &model.ServerProfile{
|
||||
Name: name,
|
||||
Protocol: protocol,
|
||||
Host: host,
|
||||
ServerIPs: ips,
|
||||
Port: port,
|
||||
Path: pathStr,
|
||||
Username: user,
|
||||
AuthMode: model.AuthMode(authMode),
|
||||
RoutingMode: model.RoutingMode(routeMode),
|
||||
CustomCIDRs: cidrs,
|
||||
MTUOverride: mtu,
|
||||
Name: name,
|
||||
Protocol: protocol,
|
||||
Host: host,
|
||||
ServerIPs: ips,
|
||||
Port: port,
|
||||
Path: pathStr,
|
||||
Username: user,
|
||||
AuthMode: model.AuthMode(authMode),
|
||||
RoutingMode: model.RoutingMode(routeMode),
|
||||
CIDRV4: cidrV4,
|
||||
CIDRV6: cidrV6,
|
||||
CIDRV4URLs: cidrV4URLs,
|
||||
CIDRV6URLs: cidrV6URLs,
|
||||
MTUOverride: mtu,
|
||||
TLSCACert: tlsCaPEM,
|
||||
TLSCAPath: tlsCaPath,
|
||||
TLSInsecure: tlsInsecure,
|
||||
TLSPinnedHash: tlsPinnedHash,
|
||||
IPPreference: ipPreference,
|
||||
}
|
||||
id, err := a.db.CreateProfile(p)
|
||||
if err != nil {
|
||||
@@ -240,8 +490,16 @@ func (a *App) saveProfile(editing *model.ServerProfile,
|
||||
editing.Username = user
|
||||
editing.AuthMode = model.AuthMode(authMode)
|
||||
editing.RoutingMode = model.RoutingMode(routeMode)
|
||||
editing.CustomCIDRs = cidrs
|
||||
editing.CIDRV4 = cidrV4
|
||||
editing.CIDRV6 = cidrV6
|
||||
editing.CIDRV4URLs = cidrV4URLs
|
||||
editing.CIDRV6URLs = cidrV6URLs
|
||||
editing.MTUOverride = mtu
|
||||
editing.TLSCACert = tlsCaPEM
|
||||
editing.TLSCAPath = tlsCaPath
|
||||
editing.TLSInsecure = tlsInsecure
|
||||
editing.TLSPinnedHash = tlsPinnedHash
|
||||
editing.IPPreference = ipPreference
|
||||
if err := a.db.UpdateProfile(editing); err != nil {
|
||||
showError(i18n.T("DlgSaveError"), err.Error(), a.window)
|
||||
return false
|
||||
@@ -258,6 +516,16 @@ func (a *App) saveProfile(editing *model.ServerProfile,
|
||||
return true
|
||||
}
|
||||
|
||||
// marshalCIDRURLsForDB is a helper for tests/debugging that encodes
|
||||
// CIDRURLSource slice to JSON.
|
||||
func marshalCIDRURLsForDB(sources []model.CIDRURLSource) string {
|
||||
data, err := json.Marshal(sources)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func fmtInt(n int) string {
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build darwin
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"lmvpn/internal/i18n"
|
||||
"lmvpn/internal/keychain"
|
||||
)
|
||||
|
||||
// setTouchIDPromptFromI18n configures the localized Touch ID prompt
|
||||
// text on the keychain store for the current language.
|
||||
func setTouchIDPromptFromI18n() {
|
||||
keychain.SetTouchIDPrompt(i18n.T("TouchIDPrompt"))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !darwin
|
||||
|
||||
package ui
|
||||
|
||||
// setTouchIDPromptFromI18n is a no-op on non-darwin platforms where
|
||||
// Touch ID is not available.
|
||||
func setTouchIDPromptFromI18n() {}
|
||||
+22
-21
@@ -46,28 +46,29 @@ func (a *App) setupTray() {
|
||||
autoItem, enItem, zhItem,
|
||||
)
|
||||
|
||||
connectItem := fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
|
||||
a.onConnect()
|
||||
})
|
||||
disconnectItem := fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() {
|
||||
a.onDisconnect()
|
||||
})
|
||||
if a.connectBtn != nil {
|
||||
connectItem.Disabled = a.connectBtn.Disabled()
|
||||
}
|
||||
if a.disconnectBtn != nil {
|
||||
disconnectItem.Disabled = a.disconnectBtn.Disabled()
|
||||
}
|
||||
connectItem := fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
|
||||
a.onConnect()
|
||||
})
|
||||
disconnectItem := fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() {
|
||||
a.onDisconnect()
|
||||
})
|
||||
if a.connectBtn != nil {
|
||||
connectItem.Disabled = a.connectBtn.Disabled()
|
||||
}
|
||||
if a.disconnectBtn != nil {
|
||||
disconnectItem.Disabled = a.disconnectBtn.Disabled()
|
||||
}
|
||||
|
||||
menu := fyne.NewMenu(i18n.T("WindowTitle"),
|
||||
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
|
||||
showDockIcon()
|
||||
a.window.Show()
|
||||
a.window.RequestFocus()
|
||||
}),
|
||||
fyne.NewMenuItemSeparator(),
|
||||
connectItem,
|
||||
disconnectItem,
|
||||
menu := fyne.NewMenu(i18n.T("WindowTitle"),
|
||||
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
|
||||
a.windowHidden = false
|
||||
activateApp()
|
||||
a.window.Show()
|
||||
a.window.RequestFocus()
|
||||
}),
|
||||
fyne.NewMenuItemSeparator(),
|
||||
connectItem,
|
||||
disconnectItem,
|
||||
fyne.NewMenuItemSeparator(),
|
||||
langItem,
|
||||
fyne.NewMenuItemSeparator(),
|
||||
|
||||
+270
-35
@@ -2,12 +2,16 @@ package ui
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"lmvpn/internal/i18n"
|
||||
"lmvpn/internal/ipc"
|
||||
"lmvpn/internal/keychain"
|
||||
"lmvpn/internal/model"
|
||||
"lmvpn/internal/stats"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
@@ -39,6 +43,13 @@ func (a *App) setConnButtons(connectEnabled, disconnectEnabled bool) {
|
||||
} else {
|
||||
a.disconnectBtn.Disable()
|
||||
}
|
||||
if a.profileSelect != nil {
|
||||
if connectEnabled {
|
||||
a.profileSelect.Enable()
|
||||
} else {
|
||||
a.profileSelect.Disable()
|
||||
}
|
||||
}
|
||||
a.setupTray()
|
||||
}
|
||||
|
||||
@@ -47,6 +58,7 @@ func (a *App) buildMainWindow() fyne.CanvasObject {
|
||||
// Profile selector.
|
||||
a.profileSelect = widget.NewSelect(a.profileNames(), func(sel string) {
|
||||
a.selectProfileByName(sel)
|
||||
a.saveDefaultProfile()
|
||||
})
|
||||
|
||||
// Status display.
|
||||
@@ -61,6 +73,15 @@ func (a *App) buildMainWindow() fyne.CanvasObject {
|
||||
a.txV6Label = widget.NewLabel(i18n.T("TxV6Zero"))
|
||||
a.rxTotalLabel = widget.NewLabel(i18n.T("RxTotalZero"))
|
||||
a.txTotalLabel = widget.NewLabel(i18n.T("TxTotalZero"))
|
||||
a.routingModeLabel = widget.NewLabel("")
|
||||
a.routingModeLabel.Hide()
|
||||
a.cidrV4Label = widget.NewLabel("")
|
||||
a.cidrV4Label.Hide()
|
||||
a.cidrV6Label = widget.NewLabel("")
|
||||
a.cidrV6Label.Hide()
|
||||
|
||||
a.refreshCIDRBtn = widget.NewButton(i18n.T("BtnRefreshCIDR"), a.onRefreshCIDR)
|
||||
a.refreshCIDRBtn.Hide()
|
||||
|
||||
statusCard := widget.NewCard(i18n.T("StatusLabel"), "", container.NewVBox(
|
||||
a.stateLabel,
|
||||
@@ -70,6 +91,10 @@ func (a *App) buildMainWindow() fyne.CanvasObject {
|
||||
container.NewHBox(a.rxV4Label, a.txV4Label),
|
||||
container.NewHBox(a.rxV6Label, a.txV6Label),
|
||||
container.NewHBox(a.rxTotalLabel, a.txTotalLabel),
|
||||
a.routingModeLabel,
|
||||
a.cidrV4Label,
|
||||
a.cidrV6Label,
|
||||
a.refreshCIDRBtn,
|
||||
))
|
||||
|
||||
// Buttons.
|
||||
@@ -132,18 +157,34 @@ func (a *App) onConnect() {
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
oldClient := a.ipcClient
|
||||
a.ipcClient = client
|
||||
a.mu.Unlock()
|
||||
if oldClient != nil {
|
||||
// Do NOT send SendStop on the old connection. The daemon's
|
||||
// startSession already calls stopSessionLocked() which
|
||||
// tears down any existing session. Sending SendStop here
|
||||
// races with SendStart on the new connection - the daemon
|
||||
// processes each IPC connection in a separate goroutine,
|
||||
// so a late SendStop could kill the newly started session.
|
||||
oldClient.Close()
|
||||
}
|
||||
|
||||
// Get password from keychain.
|
||||
password, err := a.kc.GetPassword(a.currentProfile.Name)
|
||||
if err != nil {
|
||||
fyne.Do(func() {
|
||||
showError(i18n.T("DlgCredentialError"),
|
||||
i18n.T("DlgCredentialErrorMsg"),
|
||||
a.window)
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.setConnButtons(true, false)
|
||||
if errors.Is(err, keychain.ErrUserCanceled) {
|
||||
// User canceled the Touch ID / password prompt.
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.setConnButtons(true, false)
|
||||
} else {
|
||||
showError(i18n.T("DlgCredentialError"),
|
||||
i18n.T("DlgCredentialErrorMsg"),
|
||||
a.window)
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.setConnButtons(true, false)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -158,15 +199,23 @@ func (a *App) onConnect() {
|
||||
|
||||
// Build and send the start command.
|
||||
cfg := ipc.ClientConfig{
|
||||
ServerURL: serverURL,
|
||||
SNIHost: sniHost,
|
||||
ServerIPs: serverIPs,
|
||||
Username: p.Username,
|
||||
Password: password,
|
||||
AuthMode: string(p.AuthMode),
|
||||
RoutingMode: string(p.RoutingMode),
|
||||
CustomCIDRs: splitCIDRs(p.CustomCIDRs),
|
||||
MTUOverride: p.MTUOverride,
|
||||
ServerURL: serverURL,
|
||||
SNIHost: sniHost,
|
||||
ServerIPs: serverIPs,
|
||||
Username: p.Username,
|
||||
Password: password,
|
||||
AuthMode: string(p.AuthMode),
|
||||
RoutingMode: string(p.RoutingMode),
|
||||
CIDRV4: model.SplitCIDRs(p.CIDRV4),
|
||||
CIDRV6: model.SplitCIDRs(p.CIDRV6),
|
||||
CIDRV4URLs: parseIPCCIDRURLs(p.CIDRV4URLs),
|
||||
CIDRV6URLs: parseIPCCIDRURLs(p.CIDRV6URLs),
|
||||
MTUOverride: p.MTUOverride,
|
||||
TLSCACert: p.TLSCACert,
|
||||
TLSCAPath: p.TLSCAPath,
|
||||
TLSInsecure: p.TLSInsecure,
|
||||
TLSPinnedHash: p.TLSPinnedHash,
|
||||
IPPreference: p.IPPreference,
|
||||
}
|
||||
if err := ipc.SendStart(client, cfg); err != nil {
|
||||
fyne.Do(func() {
|
||||
@@ -182,17 +231,50 @@ func (a *App) onConnect() {
|
||||
}()
|
||||
}
|
||||
|
||||
// onDisconnect handles the Disconnect button click.
|
||||
func (a *App) onDisconnect() {
|
||||
// onRefreshCIDR handles the Refresh CIDR button click.
|
||||
func (a *App) onRefreshCIDR() {
|
||||
a.mu.Lock()
|
||||
client := a.ipcClient
|
||||
a.mu.Unlock()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
a.refreshCIDRBtn.Disable()
|
||||
go func() {
|
||||
_ = ipc.SendRefreshCIDR(client)
|
||||
time.Sleep(2 * time.Second)
|
||||
fyne.Do(func() {
|
||||
a.refreshCIDRBtn.Enable()
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// onDisconnect handles the Disconnect button click.
|
||||
func (a *App) onDisconnect() {
|
||||
a.mu.Lock()
|
||||
client := a.ipcClient
|
||||
a.ipcClient = nil
|
||||
a.mu.Unlock()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
_ = ipc.SendStop(client)
|
||||
client.Close()
|
||||
a.setConnButtons(true, false)
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.ipLabel.SetText(i18n.T("IpNone"))
|
||||
a.ip6Label.SetText(i18n.T("Ip6None"))
|
||||
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
|
||||
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
|
||||
a.txV4Label.SetText(i18n.T("TxV4Zero"))
|
||||
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
|
||||
a.txV6Label.SetText(i18n.T("TxV6Zero"))
|
||||
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
|
||||
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
|
||||
a.routingModeLabel.Hide()
|
||||
a.cidrV4Label.Hide()
|
||||
a.cidrV6Label.Hide()
|
||||
a.refreshCIDRBtn.Hide()
|
||||
}
|
||||
|
||||
// eventLoop reads IPC events from the daemon and updates the UI.
|
||||
@@ -222,6 +304,10 @@ func (a *App) eventLoop() {
|
||||
a.txV6Label.SetText(i18n.T("TxV6Zero"))
|
||||
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
|
||||
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
|
||||
a.routingModeLabel.Hide()
|
||||
a.cidrV4Label.Hide()
|
||||
a.cidrV6Label.Hide()
|
||||
a.refreshCIDRBtn.Hide()
|
||||
a.setConnButtons(true, false)
|
||||
}
|
||||
})
|
||||
@@ -230,20 +316,41 @@ func (a *App) eventLoop() {
|
||||
switch ev.Event {
|
||||
case ipc.EvState:
|
||||
fyne.Do(func() {
|
||||
a.applyState(ev.State)
|
||||
a.mu.Lock()
|
||||
current := a.ipcClient
|
||||
a.mu.Unlock()
|
||||
if current == client {
|
||||
a.applyStateWithStep(ev.State, ev.ConnectStep)
|
||||
}
|
||||
})
|
||||
case ipc.EvStats:
|
||||
if ev.Stats != nil {
|
||||
s := *ev.Stats
|
||||
fyne.Do(func() {
|
||||
a.applyStats(s)
|
||||
a.mu.Lock()
|
||||
current := a.ipcClient
|
||||
a.mu.Unlock()
|
||||
if current == client {
|
||||
a.applyStats(s)
|
||||
}
|
||||
})
|
||||
}
|
||||
case ipc.EvError:
|
||||
fyne.Do(func() {
|
||||
msg := authErrorMessage(ev.Code, ev.Message)
|
||||
if msg != "" {
|
||||
showError(i18n.T("DlgAuthError"), msg, a.window)
|
||||
a.mu.Lock()
|
||||
current := a.ipcClient
|
||||
a.mu.Unlock()
|
||||
if current != client {
|
||||
return
|
||||
}
|
||||
if ev.Code == "tls_error" {
|
||||
showError(i18n.T("DlgTLSError"),
|
||||
i18n.T("TLSErrorVerification")+"\n\n"+ev.Message, a.window)
|
||||
} else {
|
||||
msg := authErrorMessage(ev.Code, ev.Message)
|
||||
if msg != "" {
|
||||
showError(i18n.T("DlgAuthError"), msg, a.window)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -272,12 +379,27 @@ func authErrorMessage(code, fallback string) string {
|
||||
|
||||
// applyState updates UI elements for a state change.
|
||||
func (a *App) applyState(state string) {
|
||||
a.applyStateWithStep(state, "")
|
||||
}
|
||||
|
||||
// applyStateWithStep updates UI elements for a state change, optionally
|
||||
// showing a connection step description (e.g. "fetching CIDR lists").
|
||||
func (a *App) applyStateWithStep(state, step string) {
|
||||
stepLabel := connectStepLabel(step)
|
||||
switch stats.State(state) {
|
||||
case stats.StateConnected:
|
||||
a.stateLabel.SetText(i18n.T("StateConnected"))
|
||||
if stepLabel != "" {
|
||||
a.stateLabel.SetText(i18n.T("StateConnected") + " (" + stepLabel + ")")
|
||||
} else {
|
||||
a.stateLabel.SetText(i18n.T("StateConnected"))
|
||||
}
|
||||
a.setConnButtons(false, true)
|
||||
case stats.StateConnecting:
|
||||
a.stateLabel.SetText(i18n.T("StateConnecting"))
|
||||
if stepLabel != "" {
|
||||
a.stateLabel.SetText(i18n.T("StateConnecting") + " (" + stepLabel + ")")
|
||||
} else {
|
||||
a.stateLabel.SetText(i18n.T("StateConnecting"))
|
||||
}
|
||||
a.setConnButtons(false, true)
|
||||
case stats.StateReconnecting:
|
||||
a.stateLabel.SetText(i18n.T("StateReconnecting"))
|
||||
@@ -304,7 +426,15 @@ func (a *App) applyStats(s stats.Snapshot) {
|
||||
a.ip6Label.SetText(i18n.T("Ip6None"))
|
||||
}
|
||||
if s.State == stats.StateConnected {
|
||||
a.stateLabel.SetText(i18n.T("StateConnected"))
|
||||
stepLabel := connectStepLabel(s.ConnectStep)
|
||||
if stepLabel != "" {
|
||||
a.stateLabel.SetText(i18n.T("StateConnected") + " (" + stepLabel + ")")
|
||||
} else if s.ServerHost != "" && s.ConnectedIP != "" {
|
||||
a.stateLabel.SetText(fmt.Sprintf("%s (%s -> %s)",
|
||||
i18n.T("StateConnected"), s.ServerHost, s.ConnectedIP))
|
||||
} else {
|
||||
a.stateLabel.SetText(i18n.T("StateConnected"))
|
||||
}
|
||||
}
|
||||
a.rxV4Label.SetText(i18n.T("RxV4Label", map[string]interface{}{
|
||||
"bytes": formatBytes(s.RxBytesV4), "speed": formatSpeed(s.RxSpeedV4),
|
||||
@@ -327,6 +457,99 @@ func (a *App) applyStats(s stats.Snapshot) {
|
||||
if s.Uptime > 0 {
|
||||
a.uptimeLabel.SetText(i18n.T("UptimeLabel", map[string]interface{}{"uptime": formatDuration(s.Uptime)}))
|
||||
}
|
||||
|
||||
// Routing mode + CIDR hit statistics.
|
||||
if s.RoutingMode != "" {
|
||||
modeLabel := routeModeLabel(s.RoutingMode)
|
||||
if s.RouteLoading {
|
||||
modeLabel += " (" + i18n.T("RouteLoading") + ")"
|
||||
}
|
||||
a.routingModeLabel.SetText(i18n.T("StatusRoutingMode", map[string]interface{}{"mode": modeLabel}))
|
||||
a.routingModeLabel.Show()
|
||||
|
||||
// Show CIDR error if present.
|
||||
if s.CIDRError != "" {
|
||||
a.cidrV4Label.SetText("IPv4 CIDR: " + i18n.T("CIDRFetchError"))
|
||||
a.cidrV6Label.SetText("IPv6 CIDR: " + i18n.T("CIDRFetchError"))
|
||||
a.cidrV4Label.Show()
|
||||
a.cidrV6Label.Show()
|
||||
a.refreshCIDRBtn.Show()
|
||||
} else {
|
||||
switch s.RoutingMode {
|
||||
case "proxy":
|
||||
if s.RouteLoading {
|
||||
a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d/%d (%s)",
|
||||
s.CIDRV4Hits, s.CIDRV4Total, i18n.T("CIDRLoading")))
|
||||
a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d/%d (%s)",
|
||||
s.CIDRV6Hits, s.CIDRV6Total, i18n.T("CIDRLoading")))
|
||||
} else {
|
||||
a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d/%d %s",
|
||||
s.CIDRV4Hits, s.CIDRV4Total, i18n.T("CIDRHit")))
|
||||
a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d/%d %s",
|
||||
s.CIDRV6Hits, s.CIDRV6Total, i18n.T("CIDRHit")))
|
||||
}
|
||||
a.cidrV4Label.Show()
|
||||
a.cidrV6Label.Show()
|
||||
a.refreshCIDRBtn.Show()
|
||||
case "bypass":
|
||||
if s.RouteLoading {
|
||||
a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d/%d (%s)",
|
||||
s.CIDRV4Hits, s.CIDRV4Total, i18n.T("CIDRLoading")))
|
||||
a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d/%d (%s)",
|
||||
s.CIDRV6Hits, s.CIDRV6Total, i18n.T("CIDRLoading")))
|
||||
} else {
|
||||
a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d %s | %s: %d %s",
|
||||
s.CIDRV4Total, i18n.T("CIDRConfigured"),
|
||||
i18n.T("CIDRUnmatched"), s.CIDRV4Hits, i18n.T("CIDRDestinations")))
|
||||
a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d %s | %s: %d %s",
|
||||
s.CIDRV6Total, i18n.T("CIDRConfigured"),
|
||||
i18n.T("CIDRUnmatched"), s.CIDRV6Hits, i18n.T("CIDRDestinations")))
|
||||
}
|
||||
a.cidrV4Label.Show()
|
||||
a.cidrV6Label.Show()
|
||||
a.refreshCIDRBtn.Show()
|
||||
default:
|
||||
a.cidrV4Label.Hide()
|
||||
a.cidrV6Label.Hide()
|
||||
a.refreshCIDRBtn.Hide()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
a.routingModeLabel.Hide()
|
||||
a.cidrV4Label.Hide()
|
||||
a.cidrV6Label.Hide()
|
||||
a.refreshCIDRBtn.Hide()
|
||||
}
|
||||
}
|
||||
|
||||
// routeModeLabel returns the localised display name for a routing mode
|
||||
// code string.
|
||||
func routeModeLabel(code string) string {
|
||||
switch code {
|
||||
case "full":
|
||||
return i18n.T("RoutingModeFull")
|
||||
case "proxy":
|
||||
return i18n.T("RoutingModeProxy")
|
||||
case "bypass":
|
||||
return i18n.T("RoutingModeBypass")
|
||||
default:
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
// connectStepLabel translates a connection step code to a localised
|
||||
// display string. Returns "" for unknown/empty steps.
|
||||
func connectStepLabel(step string) string {
|
||||
switch step {
|
||||
case "fetch_cidrs":
|
||||
return i18n.T("StepFetchCIDRs")
|
||||
case "connecting":
|
||||
return i18n.T("StepConnecting")
|
||||
case "load_routes":
|
||||
return i18n.T("StepLoadRoutes")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// formatBytes formats a byte count human-readably.
|
||||
@@ -375,20 +598,32 @@ func formatDuration(d time.Duration) string {
|
||||
return fmt.Sprintf("%ds", s)
|
||||
}
|
||||
|
||||
// splitCIDRs splits a comma-separated CIDR string into a slice.
|
||||
func splitCIDRs(s string) []string {
|
||||
if s == "" {
|
||||
// parseIPCCIDRURLs decodes a JSON-encoded model.CIDRURLSource array
|
||||
// from the profile string and converts it to the IPC wire type.
|
||||
func parseIPCCIDRURLs(jsonStr string) []ipc.CIDRURLSource {
|
||||
sources := model.ParseCIDRURLs(jsonStr)
|
||||
if len(sources) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
start := 0
|
||||
for i := 0; i <= len(s); i++ {
|
||||
if i == len(s) || s[i] == ',' {
|
||||
if i > start {
|
||||
out = append(out, s[start:i])
|
||||
}
|
||||
start = i + 1
|
||||
out := make([]ipc.CIDRURLSource, len(sources))
|
||||
for i, s := range sources {
|
||||
out[i] = ipc.CIDRURLSource{
|
||||
URL: s.URL,
|
||||
FetchTiming: string(s.FetchTiming),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// marshalCIDRURLs encodes a slice of CIDRURLSource to a JSON string
|
||||
// for storage in the database.
|
||||
func marshalCIDRURLs(sources []model.CIDRURLSource) string {
|
||||
if len(sources) == 0 {
|
||||
return ""
|
||||
}
|
||||
data, err := json.Marshal(sources)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
+566
-54
@@ -9,35 +9,48 @@ package vpn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"lmvpn/internal/auth"
|
||||
"lmvpn/internal/cidrsource"
|
||||
"lmvpn/internal/log"
|
||||
"lmvpn/internal/model"
|
||||
"lmvpn/internal/protocol"
|
||||
"lmvpn/internal/route"
|
||||
"lmvpn/internal/stats"
|
||||
"lmvpn/internal/tlsconfig"
|
||||
"lmvpn/internal/transport"
|
||||
"lmvpn/internal/tun"
|
||||
)
|
||||
|
||||
// SessionConfig describes how to connect to a VPN server.
|
||||
type SessionConfig struct {
|
||||
ServerURL string
|
||||
SNIHost string // TLS SNI hostname for CDN
|
||||
ServerIPs []string // CDN edge IPs for failover
|
||||
Username string
|
||||
Password string
|
||||
AuthMode model.AuthMode
|
||||
Token string // pre-obtained JWT (empty = fetch via HTTP login)
|
||||
RoutingMode route.Mode
|
||||
CustomCIDRs []string
|
||||
MTUOverride int // 0 = use server MTU
|
||||
ServerURL string
|
||||
SNIHost string // TLS SNI hostname for CDN
|
||||
ServerIPs []string // CDN edge IPs for failover
|
||||
Username string
|
||||
Password string
|
||||
AuthMode model.AuthMode
|
||||
Token string // pre-obtained JWT (empty = fetch via HTTP login)
|
||||
RoutingMode route.Mode
|
||||
CIDRV4 []string // static IPv4 CIDRs (proxy/bypass mode)
|
||||
CIDRV6 []string // static IPv6 CIDRs (proxy/bypass mode)
|
||||
CIDRV4URLs []model.CIDRURLSource // IPv4 CIDR URL sources
|
||||
CIDRV6URLs []model.CIDRURLSource // IPv6 CIDR URL sources
|
||||
MTUOverride int // 0 = use server MTU
|
||||
TLSCACert string // inline CA cert PEM (wss only)
|
||||
TLSCAPath string // CA cert file path (wss only)
|
||||
TLSInsecure bool // skip cert verification (wss only)
|
||||
TLSPinnedHash string // SHA-256 cert pin (wss only)
|
||||
IPPreference string // "auto" (default), "v4", "v6" - hostname mode only
|
||||
}
|
||||
|
||||
// SessionManager manages a single VPN session with auto-reconnect.
|
||||
@@ -53,6 +66,13 @@ type SessionManager struct {
|
||||
dev tun.Device
|
||||
routeMgr *route.Manager
|
||||
conn *transport.Conn
|
||||
done chan struct{}
|
||||
|
||||
// CIDR hit tracking. Set during setupTUN, cleared on cleanup.
|
||||
cidrTracker *cidrTracker
|
||||
|
||||
// lastCfg stores the session config for RefreshCIDRs.
|
||||
lastCfg SessionConfig
|
||||
|
||||
// EWMA speed smoothing state. Only touched by reportStats (single
|
||||
// goroutine), so no lock needed. ewma* fields hold the smoothed
|
||||
@@ -99,6 +119,8 @@ func (sm *SessionManager) Connect(ctx context.Context, cfg SessionConfig) error
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
sm.cancel = cancel
|
||||
sm.running = true
|
||||
sm.done = make(chan struct{})
|
||||
sm.lastCfg = cfg
|
||||
sm.mu.Unlock()
|
||||
|
||||
go sm.run(ctx, cfg)
|
||||
@@ -115,47 +137,106 @@ func (sm *SessionManager) Disconnect() {
|
||||
}
|
||||
sm.running = false
|
||||
cancel := sm.cancel
|
||||
// Extract resources so we can clean them up outside the lock.
|
||||
// Setting them to nil here also prevents the run goroutine's
|
||||
// cleanup from double-cleaning (it will see nil and skip).
|
||||
routeMgr := sm.routeMgr
|
||||
conn := sm.conn
|
||||
dev := sm.dev
|
||||
sm.routeMgr = nil
|
||||
sm.conn = nil
|
||||
sm.dev = nil
|
||||
sm.cidrTracker = nil
|
||||
sm.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
// Close the transport to unblock the packet pump.
|
||||
sm.mu.Lock()
|
||||
conn := sm.conn
|
||||
sm.mu.Unlock()
|
||||
|
||||
// CRITICAL: remove routes BEFORE closing the TUN device. If the TUN
|
||||
// is closed first, the /1 cover routes still point at the dead TUN
|
||||
// and all traffic is blackholed until routeMgr.Cleanup() runs -
|
||||
// this causes a brief network outage (browsers, DNS, etc.).
|
||||
if routeMgr != nil {
|
||||
if err := routeMgr.Cleanup(); err != nil {
|
||||
log.L().Error("route cleanup error during disconnect", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Now safe to close the transport and TUN device.
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
if dev != nil {
|
||||
dev.Close()
|
||||
}
|
||||
|
||||
// Wait for the run goroutine to fully exit. By now it should be
|
||||
// unblocked (ctx cancelled, conn/dev closed) and will see nil
|
||||
// routeMgr/conn/dev in cleanup, so it won't double-clean.
|
||||
if sm.done != nil {
|
||||
<-sm.done
|
||||
}
|
||||
}
|
||||
|
||||
// run is the main session loop with exponential-backoff reconnection
|
||||
// and CDN IP failover.
|
||||
func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
|
||||
fatal := false
|
||||
defer close(sm.done)
|
||||
defer func() {
|
||||
if !fatal {
|
||||
if !fatal && ctx.Err() == nil {
|
||||
sm.setState(stats.StateDisconnected)
|
||||
}
|
||||
}()
|
||||
|
||||
// Fetch "before-proxy" CIDR lists ONCE before the reconnect loop.
|
||||
// These HTTP requests go through the physical NIC (routes are
|
||||
// clean). The result is reused across reconnection attempts so we
|
||||
// don't re-fetch on every retry. This runs outside the handshake
|
||||
// to avoid consuming the server's 30s ReadyTimeout budget.
|
||||
var beforeCIDRs []string
|
||||
allURLSources := append(append([]model.CIDRURLSource{}, cfg.CIDRV4URLs...), cfg.CIDRV6URLs...)
|
||||
if len(allURLSources) > 0 && cfg.RoutingMode != route.ModeFull {
|
||||
sm.stats.SetConnectStep("fetch_cidrs")
|
||||
sm.setState(stats.StateConnecting)
|
||||
log.L().Info("fetching before-proxy CIDR lists", "url_count", len(allURLSources))
|
||||
fetched, err := cidrsource.FetchBeforeProxy(ctx, allURLSources)
|
||||
if err != nil {
|
||||
log.L().Error("fetch before-proxy CIDR lists failed (continuing)", "error", err)
|
||||
}
|
||||
beforeCIDRs = fetched
|
||||
log.L().Info("before-proxy CIDR lists ready", "total_cidrs", len(beforeCIDRs))
|
||||
sm.stats.SetConnectStep("")
|
||||
}
|
||||
|
||||
backoff := time.Second
|
||||
maxBackoff := 60 * time.Second
|
||||
|
||||
// Build the full target list: original host first, then CDN IPs.
|
||||
targets := append([]string{""}, cfg.ServerIPs...) // "" = use base URL
|
||||
// Build the full target list. When ServerIPs is empty, connect via
|
||||
// hostname (IPPreference + race dialer apply). When ServerIPs is
|
||||
// non-empty, skip hostname and use IPs directly with failover.
|
||||
usingHostname := len(cfg.ServerIPs) == 0
|
||||
var targets []string
|
||||
if usingHostname {
|
||||
targets = []string{""} // "" = use base URL (hostname)
|
||||
} else {
|
||||
targets = cfg.ServerIPs // direct IP mode: sequential failover
|
||||
}
|
||||
ipIndex := 0
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
sm.cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
targetIP := ""
|
||||
if ipIndex > 0 && ipIndex < len(targets) {
|
||||
if ipIndex < len(targets) {
|
||||
targetIP = targets[ipIndex]
|
||||
}
|
||||
|
||||
err := sm.connectOnce(ctx, cfg, targetIP)
|
||||
err := sm.connectOnce(ctx, cfg, targetIP, beforeCIDRs)
|
||||
if ctx.Err() != nil {
|
||||
sm.cleanup()
|
||||
return
|
||||
@@ -164,27 +245,58 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
|
||||
if err != nil {
|
||||
log.L().Error("VPN connection failed", "error", err)
|
||||
|
||||
// A fatal authentication failure (wrong password, disabled
|
||||
// account, expired token, rate limit) is not retryable:
|
||||
// stop the loop and surface the reason to the user instead
|
||||
// of hammering the server forever.
|
||||
if code, msg, isFatal := fatalAuthError(err); isFatal {
|
||||
log.L().Warn("fatal auth error, stopping reconnect", "code", code, "message", msg)
|
||||
sm.setState(stats.StateError)
|
||||
if sm.onError != nil {
|
||||
sm.onError(string(code), msg)
|
||||
// Safety net: ensure no TUN/routes leak from a failed
|
||||
// attempt. connectOnce should have already cleaned up, but
|
||||
// this guards against any path that returned early.
|
||||
sm.cleanupResources()
|
||||
|
||||
// A TLS certificate verification failure on the original
|
||||
// hostname (ipIndex == 0, hostname mode) is not retryable:
|
||||
// the cert won't change between attempts, so stop the loop
|
||||
// and surface the reason to the user. On a CDN edge IP the
|
||||
// TLS error likely means that IP points to a different
|
||||
// server; skip it and try the next target.
|
||||
if tlsconfig.IsTLSError(err) {
|
||||
if usingHostname && ipIndex == 0 {
|
||||
log.L().Warn("fatal TLS error, stopping reconnect", "error", err)
|
||||
sm.setState(stats.StateError)
|
||||
if sm.onError != nil {
|
||||
sm.onError("tls_error", err.Error())
|
||||
}
|
||||
fatal = true
|
||||
sm.cleanup()
|
||||
return
|
||||
}
|
||||
fatal = true
|
||||
sm.cleanup()
|
||||
return
|
||||
log.L().Warn("TLS error on IP, skipping",
|
||||
"index", ipIndex, "ip", targets[ipIndex], "error", err)
|
||||
}
|
||||
|
||||
// A fatal authentication failure (wrong password, disabled
|
||||
// account, expired token, rate limit) on the original
|
||||
// hostname is not retryable. On a CDN edge IP it likely
|
||||
// means the IP points to a different server that returned
|
||||
// 401/403, so skip it instead of stopping the loop.
|
||||
if code, msg, isFatal := fatalAuthError(err); isFatal {
|
||||
if usingHostname && ipIndex == 0 {
|
||||
log.L().Warn("fatal auth error, stopping reconnect", "code", code, "message", msg)
|
||||
sm.setState(stats.StateError)
|
||||
if sm.onError != nil {
|
||||
sm.onError(string(code), msg)
|
||||
}
|
||||
fatal = true
|
||||
sm.cleanup()
|
||||
return
|
||||
}
|
||||
log.L().Warn("auth error on IP, skipping",
|
||||
"index", ipIndex, "ip", targets[ipIndex], "code", code)
|
||||
}
|
||||
|
||||
sm.setState(stats.StateReconnecting)
|
||||
|
||||
// Try next CDN IP immediately.
|
||||
// Try next target IP immediately.
|
||||
ipIndex++
|
||||
if ipIndex < len(targets) {
|
||||
log.L().Info("trying next CDN IP", "index", ipIndex, "ip", targets[ipIndex])
|
||||
log.L().Info("trying next server IP", "index", ipIndex, "ip", targets[ipIndex])
|
||||
continue
|
||||
}
|
||||
// All targets exhausted; reset and wait with backoff.
|
||||
@@ -244,8 +356,11 @@ func fatalAuthError(err error) (protocol.AuthErrorCode, string, bool) {
|
||||
|
||||
// connectOnce performs a single connection lifecycle: authenticate,
|
||||
// handshake, configure TUN, apply routes, pump packets until failure.
|
||||
func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, targetIP string) error {
|
||||
// beforeCIDRs contains CIDRs pre-fetched from "before" timing URL
|
||||
// sources (fetched once in run(), reused across reconnection attempts).
|
||||
func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, targetIP string, beforeCIDRs []string) error {
|
||||
sm.setState(stats.StateConnecting)
|
||||
sm.stats.SetConnectStep("connecting")
|
||||
|
||||
// Build URL for this attempt. If targetIP is set (CDN failover),
|
||||
// build a URL with that IP. Otherwise use base ServerURL.
|
||||
@@ -254,6 +369,28 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
|
||||
serverURL = replaceHost(cfg.ServerURL, targetIP)
|
||||
}
|
||||
|
||||
// Build TLS config for wss:// connections. For ws:// there is no
|
||||
// TLS layer, so tlsCfg remains nil and both the HTTP client and
|
||||
// the WebSocket dialer use their default (plaintext) behaviour.
|
||||
var tlsCfg *tls.Config
|
||||
if strings.HasPrefix(serverURL, "wss://") {
|
||||
serverName := cfg.SNIHost
|
||||
if serverName == "" {
|
||||
serverName = serverHostFromURL(cfg.ServerURL)
|
||||
}
|
||||
var err error
|
||||
tlsCfg, err = tlsconfig.Build(tlsconfig.Config{
|
||||
ServerName: serverName,
|
||||
CACertPEM: cfg.TLSCACert,
|
||||
CACertPath: cfg.TLSCAPath,
|
||||
InsecureSkipVerify: cfg.TLSInsecure,
|
||||
PinnedCertHash: cfg.TLSPinnedHash,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("tls config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine auth strategy and obtain JWT if needed.
|
||||
token := cfg.Token
|
||||
if token == "" && (cfg.AuthMode == model.AuthModeJWT || cfg.AuthMode == model.AuthModeBoth) {
|
||||
@@ -261,7 +398,7 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse server URL: %w", err)
|
||||
}
|
||||
result, err := auth.Login(httpBase, cfg.Username, cfg.Password)
|
||||
result, err := auth.Login(ctx, httpBase, cfg.Username, cfg.Password, tlsCfg, cfg.IPPreference)
|
||||
if err != nil {
|
||||
if cfg.AuthMode == model.AuthModeBoth {
|
||||
// Fall back to password auth.
|
||||
@@ -277,14 +414,16 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
|
||||
// Prepare the TUN + route setup callback (called during handshake,
|
||||
// between receiving init and sending ready).
|
||||
handshake := transport.HandshakeConfig{
|
||||
ServerURL: serverURL,
|
||||
SNIHost: cfg.SNIHost,
|
||||
Token: token,
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
ServerURL: serverURL,
|
||||
SNIHost: cfg.SNIHost,
|
||||
Token: token,
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
IPPreference: cfg.IPPreference,
|
||||
OnInit: func(init protocol.InitMessage) error {
|
||||
return sm.setupTUN(init, cfg)
|
||||
return sm.setupTUN(init, cfg, beforeCIDRs)
|
||||
},
|
||||
TLSConfig: tlsCfg,
|
||||
}
|
||||
|
||||
// Attempt JWT connection first; fall back to password on auth error.
|
||||
@@ -298,11 +437,19 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
|
||||
if errors.As(err, &authErr) ||
|
||||
(errors.As(err, &serverErr) && serverErr.Type == protocol.TypeAuthErr) {
|
||||
log.L().Info("JWT auth failed, falling back to password auth", "error", err)
|
||||
// Clean up any resources created by the failed attempt's
|
||||
// setupTUN before retrying.
|
||||
sm.cleanupResources()
|
||||
handshake.Token = ""
|
||||
conn, err = transport.Connect(ctx, handshake)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// Clean up TUN/routes if setupTUN ran but the handshake
|
||||
// failed (e.g. server closed the connection after
|
||||
// ReadyTimeout). Without this, leaked /1 cover routes
|
||||
// blackhole all traffic and prevent reconnection.
|
||||
sm.cleanupResources()
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -311,17 +458,42 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
|
||||
sm.conn = conn
|
||||
sm.mu.Unlock()
|
||||
|
||||
sm.stats.SetConnected(conn.Init().IP, conn.Init().IP6)
|
||||
serverHost := serverHostFromURL(cfg.ServerURL)
|
||||
connectedIP := conn.RemoteIP()
|
||||
sm.stats.SetConnected(conn.Init().IP, conn.Init().IP6, serverHost, connectedIP)
|
||||
sm.stats.SetConnectStep("load_routes")
|
||||
sm.setState(stats.StateConnected)
|
||||
log.L().Info("VPN connected",
|
||||
"ip", conn.Init().IP, "server_ip", conn.Init().ServerIP,
|
||||
"ip6", conn.Init().IP6, "server_ip6", conn.Init().ServerIP6,
|
||||
"mtu", conn.Init().MTU)
|
||||
"mtu", conn.Init().MTU,
|
||||
"server_host", serverHost, "connected_ip", connectedIP)
|
||||
|
||||
// Start stats reporter.
|
||||
statsDone := make(chan struct{})
|
||||
go sm.reportStats(statsDone, ctx)
|
||||
|
||||
// Apply deferred routes (user CIDRs) and fetch after-proxy CIDR
|
||||
// lists in a background goroutine. For proxy/bypass modes with
|
||||
// thousands of CIDRs this uses batch script execution (~3-5s).
|
||||
go func() {
|
||||
sm.mu.Lock()
|
||||
mgr := sm.routeMgr
|
||||
sm.mu.Unlock()
|
||||
if mgr != nil {
|
||||
sm.stats.SetRouteLoading(true)
|
||||
sm.stats.SetConnectStep("load_routes")
|
||||
log.L().Info("applying deferred routes")
|
||||
if err := mgr.ApplyDeferred(); err != nil {
|
||||
log.L().Error("apply deferred routes failed (continuing)", "error", err)
|
||||
}
|
||||
sm.stats.SetRouteLoading(false)
|
||||
sm.stats.SetConnectStep("")
|
||||
log.L().Info("deferred routes applied")
|
||||
}
|
||||
sm.fetchAfterProxyCIDRs(ctx, cfg)
|
||||
}()
|
||||
|
||||
// Run the packet pump (blocks until connection breaks).
|
||||
sm.pumpPackets(ctx, conn)
|
||||
|
||||
@@ -332,8 +504,12 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
|
||||
|
||||
// setupTUN creates and configures the TUN device and applies routes.
|
||||
// This is called by the transport during the handshake, between init
|
||||
// and ready.
|
||||
func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig) error {
|
||||
// and ready. It performs NO network calls (HTTP/DNS) so it completes
|
||||
// in milliseconds and never exceeds the server's ReadyTimeout.
|
||||
//
|
||||
// beforeCIDRs contains CIDRs fetched from "before" timing URL sources,
|
||||
// pre-fetched by connectOnce before the handshake began.
|
||||
func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig, beforeCIDRs []string) error {
|
||||
dev, err := tun.Create("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create tun: %w", err)
|
||||
@@ -345,12 +521,10 @@ func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig)
|
||||
localIP := net.ParseIP(init.IP)
|
||||
peerIP := net.ParseIP(init.ServerIP)
|
||||
if localIP == nil || peerIP == nil {
|
||||
dev.Close()
|
||||
return fmt.Errorf("invalid init IPs: %s / %s", init.IP, init.ServerIP)
|
||||
}
|
||||
|
||||
if err := dev.Configure(localIP, init.Prefix, peerIP); err != nil {
|
||||
dev.Close()
|
||||
return fmt.Errorf("configure tun: %w", err)
|
||||
}
|
||||
|
||||
@@ -359,11 +533,9 @@ func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig)
|
||||
if hasV6 {
|
||||
ip6 := net.ParseIP(init.IP6)
|
||||
if ip6 == nil {
|
||||
dev.Close()
|
||||
return fmt.Errorf("invalid init IPv6: %s", init.IP6)
|
||||
}
|
||||
if err := dev.ConfigureIPv6(ip6, init.Prefix6); err != nil {
|
||||
dev.Close()
|
||||
return fmt.Errorf("configure tun ipv6: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -373,10 +545,15 @@ func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig)
|
||||
mtu = cfg.MTUOverride
|
||||
}
|
||||
if err := dev.SetMTU(mtu); err != nil {
|
||||
dev.Close()
|
||||
return fmt.Errorf("set mtu: %w", err)
|
||||
}
|
||||
|
||||
// Merge static CIDRs with pre-fetched before-proxy CIDRs.
|
||||
var cidrs []string
|
||||
cidrs = append(cidrs, cfg.CIDRV4...)
|
||||
cidrs = append(cidrs, cfg.CIDRV6...)
|
||||
cidrs = append(cidrs, beforeCIDRs...)
|
||||
|
||||
// Apply routing.
|
||||
routeCfg := route.Config{
|
||||
Mode: cfg.RoutingMode,
|
||||
@@ -386,19 +563,141 @@ func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig)
|
||||
VPNIP6: init.IP6,
|
||||
VPNPrefix6: init.Prefix6,
|
||||
ServerHost: serverHostFromURL(cfg.ServerURL),
|
||||
CustomCIDRs: cfg.CustomCIDRs,
|
||||
CIDRs: cidrs,
|
||||
}
|
||||
sm.routeMgr = route.NewManager(routeCfg)
|
||||
if err := sm.routeMgr.Apply(); err != nil {
|
||||
log.L().Error("route apply failed (continuing)", "error", err)
|
||||
}
|
||||
|
||||
// Initialise the CIDR hit tracker for proxy/bypass modes.
|
||||
sm.cidrTracker = newCIDRTracker(cfg.RoutingMode, cidrs)
|
||||
|
||||
// Log CIDR breakdown by family for diagnostics.
|
||||
v4Total, _, v6Total, _ := sm.cidrTracker.Stats()
|
||||
log.L().Info("TUN configured",
|
||||
"dev", dev.Name(), "ip", init.IP, "prefix", init.Prefix,
|
||||
"ip6", init.IP6, "prefix6", init.Prefix6, "mtu", mtu)
|
||||
"ip6", init.IP6, "prefix6", init.Prefix6, "mtu", mtu,
|
||||
"routing_mode", cfg.RoutingMode,
|
||||
"cidr_v4", v4Total, "cidr_v6", v6Total,
|
||||
"before_proxy_cidrs", len(beforeCIDRs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchAfterProxyCIDRs fetches CIDR lists from URLs with "after" timing
|
||||
// (via the tunnel) and dynamically adds their routes to the route
|
||||
// manager. This is called in a goroutine after the data plane is up.
|
||||
func (sm *SessionManager) fetchAfterProxyCIDRs(ctx context.Context, cfg SessionConfig) {
|
||||
if cfg.RoutingMode == route.ModeFull {
|
||||
return
|
||||
}
|
||||
allURLSources := append(append([]model.CIDRURLSource{}, cfg.CIDRV4URLs...), cfg.CIDRV6URLs...)
|
||||
// Count only "after" sources for logging.
|
||||
afterCount := 0
|
||||
for _, s := range allURLSources {
|
||||
if s.FetchTiming == model.FetchAfter {
|
||||
afterCount++
|
||||
}
|
||||
}
|
||||
if afterCount == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sm.stats.SetRouteLoading(true)
|
||||
sm.stats.SetConnectStep("fetch_cidrs")
|
||||
log.L().Info("fetching after-proxy CIDR lists", "url_count", afterCount)
|
||||
fetched, err := cidrsource.FetchAfterProxy(ctx, allURLSources)
|
||||
sm.stats.SetConnectStep("load_routes")
|
||||
if err != nil {
|
||||
log.L().Error("fetch after-proxy CIDR lists completed with errors", "error", err)
|
||||
sm.stats.SetCIDRError(err.Error())
|
||||
} else {
|
||||
sm.stats.SetCIDRError("")
|
||||
}
|
||||
if len(fetched) == 0 {
|
||||
sm.stats.SetRouteLoading(false)
|
||||
sm.stats.SetConnectStep("")
|
||||
return
|
||||
}
|
||||
|
||||
merged := fetched // AddRoutes already calls mergeCIDRs internally
|
||||
added := sm.addCIDRRoutes(fetched)
|
||||
if added > 0 {
|
||||
log.L().Info("added after-proxy routes", "fetched", len(fetched), "added", added, "merged", len(merged))
|
||||
}
|
||||
|
||||
sm.stats.SetRouteLoading(false)
|
||||
sm.stats.SetConnectStep("")
|
||||
}
|
||||
|
||||
// addCIDRRoutes adds routes and updates the CIDR tracker. Returns the
|
||||
// number of CIDRs successfully added (after merge).
|
||||
func (sm *SessionManager) addCIDRRoutes(cidrs []string) int {
|
||||
sm.mu.Lock()
|
||||
mgr := sm.routeMgr
|
||||
tracker := sm.cidrTracker
|
||||
sm.mu.Unlock()
|
||||
if mgr == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
if err := mgr.AddRoutes(cidrs); err != nil {
|
||||
log.L().Error("add CIDR routes failed (continuing)", "error", err)
|
||||
}
|
||||
if tracker != nil {
|
||||
tracker.AddCIDRs(cidrs)
|
||||
}
|
||||
return len(cidrs)
|
||||
}
|
||||
|
||||
// RefreshCIDRs re-fetches all CIDR URL sources (both before and after
|
||||
// timing) via the current tunnel and dynamically adds their routes.
|
||||
// This is called when the user clicks the "Refresh CIDR" button.
|
||||
func (sm *SessionManager) RefreshCIDRs() {
|
||||
sm.mu.Lock()
|
||||
ctx := sm.cancel
|
||||
cfg := sm.lastCfg
|
||||
sm.mu.Unlock()
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.RoutingMode == route.ModeFull {
|
||||
return
|
||||
}
|
||||
|
||||
// Use a background context with 30s timeout so the refresh works
|
||||
// even if the session context is in a weird state.
|
||||
refreshCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
_ = refreshCtx
|
||||
|
||||
allURLSources := append(append([]model.CIDRURLSource{}, cfg.CIDRV4URLs...), cfg.CIDRV6URLs...)
|
||||
if len(allURLSources) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sm.stats.SetRouteLoading(true)
|
||||
sm.stats.SetCIDRError("")
|
||||
log.L().Info("refreshing CIDR lists", "url_count", len(allURLSources))
|
||||
|
||||
fetched, err := cidrsource.FetchAfterProxy(refreshCtx, allURLSources)
|
||||
if err != nil {
|
||||
log.L().Error("refresh CIDR lists completed with errors", "error", err)
|
||||
sm.stats.SetCIDRError(err.Error())
|
||||
} else {
|
||||
sm.stats.SetCIDRError("")
|
||||
}
|
||||
if len(fetched) == 0 {
|
||||
sm.stats.SetRouteLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
added := sm.addCIDRRoutes(fetched)
|
||||
log.L().Info("refreshed CIDR routes", "fetched", len(fetched), "added", added)
|
||||
sm.stats.SetRouteLoading(false)
|
||||
}
|
||||
|
||||
// pumpPackets runs the bidirectional packet loop until the connection
|
||||
// breaks.
|
||||
func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn) {
|
||||
@@ -409,6 +708,12 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
buf := make([]byte, 65536)
|
||||
// Cache the cidrTracker pointer once; it is stable for the
|
||||
// lifetime of pumpPackets (set in setupTUN, cleared in cleanup
|
||||
// which only runs after pumpPackets returns).
|
||||
sm.mu.Lock()
|
||||
tracker := sm.cidrTracker
|
||||
sm.mu.Unlock()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -433,6 +738,9 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn)
|
||||
return
|
||||
}
|
||||
sm.stats.AddTx(buf[:n])
|
||||
if tracker != nil {
|
||||
tracker.Record(buf[:n])
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -466,8 +774,12 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// cleanup tears down the TUN device and routes.
|
||||
func (sm *SessionManager) cleanup() {
|
||||
// cleanupResources tears down the TUN device, routes, and transport
|
||||
// connection WITHOUT changing the session state. This is used on
|
||||
// handshake-failure paths where the caller will set the appropriate
|
||||
// state (e.g. StateReconnecting). Returns without error if nothing
|
||||
// was set up.
|
||||
func (sm *SessionManager) cleanupResources() {
|
||||
sm.mu.Lock()
|
||||
dev := sm.dev
|
||||
routeMgr := sm.routeMgr
|
||||
@@ -475,6 +787,7 @@ func (sm *SessionManager) cleanup() {
|
||||
sm.dev = nil
|
||||
sm.routeMgr = nil
|
||||
sm.conn = nil
|
||||
sm.cidrTracker = nil
|
||||
sm.mu.Unlock()
|
||||
|
||||
if routeMgr != nil {
|
||||
@@ -488,6 +801,12 @@ func (sm *SessionManager) cleanup() {
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// cleanup tears down the TUN device, routes, and transport, then marks
|
||||
// the session as disconnected. Used on normal session termination.
|
||||
func (sm *SessionManager) cleanup() {
|
||||
sm.cleanupResources()
|
||||
sm.stats.SetDisconnected()
|
||||
}
|
||||
|
||||
@@ -577,6 +896,20 @@ func (sm *SessionManager) reportStats(done <-chan struct{}, ctx context.Context)
|
||||
sm.prevSnap.RxSpeed, sm.prevSnap.TxSpeed = 0, 0
|
||||
sm.prevTick = now
|
||||
sm.speedReady = true
|
||||
|
||||
// Fill in CIDR hit statistics.
|
||||
sm.mu.Lock()
|
||||
tracker := sm.cidrTracker
|
||||
sm.mu.Unlock()
|
||||
if tracker != nil {
|
||||
v4Total, v4Hits, v6Total, v6Hits := tracker.Stats()
|
||||
snap.RoutingMode = string(tracker.mode)
|
||||
snap.CIDRV4Total = v4Total
|
||||
snap.CIDRV4Hits = v4Hits
|
||||
snap.CIDRV6Total = v6Total
|
||||
snap.CIDRV6Hits = v6Hits
|
||||
}
|
||||
|
||||
sm.onStats(snap)
|
||||
}
|
||||
}
|
||||
@@ -615,7 +948,14 @@ func wsURLToHTTP(wsURL string) (string, error) {
|
||||
|
||||
// replaceHost substitutes the host portion of a URL string.
|
||||
// e.g. wss://host:443/ws with 1.2.3.4 → wss://1.2.3.4:443/ws
|
||||
// Bare IPv6 addresses are automatically bracketed:
|
||||
// wss://host:443/ws with 2001:db8::1 → wss://[2001:db8::1]:443/ws
|
||||
func replaceHost(rawURL, newHost string) string {
|
||||
// Auto-bracket bare IPv6 addresses so the colons in the address
|
||||
// are not confused with the port separator.
|
||||
if ip := net.ParseIP(newHost); ip != nil && ip.To4() == nil && !strings.HasPrefix(newHost, "[") {
|
||||
newHost = "[" + newHost + "]"
|
||||
}
|
||||
u := rawURL
|
||||
for _, prefix := range []string{"wss://", "ws://"} {
|
||||
if len(u) > len(prefix) && u[:len(prefix)] == prefix {
|
||||
@@ -630,3 +970,175 @@ func replaceHost(rawURL, newHost string) string {
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
|
||||
// cidrTracker tracks which configured CIDRs have been "hit" by
|
||||
// outbound traffic (TUN -> WebSocket). The behaviour differs by mode:
|
||||
//
|
||||
// - Proxy mode: each outbound packet's destination IP is matched
|
||||
// against the CIDR list; the first matching CIDR is marked as hit.
|
||||
// Stats() returns the count of hit CIDRs vs total CIDRs.
|
||||
// - Bypass mode: outbound traffic through TUN is, by definition,
|
||||
// traffic that did NOT match any bypass CIDR (bypassed traffic
|
||||
// goes via the physical NIC). We count distinct destination
|
||||
// /24 (v4) or /48 (v6) prefixes seen on TUN as "unmatched
|
||||
// destinations". Stats() returns the total bypass CIDR count
|
||||
// and the unmatched destination count.
|
||||
type cidrTracker struct {
|
||||
mode route.Mode
|
||||
mu sync.RWMutex
|
||||
|
||||
// Proxy mode: pre-parsed CIDR nets + per-CIDR hit flags.
|
||||
// Protected by mu for concurrent AddCIDRs (write) vs Record/Stats
|
||||
// (read). The atomic.Bool hit flags are individually atomic, but
|
||||
// the slices themselves need the lock.
|
||||
v4CIDRs []*net.IPNet
|
||||
v6CIDRs []*net.IPNet
|
||||
v4Hits []atomic.Bool
|
||||
v6Hits []atomic.Bool
|
||||
|
||||
// Bypass mode: distinct destination prefix sets.
|
||||
// v4 key = first 3 bytes of dest IP (a /24 prefix).
|
||||
// v6 key = first 6 bytes of dest IP (a /48 prefix).
|
||||
// sync.Map is already goroutine-safe; no lock needed.
|
||||
v4Prefixes sync.Map
|
||||
v6Prefixes sync.Map
|
||||
v4Count atomic.Int64
|
||||
v6Count atomic.Int64
|
||||
}
|
||||
|
||||
// newCIDRTracker creates a tracker for the given routing mode and CIDR
|
||||
// list. For full-tunnel mode, a tracker is still created but will
|
||||
// report zero totals (no CIDRs configured).
|
||||
func newCIDRTracker(mode route.Mode, cidrs []string) *cidrTracker {
|
||||
t := &cidrTracker{mode: mode}
|
||||
if mode == route.ModeFull {
|
||||
return t
|
||||
}
|
||||
t.addCIDRs(cidrs)
|
||||
return t
|
||||
}
|
||||
|
||||
// AddCIDRs appends additional CIDRs to the tracker (used for
|
||||
// after-proxy fetched CIDRs). Existing hit flags are preserved.
|
||||
func (t *cidrTracker) AddCIDRs(cidrs []string) {
|
||||
t.addCIDRs(cidrs)
|
||||
}
|
||||
|
||||
func (t *cidrTracker) addCIDRs(cidrs []string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
for _, cidrStr := range cidrs {
|
||||
cidrStr = strings.TrimSpace(cidrStr)
|
||||
if cidrStr == "" {
|
||||
continue
|
||||
}
|
||||
_, ipNet, err := net.ParseCIDR(cidrStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ipNet.IP.To4() != nil {
|
||||
t.v4CIDRs = append(t.v4CIDRs, ipNet)
|
||||
t.v4Hits = append(t.v4Hits, atomic.Bool{})
|
||||
} else {
|
||||
t.v6CIDRs = append(t.v6CIDRs, ipNet)
|
||||
t.v6Hits = append(t.v6Hits, atomic.Bool{})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record inspects an outbound IP packet (from TUN) and updates hit
|
||||
// counters. It is called from the TUN -> WebSocket goroutine for every
|
||||
// packet. Packets too short to contain an IP header or with an unknown
|
||||
// version are silently ignored.
|
||||
func (t *cidrTracker) Record(p []byte) {
|
||||
if len(p) < 1 {
|
||||
return
|
||||
}
|
||||
switch p[0] >> 4 {
|
||||
case 4:
|
||||
if len(p) < 20 {
|
||||
return
|
||||
}
|
||||
dst := net.IP(p[16:20])
|
||||
t.recordV4(dst)
|
||||
case 6:
|
||||
if len(p) < 40 {
|
||||
return
|
||||
}
|
||||
dst := net.IP(p[24:40])
|
||||
t.recordV6(dst)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *cidrTracker) recordV4(dst net.IP) {
|
||||
switch t.mode {
|
||||
case route.ModeProxy:
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
for i, cidr := range t.v4CIDRs {
|
||||
if !t.v4Hits[i].Load() && cidr.Contains(dst) {
|
||||
t.v4Hits[i].Store(true)
|
||||
}
|
||||
}
|
||||
case route.ModeBypass:
|
||||
key := string(dst.To4()[:3])
|
||||
if _, loaded := t.v4Prefixes.LoadOrStore(key, struct{}{}); !loaded {
|
||||
t.v4Count.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *cidrTracker) recordV6(dst net.IP) {
|
||||
switch t.mode {
|
||||
case route.ModeProxy:
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
for i, cidr := range t.v6CIDRs {
|
||||
if !t.v6Hits[i].Load() && cidr.Contains(dst) {
|
||||
t.v6Hits[i].Store(true)
|
||||
}
|
||||
}
|
||||
case route.ModeBypass:
|
||||
v6 := dst.To16()
|
||||
if len(v6) < 6 {
|
||||
return
|
||||
}
|
||||
key := string(v6[:6])
|
||||
if _, loaded := t.v6Prefixes.LoadOrStore(key, struct{}{}); !loaded {
|
||||
t.v6Count.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stats returns the total and hit counts for IPv4 and IPv6 CIDRs.
|
||||
//
|
||||
// For proxy mode: "hits" = number of CIDRs that have been matched by
|
||||
// at least one outbound packet.
|
||||
// For bypass mode: "hits" = number of distinct destination prefixes
|
||||
// seen on TUN (i.e. unmatched destinations that went through the
|
||||
// tunnel because they didn't match any bypass CIDR).
|
||||
func (t *cidrTracker) Stats() (v4Total, v4Hits, v6Total, v6Hits int) {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
switch t.mode {
|
||||
case route.ModeProxy:
|
||||
v4Total = len(t.v4CIDRs)
|
||||
for i := range t.v4Hits {
|
||||
if t.v4Hits[i].Load() {
|
||||
v4Hits++
|
||||
}
|
||||
}
|
||||
v6Total = len(t.v6CIDRs)
|
||||
for i := range t.v6Hits {
|
||||
if t.v6Hits[i].Load() {
|
||||
v6Hits++
|
||||
}
|
||||
}
|
||||
case route.ModeBypass:
|
||||
v4Total = len(t.v4CIDRs)
|
||||
v4Hits = int(t.v4Count.Load())
|
||||
v6Total = len(t.v6CIDRs)
|
||||
v6Hits = int(t.v6Count.Load())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>LSUIElement</key>
|
||||
<false/>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.lmvpn.client</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -46,12 +46,12 @@ func main() {
|
||||
for x := sx - sw/2; x < sx+sw/2; x++ {
|
||||
// Shield shape: rounded top, pointed bottom.
|
||||
progress := float64(y-sy) / float64(sh)
|
||||
halfW := float64(sw)/2 * (1.0 - 0.3*progress*progress)
|
||||
halfW := float64(sw) / 2 * (1.0 - 0.3*progress*progress)
|
||||
if math.Abs(float64(x-sx)) < halfW && progress < 0.7 {
|
||||
img.SetRGBA(x, y, shieldColor)
|
||||
} else if progress >= 0.7 {
|
||||
tp := (progress - 0.7) / 0.3
|
||||
halfW2 := float64(sw)/2 * (1.0 - 0.3*0.49) * (1.0 - tp)
|
||||
halfW2 := float64(sw) / 2 * (1.0 - 0.3*0.49) * (1.0 - tp)
|
||||
if math.Abs(float64(x-sx)) < halfW2 {
|
||||
img.SetRGBA(x, y, shieldColor)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user