feat: 新增 IPv6 双栈支持
- 协议: InitMessage 增加 ip6/prefix6/server_ip6 字段(omitempty,向后兼容) - TUN: Device 接口新增 ConfigureIPv6, macOS 用 ifconfig inet6, Linux 用 ip -6 addr add - 路由: full 模式自动添加 ::/1+8000::/1, v6 服务器旁路 /128, resolveHosts 双栈解析 - 会话: setupTUN 解析 v6 字段并配置 TUN+路由, connectOnce 传 v6 给 stats/日志 - 统计: SetConnected(ip,ip6), Snapshot.AssignedIP6 - DB: connection_logs 增加 assigned_ip6 列 + migrateV3 幂等迁移 - UI: 状态卡片新增独立 IPv6 行, 始终可见(未获取显示 IPv6: —) - 文档: 同步 client-development.md 至服务端 IPv6 版本
This commit is contained in:
+80
-23
@@ -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,7 +536,7 @@ type controlMessage struct {
|
||||
}
|
||||
```
|
||||
|
||||
`init` 消息结构较为特殊(`internal/vpn/protocol.go:3-9`):
|
||||
`init` 消息结构较为特殊(`internal/vpn/protocol.go:3-10`):
|
||||
|
||||
```go
|
||||
type initMessage struct {
|
||||
@@ -525,6 +545,9 @@ type initMessage struct {
|
||||
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 <tun> <ip>/<prefix> peer <server_ip>
|
||||
ip link set dev <tun> mtu <mtu>
|
||||
```
|
||||
|
||||
IPv6(仅当 init 含 `ip6` 时):
|
||||
|
||||
```
|
||||
ip addr add dev <tun> <ip6>/<prefix6>
|
||||
```
|
||||
|
||||
路由:若需将所有流量经 VPN,添加默认路由:
|
||||
|
||||
```
|
||||
ip route add 0.0.0.0/0 dev <tun>
|
||||
ip route add ::/0 dev <tun> # IPv6 默认路由(可选)
|
||||
```
|
||||
|
||||
### 11.3 macOS
|
||||
@@ -623,10 +655,17 @@ ifconfig <utun> inet <ip>/<prefix> <server_ip> up
|
||||
ifconfig <utun> mtu <mtu>
|
||||
```
|
||||
|
||||
IPv6(仅当 init 含 `ip6` 时):
|
||||
|
||||
```
|
||||
ifconfig <utun> inet6 <ip6>/<prefix6> up
|
||||
```
|
||||
|
||||
路由:
|
||||
|
||||
```
|
||||
route add -inet -net 0.0.0.0/0 -interface <utun>
|
||||
route add -inet6 -net ::/0 -interface <utun> # 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 <VPN_V6_SUBNET> masquerade
|
||||
```
|
||||
|
||||
**ip6tables(回退)**:
|
||||
```
|
||||
ip6tables -t nat -A POSTROUTING -s <VPN_V6_SUBNET> -o <物理网卡> -j MASQUERADE
|
||||
```
|
||||
|
||||
> 服务端诊断接口 `GET /api/admin/vpn/diag`(`internal/handler/vpn.go:190-192`)会检测上述配置(含 IPv6),客户端无法上网时可请管理员查看诊断结果。
|
||||
|
||||
### 12.3 子网与 MTU
|
||||
|
||||
|
||||
+40
-1
@@ -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,
|
||||
@@ -190,6 +228,7 @@ CREATE TABLE IF NOT EXISTS connection_logs (
|
||||
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',
|
||||
|
||||
+5
-5
@@ -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 {
|
||||
|
||||
@@ -19,6 +19,8 @@ BtnCancel = "Cancel"
|
||||
BtnOK = "OK"
|
||||
|
||||
IpLabel = "IP: {{.ip}}"
|
||||
Ip6Label = "IPv6: {{.ip}}"
|
||||
Ip6None = "IPv6: —"
|
||||
IpNone = "IP: —"
|
||||
UptimeLabel = "Uptime: {{.uptime}}"
|
||||
UptimeNone = "Uptime: —"
|
||||
|
||||
@@ -19,6 +19,8 @@ BtnCancel = "取消"
|
||||
BtnOK = "确定"
|
||||
|
||||
IpLabel = "IP: {{.ip}}"
|
||||
Ip6Label = "IPv6: {{.ip}}"
|
||||
Ip6None = "IPv6: —"
|
||||
IpNone = "IP: —"
|
||||
UptimeLabel = "运行时长: {{.uptime}}"
|
||||
UptimeNone = "运行时长: —"
|
||||
|
||||
@@ -113,6 +113,7 @@ type ConnectionLog struct {
|
||||
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"`
|
||||
|
||||
@@ -31,13 +31,16 @@ const (
|
||||
)
|
||||
|
||||
// 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)
|
||||
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 IP (peer/gateway)
|
||||
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.
|
||||
|
||||
+128
-28
@@ -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
|
||||
}
|
||||
@@ -39,9 +44,14 @@ type Config struct {
|
||||
// they can be cleaned up deterministically.
|
||||
type Manager struct {
|
||||
cfg Config
|
||||
addedRoutes []string // route specs added, for deletion
|
||||
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 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
|
||||
}
|
||||
|
||||
// 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 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)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// addRoute adds a route via a network interface (macOS route command).
|
||||
//
|
||||
// route add -inet -net <cidr> -interface <iface>
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-4
@@ -24,7 +24,8 @@ type Stats struct {
|
||||
TxBytes atomic.Int64
|
||||
ConnectedAt atomic.Int64 // unix timestamp, 0 = not connected
|
||||
state atomic.Value // State
|
||||
assignedIP atomic.Value // string
|
||||
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
|
||||
}
|
||||
@@ -74,6 +83,7 @@ func (s *Stats) Snapshot() Snapshot {
|
||||
RxBytes: s.RxBytes.Load(),
|
||||
TxBytes: s.TxBytes.Load(),
|
||||
AssignedIP: s.AssignedIP(),
|
||||
AssignedIP6: s.AssignedIP6(),
|
||||
State: s.State(),
|
||||
}
|
||||
ts := s.ConnectedAt.Load()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <ip6>/<prefix6> 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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
+20
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user