9 Commits
Author SHA1 Message Date
kevin b52db70015 fix: 修复Windows/Linux编译缺失onAppActive符号的问题
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
onAppActive 仅在 dock_callback_darwin.go 中定义,导致非 darwin
平台 go vet/编译失败。在 dock_other.go 补充同名声明。
2026-07-09 10:48:23 +08:00
kevin 1c4906c266 chore: bump version to 0.6.1
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
2026-07-09 09:46:45 +08:00
kevin ebe082500f perf: 路由批量执行+CIDR聚合+并行脚本+连接步骤显示+CIDR刷新按钮
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
路由性能优化:
- 修复 macOS route 命令语法错误(去掉 -net 参数)
- 路由应用拆分为 Essential(ready前,4-6条命令<1s)+ Deferred(ready后,批量脚本)
- 新增 CIDR 聚合算法(cidrmerge.go),合并相邻 CIDR 块减少路由数量(10826->7500)
- 批量脚本执行:3平台各实现8个批量函数,临时脚本单次执行替代逐条进程创建
- 并行批量:拆为4个子shell并行执行,总时间降为1/4

连接步骤显示:
- stats 新增 connectStep + cidrError 原子变量
- 连接生命周期中设置步骤(fetch_cidrs/connecting/load_routes),UI 实时显示
- after-proxy CIDR 获取期间显示加载状态,获取失败显示错误提示

CIDR 刷新按钮:
- ipc 新增 CmdRefreshCIDR 命令
- daemon 处理刷新请求,session.RefreshCIDRs() 重新获取+添加路由
- UI 状态卡新增刷新按钮,proxy/bypass 模式已连接时显示

README:
- 补充 URL 获取时机注意事项(bypass模式after-proxy可能失败)
- 补充 CIDR 聚合与批量执行说明
2026-07-09 09:44:49 +08:00
kevin 7c49ae4d72 up 2026-07-09 08:22:16 +08:00
kevin 7b4289ae00 feat: 路由模式重构(full/proxy/bypass) + CIDR动态URL获取 + 命中统计 + 连接稳定性修复
路由模式与CIDR配置重构:
- 路由模式从 full/split/custom 改为 full/proxy/bypass,移除 split 和 custom
- CIDR 按 IPv4/IPv6 分开配置,支持静态 CIDR + URL 动态获取
- URL 支持"代理前获取"(直连)和"代理后获取"(走隧道)两种时机
- 数据库迁移 v5 自动转换旧数据(custom_cidrs 拆分为 cidr_v4/v6,custom->proxy,split->full)

CIDR命中统计:
- 状态栏显示当前路由模式及 IPv4/IPv6 CIDR 命中数
- 代理模式: 显示命中/总数; 绕过模式: 显示已配置数 + 未命中目的地址数
- cidrTracker 在 pumpPackets 中逐包检查出站目的IP

连接稳定性修复(多轮):
- transport.Connect 添加 ctx 监听 goroutine,取消时关闭WS中断阻塞的ReadMessage
- auth.Login 加 context.Context 参数,可被 cancel 中断
- Disconnect() 先删路由再关TUN,避免断网窗口
- startSession 在 Connect 前释放 d.mu,避免锁持有过久
- onConnect 移除 SendStop 消除竞态
- before-proxy CIDR 获取移到 run 循环前,并行获取+5s超时,重连复用
- cidrTracker 加 RWMutex 防数据竞争

UI修复:
- CIDR URL 输入框改为水平滚动+纵向布局,防止撑宽窗口
- 路由模式为full时隐藏CIDR配置区域
2026-07-09 08:21:47 +08:00
kevin 15af9ef72c fix: 移除showAndActivate避免误显示SystrayMonitor窗口
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
回退绕过GLFW遍历所有NSWindow的失败方案,恢复使用activateApp()+
showDockIcon()+window.Show()。保留真正生效的修复:正确selector
applicationShouldHandleReopen:hasVisibleWindows: + SetOnStarted延迟注册
2026-07-09 00:49:53 +08:00
kevin 96b1a8839c feat: 单实例锁与窗口恢复机制
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
- GUI 单实例锁: 新增 internal/instance 包,首实例监听专用端点,
  第二实例发送 focus 信号后静默退出 (Unix: gui.sock, Win: 18924)
- 守护进程弱锁修复: NewServer() 在 os.Remove 前先 net.Dial 探测,
  有活进程时拒绝启动,避免静默抢占 socket 导致孤儿进程
