perf: 路由批量执行+CIDR聚合+并行脚本+连接步骤显示+CIDR刷新按钮
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s

路由性能优化:
- 修复 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:
2026-07-09 09:44:49 +08:00
parent 7c49ae4d72
commit ebe082500f
15 changed files with 1244 additions and 196 deletions
+210
View File
@@ -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:])
}
+269 -135
View File
@@ -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)
}
}
+144 -9
View File
@@ -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)
}
+132
View File
@@ -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)
}
+176
View File
@@ -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)
}