diff --git a/Makefile b/Makefile index 30a07e2..2847f6d 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ GO = go CGO_ENABLED = 1 WINDRES ?= x86_64-w64-mingw32-windres MINGW_CC ?= x86_64-w64-mingw32-gcc -SEMVER ?= 0.4.7 +SEMVER ?= 0.5.5 GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) VERSION = $(SEMVER)-$(GIT_HASH) LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION) diff --git a/README.md b/README.md index 5966033..abf1766 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ LMVPN 是一个基于 WebSocket 隧道与 TUN 虚拟网卡的**三层(网络 - **双进程架构**:GUI(`lmvpn`,普通用户)+ 守护进程(`lmvpnd`,root/管理员),自动拉起与生命周期管理 - **多种认证**:JWT 令牌 / 用户名密码 -- **隧道模式**:全量隧道、分流隧道(按目标绕过)、自定义隧道 +- **隧道模式**:全隧道、代理 CIDR(指定 CIDR 走隧道)、绕过 CIDR(指定 CIDR 绕过隧道),支持 IPv4/IPv6 分开配置与 URL 动态获取 CIDR 列表 - **多服务器管理**:配置文件 + SQLite 存储多个服务器配置(Profile) - **国际化**:中文(简体)、英文,跟随系统语言 - **安全存储**:macOS Keychain / Windows Credential Manager 加密保存凭据 @@ -274,7 +274,7 @@ lmvpn_client/ │ ├── model/ # 数据模型 │ ├── paths/ # 平台路径解析(darwin/windows/other) │ ├── protocol/ # 与服务端的 WebSocket 协议 -│ ├── route/ # 路由管理(全量/分流/自定义) +│ ├── route/ # 路由管理(全隧道/代理CIDR/绕过CIDR) │ ├── stats/ # 流量统计 │ ├── transport/ # WebSocket 传输层 │ ├── tun/ # TUN 虚拟网卡(darwin/linux/windows) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 1a3b5dc..ffb3410 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -6,6 +6,7 @@ package auth import ( "bytes" + "context" "crypto/tls" "encoding/json" "fmt" @@ -47,7 +48,10 @@ type errorResponse struct { // client. This is essential when connecting via CDN edge IPs: the URL // host will be an IP address, but the certificate must be verified // against the real hostname (set tlsCfg.ServerName). -func Login(baseURL, username, password string, tlsCfg *tls.Config) (*LoginResult, error) { +// +// ctx allows cancellation of the HTTP request (e.g. when the VPN +// session is disconnected while login is in flight). +func Login(ctx context.Context, baseURL, username, password string, tlsCfg *tls.Config) (*LoginResult, error) { body, err := json.Marshal(loginRequest{Username: username, Password: password}) if err != nil { return nil, err @@ -58,7 +62,14 @@ func Login(baseURL, username, password string, tlsCfg *tls.Config) (*LoginResult if tlsCfg != nil { client.Transport = &http.Transport{TLSClientConfig: tlsCfg} } - resp, err := client.Post(url, "application/json", bytes.NewReader(body)) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create login request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("login request: %w", err) } diff --git a/internal/cidrsource/cidrsource.go b/internal/cidrsource/cidrsource.go new file mode 100644 index 0000000..d55a284 --- /dev/null +++ b/internal/cidrsource/cidrsource.go @@ -0,0 +1,173 @@ +// Package cidrsource fetches CIDR lists from URLs. CIDR lists can be +// fetched before routing is applied (via a direct connection) or after +// the tunnel is established (via the tunnel itself). +// +// The expected format is one CIDR per line. Empty lines and lines +// starting with '#' are treated as comments and skipped. Invalid CIDR +// entries are silently ignored (with a debug log entry). +package cidrsource + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "lmvpn/internal/log" + "lmvpn/internal/model" +) + +// fetchTimeout is the total timeout for all before-proxy fetches +// combined. It must be well under the server's 30s ReadyTimeout since +// before-proxy fetching happens before the WS handshake begins. +const fetchTimeout = 5 * time.Second + +// FetchBeforeProxy fetches all CIDR URL sources with FetchTiming == +// "before". These are fetched via the system's direct connection +// (before routing is applied), so no special proxy handling is needed. +// The context allows cancellation (e.g. session teardown). +// +// All matching sources are fetched concurrently with a shared 5s +// deadline so a single slow URL does not block the entire session. +func FetchBeforeProxy(ctx context.Context, sources []model.CIDRURLSource) ([]string, error) { + return fetchSources(ctx, sources, model.FetchBefore) +} + +// FetchAfterProxy fetches all CIDR URL sources with FetchTiming == +// "after". These are fetched via the tunnel after the data plane is up. +// The HTTP client uses the default dialer, which respects the system +// routing table - so when full-tunnel or bypass routes are in effect, +// the request goes through the TUN interface. +// +// Sources are fetched concurrently with a 15s deadline (the tunnel is +// already up, so there is no ReadyTimeout pressure). +func FetchAfterProxy(ctx context.Context, sources []model.CIDRURLSource) ([]string, error) { + return fetchSources(ctx, sources, model.FetchAfter) +} + +// fetchSources fetches all sources matching the given timing +// concurrently, bounded by a shared deadline derived from fetchTimeout +// (for "before") or 15s (for "after"). +func fetchSources(ctx context.Context, sources []model.CIDRURLSource, timing model.FetchTiming) ([]string, error) { + // Collect matching sources. + var matching []model.CIDRURLSource + for _, src := range sources { + if src.FetchTiming == timing { + matching = append(matching, src) + } + } + if len(matching) == 0 { + return nil, nil + } + + // Shared deadline for all fetches. + deadline := fetchTimeout + if timing == model.FetchAfter { + deadline = 15 * time.Second + } + fetchCtx, cancel := context.WithTimeout(ctx, deadline) + defer cancel() + + type result struct { + cidrs []string + err error + url string + } + results := make(chan result, len(matching)) + + for _, src := range matching { + go func(s model.CIDRURLSource) { + cidrs, err := fetchOne(fetchCtx, s.URL) + results <- result{cidrs: cidrs, err: err, url: s.URL} + }(src) + } + + var allCIDRs []string + var errs []string + for range matching { + r := <-results + if r.err != nil { + label := "before-proxy" + if timing == model.FetchAfter { + label = "after-proxy" + } + log.L().Error("fetch "+label+" CIDR list failed (continuing)", + "url", r.url, "error", r.err) + errs = append(errs, fmt.Sprintf("%s: %v", r.url, r.err)) + continue + } + label := "before-proxy" + if timing == model.FetchAfter { + label = "after-proxy" + } + log.L().Info("fetched "+label+" CIDR list", + "url", r.url, "count", len(r.cidrs)) + allCIDRs = append(allCIDRs, r.cidrs...) + } + + if len(allCIDRs) == 0 && len(errs) > 0 { + return nil, fmt.Errorf("all CIDR URL fetches failed: %s", strings.Join(errs, "; ")) + } + return allCIDRs, nil +} + +// fetchOne fetches a single URL and parses the response as a CIDR list. +func fetchOne(ctx context.Context, url string) ([]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("http %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB max + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + return ParseCIDRList(string(body)), nil +} + +// ParseCIDRList parses text into a list of valid CIDR strings. Each +// line is treated as a separate CIDR entry. Empty lines and lines +// starting with '#' are skipped. Invalid CIDR entries are silently +// ignored. +func ParseCIDRList(text string) []string { + var out []string + for _, line := range strings.Split(text, "\n") { + c := strings.TrimSpace(line) + if c == "" || strings.HasPrefix(c, "#") { + continue + } + _, _, err := net.ParseCIDR(c) + if err != nil { + log.L().Debug("ignoring invalid CIDR entry", "cidr", c) + continue + } + out = append(out, c) + } + return out +} + +// ClassifyCIDRs splits a list of CIDR strings into IPv4 and IPv6 lists. +func ClassifyCIDRs(cidrs []string) (v4, v6 []string) { + for _, cidr := range cidrs { + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + continue + } + if ipNet.IP.To4() != nil { + v4 = append(v4, cidr) + } else { + v6 = append(v6, cidr) + } + } + return v4, v6 +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 63d4c54..d5117c3 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -128,8 +128,8 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) { func (d *daemon) startSession(conn net.Conn, req ipc.Request) { d.mu.Lock() - defer d.mu.Unlock() if req.Config == nil { + d.mu.Unlock() _ = ipc.WriteErr(conn, "missing config") return } @@ -146,7 +146,10 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) { Token: req.Config.Token, AuthMode: model.AuthMode(req.Config.AuthMode), RoutingMode: ipc.RoutingModeFromIPC(req.Config.RoutingMode), - CustomCIDRs: req.Config.CustomCIDRs, + CIDRV4: req.Config.CIDRV4, + CIDRV6: req.Config.CIDRV6, + CIDRV4URLs: convertIPCSources(req.Config.CIDRV4URLs), + CIDRV6URLs: convertIPCSources(req.Config.CIDRV6URLs), MTUOverride: req.Config.MTUOverride, TLSCACert: req.Config.TLSCACert, TLSCAPath: req.Config.TLSCAPath, @@ -168,10 +171,18 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) { d.server.Broadcast(ipc.Event{Event: ipc.EvError, Code: code, Message: msg}) }, ) + // Release the lock before Connect. Connect is non-blocking (it + // starts a goroutine), but holding the lock across it would + // serialize all IPC commands (CmdStats, CmdStop, etc.) behind the + // session startup. + d.mu.Unlock() if err := d.session.Connect(ctx, cfg); err != nil { _ = ipc.WriteErr(conn, "connect: "+err.Error()) + d.mu.Lock() d.session = nil + d.cancel = nil + d.mu.Unlock() return } } @@ -192,3 +203,18 @@ func (d *daemon) stopSessionLocked() { d.session = nil } } + +// convertIPCSources converts IPC CIDRURLSource values to model.CIDRURLSource. +func convertIPCSources(srcs []ipc.CIDRURLSource) []model.CIDRURLSource { + if len(srcs) == 0 { + return nil + } + out := make([]model.CIDRURLSource, len(srcs)) + for i, s := range srcs { + out[i] = model.CIDRURLSource{ + URL: s.URL, + FetchTiming: model.FetchTiming(s.FetchTiming), + } + } + return out +} diff --git a/internal/db/db.go b/internal/db/db.go index 80509d8..e54d355 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -8,6 +8,7 @@ package db import ( "database/sql" "fmt" + "net" "strings" "lmvpn/internal/paths" @@ -51,7 +52,10 @@ func (s *Store) migrate() error { if err := s.migrateV3(); err != nil { return err } - return s.migrateV4() + if err := s.migrateV4(); err != nil { + return err + } + return s.migrateV5() } func (s *Store) migrateV2() error { @@ -207,6 +211,108 @@ func (s *Store) migrateV4() error { return nil } +// migrateV5 replaces the single custom_cidrs column with separate +// cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls columns. It migrates +// existing routing modes: 'custom' -> 'proxy', 'split' -> 'full'. +// Existing custom_cidrs are split into v4/v6 based on address family. +// Idempotent: skips columns that already exist. +func (s *Store) migrateV5() error { + cols := []struct { + name string + sql string + }{ + {"cidr_v4", "ALTER TABLE server_profiles ADD COLUMN cidr_v4 TEXT NOT NULL DEFAULT ''"}, + {"cidr_v6", "ALTER TABLE server_profiles ADD COLUMN cidr_v6 TEXT NOT NULL DEFAULT ''"}, + {"cidr_v4_urls", "ALTER TABLE server_profiles ADD COLUMN cidr_v4_urls TEXT NOT NULL DEFAULT ''"}, + {"cidr_v6_urls", "ALTER TABLE server_profiles ADD COLUMN cidr_v6_urls TEXT NOT NULL DEFAULT ''"}, + } + needMigration := false + for _, c := range cols { + if !columnExists(s.db, "server_profiles", c.name) { + needMigration = true + break + } + } + if !needMigration { + return nil + } + + for _, c := range cols { + if !columnExists(s.db, "server_profiles", c.name) { + if _, err := s.db.Exec(c.sql); err != nil { + return fmt.Errorf("migrate v5 add %s: %w", c.name, err) + } + } + } + + // Migrate existing custom_cidrs into cidr_v4 / cidr_v6 and update + // routing mode codes. Only process rows that still have a non-empty + // custom_cidrs or an old routing mode. + rows, err := s.db.Query(`SELECT id, routing_mode, custom_cidrs FROM server_profiles`) + if err != nil { + return fmt.Errorf("migrate v5 read rows: %w", err) + } + type row struct { + id int64 + routingMode string + customCIDRs string + } + var toUpdate []row + for rows.Next() { + var r row + if err := rows.Scan(&r.id, &r.routingMode, &r.customCIDRs); err != nil { + rows.Close() + return fmt.Errorf("migrate v5 scan: %w", err) + } + toUpdate = append(toUpdate, r) + } + rows.Close() + + for _, r := range toUpdate { + newMode := r.routingMode + switch newMode { + case "custom": + newMode = "proxy" + case "split": + newMode = "full" + } + + v4CIDRs, v6CIDRs := splitCIDRsByFamily(r.customCIDRs) + + _, err := s.db.Exec( + `UPDATE server_profiles SET routing_mode = ?, cidr_v4 = ?, cidr_v6 = ? WHERE id = ?`, + newMode, v4CIDRs, v6CIDRs, r.id) + if err != nil { + return fmt.Errorf("migrate v5 update row %d: %w", r.id, err) + } + } + + return nil +} + +// splitCIDRsByFamily splits a comma-separated CIDR string into IPv4 and +// IPv6 parts. Used for migration from the old custom_cidrs column. +func splitCIDRsByFamily(customCIDRs string) (v4, v6 string) { + if customCIDRs == "" { + return "", "" + } + var v4Parts, v6Parts []string + for _, part := range strings.Split(customCIDRs, ",") { + c := strings.TrimSpace(part) + if c == "" { + continue + } + if _, ipNet, err := net.ParseCIDR(c); err == nil { + if ipNet.IP.To4() != nil { + v4Parts = append(v4Parts, c) + } else { + v6Parts = append(v6Parts, c) + } + } + } + return strings.Join(v4Parts, ", "), strings.Join(v6Parts, ", ") +} + // columnExists reports whether a column exists on a table. func columnExists(db *sql.DB, table, column string) bool { rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) @@ -242,6 +348,10 @@ CREATE TABLE IF NOT EXISTS server_profiles ( auth_mode TEXT NOT NULL DEFAULT 'both', routing_mode TEXT NOT NULL DEFAULT 'full', custom_cidrs TEXT NOT NULL DEFAULT '', + cidr_v4 TEXT NOT NULL DEFAULT '', + cidr_v6 TEXT NOT NULL DEFAULT '', + cidr_v4_urls TEXT NOT NULL DEFAULT '', + cidr_v6_urls TEXT NOT NULL DEFAULT '', mtu_override INTEGER NOT NULL DEFAULT 0, auto_connect INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/internal/db/profile.go b/internal/db/profile.go index 7262e2c..fa0101d 100644 --- a/internal/db/profile.go +++ b/internal/db/profile.go @@ -14,12 +14,14 @@ func (s *Store) CreateProfile(p *model.ServerProfile) (int64, error) { `INSERT INTO server_profiles (name, protocol, host, server_ips, port, path, username, auth_mode, routing_mode, - custom_cidrs, mtu_override, auto_connect, + cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls, + mtu_override, auto_connect, tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path, p.Username, p.AuthMode, p.RoutingMode, - p.CustomCIDRs, p.MTUOverride, p.AutoConnect, + p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs, + p.MTUOverride, p.AutoConnect, p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash, ) if err != nil { @@ -38,13 +40,16 @@ func (s *Store) GetProfile(id int64) (*model.ServerProfile, error) { err := s.db.QueryRow( `SELECT id, name, protocol, host, server_ips, port, path, username, auth_mode, routing_mode, - custom_cidrs, mtu_override, auto_connect, + cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls, + mtu_override, auto_connect, tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash, created_at, last_connected_at FROM server_profiles WHERE id = ?`, id, ).Scan(&p.ID, &p.Name, &p.Protocol, &p.Host, &p.ServerIPs, &p.Port, &p.Path, - &p.Username, &p.AuthMode, &p.RoutingMode, &p.CustomCIDRs, &p.MTUOverride, - &p.AutoConnect, &p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash, + &p.Username, &p.AuthMode, &p.RoutingMode, + &p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs, + &p.MTUOverride, &p.AutoConnect, + &p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash, &p.CreatedAt, &last) if err != nil { return nil, fmt.Errorf("get profile %d: %w", id, err) @@ -60,7 +65,8 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) { rows, err := s.db.Query( `SELECT id, name, protocol, host, server_ips, port, path, username, auth_mode, routing_mode, - custom_cidrs, mtu_override, auto_connect, + cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls, + mtu_override, auto_connect, tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash, created_at, last_connected_at FROM server_profiles ORDER BY name`) @@ -75,7 +81,8 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) { var last sql.NullTime if err := rows.Scan(&p.ID, &p.Name, &p.Protocol, &p.Host, &p.ServerIPs, &p.Port, &p.Path, &p.Username, &p.AuthMode, &p.RoutingMode, - &p.CustomCIDRs, &p.MTUOverride, &p.AutoConnect, + &p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs, + &p.MTUOverride, &p.AutoConnect, &p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash, &p.CreatedAt, &last); err != nil { return nil, err @@ -94,12 +101,14 @@ func (s *Store) UpdateProfile(p *model.ServerProfile) error { `UPDATE server_profiles SET name = ?, protocol = ?, host = ?, server_ips = ?, port = ?, path = ?, username = ?, auth_mode = ?, routing_mode = ?, - custom_cidrs = ?, mtu_override = ?, auto_connect = ?, + cidr_v4 = ?, cidr_v6 = ?, cidr_v4_urls = ?, cidr_v6_urls = ?, + mtu_override = ?, auto_connect = ?, tls_ca_cert = ?, tls_ca_path = ?, tls_insecure = ?, tls_pinned_hash = ? WHERE id = ?`, p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path, p.Username, p.AuthMode, p.RoutingMode, - p.CustomCIDRs, p.MTUOverride, p.AutoConnect, + p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs, + p.MTUOverride, p.AutoConnect, p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash, p.ID) if err != nil { return fmt.Errorf("update profile %d: %w", p.ID, err) diff --git a/internal/i18n/en.toml b/internal/i18n/en.toml index 820cbf5..8367778 100644 --- a/internal/i18n/en.toml +++ b/internal/i18n/en.toml @@ -37,6 +37,12 @@ TxV6Zero = "↑ 0 B 0 bps" RxTotalZero = "Total ↓ 0 B 0 bps" TxTotalZero = "↑ 0 B 0 bps" +StatusRoutingMode = "Routing Mode: {{.mode}}" +CIDRHit = "hit" +CIDRConfigured = "configured" +CIDRUnmatched = "unmatched" +CIDRDestinations = "destinations" + DlgNoProfileTitle = "No Profile" DlgNoProfileEditMsg = "Select a profile to edit." DlgNoProfileConnectMsg = "Please select or create a profile first." @@ -88,10 +94,15 @@ FieldUsername = "Username" FieldPassword = "Password" FieldAuthMode = "Auth Mode" FieldRoutingMode = "Routing Mode" -FieldCustomCIDRs = "Custom CIDRs (comma-separated)" +FieldCIDRV4 = "IPv4 CIDRs (comma-separated)" +FieldCIDRV6 = "IPv6 CIDRs (comma-separated)" +FieldCIDRV4URLs = "IPv4 CIDR URL Sources" +FieldCIDRV6URLs = "IPv6 CIDR URL Sources" FieldMTUOverride = "MTU Override" -PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12" +PlaceholderCIDRV4 = "10.0.0.0/8, 172.16.0.0/12" +PlaceholderCIDRV6 = "fd00::/8, 2001:db8::/32" +PlaceholderCIDRURL = "https://example.com/cidrs.txt" PlaceholderMTU = "0 = use server MTU" PlaceholderPasswordUnchanged = "(unchanged)" PlaceholderServerIPs = "IPv4/IPv6 supported, e.g. 1.2.3.4, 5.6.7.8" @@ -101,8 +112,14 @@ AuthModeJWT = "JWT" AuthModePassword = "Password" RoutingModeFull = "Full Tunnel" -RoutingModeSplit = "Split Tunnel" -RoutingModeCustom = "Custom" +RoutingModeProxy = "Proxy CIDR" +RoutingModeBypass = "Bypass CIDR" + +FetchTimingBefore = "Before Proxy" +FetchTimingAfter = "After Proxy" + +BtnAddURL = "Add URL" +BtnRemoveURL = "Remove" FieldTLS = "TLS Certificate" FieldTLSCACert = "CA Certificate (PEM)" diff --git a/internal/i18n/zh-Hans.toml b/internal/i18n/zh-Hans.toml index e92cbcc..3035bf9 100644 --- a/internal/i18n/zh-Hans.toml +++ b/internal/i18n/zh-Hans.toml @@ -37,6 +37,12 @@ TxV6Zero = "↑ 0 B 0 bps" RxTotalZero = "合计 ↓ 0 B 0 bps" TxTotalZero = "↑ 0 B 0 bps" +StatusRoutingMode = "路由模式: {{.mode}}" +CIDRHit = "命中" +CIDRConfigured = "已配置" +CIDRUnmatched = "未命中" +CIDRDestinations = "目的地址" + DlgNoProfileTitle = "无配置" DlgNoProfileEditMsg = "请选择要编辑的配置。" DlgNoProfileConnectMsg = "请先选择或创建一个配置。" @@ -88,10 +94,15 @@ FieldUsername = "用户名" FieldPassword = "密码" FieldAuthMode = "认证方式" FieldRoutingMode = "路由模式" -FieldCustomCIDRs = "自定义 CIDR(逗号分隔)" +FieldCIDRV4 = "IPv4 CIDR(逗号分隔)" +FieldCIDRV6 = "IPv6 CIDR(逗号分隔)" +FieldCIDRV4URLs = "IPv4 CIDR URL 来源" +FieldCIDRV6URLs = "IPv6 CIDR URL 来源" FieldMTUOverride = "MTU 覆盖" -PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12" +PlaceholderCIDRV4 = "10.0.0.0/8, 172.16.0.0/12" +PlaceholderCIDRV6 = "fd00::/8, 2001:db8::/32" +PlaceholderCIDRURL = "https://example.com/cidrs.txt" PlaceholderMTU = "0 = 使用服务器 MTU" PlaceholderPasswordUnchanged = "(未更改)" PlaceholderServerIPs = "支持 IPv4/IPv6,例: 1.2.3.4, 5.6.7.8" @@ -101,8 +112,14 @@ AuthModeJWT = "JWT" AuthModePassword = "密码" RoutingModeFull = "全隧道" -RoutingModeSplit = "分离隧道" -RoutingModeCustom = "自定义" +RoutingModeProxy = "代理 CIDR" +RoutingModeBypass = "绕过 CIDR" + +FetchTimingBefore = "代理前获取" +FetchTimingAfter = "代理后获取" + +BtnAddURL = "添加 URL" +BtnRemoveURL = "删除" FieldTLS = "TLS 证书" FieldTLSCACert = "CA 证书 (PEM)" diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 2022554..ca92b89 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -49,20 +49,31 @@ type Request struct { // vpn.SessionConfig but is kept separate to avoid importing the vpn // package (which needs root-only TUN) into the GUI. type ClientConfig struct { - ServerURL string `json:"server_url"` - SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN - ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover - Username string `json:"username"` - Password string `json:"password"` - Token string `json:"token"` - AuthMode string `json:"auth_mode"` - RoutingMode string `json:"routing_mode"` - CustomCIDRs []string `json:"custom_cidrs"` - MTUOverride int `json:"mtu_override"` - TLSCACert string `json:"tls_ca_cert"` // inline CA cert PEM (wss only) - TLSCAPath string `json:"tls_ca_path"` // CA cert file path (wss only) - TLSInsecure bool `json:"tls_insecure"` // skip cert verification (wss only) - TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 cert pin (wss only) + ServerURL string `json:"server_url"` + SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN + ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover + Username string `json:"username"` + Password string `json:"password"` + Token string `json:"token"` + AuthMode string `json:"auth_mode"` + RoutingMode string `json:"routing_mode"` // "full", "proxy", "bypass" + CIDRV4 []string `json:"cidr_v4"` // static IPv4 CIDRs + CIDRV6 []string `json:"cidr_v6"` // static IPv6 CIDRs + CIDRV4URLs []CIDRURLSource `json:"cidr_v4_urls"` // IPv4 CIDR URL sources + CIDRV6URLs []CIDRURLSource `json:"cidr_v6_urls"` // IPv6 CIDR URL sources + MTUOverride int `json:"mtu_override"` + TLSCACert string `json:"tls_ca_cert"` // inline CA cert PEM (wss only) + TLSCAPath string `json:"tls_ca_path"` // CA cert file path (wss only) + TLSInsecure bool `json:"tls_insecure"` // skip cert verification (wss only) + TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 cert pin (wss only) +} + +// CIDRURLSource describes a URL that provides a CIDR list. It mirrors +// model.CIDRURLSource but is kept here to avoid importing the model +// package into the IPC wire format. +type CIDRURLSource struct { + URL string `json:"url"` + FetchTiming string `json:"fetch_timing"` // "before" or "after" } // Event is a notification from the daemon to the GUI. @@ -285,10 +296,10 @@ func RoutingModeFromIPC(s string) route.Mode { switch s { case "full": return route.ModeFull - case "split": - return route.ModeSplit - case "custom": - return route.ModeCustom + case "proxy": + return route.ModeProxy + case "bypass": + return route.ModeBypass default: return route.ModeFull } diff --git a/internal/model/model.go b/internal/model/model.go index 888ed5e..17e626d 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -3,6 +3,7 @@ package model import ( + "encoding/json" "fmt" "net" "strings" @@ -22,11 +23,26 @@ const ( type RoutingMode string const ( - RoutingFull RoutingMode = "full" // 0.0.0.0/0 via tunnel - RoutingSplit RoutingMode = "split" // only VPN subnet via tunnel - RoutingCustom RoutingMode = "custom" // user-specified CIDRs + RoutingFull RoutingMode = "full" // 全隧道: all traffic via tunnel + RoutingProxy RoutingMode = "proxy" // 代理CIDR: only specified CIDRs via tunnel + RoutingBypass RoutingMode = "bypass" // 绕过CIDR: all traffic via tunnel except specified CIDRs ) +// FetchTiming specifies when a CIDR URL source is fetched. +type FetchTiming string + +const ( + FetchBefore FetchTiming = "before" // before proxy: fetched via direct connection before routing is applied + FetchAfter FetchTiming = "after" // after proxy: fetched via the tunnel after the data plane is up +) + +// CIDRURLSource describes a URL that provides a CIDR list. The list +// is fetched at FetchTiming and merged into the routing configuration. +type CIDRURLSource struct { + URL string `json:"url"` + FetchTiming FetchTiming `json:"fetch_timing"` // "before" or "after" +} + // ServerProfile is a saved VPN server configuration. type ServerProfile struct { ID int64 `json:"id"` @@ -39,7 +55,10 @@ type ServerProfile struct { Username string `json:"username"` AuthMode AuthMode `json:"auth_mode"` RoutingMode RoutingMode `json:"routing_mode"` - CustomCIDRs string `json:"custom_cidrs"` // comma-separated, for RoutingCustom + CIDRV4 string `json:"cidr_v4"` // comma-separated static IPv4 CIDRs + CIDRV6 string `json:"cidr_v6"` // comma-separated static IPv6 CIDRs + CIDRV4URLs string `json:"cidr_v4_urls"` // JSON array of CIDRURLSource for IPv4 + CIDRV6URLs string `json:"cidr_v6_urls"` // JSON array of CIDRURLSource for IPv6 MTUOverride int `json:"mtu_override"` // 0 = use server MTU AutoConnect bool `json:"auto_connect"` TLSCACert string `json:"tls_ca_cert"` // inline CA certificate PEM (wss only) @@ -113,6 +132,35 @@ func (p *ServerProfile) GetServerIPList() []string { return valid } +// ParseCIDRURLs decodes a JSON-encoded CIDRURLSource array. Returns an +// empty slice if the string is empty or unparseable. +func ParseCIDRURLs(jsonStr string) []CIDRURLSource { + if jsonStr == "" { + return nil + } + var sources []CIDRURLSource + if err := json.Unmarshal([]byte(jsonStr), &sources); err != nil { + return nil + } + return sources +} + +// SplitCIDRs splits a comma-separated CIDR string into a slice, +// trimming whitespace from each entry and skipping empty ones. +func SplitCIDRs(s string) []string { + if s == "" { + return nil + } + var out []string + for _, part := range strings.Split(s, ",") { + c := strings.TrimSpace(part) + if c != "" { + out = append(out, c) + } + } + return out +} + // ConnectionStatus records the outcome of a connection attempt. type ConnectionStatus string diff --git a/internal/route/route.go b/internal/route/route.go index 8c9b814..e16e7ce 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -5,8 +5,9 @@ // interface, with bypass routes for the server's // public IP (v4 and v6) so the WebSocket connection // stays on the physical NIC -// - Split tunnel: only the VPN virtual subnet (v4 and v6) via TUN -// - Custom: user-specified CIDRs via the TUN interface +// - 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 // // IPv6 routes are applied automatically when the server assigned an // IPv6 address (Config.VPNIP6 != ""). All routes are tracked so they @@ -14,9 +15,11 @@ package route import ( + "context" "fmt" "net" "strings" + "time" ) // Mode selects which traffic goes through the VPN tunnel. @@ -24,8 +27,8 @@ type Mode string const ( ModeFull Mode = "full" - ModeSplit Mode = "split" - ModeCustom Mode = "custom" + ModeProxy Mode = "proxy" + ModeBypass Mode = "bypass" ) // Config describes the desired routing configuration. @@ -37,15 +40,17 @@ type Config struct { VPNIP6 string // assigned tunnel IPv6 (empty = v4-only) VPNPrefix6 int // IPv6 subnet prefix ServerHost string // server hostname/IP (for full-tunnel bypass) - CustomCIDRs []string // for ModeCustom + 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. type Manager struct { cfg Config - addedRoutes []string // v4 route specs added, for deletion - addedRoutes6 []string // v6 route specs added, for deletion + 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 @@ -64,16 +69,86 @@ func (m *Manager) Apply() error { switch m.cfg.Mode { case ModeFull: return m.applyFull() - case ModeSplit: - return m.applySplit() - case ModeCustom: - return m.applyCustom() + case ModeProxy: + return m.applyProxy() + case ModeBypass: + return m.applyBypass() default: return fmt.Errorf("unknown routing mode: %s", m.cfg.Mode) } } -// Cleanup removes all routes that were added by Apply. +// 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. +func (m *Manager) AddRoutes(cidrs []string) error { + var errs []string + for _, cidr := range cidrs { + 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) + } + } 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) + } else { + if err := addRoute(cidr, m.cfg.InterfaceName); err != nil { + errs = append(errs, err.Error()) + continue + } + m.addedRoutes = append(m.addedRoutes, cidr) + } + } + } + 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. This is needed by callers +// that want to add bypass routes dynamically in bypass mode. +func (m *Manager) HasOriginalGateway() bool { + return m.originalGateway != "" || m.originalGateway6 != "" +} + +// OriginalGatewayV4 returns the captured IPv4 default gateway, if any. +func (m *Manager) OriginalGatewayV4() string { + return m.originalGateway +} + +// OriginalGatewayV6 returns the captured IPv6 default gateway, if any. +func (m *Manager) OriginalGatewayV6() string { + return m.originalGateway6 +} + +// Cleanup removes all routes that were added by Apply or AddRoutes. func (m *Manager) Cleanup() error { var errs []string for _, r := range m.addedRoutes { @@ -88,6 +163,18 @@ func (m *Manager) Cleanup() 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 { errs = append(errs, err.Error()) @@ -106,7 +193,10 @@ func (m *Manager) Cleanup() error { return nil } -func (m *Manager) applyFull() 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. +func (m *Manager) captureGatewaysAndBypass() error { // Capture the current default gateways before modifying routes. gw, err := defaultGateway() if err != nil { @@ -147,6 +237,13 @@ func (m *Manager) applyFull() error { 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 and are more specific // than the default route (0.0.0.0/0), so they take precedence @@ -172,37 +269,21 @@ func (m *Manager) applyFull() error { return nil } -func (m *Manager) applySplit() error { - subnet := vpnSubnet(m.cfg.VPNIP, m.cfg.VPNPrefix, false) - if err := addRoute(subnet, m.cfg.InterfaceName); err != nil { - return fmt.Errorf("add split route %s: %w", subnet, err) - } - m.addedRoutes = append(m.addedRoutes, subnet) - - if m.cfg.VPNIP6 != "" { - subnet6 := vpnSubnet(m.cfg.VPNIP6, m.cfg.VPNPrefix6, true) - if err := addRoute6(subnet6, m.cfg.InterfaceName); err != nil { - return fmt.Errorf("add split route6 %s: %w", subnet6, err) - } - m.addedRoutes6 = append(m.addedRoutes6, subnet6) - } - return nil -} - -func (m *Manager) applyCustom() error { - for _, cidr := range m.cfg.CustomCIDRs { +// applyProxy routes only the specified CIDRs through the TUN interface. +func (m *Manager) applyProxy() error { + for _, cidr := range m.cfg.CIDRs { cidr = strings.TrimSpace(cidr) if cidr == "" { continue } if isIPv6CIDR(cidr) { if err := addRoute6(cidr, m.cfg.InterfaceName); err != nil { - return fmt.Errorf("add custom route6 %s: %w", cidr, err) + return fmt.Errorf("add proxy route6 %s: %w", cidr, err) } m.addedRoutes6 = append(m.addedRoutes6, cidr) } else { if err := addRoute(cidr, m.cfg.InterfaceName); err != nil { - return fmt.Errorf("add custom route %s: %w", cidr, err) + return fmt.Errorf("add proxy route %s: %w", cidr, err) } m.addedRoutes = append(m.addedRoutes, cidr) } @@ -210,6 +291,59 @@ func (m *Manager) applyCustom() error { 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 { + 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 { + 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 +} + func (m *Manager) deleteServerBypass() error { if m.serverIP == "" { return nil @@ -224,24 +358,10 @@ func (m *Manager) deleteServerBypass6() error { return deleteRouteVia6(m.serverIP6+"/128", m.originalGateway6) } -// vpnSubnet computes the network CIDR from an IP and prefix. -func vpnSubnet(ipStr string, prefix int, ipv6 bool) string { - ip := net.ParseIP(ipStr) - if ip == nil { - return ipStr + "/" + fmt.Sprint(prefix) - } - bits := 32 - if ipv6 { - bits = 128 - } - mask := net.CIDRMask(prefix, bits) - network := ip.Mask(mask) - return fmt.Sprintf("%s/%d", network.String(), prefix) -} - // 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. +// 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. func resolveHosts(host string) (v4, v6 string, err error) { if ip := net.ParseIP(host); ip != nil { if ip.To4() != nil { @@ -253,15 +373,17 @@ func resolveHosts(host string) (v4, v6 string, err error) { if h, _, e := net.SplitHostPort(host); e == nil { host = h } - ips, err := net.LookupIP(host) - if err != nil || len(ips) == 0 { + 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 _, ip := range ips { - if v4 == "" && ip.To4() != nil { - v4 = ip.String() - } else if v6 == "" && ip.To4() == nil { - v6 = ip.String() + 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 == "" { diff --git a/internal/stats/stats.go b/internal/stats/stats.go index 1d697dc..3834bab 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -135,6 +135,13 @@ type Snapshot struct { AssignedIP6 string State State Uptime time.Duration + + // Routing info (filled by SessionManager.reportStats). + RoutingMode string `json:"routing_mode,omitempty"` // "full", "proxy", "bypass" + CIDRV4Total int `json:"cidr_v4_total,omitempty"` + CIDRV4Hits int `json:"cidr_v4_hits,omitempty"` + CIDRV6Total int `json:"cidr_v6_total,omitempty"` + CIDRV6Hits int `json:"cidr_v6_hits,omitempty"` } // Snapshot returns a point-in-time copy of the statistics. diff --git a/internal/transport/transport.go b/internal/transport/transport.go index 0bedd94..8569e18 100644 --- a/internal/transport/transport.go +++ b/internal/transport/transport.go @@ -89,12 +89,26 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) { ws.SetReadLimit(protocol.MaxMessageSize) + // Watch ctx and close the WS if it is cancelled. This unblocks all + // ReadMessage/WriteMessage calls (they only use SetReadDeadline, + // not ctx). The goroutine exits when connectDone is closed (on + // either success or failure) to avoid leaking. + connectDone := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + ws.Close() + case <-connectDone: + } + }() + conn := &Conn{ws: ws} // Step 2: authenticate (only for password mode; JWT is validated // during the WS upgrade). if cfg.Token == "" { if err := conn.passwordAuth(cfg.Username, cfg.Password); err != nil { + close(connectDone) ws.Close() return nil, err } @@ -103,6 +117,7 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) { // Step 3: receive init (or error/auth_err). initMsg, err := conn.readInit() if err != nil { + close(connectDone) ws.Close() return nil, err } @@ -111,6 +126,7 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) { // Step 4: configure TUN via callback. if cfg.OnInit != nil { if err := cfg.OnInit(initMsg); err != nil { + close(connectDone) ws.Close() return nil, fmt.Errorf("tun configure: %w", err) } @@ -118,10 +134,15 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) { // Step 5: send ready. if err := conn.sendReady(); err != nil { + close(connectDone) ws.Close() return nil, fmt.Errorf("send ready: %w", err) } + // Handshake complete - stop the ctx watcher so it doesn't close + // the connection after we return it to the caller. + close(connectDone) + // Step 6: set up keepalive — reset read deadline on each server // ping, and auto-respond with pong (allowed via WriteControl in // handler — see gorilla/websocket docs). diff --git a/internal/ui/app.go b/internal/ui/app.go index a23fd41..9af1263 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -46,6 +46,9 @@ type App struct { txV6Label *widget.Label rxTotalLabel *widget.Label txTotalLabel *widget.Label + routingModeLabel *widget.Label + cidrV4Label *widget.Label + cidrV6Label *widget.Label connectBtn *widget.Button disconnectBtn *widget.Button diff --git a/internal/ui/profile.go b/internal/ui/profile.go index 46b71d5..6c06be4 100644 --- a/internal/ui/profile.go +++ b/internal/ui/profile.go @@ -1,6 +1,7 @@ package ui import ( + "encoding/json" "fmt" "strconv" "strings" @@ -19,9 +20,11 @@ import ( // mapped back to the codes stored in the database. var ( authCodes = []string{string(model.AuthModeBoth), string(model.AuthModeJWT), string(model.AuthModePassword)} - routeCodes = []string{string(model.RoutingFull), string(model.RoutingSplit), string(model.RoutingCustom)} + routeCodes = []string{string(model.RoutingFull), string(model.RoutingProxy), string(model.RoutingBypass)} protoCodes = []string{"wss", "ws"} + + fetchTimingCodes = []string{string(model.FetchBefore), string(model.FetchAfter)} ) func authModeLabels() []string { @@ -29,13 +32,17 @@ func authModeLabels() []string { } func routeModeLabels() []string { - return []string{i18n.T("RoutingModeFull"), i18n.T("RoutingModeSplit"), i18n.T("RoutingModeCustom")} + return []string{i18n.T("RoutingModeFull"), i18n.T("RoutingModeProxy"), i18n.T("RoutingModeBypass")} } func protoLabels() []string { return []string{"wss", "ws"} } +func fetchTimingLabels() []string { + return []string{i18n.T("FetchTimingBefore"), i18n.T("FetchTimingAfter")} +} + // codeIndex returns the position of code in codes, or 0 if not found. func codeIndex(codes []string, code string) int { for i, c := range codes { @@ -54,6 +61,100 @@ func selectedCode(codes []string, idx int) string { return codes[idx] } +// urlEntryRow holds the widgets for a single CIDR URL source row. +type urlEntryRow struct { + urlEntry *widget.Entry + timingSelect *widget.Select + container *fyne.Container +} + +// cidrURLList manages a dynamic list of CIDR URL source rows. +type cidrURLList struct { + rows []*urlEntryRow + container *fyne.Container + parent fyne.Window +} + +func newCIDRURLList(parent fyne.Window) *cidrURLList { + cl := &cidrURLList{ + container: container.NewVBox(), + parent: parent, + } + return cl +} + +func (cl *cidrURLList) addRow(url string, timingIdx int) { + urlEntry := widget.NewEntry() + urlEntry.Wrapping = fyne.TextWrapOff + urlEntry.Scroll = container.ScrollHorizontalOnly + urlEntry.SetPlaceHolder(i18n.T("PlaceholderCIDRURL")) + urlEntry.SetText(url) + + timingSelect := widget.NewSelect(fetchTimingLabels(), nil) + if timingIdx >= 0 && timingIdx < len(fetchTimingCodes) { + timingSelect.SetSelectedIndex(timingIdx) + } else { + timingSelect.SetSelectedIndex(0) + } + + row := &urlEntryRow{ + urlEntry: urlEntry, + timingSelect: timingSelect, + } + + removeBtn := widget.NewButton(i18n.T("BtnRemoveURL"), func() { + cl.removeRow(row) + }) + + row.container = container.NewBorder(nil, nil, nil, removeBtn, + container.NewVBox(urlEntry, timingSelect)) + + cl.rows = append(cl.rows, row) + cl.container.Add(row.container) +} + +func (cl *cidrURLList) removeRow(row *urlEntryRow) { + for i, r := range cl.rows { + if r == row { + cl.rows = append(cl.rows[:i], cl.rows[i+1:]...) + cl.container.Remove(row.container) + break + } + } +} + +func (cl *cidrURLList) loadFromJSON(jsonStr string) { + for _, r := range cl.rows { + cl.container.Remove(r.container) + } + cl.rows = nil + + sources := model.ParseCIDRURLs(jsonStr) + for _, s := range sources { + timingIdx := codeIndex(fetchTimingCodes, string(s.FetchTiming)) + cl.addRow(s.URL, timingIdx) + } +} + +func (cl *cidrURLList) toSources() []model.CIDRURLSource { + var sources []model.CIDRURLSource + for _, r := range cl.rows { + url := strings.TrimSpace(r.urlEntry.Text) + if url == "" { + continue + } + sources = append(sources, model.CIDRURLSource{ + URL: url, + FetchTiming: model.FetchTiming(selectedCode(fetchTimingCodes, r.timingSelect.SelectedIndex())), + }) + } + return sources +} + +func (cl *cidrURLList) toJSON() string { + return marshalCIDRURLs(cl.toSources()) +} + // showProfileDialog opens a separate window for editing a server profile. // If editing is nil, a new profile is created. If a profile window is // already open, it is brought to the front instead of opening a second one. @@ -98,11 +199,17 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { authSelect := widget.NewSelect(authModeLabels(), nil) routeSelect := widget.NewSelect(routeModeLabels(), nil) - cidrEntry := widget.NewMultiLineEntry() - cidrEntry.Wrapping = fyne.TextWrapOff - cidrEntry.Scroll = container.ScrollNone - cidrEntry.SetMinRowsVisible(4) - cidrEntry.SetPlaceHolder(i18n.T("PlaceholderCIDRs")) + cidrV4Entry := widget.NewMultiLineEntry() + cidrV4Entry.Wrapping = fyne.TextWrapOff + cidrV4Entry.Scroll = container.ScrollNone + cidrV4Entry.SetMinRowsVisible(3) + cidrV4Entry.SetPlaceHolder(i18n.T("PlaceholderCIDRV4")) + + cidrV6Entry := widget.NewMultiLineEntry() + cidrV6Entry.Wrapping = fyne.TextWrapOff + cidrV6Entry.Scroll = container.ScrollNone + cidrV6Entry.SetMinRowsVisible(3) + cidrV6Entry.SetPlaceHolder(i18n.T("PlaceholderCIDRV6")) mtuEntry := widget.NewEntry() mtuEntry.Wrapping = fyne.TextWrapOff @@ -155,6 +262,32 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { } } + // CIDR URL lists for IPv4 and IPv6. + v4URLList := newCIDRURLList(profileWin) + v6URLList := newCIDRURLList(profileWin) + + v4AddURLBtn := widget.NewButton(i18n.T("BtnAddURL"), func() { + v4URLList.addRow("", 0) + }) + v6AddURLBtn := widget.NewButton(i18n.T("BtnAddURL"), func() { + v6URLList.addRow("", 0) + }) + + // CIDR section visibility: hide when routing mode is "full". + cidrV4EntryBox := container.NewVBox(widget.NewLabel(i18n.T("FieldCIDRV4")), cidrV4Entry) + cidrV6EntryBox := container.NewVBox(widget.NewLabel(i18n.T("FieldCIDRV6")), cidrV6Entry) + v4URLBox := container.NewVBox(widget.NewLabel(i18n.T("FieldCIDRV4URLs")), v4URLList.container, v4AddURLBtn) + v6URLBox := container.NewVBox(widget.NewLabel(i18n.T("FieldCIDRV6URLs")), v6URLList.container, v6AddURLBtn) + cidrSection := container.NewVBox(cidrV4EntryBox, cidrV6EntryBox, v4URLBox, v6URLBox) + + setCIDRVisible := func(mode string) { + if mode == string(model.RoutingFull) { + cidrSection.Hide() + } else { + cidrSection.Show() + } + } + if !isNew { nameEntry.SetText(editing.Name) protoSelect.SetSelectedIndex(codeIndex(protoCodes, editing.Protocol)) @@ -167,7 +300,10 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { userEntry.SetText(editing.Username) authSelect.SetSelectedIndex(codeIndex(authCodes, string(editing.AuthMode))) routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(editing.RoutingMode))) - cidrEntry.SetText(editing.CustomCIDRs) + cidrV4Entry.SetText(editing.CIDRV4) + cidrV6Entry.SetText(editing.CIDRV6) + v4URLList.loadFromJSON(editing.CIDRV4URLs) + v6URLList.loadFromJSON(editing.CIDRV6URLs) mtuEntry.SetText(fmtInt(editing.MTUOverride)) tlsCaPEMEntry.SetText(editing.TLSCACert) tlsCaPathEntry.SetText(editing.TLSCAPath) @@ -188,6 +324,11 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { } setTLSEnabled(selectedCode(protoCodes, protoSelect.SelectedIndex()) == "wss") + routeSelect.OnChanged = func(_ string) { + setCIDRVisible(selectedCode(routeCodes, routeSelect.SelectedIndex())) + } + setCIDRVisible(selectedCode(routeCodes, routeSelect.SelectedIndex())) + form := container.NewVBox( widget.NewLabel(i18n.T("FieldName")), nameEntry, @@ -213,7 +354,8 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { container.NewVBox(widget.NewLabel(i18n.T("FieldRoutingMode")), routeSelect), ), - widget.NewLabel(i18n.T("FieldCustomCIDRs")), cidrEntry, + cidrSection, + widget.NewLabel(i18n.T("FieldMTUOverride")), mtuEntry, widget.NewLabel(i18n.T("FieldTLS")), @@ -230,6 +372,10 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { profileWin = a.fyneApp.NewWindow(i18n.T("DlgProfileTitle")) a.profileWindow = profileWin + // Update parent references now that the window exists. + v4URLList.parent = profileWin + v6URLList.parent = profileWin + profileWin.SetOnClosed(func() { a.profileWindow = nil }) @@ -243,7 +389,9 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { userEntry.Text, passEntry.Text, selectedCode(authCodes, authSelect.SelectedIndex()), selectedCode(routeCodes, routeSelect.SelectedIndex()), - cidrEntry.Text, mtuEntry.Text, + cidrV4Entry.Text, cidrV6Entry.Text, + v4URLList.toJSON(), v6URLList.toJSON(), + mtuEntry.Text, tlsCaPEMEntry.Text, tlsCaPathEntry.Text, tlsPinnedHashEntry.Text, tlsInsecureCheck.Checked, isNew) { @@ -257,14 +405,15 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { }) profileWin.SetContent(container.NewBorder(nil, container.NewHBox(saveBtn, cancelBtn), nil, nil, container.NewVScroll(form))) - profileWin.Resize(fyne.NewSize(460, 760)) + profileWin.Resize(fyne.NewSize(460, 860)) profileWin.Show() } // saveProfile creates or updates a profile and stores credentials. // Returns true on success, false if validation or DB operation failed. func (a *App) saveProfile(editing *model.ServerProfile, - name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode, cidrs, mtuStr, + name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode, + cidrV4, cidrV6, cidrV4URLs, cidrV6URLs, mtuStr, tlsCaPEM, tlsCaPath, tlsPinnedHash string, tlsInsecure, isNew bool) bool { if name == "" || host == "" || user == "" { showError(i18n.T("DlgValidationTitle"), i18n.T("DlgValidationMsg"), a.window) @@ -296,7 +445,10 @@ func (a *App) saveProfile(editing *model.ServerProfile, Username: user, AuthMode: model.AuthMode(authMode), RoutingMode: model.RoutingMode(routeMode), - CustomCIDRs: cidrs, + CIDRV4: cidrV4, + CIDRV6: cidrV6, + CIDRV4URLs: cidrV4URLs, + CIDRV6URLs: cidrV6URLs, MTUOverride: mtu, TLSCACert: tlsCaPEM, TLSCAPath: tlsCaPath, @@ -325,7 +477,10 @@ func (a *App) saveProfile(editing *model.ServerProfile, editing.Username = user editing.AuthMode = model.AuthMode(authMode) editing.RoutingMode = model.RoutingMode(routeMode) - editing.CustomCIDRs = cidrs + editing.CIDRV4 = cidrV4 + editing.CIDRV6 = cidrV6 + editing.CIDRV4URLs = cidrV4URLs + editing.CIDRV6URLs = cidrV6URLs editing.MTUOverride = mtu editing.TLSCACert = tlsCaPEM editing.TLSCAPath = tlsCaPath @@ -347,6 +502,16 @@ func (a *App) saveProfile(editing *model.ServerProfile, return true } +// marshalCIDRURLsForDB is a helper for tests/debugging that encodes +// CIDRURLSource slice to JSON. +func marshalCIDRURLsForDB(sources []model.CIDRURLSource) string { + data, err := json.Marshal(sources) + if err != nil { + return "" + } + return string(data) +} + func fmtInt(n int) string { return strconv.Itoa(n) } diff --git a/internal/ui/view.go b/internal/ui/view.go index d7747ef..ab8c40a 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -2,12 +2,14 @@ package ui import ( _ "embed" + "encoding/json" "fmt" "net/url" "time" "lmvpn/internal/i18n" "lmvpn/internal/ipc" + "lmvpn/internal/model" "lmvpn/internal/stats" "fyne.io/fyne/v2" @@ -69,6 +71,12 @@ func (a *App) buildMainWindow() fyne.CanvasObject { a.txV6Label = widget.NewLabel(i18n.T("TxV6Zero")) a.rxTotalLabel = widget.NewLabel(i18n.T("RxTotalZero")) a.txTotalLabel = widget.NewLabel(i18n.T("TxTotalZero")) + a.routingModeLabel = widget.NewLabel("") + a.routingModeLabel.Hide() + a.cidrV4Label = widget.NewLabel("") + a.cidrV4Label.Hide() + a.cidrV6Label = widget.NewLabel("") + a.cidrV6Label.Hide() statusCard := widget.NewCard(i18n.T("StatusLabel"), "", container.NewVBox( a.stateLabel, @@ -78,6 +86,9 @@ func (a *App) buildMainWindow() fyne.CanvasObject { container.NewHBox(a.rxV4Label, a.txV4Label), container.NewHBox(a.rxV6Label, a.txV6Label), container.NewHBox(a.rxTotalLabel, a.txTotalLabel), + a.routingModeLabel, + a.cidrV4Label, + a.cidrV6Label, )) // Buttons. @@ -144,7 +155,12 @@ func (a *App) onConnect() { a.ipcClient = client a.mu.Unlock() if oldClient != nil { - _ = ipc.SendStop(oldClient) + // Do NOT send SendStop on the old connection. The daemon's + // startSession already calls stopSessionLocked() which + // tears down any existing session. Sending SendStop here + // races with SendStart on the new connection - the daemon + // processes each IPC connection in a separate goroutine, + // so a late SendStop could kill the newly started session. oldClient.Close() } @@ -178,7 +194,10 @@ func (a *App) onConnect() { Password: password, AuthMode: string(p.AuthMode), RoutingMode: string(p.RoutingMode), - CustomCIDRs: splitCIDRs(p.CustomCIDRs), + CIDRV4: model.SplitCIDRs(p.CIDRV4), + CIDRV6: model.SplitCIDRs(p.CIDRV6), + CIDRV4URLs: parseIPCCIDRURLs(p.CIDRV4URLs), + CIDRV6URLs: parseIPCCIDRURLs(p.CIDRV6URLs), MTUOverride: p.MTUOverride, TLSCACert: p.TLSCACert, TLSCAPath: p.TLSCAPath, @@ -221,6 +240,9 @@ func (a *App) onDisconnect() { a.txV6Label.SetText(i18n.T("TxV6Zero")) a.rxTotalLabel.SetText(i18n.T("RxTotalZero")) a.txTotalLabel.SetText(i18n.T("TxTotalZero")) + a.routingModeLabel.Hide() + a.cidrV4Label.Hide() + a.cidrV6Label.Hide() } // eventLoop reads IPC events from the daemon and updates the UI. @@ -250,6 +272,9 @@ func (a *App) eventLoop() { a.txV6Label.SetText(i18n.T("TxV6Zero")) a.rxTotalLabel.SetText(i18n.T("RxTotalZero")) a.txTotalLabel.SetText(i18n.T("TxTotalZero")) + a.routingModeLabel.Hide() + a.cidrV4Label.Hide() + a.cidrV6Label.Hide() a.setConnButtons(true, false) } }) @@ -376,6 +401,53 @@ func (a *App) applyStats(s stats.Snapshot) { if s.Uptime > 0 { a.uptimeLabel.SetText(i18n.T("UptimeLabel", map[string]interface{}{"uptime": formatDuration(s.Uptime)})) } + + // Routing mode + CIDR hit statistics. + if s.RoutingMode != "" { + modeLabel := routeModeLabel(s.RoutingMode) + a.routingModeLabel.SetText(i18n.T("StatusRoutingMode", map[string]interface{}{"mode": modeLabel})) + a.routingModeLabel.Show() + switch s.RoutingMode { + case "proxy": + a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d/%d %s", + s.CIDRV4Hits, s.CIDRV4Total, i18n.T("CIDRHit"))) + a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d/%d %s", + s.CIDRV6Hits, s.CIDRV6Total, i18n.T("CIDRHit"))) + a.cidrV4Label.Show() + a.cidrV6Label.Show() + case "bypass": + a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d %s | %s: %d %s", + s.CIDRV4Total, i18n.T("CIDRConfigured"), + i18n.T("CIDRUnmatched"), s.CIDRV4Hits, i18n.T("CIDRDestinations"))) + a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d %s | %s: %d %s", + s.CIDRV6Total, i18n.T("CIDRConfigured"), + i18n.T("CIDRUnmatched"), s.CIDRV6Hits, i18n.T("CIDRDestinations"))) + a.cidrV4Label.Show() + a.cidrV6Label.Show() + default: + a.cidrV4Label.Hide() + a.cidrV6Label.Hide() + } + } else { + a.routingModeLabel.Hide() + a.cidrV4Label.Hide() + a.cidrV6Label.Hide() + } +} + +// routeModeLabel returns the localised display name for a routing mode +// code string. +func routeModeLabel(code string) string { + switch code { + case "full": + return i18n.T("RoutingModeFull") + case "proxy": + return i18n.T("RoutingModeProxy") + case "bypass": + return i18n.T("RoutingModeBypass") + default: + return code + } } // formatBytes formats a byte count human-readably. @@ -424,20 +496,32 @@ func formatDuration(d time.Duration) string { return fmt.Sprintf("%ds", s) } -// splitCIDRs splits a comma-separated CIDR string into a slice. -func splitCIDRs(s string) []string { - if s == "" { +// parseIPCCIDRURLs decodes a JSON-encoded model.CIDRURLSource array +// from the profile string and converts it to the IPC wire type. +func parseIPCCIDRURLs(jsonStr string) []ipc.CIDRURLSource { + sources := model.ParseCIDRURLs(jsonStr) + if len(sources) == 0 { return nil } - var out []string - start := 0 - for i := 0; i <= len(s); i++ { - if i == len(s) || s[i] == ',' { - if i > start { - out = append(out, s[start:i]) - } - start = i + 1 + out := make([]ipc.CIDRURLSource, len(sources)) + for i, s := range sources { + out[i] = ipc.CIDRURLSource{ + URL: s.URL, + FetchTiming: string(s.FetchTiming), } } return out } + +// marshalCIDRURLs encodes a slice of CIDRURLSource to a JSON string +// for storage in the database. +func marshalCIDRURLs(sources []model.CIDRURLSource) string { + if len(sources) == 0 { + return "" + } + data, err := json.Marshal(sources) + if err != nil { + return "" + } + return string(data) +} diff --git a/internal/vpn/session.go b/internal/vpn/session.go index 5a3168b..4e9bd3c 100644 --- a/internal/vpn/session.go +++ b/internal/vpn/session.go @@ -16,9 +16,11 @@ import ( "net/http" "strings" "sync" + "sync/atomic" "time" "lmvpn/internal/auth" + "lmvpn/internal/cidrsource" "lmvpn/internal/log" "lmvpn/internal/model" "lmvpn/internal/protocol" @@ -39,8 +41,11 @@ type SessionConfig struct { AuthMode model.AuthMode Token string // pre-obtained JWT (empty = fetch via HTTP login) RoutingMode route.Mode - CustomCIDRs []string - MTUOverride int // 0 = use server MTU + CIDRV4 []string // static IPv4 CIDRs (proxy/bypass mode) + CIDRV6 []string // static IPv6 CIDRs (proxy/bypass mode) + CIDRV4URLs []model.CIDRURLSource // IPv4 CIDR URL sources + CIDRV6URLs []model.CIDRURLSource // IPv6 CIDR URL sources + MTUOverride int // 0 = use server MTU TLSCACert string // inline CA cert PEM (wss only) TLSCAPath string // CA cert file path (wss only) TLSInsecure bool // skip cert verification (wss only) @@ -62,6 +67,9 @@ type SessionManager struct { conn *transport.Conn done chan struct{} + // CIDR hit tracking. Set during setupTUN, cleared on cleanup. + cidrTracker *cidrTracker + // EWMA speed smoothing state. Only touched by reportStats (single // goroutine), so no lock needed. ewma* fields hold the smoothed // bytes/sec; prev* hold the last snapshot's cumulative counters and @@ -124,24 +132,43 @@ func (sm *SessionManager) Disconnect() { } sm.running = false cancel := sm.cancel + // Extract resources so we can clean them up outside the lock. + // Setting them to nil here also prevents the run goroutine's + // cleanup from double-cleaning (it will see nil and skip). + routeMgr := sm.routeMgr + conn := sm.conn + dev := sm.dev + sm.routeMgr = nil + sm.conn = nil + sm.dev = nil + sm.cidrTracker = nil sm.mu.Unlock() + if cancel != nil { cancel() } - // Close the transport to unblock the WS->TUN goroutine. - sm.mu.Lock() - conn := sm.conn - dev := sm.dev - sm.mu.Unlock() + + // CRITICAL: remove routes BEFORE closing the TUN device. If the TUN + // is closed first, the /1 cover routes still point at the dead TUN + // and all traffic is blackholed until routeMgr.Cleanup() runs - + // this causes a brief network outage (browsers, DNS, etc.). + if routeMgr != nil { + if err := routeMgr.Cleanup(); err != nil { + log.L().Error("route cleanup error during disconnect", "error", err) + } + } + + // Now safe to close the transport and TUN device. if conn != nil { conn.Close() } - // Close the TUN device to unblock the TUN->WS goroutine's dev.Read(). if dev != nil { dev.Close() } - // Wait for the run goroutine to fully exit so that cleanup - // (route removal, TUN teardown) is complete before returning. + + // Wait for the run goroutine to fully exit. By now it should be + // unblocked (ctx cancelled, conn/dev closed) and will see nil + // routeMgr/conn/dev in cleanup, so it won't double-clean. if sm.done != nil { <-sm.done } @@ -158,6 +185,23 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { } }() + // Fetch "before-proxy" CIDR lists ONCE before the reconnect loop. + // These HTTP requests go through the physical NIC (routes are + // clean). The result is reused across reconnection attempts so we + // don't re-fetch on every retry. This runs outside the handshake + // to avoid consuming the server's 30s ReadyTimeout budget. + var beforeCIDRs []string + allURLSources := append(append([]model.CIDRURLSource{}, cfg.CIDRV4URLs...), cfg.CIDRV6URLs...) + if len(allURLSources) > 0 { + log.L().Info("fetching before-proxy CIDR lists", "url_count", len(allURLSources)) + fetched, err := cidrsource.FetchBeforeProxy(ctx, allURLSources) + if err != nil { + log.L().Error("fetch before-proxy CIDR lists failed (continuing)", "error", err) + } + beforeCIDRs = fetched + log.L().Info("before-proxy CIDR lists ready", "total_cidrs", len(beforeCIDRs)) + } + backoff := time.Second maxBackoff := 60 * time.Second @@ -176,7 +220,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { targetIP = targets[ipIndex] } - err := sm.connectOnce(ctx, cfg, targetIP) + err := sm.connectOnce(ctx, cfg, targetIP, beforeCIDRs) if ctx.Err() != nil { sm.cleanup() return @@ -185,6 +229,11 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { if err != nil { log.L().Error("VPN connection failed", "error", err) + // Safety net: ensure no TUN/routes leak from a failed + // attempt. connectOnce should have already cleaned up, but + // this guards against any path that returned early. + sm.cleanupResources() + // A TLS certificate verification failure on the original // hostname (ipIndex == 0) is not retryable: the cert won't // change between attempts, so stop the loop and surface the @@ -291,7 +340,9 @@ func fatalAuthError(err error) (protocol.AuthErrorCode, string, bool) { // connectOnce performs a single connection lifecycle: authenticate, // handshake, configure TUN, apply routes, pump packets until failure. -func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, targetIP string) error { +// beforeCIDRs contains CIDRs pre-fetched from "before" timing URL +// sources (fetched once in run(), reused across reconnection attempts). +func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, targetIP string, beforeCIDRs []string) error { sm.setState(stats.StateConnecting) // Build URL for this attempt. If targetIP is set (CDN failover), @@ -330,7 +381,7 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta if err != nil { return fmt.Errorf("parse server URL: %w", err) } - result, err := auth.Login(httpBase, cfg.Username, cfg.Password, tlsCfg) + result, err := auth.Login(ctx, httpBase, cfg.Username, cfg.Password, tlsCfg) if err != nil { if cfg.AuthMode == model.AuthModeBoth { // Fall back to password auth. @@ -352,7 +403,7 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta Username: cfg.Username, Password: cfg.Password, OnInit: func(init protocol.InitMessage) error { - return sm.setupTUN(init, cfg) + return sm.setupTUN(init, cfg, beforeCIDRs) }, TLSConfig: tlsCfg, } @@ -368,11 +419,19 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta if errors.As(err, &authErr) || (errors.As(err, &serverErr) && serverErr.Type == protocol.TypeAuthErr) { log.L().Info("JWT auth failed, falling back to password auth", "error", err) + // Clean up any resources created by the failed attempt's + // setupTUN before retrying. + sm.cleanupResources() handshake.Token = "" conn, err = transport.Connect(ctx, handshake) } } if err != nil { + // Clean up TUN/routes if setupTUN ran but the handshake + // failed (e.g. server closed the connection after + // ReadyTimeout). Without this, leaked /1 cover routes + // blackhole all traffic and prevent reconnection. + sm.cleanupResources() return err } } @@ -392,6 +451,11 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta statsDone := make(chan struct{}) go sm.reportStats(statsDone, ctx) + // Fetch "after-proxy" CIDR lists via the tunnel and dynamically add + // their routes. This runs in a goroutine so it doesn't block the + // packet pump. + go sm.fetchAfterProxyCIDRs(ctx, cfg) + // Run the packet pump (blocks until connection breaks). sm.pumpPackets(ctx, conn) @@ -402,8 +466,12 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta // setupTUN creates and configures the TUN device and applies routes. // This is called by the transport during the handshake, between init -// and ready. -func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig) error { +// and ready. It performs NO network calls (HTTP/DNS) so it completes +// in milliseconds and never exceeds the server's ReadyTimeout. +// +// beforeCIDRs contains CIDRs fetched from "before" timing URL sources, +// pre-fetched by connectOnce before the handshake began. +func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig, beforeCIDRs []string) error { dev, err := tun.Create("") if err != nil { return fmt.Errorf("create tun: %w", err) @@ -415,12 +483,10 @@ func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig) localIP := net.ParseIP(init.IP) peerIP := net.ParseIP(init.ServerIP) if localIP == nil || peerIP == nil { - dev.Close() return fmt.Errorf("invalid init IPs: %s / %s", init.IP, init.ServerIP) } if err := dev.Configure(localIP, init.Prefix, peerIP); err != nil { - dev.Close() return fmt.Errorf("configure tun: %w", err) } @@ -429,11 +495,9 @@ func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig) if hasV6 { ip6 := net.ParseIP(init.IP6) if ip6 == nil { - dev.Close() return fmt.Errorf("invalid init IPv6: %s", init.IP6) } if err := dev.ConfigureIPv6(ip6, init.Prefix6); err != nil { - dev.Close() return fmt.Errorf("configure tun ipv6: %w", err) } } @@ -443,10 +507,15 @@ func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig) mtu = cfg.MTUOverride } if err := dev.SetMTU(mtu); err != nil { - dev.Close() return fmt.Errorf("set mtu: %w", err) } + // Merge static CIDRs with pre-fetched before-proxy CIDRs. + var cidrs []string + cidrs = append(cidrs, cfg.CIDRV4...) + cidrs = append(cidrs, cfg.CIDRV6...) + cidrs = append(cidrs, beforeCIDRs...) + // Apply routing. routeCfg := route.Config{ Mode: cfg.RoutingMode, @@ -456,19 +525,73 @@ func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig) VPNIP6: init.IP6, VPNPrefix6: init.Prefix6, ServerHost: serverHostFromURL(cfg.ServerURL), - CustomCIDRs: cfg.CustomCIDRs, + CIDRs: cidrs, } sm.routeMgr = route.NewManager(routeCfg) if err := sm.routeMgr.Apply(); err != nil { log.L().Error("route apply failed (continuing)", "error", err) } + // Initialise the CIDR hit tracker for proxy/bypass modes. + sm.cidrTracker = newCIDRTracker(cfg.RoutingMode, cidrs) + + // Log CIDR breakdown by family for diagnostics. + v4Total, _, v6Total, _ := sm.cidrTracker.Stats() log.L().Info("TUN configured", "dev", dev.Name(), "ip", init.IP, "prefix", init.Prefix, - "ip6", init.IP6, "prefix6", init.Prefix6, "mtu", mtu) + "ip6", init.IP6, "prefix6", init.Prefix6, "mtu", mtu, + "routing_mode", cfg.RoutingMode, + "cidr_v4", v4Total, "cidr_v6", v6Total, + "before_proxy_cidrs", len(beforeCIDRs)) return nil } +// fetchAfterProxyCIDRs fetches CIDR lists from URLs with "after" timing +// (via the tunnel) and dynamically adds their routes to the route +// manager. This is called in a goroutine after the data plane is up. +func (sm *SessionManager) fetchAfterProxyCIDRs(ctx context.Context, cfg SessionConfig) { + allURLSources := append(append([]model.CIDRURLSource{}, cfg.CIDRV4URLs...), cfg.CIDRV6URLs...) + // Count only "after" sources for logging. + afterCount := 0 + for _, s := range allURLSources { + if s.FetchTiming == model.FetchAfter { + afterCount++ + } + } + if afterCount == 0 { + return + } + + log.L().Info("fetching after-proxy CIDR lists", "url_count", afterCount) + fetched, err := cidrsource.FetchAfterProxy(ctx, allURLSources) + if err != nil { + log.L().Error("fetch after-proxy CIDR lists completed with errors", "error", err) + } + if len(fetched) == 0 { + return + } + + sm.mu.Lock() + mgr := sm.routeMgr + sm.mu.Unlock() + if mgr == nil { + return + } + + if err := mgr.AddRoutes(fetched); err != nil { + log.L().Error("add after-proxy routes failed (continuing)", "error", err) + } else { + log.L().Info("added after-proxy routes", "count", len(fetched)) + } + + sm.mu.Lock() + tracker := sm.cidrTracker + sm.mu.Unlock() + if tracker != nil { + tracker.AddCIDRs(fetched) + } +} + // pumpPackets runs the bidirectional packet loop until the connection // breaks. func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn) { @@ -479,6 +602,12 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn) go func() { defer wg.Done() buf := make([]byte, 65536) + // Cache the cidrTracker pointer once; it is stable for the + // lifetime of pumpPackets (set in setupTUN, cleared in cleanup + // which only runs after pumpPackets returns). + sm.mu.Lock() + tracker := sm.cidrTracker + sm.mu.Unlock() for { select { case <-ctx.Done(): @@ -503,6 +632,9 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn) return } sm.stats.AddTx(buf[:n]) + if tracker != nil { + tracker.Record(buf[:n]) + } } }() @@ -536,8 +668,12 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn) wg.Wait() } -// cleanup tears down the TUN device and routes. -func (sm *SessionManager) cleanup() { +// cleanupResources tears down the TUN device, routes, and transport +// connection WITHOUT changing the session state. This is used on +// handshake-failure paths where the caller will set the appropriate +// state (e.g. StateReconnecting). Returns without error if nothing +// was set up. +func (sm *SessionManager) cleanupResources() { sm.mu.Lock() dev := sm.dev routeMgr := sm.routeMgr @@ -545,6 +681,7 @@ func (sm *SessionManager) cleanup() { sm.dev = nil sm.routeMgr = nil sm.conn = nil + sm.cidrTracker = nil sm.mu.Unlock() if routeMgr != nil { @@ -558,6 +695,12 @@ func (sm *SessionManager) cleanup() { if conn != nil { conn.Close() } +} + +// cleanup tears down the TUN device, routes, and transport, then marks +// the session as disconnected. Used on normal session termination. +func (sm *SessionManager) cleanup() { + sm.cleanupResources() sm.stats.SetDisconnected() } @@ -647,6 +790,20 @@ func (sm *SessionManager) reportStats(done <-chan struct{}, ctx context.Context) sm.prevSnap.RxSpeed, sm.prevSnap.TxSpeed = 0, 0 sm.prevTick = now sm.speedReady = true + + // Fill in CIDR hit statistics. + sm.mu.Lock() + tracker := sm.cidrTracker + sm.mu.Unlock() + if tracker != nil { + v4Total, v4Hits, v6Total, v6Hits := tracker.Stats() + snap.RoutingMode = string(tracker.mode) + snap.CIDRV4Total = v4Total + snap.CIDRV4Hits = v4Hits + snap.CIDRV6Total = v6Total + snap.CIDRV6Hits = v6Hits + } + sm.onStats(snap) } } @@ -707,3 +864,175 @@ func replaceHost(rawURL, newHost string) string { } return rawURL } + +// cidrTracker tracks which configured CIDRs have been "hit" by +// outbound traffic (TUN -> WebSocket). The behaviour differs by mode: +// +// - Proxy mode: each outbound packet's destination IP is matched +// against the CIDR list; the first matching CIDR is marked as hit. +// Stats() returns the count of hit CIDRs vs total CIDRs. +// - Bypass mode: outbound traffic through TUN is, by definition, +// traffic that did NOT match any bypass CIDR (bypassed traffic +// goes via the physical NIC). We count distinct destination +// /24 (v4) or /48 (v6) prefixes seen on TUN as "unmatched +// destinations". Stats() returns the total bypass CIDR count +// and the unmatched destination count. +type cidrTracker struct { + mode route.Mode + mu sync.RWMutex + + // Proxy mode: pre-parsed CIDR nets + per-CIDR hit flags. + // Protected by mu for concurrent AddCIDRs (write) vs Record/Stats + // (read). The atomic.Bool hit flags are individually atomic, but + // the slices themselves need the lock. + v4CIDRs []*net.IPNet + v6CIDRs []*net.IPNet + v4Hits []atomic.Bool + v6Hits []atomic.Bool + + // Bypass mode: distinct destination prefix sets. + // v4 key = first 3 bytes of dest IP (a /24 prefix). + // v6 key = first 6 bytes of dest IP (a /48 prefix). + // sync.Map is already goroutine-safe; no lock needed. + v4Prefixes sync.Map + v6Prefixes sync.Map + v4Count atomic.Int64 + v6Count atomic.Int64 +} + +// newCIDRTracker creates a tracker for the given routing mode and CIDR +// list. For full-tunnel mode, a tracker is still created but will +// report zero totals (no CIDRs configured). +func newCIDRTracker(mode route.Mode, cidrs []string) *cidrTracker { + t := &cidrTracker{mode: mode} + if mode == route.ModeFull { + return t + } + t.addCIDRs(cidrs) + return t +} + +// AddCIDRs appends additional CIDRs to the tracker (used for +// after-proxy fetched CIDRs). Existing hit flags are preserved. +func (t *cidrTracker) AddCIDRs(cidrs []string) { + t.addCIDRs(cidrs) +} + +func (t *cidrTracker) addCIDRs(cidrs []string) { + t.mu.Lock() + defer t.mu.Unlock() + for _, cidrStr := range cidrs { + cidrStr = strings.TrimSpace(cidrStr) + if cidrStr == "" { + continue + } + _, ipNet, err := net.ParseCIDR(cidrStr) + if err != nil { + continue + } + if ipNet.IP.To4() != nil { + t.v4CIDRs = append(t.v4CIDRs, ipNet) + t.v4Hits = append(t.v4Hits, atomic.Bool{}) + } else { + t.v6CIDRs = append(t.v6CIDRs, ipNet) + t.v6Hits = append(t.v6Hits, atomic.Bool{}) + } + } +} + +// Record inspects an outbound IP packet (from TUN) and updates hit +// counters. It is called from the TUN -> WebSocket goroutine for every +// packet. Packets too short to contain an IP header or with an unknown +// version are silently ignored. +func (t *cidrTracker) Record(p []byte) { + if len(p) < 1 { + return + } + switch p[0] >> 4 { + case 4: + if len(p) < 20 { + return + } + dst := net.IP(p[16:20]) + t.recordV4(dst) + case 6: + if len(p) < 40 { + return + } + dst := net.IP(p[24:40]) + t.recordV6(dst) + } +} + +func (t *cidrTracker) recordV4(dst net.IP) { + switch t.mode { + case route.ModeProxy: + t.mu.RLock() + defer t.mu.RUnlock() + for i, cidr := range t.v4CIDRs { + if !t.v4Hits[i].Load() && cidr.Contains(dst) { + t.v4Hits[i].Store(true) + } + } + case route.ModeBypass: + key := string(dst.To4()[:3]) + if _, loaded := t.v4Prefixes.LoadOrStore(key, struct{}{}); !loaded { + t.v4Count.Add(1) + } + } +} + +func (t *cidrTracker) recordV6(dst net.IP) { + switch t.mode { + case route.ModeProxy: + t.mu.RLock() + defer t.mu.RUnlock() + for i, cidr := range t.v6CIDRs { + if !t.v6Hits[i].Load() && cidr.Contains(dst) { + t.v6Hits[i].Store(true) + } + } + case route.ModeBypass: + v6 := dst.To16() + if len(v6) < 6 { + return + } + key := string(v6[:6]) + if _, loaded := t.v6Prefixes.LoadOrStore(key, struct{}{}); !loaded { + t.v6Count.Add(1) + } + } +} + +// Stats returns the total and hit counts for IPv4 and IPv6 CIDRs. +// +// For proxy mode: "hits" = number of CIDRs that have been matched by +// at least one outbound packet. +// For bypass mode: "hits" = number of distinct destination prefixes +// seen on TUN (i.e. unmatched destinations that went through the +// tunnel because they didn't match any bypass CIDR). +func (t *cidrTracker) Stats() (v4Total, v4Hits, v6Total, v6Hits int) { + t.mu.RLock() + defer t.mu.RUnlock() + switch t.mode { + case route.ModeProxy: + v4Total = len(t.v4CIDRs) + for i := range t.v4Hits { + if t.v4Hits[i].Load() { + v4Hits++ + } + } + v6Total = len(t.v6CIDRs) + for i := range t.v6Hits { + if t.v6Hits[i].Load() { + v6Hits++ + } + } + case route.ModeBypass: + v4Total = len(t.v4CIDRs) + v4Hits = int(t.v4Count.Load()) + v6Total = len(t.v6CIDRs) + v6Hits = int(t.v6Count.Load()) + } + return +}