From a770862c7c4ee82877b600cb45de2ccceb829ea3 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 7 Jul 2026 11:38:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20IPv6=20=E5=8F=8C?= =?UTF-8?q?=E6=A0=88=E5=9C=B0=E5=9D=80=E6=B4=BE=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model: VpnSetting 新增 Subnet6 字段,VpnReservation 新增 IPAddress6 - alloc: 处理 /64 等大子网 cidr.AddressCount 溢出,回退 65536 扫描上限 - protocol: initMessage 新增 ip6/prefix6/server_ip6(omitempty 向后兼容) - service: 双分配器 alloc4+alloc6,ApplySettings 配置双 IP 与双路由, Allocate 返回 v4+v6 一对地址 - switch: SwitchConn 新增 AssignedIP6(),Register/Unregister 注册双 key, 反欺骗按 IP 版本分别校验 v4/v6 源地址 - tunnel: tunnelConn 新增 assignedIP6,init 消息填充 v6 字段 - handler: 新增 validateSubnet6(/64~/126),settings/预留 API 全面支持 v6 - diag: 新增 IPv6 forwarding 与 NAT66 masquerade 检测 - install_linux.sh: 新增 VPN_SUBNET6、ipv6 forwarding、nft/ip6tables NAT66 - 前端: v6 子网输入框、v6 预留、v6 诊断卡片、客户端列表 v6 列 - 文档: init 消息、IP 分配、子网约束、TUN 配置、NAT、反欺骗全部更新 --- docs/client-development.md | 113 ++++++++++++++----- frontend/src/views/VpnView.vue | 72 ++++++++++-- install_linux.sh | 30 +++++ internal/handler/vpn.go | 194 ++++++++++++++++++++++++--------- internal/model/vpn.go | 10 +- internal/vpn/alloc.go | 6 + internal/vpn/diag.go | 26 +++-- internal/vpn/diag_linux.go | 51 +++++++++ internal/vpn/diag_other.go | 4 + internal/vpn/protocol.go | 13 ++- internal/vpn/service.go | 109 ++++++++++++++++-- internal/vpn/switch.go | 33 ++++-- internal/vpn/tunnel.go | 45 +++++--- 13 files changed, 561 insertions(+), 145 deletions(-) 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/frontend/src/views/VpnView.vue b/frontend/src/views/VpnView.vue index c15ab50..e227efd 100644 --- a/frontend/src/views/VpnView.vue +++ b/frontend/src/views/VpnView.vue @@ -8,6 +8,7 @@ const authHeader = () => ({ Authorization: `Bearer ${authStore.token}` }) interface Settings { enabled: boolean subnet: string + subnet6: string mtu: number interface_name: string allow_client_to_client: boolean @@ -17,6 +18,7 @@ interface Settings { interface ClientInfo { username: string ip: string + ip6?: string connected_at: string } interface Status { @@ -24,6 +26,8 @@ interface Status { online: number used_ips: number capacity: number + used_ips6?: number + capacity6?: number clients: ClientInfo[] } interface Reservation { @@ -31,6 +35,7 @@ interface Reservation { user_id: number username: string ip_address: string + ip_address6?: string created_at: string } interface User { @@ -46,6 +51,10 @@ interface Diag { ip_forward_note?: string masquerade: boolean | null masquerade_note?: string + ip6_forward: boolean | null + ip6_forward_note?: string + masquerade6: boolean | null + masquerade6_note?: string tun_create: string tun_running: boolean tun_name?: string @@ -64,6 +73,7 @@ const saveMsg = ref('') const form = ref({ enabled: false, subnet: '192.168.77.0/24', + subnet6: '', mtu: 1420, interface_name: '', allow_client_to_client: false, @@ -146,13 +156,13 @@ async function handleSave() { } const showAddResv = ref(false) -const resvForm = ref({ user_id: 0, ip_address: '' }) +const resvForm = ref({ user_id: 0, ip_address: '', ip_address6: '' }) const resvError = ref('') async function handleAddResv() { resvError.value = '' - if (!resvForm.value.user_id || !resvForm.value.ip_address) { - resvError.value = '请选择用户并填写 IP' + if (!resvForm.value.user_id || (!resvForm.value.ip_address && !resvForm.value.ip_address6)) { + resvError.value = '请选择用户并至少填写一个 IP 地址' return } try { @@ -164,7 +174,7 @@ async function handleAddResv() { const data = await res.json() if (!res.ok) throw new Error(data.error || '创建失败') showAddResv.value = false - resvForm.value = { user_id: 0, ip_address: '' } + resvForm.value = { user_id: 0, ip_address: '', ip_address6: '' } await fetchReservations() } catch (e: any) { resvError.value = e.message @@ -296,7 +306,7 @@ onMounted(() => {
-

NAT MASQUERADE

+

NAT MASQUERADE (IPv4)

@@ -304,6 +314,34 @@ onMounted(() => {

+ +
+ + + +
+

IPv6 转发 (forwarding)

+

+ + +

+
+
+ + +
+ + + +
+

NAT MASQUERADE (IPv6)

+

+ + +

+
+
+
@@ -333,6 +371,10 @@ onMounted(() => {
+
+ + +
@@ -375,17 +417,19 @@ onMounted(() => { 用户 - 分配 IP + IPv4 + IPv6 连接时间 - 暂无在线客户端 + 暂无在线客户端 {{ c.username }} {{ c.ip }} + {{ c.ip6 || '—' }} {{ c.connected_at }} @@ -402,18 +446,20 @@ onMounted(() => { 用户 - 预留 IP + IPv4 + IPv6 创建时间 操作 - 暂无预留 + 暂无预留 {{ r.username }} - {{ r.ip_address }} + {{ r.ip_address || '—' }} + {{ r.ip_address6 || '—' }} {{ r.created_at }} @@ -436,9 +482,13 @@ onMounted(() => {
- +
+
+ + +

{{ resvError }}

diff --git a/install_linux.sh b/install_linux.sh index f9f2f90..bc3de5e 100755 --- a/install_linux.sh +++ b/install_linux.sh @@ -6,6 +6,8 @@ set -e # 若后台修改了子网,需同步修改此处并重新执行脚本,或手动更新 iptables 规则 # ───────────────────────────────────────────────────────────── VPN_SUBNET="192.168.77.0/24" +# IPv6 子网(留空则不配置 IPv6 NAT;与后台设置保持一致) +VPN_SUBNET6="fd00:dead:beef::/112" if [ "$(id -u)" -ne 0 ]; then echo "请使用 root 用户执行此脚本: sudo bash install_linux.sh" @@ -42,10 +44,16 @@ chown -R lmvpn:lmvpn /opt/lmvpn echo ">>> 配置内核 IP 转发..." # 临时生效 sysctl -w net.ipv4.ip_forward=1 >/dev/null +if [ -n "$VPN_SUBNET6" ]; then + sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null +fi # 持久化 cat > /etc/sysctl.d/99-lmvpn.conf << EOF net.ipv4.ip_forward = 1 EOF +if [ -n "$VPN_SUBNET6" ]; then + echo "net.ipv6.conf.all.forwarding = 1" >> /etc/sysctl.d/99-lmvpn.conf +fi sysctl -p /etc/sysctl.d/99-lmvpn.conf >/dev/null echo ">>> 配置 NAT 与转发规则..." @@ -75,9 +83,16 @@ else nft add table inet lmvpn_nat nft 'add chain inet lmvpn_nat postrouting { type nat hook postrouting priority 100 ; }' nft add rule inet lmvpn_nat postrouting oifname "$WAN_IFACE" ip saddr "$VPN_SUBNET" masquerade + if [ -n "$VPN_SUBNET6" ]; then + nft add rule inet lmvpn_nat postrouting oifname "$WAN_IFACE" ip6 saddr "$VPN_SUBNET6" masquerade + fi nft 'add chain inet lmvpn_nat forward { type filter hook forward priority 0 ; policy accept ; }' nft add rule inet lmvpn_nat forward ip saddr "$VPN_SUBNET" accept nft add rule inet lmvpn_nat forward ip daddr "$VPN_SUBNET" accept + if [ -n "$VPN_SUBNET6" ]; then + nft add rule inet lmvpn_nat forward ip6 saddr "$VPN_SUBNET6" accept + nft add rule inet lmvpn_nat forward ip6 daddr "$VPN_SUBNET6" accept + fi # 持久化 nft 规则 mkdir -p /etc/nftables.d @@ -99,6 +114,17 @@ else iptables -A FORWARD -s "$VPN_SUBNET" -j ACCEPT iptables -A FORWARD -d "$VPN_SUBNET" -j ACCEPT + # IPv6 NAT(需 ip6tables) + if [ -n "$VPN_SUBNET6" ] && command -v ip6tables >/dev/null 2>&1; then + echo "配置 IPv6 NAT (ip6tables)..." + ip6tables -t nat -D POSTROUTING -s "$VPN_SUBNET6" -o "$WAN_IFACE" -j MASQUERADE 2>/dev/null || true + ip6tables -t nat -A POSTROUTING -s "$VPN_SUBNET6" -o "$WAN_IFACE" -j MASQUERADE + ip6tables -D FORWARD -s "$VPN_SUBNET6" -j ACCEPT 2>/dev/null || true + ip6tables -D FORWARD -d "$VPN_SUBNET6" -j ACCEPT 2>/dev/null || true + ip6tables -A FORWARD -s "$VPN_SUBNET6" -j ACCEPT + ip6tables -A FORWARD -d "$VPN_SUBNET6" -j ACCEPT + fi + # 持久化 iptables 规则 if command -v netfilter-persistent >/dev/null 2>&1; then netfilter-persistent save @@ -106,6 +132,10 @@ else elif command -v iptables-save >/dev/null 2>&1; then mkdir -p /etc/iptables iptables-save > /etc/iptables/rules.v4 + if [ -n "$VPN_SUBNET6" ] && command -v ip6tables-save >/dev/null 2>&1; then + ip6tables-save > /etc/iptables/rules.v6 + echo "ip6tables 规则已写入 /etc/iptables/rules.v6" + fi echo "iptables 规则已写入 /etc/iptables/rules.v4" echo "注意: 重启后规则持久化需配合 iptables-persistent 包,建议安装: apt install iptables-persistent" else diff --git a/internal/handler/vpn.go b/internal/handler/vpn.go index 0b4b01d..d69cf2b 100644 --- a/internal/handler/vpn.go +++ b/internal/handler/vpn.go @@ -16,6 +16,7 @@ import ( type vpnSettingsResponse struct { Enabled bool `json:"enabled"` Subnet string `json:"subnet"` + Subnet6 string `json:"subnet6"` MTU int `json:"mtu"` InterfaceName string `json:"interface_name"` AllowClientToClient bool `json:"allow_client_to_client"` @@ -26,6 +27,7 @@ type vpnSettingsResponse struct { type updateVpnSettingsRequest struct { Enabled *bool `json:"enabled"` Subnet *string `json:"subnet"` + Subnet6 *string `json:"subnet6"` MTU *int `json:"mtu"` InterfaceName *string `json:"interface_name"` AllowClientToClient *bool `json:"allow_client_to_client"` @@ -39,16 +41,22 @@ func loadVpnSettings() (model.VpnSetting, error) { return s, err } -func loadReservationsMap() (map[uint]string, error) { +func loadReservationsMap() (map[uint]string, map[uint]string, error) { var rows []model.VpnReservation if err := db.DB.Find(&rows).Error; err != nil { - return nil, err + return nil, nil, err } - out := make(map[uint]string, len(rows)) + out4 := make(map[uint]string, len(rows)) + out6 := make(map[uint]string, len(rows)) for _, r := range rows { - out[r.UserID] = r.IPAddress + if r.IPAddress != "" { + out4[r.UserID] = r.IPAddress + } + if r.IPAddress6 != "" { + out6[r.UserID] = r.IPAddress6 + } } - return out, nil + return out4, out6, nil } func ApplyVpnFromDB(svc *vpn.VpnService) error { @@ -56,11 +64,11 @@ func ApplyVpnFromDB(svc *vpn.VpnService) error { if err != nil { return err } - reservations, err := loadReservationsMap() + resv4, resv6, err := loadReservationsMap() if err != nil { return err } - return svc.ApplySettings(s, reservations) + return svc.ApplySettings(s, resv4, resv6) } func validateSubnet(subnet string) error { @@ -78,12 +86,30 @@ func validateSubnet(subnet string) error { return nil } +func validateSubnet6(subnet string) error { + ip, ipNet, err := net.ParseCIDR(subnet) + if err != nil { + return err + } + if ip.To4() != nil { + return errIPv6Only + } + ones, _ := ipNet.Mask.Size() + if ones < 64 || ones > 126 { + return errSubnet6Range + } + return nil +} + var ( errIPv4Only = errStr("仅支持 IPv4 子网") + errIPv6Only = errStr("IPv6 子网不能使用 IPv4 地址") errSubnetTooSmall = errStr("子网前缀长度不能大于 /30") + errSubnet6Range = errStr("IPv6 子网前缀长度应在 /64 到 /126 之间") errIPNotInSubnet = errStr("IP 不在子网范围内") errIPReserved = errStr("该 IP 已被预留") errIPIsServer = errStr("该 IP 为服务器 IP,不可预留") + errNeedAtLeastOne = errStr("至少需要填写 IPv4 或 IPv6 地址之一") ) type errStr string @@ -99,6 +125,7 @@ func GetVpnSettings(c *gin.Context) { c.JSON(http.StatusOK, vpnSettingsResponse{ Enabled: s.Enabled, Subnet: s.Subnet, + Subnet6: s.Subnet6, MTU: s.MTU, InterfaceName: s.InterfaceName, AllowClientToClient: s.AllowClientToClient, @@ -127,6 +154,15 @@ func UpdateVpnSettings(c *gin.Context) { } s.Subnet = *req.Subnet } + if req.Subnet6 != nil && *req.Subnet6 != s.Subnet6 { + if *req.Subnet6 != "" { + if err := validateSubnet6(*req.Subnet6); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } + s.Subnet6 = *req.Subnet6 + } if req.MTU != nil { if *req.MTU < 500 || *req.MTU > 65535 { c.JSON(http.StatusBadRequest, gin.H{"error": "MTU 范围 500-65535"}) @@ -163,11 +199,13 @@ func UpdateVpnSettings(c *gin.Context) { } type vpnStatusResponse struct { - Enabled bool `json:"enabled"` - Online int `json:"online"` - UsedIPs int `json:"used_ips"` - Capacity uint64 `json:"capacity"` - Clients []vpn.ClientInfo `json:"clients"` + Enabled bool `json:"enabled"` + Online int `json:"online"` + UsedIPs int `json:"used_ips"` + Capacity uint64 `json:"capacity"` + UsedIPs6 int `json:"used_ips6,omitempty"` + Capacity6 uint64 `json:"capacity6,omitempty"` + Clients []vpn.ClientInfo `json:"clients"` } func GetVpnStatus(c *gin.Context) { @@ -176,14 +214,16 @@ func GetVpnStatus(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "加载设置失败"}) return } - used, cap := vpn.VPN.AllocStats() + used4, cap4, used6, cap6 := vpn.VPN.AllocStats() clients := vpn.VPN.ClientList() c.JSON(http.StatusOK, vpnStatusResponse{ - Enabled: s.Enabled, - Online: len(clients), - UsedIPs: used, - Capacity: cap, - Clients: clients, + Enabled: s.Enabled, + Online: len(clients), + UsedIPs: used4, + Capacity: cap4, + UsedIPs6: used6, + Capacity6: cap6, + Clients: clients, }) } @@ -192,11 +232,12 @@ func GetVpnDiag(c *gin.Context) { } type reservationResponse struct { - ID uint `json:"id"` - UserID uint `json:"user_id"` - Username string `json:"username"` - IPAddress string `json:"ip_address"` - CreatedAt string `json:"created_at"` + ID uint `json:"id"` + UserID uint `json:"user_id"` + Username string `json:"username"` + IPAddress string `json:"ip_address"` + IPAddress6 string `json:"ip_address6,omitempty"` + CreatedAt string `json:"created_at"` } func ListVpnReservations(c *gin.Context) { @@ -220,19 +261,21 @@ func ListVpnReservations(c *gin.Context) { out := make([]reservationResponse, len(rows)) for i, r := range rows { out[i] = reservationResponse{ - ID: r.ID, - UserID: r.UserID, - Username: nameMap[r.UserID], - IPAddress: r.IPAddress, - CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"), + ID: r.ID, + UserID: r.UserID, + Username: nameMap[r.UserID], + IPAddress: r.IPAddress, + IPAddress6: r.IPAddress6, + CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"), } } c.JSON(http.StatusOK, gin.H{"reservations": out}) } type createReservationRequest struct { - UserID uint `json:"user_id" binding:"required"` - IPAddress string `json:"ip_address" binding:"required"` + UserID uint `json:"user_id" binding:"required"` + IPAddress string `json:"ip_address"` + IPAddress6 string `json:"ip_address6"` } func CreateVpnReservation(c *gin.Context) { @@ -242,6 +285,11 @@ func CreateVpnReservation(c *gin.Context) { return } + if req.IPAddress == "" && req.IPAddress6 == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": errNeedAtLeastOne.Error()}) + return + } + var user model.User if err := db.DB.First(&user, req.UserID).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "用户不存在"}) @@ -253,42 +301,86 @@ func CreateVpnReservation(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "加载设置失败"}) return } - _, ipNet, err := net.ParseCIDR(s.Subnet) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "子网配置错误"}) - return - } - ip := net.ParseIP(req.IPAddress) - if ip == nil || !ipNet.Contains(ip) { - c.JSON(http.StatusBadRequest, gin.H{"error": errIPNotInSubnet.Error()}) - return - } - serverIP, _ := cidr.Host(ipNet, 1) - if ip.Equal(serverIP) { - c.JSON(http.StatusBadRequest, gin.H{"error": errIPIsServer.Error()}) - return + + // validate v4 + if req.IPAddress != "" { + _, ipNet, err := net.ParseCIDR(s.Subnet) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "子网配置错误"}) + return + } + ip := net.ParseIP(req.IPAddress) + if ip == nil || !ipNet.Contains(ip) { + c.JSON(http.StatusBadRequest, gin.H{"error": errIPNotInSubnet.Error()}) + return + } + serverIP, _ := cidr.Host(ipNet, 1) + if ip.Equal(serverIP) { + c.JSON(http.StatusBadRequest, gin.H{"error": errIPIsServer.Error()}) + return + } } - var count int64 - db.DB.Model(&model.VpnReservation{}).Where("ip_address = ?", req.IPAddress).Count(&count) - if count > 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": errIPReserved.Error()}) - return + // validate v6 + if req.IPAddress6 != "" { + if s.Subnet6 == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "服务端未配置 IPv6 子网"}) + return + } + _, ipNet6, err := net.ParseCIDR(s.Subnet6) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "IPv6 子网配置错误"}) + return + } + ip6 := net.ParseIP(req.IPAddress6) + if ip6 == nil || ip6.To4() != nil || !ipNet6.Contains(ip6) { + c.JSON(http.StatusBadRequest, gin.H{"error": "IPv6 " + errIPNotInSubnet.Error()}) + return + } + serverIP6, _ := cidr.Host(ipNet6, 1) + if ip6.Equal(serverIP6) { + c.JSON(http.StatusBadRequest, gin.H{"error": "IPv6 " + errIPIsServer.Error()}) + return + } } + + // check uniqueness + if req.IPAddress != "" { + var count int64 + db.DB.Model(&model.VpnReservation{}).Where("ip_address = ?", req.IPAddress).Count(&count) + if count > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": errIPReserved.Error()}) + return + } + } + if req.IPAddress6 != "" { + var count int64 + db.DB.Model(&model.VpnReservation{}).Where("ip_address6 = ?", req.IPAddress6).Count(&count) + if count > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "IPv6 " + errIPReserved.Error()}) + return + } + } + var existUser model.VpnReservation if err := db.DB.Where("user_id = ?", req.UserID).First(&existUser).Error; err == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "该用户已有预留 IP"}) return } - r := model.VpnReservation{UserID: req.UserID, IPAddress: req.IPAddress} + r := model.VpnReservation{UserID: req.UserID, IPAddress: req.IPAddress, IPAddress6: req.IPAddress6} if err := db.DB.Create(&r).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建预留失败"}) return } if vpn.VPN.Running() { - vpn.VPN.AddReservation(req.UserID, req.IPAddress) + if req.IPAddress != "" { + vpn.VPN.AddReservation(req.UserID, req.IPAddress) + } + if req.IPAddress6 != "" { + vpn.VPN.AddReservation6(req.UserID, req.IPAddress6) + } } c.JSON(http.StatusOK, gin.H{"message": "预留已创建"}) } diff --git a/internal/model/vpn.go b/internal/model/vpn.go index 4f2b042..073ab7d 100644 --- a/internal/model/vpn.go +++ b/internal/model/vpn.go @@ -8,6 +8,7 @@ type VpnSetting struct { ID uint `gorm:"primaryKey"` Enabled bool `gorm:"default:false"` Subnet string `gorm:"size:64;not null"` + Subnet6 string `gorm:"size:64"` MTU int `gorm:"default:1420"` InterfaceName string `gorm:"size:16"` AllowClientToClient bool `gorm:"default:false"` @@ -21,10 +22,11 @@ func (VpnSetting) TableName() string { } type VpnReservation struct { - ID uint `gorm:"primaryKey;autoIncrement"` - UserID uint `gorm:"uniqueIndex;not null"` - IPAddress string `gorm:"size:64;uniqueIndex;not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` + ID uint `gorm:"primaryKey;autoIncrement"` + UserID uint `gorm:"uniqueIndex;not null"` + IPAddress string `gorm:"size:64;uniqueIndex"` + IPAddress6 string `gorm:"size:64"` + CreatedAt time.Time `gorm:"autoCreateTime"` } func (VpnReservation) TableName() string { diff --git a/internal/vpn/alloc.go b/internal/vpn/alloc.go index b3152ae..154b97b 100644 --- a/internal/vpn/alloc.go +++ b/internal/vpn/alloc.go @@ -49,6 +49,9 @@ func (m *AllocationManager) Allocate(userID uint) (net.IP, error) { } count := cidr.AddressCount(m.net) + if count == 0 && m.net.IP.To4() == nil { + count = 65536 + } maxIndex := int(count - 1) for i := 2; i < maxIndex; i++ { ip, err := cidr.Host(m.net, i) @@ -105,6 +108,9 @@ func (m *AllocationManager) UsedCount() int { func (m *AllocationManager) Capacity() uint64 { count := cidr.AddressCount(m.net) + if count == 0 && m.net.IP.To4() == nil { + return 65533 + } if count < 3 { return 0 } diff --git a/internal/vpn/diag.go b/internal/vpn/diag.go index 77785a1..288366d 100644 --- a/internal/vpn/diag.go +++ b/internal/vpn/diag.go @@ -6,17 +6,21 @@ import ( ) type DiagResult struct { - Platform string `json:"platform"` - IsRoot bool `json:"is_root"` - HasCapNetAdmin *bool `json:"has_cap_net_admin"` - CapNetAdminNote string `json:"cap_net_admin_note,omitempty"` - IPForward *bool `json:"ip_forward"` - IPForwardNote string `json:"ip_forward_note,omitempty"` - Masquerade *bool `json:"masquerade"` - MasqueradeNote string `json:"masquerade_note,omitempty"` - TUNCreate string `json:"tun_create"` - TUNRunning bool `json:"tun_running"` - TUNName string `json:"tun_name,omitempty"` + Platform string `json:"platform"` + IsRoot bool `json:"is_root"` + HasCapNetAdmin *bool `json:"has_cap_net_admin"` + CapNetAdminNote string `json:"cap_net_admin_note,omitempty"` + IPForward *bool `json:"ip_forward"` + IPForwardNote string `json:"ip_forward_note,omitempty"` + Masquerade *bool `json:"masquerade"` + MasqueradeNote string `json:"masquerade_note,omitempty"` + IP6Forward *bool `json:"ip6_forward"` + IP6ForwardNote string `json:"ip6_forward_note,omitempty"` + Masquerade6 *bool `json:"masquerade6"` + Masquerade6Note string `json:"masquerade6_note,omitempty"` + TUNCreate string `json:"tun_create"` + TUNRunning bool `json:"tun_running"` + TUNName string `json:"tun_name,omitempty"` } func Diag(svc *VpnService) DiagResult { diff --git a/internal/vpn/diag_linux.go b/internal/vpn/diag_linux.go index f050b66..066c7b0 100644 --- a/internal/vpn/diag_linux.go +++ b/internal/vpn/diag_linux.go @@ -24,6 +24,16 @@ func fillPlatformDiag(r *DiagResult) { m, note := checkMasquerade() r.Masquerade = m r.MasqueradeNote = note + + v6 := readIP6Forward() + r.IP6Forward = v6 + if v6 == nil || !*v6 { + r.IP6ForwardNote = "未开启,执行: sysctl -w net.ipv6.conf.all.forwarding=1" + } + + m6, note6 := checkMasquerade6() + r.Masquerade6 = m6 + r.Masquerade6Note = note6 } func ptrBool(b bool) *bool { return &b } @@ -112,3 +122,44 @@ func checkMasquerade() (*bool, string) { return nil, "iptables 与 nft 均未安装,无法检测 NAT 规则。Debian/Ubuntu 安装: apt install nftables" } + +func readIP6Forward() *bool { + data, err := os.ReadFile("/proc/sys/net/ipv6/conf/all/forwarding") + if err != nil { + return nil + } + v := strings.TrimSpace(string(data)) == "1" + return &v +} + +func checkMasquerade6() (*bool, string) { + nftPath := findExecutable("nft") + if nftPath != "" { + out, err := exec.Command(nftPath, "list", "ruleset").Output() + if err == nil { + s := string(out) + if strings.Contains(s, "ip6 saddr") && strings.Contains(s, "masquerade") { + return ptrBool(true), "" + } + return ptrBool(false), "未检测到 IPv6 masquerade 规则,IPv6 客户端无法出网" + } + } + + ip6tPath := findExecutable("ip6tables") + if ip6tPath != "" { + out, err := exec.Command(ip6tPath, "-t", "nat", "-L", "POSTROUTING", "-n").Output() + if err != nil { + if nftPath != "" { + return nil, "ip6tables 与原生 nft 表不兼容且 nft 不可执行,无法检测 IPv6 MASQUERADE" + } + return nil, "ip6tables 不可执行(权限不足?),无法检测 IPv6 MASQUERADE" + } + has := strings.Contains(string(out), "MASQUERADE") + if has { + return &has, "" + } + return &has, "未检测到 IPv6 MASQUERADE 规则,IPv6 客户端无法出网" + } + + return nil, "ip6tables 与 nft 均未安装,无法检测 IPv6 NAT 规则" +} diff --git a/internal/vpn/diag_other.go b/internal/vpn/diag_other.go index 0f15bad..6f740d3 100644 --- a/internal/vpn/diag_other.go +++ b/internal/vpn/diag_other.go @@ -9,4 +9,8 @@ func fillPlatformDiag(r *DiagResult) { r.IPForwardNote = "仅 Linux 适用" r.Masquerade = nil r.MasqueradeNote = "仅 Linux 适用" + r.IP6Forward = nil + r.IP6ForwardNote = "仅 Linux 适用" + r.Masquerade6 = nil + r.Masquerade6Note = "仅 Linux 适用" } diff --git a/internal/vpn/protocol.go b/internal/vpn/protocol.go index 8be2fe7..deb6b3a 100644 --- a/internal/vpn/protocol.go +++ b/internal/vpn/protocol.go @@ -1,11 +1,14 @@ package vpn 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"` } type controlMessage struct { diff --git a/internal/vpn/service.go b/internal/vpn/service.go index 37a962f..ff50101 100644 --- a/internal/vpn/service.go +++ b/internal/vpn/service.go @@ -19,6 +19,10 @@ type VpnService struct { serverIP net.IP prefix int alloc *AllocationManager + net6 *net.IPNet + serverIP6 net.IP + prefix6 int + alloc6 *AllocationManager switchx *PacketSwitch tun *TUNInterface tunDone chan struct{} @@ -57,7 +61,7 @@ func (s *VpnService) parseNet(subnet string) (*net.IPNet, net.IP, int, error) { return ipNet, serverIP, ones, nil } -func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations map[uint]string) error { +func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations4, reservations6 map[uint]string) error { s.mu.Lock() if s.running { s.mu.Unlock() @@ -76,6 +80,17 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations map[u return err } + var ipNet6 *net.IPNet + var serverIP6 net.IP + var prefix6 int + var alloc6 *AllocationManager + if settings.Subnet6 != "" { + ipNet6, serverIP6, prefix6, err = s.parseNet(settings.Subnet6) + if err != nil { + return fmt.Errorf("IPv6 子网错误: %w", err) + } + } + tun, err := CreateTUN(settings.InterfaceName) if err != nil { return err @@ -84,11 +99,22 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations map[u if settings.DoLocalIPConfig { if err := tun.Configure(serverIP, prefix, nil); err != nil { _ = tun.Close() - return fmt.Errorf("配置 TUN 失败: %w", err) + return fmt.Errorf("配置 TUN IPv4 失败: %w", err) } } if err := tun.AddSubnetRoute(ipNet); err != nil { - log.Printf("警告: 添加子网路由失败: %v", err) + log.Printf("警告: 添加 IPv4 子网路由失败: %v", err) + } + if ipNet6 != nil { + if settings.DoLocalIPConfig { + if err := tun.Configure(serverIP6, prefix6, nil); err != nil { + log.Printf("警告: 配置 TUN IPv6 失败: %v", err) + } + } + if err := tun.AddSubnetRoute(ipNet6); err != nil { + log.Printf("警告: 添加 IPv6 子网路由失败: %v", err) + } + alloc6 = NewAllocationManager(ipNet6, serverIP6, reservations6) } if err := tun.SetMTU(settings.MTU); err != nil { log.Printf("警告: 设置 MTU 失败: %v", err) @@ -98,7 +124,11 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations map[u s.net = ipNet s.serverIP = serverIP s.prefix = prefix - s.alloc = NewAllocationManager(ipNet, serverIP, reservations) + s.alloc = NewAllocationManager(ipNet, serverIP, reservations4) + s.net6 = ipNet6 + s.serverIP6 = serverIP6 + s.prefix6 = prefix6 + s.alloc6 = alloc6 s.switchx = NewPacketSwitch(settings.AllowClientToClient) s.tun = tun s.tunDone = make(chan struct{}) @@ -106,7 +136,10 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations map[u s.mu.Unlock() go s.serveTUN() - log.Printf("VPN 服务已启动: tun=%s subnet=%s server=%s mtu=%d", tun.Name(), ipNet.String(), serverIP.String(), settings.MTU) + log.Printf("VPN 服务已启动: tun=%s subnet=%s server=%s", tun.Name(), ipNet.String(), serverIP.String()) + if ipNet6 != nil { + log.Printf(" IPv6: subnet=%s server=%s", ipNet6.String(), serverIP6.String()) + } return nil } @@ -162,14 +195,27 @@ func (s *VpnService) Stop() error { return nil } -func (s *VpnService) Allocate(user *model.User) (net.IP, error) { +func (s *VpnService) Allocate(user *model.User) (net.IP, net.IP, error) { s.mu.RLock() alloc := s.alloc + alloc6 := s.alloc6 s.mu.RUnlock() if alloc == nil { - return nil, errors.New("VPN 服务未运行") + return nil, nil, errors.New("VPN 服务未运行") } - return alloc.Allocate(user.ID) + ip4, err := alloc.Allocate(user.ID) + if err != nil { + return nil, nil, err + } + var ip6 net.IP + if alloc6 != nil { + ip6, err = alloc6.Allocate(user.ID) + if err != nil { + alloc.Release(ip4) + return ip4, nil, fmt.Errorf("IPv6 分配失败: %w", err) + } + } + return ip4, ip6, nil } func (s *VpnService) WriteToTUN(packet []byte) error { @@ -209,6 +255,9 @@ func (s *VpnService) unregisterClient(c *tunnelConn) { if s.alloc != nil { s.alloc.Release(c.assignedIP) } + if s.alloc6 != nil && c.assignedIP6 != nil { + s.alloc6.Release(c.assignedIP6) + } s.mu.Unlock() } @@ -224,14 +273,36 @@ func (s *VpnService) Prefix() int { return s.prefix } -func (s *VpnService) AllocStats() (used int, capacity uint64) { +func (s *VpnService) ServerIP6() net.IP { + s.mu.RLock() + defer s.mu.RUnlock() + return s.serverIP6 +} + +func (s *VpnService) Prefix6() int { + s.mu.RLock() + defer s.mu.RUnlock() + return s.prefix6 +} + +func (s *VpnService) HasIPv6() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.alloc6 != nil +} + +func (s *VpnService) AllocStats() (used4 int, cap4 uint64, used6 int, cap6 uint64) { s.mu.RLock() alloc := s.alloc + alloc6 := s.alloc6 s.mu.RUnlock() - if alloc == nil { - return 0, 0 + if alloc != nil { + used4, cap4 = alloc.UsedCount(), alloc.Capacity() } - return alloc.UsedCount(), alloc.Capacity() + if alloc6 != nil { + used6, cap6 = alloc6.UsedCount(), alloc6.Capacity() + } + return } func (s *VpnService) AddReservation(userID uint, ipStr string) { @@ -243,13 +314,26 @@ func (s *VpnService) AddReservation(userID uint, ipStr string) { } } +func (s *VpnService) AddReservation6(userID uint, ipStr string) { + s.mu.RLock() + alloc6 := s.alloc6 + s.mu.RUnlock() + if alloc6 != nil { + alloc6.AddReservation(userID, ipStr) + } +} + func (s *VpnService) RemoveReservation(userID uint) { s.mu.RLock() alloc := s.alloc + alloc6 := s.alloc6 s.mu.RUnlock() if alloc != nil { alloc.RemoveReservation(userID) } + if alloc6 != nil { + alloc6.RemoveReservation(userID) + } } func (s *VpnService) ClientList() []ClientInfo { @@ -265,6 +349,7 @@ func (s *VpnService) ClientList() []ClientInfo { type ClientInfo struct { Username string `json:"username"` IP string `json:"ip"` + IP6 string `json:"ip6,omitempty"` ConnectedAt string `json:"connected_at"` } diff --git a/internal/vpn/switch.go b/internal/vpn/switch.go index 77f506f..6159190 100644 --- a/internal/vpn/switch.go +++ b/internal/vpn/switch.go @@ -10,6 +10,7 @@ import ( type SwitchConn interface { WritePacket(data []byte) error AssignedIP() net.IP + AssignedIP6() net.IP } type ipKey [16]byte @@ -40,17 +41,24 @@ func (s *PacketSwitch) SetAllowClientToClient(v bool) { } func (s *PacketSwitch) Register(c SwitchConn) { - k := ipToKey(c.AssignedIP()) s.mu.Lock() - s.table[k] = c + s.table[ipToKey(c.AssignedIP())] = c + if ip6 := c.AssignedIP6(); ip6 != nil { + s.table[ipToKey(ip6)] = c + } s.mu.Unlock() } func (s *PacketSwitch) Unregister(c SwitchConn) { - k := ipToKey(c.AssignedIP()) s.mu.Lock() - if cur, ok := s.table[k]; ok && cur == c { - delete(s.table, k) + if cur, ok := s.table[ipToKey(c.AssignedIP())]; ok && cur == c { + delete(s.table, ipToKey(c.AssignedIP())) + } + if ip6 := c.AssignedIP6(); ip6 != nil { + k := ipToKey(ip6) + if cur, ok := s.table[k]; ok && cur == c { + delete(s.table, k) + } } s.mu.Unlock() } @@ -110,9 +118,18 @@ func (s *PacketSwitch) RouteFromClient(src SwitchConn, packet []byte) []SwitchCo if !ok { return nil } - // anti-spoof: enforce assigned source IP - if srcIP != nil && !srcIP.Equal(src.AssignedIP()) { - return nil + // anti-spoof: enforce assigned source IP by version + if srcIP != nil { + if srcIP.To4() != nil { + if !srcIP.Equal(src.AssignedIP()) { + return nil + } + } else { + assigned6 := src.AssignedIP6() + if assigned6 == nil || !srcIP.Equal(assigned6) { + return nil + } + } } if dest.IsGlobalUnicast() { if c := s.findByIP(dest); c != nil && s.allowC2C() { diff --git a/internal/vpn/tunnel.go b/internal/vpn/tunnel.go index 77662b9..69684d0 100644 --- a/internal/vpn/tunnel.go +++ b/internal/vpn/tunnel.go @@ -28,18 +28,20 @@ var ( ) type tunnelConn struct { - conn *websocket.Conn - user *model.User - svc *VpnService - assignedIP net.IP - connectedAt time.Time - writeMu sync.Mutex - ready atomic.Bool - rxBytes atomic.Int64 - txBytes atomic.Int64 + conn *websocket.Conn + user *model.User + svc *VpnService + assignedIP net.IP + assignedIP6 net.IP + connectedAt time.Time + writeMu sync.Mutex + ready atomic.Bool + rxBytes atomic.Int64 + txBytes atomic.Int64 } func (c *tunnelConn) AssignedIP() net.IP { return c.assignedIP } +func (c *tunnelConn) AssignedIP6() net.IP { return c.assignedIP6 } func (c *tunnelConn) WritePacket(data []byte) error { if !c.ready.Load() || len(data) == 0 { @@ -73,11 +75,15 @@ func (c *tunnelConn) close() { } func (c *tunnelConn) info() ClientInfo { - return ClientInfo{ + ci := ClientInfo{ Username: c.user.Username, IP: c.assignedIP.String(), ConnectedAt: c.connectedAt.Format("2006-01-02 15:04:05"), } + if c.assignedIP6 != nil { + ci.IP6 = c.assignedIP6.String() + } + return ci } func runTunnel(conn *websocket.Conn, user *model.User) { @@ -106,7 +112,7 @@ func runTunnel(conn *websocket.Conn, user *model.User) { activeConnsMu.Unlock() }() - ip, err := VPN.Allocate(user) + ip4, ip6, err := VPN.Allocate(user) if err != nil { _ = sendJSON(conn, controlMessage{Type: "error", Message: "分配 IP 失败: " + err.Error()}) return @@ -116,7 +122,8 @@ func runTunnel(conn *websocket.Conn, user *model.User) { conn: conn, user: user, svc: VPN, - assignedIP: ip, + assignedIP: ip4, + assignedIP6: ip6, connectedAt: time.Now(), } @@ -126,17 +133,25 @@ func runTunnel(conn *websocket.Conn, user *model.User) { settings := VPN.Settings() initMsg := initMessage{ Type: "init", - IP: ip.String(), + IP: ip4.String(), Prefix: VPN.Prefix(), MTU: settings.MTU, ServerIP: VPN.ServerIP().String(), } + if ip6 != nil { + initMsg.IP6 = ip6.String() + initMsg.Prefix6 = VPN.Prefix6() + initMsg.ServerIP6 = VPN.ServerIP6().String() + } if err := tc.writeControl(initMsg); err != nil { log.Printf("用户 %s 发送 init 失败: %v", user.Username, err) return } - log.Printf("用户 %s 已连接,分配 IP %s", user.Username, ip.String()) + log.Printf("用户 %s 已连接,分配 IP %s", user.Username, ip4.String()) + if ip6 != nil { + log.Printf(" IPv6: %s", ip6.String()) + } conn.SetReadLimit(maxMessageSize) conn.SetReadDeadline(time.Now().Add(readyTimeout)) @@ -175,7 +190,7 @@ func runTunnel(conn *websocket.Conn, user *model.User) { if msg.Type == "ready" && !tc.ready.Load() { tc.ready.Store(true) conn.SetReadDeadline(time.Now().Add(readTimeout)) - log.Printf("用户 %s 就绪 (IP %s)", user.Username, ip.String()) + log.Printf("用户 %s 就绪 (IP %s)", user.Username, ip4.String()) } continue }