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配置区域
This commit is contained in:
2026-07-09 08:21:47 +08:00
parent 15af9ef72c
commit 7b4289ae00
18 changed files with 1310 additions and 157 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.7
SEMVER ?= 0.5.5
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)
+2 -2
View File
@@ -30,7 +30,7 @@ LMVPN 是一个基于 WebSocket 隧道与 TUN 虚拟网卡的**三层(网络
- **双进程架构**GUI`lmvpn`,普通用户)+ 守护进程(`lmvpnd`,root/管理员),自动拉起与生命周期管理
- **多种认证**:JWT 令牌 / 用户名密码
- **隧道模式**:全隧道、分流隧道(按目标绕过)、自定义隧道
- **隧道模式**:全隧道、代理 CIDR(指定 CIDR 走隧道)、绕过 CIDR(指定 CIDR 绕过隧道),支持 IPv4/IPv6 分开配置与 URL 动态获取 CIDR 列表
- **多服务器管理**:配置文件 + SQLite 存储多个服务器配置(Profile)
- **国际化**:中文(简体)、英文,跟随系统语言
- **安全存储**macOS Keychain / Windows Credential Manager 加密保存凭据
@@ -274,7 +274,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
}
+28 -2
View File
@@ -128,8 +128,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 +146,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,
@@ -168,10 +171,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 +203,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)
+21 -4
View File
@@ -37,6 +37,12 @@ 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"
DlgNoProfileTitle = "No Profile"
DlgNoProfileEditMsg = "Select a profile to edit."
DlgNoProfileConnectMsg = "Please select or create a profile first."
@@ -88,10 +94,15 @@ 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 = "IPv4/IPv6 supported, e.g. 1.2.3.4, 5.6.7.8"
@@ -101,8 +112,14 @@ 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)"
+21 -4
View File
@@ -37,6 +37,12 @@ TxV6Zero = "↑ 0 B 0 bps"
RxTotalZero = "合计 ↓ 0 B 0 bps"
TxTotalZero = "↑ 0 B 0 bps"
StatusRoutingMode = "路由模式: {{.mode}}"
CIDRHit = "命中"
CIDRConfigured = "已配置"
CIDRUnmatched = "未命中"
CIDRDestinations = "目的地址"
DlgNoProfileTitle = "无配置"
DlgNoProfileEditMsg = "请选择要编辑的配置。"
DlgNoProfileConnectMsg = "请先选择或创建一个配置。"
@@ -88,10 +94,15 @@ 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 = "支持 IPv4/IPv6,例: 1.2.3.4, 5.6.7.8"
@@ -101,8 +112,14 @@ AuthModeJWT = "JWT"
AuthModePassword = "密码"
RoutingModeFull = "全隧道"
RoutingModeSplit = "分离隧道"
RoutingModeCustom = "自定义"
RoutingModeProxy = "代理 CIDR"
RoutingModeBypass = "绕过 CIDR"
FetchTimingBefore = "代理前获取"
FetchTimingAfter = "代理后获取"
BtnAddURL = "添加 URL"
BtnRemoveURL = "删除"
FieldTLS = "TLS 证书"
FieldTLSCACert = "CA 证书 (PEM)"
+29 -18
View File
@@ -49,20 +49,31 @@ 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.
@@ -285,10 +296,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
}
+52 -4
View File
@@ -3,6 +3,7 @@
package model
import (
"encoding/json"
"fmt"
"net"
"strings"
@@ -22,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"`
@@ -39,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)
@@ -113,6 +132,35 @@ func (p *ServerProfile) GetServerIPList() []string {
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
}
var out []string
for _, part := range strings.Split(s, ",") {
c := strings.TrimSpace(part)
if c != "" {
out = append(out, c)
}
}
return out
}
// ConnectionStatus records the outcome of a connection attempt.
type ConnectionStatus string
+179 -57
View File
@@ -5,8 +5,9 @@
// 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
//
// IPv6 routes are applied automatically when the server assigned an
// IPv6 address (Config.VPNIP6 != ""). All routes are tracked so they
@@ -14,9 +15,11 @@
package route
import (
"context"
"fmt"
"net"
"strings"
"time"
)
// Mode selects which traffic goes through the VPN tunnel.
@@ -24,8 +27,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 +40,17 @@ 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.
type Manager struct {
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
@@ -64,16 +69,86 @@ 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.applyProxy()
case ModeBypass:
return m.applyBypass()
default:
return fmt.Errorf("unknown routing mode: %s", m.cfg.Mode)
}
}
// Cleanup removes all routes that were added by Apply.
// AddRoutes dynamically adds routes for additional CIDRs after the
// initial Apply. This is used for CIDRs fetched from URLs after the
// tunnel is established. In proxy mode the CIDRs are routed via TUN;
// in bypass mode they are routed via the original gateway.
func (m *Manager) AddRoutes(cidrs []string) error {
var errs []string
for _, cidr := range cidrs {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
if isIPv6CIDR(cidr) {
if m.cfg.Mode == ModeBypass {
if m.originalGateway6 == "" {
continue
}
if err := addRouteVia6(cidr, m.originalGateway6); err != nil {
errs = append(errs, err.Error())
continue
}
m.bypassRoutes6 = append(m.bypassRoutes6, cidr)
} else {
if err := addRoute6(cidr, m.cfg.InterfaceName); err != nil {
errs = append(errs, err.Error())
continue
}
m.addedRoutes6 = append(m.addedRoutes6, cidr)
}
} else {
if m.cfg.Mode == ModeBypass {
if m.originalGateway == "" {
continue
}
if err := addRouteVia(cidr, m.originalGateway); err != nil {
errs = append(errs, err.Error())
continue
}
m.bypassRoutes = append(m.bypassRoutes, cidr)
} else {
if err := addRoute(cidr, m.cfg.InterfaceName); err != nil {
errs = append(errs, err.Error())
continue
}
m.addedRoutes = append(m.addedRoutes, cidr)
}
}
}
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. This is needed by callers
// that want to add bypass routes dynamically in bypass mode.
func (m *Manager) HasOriginalGateway() bool {
return m.originalGateway != "" || m.originalGateway6 != ""
}
// OriginalGatewayV4 returns the captured IPv4 default gateway, if any.
func (m *Manager) OriginalGatewayV4() string {
return m.originalGateway
}
// OriginalGatewayV6 returns the captured IPv6 default gateway, if any.
func (m *Manager) OriginalGatewayV6() string {
return m.originalGateway6
}
// Cleanup removes all routes that were added by Apply or AddRoutes.
func (m *Manager) Cleanup() error {
var errs []string
for _, r := range m.addedRoutes {
@@ -88,6 +163,18 @@ func (m *Manager) Cleanup() error {
}
}
m.addedRoutes6 = nil
for _, r := range m.bypassRoutes {
if err := deleteRouteVia(r, m.originalGateway); err != nil {
errs = append(errs, err.Error())
}
}
m.bypassRoutes = nil
for _, r := range m.bypassRoutes6 {
if err := deleteRouteVia6(r, m.originalGateway6); err != nil {
errs = append(errs, err.Error())
}
}
m.bypassRoutes6 = nil
if m.serverBypass {
if err := m.deleteServerBypass(); err != nil {
errs = append(errs, err.Error())
@@ -106,7 +193,10 @@ func (m *Manager) Cleanup() error {
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 applyBypass.
func (m *Manager) captureGatewaysAndBypass() error {
// Capture the current default gateways before modifying routes.
gw, err := defaultGateway()
if err != nil {
@@ -147,6 +237,13 @@ func (m *Manager) applyFull() error {
m.serverBypass6 = true
}
}
return nil
}
func (m *Manager) applyFull() error {
if err := m.captureGatewaysAndBypass(); err != nil {
return err
}
// 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
@@ -172,37 +269,21 @@ 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)
}
return nil
}
func (m *Manager) applyCustom() error {
for _, cidr := range m.cfg.CustomCIDRs {
// applyProxy routes only the specified CIDRs through the TUN interface.
func (m *Manager) applyProxy() error {
for _, cidr := range m.cfg.CIDRs {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
if isIPv6CIDR(cidr) {
if err := addRoute6(cidr, m.cfg.InterfaceName); err != nil {
return fmt.Errorf("add custom route6 %s: %w", cidr, err)
return fmt.Errorf("add proxy 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)
return fmt.Errorf("add proxy route %s: %w", cidr, err)
}
m.addedRoutes = append(m.addedRoutes, cidr)
}
@@ -210,6 +291,59 @@ func (m *Manager) applyCustom() error {
return nil
}
// applyBypass routes all traffic through TUN except the specified
// CIDRs, which are routed via the original gateway. This combines the
// full-tunnel /1 cover routes with per-CIDR bypass routes.
func (m *Manager) applyBypass() error {
if err := m.captureGatewaysAndBypass(); err != nil {
return err
}
// Add bypass routes for user-specified CIDRs via the original
// gateway. These are more specific than the /1 cover routes below,
// so they take precedence and keep the bypassed traffic on the
// physical NIC.
for _, cidr := range m.cfg.CIDRs {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
if isIPv6CIDR(cidr) {
if m.originalGateway6 == "" {
continue
}
if err := addRouteVia6(cidr, m.originalGateway6); err != nil {
return fmt.Errorf("add bypass route6 %s: %w", cidr, err)
}
m.bypassRoutes6 = append(m.bypassRoutes6, cidr)
} else {
if err := addRouteVia(cidr, m.originalGateway); err != nil {
return fmt.Errorf("add bypass route %s: %w", cidr, err)
}
m.bypassRoutes = append(m.bypassRoutes, cidr)
}
}
// 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 route6 %s: %w", cidr, err)
}
m.addedRoutes6 = append(m.addedRoutes6, cidr)
}
}
return nil
}
func (m *Manager) deleteServerBypass() error {
if m.serverIP == "" {
return nil
@@ -224,24 +358,10 @@ func (m *Manager) deleteServerBypass6() error {
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)
}
bits := 32
if ipv6 {
bits = 128
}
mask := net.CIDRMask(prefix, bits)
network := ip.Mask(mask)
return fmt.Sprintf("%s/%d", network.String(), prefix)
}
// 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.
// result may be empty if no address of that family is available. The
// DNS lookup is bounded to 5 seconds to avoid blocking the handshake.
func resolveHosts(host string) (v4, v6 string, err error) {
if ip := net.ParseIP(host); ip != nil {
if ip.To4() != nil {
@@ -253,15 +373,17 @@ func resolveHosts(host string) (v4, v6 string, err error) {
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 == "" {
+7
View File
@@ -135,6 +135,13 @@ 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"`
}
// Snapshot returns a point-in-time copy of the statistics.
+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).
+3
View File
@@ -46,6 +46,9 @@ type App struct {
txV6Label *widget.Label
rxTotalLabel *widget.Label
txTotalLabel *widget.Label
routingModeLabel *widget.Label
cidrV4Label *widget.Label
cidrV6Label *widget.Label
connectBtn *widget.Button
disconnectBtn *widget.Button
+179 -14
View File
@@ -1,6 +1,7 @@
package ui
import (
"encoding/json"
"fmt"
"strconv"
"strings"
@@ -19,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 {
@@ -29,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 {
@@ -54,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.
@@ -98,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
@@ -155,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))
@@ -167,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)
@@ -188,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,
@@ -213,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")),
@@ -230,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
})
@@ -243,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) {
@@ -257,14 +405,15 @@ 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)
@@ -296,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,
@@ -325,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
@@ -347,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)
}
+97 -13
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"
@@ -69,6 +71,12 @@ 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()
statusCard := widget.NewCard(i18n.T("StatusLabel"), "", container.NewVBox(
a.stateLabel,
@@ -78,6 +86,9 @@ 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,
))
// Buttons.
@@ -144,7 +155,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()
}
@@ -178,7 +194,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,
@@ -221,6 +240,9 @@ 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()
}
// eventLoop reads IPC events from the daemon and updates the UI.
@@ -250,6 +272,9 @@ 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.setConnButtons(true, false)
}
})
@@ -376,6 +401,53 @@ 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)
a.routingModeLabel.SetText(i18n.T("StatusRoutingMode", map[string]interface{}{"mode": modeLabel}))
a.routingModeLabel.Show()
switch s.RoutingMode {
case "proxy":
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()
case "bypass":
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()
default:
a.cidrV4Label.Hide()
a.cidrV6Label.Hide()
}
} else {
a.routingModeLabel.Hide()
a.cidrV4Label.Hide()
a.cidrV6Label.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
}
}
// formatBytes formats a byte count human-readably.
@@ -424,20 +496,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)
}
+354 -25
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,9 @@ type SessionManager struct {
conn *transport.Conn
done chan struct{}
// CIDR hit tracking. Set during setupTUN, cleared on cleanup.
cidrTracker *cidrTracker
// 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
@@ -124,24 +132,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 +185,23 @@ 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 {
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))
}
backoff := time.Second
maxBackoff := 60 * time.Second
@@ -176,7 +220,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,6 +229,11 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
if err != nil {
log.L().Error("VPN connection failed", "error", err)
// 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
@@ -291,7 +340,9 @@ 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)
// Build URL for this attempt. If targetIP is set (CDN failover),
@@ -330,7 +381,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.
@@ -352,7 +403,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,
}
@@ -368,11 +419,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
}
}
@@ -392,6 +451,11 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
statsDone := make(chan struct{})
go sm.reportStats(statsDone, ctx)
// Fetch "after-proxy" CIDR lists via the tunnel and dynamically add
// their routes. This runs in a goroutine so it doesn't block the
// packet pump.
go sm.fetchAfterProxyCIDRs(ctx, cfg)
// Run the packet pump (blocks until connection breaks).
sm.pumpPackets(ctx, conn)
@@ -402,8 +466,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)
@@ -415,12 +483,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)
}
@@ -429,11 +495,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)
}
}
@@ -443,10 +507,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,
@@ -456,19 +525,73 @@ 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
}
log.L().Info("fetching after-proxy CIDR lists", "url_count", afterCount)
fetched, err := cidrsource.FetchAfterProxy(ctx, allURLSources)
if err != nil {
log.L().Error("fetch after-proxy CIDR lists completed with errors", "error", err)
}
if len(fetched) == 0 {
return
}
sm.mu.Lock()
mgr := sm.routeMgr
sm.mu.Unlock()
if mgr == nil {
return
}
if err := mgr.AddRoutes(fetched); err != nil {
log.L().Error("add after-proxy routes failed (continuing)", "error", err)
} else {
log.L().Info("added after-proxy routes", "count", len(fetched))
}
sm.mu.Lock()
tracker := sm.cidrTracker
sm.mu.Unlock()
if tracker != nil {
tracker.AddCIDRs(fetched)
}
}
// pumpPackets runs the bidirectional packet loop until the connection
// breaks.
func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn) {
@@ -479,6 +602,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():
@@ -503,6 +632,9 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn)
return
}
sm.stats.AddTx(buf[:n])
if tracker != nil {
tracker.Record(buf[:n])
}
}
}()
@@ -536,8 +668,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
@@ -545,6 +681,7 @@ func (sm *SessionManager) cleanup() {
sm.dev = nil
sm.routeMgr = nil
sm.conn = nil
sm.cidrTracker = nil
sm.mu.Unlock()
if routeMgr != nil {
@@ -558,6 +695,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()
}
@@ -647,6 +790,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)
}
}
@@ -707,3 +864,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
}