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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user