路由性能优化: - 修复 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 聚合与批量执行说明
537 lines
15 KiB
Go
537 lines
15 KiB
Go
// Package route manages VPN routing on the client. It supports three
|
|
// modes:
|
|
//
|
|
// - Full tunnel: all traffic (0.0.0.0/0 and ::/0) via the TUN
|
|
// interface, with bypass routes for the server's
|
|
// public IP (v4 and v6) so the WebSocket connection
|
|
// stays on the physical NIC
|
|
// - Proxy CIDR: only the specified CIDRs (v4 and v6) via TUN
|
|
// - Bypass CIDR: all traffic via TUN except the specified CIDRs,
|
|
// which are routed via the original gateway
|
|
//
|
|
// 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.
|
|
package route
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"lmvpn/internal/log"
|
|
)
|
|
|
|
// Mode selects which traffic goes through the VPN tunnel.
|
|
type Mode string
|
|
|
|
const (
|
|
ModeFull Mode = "full"
|
|
ModeProxy Mode = "proxy"
|
|
ModeBypass Mode = "bypass"
|
|
)
|
|
|
|
// Config describes the desired routing configuration.
|
|
type Config struct {
|
|
Mode Mode
|
|
InterfaceName string // e.g. "utun4"
|
|
VPNIP string // assigned tunnel IPv4, e.g. "192.168.77.5"
|
|
VPNPrefix int // IPv4 subnet prefix, e.g. 24
|
|
VPNIP6 string // assigned tunnel IPv6 (empty = v4-only)
|
|
VPNPrefix6 int // IPv6 subnet prefix
|
|
ServerHost string // server hostname/IP (for full-tunnel bypass)
|
|
CIDRs []string // CIDR list for ModeProxy and ModeBypass
|
|
}
|
|
|
|
// Manager applies and removes routes. It tracks all added routes so
|
|
// they can be cleaned up deterministically. 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
|
|
bypassRoutes []string // v4 bypass route specs added via gateway
|
|
bypassRoutes6 []string // v6 bypass route specs added via gateway
|
|
serverBypass bool
|
|
serverBypass6 bool
|
|
originalGateway string
|
|
originalGateway6 string
|
|
serverIP string // resolved v4 (for bypass delete)
|
|
serverIP6 string // resolved v6 (for bypass delete)
|
|
}
|
|
|
|
// NewManager creates a route manager for the given configuration.
|
|
func NewManager(cfg Config) *Manager {
|
|
return &Manager{cfg: cfg}
|
|
}
|
|
|
|
// 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.applyProxyEssential()
|
|
case ModeBypass:
|
|
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/ApplyDeferred. This is used for CIDRs fetched from URLs
|
|
// after the tunnel is established. Uses batch execution.
|
|
func (m *Manager) AddRoutes(cidrs []string) error {
|
|
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 len(v4) > 0 && m.originalGateway != "" {
|
|
if err := addRoutesViaBatch(v4, m.originalGateway); err != nil {
|
|
errs = append(errs, err.Error())
|
|
} else {
|
|
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, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// HasOriginalGateway reports whether the manager captured an original
|
|
// 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, 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
|
|
|
|
// 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())
|
|
}
|
|
}
|
|
}
|
|
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())
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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 applyBypassEssential.
|
|
func (m *Manager) captureGatewaysAndBypass() error {
|
|
// Capture the current default gateways before modifying routes.
|
|
gw, err := defaultGateway()
|
|
if err != nil {
|
|
return fmt.Errorf("get default gateway: %w", err)
|
|
}
|
|
m.originalGateway = gw
|
|
|
|
// IPv6 default gateway is best-effort.
|
|
gw6, _ := defaultGateway6()
|
|
m.originalGateway6 = gw6
|
|
|
|
// Resolve server host to v4 + v6 IPs for bypass routes.
|
|
v4, v6, err := resolveHosts(m.cfg.ServerHost)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve server host %s: %w", m.cfg.ServerHost, err)
|
|
}
|
|
m.serverIP = v4
|
|
m.serverIP6 = v6
|
|
|
|
// Bypass: server's public IPv4 via the original gateway.
|
|
if v4 != "" {
|
|
bypassSpec := v4 + "/32"
|
|
if err := addRouteVia(bypassSpec, gw); err != nil {
|
|
return fmt.Errorf("add server bypass route: %w", err)
|
|
}
|
|
m.serverBypass = true
|
|
}
|
|
|
|
// Bypass: server's public IPv6 via the original v6 gateway.
|
|
if v6 != "" && gw6 != "" {
|
|
bypassSpec := v6 + "/128"
|
|
if err := addRouteVia6(bypassSpec, gw6); err != nil {
|
|
m.serverBypass6 = false
|
|
} else {
|
|
m.serverBypass6 = true
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) applyFull() error {
|
|
if err := m.captureGatewaysAndBypass(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Two /1 routes cover the entire IPv4 space.
|
|
for _, cidr := range []string{"0.0.0.0/1", "128.0.0.0/1"} {
|
|
if err := addRoute(cidr, m.cfg.InterfaceName); err != nil {
|
|
return fmt.Errorf("add route %s: %w", cidr, err)
|
|
}
|
|
m.addedRoutes = append(m.addedRoutes, cidr)
|
|
}
|
|
|
|
// IPv6 full tunnel cover routes.
|
|
if m.cfg.VPNIP6 != "" {
|
|
for _, cidr := range []string{"::/1", "8000::/1"} {
|
|
if err := addRoute6(cidr, m.cfg.InterfaceName); err != nil {
|
|
return fmt.Errorf("add route6 %s: %w", cidr, err)
|
|
}
|
|
m.addedRoutes6 = append(m.addedRoutes6, cidr)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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) {
|
|
v6 = append(v6, cidr)
|
|
} else {
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Two /1 routes cover the entire IPv4 space (full tunnel).
|
|
for _, cidr := range []string{"0.0.0.0/1", "128.0.0.0/1"} {
|
|
if err := addRoute(cidr, m.cfg.InterfaceName); err != nil {
|
|
return fmt.Errorf("add route %s: %w", cidr, err)
|
|
}
|
|
m.addedRoutes = append(m.addedRoutes, cidr)
|
|
}
|
|
|
|
// IPv6 full tunnel cover routes.
|
|
if m.cfg.VPNIP6 != "" {
|
|
for _, cidr := range []string{"::/1", "8000::/1"} {
|
|
if err := addRoute6(cidr, m.cfg.InterfaceName); err != nil {
|
|
return fmt.Errorf("add route6 %s: %w", cidr, err)
|
|
}
|
|
m.addedRoutes6 = append(m.addedRoutes6, cidr)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
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. 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 {
|
|
return ip.String(), "", nil
|
|
}
|
|
return "", ip.String(), nil
|
|
}
|
|
if h, _, e := net.SplitHostPort(host); e == nil {
|
|
host = h
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
|
if err != nil || len(addrs) == 0 {
|
|
return "", "", fmt.Errorf("lookup %s: %w", host, err)
|
|
}
|
|
for _, addr := range addrs {
|
|
if v4 == "" && addr.IP.To4() != nil {
|
|
v4 = addr.IP.String()
|
|
} else if v6 == "" && addr.IP.To4() == nil {
|
|
v6 = addr.IP.String()
|
|
}
|
|
}
|
|
if v4 == "" && v6 == "" {
|
|
return "", "", fmt.Errorf("lookup %s: no addresses", host)
|
|
}
|
|
return v4, v6, nil
|
|
}
|
|
|
|
// isIPv6CIDR reports whether the CIDR string is IPv6.
|
|
func isIPv6CIDR(cidr string) bool {
|
|
_, ipNet, err := net.ParseCIDR(cidr)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
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)
|
|
}
|
|
}
|