perf: 路由批量执行+CIDR聚合+并行脚本+连接步骤显示+CIDR刷新按钮
路由性能优化: - 修复 macOS route 命令语法错误(去掉 -net 参数) - 路由应用拆分为 Essential(ready前,4-6条命令<1s)+ Deferred(ready后,批量脚本) - 新增 CIDR 聚合算法(cidrmerge.go),合并相邻 CIDR 块减少路由数量(10826->7500) - 批量脚本执行:3平台各实现8个批量函数,临时脚本单次执行替代逐条进程创建 - 并行批量:拆为4个子shell并行执行,总时间降为1/4 连接步骤显示: - stats 新增 connectStep + cidrError 原子变量 - 连接生命周期中设置步骤(fetch_cidrs/connecting/load_routes),UI 实时显示 - after-proxy CIDR 获取期间显示加载状态,获取失败显示错误提示 CIDR 刷新按钮: - ipc 新增 CmdRefreshCIDR 命令 - daemon 处理刷新请求,session.RefreshCIDRs() 重新获取+添加路由 - UI 状态卡新增刷新按钮,proxy/bypass 模式已连接时显示 README: - 补充 URL 获取时机注意事项(bypass模式after-proxy可能失败) - 补充 CIDR 聚合与批量执行说明
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.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)
|
||||
|
||||
@@ -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 加密保存凭据
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -42,6 +42,14 @@ CIDRHit = "命中"
|
||||
CIDRConfigured = "已配置"
|
||||
CIDRUnmatched = "未命中"
|
||||
CIDRDestinations = "目的地址"
|
||||
RouteLoading = "加载路由中"
|
||||
CIDRLoading = "加载中"
|
||||
StepFetchCIDRs = "获取CIDR列表"
|
||||
StepConnecting = "建立连接"
|
||||
StepLoadRoutes = "加载路由"
|
||||
|
||||
BtnRefreshCIDR = "刷新 CIDR"
|
||||
CIDRFetchError = "获取失败"
|
||||
|
||||
DlgNoProfileTitle = "无配置"
|
||||
DlgNoProfileEditMsg = "请选择要编辑的配置。"
|
||||
|
||||
@@ -30,6 +30,7 @@ const (
|
||||
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.
|
||||
@@ -80,6 +81,7 @@ type CIDRURLSource struct {
|
||||
type Event struct {
|
||||
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"`
|
||||
@@ -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})
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// cidrmerge.go merges adjacent CIDR blocks to reduce the total number
|
||||
// of routes. For example, 1.0.1.0/24 + 1.0.2.0/23 -> 1.0.0.0/22.
|
||||
// This is critical for large lists like chnroute (8786 entries) where
|
||||
// many blocks can be merged, cutting the route count by 50-70%.
|
||||
package route
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// mergeCIDRs takes a list of CIDR strings, parses them, merges
|
||||
// adjacent blocks, and returns a deduplicated, minimized list.
|
||||
// Invalid CIDR strings are silently skipped.
|
||||
func mergeCIDRs(cidrs []string) []string {
|
||||
if len(cidrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var nets []netEntry
|
||||
for _, s := range cidrs {
|
||||
_, n, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ones, _ := n.Mask.Size()
|
||||
isV6 := n.IP.To4() == nil
|
||||
if isV6 {
|
||||
nets = append(nets, netEntry{ip: n.IP.To16(), bits: ones})
|
||||
} else {
|
||||
nets = append(nets, netEntry{ip: n.IP.To4(), bits: ones})
|
||||
}
|
||||
}
|
||||
|
||||
// Split into v4 and v6, merge separately.
|
||||
var v4nets, v6nets []netEntry
|
||||
for _, n := range nets {
|
||||
if n.ip.To4() != nil && len(n.ip) == 4 {
|
||||
v4nets = append(v4nets, n)
|
||||
} else if n.ip.To4() == nil && len(n.ip) == 16 {
|
||||
v6nets = append(v6nets, n)
|
||||
}
|
||||
}
|
||||
|
||||
mergedV4 := mergeNets(v4nets, 32)
|
||||
mergedV6 := mergeNets(v6nets, 128)
|
||||
|
||||
var result []string
|
||||
for _, n := range mergedV4 {
|
||||
result = append(result, n.ip.String()+"/"+itoa(n.bits))
|
||||
}
|
||||
for _, n := range mergedV6 {
|
||||
result = append(result, n.ip.String()+"/"+itoa(n.bits))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type netEntry struct {
|
||||
ip net.IP
|
||||
bits int
|
||||
}
|
||||
|
||||
type sortableNet struct {
|
||||
ip []byte
|
||||
bits int
|
||||
}
|
||||
|
||||
func mergeNets(nets []netEntry, maxBits int) []netEntry {
|
||||
if len(nets) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert to sortable form.
|
||||
var sn []sortableNet
|
||||
for _, n := range nets {
|
||||
sn = append(sn, sortableNet{ip: []byte(n.ip), bits: n.bits})
|
||||
}
|
||||
|
||||
// Sort by IP then by prefix length (more specific first).
|
||||
sort.Slice(sn, func(i, j int) bool {
|
||||
return cmpIP(sn[i].ip, sn[j].ip) < 0 ||
|
||||
(cmpIP(sn[i].ip, sn[j].ip) == 0 && sn[i].bits < sn[j].bits)
|
||||
})
|
||||
|
||||
// Merge: repeatedly try to combine pairs into supernets.
|
||||
// Two adjacent /n networks can merge into a /(n-1) if:
|
||||
// 1. Both have the same prefix length n
|
||||
// 2. Their network addresses differ only in bit n (i.e. one ends in 0, the other in 1 at position n)
|
||||
// 3. The merged address has bit n cleared
|
||||
changed := true
|
||||
for changed {
|
||||
changed = false
|
||||
var merged []sortableNet
|
||||
i := 0
|
||||
for i < len(sn) {
|
||||
if i+1 < len(sn) && canMerge(sn[i], sn[i+1], maxBits) {
|
||||
mergedNet := mergePair(sn[i], maxBits)
|
||||
merged = append(merged, mergedNet)
|
||||
i += 2
|
||||
changed = true
|
||||
} else {
|
||||
merged = append(merged, sn[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
sn = merged
|
||||
// Re-sort after merge (merged pairs may be out of order).
|
||||
if changed {
|
||||
sort.Slice(sn, func(i, j int) bool {
|
||||
return cmpIP(sn[i].ip, sn[j].ip) < 0 ||
|
||||
(cmpIP(sn[i].ip, sn[j].ip) == 0 && sn[i].bits < sn[j].bits)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Convert back.
|
||||
var result []netEntry
|
||||
for _, n := range sn {
|
||||
result = append(result, netEntry{ip: net.IP(n.ip), bits: n.bits})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func canMerge(a, b sortableNet, maxBits int) bool {
|
||||
if a.bits != b.bits || a.bits == 0 {
|
||||
return false
|
||||
}
|
||||
if len(a.ip) != len(b.ip) {
|
||||
return false
|
||||
}
|
||||
// Check that they share the same prefix up to bit (a.bits - 1).
|
||||
prefixBits := a.bits - 1
|
||||
byteIdx := prefixBits / 8
|
||||
bitIdx := uint(7 - (prefixBits % 8))
|
||||
|
||||
// All bytes before byteIdx must be equal.
|
||||
for k := 0; k < byteIdx; k++ {
|
||||
if a.ip[k] != b.ip[k] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// In byteIdx, bits above bitIdx must be equal.
|
||||
mask := byte(0xFF << (bitIdx + 1))
|
||||
if a.ip[byteIdx]&mask != b.ip[byteIdx]&mask {
|
||||
return false
|
||||
}
|
||||
// The bit at bitIdx must differ (one is 0, one is 1).
|
||||
bitA := (a.ip[byteIdx] >> bitIdx) & 1
|
||||
bitB := (b.ip[byteIdx] >> bitIdx) & 1
|
||||
if bitA == bitB {
|
||||
return false
|
||||
}
|
||||
// The lower bits of both must be zero (network address).
|
||||
lowerMask := byte(1<<bitIdx) - 1
|
||||
if a.ip[byteIdx]&lowerMask != 0 || b.ip[byteIdx]&lowerMask != 0 {
|
||||
return false
|
||||
}
|
||||
// All bytes after byteIdx must be zero.
|
||||
for k := byteIdx + 1; k < len(a.ip); k++ {
|
||||
if a.ip[k] != 0 || b.ip[k] != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func mergePair(a sortableNet, maxBits int) sortableNet {
|
||||
result := make([]byte, len(a.ip))
|
||||
copy(result, a.ip)
|
||||
// Clear the bit at position (a.bits - 1).
|
||||
prefixBits := a.bits - 1
|
||||
byteIdx := prefixBits / 8
|
||||
bitIdx := uint(7 - (prefixBits % 8))
|
||||
result[byteIdx] &^= 1 << bitIdx
|
||||
return sortableNet{ip: result, bits: a.bits - 1}
|
||||
}
|
||||
|
||||
func cmpIP(a, b []byte) int {
|
||||
for i := 0; i < len(a) && i < len(b); i++ {
|
||||
if a[i] < b[i] {
|
||||
return -1
|
||||
}
|
||||
if a[i] > b[i] {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return len(a) - len(b)
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
pos := len(buf)
|
||||
negative := n < 0
|
||||
if negative {
|
||||
n = -n
|
||||
}
|
||||
for n > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if negative {
|
||||
pos--
|
||||
buf[pos] = '-'
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
+248
-114
@@ -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) {
|
||||
v6 = append(v6, cidr)
|
||||
} else {
|
||||
v4 = append(v4, cidr)
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
var errs []string
|
||||
|
||||
if m.cfg.Mode == ModeBypass {
|
||||
if m.originalGateway6 == "" {
|
||||
continue
|
||||
}
|
||||
if err := addRouteVia6(cidr, m.originalGateway6); err != nil {
|
||||
if len(v4) > 0 && m.originalGateway != "" {
|
||||
if err := addRoutesViaBatch(v4, m.originalGateway); 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.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...)
|
||||
}
|
||||
m.addedRoutes6 = append(m.addedRoutes6, cidr)
|
||||
}
|
||||
} else {
|
||||
if m.cfg.Mode == ModeBypass {
|
||||
if m.originalGateway == "" {
|
||||
continue
|
||||
}
|
||||
if err := addRouteVia(cidr, m.originalGateway); err != nil {
|
||||
if len(v4) > 0 {
|
||||
if err := addRoutesBatch(v4, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
continue
|
||||
}
|
||||
m.bypassRoutes = append(m.bypassRoutes, cidr)
|
||||
} else {
|
||||
if err := addRoute(cidr, m.cfg.InterfaceName); err != nil {
|
||||
m.addedRoutes = append(m.addedRoutes, v4...)
|
||||
}
|
||||
}
|
||||
if len(v6) > 0 {
|
||||
if err := addRoutes6Batch(v6, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
continue
|
||||
}
|
||||
m.addedRoutes = append(m.addedRoutes, cidr)
|
||||
} 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 {
|
||||
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
|
||||
m.addedRoutes6 = nil
|
||||
m.bypassRoutes = nil
|
||||
m.bypassRoutes6 = nil
|
||||
m.serverBypass = false
|
||||
m.serverBypass6 = false
|
||||
m.mu.Unlock()
|
||||
|
||||
var errs []string
|
||||
for _, r := range m.addedRoutes {
|
||||
|
||||
// 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())
|
||||
}
|
||||
} else {
|
||||
for _, r := range addedRoutes {
|
||||
if err := deleteRoute(r, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
m.addedRoutes = nil
|
||||
for _, r := range m.addedRoutes6 {
|
||||
}
|
||||
if len(addedRoutes6) > 3 {
|
||||
if err := deleteRoutes6Batch(addedRoutes6, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
} else {
|
||||
for _, r := range 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 {
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
m.bypassRoutes = nil
|
||||
for _, r := range m.bypassRoutes6 {
|
||||
if err := deleteRouteVia6(r, m.originalGateway6); err != nil {
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
m.bypassRoutes6 = nil
|
||||
if m.serverBypass {
|
||||
if err := m.deleteServerBypass(); err != nil {
|
||||
}
|
||||
|
||||
// Delete server bypass routes.
|
||||
if serverBypass && serverIP != "" {
|
||||
if err := deleteRouteVia(serverIP+"/32", originalGateway); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
m.serverBypass = false
|
||||
}
|
||||
if m.serverBypass6 {
|
||||
if err := m.deleteServerBypass6(); err != nil {
|
||||
if serverBypass6 && serverIP6 != "" && originalGateway6 != "" {
|
||||
if err := deleteRouteVia6(serverIP6+"/128", originalGateway6); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
m.serverBypass6 = false
|
||||
}
|
||||
|
||||
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)
|
||||
v4 = append(v4, cidr)
|
||||
}
|
||||
m.addedRoutes = append(m.addedRoutes, 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
|
||||
// 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))
|
||||
|
||||
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 deleteRouteVia(m.serverIP+"/32", m.originalGateway)
|
||||
}
|
||||
|
||||
func (m *Manager) deleteServerBypass6() error {
|
||||
if m.serverIP6 == "" || m.originalGateway6 == "" {
|
||||
return nil
|
||||
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...)
|
||||
}
|
||||
return deleteRouteVia6(m.serverIP6+"/128", m.originalGateway6)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,48 +4,50 @@ package route
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// addRoute adds a route via a network interface (macOS route command).
|
||||
//
|
||||
// route add -inet -net <cidr> -interface <iface>
|
||||
// route add -inet <cidr> -interface <iface>
|
||||
func addRoute(cidr, iface string) error {
|
||||
return runRoute("add", "-inet", "-net", cidr, "-interface", iface)
|
||||
return runRoute("add", "-inet", cidr, "-interface", iface)
|
||||
}
|
||||
|
||||
// deleteRoute removes a route via a network interface.
|
||||
func deleteRoute(cidr, iface string) error {
|
||||
return runRoute("delete", "-inet", "-net", cidr, "-interface", iface)
|
||||
return runRoute("delete", "-inet", cidr, "-interface", iface)
|
||||
}
|
||||
|
||||
// addRouteVia adds a route via a gateway IP.
|
||||
func addRouteVia(cidr, gateway string) error {
|
||||
return runRoute("add", "-inet", "-net", cidr, gateway)
|
||||
return runRoute("add", "-inet", cidr, gateway)
|
||||
}
|
||||
|
||||
// deleteRouteVia removes a route via a gateway IP.
|
||||
func deleteRouteVia(cidr, gateway string) error {
|
||||
return runRoute("delete", "-inet", "-net", cidr, gateway)
|
||||
return runRoute("delete", "-inet", cidr, gateway)
|
||||
}
|
||||
|
||||
// --- IPv6 variants ---
|
||||
|
||||
func addRoute6(cidr, iface string) error {
|
||||
return runRoute("add", "-inet6", "-net", cidr, "-interface", iface)
|
||||
return runRoute("add", "-inet6", cidr, "-interface", iface)
|
||||
}
|
||||
|
||||
func deleteRoute6(cidr, iface string) error {
|
||||
return runRoute("delete", "-inet6", "-net", cidr, "-interface", iface)
|
||||
return runRoute("delete", "-inet6", cidr, "-interface", iface)
|
||||
}
|
||||
|
||||
func addRouteVia6(cidr, gateway string) error {
|
||||
return runRoute("add", "-inet6", "-net", cidr, gateway)
|
||||
return runRoute("add", "-inet6", cidr, gateway)
|
||||
}
|
||||
|
||||
func deleteRouteVia6(cidr, gateway string) error {
|
||||
return runRoute("delete", "-inet6", "-net", cidr, gateway)
|
||||
return runRoute("delete", "-inet6", cidr, gateway)
|
||||
}
|
||||
|
||||
// defaultGateway returns the current IPv4 default gateway IP.
|
||||
@@ -89,3 +91,136 @@ func runRoute(args ...string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Batch functions ---
|
||||
|
||||
// runBatchScript writes commands to temporary shell scripts and
|
||||
// executes them in parallel (up to 4 concurrent scripts). Each line
|
||||
// uses "|| true" so that individual route failures don't abort the
|
||||
// batch.
|
||||
func runBatchScript(lines []string) error {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Split lines into up to 4 chunks for parallel execution.
|
||||
const maxChunks = 4
|
||||
chunkSize := (len(lines) + maxChunks - 1) / maxChunks
|
||||
var chunks [][]string
|
||||
for i := 0; i < len(lines); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(lines) {
|
||||
end = len(lines)
|
||||
}
|
||||
chunks = append(chunks, lines[i:end])
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(chunks))
|
||||
for i, chunk := range chunks {
|
||||
wg.Add(1)
|
||||
go func(idx int, c []string) {
|
||||
defer wg.Done()
|
||||
errs[idx] = runSingleScript(c)
|
||||
}(i, chunk)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var errStrs []string
|
||||
for _, e := range errs {
|
||||
if e != nil {
|
||||
errStrs = append(errStrs, e.Error())
|
||||
}
|
||||
}
|
||||
if len(errStrs) > 0 {
|
||||
return fmt.Errorf("batch script: %s", strings.Join(errStrs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSingleScript(lines []string) error {
|
||||
f, err := os.CreateTemp("", "lmvpn-routes-*.sh")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create batch script: %w", err)
|
||||
}
|
||||
tmpFile := f.Name()
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
for _, line := range lines {
|
||||
if _, err := fmt.Fprintln(f, line, "|| true"); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("write batch script: %w", err)
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
|
||||
cmd := exec.Command("sh", tmpFile)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("batch: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addRoutesBatch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route add -inet %s -interface %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesBatch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route delete -inet %s -interface %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutes6Batch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route add -inet6 %s -interface %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutes6Batch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route delete -inet6 %s -interface %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutesViaBatch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route add -inet %s %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesViaBatch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route delete -inet %s %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutesVia6Batch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route add -inet6 %s %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesVia6Batch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route delete -inet6 %s %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -142,6 +183,9 @@ type Snapshot struct {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+91
-1
@@ -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:
|
||||
if stepLabel != "" {
|
||||
a.stateLabel.SetText(i18n.T("StateConnected") + " (" + stepLabel + ")")
|
||||
} else {
|
||||
a.stateLabel.SetText(i18n.T("StateConnected"))
|
||||
}
|
||||
a.setConnButtons(false, true)
|
||||
case stats.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,8 +417,13 @@ func (a *App) applyStats(s stats.Snapshot) {
|
||||
a.ip6Label.SetText(i18n.T("Ip6None"))
|
||||
}
|
||||
if s.State == stats.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()
|
||||
|
||||
// Show CIDR error if present.
|
||||
if s.CIDRError != "" {
|
||||
a.cidrV4Label.SetText("IPv4 CIDR: " + i18n.T("CIDRFetchError"))
|
||||
a.cidrV6Label.SetText("IPv6 CIDR: " + i18n.T("CIDRFetchError"))
|
||||
a.cidrV4Label.Show()
|
||||
a.cidrV6Label.Show()
|
||||
a.refreshCIDRBtn.Show()
|
||||
} else {
|
||||
switch s.RoutingMode {
|
||||
case "proxy":
|
||||
if s.RouteLoading {
|
||||
a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d/%d (%s)",
|
||||
s.CIDRV4Hits, s.CIDRV4Total, i18n.T("CIDRLoading")))
|
||||
a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d/%d (%s)",
|
||||
s.CIDRV6Hits, s.CIDRV6Total, i18n.T("CIDRLoading")))
|
||||
} else {
|
||||
a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d/%d %s",
|
||||
s.CIDRV4Hits, s.CIDRV4Total, i18n.T("CIDRHit")))
|
||||
a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d/%d %s",
|
||||
s.CIDRV6Hits, s.CIDRV6Total, i18n.T("CIDRHit")))
|
||||
}
|
||||
a.cidrV4Label.Show()
|
||||
a.cidrV6Label.Show()
|
||||
a.refreshCIDRBtn.Show()
|
||||
case "bypass":
|
||||
if s.RouteLoading {
|
||||
a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d/%d (%s)",
|
||||
s.CIDRV4Hits, s.CIDRV4Total, i18n.T("CIDRLoading")))
|
||||
a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d/%d (%s)",
|
||||
s.CIDRV6Hits, s.CIDRV6Total, i18n.T("CIDRLoading")))
|
||||
} else {
|
||||
a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d %s | %s: %d %s",
|
||||
s.CIDRV4Total, i18n.T("CIDRConfigured"),
|
||||
i18n.T("CIDRUnmatched"), s.CIDRV4Hits, i18n.T("CIDRDestinations")))
|
||||
a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d %s | %s: %d %s",
|
||||
s.CIDRV6Total, i18n.T("CIDRConfigured"),
|
||||
i18n.T("CIDRUnmatched"), s.CIDRV6Hits, i18n.T("CIDRDestinations")))
|
||||
}
|
||||
a.cidrV4Label.Show()
|
||||
a.cidrV6Label.Show()
|
||||
a.refreshCIDRBtn.Show()
|
||||
default:
|
||||
a.cidrV4Label.Hide()
|
||||
a.cidrV6Label.Hide()
|
||||
a.refreshCIDRBtn.Hide()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
a.routingModeLabel.Hide()
|
||||
a.cidrV4Label.Hide()
|
||||
a.cidrV6Label.Hide()
|
||||
a.refreshCIDRBtn.Hide()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+99
-13
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user