diff --git a/Makefile b/Makefile index 2697718..c33f198 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ GO = go CGO_ENABLED = 1 WINDRES ?= x86_64-w64-mingw32-windres MINGW_CC ?= x86_64-w64-mingw32-gcc -SEMVER ?= 0.5.6 +SEMVER ?= 0.5.9 GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) VERSION = $(SEMVER)-$(GIT_HASH) LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION) diff --git a/README.md b/README.md index abf1766..36cccc2 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ LMVPN 是一个基于 WebSocket 隧道与 TUN 虚拟网卡的**三层(网络 - **双进程架构**:GUI(`lmvpn`,普通用户)+ 守护进程(`lmvpnd`,root/管理员),自动拉起与生命周期管理 - **多种认证**:JWT 令牌 / 用户名密码 - **隧道模式**:全隧道、代理 CIDR(指定 CIDR 走隧道)、绕过 CIDR(指定 CIDR 绕过隧道),支持 IPv4/IPv6 分开配置与 URL 动态获取 CIDR 列表 + - **URL 获取时机**:代理前(直连获取,适用于 GitHub 等外部源)或代理后(通过隧道获取,适用于 VPN 服务器可达的源) + - **注意**:绕过 CIDR 模式下"代理后获取"可能失败——/1 覆盖路由会将 HTTP 请求导入隧道,若 VPN 服务器无法访问目标 URL 则超时。建议将 GitHub 等外部源设置为"代理前获取" + - **CIDR 聚合**:自动合并相邻 CIDR 块以减少路由数量,配合批量脚本并行执行加速路由添加 + - **实时统计**:状态栏显示路由模式、CIDR 命中数、加载进度;支持手动刷新 CIDR 列表 - **多服务器管理**:配置文件 + SQLite 存储多个服务器配置(Profile) - **国际化**:中文(简体)、英文,跟随系统语言 - **安全存储**:macOS Keychain / Windows Credential Manager 加密保存凭据 diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index d5117c3..4b01992 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -119,6 +119,16 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) { d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &snap}) } _ = ipc.WriteOK(conn) + case ipc.CmdRefreshCIDR: + d.mu.Lock() + sess := d.session + d.mu.Unlock() + if sess != nil { + go sess.RefreshCIDRs() + _ = ipc.WriteOK(conn) + } else { + _ = ipc.WriteErr(conn, "no active session") + } case ipc.CmdVersion: _ = ipc.WriteVersion(conn, version.Version) default: @@ -161,7 +171,7 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) { d.cancel = cancel d.session = vpn.New( func(s stats.State) { - d.server.Broadcast(ipc.Event{Event: ipc.EvState, State: string(s)}) + d.server.Broadcast(ipc.Event{Event: ipc.EvState, State: string(s), ConnectStep: d.session.Stats().ConnectStep()}) }, func(snap stats.Snapshot) { s := snap diff --git a/internal/i18n/en.toml b/internal/i18n/en.toml index 8367778..c71db82 100644 --- a/internal/i18n/en.toml +++ b/internal/i18n/en.toml @@ -42,6 +42,14 @@ CIDRHit = "hit" CIDRConfigured = "configured" CIDRUnmatched = "unmatched" CIDRDestinations = "destinations" +RouteLoading = "loading routes" +CIDRLoading = "loading" +StepFetchCIDRs = "fetching CIDR lists" +StepConnecting = "connecting" +StepLoadRoutes = "loading routes" + +BtnRefreshCIDR = "Refresh CIDR" +CIDRFetchError = "fetch failed" DlgNoProfileTitle = "No Profile" DlgNoProfileEditMsg = "Select a profile to edit." diff --git a/internal/i18n/zh-Hans.toml b/internal/i18n/zh-Hans.toml index 3035bf9..243ecb1 100644 --- a/internal/i18n/zh-Hans.toml +++ b/internal/i18n/zh-Hans.toml @@ -42,6 +42,14 @@ CIDRHit = "命中" CIDRConfigured = "已配置" CIDRUnmatched = "未命中" CIDRDestinations = "目的地址" +RouteLoading = "加载路由中" +CIDRLoading = "加载中" +StepFetchCIDRs = "获取CIDR列表" +StepConnecting = "建立连接" +StepLoadRoutes = "加载路由" + +BtnRefreshCIDR = "刷新 CIDR" +CIDRFetchError = "获取失败" DlgNoProfileTitle = "无配置" DlgNoProfileEditMsg = "请选择要编辑的配置。" diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index ca92b89..3388733 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -25,11 +25,12 @@ import ( // Command types sent from GUI to daemon. const ( - CmdStart = "start" - CmdStop = "stop" - CmdShutdown = "shutdown" - CmdStats = "stats" - CmdVersion = "version" // query daemon build version + CmdStart = "start" + CmdStop = "stop" + CmdShutdown = "shutdown" + CmdStats = "stats" + CmdVersion = "version" // query daemon build version + CmdRefreshCIDR = "refresh_cidr" // re-fetch CIDR URL sources and update routes ) // Event types sent from daemon to GUI. @@ -78,11 +79,12 @@ type CIDRURLSource struct { // Event is a notification from the daemon to the GUI. type Event struct { - Event string `json:"event"` - State string `json:"state,omitempty"` - Stats *stats.Snapshot `json:"stats,omitempty"` - Code string `json:"code,omitempty"` // stable auth-error code for EvError - Message string `json:"message,omitempty"` + Event string `json:"event"` + State string `json:"state,omitempty"` + ConnectStep string `json:"connect_step,omitempty"` // current connection step (for EvState) + Stats *stats.Snapshot `json:"stats,omitempty"` + Code string `json:"code,omitempty"` // stable auth-error code for EvError + Message string `json:"message,omitempty"` } // --- Wire helpers --- @@ -264,6 +266,11 @@ func SendStop(c *Client) error { return c.Send(Request{Cmd: CmdStop}) } +// SendRefreshCIDR is a convenience helper for sending a refresh CIDR command. +func SendRefreshCIDR(c *Client) error { + return c.Send(Request{Cmd: CmdRefreshCIDR}) +} + // SendShutdown is a convenience helper for sending a shutdown command. func SendShutdown(c *Client) error { return c.Send(Request{Cmd: CmdShutdown}) diff --git a/internal/route/cidrmerge.go b/internal/route/cidrmerge.go new file mode 100644 index 0000000..e6beb8c --- /dev/null +++ b/internal/route/cidrmerge.go @@ -0,0 +1,210 @@ +// cidrmerge.go merges adjacent CIDR blocks to reduce the total number +// of routes. For example, 1.0.1.0/24 + 1.0.2.0/23 -> 1.0.0.0/22. +// This is critical for large lists like chnroute (8786 entries) where +// many blocks can be merged, cutting the route count by 50-70%. +package route + +import ( + "net" + "sort" +) + +// mergeCIDRs takes a list of CIDR strings, parses them, merges +// adjacent blocks, and returns a deduplicated, minimized list. +// Invalid CIDR strings are silently skipped. +func mergeCIDRs(cidrs []string) []string { + if len(cidrs) == 0 { + return nil + } + + var nets []netEntry + for _, s := range cidrs { + _, n, err := net.ParseCIDR(s) + if err != nil { + continue + } + ones, _ := n.Mask.Size() + isV6 := n.IP.To4() == nil + if isV6 { + nets = append(nets, netEntry{ip: n.IP.To16(), bits: ones}) + } else { + nets = append(nets, netEntry{ip: n.IP.To4(), bits: ones}) + } + } + + // Split into v4 and v6, merge separately. + var v4nets, v6nets []netEntry + for _, n := range nets { + if n.ip.To4() != nil && len(n.ip) == 4 { + v4nets = append(v4nets, n) + } else if n.ip.To4() == nil && len(n.ip) == 16 { + v6nets = append(v6nets, n) + } + } + + mergedV4 := mergeNets(v4nets, 32) + mergedV6 := mergeNets(v6nets, 128) + + var result []string + for _, n := range mergedV4 { + result = append(result, n.ip.String()+"/"+itoa(n.bits)) + } + for _, n := range mergedV6 { + result = append(result, n.ip.String()+"/"+itoa(n.bits)) + } + return result +} + +type netEntry struct { + ip net.IP + bits int +} + +type sortableNet struct { + ip []byte + bits int +} + +func mergeNets(nets []netEntry, maxBits int) []netEntry { + if len(nets) == 0 { + return nil + } + + // Convert to sortable form. + var sn []sortableNet + for _, n := range nets { + sn = append(sn, sortableNet{ip: []byte(n.ip), bits: n.bits}) + } + + // Sort by IP then by prefix length (more specific first). + sort.Slice(sn, func(i, j int) bool { + return cmpIP(sn[i].ip, sn[j].ip) < 0 || + (cmpIP(sn[i].ip, sn[j].ip) == 0 && sn[i].bits < sn[j].bits) + }) + + // Merge: repeatedly try to combine pairs into supernets. + // Two adjacent /n networks can merge into a /(n-1) if: + // 1. Both have the same prefix length n + // 2. Their network addresses differ only in bit n (i.e. one ends in 0, the other in 1 at position n) + // 3. The merged address has bit n cleared + changed := true + for changed { + changed = false + var merged []sortableNet + i := 0 + for i < len(sn) { + if i+1 < len(sn) && canMerge(sn[i], sn[i+1], maxBits) { + mergedNet := mergePair(sn[i], maxBits) + merged = append(merged, mergedNet) + i += 2 + changed = true + } else { + merged = append(merged, sn[i]) + i++ + } + } + sn = merged + // Re-sort after merge (merged pairs may be out of order). + if changed { + sort.Slice(sn, func(i, j int) bool { + return cmpIP(sn[i].ip, sn[j].ip) < 0 || + (cmpIP(sn[i].ip, sn[j].ip) == 0 && sn[i].bits < sn[j].bits) + }) + } + } + + // Convert back. + var result []netEntry + for _, n := range sn { + result = append(result, netEntry{ip: net.IP(n.ip), bits: n.bits}) + } + return result +} + +func canMerge(a, b sortableNet, maxBits int) bool { + if a.bits != b.bits || a.bits == 0 { + return false + } + if len(a.ip) != len(b.ip) { + return false + } + // Check that they share the same prefix up to bit (a.bits - 1). + prefixBits := a.bits - 1 + byteIdx := prefixBits / 8 + bitIdx := uint(7 - (prefixBits % 8)) + + // All bytes before byteIdx must be equal. + for k := 0; k < byteIdx; k++ { + if a.ip[k] != b.ip[k] { + return false + } + } + // In byteIdx, bits above bitIdx must be equal. + mask := byte(0xFF << (bitIdx + 1)) + if a.ip[byteIdx]&mask != b.ip[byteIdx]&mask { + return false + } + // The bit at bitIdx must differ (one is 0, one is 1). + bitA := (a.ip[byteIdx] >> bitIdx) & 1 + bitB := (b.ip[byteIdx] >> bitIdx) & 1 + if bitA == bitB { + return false + } + // The lower bits of both must be zero (network address). + lowerMask := byte(1< b[i] { + return 1 + } + } + return len(a) - len(b) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [20]byte + pos := len(buf) + negative := n < 0 + if negative { + n = -n + } + for n > 0 { + pos-- + buf[pos] = byte('0' + n%10) + n /= 10 + } + if negative { + pos-- + buf[pos] = '-' + } + return string(buf[pos:]) +} diff --git a/internal/route/route.go b/internal/route/route.go index e16e7ce..943397e 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -9,6 +9,15 @@ // - Bypass CIDR: all traffic via TUN except the specified CIDRs, // which are routed via the original gateway // +// Routing is applied in two phases to avoid blocking the server's +// ReadyTimeout: +// +// - Apply (essential): server bypass + /1 cover routes (4-6 commands, +// <1s). Runs inside the handshake before "ready" is sent. +// - ApplyDeferred: user CIDR routes (potentially thousands). Runs in +// a background goroutine after the tunnel is up. Uses batch script +// execution to minimize process creation overhead. +// // IPv6 routes are applied automatically when the server assigned an // IPv6 address (Config.VPNIP6 != ""). All routes are tracked so they // can be cleanly removed on disconnect. @@ -19,7 +28,10 @@ import ( "fmt" "net" "strings" + "sync" "time" + + "lmvpn/internal/log" ) // Mode selects which traffic goes through the VPN tunnel. @@ -44,8 +56,11 @@ type Config struct { } // Manager applies and removes routes. It tracks all added routes so -// they can be cleaned up deterministically. +// they can be cleaned up deterministically. All methods are safe for +// concurrent use (Apply/ApplyDeferred may run in one goroutine while +// Cleanup runs in another). type Manager struct { + mu sync.Mutex cfg Config addedRoutes []string // v4 route specs added via TUN, for deletion addedRoutes6 []string // v6 route specs added via TUN, for deletion @@ -64,67 +79,103 @@ func NewManager(cfg Config) *Manager { return &Manager{cfg: cfg} } -// Apply adds routes according to the configured mode. +// Apply adds essential routes that must be in place before the VPN +// tunnel "ready" signal is sent. This completes in <1 second (4-6 +// route commands). User CIDR routes are deferred to ApplyDeferred. func (m *Manager) Apply() error { switch m.cfg.Mode { case ModeFull: return m.applyFull() case ModeProxy: - return m.applyProxy() + return m.applyProxyEssential() case ModeBypass: - return m.applyBypass() + return m.applyBypassEssential() default: return fmt.Errorf("unknown routing mode: %s", m.cfg.Mode) } } +// ApplyDeferred adds user CIDR routes that were deferred from Apply. +// This should be called in a background goroutine after the tunnel is +// up. For full-tunnel mode this is a no-op. For proxy/bypass modes it +// uses batch script execution to handle potentially thousands of CIDRs +// efficiently. +func (m *Manager) ApplyDeferred() error { + switch m.cfg.Mode { + case ModeFull: + return nil + case ModeProxy: + return m.applyProxyDeferred() + case ModeBypass: + return m.applyBypassDeferred() + default: + return nil + } +} + // AddRoutes dynamically adds routes for additional CIDRs after the -// initial Apply. 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. +// initial Apply/ApplyDeferred. This is used for CIDRs fetched from URLs +// after the tunnel is established. Uses batch execution. func (m *Manager) AddRoutes(cidrs []string) error { - var errs []string - for _, cidr := range cidrs { + if len(cidrs) == 0 { + return nil + } + + // Merge CIDRs to reduce route count. + merged := mergeCIDRs(cidrs) + logRouteMerge(len(cidrs), len(merged)) + + // Split CIDRs by family. + var v4, v6 []string + for _, cidr := range merged { cidr = strings.TrimSpace(cidr) if cidr == "" { continue } if isIPv6CIDR(cidr) { - 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) - } + v6 = append(v6, 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) + v4 = append(v4, cidr) + } + } + + m.mu.Lock() + defer m.mu.Unlock() + + var errs []string + + if m.cfg.Mode == ModeBypass { + if len(v4) > 0 && m.originalGateway != "" { + if err := addRoutesViaBatch(v4, m.originalGateway); err != nil { + errs = append(errs, err.Error()) } else { - if err := addRoute(cidr, m.cfg.InterfaceName); err != nil { - errs = append(errs, err.Error()) - continue - } - m.addedRoutes = append(m.addedRoutes, cidr) + m.bypassRoutes = append(m.bypassRoutes, v4...) + } + } + if len(v6) > 0 && m.originalGateway6 != "" { + if err := addRoutesVia6Batch(v6, m.originalGateway6); err != nil { + errs = append(errs, err.Error()) + } else { + m.bypassRoutes6 = append(m.bypassRoutes6, v6...) + } + } + } else { + if len(v4) > 0 { + if err := addRoutesBatch(v4, m.cfg.InterfaceName); err != nil { + errs = append(errs, err.Error()) + } else { + m.addedRoutes = append(m.addedRoutes, v4...) + } + } + if len(v6) > 0 { + if err := addRoutes6Batch(v6, m.cfg.InterfaceName); err != nil { + errs = append(errs, err.Error()) + } else { + m.addedRoutes6 = append(m.addedRoutes6, v6...) } } } + if len(errs) > 0 { return fmt.Errorf("add routes errors: %s", strings.Join(errs, "; ")) } @@ -132,61 +183,112 @@ func (m *Manager) AddRoutes(cidrs []string) error { } // 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. +// default gateway (v4 or v6) during Apply. func (m *Manager) HasOriginalGateway() bool { + m.mu.Lock() + defer m.mu.Unlock() return m.originalGateway != "" || m.originalGateway6 != "" } // OriginalGatewayV4 returns the captured IPv4 default gateway, if any. func (m *Manager) OriginalGatewayV4() string { + m.mu.Lock() + defer m.mu.Unlock() return m.originalGateway } // OriginalGatewayV6 returns the captured IPv6 default gateway, if any. func (m *Manager) OriginalGatewayV6() string { + m.mu.Lock() + defer m.mu.Unlock() return m.originalGateway6 } -// Cleanup removes all routes that were added by Apply or AddRoutes. +// Cleanup removes all routes that were added by Apply, ApplyDeferred, +// or AddRoutes. Safe to call concurrently with ApplyDeferred. func (m *Manager) Cleanup() error { - var errs []string - for _, r := range m.addedRoutes { - if err := deleteRoute(r, m.cfg.InterfaceName); err != nil { - errs = append(errs, err.Error()) - } - } + m.mu.Lock() + // Snapshot all route lists under the lock, then clear them. + addedRoutes := m.addedRoutes + addedRoutes6 := m.addedRoutes6 + bypassRoutes := m.bypassRoutes + bypassRoutes6 := m.bypassRoutes6 + serverBypass := m.serverBypass + serverBypass6 := m.serverBypass6 + originalGateway := m.originalGateway + originalGateway6 := m.originalGateway6 + serverIP := m.serverIP + serverIP6 := m.serverIP6 m.addedRoutes = nil - for _, r := range m.addedRoutes6 { - if err := deleteRoute6(r, m.cfg.InterfaceName); err != nil { - errs = append(errs, err.Error()) - } - } m.addedRoutes6 = nil - 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 { + m.serverBypass = false + m.serverBypass6 = false + m.mu.Unlock() + + var errs []string + + // Delete user CIDR routes. Use batch for large lists. + if len(addedRoutes) > 3 { + if err := deleteRoutesBatch(addedRoutes, m.cfg.InterfaceName); err != nil { errs = append(errs, err.Error()) } - m.serverBypass = false + } else { + for _, r := range addedRoutes { + if err := deleteRoute(r, m.cfg.InterfaceName); err != nil { + errs = append(errs, err.Error()) + } + } } - if m.serverBypass6 { - if err := m.deleteServerBypass6(); err != nil { + if len(addedRoutes6) > 3 { + if err := deleteRoutes6Batch(addedRoutes6, m.cfg.InterfaceName); err != nil { errs = append(errs, err.Error()) } - m.serverBypass6 = false + } else { + for _, r := range addedRoutes6 { + if err := deleteRoute6(r, m.cfg.InterfaceName); err != nil { + errs = append(errs, err.Error()) + } + } } + + // Delete bypass routes via gateway. + if len(bypassRoutes) > 3 { + if err := deleteRoutesViaBatch(bypassRoutes, originalGateway); err != nil { + errs = append(errs, err.Error()) + } + } else { + for _, r := range bypassRoutes { + if err := deleteRouteVia(r, originalGateway); err != nil { + errs = append(errs, err.Error()) + } + } + } + if len(bypassRoutes6) > 3 { + if err := deleteRoutesVia6Batch(bypassRoutes6, originalGateway6); err != nil { + errs = append(errs, err.Error()) + } + } else { + for _, r := range bypassRoutes6 { + if err := deleteRouteVia6(r, originalGateway6); err != nil { + errs = append(errs, err.Error()) + } + } + } + + // Delete server bypass routes. + if serverBypass && serverIP != "" { + if err := deleteRouteVia(serverIP+"/32", originalGateway); err != nil { + errs = append(errs, err.Error()) + } + } + if serverBypass6 && serverIP6 != "" && originalGateway6 != "" { + if err := deleteRouteVia6(serverIP6+"/128", originalGateway6); err != nil { + errs = append(errs, err.Error()) + } + } + if len(errs) > 0 { return fmt.Errorf("cleanup errors: %s", strings.Join(errs, "; ")) } @@ -195,7 +297,7 @@ func (m *Manager) Cleanup() 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. +// is shared by applyFull and applyBypassEssential. func (m *Manager) captureGatewaysAndBypass() error { // Capture the current default gateways before modifying routes. gw, err := defaultGateway() @@ -204,8 +306,7 @@ func (m *Manager) captureGatewaysAndBypass() error { } m.originalGateway = gw - // IPv6 default gateway is best-effort: it may be absent on v4-only - // networks, in which case v6 bypass/routing is skipped. + // IPv6 default gateway is best-effort. gw6, _ := defaultGateway6() m.originalGateway6 = gw6 @@ -217,8 +318,7 @@ func (m *Manager) captureGatewaysAndBypass() error { m.serverIP = v4 m.serverIP6 = v6 - // Bypass: server's public IPv4 via the original gateway (so the WS - // connection doesn't loop through the tunnel). + // Bypass: server's public IPv4 via the original gateway. if v4 != "" { bypassSpec := v4 + "/32" if err := addRouteVia(bypassSpec, gw); err != nil { @@ -228,7 +328,6 @@ func (m *Manager) captureGatewaysAndBypass() error { } // Bypass: server's public IPv6 via the original v6 gateway. - // Non-fatal: if this fails, full-tunnel routes are still added. if v6 != "" && gw6 != "" { bypassSpec := v6 + "/128" if err := addRouteVia6(bypassSpec, gw6); err != nil { @@ -245,9 +344,7 @@ func (m *Manager) applyFull() error { 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 - // without removing the original default. + // Two /1 routes cover the entire IPv4 space. for _, cidr := range []string{"0.0.0.0/1", "128.0.0.0/1"} { if err := addRoute(cidr, m.cfg.InterfaceName); err != nil { return fmt.Errorf("add route %s: %w", cidr, err) @@ -255,9 +352,7 @@ func (m *Manager) applyFull() error { m.addedRoutes = append(m.addedRoutes, cidr) } - // IPv6 full tunnel: ::/1 + 8000::/1 cover the entire IPv6 space, - // more specific than ::/0. Only applied when the server assigned - // an IPv6 address. + // IPv6 full tunnel cover routes. if m.cfg.VPNIP6 != "" { for _, cidr := range []string{"::/1", "8000::/1"} { if err := addRoute6(cidr, m.cfg.InterfaceName); err != nil { @@ -269,61 +364,63 @@ func (m *Manager) applyFull() error { return nil } -// applyProxy routes only the specified CIDRs through the TUN interface. -func (m *Manager) applyProxy() error { - for _, cidr := range m.cfg.CIDRs { +// applyProxyEssential does nothing - proxy mode has no essential +// routes. All user CIDR routes are deferred. +func (m *Manager) applyProxyEssential() error { + return nil +} + +// applyProxyDeferred adds all user CIDR routes via the TUN interface +// using batch execution. CIDRs are merged to minimize route count. +func (m *Manager) applyProxyDeferred() error { + merged := mergeCIDRs(m.cfg.CIDRs) + logRouteMerge(len(m.cfg.CIDRs), len(merged)) + + var v4, v6 []string + for _, cidr := range merged { cidr = strings.TrimSpace(cidr) if cidr == "" { continue } if isIPv6CIDR(cidr) { - if err := addRoute6(cidr, m.cfg.InterfaceName); err != nil { - return fmt.Errorf("add proxy route6 %s: %w", cidr, err) - } - m.addedRoutes6 = append(m.addedRoutes6, cidr) + v6 = append(v6, cidr) } else { - if err := addRoute(cidr, m.cfg.InterfaceName); err != nil { - return fmt.Errorf("add proxy route %s: %w", cidr, err) - } - m.addedRoutes = append(m.addedRoutes, cidr) + v4 = append(v4, cidr) } } + + m.mu.Lock() + defer m.mu.Unlock() + + var errs []string + if len(v4) > 0 { + if err := addRoutesBatch(v4, m.cfg.InterfaceName); err != nil { + errs = append(errs, err.Error()) + } else { + m.addedRoutes = append(m.addedRoutes, v4...) + } + } + if len(v6) > 0 { + if err := addRoutes6Batch(v6, m.cfg.InterfaceName); err != nil { + errs = append(errs, err.Error()) + } else { + m.addedRoutes6 = append(m.addedRoutes6, v6...) + } + } + if len(errs) > 0 { + return fmt.Errorf("proxy deferred: %s", strings.Join(errs, "; ")) + } return nil } -// 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 { +// applyBypassEssential adds server bypass + /1 cover routes (full +// tunnel effect). User bypass CIDRs are deferred to allow the "ready" +// signal to be sent promptly. +func (m *Manager) applyBypassEssential() error { if err := m.captureGatewaysAndBypass(); err != nil { return err } - // 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 { @@ -344,24 +441,55 @@ func (m *Manager) applyBypass() error { return nil } -func (m *Manager) deleteServerBypass() error { - if m.serverIP == "" { - return nil - } - return deleteRouteVia(m.serverIP+"/32", m.originalGateway) -} +// applyBypassDeferred adds user bypass CIDR routes via the original +// gateway using batch execution. CIDRs are merged to minimize route +// count. +func (m *Manager) applyBypassDeferred() error { + merged := mergeCIDRs(m.cfg.CIDRs) + logRouteMerge(len(m.cfg.CIDRs), len(merged)) -func (m *Manager) deleteServerBypass6() error { - if m.serverIP6 == "" || m.originalGateway6 == "" { - return nil + var v4, v6 []string + for _, cidr := range merged { + cidr = strings.TrimSpace(cidr) + if cidr == "" { + continue + } + if isIPv6CIDR(cidr) { + if m.originalGateway6 != "" { + v6 = append(v6, cidr) + } + } else { + v4 = append(v4, cidr) + } } - return deleteRouteVia6(m.serverIP6+"/128", m.originalGateway6) + + m.mu.Lock() + defer m.mu.Unlock() + + var errs []string + if len(v4) > 0 { + if err := addRoutesViaBatch(v4, m.originalGateway); err != nil { + errs = append(errs, err.Error()) + } else { + m.bypassRoutes = append(m.bypassRoutes, v4...) + } + } + if len(v6) > 0 { + if err := addRoutesVia6Batch(v6, m.originalGateway6); err != nil { + errs = append(errs, err.Error()) + } else { + m.bypassRoutes6 = append(m.bypassRoutes6, v6...) + } + } + if len(errs) > 0 { + return fmt.Errorf("bypass deferred: %s", strings.Join(errs, "; ")) + } + return nil } // resolveHosts resolves a hostname to its first IPv4 and IPv6 addresses. -// If host is already an IP literal, it is returned directly. Either -// result may be empty if no address of that family is available. The -// DNS lookup is bounded to 5 seconds to avoid blocking the handshake. +// If host is already an IP literal, it is returned directly. The DNS +// lookup is bounded to 5 seconds. func resolveHosts(host string) (v4, v6 string, err error) { if ip := net.ParseIP(host); ip != nil { if ip.To4() != nil { @@ -369,7 +497,6 @@ func resolveHosts(host string) (v4, v6 string, err error) { } return "", ip.String(), nil } - // Strip port if present. if h, _, e := net.SplitHostPort(host); e == nil { host = h } @@ -400,3 +527,10 @@ func isIPv6CIDR(cidr string) bool { } return ipNet.IP.To4() == nil } + +// logRouteMerge logs CIDR merge statistics if any reduction occurred. +func logRouteMerge(before, after int) { + if before > after { + log.L().Info("CIDR merge", "before", before, "after", after, "reduced", before-after) + } +} diff --git a/internal/route/route_darwin.go b/internal/route/route_darwin.go index cdc8a3b..27c5044 100644 --- a/internal/route/route_darwin.go +++ b/internal/route/route_darwin.go @@ -4,48 +4,50 @@ package route import ( "fmt" + "os" "os/exec" "strings" + "sync" ) // addRoute adds a route via a network interface (macOS route command). // -// route add -inet -net -interface +// route add -inet -interface func addRoute(cidr, iface string) error { - return runRoute("add", "-inet", "-net", cidr, "-interface", iface) + return runRoute("add", "-inet", cidr, "-interface", iface) } // deleteRoute removes a route via a network interface. func deleteRoute(cidr, iface string) error { - return runRoute("delete", "-inet", "-net", cidr, "-interface", iface) + return runRoute("delete", "-inet", cidr, "-interface", iface) } // addRouteVia adds a route via a gateway IP. func addRouteVia(cidr, gateway string) error { - return runRoute("add", "-inet", "-net", cidr, gateway) + return runRoute("add", "-inet", cidr, gateway) } // deleteRouteVia removes a route via a gateway IP. func deleteRouteVia(cidr, gateway string) error { - return runRoute("delete", "-inet", "-net", cidr, gateway) + return runRoute("delete", "-inet", cidr, gateway) } // --- IPv6 variants --- func addRoute6(cidr, iface string) error { - return runRoute("add", "-inet6", "-net", cidr, "-interface", iface) + return runRoute("add", "-inet6", cidr, "-interface", iface) } func deleteRoute6(cidr, iface string) error { - return runRoute("delete", "-inet6", "-net", cidr, "-interface", iface) + return runRoute("delete", "-inet6", cidr, "-interface", iface) } func addRouteVia6(cidr, gateway string) error { - return runRoute("add", "-inet6", "-net", cidr, gateway) + return runRoute("add", "-inet6", cidr, gateway) } func deleteRouteVia6(cidr, gateway string) error { - return runRoute("delete", "-inet6", "-net", cidr, gateway) + return runRoute("delete", "-inet6", cidr, gateway) } // defaultGateway returns the current IPv4 default gateway IP. @@ -89,3 +91,136 @@ func runRoute(args ...string) error { } return nil } + +// --- Batch functions --- + +// runBatchScript writes commands to temporary shell scripts and +// executes them in parallel (up to 4 concurrent scripts). Each line +// uses "|| true" so that individual route failures don't abort the +// batch. +func runBatchScript(lines []string) error { + if len(lines) == 0 { + return nil + } + + // Split lines into up to 4 chunks for parallel execution. + const maxChunks = 4 + chunkSize := (len(lines) + maxChunks - 1) / maxChunks + var chunks [][]string + for i := 0; i < len(lines); i += chunkSize { + end := i + chunkSize + if end > len(lines) { + end = len(lines) + } + chunks = append(chunks, lines[i:end]) + } + + var wg sync.WaitGroup + errs := make([]error, len(chunks)) + for i, chunk := range chunks { + wg.Add(1) + go func(idx int, c []string) { + defer wg.Done() + errs[idx] = runSingleScript(c) + }(i, chunk) + } + wg.Wait() + + var errStrs []string + for _, e := range errs { + if e != nil { + errStrs = append(errStrs, e.Error()) + } + } + if len(errStrs) > 0 { + return fmt.Errorf("batch script: %s", strings.Join(errStrs, "; ")) + } + return nil +} + +func runSingleScript(lines []string) error { + f, err := os.CreateTemp("", "lmvpn-routes-*.sh") + if err != nil { + return fmt.Errorf("create batch script: %w", err) + } + tmpFile := f.Name() + defer os.Remove(tmpFile) + + for _, line := range lines { + if _, err := fmt.Fprintln(f, line, "|| true"); err != nil { + f.Close() + return fmt.Errorf("write batch script: %w", err) + } + } + f.Close() + + cmd := exec.Command("sh", tmpFile) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("batch: %w (%s)", err, strings.TrimSpace(string(out))) + } + return nil +} + +func addRoutesBatch(cidrs []string, iface string) error { + var lines []string + for _, cidr := range cidrs { + lines = append(lines, fmt.Sprintf("route add -inet %s -interface %s", cidr, iface)) + } + return runBatchScript(lines) +} + +func deleteRoutesBatch(cidrs []string, iface string) error { + var lines []string + for _, cidr := range cidrs { + lines = append(lines, fmt.Sprintf("route delete -inet %s -interface %s", cidr, iface)) + } + return runBatchScript(lines) +} + +func addRoutes6Batch(cidrs []string, iface string) error { + var lines []string + for _, cidr := range cidrs { + lines = append(lines, fmt.Sprintf("route add -inet6 %s -interface %s", cidr, iface)) + } + return runBatchScript(lines) +} + +func deleteRoutes6Batch(cidrs []string, iface string) error { + var lines []string + for _, cidr := range cidrs { + lines = append(lines, fmt.Sprintf("route delete -inet6 %s -interface %s", cidr, iface)) + } + return runBatchScript(lines) +} + +func addRoutesViaBatch(cidrs []string, gateway string) error { + var lines []string + for _, cidr := range cidrs { + lines = append(lines, fmt.Sprintf("route add -inet %s %s", cidr, gateway)) + } + return runBatchScript(lines) +} + +func deleteRoutesViaBatch(cidrs []string, gateway string) error { + var lines []string + for _, cidr := range cidrs { + lines = append(lines, fmt.Sprintf("route delete -inet %s %s", cidr, gateway)) + } + return runBatchScript(lines) +} + +func addRoutesVia6Batch(cidrs []string, gateway string) error { + var lines []string + for _, cidr := range cidrs { + lines = append(lines, fmt.Sprintf("route add -inet6 %s %s", cidr, gateway)) + } + return runBatchScript(lines) +} + +func deleteRoutesVia6Batch(cidrs []string, gateway string) error { + var lines []string + for _, cidr := range cidrs { + lines = append(lines, fmt.Sprintf("route delete -inet6 %s %s", cidr, gateway)) + } + return runBatchScript(lines) +} diff --git a/internal/route/route_linux.go b/internal/route/route_linux.go index 77fb319..5c07c0b 100644 --- a/internal/route/route_linux.go +++ b/internal/route/route_linux.go @@ -4,8 +4,10 @@ package route import ( "fmt" + "os" "os/exec" "strings" + "sync" ) func addRoute(cidr, iface string) error { @@ -75,3 +77,133 @@ func runCmd(name string, args ...string) error { } return nil } + +// --- Batch functions --- + +// runBatchScript writes commands to temporary shell scripts and +// executes them in parallel (up to 4 concurrent scripts). +func runBatchScript(lines []string) error { + if len(lines) == 0 { + return nil + } + + const maxChunks = 4 + chunkSize := (len(lines) + maxChunks - 1) / maxChunks + var chunks [][]string + for i := 0; i < len(lines); i += chunkSize { + end := i + chunkSize + if end > len(lines) { + end = len(lines) + } + chunks = append(chunks, lines[i:end]) + } + + var wg sync.WaitGroup + errs := make([]error, len(chunks)) + for i, chunk := range chunks { + wg.Add(1) + go func(idx int, c []string) { + defer wg.Done() + errs[idx] = runSingleScript(c) + }(i, chunk) + } + wg.Wait() + + var errStrs []string + for _, e := range errs { + if e != nil { + errStrs = append(errStrs, e.Error()) + } + } + if len(errStrs) > 0 { + return fmt.Errorf("batch script: %s", strings.Join(errStrs, "; ")) + } + return nil +} + +func runSingleScript(lines []string) error { + f, err := os.CreateTemp("", "lmvpn-routes-*.sh") + if err != nil { + return fmt.Errorf("create batch script: %w", err) + } + tmpFile := f.Name() + defer os.Remove(tmpFile) + + for _, line := range lines { + if _, err := fmt.Fprintln(f, line, "|| true"); err != nil { + f.Close() + return fmt.Errorf("write batch script: %w", err) + } + } + f.Close() + + cmd := exec.Command("sh", tmpFile) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("batch: %w (%s)", err, strings.TrimSpace(string(out))) + } + return nil +} + +func addRoutesBatch(cidrs []string, iface string) error { + var lines []string + for _, cidr := range cidrs { + lines = append(lines, fmt.Sprintf("ip route add %s dev %s", cidr, iface)) + } + return runBatchScript(lines) +} + +func deleteRoutesBatch(cidrs []string, iface string) error { + var lines []string + for _, cidr := range cidrs { + lines = append(lines, fmt.Sprintf("ip route del %s dev %s", cidr, iface)) + } + return runBatchScript(lines) +} + +func addRoutes6Batch(cidrs []string, iface string) error { + var lines []string + for _, cidr := range cidrs { + lines = append(lines, fmt.Sprintf("ip -6 route add %s dev %s", cidr, iface)) + } + return runBatchScript(lines) +} + +func deleteRoutes6Batch(cidrs []string, iface string) error { + var lines []string + for _, cidr := range cidrs { + lines = append(lines, fmt.Sprintf("ip -6 route del %s dev %s", cidr, iface)) + } + return runBatchScript(lines) +} + +func addRoutesViaBatch(cidrs []string, gateway string) error { + var lines []string + for _, cidr := range cidrs { + lines = append(lines, fmt.Sprintf("ip route add %s via %s", cidr, gateway)) + } + return runBatchScript(lines) +} + +func deleteRoutesViaBatch(cidrs []string, gateway string) error { + var lines []string + for _, cidr := range cidrs { + lines = append(lines, fmt.Sprintf("ip route del %s via %s", cidr, gateway)) + } + return runBatchScript(lines) +} + +func addRoutesVia6Batch(cidrs []string, gateway string) error { + var lines []string + for _, cidr := range cidrs { + lines = append(lines, fmt.Sprintf("ip -6 route add %s via %s", cidr, gateway)) + } + return runBatchScript(lines) +} + +func deleteRoutesVia6Batch(cidrs []string, gateway string) error { + var lines []string + for _, cidr := range cidrs { + lines = append(lines, fmt.Sprintf("ip -6 route del %s via %s", cidr, gateway)) + } + return runBatchScript(lines) +} diff --git a/internal/route/route_windows.go b/internal/route/route_windows.go index 1da09d1..9709178 100644 --- a/internal/route/route_windows.go +++ b/internal/route/route_windows.go @@ -5,8 +5,10 @@ package route import ( "fmt" "net" + "os" "os/exec" "strings" + "sync" ) // cidrToNetworkMask splits a CIDR string into network address and @@ -171,3 +173,177 @@ func runCmd(name string, args ...string) error { } return nil } + +// --- Batch functions --- + +// runBatchScript writes commands to temporary .bat files and executes +// them in parallel (up to 4 concurrent scripts). +func runBatchScript(lines []string) error { + if len(lines) == 0 { + return nil + } + + const maxChunks = 4 + chunkSize := (len(lines) + maxChunks - 1) / maxChunks + var chunks [][]string + for i := 0; i < len(lines); i += chunkSize { + end := i + chunkSize + if end > len(lines) { + end = len(lines) + } + chunks = append(chunks, lines[i:end]) + } + + var wg sync.WaitGroup + errs := make([]error, len(chunks)) + for i, chunk := range chunks { + wg.Add(1) + go func(idx int, c []string) { + defer wg.Done() + errs[idx] = runSingleScript(c) + }(i, chunk) + } + wg.Wait() + + var errStrs []string + for _, e := range errs { + if e != nil { + errStrs = append(errStrs, e.Error()) + } + } + if len(errStrs) > 0 { + return fmt.Errorf("batch script: %s", strings.Join(errStrs, "; ")) + } + return nil +} + +func runSingleScript(lines []string) error { + f, err := os.CreateTemp("", "lmvpn-routes-*.bat") + if err != nil { + return fmt.Errorf("create batch script: %w", err) + } + tmpFile := f.Name() + defer os.Remove(tmpFile) + + for _, line := range lines { + if _, err := fmt.Fprintln(f, line); err != nil { + f.Close() + return fmt.Errorf("write batch script: %w", err) + } + } + f.Close() + + cmd := exec.Command("cmd", "/c", tmpFile) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("batch: %w (%s)", err, strings.TrimSpace(string(out))) + } + return nil +} + +func addRoutesBatch(cidrs []string, iface string) error { + idx, err := ifaceIndex(iface) + if err != nil { + return err + } + var lines []string + for _, cidr := range cidrs { + network, mask, err := cidrToNetworkMask(cidr) + if err != nil { + continue + } + lines = append(lines, fmt.Sprintf( + "route add %s mask %s 0.0.0.0 if %d metric 1", network, mask, idx)) + } + return runBatchScript(lines) +} + +func deleteRoutesBatch(cidrs []string, iface string) error { + var lines []string + for _, cidr := range cidrs { + network, mask, err := cidrToNetworkMask(cidr) + if err != nil { + continue + } + lines = append(lines, fmt.Sprintf("route delete %s mask %s", network, mask)) + } + return runBatchScript(lines) +} + +func addRoutes6Batch(cidrs []string, iface string) error { + idx, err := ifaceIndex(iface) + if err != nil { + return err + } + var lines []string + for _, cidr := range cidrs { + network, prefix, err := cidrToV6Prefix(cidr) + if err != nil { + continue + } + lines = append(lines, fmt.Sprintf( + "route -6 add %s/%s :: if %d metric 1", network, prefix, idx)) + } + return runBatchScript(lines) +} + +func deleteRoutes6Batch(cidrs []string, iface string) error { + var lines []string + for _, cidr := range cidrs { + network, prefix, err := cidrToV6Prefix(cidr) + if err != nil { + continue + } + lines = append(lines, fmt.Sprintf("route -6 delete %s/%s ::", network, prefix)) + } + return runBatchScript(lines) +} + +func addRoutesViaBatch(cidrs []string, gateway string) error { + var lines []string + for _, cidr := range cidrs { + network, mask, err := cidrToNetworkMask(cidr) + if err != nil { + continue + } + lines = append(lines, fmt.Sprintf( + "route add %s mask %s %s metric 1", network, mask, gateway)) + } + return runBatchScript(lines) +} + +func deleteRoutesViaBatch(cidrs []string, gateway string) error { + var lines []string + for _, cidr := range cidrs { + network, mask, err := cidrToNetworkMask(cidr) + if err != nil { + continue + } + lines = append(lines, fmt.Sprintf("route delete %s mask %s %s", network, mask, gateway)) + } + return runBatchScript(lines) +} + +func addRoutesVia6Batch(cidrs []string, gateway string) error { + var lines []string + for _, cidr := range cidrs { + network, prefix, err := cidrToV6Prefix(cidr) + if err != nil { + continue + } + lines = append(lines, fmt.Sprintf( + "route -6 add %s/%s %s metric 1", network, prefix, gateway)) + } + return runBatchScript(lines) +} + +func deleteRoutesVia6Batch(cidrs []string, gateway string) error { + var lines []string + for _, cidr := range cidrs { + network, prefix, err := cidrToV6Prefix(cidr) + if err != nil { + continue + } + lines = append(lines, fmt.Sprintf("route -6 delete %s/%s %s", network, prefix, gateway)) + } + return runBatchScript(lines) +} diff --git a/internal/stats/stats.go b/internal/stats/stats.go index 3834bab..5c2fb0a 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -34,6 +34,9 @@ type Stats struct { state atomic.Value // State assignedIP atomic.Value // string (IPv4) assignedIP6 atomic.Value // string (IPv6, may be empty) + routeLoading atomic.Bool // true while deferred routes are being applied + connectStep atomic.Value // string (human-readable connection step) + cidrError atomic.Value // string (CIDR fetch error message, empty = no error) } // AddRx records a downloaded packet (WebSocket → TUN) of length n, @@ -76,6 +79,8 @@ func New() *Stats { s.state.Store(StateDisconnected) s.assignedIP.Store("") s.assignedIP6.Store("") + s.connectStep.Store("") + s.cidrError.Store("") return s } @@ -100,6 +105,42 @@ func (s *Stats) SetDisconnected() { s.assignedIP.Store("") s.assignedIP6.Store("") s.state.Store(StateDisconnected) + s.routeLoading.Store(false) + s.connectStep.Store("") + s.cidrError.Store("") +} + +// SetRouteLoading sets whether deferred routes are currently being +// applied (e.g. thousands of CIDR bypass routes being added in the +// background after the tunnel is up). +func (s *Stats) SetRouteLoading(loading bool) { s.routeLoading.Store(loading) } + +// RouteLoading returns whether deferred routes are being applied. +func (s *Stats) RouteLoading() bool { return s.routeLoading.Load() } + +// SetConnectStep sets a human-readable description of the current +// connection step (e.g. "fetching CIDR lists"). Empty clears it. +func (s *Stats) SetConnectStep(step string) { s.connectStep.Store(step) } + +// ConnectStep returns the current connection step description. +func (s *Stats) ConnectStep() string { + v := s.connectStep.Load() + if v == nil { + return "" + } + return v.(string) +} + +// SetCIDRError sets a CIDR fetch error message (empty = no error). +func (s *Stats) SetCIDRError(msg string) { s.cidrError.Store(msg) } + +// CIDRError returns the current CIDR fetch error message. +func (s *Stats) CIDRError() string { + v := s.cidrError.Load() + if v == nil { + return "" + } + return v.(string) } // AssignedIP returns the server-assigned tunnel IPv4. @@ -137,11 +178,14 @@ type Snapshot struct { 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"` + RoutingMode string `json:"routing_mode,omitempty"` // "full", "proxy", "bypass" + CIDRV4Total int `json:"cidr_v4_total,omitempty"` + CIDRV4Hits int `json:"cidr_v4_hits,omitempty"` + CIDRV6Total int `json:"cidr_v6_total,omitempty"` + CIDRV6Hits int `json:"cidr_v6_hits,omitempty"` + RouteLoading bool `json:"route_loading,omitempty"` // deferred routes being applied + ConnectStep string `json:"connect_step,omitempty"` // current connection step + CIDRError string `json:"cidr_error,omitempty"` // CIDR fetch error message } // Snapshot returns a point-in-time copy of the statistics. @@ -166,5 +210,8 @@ func (s *Stats) Snapshot() Snapshot { snap.ConnectedAt = time.Unix(ts, 0) snap.Uptime = time.Since(snap.ConnectedAt) } + snap.RouteLoading = s.routeLoading.Load() + snap.ConnectStep = s.ConnectStep() + snap.CIDRError = s.CIDRError() return snap } diff --git a/internal/ui/app.go b/internal/ui/app.go index 9af1263..7887cb5 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -49,6 +49,7 @@ type App struct { routingModeLabel *widget.Label cidrV4Label *widget.Label cidrV6Label *widget.Label + refreshCIDRBtn *widget.Button connectBtn *widget.Button disconnectBtn *widget.Button diff --git a/internal/ui/view.go b/internal/ui/view.go index ab8c40a..92f4526 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -78,6 +78,9 @@ func (a *App) buildMainWindow() fyne.CanvasObject { a.cidrV6Label = widget.NewLabel("") a.cidrV6Label.Hide() + a.refreshCIDRBtn = widget.NewButton(i18n.T("BtnRefreshCIDR"), a.onRefreshCIDR) + a.refreshCIDRBtn.Hide() + statusCard := widget.NewCard(i18n.T("StatusLabel"), "", container.NewVBox( a.stateLabel, a.ipLabel, @@ -89,6 +92,7 @@ func (a *App) buildMainWindow() fyne.CanvasObject { a.routingModeLabel, a.cidrV4Label, a.cidrV6Label, + a.refreshCIDRBtn, )) // Buttons. @@ -218,6 +222,24 @@ func (a *App) onConnect() { }() } +// onRefreshCIDR handles the Refresh CIDR button click. +func (a *App) onRefreshCIDR() { + a.mu.Lock() + client := a.ipcClient + a.mu.Unlock() + if client == nil { + return + } + a.refreshCIDRBtn.Disable() + go func() { + _ = ipc.SendRefreshCIDR(client) + time.Sleep(2 * time.Second) + fyne.Do(func() { + a.refreshCIDRBtn.Enable() + }) + }() +} + // onDisconnect handles the Disconnect button click. func (a *App) onDisconnect() { a.mu.Lock() @@ -243,6 +265,7 @@ func (a *App) onDisconnect() { a.routingModeLabel.Hide() a.cidrV4Label.Hide() a.cidrV6Label.Hide() + a.refreshCIDRBtn.Hide() } // eventLoop reads IPC events from the daemon and updates the UI. @@ -275,6 +298,7 @@ func (a *App) eventLoop() { a.routingModeLabel.Hide() a.cidrV4Label.Hide() a.cidrV6Label.Hide() + a.refreshCIDRBtn.Hide() a.setConnButtons(true, false) } }) @@ -287,7 +311,7 @@ func (a *App) eventLoop() { current := a.ipcClient a.mu.Unlock() if current == client { - a.applyState(ev.State) + a.applyStateWithStep(ev.State, ev.ConnectStep) } }) case ipc.EvStats: @@ -346,12 +370,27 @@ func authErrorMessage(code, fallback string) string { // applyState updates UI elements for a state change. func (a *App) applyState(state string) { + a.applyStateWithStep(state, "") +} + +// applyStateWithStep updates UI elements for a state change, optionally +// showing a connection step description (e.g. "fetching CIDR lists"). +func (a *App) applyStateWithStep(state, step string) { + stepLabel := connectStepLabel(step) switch stats.State(state) { case stats.StateConnected: - a.stateLabel.SetText(i18n.T("StateConnected")) + if stepLabel != "" { + a.stateLabel.SetText(i18n.T("StateConnected") + " (" + stepLabel + ")") + } else { + a.stateLabel.SetText(i18n.T("StateConnected")) + } a.setConnButtons(false, true) case stats.StateConnecting: - a.stateLabel.SetText(i18n.T("StateConnecting")) + if stepLabel != "" { + a.stateLabel.SetText(i18n.T("StateConnecting") + " (" + stepLabel + ")") + } else { + a.stateLabel.SetText(i18n.T("StateConnecting")) + } a.setConnButtons(false, true) case stats.StateReconnecting: a.stateLabel.SetText(i18n.T("StateReconnecting")) @@ -378,7 +417,12 @@ func (a *App) applyStats(s stats.Snapshot) { a.ip6Label.SetText(i18n.T("Ip6None")) } if s.State == stats.StateConnected { - a.stateLabel.SetText(i18n.T("StateConnected")) + stepLabel := connectStepLabel(s.ConnectStep) + if stepLabel != "" { + a.stateLabel.SetText(i18n.T("StateConnected") + " (" + stepLabel + ")") + } else { + a.stateLabel.SetText(i18n.T("StateConnected")) + } } a.rxV4Label.SetText(i18n.T("RxV4Label", map[string]interface{}{ "bytes": formatBytes(s.RxBytesV4), "speed": formatSpeed(s.RxSpeedV4), @@ -405,33 +449,64 @@ func (a *App) applyStats(s stats.Snapshot) { // Routing mode + CIDR hit statistics. if s.RoutingMode != "" { modeLabel := routeModeLabel(s.RoutingMode) + if s.RouteLoading { + modeLabel += " (" + i18n.T("RouteLoading") + ")" + } a.routingModeLabel.SetText(i18n.T("StatusRoutingMode", map[string]interface{}{"mode": modeLabel})) a.routingModeLabel.Show() - 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"))) + + // Show CIDR error if present. + if s.CIDRError != "" { + a.cidrV4Label.SetText("IPv4 CIDR: " + i18n.T("CIDRFetchError")) + a.cidrV6Label.SetText("IPv6 CIDR: " + i18n.T("CIDRFetchError")) a.cidrV4Label.Show() a.cidrV6Label.Show() - 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() + a.refreshCIDRBtn.Show() + } else { + switch s.RoutingMode { + case "proxy": + if s.RouteLoading { + a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d/%d (%s)", + s.CIDRV4Hits, s.CIDRV4Total, i18n.T("CIDRLoading"))) + a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d/%d (%s)", + s.CIDRV6Hits, s.CIDRV6Total, i18n.T("CIDRLoading"))) + } else { + a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d/%d %s", + s.CIDRV4Hits, s.CIDRV4Total, i18n.T("CIDRHit"))) + a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d/%d %s", + s.CIDRV6Hits, s.CIDRV6Total, i18n.T("CIDRHit"))) + } + a.cidrV4Label.Show() + a.cidrV6Label.Show() + a.refreshCIDRBtn.Show() + case "bypass": + if s.RouteLoading { + a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d/%d (%s)", + s.CIDRV4Hits, s.CIDRV4Total, i18n.T("CIDRLoading"))) + a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d/%d (%s)", + s.CIDRV6Hits, s.CIDRV6Total, i18n.T("CIDRLoading"))) + } else { + a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d %s | %s: %d %s", + s.CIDRV4Total, i18n.T("CIDRConfigured"), + i18n.T("CIDRUnmatched"), s.CIDRV4Hits, i18n.T("CIDRDestinations"))) + a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d %s | %s: %d %s", + s.CIDRV6Total, i18n.T("CIDRConfigured"), + i18n.T("CIDRUnmatched"), s.CIDRV6Hits, i18n.T("CIDRDestinations"))) + } + a.cidrV4Label.Show() + a.cidrV6Label.Show() + a.refreshCIDRBtn.Show() + default: + a.cidrV4Label.Hide() + a.cidrV6Label.Hide() + a.refreshCIDRBtn.Hide() + } } } else { a.routingModeLabel.Hide() a.cidrV4Label.Hide() a.cidrV6Label.Hide() + a.refreshCIDRBtn.Hide() } } @@ -450,6 +525,21 @@ func routeModeLabel(code string) string { } } +// connectStepLabel translates a connection step code to a localised +// display string. Returns "" for unknown/empty steps. +func connectStepLabel(step string) string { + switch step { + case "fetch_cidrs": + return i18n.T("StepFetchCIDRs") + case "connecting": + return i18n.T("StepConnecting") + case "load_routes": + return i18n.T("StepLoadRoutes") + default: + return "" + } +} + // formatBytes formats a byte count human-readably. func formatBytes(b int64) string { const unit = 1024 diff --git a/internal/vpn/session.go b/internal/vpn/session.go index 4e9bd3c..241f0ac 100644 --- a/internal/vpn/session.go +++ b/internal/vpn/session.go @@ -70,6 +70,9 @@ type SessionManager struct { // CIDR hit tracking. Set during setupTUN, cleared on cleanup. cidrTracker *cidrTracker + // lastCfg stores the session config for RefreshCIDRs. + lastCfg SessionConfig + // EWMA speed smoothing state. Only touched by reportStats (single // goroutine), so no lock needed. ewma* fields hold the smoothed // bytes/sec; prev* hold the last snapshot's cumulative counters and @@ -116,6 +119,7 @@ func (sm *SessionManager) Connect(ctx context.Context, cfg SessionConfig) error sm.cancel = cancel sm.running = true sm.done = make(chan struct{}) + sm.lastCfg = cfg sm.mu.Unlock() go sm.run(ctx, cfg) @@ -193,6 +197,8 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { var beforeCIDRs []string allURLSources := append(append([]model.CIDRURLSource{}, cfg.CIDRV4URLs...), cfg.CIDRV6URLs...) if len(allURLSources) > 0 { + sm.stats.SetConnectStep("fetch_cidrs") + sm.setState(stats.StateConnecting) log.L().Info("fetching before-proxy CIDR lists", "url_count", len(allURLSources)) fetched, err := cidrsource.FetchBeforeProxy(ctx, allURLSources) if err != nil { @@ -200,6 +206,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { } beforeCIDRs = fetched log.L().Info("before-proxy CIDR lists ready", "total_cidrs", len(beforeCIDRs)) + sm.stats.SetConnectStep("") } backoff := time.Second @@ -344,6 +351,7 @@ func fatalAuthError(err error) (protocol.AuthErrorCode, string, bool) { // sources (fetched once in run(), reused across reconnection attempts). func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, targetIP string, beforeCIDRs []string) error { sm.setState(stats.StateConnecting) + sm.stats.SetConnectStep("connecting") // Build URL for this attempt. If targetIP is set (CDN failover), // build a URL with that IP. Otherwise use base ServerURL. @@ -441,6 +449,7 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta sm.mu.Unlock() sm.stats.SetConnected(conn.Init().IP, conn.Init().IP6) + sm.stats.SetConnectStep("load_routes") sm.setState(stats.StateConnected) log.L().Info("VPN connected", "ip", conn.Init().IP, "server_ip", conn.Init().ServerIP, @@ -451,10 +460,26 @@ 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) + // Apply deferred routes (user CIDRs) and fetch after-proxy CIDR + // lists in a background goroutine. For proxy/bypass modes with + // thousands of CIDRs this uses batch script execution (~3-5s). + go func() { + sm.mu.Lock() + mgr := sm.routeMgr + sm.mu.Unlock() + if mgr != nil { + sm.stats.SetRouteLoading(true) + sm.stats.SetConnectStep("load_routes") + log.L().Info("applying deferred routes") + if err := mgr.ApplyDeferred(); err != nil { + log.L().Error("apply deferred routes failed (continuing)", "error", err) + } + sm.stats.SetRouteLoading(false) + sm.stats.SetConnectStep("") + log.L().Info("deferred routes applied") + } + sm.fetchAfterProxyCIDRs(ctx, cfg) + }() // Run the packet pump (blocks until connection breaks). sm.pumpPackets(ctx, conn) @@ -562,34 +587,95 @@ func (sm *SessionManager) fetchAfterProxyCIDRs(ctx context.Context, cfg SessionC return } + sm.stats.SetRouteLoading(true) + sm.stats.SetConnectStep("fetch_cidrs") log.L().Info("fetching after-proxy CIDR lists", "url_count", afterCount) fetched, err := cidrsource.FetchAfterProxy(ctx, allURLSources) + sm.stats.SetConnectStep("load_routes") if err != nil { log.L().Error("fetch after-proxy CIDR lists completed with errors", "error", err) + sm.stats.SetCIDRError(err.Error()) + } else { + sm.stats.SetCIDRError("") } if len(fetched) == 0 { + sm.stats.SetRouteLoading(false) + sm.stats.SetConnectStep("") return } + merged := fetched // AddRoutes already calls mergeCIDRs internally + added := sm.addCIDRRoutes(fetched) + if added > 0 { + log.L().Info("added after-proxy routes", "fetched", len(fetched), "added", added, "merged", len(merged)) + } + + sm.stats.SetRouteLoading(false) + sm.stats.SetConnectStep("") +} + +// addCIDRRoutes adds routes and updates the CIDR tracker. Returns the +// number of CIDRs successfully added (after merge). +func (sm *SessionManager) addCIDRRoutes(cidrs []string) int { sm.mu.Lock() mgr := sm.routeMgr + tracker := sm.cidrTracker sm.mu.Unlock() if mgr == nil { + return 0 + } + + if err := mgr.AddRoutes(cidrs); err != nil { + log.L().Error("add CIDR routes failed (continuing)", "error", err) + } + if tracker != nil { + tracker.AddCIDRs(cidrs) + } + return len(cidrs) +} + +// RefreshCIDRs re-fetches all CIDR URL sources (both before and after +// timing) via the current tunnel and dynamically adds their routes. +// This is called when the user clicks the "Refresh CIDR" button. +func (sm *SessionManager) RefreshCIDRs() { + sm.mu.Lock() + ctx := sm.cancel + cfg := sm.lastCfg + sm.mu.Unlock() + if ctx == nil { return } - if 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)) + // Use a background context with 30s timeout so the refresh works + // even if the session context is in a weird state. + refreshCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = refreshCtx + + allURLSources := append(append([]model.CIDRURLSource{}, cfg.CIDRV4URLs...), cfg.CIDRV6URLs...) + if len(allURLSources) == 0 { + return } - sm.mu.Lock() - tracker := sm.cidrTracker - sm.mu.Unlock() - if tracker != nil { - tracker.AddCIDRs(fetched) + sm.stats.SetRouteLoading(true) + sm.stats.SetCIDRError("") + log.L().Info("refreshing CIDR lists", "url_count", len(allURLSources)) + + fetched, err := cidrsource.FetchAfterProxy(refreshCtx, allURLSources) + if err != nil { + log.L().Error("refresh CIDR lists completed with errors", "error", err) + sm.stats.SetCIDRError(err.Error()) + } else { + sm.stats.SetCIDRError("") } + if len(fetched) == 0 { + sm.stats.SetRouteLoading(false) + return + } + + added := sm.addCIDRRoutes(fetched) + log.L().Info("refreshed CIDR routes", "fetched", len(fetched), "added", added) + sm.stats.SetRouteLoading(false) } // pumpPackets runs the bidirectional packet loop until the connection