- macOS .app 重开恢复: 通过 class_addMethod 给 GLFWApplicationDelegate
  注册 applicationShouldHandleReopen:hasVisibleWindows: 回调,
  绕过 GLFW 直接用 Cocoa makeKeyAndOrderFront: 显示窗口
- 三平台覆盖: macOS/Linux 用 unix socket, Windows 用 TCP 端口
2026-07-09 00:29:21 +08:00
kevin c56fc0c21c fix: CDN优选IP支持IPv6、格式校验及错误降级
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
- replaceHost() 自动为裸 IPv6 地址包裹方括号,修复 IPv6 URL 畸形问题
- 新增 ValidateServerIPs() 校验 IP 格式,保存配置时拦截非法输入
- GetServerIPList() 运行时自动过滤非法 IP 条目
- CDN IP 尝试时 TLS/认证错误降级为非致命,跳过该 IP 继续故障转移
  而非终止整个连接循环(仅原始域名 ipIndex==0 时才判为致命)
- 占位符提示支持 IPv4/IPv6
2026-07-08 23:09:26 +08:00
kevin bb4e4552d8 feat: 记住上次选中的配置,下次启动自动选中
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
- 启用 AppConfig.DefaultProfileID 字段(此前声明但未使用)
- 启动时 loadProfiles() 优先选中上次记录的配置,找不到则回退到第一个
- 切换配置时立即持久化到 config.yml,防止崩溃丢失
- 使用 Profile ID 而非名称,避免重命名后失效
2026-07-08 21:05:32 +08:00
30 changed files with 2773 additions and 261 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ GO = go
CGO_ENABLED = 1
WINDRES ?= x86_64-w64-mingw32-windres
MINGW_CC ?= x86_64-w64-mingw32-gcc
SEMVER ?= 0.4.2
SEMVER ?= 0.6.2
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)
+6 -2
View File
@@ -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
+13 -2
View File
@@ -6,6 +6,7 @@ package auth
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
@@ -47,7 +48,10 @@ type errorResponse struct {
// 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).
func Login(baseURL, username, password string, tlsCfg *tls.Config) (*LoginResult, error) {
//
// 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) (*LoginResult, error) {
body, err := json.Marshal(loginRequest{Username: username, Password: password})
if err != nil {
return nil, err
@@ -58,7 +62,14 @@ func Login(baseURL, username, password string, tlsCfg *tls.Config) (*LoginResult
if tlsCfg != nil {
client.Transport = &http.Transport{TLSClientConfig: tlsCfg}
}
resp, err := client.Post(url, "application/json", bytes.NewReader(body))
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)
}
+173
View File
@@ -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
}
+39 -3
View File
@@ -119,6 +119,16 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) {
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:
@@ -128,8 +138,8 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) {
func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
d.mu.Lock()
defer d.mu.Unlock()
if req.Config == nil {
d.mu.Unlock()
_ = ipc.WriteErr(conn, "missing config")
return
}
@@ -146,7 +156,10 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
Token: req.Config.Token,
AuthMode: model.AuthMode(req.Config.AuthMode),
RoutingMode: ipc.RoutingModeFromIPC(req.Config.RoutingMode),
CustomCIDRs: req.Config.CustomCIDRs,
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,
@@ -158,7 +171,7 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
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
@@ -168,10 +181,18 @@ 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
}
}
@@ -192,3 +213,18 @@ func (d *daemon) stopSessionLocked() {
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
}
+111 -1
View File
@@ -8,6 +8,7 @@ package db
import (
"database/sql"
"fmt"
"net"
"strings"
"lmvpn/internal/paths"
@@ -51,7 +52,10 @@ func (s *Store) migrate() error {
if err := s.migrateV3(); err != nil {
return err
}
return s.migrateV4()
if err := s.migrateV4(); err != nil {
return err
}
return s.migrateV5()
}
func (s *Store) migrateV2() error {
@@ -207,6 +211,108 @@ func (s *Store) migrateV4() error {
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
}
// 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))
@@ -242,6 +348,10 @@ 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,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+19 -10
View File
@@ -14,12 +14,14 @@ 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,
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)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
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,
)
if err != nil {
@@ -38,13 +40,16 @@ 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,
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,
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.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash,
&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.CreatedAt, &last)
if err != nil {
return nil, fmt.Errorf("get profile %d: %w", id, err)
@@ -60,7 +65,8 @@ 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,
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,
created_at, last_connected_at
FROM server_profiles ORDER BY name`)
@@ -75,7 +81,8 @@ 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.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs,
&p.MTUOverride, &p.AutoConnect,
&p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash,
&p.CreatedAt, &last); err != nil {
return nil, err
@@ -94,12 +101,14 @@ 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 = ?
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.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs,
p.MTUOverride, p.AutoConnect,
p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash, p.ID)
if err != nil {
return fmt.Errorf("update profile %d: %w", p.ID, err)
+31 -5
View File
@@ -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,21 +102,32 @@ 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"
BtnAddURL = "Add URL"
BtnRemoveURL = "Remove"
FieldTLS = "TLS Certificate"
FieldTLSCACert = "CA Certificate (PEM)"
+31 -5
View File
@@ -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,21 +102,32 @@ 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 = "代理后获取"
BtnAddURL = "添加 URL"
BtnRemoveURL = "删除"
FieldTLS = "TLS 证书"
FieldTLSCACert = "CA 证书 (PEM)"
+81
View File
@@ -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:
}
}
}
+55 -29
View File
@@ -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,29 +50,41 @@ 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"`
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)
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)
}
// 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 ---
@@ -109,8 +122,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)
}
@@ -245,6 +266,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})
@@ -277,10 +303,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
}
+71 -11
View File
@@ -3,7 +3,9 @@
package model
import (
"encoding/json"
"fmt"
"net"
"strings"
"time"
)
@@ -21,11 +23,26 @@ const (
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,7 +55,10 @@ 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)
@@ -85,17 +105,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
+6
View File
@@ -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)
+6
View File
@@ -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)
+7
View File
@@ -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)
+210
View File
@@ -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
View File
@@ -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)
}
}
+144 -9
View File
@@ -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)
}
+132
View File
@@ -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)
}
+176
View File
@@ -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)
}
+54
View File
@@ -34,6 +34,9 @@ type Stats struct {
state atomic.Value // State
assignedIP atomic.Value // string (IPv4)
assignedIP6 atomic.Value // string (IPv6, may be empty)
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 +79,8 @@ func New() *Stats {
s.state.Store(StateDisconnected)
s.assignedIP.Store("")
s.assignedIP6.Store("")
s.connectStep.Store("")
s.cidrError.Store("")
return s
}
@@ -100,6 +105,42 @@ func (s *Stats) SetDisconnected() {
s.assignedIP.Store("")
s.assignedIP6.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.
@@ -135,6 +176,16 @@ type Snapshot struct {
AssignedIP6 string
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.
@@ -159,5 +210,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
}
+21
View File
@@ -89,12 +89,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
}
@@ -103,6 +117,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
}
@@ -111,6 +126,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)
}
@@ -118,10 +134,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).
+101 -7
View File
@@ -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"
@@ -44,15 +46,21 @@ type App struct {
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 {
@@ -84,6 +103,7 @@ func Run() {
db: store,
kc: keychain.New(),
langSetting: cfg.Language,
defaultProfileID: cfg.DefaultProfileID,
listSelectedIndex: -1,
}
@@ -99,6 +119,46 @@ 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()
showDockIcon()
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()
showDockIcon()
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,6 +169,7 @@ func Run() {
a.window.SetCloseIntercept(func() {
if cfg.CloseToTray {
a.windowHidden = true
hideDockIcon()
a.window.Hide()
} else {
@@ -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)
+18
View File
@@ -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()
}
}
+41
View File
@@ -8,13 +8,54 @@ package ui
#include <objc/runtime.h>
#include <objc/message.h>
// 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 setDockIconVisible(int visible) {
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);
}
static void cmActivateApp(void) {
Class cls = objc_getClass("NSApplication");
id app = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("sharedApplication"));
((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() }
+6
View File
@@ -2,5 +2,11 @@
package ui
var onAppActive func()
func showDockIcon() {}
func hideDockIcon() {}
func activateApp() {}
func registerReopenHandler() {}
+191 -14
View File
@@ -1,8 +1,10 @@
package ui
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"lmvpn/internal/i18n"
"lmvpn/internal/model"
@@ -18,9 +20,11 @@ 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)}
)
func authModeLabels() []string {
@@ -28,13 +32,17 @@ 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")}
}
// 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 +61,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.
@@ -97,11 +199,17 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
authSelect := widget.NewSelect(authModeLabels(), nil)
routeSelect := widget.NewSelect(routeModeLabels(), 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
@@ -154,6 +262,32 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
}
}
// 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))
@@ -166,7 +300,10 @@ 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)
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)
@@ -187,6 +324,11 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
}
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,
@@ -212,7 +354,8 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
container.NewVBox(widget.NewLabel(i18n.T("FieldRoutingMode")), routeSelect),
),
widget.NewLabel(i18n.T("FieldCustomCIDRs")), cidrEntry,
cidrSection,
widget.NewLabel(i18n.T("FieldMTUOverride")), mtuEntry,
widget.NewLabel(i18n.T("FieldTLS")),
@@ -229,6 +372,10 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
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
})
@@ -242,7 +389,9 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
userEntry.Text, passEntry.Text,
selectedCode(authCodes, authSelect.SelectedIndex()),
selectedCode(routeCodes, routeSelect.SelectedIndex()),
cidrEntry.Text, mtuEntry.Text,
cidrV4Entry.Text, cidrV6Entry.Text,
v4URLList.toJSON(), v6URLList.toJSON(),
mtuEntry.Text,
tlsCaPEMEntry.Text, tlsCaPathEntry.Text,
tlsPinnedHashEntry.Text, tlsInsecureCheck.Checked,
isNew) {
@@ -256,20 +405,32 @@ 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, 760))
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,
name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode,
cidrV4, cidrV6, cidrV4URLs, cidrV6URLs, mtuStr,
tlsCaPEM, tlsCaPath, tlsPinnedHash string, tlsInsecure, isNew bool) 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)
@@ -284,7 +445,10 @@ func (a *App) saveProfile(editing *model.ServerProfile,
Username: user,
AuthMode: model.AuthMode(authMode),
RoutingMode: model.RoutingMode(routeMode),
CustomCIDRs: cidrs,
CIDRV4: cidrV4,
CIDRV6: cidrV6,
CIDRV4URLs: cidrV4URLs,
CIDRV6URLs: cidrV6URLs,
MTUOverride: mtu,
TLSCACert: tlsCaPEM,
TLSCAPath: tlsCaPath,
@@ -313,7 +477,10 @@ 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
@@ -335,6 +502,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)
}
+7 -5
View File
@@ -60,11 +60,13 @@ func (a *App) setupTray() {
}
menu := fyne.NewMenu(i18n.T("WindowTitle"),
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
showDockIcon()
a.window.Show()
a.window.RequestFocus()
}),
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
a.windowHidden = false
activateApp()
showDockIcon()
a.window.Show()
a.window.RequestFocus()
}),
fyne.NewMenuItemSeparator(),
connectItem,
disconnectItem,
+192 -17
View File
@@ -2,12 +2,14 @@ package ui
import (
_ "embed"
"encoding/json"
"fmt"
"net/url"
"time"
"lmvpn/internal/i18n"
"lmvpn/internal/ipc"
"lmvpn/internal/model"
"lmvpn/internal/stats"
"fyne.io/fyne/v2"
@@ -54,6 +56,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.
@@ -68,6 +71,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,
@@ -77,6 +89,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.
@@ -143,7 +159,12 @@ func (a *App) onConnect() {
a.ipcClient = client
a.mu.Unlock()
if oldClient != nil {
_ = ipc.SendStop(oldClient)
// 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()
}
@@ -177,7 +198,10 @@ func (a *App) onConnect() {
Password: password,
AuthMode: string(p.AuthMode),
RoutingMode: string(p.RoutingMode),
CustomCIDRs: splitCIDRs(p.CustomCIDRs),
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,
@@ -198,6 +222,24 @@ func (a *App) onConnect() {
}()
}
// 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()
@@ -220,6 +262,10 @@ func (a *App) onDisconnect() {
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.
@@ -249,6 +295,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)
}
})
@@ -261,7 +311,7 @@ func (a *App) eventLoop() {
current := a.ipcClient
a.mu.Unlock()
if current == client {
a.applyState(ev.State)
a.applyStateWithStep(ev.State, ev.ConnectStep)
}
})
case ipc.EvStats:
@@ -320,12 +370,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"))
@@ -352,7 +417,12 @@ 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 {
a.stateLabel.SetText(i18n.T("StateConnected"))
}
}
a.rxV4Label.SetText(i18n.T("RxV4Label", map[string]interface{}{
"bytes": formatBytes(s.RxBytesV4), "speed": formatSpeed(s.RxSpeedV4),
@@ -375,6 +445,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.
@@ -423,20 +586,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)
}
+479 -45
View File
@@ -16,9 +16,11 @@ import (
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"lmvpn/internal/auth"
"lmvpn/internal/cidrsource"
"lmvpn/internal/log"
"lmvpn/internal/model"
"lmvpn/internal/protocol"
@@ -39,8 +41,11 @@ type SessionConfig struct {
AuthMode model.AuthMode
Token string // pre-obtained JWT (empty = fetch via HTTP login)
RoutingMode route.Mode
CustomCIDRs []string
MTUOverride int // 0 = use server MTU
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)
@@ -62,6 +67,12 @@ type SessionManager struct {
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
// bytes/sec; prev* hold the last snapshot's cumulative counters and
@@ -108,6 +119,7 @@ func (sm *SessionManager) Connect(ctx context.Context, cfg SessionConfig) error
sm.cancel = cancel
sm.running = true
sm.done = make(chan struct{})
sm.lastCfg = cfg
sm.mu.Unlock()
go sm.run(ctx, cfg)
@@ -124,24 +136,43 @@ 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 WS->TUN goroutine.
sm.mu.Lock()
conn := sm.conn
dev := sm.dev
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()
}
// Close the TUN device to unblock the TUN->WS goroutine's dev.Read().
if dev != nil {
dev.Close()
}
// Wait for the run goroutine to fully exit so that cleanup
// (route removal, TUN teardown) is complete before returning.
// 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
}
@@ -158,6 +189,26 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
}
}()
// 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 {
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
@@ -176,7 +227,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
targetIP = targets[ipIndex]
}
err := sm.connectOnce(ctx, cfg, targetIP)
err := sm.connectOnce(ctx, cfg, targetIP, beforeCIDRs)
if ctx.Err() != nil {
sm.cleanup()
return
@@ -185,33 +236,50 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
if err != nil {
log.L().Error("VPN connection failed", "error", err)
// A TLS certificate verification failure is not retryable:
// the cert won't change between attempts, so stop the
// loop and surface the reason to the user.
// 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) 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 (ipIndex > 0) the
// TLS error likely means that IP points to a different
// server; skip it and try the next target.
if tlsconfig.IsTLSError(err) {
log.L().Warn("fatal TLS error, stopping reconnect", "error", err)
sm.setState(stats.StateError)
if sm.onError != nil {
sm.onError("tls_error", err.Error())
if 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 CDN IP, skipping",
"index", ipIndex, "ip", targets[ipIndex], "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.
// 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 {
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)
if 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
}
fatal = true
sm.cleanup()
return
log.L().Warn("auth error on CDN IP, skipping",
"index", ipIndex, "ip", targets[ipIndex], "code", code)
}
sm.setState(stats.StateReconnecting)
@@ -279,8 +347,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.
@@ -318,7 +389,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, tlsCfg)
result, err := auth.Login(ctx, httpBase, cfg.Username, cfg.Password, tlsCfg)
if err != nil {
if cfg.AuthMode == model.AuthModeBoth {
// Fall back to password auth.
@@ -340,7 +411,7 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
Username: cfg.Username,
Password: cfg.Password,
OnInit: func(init protocol.InitMessage) error {
return sm.setupTUN(init, cfg)
return sm.setupTUN(init, cfg, beforeCIDRs)
},
TLSConfig: tlsCfg,
}
@@ -356,11 +427,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
}
}
@@ -370,6 +449,7 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
sm.mu.Unlock()
sm.stats.SetConnected(conn.Init().IP, conn.Init().IP6)
sm.stats.SetConnectStep("load_routes")
sm.setState(stats.StateConnected)
log.L().Info("VPN connected",
"ip", conn.Init().IP, "server_ip", conn.Init().ServerIP,
@@ -380,6 +460,27 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
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)
@@ -390,8 +491,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)
@@ -403,12 +508,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)
}
@@ -417,11 +520,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)
}
}
@@ -431,10 +532,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,
@@ -444,19 +550,134 @@ 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) {
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
}
// 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) {
@@ -467,6 +688,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():
@@ -491,6 +718,9 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn)
return
}
sm.stats.AddTx(buf[:n])
if tracker != nil {
tracker.Record(buf[:n])
}
}
}()
@@ -524,8 +754,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
@@ -533,6 +767,7 @@ func (sm *SessionManager) cleanup() {
sm.dev = nil
sm.routeMgr = nil
sm.conn = nil
sm.cidrTracker = nil
sm.mu.Unlock()
if routeMgr != nil {
@@ -546,6 +781,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()
}
@@ -635,6 +876,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)
}
}
@@ -673,7 +928,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 {
@@ -688,3 +950,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
}