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