diff --git a/docs/client-development.md b/docs/client-development.md index a308834..9bab12f 100644 --- a/docs/client-development.md +++ b/docs/client-development.md @@ -294,7 +294,7 @@ sequenceDiagram ### 5.2 IP 分配规则 -服务端从 VPN 子网中分配客户端内网 IP(`internal/vpn/alloc.go:39-66`、`internal/vpn/service.go:47-57`): +服务端从 VPN 子网中分配客户端内网 IP(`internal/vpn/alloc.go:39-66`、`internal/vpn/service.go:47-58`): | 地址 | 分配规则 | 说明 | |------|----------|------| @@ -308,9 +308,19 @@ sequenceDiagram - 若该用户有预留 IP,优先使用预留;预留被占用时报错(`alloc.go:43-48`) - 地址耗尽时报错 "可用 IP 地址已耗尽"(`alloc.go:65`) +#### IPv6 双栈 + +当服务端配置了 IPv6 子网(`Subnet6`)时,客户端同时获得 IPv4 和 IPv6 地址: + +- IPv4 地址始终分配(`Subnet` 必填) +- IPv6 地址仅当 `Subnet6` 非空时分配(可选) +- IPv6 预留独立于 IPv4 预留,可单独配置 +- IPv6 子网前缀限制:`/64` ~ `/126` +- 对于 `/64` 等大子网,`cidr.AddressCount` 会溢出,实际扫描上限为 65536 个地址 + ### 5.3 init 消息 -前置检查通过后,服务端发送 `init` 文本消息(`internal/vpn/tunnel.go:126-137`): +前置检查通过后,服务端发送 `init` 文本消息(`internal/vpn/tunnel.go:132-148`): ```json { @@ -318,17 +328,25 @@ sequenceDiagram "ip": "10.0.0.5", "prefix": 24, "mtu": 1420, - "server_ip": "10.0.0.1" + "server_ip": "10.0.0.1", + "ip6": "fd00:dead:beef::5", + "prefix6": 112, + "server_ip6": "fd00:dead:beef::1" } ``` -| 字段 | 类型 | 说明 | 源码 | -|------|------|------|------| -| `type` | string | 固定值 `"init"` | `internal/vpn/protocol.go:3-9` | -| `ip` | string | 分配给客户端的内网 IP(点分十进制) | `tunnel.go:130` | -| `prefix` | int | 子网前缀长度(如 `24`) | `tunnel.go:131` | -| `mtu` | int | TUN 网卡 MTU | `tunnel.go:132` | -| `server_ip` | string | 服务器内网 IP(对端地址) | `tunnel.go:133` | +| 字段 | 类型 | 必填 | 说明 | 源码 | +|------|------|------|------|------| +| `type` | string | 是 | 固定值 `"init"` | `internal/vpn/protocol.go:3-10` | +| `ip` | string | 是 | 分配给客户端的 IPv4 地址 | `tunnel.go:136` | +| `prefix` | int | 是 | IPv4 子网前缀长度(如 `24`) | `tunnel.go:137` | +| `mtu` | int | 是 | TUN 网卡 MTU | `tunnel.go:138` | +| `server_ip` | string | 是 | 服务器 IPv4 地址 | `tunnel.go:139` | +| `ip6` | string | 否 | 分配给客户端的 IPv6 地址(仅当服务端配置了 IPv6 子网时存在) | `tunnel.go:141-144` | +| `prefix6` | int | 否 | IPv6 子网前缀长度 | `tunnel.go:142` | +| `server_ip6` | string | 否 | 服务器 IPv6 地址 | `tunnel.go:143` | + +> ℹ️ `ip6`/`prefix6`/`server_ip6` 字段使用 `omitempty`,旧客户端可安全忽略。若服务端未配置 IPv6 子网,这三个字段不会出现。 ### 5.4 ready 消息与超时 @@ -412,14 +430,14 @@ sequenceDiagram ### 6.4 反欺骗(Anti-Spoofing) -服务端**强制校验**每个来自客户端的 IP 包的源地址(`internal/vpn/switch.go:113-116`): +服务端**强制校验**每个来自客户端的 IP 包的源地址(`internal/vpn/switch.go:113-126`): -``` -if 源IP != 该客户端被分配的IP: - 丢弃该包(不转发,不写入TUN,不断开连接) -``` +- **IPv4 包**:源地址必须等于客户端被分配的 IPv4 地址(`init.ip`) +- **IPv6 包**:源地址必须等于客户端被分配的 IPv6 地址(`init.ip6`) -> ⚠️ 客户端必须确保 TUN 网卡只发送源地址为 `init.ip` 的 IP 包。若客户端配置错误导致源 IP 不匹配,所有上行包将被静默丢弃。 +不匹配的包将被静默丢弃(不转发、不写入 TUN、不断开连接)。 + +> ⚠️ 客户端必须确保 TUN 网卡只发送源地址与 `init.ip` / `init.ip6` 匹配的 IP 包。若客户端配置错误导致源 IP 不匹配,所有上行包将被静默丢弃。 ### 6.5 非 IP 二进制帧 @@ -488,9 +506,11 @@ conn.WriteControl(websocket.PingMessage, nil, ...) | 约束 | 值 | 源码 | |------|----|----| -| IP 版本 | 仅 IPv4 | `internal/handler/vpn.go:66-73` | -| 前缀长度 | ≤ /30 | `internal/handler/vpn.go:74-78` | +| IPv4 版本 | 仅 IPv4 | `internal/handler/vpn.go:66-73` | +| IPv4 前缀长度 | ≤ /30 | `internal/handler/vpn.go:74-78` | +| IPv6 前缀长度 | /64 ~ /126 | `internal/handler/vpn.go:81-92` | | 可用容量 | `AddressCount - 3` | `internal/vpn/alloc.go:106-112` | +| IPv6 大子网容量 | ~65533(/64 等大子网 AddressCount 溢出时) | `internal/vpn/alloc.go:108-110` | ### 8.4 MTU 约束 @@ -516,15 +536,18 @@ type controlMessage struct { } ``` -`init` 消息结构较为特殊(`internal/vpn/protocol.go:3-9`): +`init` 消息结构较为特殊(`internal/vpn/protocol.go:3-10`): ```go type initMessage struct { - Type string `json:"type"` - IP string `json:"ip"` - Prefix int `json:"prefix"` - MTU int `json:"mtu"` - ServerIP string `json:"server_ip"` + Type string `json:"type"` + IP string `json:"ip"` + Prefix int `json:"prefix"` + MTU int `json:"mtu"` + ServerIP string `json:"server_ip"` + IP6 string `json:"ip6,omitempty"` + Prefix6 int `json:"prefix6,omitempty"` + ServerIP6 string `json:"server_ip6,omitempty"` } ``` @@ -578,7 +601,7 @@ type initMessage struct { |------|----------------|------|------|------| | `auth_ok` | Text | 密码认证成功(仅方式 B) | JSON | `{"type":"auth_ok"}` | | `auth_err` | Text | 认证失败 | JSON | `{"type":"auth_err","message":"..."}` | -| `init` | Text | 认证成功且前置检查通过 | JSON | `{"type":"init","ip":"...","prefix":24,"mtu":1420,"server_ip":"..."}` | +| `init` | Text | 认证成功且前置检查通过 | JSON | `{"type":"init","ip":"...","prefix":24,"mtu":1420,"server_ip":"...","ip6":"...","prefix6":112,"server_ip6":"..."}` | | `error` | Text | 握手阶段失败 | JSON | `{"type":"error","message":"..."}` | | 数据帧 | Binary | `ready` 之后,下行 IP 包 | 原始 IP 包 | (二进制) | | Ping | WebSocket Ping | 每 30 秒 | 空 | (WebSocket 协议层) | @@ -593,9 +616,11 @@ type initMessage struct { | 配置项 | 取值来源 | 说明 | |--------|----------|------| -| TUN 网卡地址 | `init.ip` / `init.prefix` | 如 `10.0.0.5/24` | +| TUN 网卡地址 (IPv4) | `init.ip` / `init.prefix` | 如 `10.0.0.5/24` | +| TUN 网卡地址 (IPv6) | `init.ip6` / `init.prefix6` | 如 `fd00:dead:beef::5/112`(可选,仅当 init 含 ip6 时) | | MTU | `init.mtu` | 如 `1420` | -| 对端地址 / 默认路由网关 | `init.server_ip` | 如 `10.0.0.1` | +| 对端地址 / 默认路由网关 (IPv4) | `init.server_ip` | 如 `10.0.0.1` | +| 对端地址 (IPv6) | `init.server_ip6` | 如 `fd00:dead:beef::1`(可选) | ### 11.2 Linux @@ -608,10 +633,17 @@ ip addr add dev / peer ip link set dev mtu ``` +IPv6(仅当 init 含 `ip6` 时): + +``` +ip addr add dev / +``` + 路由:若需将所有流量经 VPN,添加默认路由: ``` ip route add 0.0.0.0/0 dev +ip route add ::/0 dev # IPv6 默认路由(可选) ``` ### 11.3 macOS @@ -623,10 +655,17 @@ ifconfig inet / up ifconfig mtu ``` +IPv6(仅当 init 含 `ip6` 时): + +``` +ifconfig inet6 / up +``` + 路由: ``` route add -inet -net 0.0.0.0/0 -interface +route add -inet6 -net ::/0 -interface # IPv6 默认路由(可选) ``` > macOS 的 utun 接口由系统分配编号(如 `utun4`),客户端通常无法指定名称。 @@ -670,6 +709,12 @@ Linux 服务端须开启 IP 转发(`internal/vpn/diag_linux.go:53-60`): sysctl -w net.ipv4.ip_forward=1 ``` +IPv6 双栈场景还须开启 IPv6 转发(`internal/vpn/diag_linux.go:116-122`): + +``` +sysctl -w net.ipv6.conf.all.forwarding=1 +``` + ### 12.2 NAT 伪装 服务端须配置 NAT masquerade,将客户端源 IP 转换为服务器物理网卡 IP(`internal/vpn/diag_linux.go:80-113`)。 @@ -688,7 +733,19 @@ nft add rule ip nat postrouting oifname <物理网卡> masquerade iptables -t nat -A POSTROUTING -o <物理网卡> -j MASQUERADE ``` -> 服务端诊断接口 `GET /api/admin/vpn/diag`(`internal/handler/vpn.go:190-192`)会检测上述配置,客户端无法上网时可请管理员查看诊断结果。 +IPv6 NAT66(仅双栈场景需要): + +**nft**: +``` +nft add rule inet lmvpn_nat postrouting oifname <物理网卡> ip6 saddr masquerade +``` + +**ip6tables(回退)**: +``` +ip6tables -t nat -A POSTROUTING -s -o <物理网卡> -j MASQUERADE +``` + +> 服务端诊断接口 `GET /api/admin/vpn/diag`(`internal/handler/vpn.go:190-192`)会检测上述配置(含 IPv6),客户端无法上网时可请管理员查看诊断结果。 ### 12.3 子网与 MTU diff --git a/internal/db/db.go b/internal/db/db.go index 4562c1e..cad5532 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -45,7 +45,10 @@ func (s *Store) migrate() error { if err != nil { return err } - return s.migrateV2() + if err := s.migrateV2(); err != nil { + return err + } + return s.migrateV3() } func (s *Store) migrateV2() error { @@ -165,6 +168,41 @@ func nullStr(s sql.NullString) sql.NullString { return s } +// migrateV3 adds the assigned_ip6 column to connection_logs for IPv6 +// dual-stack support. Idempotent: skips if the column already exists. +func (s *Store) migrateV3() error { + if columnExists(s.db, "connection_logs", "assigned_ip6") { + return nil + } + _, err := s.db.Exec(`ALTER TABLE connection_logs ADD COLUMN assigned_ip6 TEXT NOT NULL DEFAULT ''`) + if err != nil { + return fmt.Errorf("migrate v3 add assigned_ip6: %w", err) + } + return nil +} + +// 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)) + if err != nil { + return false + } + defer rows.Close() + for rows.Next() { + var cid int + var name, ctype string + var notnull, pk int + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + return false + } + if name == column { + return true + } + } + return false +} + const schemaV2 = ` CREATE TABLE IF NOT EXISTS server_profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -185,15 +223,16 @@ CREATE TABLE IF NOT EXISTS server_profiles ( ); CREATE TABLE IF NOT EXISTS connection_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - profile_id INTEGER NOT NULL, - started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - ended_at DATETIME, - assigned_ip TEXT NOT NULL DEFAULT '', - rx_bytes INTEGER NOT NULL DEFAULT 0, - tx_bytes INTEGER NOT NULL DEFAULT 0, - status TEXT NOT NULL DEFAULT 'connected', - error_msg TEXT NOT NULL DEFAULT '', + id INTEGER PRIMARY KEY AUTOINCREMENT, + profile_id INTEGER NOT NULL, + started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + ended_at DATETIME, + assigned_ip TEXT NOT NULL DEFAULT '', + assigned_ip6 TEXT NOT NULL DEFAULT '', + rx_bytes INTEGER NOT NULL DEFAULT 0, + tx_bytes INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'connected', + error_msg TEXT NOT NULL DEFAULT '', FOREIGN KEY (profile_id) REFERENCES server_profiles(id) ON DELETE CASCADE ); diff --git a/internal/db/log.go b/internal/db/log.go index b87be99..761e4c6 100644 --- a/internal/db/log.go +++ b/internal/db/log.go @@ -21,13 +21,13 @@ func (s *Store) StartLog(profileID int64) (int64, error) { } // FinishLog finalises a connection log entry with final stats. -func (s *Store) FinishLog(id int64, status model.ConnectionStatus, assignedIP string, rxBytes, txBytes int64, errMsg string) error { +func (s *Store) FinishLog(id int64, status model.ConnectionStatus, assignedIP, assignedIP6 string, rxBytes, txBytes int64, errMsg string) error { _, err := s.db.Exec( `UPDATE connection_logs SET - ended_at = ?, assigned_ip = ?, rx_bytes = ?, tx_bytes = ?, + ended_at = ?, assigned_ip = ?, assigned_ip6 = ?, rx_bytes = ?, tx_bytes = ?, status = ?, error_msg = ? WHERE id = ?`, - time.Now(), assignedIP, rxBytes, txBytes, status, errMsg, id) + time.Now(), assignedIP, assignedIP6, rxBytes, txBytes, status, errMsg, id) if err != nil { return fmt.Errorf("finish log %d: %w", id, err) } @@ -37,7 +37,7 @@ func (s *Store) FinishLog(id int64, status model.ConnectionStatus, assignedIP st // RecentLogs returns the most recent N connection logs for a profile. func (s *Store) RecentLogs(profileID int64, limit int) ([]model.ConnectionLog, error) { rows, err := s.db.Query( - `SELECT id, profile_id, started_at, ended_at, assigned_ip, + `SELECT id, profile_id, started_at, ended_at, assigned_ip, assigned_ip6, rx_bytes, tx_bytes, status, error_msg FROM connection_logs WHERE profile_id = ? ORDER BY started_at DESC LIMIT ?`, @@ -52,7 +52,7 @@ func (s *Store) RecentLogs(profileID int64, limit int) ([]model.ConnectionLog, e var l model.ConnectionLog var ended interface{} if err := rows.Scan(&l.ID, &l.ProfileID, &l.StartedAt, &ended, - &l.AssignedIP, &l.RxBytes, &l.TxBytes, &l.Status, &l.ErrorMsg); err != nil { + &l.AssignedIP, &l.AssignedIP6, &l.RxBytes, &l.TxBytes, &l.Status, &l.ErrorMsg); err != nil { return nil, err } if t, ok := ended.(time.Time); ok { diff --git a/internal/i18n/en.toml b/internal/i18n/en.toml index 9ad2271..9ae059b 100644 --- a/internal/i18n/en.toml +++ b/internal/i18n/en.toml @@ -19,6 +19,8 @@ BtnCancel = "Cancel" BtnOK = "OK" IpLabel = "IP: {{.ip}}" +Ip6Label = "IPv6: {{.ip}}" +Ip6None = "IPv6: —" IpNone = "IP: —" UptimeLabel = "Uptime: {{.uptime}}" UptimeNone = "Uptime: —" diff --git a/internal/i18n/zh-Hans.toml b/internal/i18n/zh-Hans.toml index f97bcdf..dd40b33 100644 --- a/internal/i18n/zh-Hans.toml +++ b/internal/i18n/zh-Hans.toml @@ -19,6 +19,8 @@ BtnCancel = "取消" BtnOK = "确定" IpLabel = "IP: {{.ip}}" +Ip6Label = "IPv6: {{.ip}}" +Ip6None = "IPv6: —" IpNone = "IP: —" UptimeLabel = "运行时长: {{.uptime}}" UptimeNone = "运行时长: —" diff --git a/internal/model/model.go b/internal/model/model.go index 575a7c1..6237674 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -28,21 +28,21 @@ const ( // ServerProfile is a saved VPN server configuration. type ServerProfile struct { - ID int64 `json:"id"` - Name string `json:"name"` - Protocol string `json:"protocol"` // "wss" (default) or "ws" - Host string `json:"host"` // hostname for SNI, e.g. vpn.example.com - ServerIPs string `json:"server_ips"` // comma-separated CDN IPs, first used by default - Port int `json:"port"` // default 443 - Path string `json:"path"` // default "/ws" - Username string `json:"username"` - AuthMode AuthMode `json:"auth_mode"` + ID int64 `json:"id"` + Name string `json:"name"` + Protocol string `json:"protocol"` // "wss" (default) or "ws" + Host string `json:"host"` // hostname for SNI, e.g. vpn.example.com + ServerIPs string `json:"server_ips"` // comma-separated CDN IPs, first used by default + Port int `json:"port"` // default 443 + Path string `json:"path"` // default "/ws" + Username string `json:"username"` + AuthMode AuthMode `json:"auth_mode"` RoutingMode RoutingMode `json:"routing_mode"` - CustomCIDRs string `json:"custom_cidrs"` // comma-separated, for RoutingCustom - MTUOverride int `json:"mtu_override"` // 0 = use server MTU - AutoConnect bool `json:"auto_connect"` - CreatedAt time.Time `json:"created_at"` - LastConnectedAt *time.Time `json:"last_connected_at"` + CustomCIDRs string `json:"custom_cidrs"` // comma-separated, for RoutingCustom + MTUOverride int `json:"mtu_override"` // 0 = use server MTU + AutoConnect bool `json:"auto_connect"` + CreatedAt time.Time `json:"created_at"` + LastConnectedAt *time.Time `json:"last_connected_at"` } // BuildServerURL constructs the WebSocket URL from the profile fields. @@ -108,13 +108,14 @@ const ( // ConnectionLog records a single VPN session. type ConnectionLog struct { - ID int64 `json:"id"` - ProfileID int64 `json:"profile_id"` - StartedAt time.Time `json:"started_at"` - EndedAt *time.Time `json:"ended_at"` - AssignedIP string `json:"assigned_ip"` - RxBytes int64 `json:"rx_bytes"` - TxBytes int64 `json:"tx_bytes"` - Status ConnectionStatus `json:"status"` - ErrorMsg string `json:"error_msg"` + ID int64 `json:"id"` + ProfileID int64 `json:"profile_id"` + StartedAt time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at"` + AssignedIP string `json:"assigned_ip"` + AssignedIP6 string `json:"assigned_ip6"` + RxBytes int64 `json:"rx_bytes"` + TxBytes int64 `json:"tx_bytes"` + Status ConnectionStatus `json:"status"` + ErrorMsg string `json:"error_msg"` } diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go index 1adeb36..30343a3 100644 --- a/internal/protocol/protocol.go +++ b/internal/protocol/protocol.go @@ -21,23 +21,26 @@ const ( // Timeout and limit constants matching the server (tunnel.go:16-23). const ( - ReadTimeout = 60 * time.Second // post-ready read deadline - WriteTimeout = 10 * time.Second // per-write deadline - ReadyTimeout = 30 * time.Second // client must send ready within this - PingPeriod = 30 * time.Second // server ping interval - MaxMessageSize = 1 << 20 // 1 MiB max WebSocket message - MaxConnsPerUser = 3 // per-user concurrent connection cap - TokenExpiry = 24 * time.Hour // JWT validity + ReadTimeout = 60 * time.Second // post-ready read deadline + WriteTimeout = 10 * time.Second // per-write deadline + ReadyTimeout = 30 * time.Second // client must send ready within this + PingPeriod = 30 * time.Second // server ping interval + MaxMessageSize = 1 << 20 // 1 MiB max WebSocket message + MaxConnsPerUser = 3 // per-user concurrent connection cap + TokenExpiry = 24 * time.Hour // JWT validity ) // InitMessage is sent by the server after auth + pre-checks pass. -// (server: protocol.go:3-9, tunnel.go:126-137) +// (server: protocol.go:3-10, tunnel.go:134-145) type InitMessage struct { - Type string `json:"type"` - IP string `json:"ip"` // assigned client IP (dotted-quad) - Prefix int `json:"prefix"` // subnet prefix length (e.g. 24) - MTU int `json:"mtu"` // TUN device MTU (e.g. 1420) - ServerIP string `json:"server_ip"` // server's tunnel IP (peer/gateway) + Type string `json:"type"` + IP string `json:"ip"` // assigned client IPv4 (dotted-quad) + Prefix int `json:"prefix"` // IPv4 subnet prefix length (e.g. 24) + MTU int `json:"mtu"` // TUN device MTU (e.g. 1420) + ServerIP string `json:"server_ip"` // server's tunnel IPv4 (peer/gateway) + IP6 string `json:"ip6,omitempty"` // assigned client IPv6 (only when server has Subnet6) + Prefix6 int `json:"prefix6,omitempty"` // IPv6 subnet prefix length + ServerIP6 string `json:"server_ip6,omitempty"` // server's tunnel IPv6 } // ControlMessage is the generic text control message. diff --git a/internal/route/route.go b/internal/route/route.go index b44dcf3..60c6e27 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -1,13 +1,16 @@ // Package route manages VPN routing on the client. It supports three // modes: // -// - Full tunnel: all traffic (0.0.0.0/0) via the TUN interface, -// with a bypass route for the server's public IP so -// the WebSocket connection stays on the physical NIC -// - Split tunnel: only the VPN virtual subnet via the TUN interface +// - 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 +// - Split tunnel: only the VPN virtual subnet (v4 and v6) via TUN // - Custom: user-specified CIDRs via the TUN interface // -// All routes are tracked so they can be cleanly removed on disconnect. +// 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 ( @@ -29,8 +32,10 @@ const ( type Config struct { Mode Mode InterfaceName string // e.g. "utun4" - VPNIP string // assigned tunnel IP, e.g. "192.168.77.5" - VPNPrefix int // subnet prefix, e.g. 24 + 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) CustomCIDRs []string // for ModeCustom } @@ -38,10 +43,15 @@ type Config struct { // Manager applies and removes routes. It tracks all added routes so // they can be cleaned up deterministically. type Manager struct { - cfg Config - addedRoutes []string // route specs added, for deletion - serverBypass bool - originalGateway string + cfg Config + addedRoutes []string // v4 route specs added, for deletion + addedRoutes6 []string // v6 route specs added, for deletion + 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. @@ -72,12 +82,24 @@ func (m *Manager) Cleanup() error { } } m.addedRoutes = nil + for _, r := range m.addedRoutes6 { + if err := deleteRoute6(r, m.cfg.InterfaceName); err != nil { + errs = append(errs, err.Error()) + } + } + m.addedRoutes6 = nil if m.serverBypass { if err := m.deleteServerBypass(); err != nil { errs = append(errs, err.Error()) } m.serverBypass = false } + if m.serverBypass6 { + if err := m.deleteServerBypass6(); err != nil { + errs = append(errs, err.Error()) + } + m.serverBypass6 = false + } if len(errs) > 0 { return fmt.Errorf("cleanup errors: %s", strings.Join(errs, "; ")) } @@ -85,26 +107,44 @@ func (m *Manager) Cleanup() error { } func (m *Manager) applyFull() error { - // Capture the current default gateway before modifying routes. + // 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 - // Resolve server host to an IP for the bypass route. - serverIP, err := resolveHost(m.cfg.ServerHost) + // IPv6 default gateway is best-effort: it may be absent on v4-only + // networks, in which case v6 bypass/routing is skipped. + 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 IP via the original gateway (so the WS + // Bypass: server's public IPv4 via the original gateway (so the WS // connection doesn't loop through the tunnel). - bypassSpec := serverIP + "/32" - if err := addRouteVia(bypassSpec, gw); err != nil { - return fmt.Errorf("add server bypass route: %w", err) + 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 { + return fmt.Errorf("add server bypass6 route: %w", err) + } + m.serverBypass6 = true } - m.serverBypass = true // 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 @@ -115,15 +155,35 @@ func (m *Manager) applyFull() error { } m.addedRoutes = append(m.addedRoutes, cidr) } + + // IPv6 full tunnel: ::/1 + 8000::/1 cover the entire IPv6 space, + // more specific than ::/0. Only applied when the server assigned + // an IPv6 address. + 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) applySplit() error { - subnet := vpnSubnet(m.cfg.VPNIP, m.cfg.VPNPrefix) + 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 } @@ -133,46 +193,86 @@ func (m *Manager) applyCustom() error { if cidr == "" { continue } - if err := addRoute(cidr, m.cfg.InterfaceName); err != nil { - return fmt.Errorf("add custom route %s: %w", cidr, err) + if isIPv6CIDR(cidr) { + if err := addRoute6(cidr, m.cfg.InterfaceName); err != nil { + return fmt.Errorf("add custom 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) + } + m.addedRoutes = append(m.addedRoutes, cidr) } - m.addedRoutes = append(m.addedRoutes, cidr) } return nil } func (m *Manager) deleteServerBypass() error { - serverIP, err := resolveHost(m.cfg.ServerHost) - if err != nil { - return nil // best-effort + if m.serverIP == "" { + return nil } - return deleteRouteVia(serverIP+"/32", m.originalGateway) + return deleteRouteVia(m.serverIP+"/32", m.originalGateway) +} + +func (m *Manager) deleteServerBypass6() error { + if m.serverIP6 == "" || m.originalGateway6 == "" { + return nil + } + return deleteRouteVia6(m.serverIP6+"/128", m.originalGateway6) } // vpnSubnet computes the network CIDR from an IP and prefix. -func vpnSubnet(ipStr string, prefix int) string { +func vpnSubnet(ipStr string, prefix int, ipv6 bool) string { ip := net.ParseIP(ipStr) if ip == nil { return ipStr + "/" + fmt.Sprint(prefix) } - mask := net.CIDRMask(prefix, 32) + bits := 32 + if ipv6 { + bits = 128 + } + mask := net.CIDRMask(prefix, bits) network := ip.Mask(mask) return fmt.Sprintf("%s/%d", network.String(), prefix) } -// resolveHost resolves a hostname to an IP address. If already an IP, -// returns it directly. -func resolveHost(host string) (string, error) { - if net.ParseIP(host) != nil { - return host, nil +// resolveHosts resolves a hostname to its first IPv4 and IPv6 addresses. +// If host is already an IP literal, it is returned directly. Either +// result may be empty if no address of that family is available. +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 } // Strip port if present. - if h, _, err := net.SplitHostPort(host); err == nil { + if h, _, e := net.SplitHostPort(host); e == nil { host = h } ips, err := net.LookupIP(host) if err != nil || len(ips) == 0 { - return "", fmt.Errorf("lookup %s: %w", host, err) + return "", "", fmt.Errorf("lookup %s: %w", host, err) } - return ips[0].String(), nil + for _, ip := range ips { + if v4 == "" && ip.To4() != nil { + v4 = ip.String() + } else if v6 == "" && ip.To4() == nil { + v6 = 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 } diff --git a/internal/route/route_darwin.go b/internal/route/route_darwin.go index eb29e93..cdc8a3b 100644 --- a/internal/route/route_darwin.go +++ b/internal/route/route_darwin.go @@ -9,7 +9,8 @@ import ( ) // addRoute adds a route via a network interface (macOS route command). -// route add -inet -net -interface +// +// route add -inet -net -interface func addRoute(cidr, iface string) error { return runRoute("add", "-inet", "-net", cidr, "-interface", iface) } @@ -29,13 +30,44 @@ func deleteRouteVia(cidr, gateway string) error { return runRoute("delete", "-inet", "-net", cidr, gateway) } -// defaultGateway returns the current default gateway IP. +// --- IPv6 variants --- + +func addRoute6(cidr, iface string) error { + return runRoute("add", "-inet6", "-net", cidr, "-interface", iface) +} + +func deleteRoute6(cidr, iface string) error { + return runRoute("delete", "-inet6", "-net", cidr, "-interface", iface) +} + +func addRouteVia6(cidr, gateway string) error { + return runRoute("add", "-inet6", "-net", cidr, gateway) +} + +func deleteRouteVia6(cidr, gateway string) error { + return runRoute("delete", "-inet6", "-net", cidr, gateway) +} + +// defaultGateway returns the current IPv4 default gateway IP. func defaultGateway() (string, error) { out, err := exec.Command("route", "-n", "get", "default").Output() if err != nil { return "", err } - for _, line := range strings.Split(string(out), "\n") { + return parseGateway(string(out)) +} + +// defaultGateway6 returns the current IPv6 default gateway IP. +func defaultGateway6() (string, error) { + out, err := exec.Command("route", "-n", "get", "-inet6", "default").Output() + if err != nil { + return "", err + } + return parseGateway(string(out)) +} + +func parseGateway(out string) (string, error) { + for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "gateway:") { parts := strings.SplitN(line, ":", 2) diff --git a/internal/route/route_linux.go b/internal/route/route_linux.go index 713a062..d408438 100644 --- a/internal/route/route_linux.go +++ b/internal/route/route_linux.go @@ -24,12 +24,42 @@ func deleteRouteVia(cidr, gateway string) error { return runCmd("ip", "route", "del", cidr, "via", gateway) } +// --- IPv6 variants --- + +func addRoute6(cidr, iface string) error { + return runCmd("ip", "-6", "route", "add", cidr, "dev", iface) +} + +func deleteRoute6(cidr, iface string) error { + return runCmd("ip", "-6", "route", "del", cidr, "dev", iface) +} + +func addRouteVia6(cidr, gateway string) error { + return runCmd("ip", "-6", "route", "add", cidr, "via", gateway) +} + +func deleteRouteVia6(cidr, gateway string) error { + return runCmd("ip", "-6", "route", "del", cidr, "via", gateway) +} + func defaultGateway() (string, error) { out, err := exec.Command("ip", "route", "show", "default").Output() if err != nil { return "", err } - fields := strings.Fields(strings.TrimSpace(string(out))) + return parseViaGateway(string(out)) +} + +func defaultGateway6() (string, error) { + out, err := exec.Command("ip", "-6", "route", "show", "default").Output() + if err != nil { + return "", err + } + return parseViaGateway(string(out)) +} + +func parseViaGateway(out string) (string, error) { + fields := strings.Fields(strings.TrimSpace(out)) for i, f := range fields { if f == "via" && i+1 < len(fields) { return fields[i+1], nil diff --git a/internal/stats/stats.go b/internal/stats/stats.go index fe43bf4..e07676e 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -10,11 +10,11 @@ import ( type State string const ( - StateDisconnected State = "disconnected" - StateConnecting State = "connecting" - StateConnected State = "connected" - StateReconnecting State = "reconnecting" - StateError State = "error" + StateDisconnected State = "disconnected" + StateConnecting State = "connecting" + StateConnected State = "connected" + StateReconnecting State = "reconnecting" + StateError State = "error" ) // Stats holds live session statistics. Counters are atomic for @@ -23,8 +23,9 @@ type Stats struct { RxBytes atomic.Int64 TxBytes atomic.Int64 ConnectedAt atomic.Int64 // unix timestamp, 0 = not connected - state atomic.Value // State - assignedIP atomic.Value // string + state atomic.Value // State + assignedIP atomic.Value // string (IPv4) + assignedIP6 atomic.Value // string (IPv6, may be empty) } // New creates a Stats instance initialised to the disconnected state. @@ -32,6 +33,7 @@ func New() *Stats { s := &Stats{} s.state.Store(StateDisconnected) s.assignedIP.Store("") + s.assignedIP6.Store("") return s } @@ -41,10 +43,12 @@ func (s *Stats) SetState(st State) { s.state.Store(st) } // State returns the current state. func (s *Stats) State() State { return s.state.Load().(State) } -// SetConnected marks the session as connected, recording the time and IP. -func (s *Stats) SetConnected(ip string) { +// SetConnected marks the session as connected, recording the time and +// assigned IP addresses. ip6 may be empty for an IPv4-only server. +func (s *Stats) SetConnected(ip, ip6 string) { s.ConnectedAt.Store(time.Now().Unix()) s.assignedIP.Store(ip) + s.assignedIP6.Store(ip6) s.state.Store(StateConnected) } @@ -52,18 +56,23 @@ func (s *Stats) SetConnected(ip string) { func (s *Stats) SetDisconnected() { s.ConnectedAt.Store(0) s.assignedIP.Store("") + s.assignedIP6.Store("") s.state.Store(StateDisconnected) } -// AssignedIP returns the server-assigned tunnel IP. +// AssignedIP returns the server-assigned tunnel IPv4. func (s *Stats) AssignedIP() string { return s.assignedIP.Load().(string) } +// AssignedIP6 returns the server-assigned tunnel IPv6 (may be empty). +func (s *Stats) AssignedIP6() string { return s.assignedIP6.Load().(string) } + // Snapshot returns a point-in-time copy of all counters. type Snapshot struct { RxBytes int64 TxBytes int64 ConnectedAt time.Time AssignedIP string + AssignedIP6 string State State Uptime time.Duration } @@ -71,10 +80,11 @@ type Snapshot struct { // Snapshot returns a point-in-time copy of the statistics. func (s *Stats) Snapshot() Snapshot { snap := Snapshot{ - RxBytes: s.RxBytes.Load(), - TxBytes: s.TxBytes.Load(), - AssignedIP: s.AssignedIP(), - State: s.State(), + RxBytes: s.RxBytes.Load(), + TxBytes: s.TxBytes.Load(), + AssignedIP: s.AssignedIP(), + AssignedIP6: s.AssignedIP6(), + State: s.State(), } ts := s.ConnectedAt.Load() if ts > 0 { diff --git a/internal/transport/transport.go b/internal/transport/transport.go index e3d679b..dceb5b3 100644 --- a/internal/transport/transport.go +++ b/internal/transport/transport.go @@ -30,18 +30,18 @@ import ( // HandshakeConfig configures a single connection attempt. type HandshakeConfig struct { - ServerURL string // e.g. wss://vpn.example.com/ws - SNIHost string // TLS SNI hostname for CDN edge connections - Token string // JWT; if non-empty, used via ?token= (method A) - Username string // for password auth (method B), or fallback - Password string // for password auth (method B), or fallback + ServerURL string // e.g. wss://vpn.example.com/ws + SNIHost string // TLS SNI hostname for CDN edge connections + Token string // JWT; if non-empty, used via ?token= (method A) + Username string // for password auth (method B), or fallback + Password string // for password auth (method B), or fallback OnInit func(protocol.InitMessage) error // configure TUN; nil = auto-ready } // Conn is an established VPN tunnel connection. type Conn struct { - ws *websocket.Conn - init protocol.InitMessage + ws *websocket.Conn + init protocol.InitMessage writeMu sync.Mutex closed bool mu sync.Mutex @@ -235,9 +235,12 @@ func (c *Conn) WritePacket(data []byte) error { // Init returns the init message received during handshake. func (c *Conn) Init() protocol.InitMessage { return c.init } -// AssignedIP returns the IP assigned by the server. +// AssignedIP returns the IPv4 assigned by the server. func (c *Conn) AssignedIP() string { return c.init.IP } +// AssignedIP6 returns the IPv6 assigned by the server (empty if none). +func (c *Conn) AssignedIP6() string { return c.init.IP6 } + // Close terminates the connection. func (c *Conn) Close() error { c.mu.Lock() diff --git a/internal/tun/tun.go b/internal/tun/tun.go index 61f0c64..9dd2e1b 100644 --- a/internal/tun/tun.go +++ b/internal/tun/tun.go @@ -18,6 +18,10 @@ type Device interface { Write(p []byte) (int, error) // Configure sets the interface address, prefix, and peer IP. Configure(localIP net.IP, prefix int, peerIP net.IP) error + // ConfigureIPv6 sets a secondary IPv6 address and prefix on the + // interface. Unlike Configure, there is no peer IP because macOS + // utun IPv6 does not use the point-to-point aliasing form. + ConfigureIPv6(localIP6 net.IP, prefix6 int) error // SetMTU sets the interface MTU. SetMTU(mtu int) error // Close destroys the TUN device. diff --git a/internal/tun/tun_darwin.go b/internal/tun/tun_darwin.go index 942ef50..5adce10 100644 --- a/internal/tun/tun_darwin.go +++ b/internal/tun/tun_darwin.go @@ -26,10 +26,10 @@ func createTUN(name string) (Device, error) { return &darwinDevice{ifce: ifce}, nil } -func (d *darwinDevice) Name() string { return d.ifce.Name() } -func (d *darwinDevice) Read(p []byte) (int, error) { return d.ifce.Read(p) } -func (d *darwinDevice) Write(p []byte) (int, error) { return d.ifce.Write(p) } -func (d *darwinDevice) Close() error { return d.ifce.Close() } +func (d *darwinDevice) Name() string { return d.ifce.Name() } +func (d *darwinDevice) Read(p []byte) (int, error) { return d.ifce.Read(p) } +func (d *darwinDevice) Write(p []byte) (int, error) { return d.ifce.Write(p) } +func (d *darwinDevice) Close() error { return d.ifce.Close() } func (d *darwinDevice) Configure(localIP net.IP, prefix int, peerIP net.IP) error { if localIP == nil { @@ -44,6 +44,16 @@ func (d *darwinDevice) Configure(localIP net.IP, prefix int, peerIP net.IP) erro return execCmd("ifconfig", d.Name(), inetType, localCidr, peerIP.String(), "up") } +func (d *darwinDevice) ConfigureIPv6(localIP6 net.IP, prefix6 int) error { + if localIP6 == nil { + return nil + } + // macOS utun IPv6 uses the plain address form (no peer aliasing): + // ifconfig utunN inet6 / up + localCidr := fmt.Sprintf("%s/%d", localIP6.String(), prefix6) + return execCmd("ifconfig", d.Name(), "inet6", localCidr, "up") +} + func (d *darwinDevice) SetMTU(mtu int) error { return execCmd("ifconfig", d.Name(), "mtu", fmt.Sprintf("%d", mtu)) } diff --git a/internal/tun/tun_linux.go b/internal/tun/tun_linux.go index a11f4fc..a392736 100644 --- a/internal/tun/tun_linux.go +++ b/internal/tun/tun_linux.go @@ -46,6 +46,14 @@ func (d *linuxDevice) Configure(localIP net.IP, prefix int, peerIP net.IP) error return nil } +func (d *linuxDevice) ConfigureIPv6(localIP6 net.IP, prefix6 int) error { + if localIP6 == nil { + return nil + } + localCidr := fmt.Sprintf("%s/%d", localIP6.String(), prefix6) + return execCmd("ip", "-6", "addr", "add", "dev", d.Name(), localCidr) +} + func (d *linuxDevice) SetMTU(mtu int) error { return execCmd("ip", "link", "set", "dev", d.Name(), "mtu", fmt.Sprintf("%d", mtu)) } diff --git a/internal/ui/app.go b/internal/ui/app.go index 62937b3..9eb461c 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -31,6 +31,7 @@ type App struct { profileSelect *widget.Select stateLabel *widget.Label ipLabel *widget.Label + ip6Label *widget.Label uptimeLabel *widget.Label rxLabel *widget.Label txLabel *widget.Label @@ -232,6 +233,7 @@ func (a *App) onResetDB() { a.loadProfiles() a.stateLabel.SetText(i18n.T("StateDisconnected")) a.ipLabel.SetText(i18n.T("IpNone")) + a.ip6Label.SetText(i18n.T("Ip6None")) a.uptimeLabel.SetText(i18n.T("UptimeNone")) a.rxLabel.SetText(i18n.T("RxZero")) a.txLabel.SetText(i18n.T("TxZero")) diff --git a/internal/ui/view.go b/internal/ui/view.go index e5c6f8d..b940cb4 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -30,6 +30,7 @@ func (a *App) buildMainWindow() fyne.CanvasObject { a.stateLabel = widget.NewLabel(i18n.T("StateDisconnected")) a.stateLabel.TextStyle = fyne.TextStyle{Bold: true} a.ipLabel = widget.NewLabel(i18n.T("IpNone")) + a.ip6Label = widget.NewLabel(i18n.T("Ip6None")) a.uptimeLabel = widget.NewLabel(i18n.T("UptimeNone")) a.rxLabel = widget.NewLabel(i18n.T("RxZero")) a.txLabel = widget.NewLabel(i18n.T("TxZero")) @@ -37,6 +38,7 @@ func (a *App) buildMainWindow() fyne.CanvasObject { statusCard := widget.NewCard(i18n.T("StatusLabel"), "", container.NewVBox( a.stateLabel, a.ipLabel, + a.ip6Label, a.uptimeLabel, container.NewHBox(a.rxLabel, a.txLabel), )) @@ -181,6 +183,7 @@ func (a *App) eventLoop() { if current == client { a.stateLabel.SetText(i18n.T("StateDisconnected")) a.ipLabel.SetText(i18n.T("IpNone")) + a.ip6Label.SetText(i18n.T("Ip6None")) a.uptimeLabel.SetText(i18n.T("UptimeNone")) a.rxLabel.SetText(i18n.T("RxZero")) a.txLabel.SetText(i18n.T("TxZero")) @@ -230,6 +233,7 @@ func (a *App) applyState(state string) { case stats.StateDisconnected: a.stateLabel.SetText(i18n.T("StateDisconnected")) a.ipLabel.SetText(i18n.T("IpNone")) + a.ip6Label.SetText(i18n.T("Ip6None")) a.connectBtn.Enable() a.disconnectBtn.Disable() case stats.StateError: @@ -244,6 +248,11 @@ func (a *App) applyStats(s stats.Snapshot) { if s.AssignedIP != "" { a.ipLabel.SetText(i18n.T("IpLabel", map[string]interface{}{"ip": s.AssignedIP})) } + if s.AssignedIP6 != "" { + a.ip6Label.SetText(i18n.T("Ip6Label", map[string]interface{}{"ip": s.AssignedIP6})) + } else { + a.ip6Label.SetText(i18n.T("Ip6None")) + } if s.State == stats.StateConnected { a.stateLabel.SetText(i18n.T("StateConnected")) } diff --git a/internal/vpn/session.go b/internal/vpn/session.go index 5bea185..3481848 100644 --- a/internal/vpn/session.go +++ b/internal/vpn/session.go @@ -238,10 +238,11 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta sm.conn = conn sm.mu.Unlock() - sm.stats.SetConnected(conn.Init().IP) + sm.stats.SetConnected(conn.Init().IP, conn.Init().IP6) sm.setState(stats.StateConnected) log.L().Info("VPN connected", "ip", conn.Init().IP, "server_ip", conn.Init().ServerIP, + "ip6", conn.Init().IP6, "server_ip6", conn.Init().ServerIP6, "mtu", conn.Init().MTU) // Start stats reporter. @@ -280,6 +281,20 @@ func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig) return fmt.Errorf("configure tun: %w", err) } + // Configure IPv6 address when the server assigned one (dual-stack). + hasV6 := init.IP6 != "" + 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) + } + } + mtu := init.MTU if cfg.MTUOverride > 0 { mtu = cfg.MTUOverride @@ -295,6 +310,8 @@ func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig) InterfaceName: dev.Name(), VPNIP: init.IP, VPNPrefix: init.Prefix, + VPNIP6: init.IP6, + VPNPrefix6: init.Prefix6, ServerHost: serverHostFromURL(cfg.ServerURL), CustomCIDRs: cfg.CustomCIDRs, } @@ -304,7 +321,8 @@ func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig) } log.L().Info("TUN configured", - "dev", dev.Name(), "ip", init.IP, "prefix", init.Prefix, "mtu", mtu) + "dev", dev.Name(), "ip", init.IP, "prefix", init.Prefix, + "ip6", init.IP6, "prefix6", init.Prefix6, "mtu", mtu) return nil }