feat: 支持 IPv6 双栈地址派发
- 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、反欺骗全部更新
This commit is contained in:
+85
-28
@@ -294,7 +294,7 @@ sequenceDiagram
|
|||||||
|
|
||||||
### 5.2 IP 分配规则
|
### 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:43-48`)
|
||||||
- 地址耗尽时报错 "可用 IP 地址已耗尽"(`alloc.go:65`)
|
- 地址耗尽时报错 "可用 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 消息
|
### 5.3 init 消息
|
||||||
|
|
||||||
前置检查通过后,服务端发送 `init` 文本消息(`internal/vpn/tunnel.go:126-137`):
|
前置检查通过后,服务端发送 `init` 文本消息(`internal/vpn/tunnel.go:132-148`):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -318,17 +328,25 @@ sequenceDiagram
|
|||||||
"ip": "10.0.0.5",
|
"ip": "10.0.0.5",
|
||||||
"prefix": 24,
|
"prefix": 24,
|
||||||
"mtu": 1420,
|
"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` |
|
| `type` | string | 是 | 固定值 `"init"` | `internal/vpn/protocol.go:3-10` |
|
||||||
| `ip` | string | 分配给客户端的内网 IP(点分十进制) | `tunnel.go:130` |
|
| `ip` | string | 是 | 分配给客户端的 IPv4 地址 | `tunnel.go:136` |
|
||||||
| `prefix` | int | 子网前缀长度(如 `24`) | `tunnel.go:131` |
|
| `prefix` | int | 是 | IPv4 子网前缀长度(如 `24`) | `tunnel.go:137` |
|
||||||
| `mtu` | int | TUN 网卡 MTU | `tunnel.go:132` |
|
| `mtu` | int | 是 | TUN 网卡 MTU | `tunnel.go:138` |
|
||||||
| `server_ip` | string | 服务器内网 IP(对端地址) | `tunnel.go:133` |
|
| `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 消息与超时
|
### 5.4 ready 消息与超时
|
||||||
|
|
||||||
@@ -412,14 +430,14 @@ sequenceDiagram
|
|||||||
|
|
||||||
### 6.4 反欺骗(Anti-Spoofing)
|
### 6.4 反欺骗(Anti-Spoofing)
|
||||||
|
|
||||||
服务端**强制校验**每个来自客户端的 IP 包的源地址(`internal/vpn/switch.go:113-116`):
|
服务端**强制校验**每个来自客户端的 IP 包的源地址(`internal/vpn/switch.go:113-126`):
|
||||||
|
|
||||||
```
|
- **IPv4 包**:源地址必须等于客户端被分配的 IPv4 地址(`init.ip`)
|
||||||
if 源IP != 该客户端被分配的IP:
|
- **IPv6 包**:源地址必须等于客户端被分配的 IPv6 地址(`init.ip6`)
|
||||||
丢弃该包(不转发,不写入TUN,不断开连接)
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ 客户端必须确保 TUN 网卡只发送源地址为 `init.ip` 的 IP 包。若客户端配置错误导致源 IP 不匹配,所有上行包将被静默丢弃。
|
不匹配的包将被静默丢弃(不转发、不写入 TUN、不断开连接)。
|
||||||
|
|
||||||
|
> ⚠️ 客户端必须确保 TUN 网卡只发送源地址与 `init.ip` / `init.ip6` 匹配的 IP 包。若客户端配置错误导致源 IP 不匹配,所有上行包将被静默丢弃。
|
||||||
|
|
||||||
### 6.5 非 IP 二进制帧
|
### 6.5 非 IP 二进制帧
|
||||||
|
|
||||||
@@ -488,9 +506,11 @@ conn.WriteControl(websocket.PingMessage, nil, ...)
|
|||||||
|
|
||||||
| 约束 | 值 | 源码 |
|
| 约束 | 值 | 源码 |
|
||||||
|------|----|----|
|
|------|----|----|
|
||||||
| IP 版本 | 仅 IPv4 | `internal/handler/vpn.go:66-73` |
|
| IPv4 版本 | 仅 IPv4 | `internal/handler/vpn.go:66-73` |
|
||||||
| 前缀长度 | ≤ /30 | `internal/handler/vpn.go:74-78` |
|
| 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` |
|
| 可用容量 | `AddressCount - 3` | `internal/vpn/alloc.go:106-112` |
|
||||||
|
| IPv6 大子网容量 | ~65533(/64 等大子网 AddressCount 溢出时) | `internal/vpn/alloc.go:108-110` |
|
||||||
|
|
||||||
### 8.4 MTU 约束
|
### 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
|
```go
|
||||||
type initMessage struct {
|
type initMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
Prefix int `json:"prefix"`
|
Prefix int `json:"prefix"`
|
||||||
MTU int `json:"mtu"`
|
MTU int `json:"mtu"`
|
||||||
ServerIP string `json:"server_ip"`
|
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_ok` | Text | 密码认证成功(仅方式 B) | JSON | `{"type":"auth_ok"}` |
|
||||||
| `auth_err` | Text | 认证失败 | JSON | `{"type":"auth_err","message":"..."}` |
|
| `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":"..."}` |
|
| `error` | Text | 握手阶段失败 | JSON | `{"type":"error","message":"..."}` |
|
||||||
| 数据帧 | Binary | `ready` 之后,下行 IP 包 | 原始 IP 包 | (二进制) |
|
| 数据帧 | Binary | `ready` 之后,下行 IP 包 | 原始 IP 包 | (二进制) |
|
||||||
| Ping | WebSocket Ping | 每 30 秒 | 空 | (WebSocket 协议层) |
|
| 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` |
|
| 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
|
### 11.2 Linux
|
||||||
|
|
||||||
@@ -608,10 +633,17 @@ ip addr add dev <tun> <ip>/<prefix> peer <server_ip>
|
|||||||
ip link set dev <tun> mtu <mtu>
|
ip link set dev <tun> mtu <mtu>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
IPv6(仅当 init 含 `ip6` 时):
|
||||||
|
|
||||||
|
```
|
||||||
|
ip addr add dev <tun> <ip6>/<prefix6>
|
||||||
|
```
|
||||||
|
|
||||||
路由:若需将所有流量经 VPN,添加默认路由:
|
路由:若需将所有流量经 VPN,添加默认路由:
|
||||||
|
|
||||||
```
|
```
|
||||||
ip route add 0.0.0.0/0 dev <tun>
|
ip route add 0.0.0.0/0 dev <tun>
|
||||||
|
ip route add ::/0 dev <tun> # IPv6 默认路由(可选)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 11.3 macOS
|
### 11.3 macOS
|
||||||
@@ -623,10 +655,17 @@ ifconfig <utun> inet <ip>/<prefix> <server_ip> up
|
|||||||
ifconfig <utun> mtu <mtu>
|
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 -inet -net 0.0.0.0/0 -interface <utun>
|
||||||
|
route add -inet6 -net ::/0 -interface <utun> # IPv6 默认路由(可选)
|
||||||
```
|
```
|
||||||
|
|
||||||
> macOS 的 utun 接口由系统分配编号(如 `utun4`),客户端通常无法指定名称。
|
> macOS 的 utun 接口由系统分配编号(如 `utun4`),客户端通常无法指定名称。
|
||||||
@@ -670,6 +709,12 @@ Linux 服务端须开启 IP 转发(`internal/vpn/diag_linux.go:53-60`):
|
|||||||
sysctl -w net.ipv4.ip_forward=1
|
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 伪装
|
### 12.2 NAT 伪装
|
||||||
|
|
||||||
服务端须配置 NAT masquerade,将客户端源 IP 转换为服务器物理网卡 IP(`internal/vpn/diag_linux.go:80-113`)。
|
服务端须配置 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
|
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
|
### 12.3 子网与 MTU
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const authHeader = () => ({ Authorization: `Bearer ${authStore.token}` })
|
|||||||
interface Settings {
|
interface Settings {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
subnet: string
|
subnet: string
|
||||||
|
subnet6: string
|
||||||
mtu: number
|
mtu: number
|
||||||
interface_name: string
|
interface_name: string
|
||||||
allow_client_to_client: boolean
|
allow_client_to_client: boolean
|
||||||
@@ -17,6 +18,7 @@ interface Settings {
|
|||||||
interface ClientInfo {
|
interface ClientInfo {
|
||||||
username: string
|
username: string
|
||||||
ip: string
|
ip: string
|
||||||
|
ip6?: string
|
||||||
connected_at: string
|
connected_at: string
|
||||||
}
|
}
|
||||||
interface Status {
|
interface Status {
|
||||||
@@ -24,6 +26,8 @@ interface Status {
|
|||||||
online: number
|
online: number
|
||||||
used_ips: number
|
used_ips: number
|
||||||
capacity: number
|
capacity: number
|
||||||
|
used_ips6?: number
|
||||||
|
capacity6?: number
|
||||||
clients: ClientInfo[]
|
clients: ClientInfo[]
|
||||||
}
|
}
|
||||||
interface Reservation {
|
interface Reservation {
|
||||||
@@ -31,6 +35,7 @@ interface Reservation {
|
|||||||
user_id: number
|
user_id: number
|
||||||
username: string
|
username: string
|
||||||
ip_address: string
|
ip_address: string
|
||||||
|
ip_address6?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
interface User {
|
interface User {
|
||||||
@@ -46,6 +51,10 @@ interface Diag {
|
|||||||
ip_forward_note?: string
|
ip_forward_note?: string
|
||||||
masquerade: boolean | null
|
masquerade: boolean | null
|
||||||
masquerade_note?: string
|
masquerade_note?: string
|
||||||
|
ip6_forward: boolean | null
|
||||||
|
ip6_forward_note?: string
|
||||||
|
masquerade6: boolean | null
|
||||||
|
masquerade6_note?: string
|
||||||
tun_create: string
|
tun_create: string
|
||||||
tun_running: boolean
|
tun_running: boolean
|
||||||
tun_name?: string
|
tun_name?: string
|
||||||
@@ -64,6 +73,7 @@ const saveMsg = ref('')
|
|||||||
const form = ref<Settings>({
|
const form = ref<Settings>({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
subnet: '192.168.77.0/24',
|
subnet: '192.168.77.0/24',
|
||||||
|
subnet6: '',
|
||||||
mtu: 1420,
|
mtu: 1420,
|
||||||
interface_name: '',
|
interface_name: '',
|
||||||
allow_client_to_client: false,
|
allow_client_to_client: false,
|
||||||
@@ -146,13 +156,13 @@ async function handleSave() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showAddResv = ref(false)
|
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('')
|
const resvError = ref('')
|
||||||
|
|
||||||
async function handleAddResv() {
|
async function handleAddResv() {
|
||||||
resvError.value = ''
|
resvError.value = ''
|
||||||
if (!resvForm.value.user_id || !resvForm.value.ip_address) {
|
if (!resvForm.value.user_id || (!resvForm.value.ip_address && !resvForm.value.ip_address6)) {
|
||||||
resvError.value = '请选择用户并填写 IP'
|
resvError.value = '请选择用户并至少填写一个 IP 地址'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -164,7 +174,7 @@ async function handleAddResv() {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || '创建失败')
|
if (!res.ok) throw new Error(data.error || '创建失败')
|
||||||
showAddResv.value = false
|
showAddResv.value = false
|
||||||
resvForm.value = { user_id: 0, ip_address: '' }
|
resvForm.value = { user_id: 0, ip_address: '', ip_address6: '' }
|
||||||
await fetchReservations()
|
await fetchReservations()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
resvError.value = e.message
|
resvError.value = e.message
|
||||||
@@ -296,7 +306,7 @@ onMounted(() => {
|
|||||||
<span v-else-if="diag?.masquerade" class="text-green-500 mt-0.5">✓</span>
|
<span v-else-if="diag?.masquerade" class="text-green-500 mt-0.5">✓</span>
|
||||||
<span v-else class="text-red-500 mt-0.5">✗</span>
|
<span v-else class="text-red-500 mt-0.5">✗</span>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-900 dark:text-white">NAT MASQUERADE</p>
|
<p class="text-sm font-medium text-gray-900 dark:text-white">NAT MASQUERADE (IPv4)</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
<template v-if="diag?.masquerade === null">{{ diag?.masquerade_note }}</template>
|
<template v-if="diag?.masquerade === null">{{ diag?.masquerade_note }}</template>
|
||||||
<template v-else>{{ diag?.masquerade ? '已配置' : diag?.masquerade_note || '未配置' }}</template>
|
<template v-else>{{ diag?.masquerade ? '已配置' : diag?.masquerade_note || '未配置' }}</template>
|
||||||
@@ -304,6 +314,34 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- IPv6 Forward -->
|
||||||
|
<div class="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/30">
|
||||||
|
<span v-if="diag?.ip6_forward === null" class="text-gray-400 mt-0.5">—</span>
|
||||||
|
<span v-else-if="diag?.ip6_forward" class="text-green-500 mt-0.5">✓</span>
|
||||||
|
<span v-else class="text-red-500 mt-0.5">✗</span>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">IPv6 转发 (forwarding)</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<template v-if="diag?.ip6_forward === null">{{ diag?.ip6_forward_note }}</template>
|
||||||
|
<template v-else>{{ diag?.ip6_forward ? '已开启' : diag?.ip6_forward_note || '未开启' }}</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- IPv6 MASQUERADE -->
|
||||||
|
<div class="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/30">
|
||||||
|
<span v-if="diag?.masquerade6 === null" class="text-gray-400 mt-0.5">—</span>
|
||||||
|
<span v-else-if="diag?.masquerade6" class="text-green-500 mt-0.5">✓</span>
|
||||||
|
<span v-else class="text-red-500 mt-0.5">✗</span>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">NAT MASQUERADE (IPv6)</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<template v-if="diag?.masquerade6 === null">{{ diag?.masquerade6_note }}</template>
|
||||||
|
<template v-else>{{ diag?.masquerade6 ? '已配置' : diag?.masquerade6_note || '未配置' }}</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 平台 -->
|
<!-- 平台 -->
|
||||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/30">
|
<div class="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/30">
|
||||||
<span class="text-gray-400 mt-0.5">ℹ</span>
|
<span class="text-gray-400 mt-0.5">ℹ</span>
|
||||||
@@ -333,6 +371,10 @@ onMounted(() => {
|
|||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">子网 (CIDR)</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">子网 (CIDR)</label>
|
||||||
<input v-model="form.subnet" type="text" placeholder="192.168.77.0/24" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
<input v-model="form.subnet" type="text" placeholder="192.168.77.0/24" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">IPv6 子网 (CIDR, 可选)</label>
|
||||||
|
<input v-model="form.subnet6" type="text" placeholder="fd00:dead:beef::/112" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">MTU</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">MTU</label>
|
||||||
<input v-model.number="form.mtu" type="number" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
<input v-model.number="form.mtu" type="number" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
@@ -375,17 +417,19 @@ onMounted(() => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||||
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">用户</th>
|
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">用户</th>
|
||||||
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">分配 IP</th>
|
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">IPv4</th>
|
||||||
|
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">IPv6</th>
|
||||||
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">连接时间</th>
|
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">连接时间</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="!status?.clients?.length">
|
<tr v-if="!status?.clients?.length">
|
||||||
<td colspan="3" class="px-6 py-6 text-center text-gray-400">暂无在线客户端</td>
|
<td colspan="4" class="px-6 py-6 text-center text-gray-400">暂无在线客户端</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="(c, i) in status?.clients" :key="i" class="border-b border-gray-100 dark:border-gray-700/50">
|
<tr v-for="(c, i) in status?.clients" :key="i" class="border-b border-gray-100 dark:border-gray-700/50">
|
||||||
<td class="px-6 py-3 text-gray-900 dark:text-white font-medium">{{ c.username }}</td>
|
<td class="px-6 py-3 text-gray-900 dark:text-white font-medium">{{ c.username }}</td>
|
||||||
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip }}</td>
|
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip }}</td>
|
||||||
|
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip6 || '—' }}</td>
|
||||||
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ c.connected_at }}</td>
|
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ c.connected_at }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -402,18 +446,20 @@ onMounted(() => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||||
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">用户</th>
|
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">用户</th>
|
||||||
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">预留 IP</th>
|
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">IPv4</th>
|
||||||
|
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">IPv6</th>
|
||||||
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">创建时间</th>
|
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">创建时间</th>
|
||||||
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">操作</th>
|
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="!reservations.length">
|
<tr v-if="!reservations.length">
|
||||||
<td colspan="4" class="px-6 py-6 text-center text-gray-400">暂无预留</td>
|
<td colspan="5" class="px-6 py-6 text-center text-gray-400">暂无预留</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="r in reservations" :key="r.id" class="border-b border-gray-100 dark:border-gray-700/50">
|
<tr v-for="r in reservations" :key="r.id" class="border-b border-gray-100 dark:border-gray-700/50">
|
||||||
<td class="px-6 py-3 text-gray-900 dark:text-white font-medium">{{ r.username }}</td>
|
<td class="px-6 py-3 text-gray-900 dark:text-white font-medium">{{ r.username }}</td>
|
||||||
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ r.ip_address }}</td>
|
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ r.ip_address || '—' }}</td>
|
||||||
|
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ r.ip_address6 || '—' }}</td>
|
||||||
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ r.created_at }}</td>
|
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ r.created_at }}</td>
|
||||||
<td class="px-6 py-3">
|
<td class="px-6 py-3">
|
||||||
<button class="px-3 py-1 text-xs rounded-md font-medium text-red-700 bg-red-50 hover:bg-red-100 dark:text-red-400 dark:bg-red-900/20 transition-colors" @click="handleDeleteResv(r.id)">删除</button>
|
<button class="px-3 py-1 text-xs rounded-md font-medium text-red-700 bg-red-50 hover:bg-red-100 dark:text-red-400 dark:bg-red-900/20 transition-colors" @click="handleDeleteResv(r.id)">删除</button>
|
||||||
@@ -436,9 +482,13 @@ onMounted(() => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">IP 地址</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">IPv4 地址 (可选)</label>
|
||||||
<input v-model="resvForm.ip_address" type="text" placeholder="192.168.77.10" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
<input v-model="resvForm.ip_address" type="text" placeholder="192.168.77.10" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">IPv6 地址 (可选)</label>
|
||||||
|
<input v-model="resvForm.ip_address6" type="text" placeholder="fd00:dead:beef::10" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
<p v-if="resvError" class="text-sm text-red-500">{{ resvError }}</p>
|
<p v-if="resvError" class="text-sm text-red-500">{{ resvError }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-3 mt-6">
|
<div class="flex justify-end gap-3 mt-6">
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ set -e
|
|||||||
# 若后台修改了子网,需同步修改此处并重新执行脚本,或手动更新 iptables 规则
|
# 若后台修改了子网,需同步修改此处并重新执行脚本,或手动更新 iptables 规则
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
VPN_SUBNET="192.168.77.0/24"
|
VPN_SUBNET="192.168.77.0/24"
|
||||||
|
# IPv6 子网(留空则不配置 IPv6 NAT;与后台设置保持一致)
|
||||||
|
VPN_SUBNET6="fd00:dead:beef::/112"
|
||||||
|
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
echo "请使用 root 用户执行此脚本: sudo bash install_linux.sh"
|
echo "请使用 root 用户执行此脚本: sudo bash install_linux.sh"
|
||||||
@@ -42,10 +44,16 @@ chown -R lmvpn:lmvpn /opt/lmvpn
|
|||||||
echo ">>> 配置内核 IP 转发..."
|
echo ">>> 配置内核 IP 转发..."
|
||||||
# 临时生效
|
# 临时生效
|
||||||
sysctl -w net.ipv4.ip_forward=1 >/dev/null
|
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
|
cat > /etc/sysctl.d/99-lmvpn.conf << EOF
|
||||||
net.ipv4.ip_forward = 1
|
net.ipv4.ip_forward = 1
|
||||||
EOF
|
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
|
sysctl -p /etc/sysctl.d/99-lmvpn.conf >/dev/null
|
||||||
|
|
||||||
echo ">>> 配置 NAT 与转发规则..."
|
echo ">>> 配置 NAT 与转发规则..."
|
||||||
@@ -75,9 +83,16 @@ else
|
|||||||
nft add table inet lmvpn_nat
|
nft add table inet lmvpn_nat
|
||||||
nft 'add chain inet lmvpn_nat postrouting { type nat hook postrouting priority 100 ; }'
|
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
|
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 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 saddr "$VPN_SUBNET" accept
|
||||||
nft add rule inet lmvpn_nat forward ip daddr "$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 规则
|
# 持久化 nft 规则
|
||||||
mkdir -p /etc/nftables.d
|
mkdir -p /etc/nftables.d
|
||||||
@@ -99,6 +114,17 @@ else
|
|||||||
iptables -A FORWARD -s "$VPN_SUBNET" -j ACCEPT
|
iptables -A FORWARD -s "$VPN_SUBNET" -j ACCEPT
|
||||||
iptables -A FORWARD -d "$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 规则
|
# 持久化 iptables 规则
|
||||||
if command -v netfilter-persistent >/dev/null 2>&1; then
|
if command -v netfilter-persistent >/dev/null 2>&1; then
|
||||||
netfilter-persistent save
|
netfilter-persistent save
|
||||||
@@ -106,6 +132,10 @@ else
|
|||||||
elif command -v iptables-save >/dev/null 2>&1; then
|
elif command -v iptables-save >/dev/null 2>&1; then
|
||||||
mkdir -p /etc/iptables
|
mkdir -p /etc/iptables
|
||||||
iptables-save > /etc/iptables/rules.v4
|
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 规则已写入 /etc/iptables/rules.v4"
|
||||||
echo "注意: 重启后规则持久化需配合 iptables-persistent 包,建议安装: apt install iptables-persistent"
|
echo "注意: 重启后规则持久化需配合 iptables-persistent 包,建议安装: apt install iptables-persistent"
|
||||||
else
|
else
|
||||||
|
|||||||
+143
-51
@@ -16,6 +16,7 @@ import (
|
|||||||
type vpnSettingsResponse struct {
|
type vpnSettingsResponse struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Subnet string `json:"subnet"`
|
Subnet string `json:"subnet"`
|
||||||
|
Subnet6 string `json:"subnet6"`
|
||||||
MTU int `json:"mtu"`
|
MTU int `json:"mtu"`
|
||||||
InterfaceName string `json:"interface_name"`
|
InterfaceName string `json:"interface_name"`
|
||||||
AllowClientToClient bool `json:"allow_client_to_client"`
|
AllowClientToClient bool `json:"allow_client_to_client"`
|
||||||
@@ -26,6 +27,7 @@ type vpnSettingsResponse struct {
|
|||||||
type updateVpnSettingsRequest struct {
|
type updateVpnSettingsRequest struct {
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
Subnet *string `json:"subnet"`
|
Subnet *string `json:"subnet"`
|
||||||
|
Subnet6 *string `json:"subnet6"`
|
||||||
MTU *int `json:"mtu"`
|
MTU *int `json:"mtu"`
|
||||||
InterfaceName *string `json:"interface_name"`
|
InterfaceName *string `json:"interface_name"`
|
||||||
AllowClientToClient *bool `json:"allow_client_to_client"`
|
AllowClientToClient *bool `json:"allow_client_to_client"`
|
||||||
@@ -39,16 +41,22 @@ func loadVpnSettings() (model.VpnSetting, error) {
|
|||||||
return s, err
|
return s, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadReservationsMap() (map[uint]string, error) {
|
func loadReservationsMap() (map[uint]string, map[uint]string, error) {
|
||||||
var rows []model.VpnReservation
|
var rows []model.VpnReservation
|
||||||
if err := db.DB.Find(&rows).Error; err != nil {
|
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 {
|
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 {
|
func ApplyVpnFromDB(svc *vpn.VpnService) error {
|
||||||
@@ -56,11 +64,11 @@ func ApplyVpnFromDB(svc *vpn.VpnService) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
reservations, err := loadReservationsMap()
|
resv4, resv6, err := loadReservationsMap()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return svc.ApplySettings(s, reservations)
|
return svc.ApplySettings(s, resv4, resv6)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateSubnet(subnet string) error {
|
func validateSubnet(subnet string) error {
|
||||||
@@ -78,12 +86,30 @@ func validateSubnet(subnet string) error {
|
|||||||
return nil
|
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 (
|
var (
|
||||||
errIPv4Only = errStr("仅支持 IPv4 子网")
|
errIPv4Only = errStr("仅支持 IPv4 子网")
|
||||||
|
errIPv6Only = errStr("IPv6 子网不能使用 IPv4 地址")
|
||||||
errSubnetTooSmall = errStr("子网前缀长度不能大于 /30")
|
errSubnetTooSmall = errStr("子网前缀长度不能大于 /30")
|
||||||
|
errSubnet6Range = errStr("IPv6 子网前缀长度应在 /64 到 /126 之间")
|
||||||
errIPNotInSubnet = errStr("IP 不在子网范围内")
|
errIPNotInSubnet = errStr("IP 不在子网范围内")
|
||||||
errIPReserved = errStr("该 IP 已被预留")
|
errIPReserved = errStr("该 IP 已被预留")
|
||||||
errIPIsServer = errStr("该 IP 为服务器 IP,不可预留")
|
errIPIsServer = errStr("该 IP 为服务器 IP,不可预留")
|
||||||
|
errNeedAtLeastOne = errStr("至少需要填写 IPv4 或 IPv6 地址之一")
|
||||||
)
|
)
|
||||||
|
|
||||||
type errStr string
|
type errStr string
|
||||||
@@ -99,6 +125,7 @@ func GetVpnSettings(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, vpnSettingsResponse{
|
c.JSON(http.StatusOK, vpnSettingsResponse{
|
||||||
Enabled: s.Enabled,
|
Enabled: s.Enabled,
|
||||||
Subnet: s.Subnet,
|
Subnet: s.Subnet,
|
||||||
|
Subnet6: s.Subnet6,
|
||||||
MTU: s.MTU,
|
MTU: s.MTU,
|
||||||
InterfaceName: s.InterfaceName,
|
InterfaceName: s.InterfaceName,
|
||||||
AllowClientToClient: s.AllowClientToClient,
|
AllowClientToClient: s.AllowClientToClient,
|
||||||
@@ -127,6 +154,15 @@ func UpdateVpnSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
s.Subnet = *req.Subnet
|
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 != nil {
|
||||||
if *req.MTU < 500 || *req.MTU > 65535 {
|
if *req.MTU < 500 || *req.MTU > 65535 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "MTU 范围 500-65535"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "MTU 范围 500-65535"})
|
||||||
@@ -163,11 +199,13 @@ func UpdateVpnSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type vpnStatusResponse struct {
|
type vpnStatusResponse struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Online int `json:"online"`
|
Online int `json:"online"`
|
||||||
UsedIPs int `json:"used_ips"`
|
UsedIPs int `json:"used_ips"`
|
||||||
Capacity uint64 `json:"capacity"`
|
Capacity uint64 `json:"capacity"`
|
||||||
Clients []vpn.ClientInfo `json:"clients"`
|
UsedIPs6 int `json:"used_ips6,omitempty"`
|
||||||
|
Capacity6 uint64 `json:"capacity6,omitempty"`
|
||||||
|
Clients []vpn.ClientInfo `json:"clients"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetVpnStatus(c *gin.Context) {
|
func GetVpnStatus(c *gin.Context) {
|
||||||
@@ -176,14 +214,16 @@ func GetVpnStatus(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载设置失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载设置失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
used, cap := vpn.VPN.AllocStats()
|
used4, cap4, used6, cap6 := vpn.VPN.AllocStats()
|
||||||
clients := vpn.VPN.ClientList()
|
clients := vpn.VPN.ClientList()
|
||||||
c.JSON(http.StatusOK, vpnStatusResponse{
|
c.JSON(http.StatusOK, vpnStatusResponse{
|
||||||
Enabled: s.Enabled,
|
Enabled: s.Enabled,
|
||||||
Online: len(clients),
|
Online: len(clients),
|
||||||
UsedIPs: used,
|
UsedIPs: used4,
|
||||||
Capacity: cap,
|
Capacity: cap4,
|
||||||
Clients: clients,
|
UsedIPs6: used6,
|
||||||
|
Capacity6: cap6,
|
||||||
|
Clients: clients,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,11 +232,12 @@ func GetVpnDiag(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type reservationResponse struct {
|
type reservationResponse struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
UserID uint `json:"user_id"`
|
UserID uint `json:"user_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
IPAddress string `json:"ip_address"`
|
IPAddress string `json:"ip_address"`
|
||||||
CreatedAt string `json:"created_at"`
|
IPAddress6 string `json:"ip_address6,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListVpnReservations(c *gin.Context) {
|
func ListVpnReservations(c *gin.Context) {
|
||||||
@@ -220,19 +261,21 @@ func ListVpnReservations(c *gin.Context) {
|
|||||||
out := make([]reservationResponse, len(rows))
|
out := make([]reservationResponse, len(rows))
|
||||||
for i, r := range rows {
|
for i, r := range rows {
|
||||||
out[i] = reservationResponse{
|
out[i] = reservationResponse{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
UserID: r.UserID,
|
UserID: r.UserID,
|
||||||
Username: nameMap[r.UserID],
|
Username: nameMap[r.UserID],
|
||||||
IPAddress: r.IPAddress,
|
IPAddress: r.IPAddress,
|
||||||
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
|
IPAddress6: r.IPAddress6,
|
||||||
|
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"reservations": out})
|
c.JSON(http.StatusOK, gin.H{"reservations": out})
|
||||||
}
|
}
|
||||||
|
|
||||||
type createReservationRequest struct {
|
type createReservationRequest struct {
|
||||||
UserID uint `json:"user_id" binding:"required"`
|
UserID uint `json:"user_id" binding:"required"`
|
||||||
IPAddress string `json:"ip_address" binding:"required"`
|
IPAddress string `json:"ip_address"`
|
||||||
|
IPAddress6 string `json:"ip_address6"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateVpnReservation(c *gin.Context) {
|
func CreateVpnReservation(c *gin.Context) {
|
||||||
@@ -242,6 +285,11 @@ func CreateVpnReservation(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.IPAddress == "" && req.IPAddress6 == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": errNeedAtLeastOne.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var user model.User
|
var user model.User
|
||||||
if err := db.DB.First(&user, req.UserID).Error; err != nil {
|
if err := db.DB.First(&user, req.UserID).Error; err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "用户不存在"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "用户不存在"})
|
||||||
@@ -253,42 +301,86 @@ func CreateVpnReservation(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载设置失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载设置失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, ipNet, err := net.ParseCIDR(s.Subnet)
|
|
||||||
if err != nil {
|
// validate v4
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "子网配置错误"})
|
if req.IPAddress != "" {
|
||||||
return
|
_, ipNet, err := net.ParseCIDR(s.Subnet)
|
||||||
}
|
if err != nil {
|
||||||
ip := net.ParseIP(req.IPAddress)
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "子网配置错误"})
|
||||||
if ip == nil || !ipNet.Contains(ip) {
|
return
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": errIPNotInSubnet.Error()})
|
}
|
||||||
return
|
ip := net.ParseIP(req.IPAddress)
|
||||||
}
|
if ip == nil || !ipNet.Contains(ip) {
|
||||||
serverIP, _ := cidr.Host(ipNet, 1)
|
c.JSON(http.StatusBadRequest, gin.H{"error": errIPNotInSubnet.Error()})
|
||||||
if ip.Equal(serverIP) {
|
return
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": errIPIsServer.Error()})
|
}
|
||||||
return
|
serverIP, _ := cidr.Host(ipNet, 1)
|
||||||
|
if ip.Equal(serverIP) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": errIPIsServer.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var count int64
|
// validate v6
|
||||||
db.DB.Model(&model.VpnReservation{}).Where("ip_address = ?", req.IPAddress).Count(&count)
|
if req.IPAddress6 != "" {
|
||||||
if count > 0 {
|
if s.Subnet6 == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": errIPReserved.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "服务端未配置 IPv6 子网"})
|
||||||
return
|
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
|
var existUser model.VpnReservation
|
||||||
if err := db.DB.Where("user_id = ?", req.UserID).First(&existUser).Error; err == nil {
|
if err := db.DB.Where("user_id = ?", req.UserID).First(&existUser).Error; err == nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "该用户已有预留 IP"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "该用户已有预留 IP"})
|
||||||
return
|
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 {
|
if err := db.DB.Create(&r).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建预留失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建预留失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if vpn.VPN.Running() {
|
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": "预留已创建"})
|
c.JSON(http.StatusOK, gin.H{"message": "预留已创建"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type VpnSetting struct {
|
|||||||
ID uint `gorm:"primaryKey"`
|
ID uint `gorm:"primaryKey"`
|
||||||
Enabled bool `gorm:"default:false"`
|
Enabled bool `gorm:"default:false"`
|
||||||
Subnet string `gorm:"size:64;not null"`
|
Subnet string `gorm:"size:64;not null"`
|
||||||
|
Subnet6 string `gorm:"size:64"`
|
||||||
MTU int `gorm:"default:1420"`
|
MTU int `gorm:"default:1420"`
|
||||||
InterfaceName string `gorm:"size:16"`
|
InterfaceName string `gorm:"size:16"`
|
||||||
AllowClientToClient bool `gorm:"default:false"`
|
AllowClientToClient bool `gorm:"default:false"`
|
||||||
@@ -21,10 +22,11 @@ func (VpnSetting) TableName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type VpnReservation struct {
|
type VpnReservation struct {
|
||||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||||
UserID uint `gorm:"uniqueIndex;not null"`
|
UserID uint `gorm:"uniqueIndex;not null"`
|
||||||
IPAddress string `gorm:"size:64;uniqueIndex;not null"`
|
IPAddress string `gorm:"size:64;uniqueIndex"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
IPAddress6 string `gorm:"size:64"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (VpnReservation) TableName() string {
|
func (VpnReservation) TableName() string {
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ func (m *AllocationManager) Allocate(userID uint) (net.IP, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
count := cidr.AddressCount(m.net)
|
count := cidr.AddressCount(m.net)
|
||||||
|
if count == 0 && m.net.IP.To4() == nil {
|
||||||
|
count = 65536
|
||||||
|
}
|
||||||
maxIndex := int(count - 1)
|
maxIndex := int(count - 1)
|
||||||
for i := 2; i < maxIndex; i++ {
|
for i := 2; i < maxIndex; i++ {
|
||||||
ip, err := cidr.Host(m.net, i)
|
ip, err := cidr.Host(m.net, i)
|
||||||
@@ -105,6 +108,9 @@ func (m *AllocationManager) UsedCount() int {
|
|||||||
|
|
||||||
func (m *AllocationManager) Capacity() uint64 {
|
func (m *AllocationManager) Capacity() uint64 {
|
||||||
count := cidr.AddressCount(m.net)
|
count := cidr.AddressCount(m.net)
|
||||||
|
if count == 0 && m.net.IP.To4() == nil {
|
||||||
|
return 65533
|
||||||
|
}
|
||||||
if count < 3 {
|
if count < 3 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-11
@@ -6,17 +6,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type DiagResult struct {
|
type DiagResult struct {
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
IsRoot bool `json:"is_root"`
|
IsRoot bool `json:"is_root"`
|
||||||
HasCapNetAdmin *bool `json:"has_cap_net_admin"`
|
HasCapNetAdmin *bool `json:"has_cap_net_admin"`
|
||||||
CapNetAdminNote string `json:"cap_net_admin_note,omitempty"`
|
CapNetAdminNote string `json:"cap_net_admin_note,omitempty"`
|
||||||
IPForward *bool `json:"ip_forward"`
|
IPForward *bool `json:"ip_forward"`
|
||||||
IPForwardNote string `json:"ip_forward_note,omitempty"`
|
IPForwardNote string `json:"ip_forward_note,omitempty"`
|
||||||
Masquerade *bool `json:"masquerade"`
|
Masquerade *bool `json:"masquerade"`
|
||||||
MasqueradeNote string `json:"masquerade_note,omitempty"`
|
MasqueradeNote string `json:"masquerade_note,omitempty"`
|
||||||
TUNCreate string `json:"tun_create"`
|
IP6Forward *bool `json:"ip6_forward"`
|
||||||
TUNRunning bool `json:"tun_running"`
|
IP6ForwardNote string `json:"ip6_forward_note,omitempty"`
|
||||||
TUNName string `json:"tun_name,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 {
|
func Diag(svc *VpnService) DiagResult {
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ func fillPlatformDiag(r *DiagResult) {
|
|||||||
m, note := checkMasquerade()
|
m, note := checkMasquerade()
|
||||||
r.Masquerade = m
|
r.Masquerade = m
|
||||||
r.MasqueradeNote = note
|
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 }
|
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"
|
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 规则"
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,4 +9,8 @@ func fillPlatformDiag(r *DiagResult) {
|
|||||||
r.IPForwardNote = "仅 Linux 适用"
|
r.IPForwardNote = "仅 Linux 适用"
|
||||||
r.Masquerade = nil
|
r.Masquerade = nil
|
||||||
r.MasqueradeNote = "仅 Linux 适用"
|
r.MasqueradeNote = "仅 Linux 适用"
|
||||||
|
r.IP6Forward = nil
|
||||||
|
r.IP6ForwardNote = "仅 Linux 适用"
|
||||||
|
r.Masquerade6 = nil
|
||||||
|
r.Masquerade6Note = "仅 Linux 适用"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package vpn
|
package vpn
|
||||||
|
|
||||||
type initMessage struct {
|
type initMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
Prefix int `json:"prefix"`
|
Prefix int `json:"prefix"`
|
||||||
MTU int `json:"mtu"`
|
MTU int `json:"mtu"`
|
||||||
ServerIP string `json:"server_ip"`
|
ServerIP string `json:"server_ip"`
|
||||||
|
IP6 string `json:"ip6,omitempty"`
|
||||||
|
Prefix6 int `json:"prefix6,omitempty"`
|
||||||
|
ServerIP6 string `json:"server_ip6,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type controlMessage struct {
|
type controlMessage struct {
|
||||||
|
|||||||
+97
-12
@@ -19,6 +19,10 @@ type VpnService struct {
|
|||||||
serverIP net.IP
|
serverIP net.IP
|
||||||
prefix int
|
prefix int
|
||||||
alloc *AllocationManager
|
alloc *AllocationManager
|
||||||
|
net6 *net.IPNet
|
||||||
|
serverIP6 net.IP
|
||||||
|
prefix6 int
|
||||||
|
alloc6 *AllocationManager
|
||||||
switchx *PacketSwitch
|
switchx *PacketSwitch
|
||||||
tun *TUNInterface
|
tun *TUNInterface
|
||||||
tunDone chan struct{}
|
tunDone chan struct{}
|
||||||
@@ -57,7 +61,7 @@ func (s *VpnService) parseNet(subnet string) (*net.IPNet, net.IP, int, error) {
|
|||||||
return ipNet, serverIP, ones, nil
|
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()
|
s.mu.Lock()
|
||||||
if s.running {
|
if s.running {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -76,6 +80,17 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations map[u
|
|||||||
return err
|
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)
|
tun, err := CreateTUN(settings.InterfaceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -84,11 +99,22 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations map[u
|
|||||||
if settings.DoLocalIPConfig {
|
if settings.DoLocalIPConfig {
|
||||||
if err := tun.Configure(serverIP, prefix, nil); err != nil {
|
if err := tun.Configure(serverIP, prefix, nil); err != nil {
|
||||||
_ = tun.Close()
|
_ = tun.Close()
|
||||||
return fmt.Errorf("配置 TUN 失败: %w", err)
|
return fmt.Errorf("配置 TUN IPv4 失败: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := tun.AddSubnetRoute(ipNet); err != nil {
|
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 {
|
if err := tun.SetMTU(settings.MTU); err != nil {
|
||||||
log.Printf("警告: 设置 MTU 失败: %v", err)
|
log.Printf("警告: 设置 MTU 失败: %v", err)
|
||||||
@@ -98,7 +124,11 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations map[u
|
|||||||
s.net = ipNet
|
s.net = ipNet
|
||||||
s.serverIP = serverIP
|
s.serverIP = serverIP
|
||||||
s.prefix = prefix
|
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.switchx = NewPacketSwitch(settings.AllowClientToClient)
|
||||||
s.tun = tun
|
s.tun = tun
|
||||||
s.tunDone = make(chan struct{})
|
s.tunDone = make(chan struct{})
|
||||||
@@ -106,7 +136,10 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations map[u
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
go s.serveTUN()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,14 +195,27 @@ func (s *VpnService) Stop() error {
|
|||||||
return nil
|
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()
|
s.mu.RLock()
|
||||||
alloc := s.alloc
|
alloc := s.alloc
|
||||||
|
alloc6 := s.alloc6
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
if alloc == nil {
|
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 {
|
func (s *VpnService) WriteToTUN(packet []byte) error {
|
||||||
@@ -209,6 +255,9 @@ func (s *VpnService) unregisterClient(c *tunnelConn) {
|
|||||||
if s.alloc != nil {
|
if s.alloc != nil {
|
||||||
s.alloc.Release(c.assignedIP)
|
s.alloc.Release(c.assignedIP)
|
||||||
}
|
}
|
||||||
|
if s.alloc6 != nil && c.assignedIP6 != nil {
|
||||||
|
s.alloc6.Release(c.assignedIP6)
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,14 +273,36 @@ func (s *VpnService) Prefix() int {
|
|||||||
return s.prefix
|
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()
|
s.mu.RLock()
|
||||||
alloc := s.alloc
|
alloc := s.alloc
|
||||||
|
alloc6 := s.alloc6
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
if alloc == nil {
|
if alloc != nil {
|
||||||
return 0, 0
|
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) {
|
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) {
|
func (s *VpnService) RemoveReservation(userID uint) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
alloc := s.alloc
|
alloc := s.alloc
|
||||||
|
alloc6 := s.alloc6
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
if alloc != nil {
|
if alloc != nil {
|
||||||
alloc.RemoveReservation(userID)
|
alloc.RemoveReservation(userID)
|
||||||
}
|
}
|
||||||
|
if alloc6 != nil {
|
||||||
|
alloc6.RemoveReservation(userID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *VpnService) ClientList() []ClientInfo {
|
func (s *VpnService) ClientList() []ClientInfo {
|
||||||
@@ -265,6 +349,7 @@ func (s *VpnService) ClientList() []ClientInfo {
|
|||||||
type ClientInfo struct {
|
type ClientInfo struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
|
IP6 string `json:"ip6,omitempty"`
|
||||||
ConnectedAt string `json:"connected_at"`
|
ConnectedAt string `json:"connected_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-8
@@ -10,6 +10,7 @@ import (
|
|||||||
type SwitchConn interface {
|
type SwitchConn interface {
|
||||||
WritePacket(data []byte) error
|
WritePacket(data []byte) error
|
||||||
AssignedIP() net.IP
|
AssignedIP() net.IP
|
||||||
|
AssignedIP6() net.IP
|
||||||
}
|
}
|
||||||
|
|
||||||
type ipKey [16]byte
|
type ipKey [16]byte
|
||||||
@@ -40,17 +41,24 @@ func (s *PacketSwitch) SetAllowClientToClient(v bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *PacketSwitch) Register(c SwitchConn) {
|
func (s *PacketSwitch) Register(c SwitchConn) {
|
||||||
k := ipToKey(c.AssignedIP())
|
|
||||||
s.mu.Lock()
|
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()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PacketSwitch) Unregister(c SwitchConn) {
|
func (s *PacketSwitch) Unregister(c SwitchConn) {
|
||||||
k := ipToKey(c.AssignedIP())
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if cur, ok := s.table[k]; ok && cur == c {
|
if cur, ok := s.table[ipToKey(c.AssignedIP())]; ok && cur == c {
|
||||||
delete(s.table, k)
|
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()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
@@ -110,9 +118,18 @@ func (s *PacketSwitch) RouteFromClient(src SwitchConn, packet []byte) []SwitchCo
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// anti-spoof: enforce assigned source IP
|
// anti-spoof: enforce assigned source IP by version
|
||||||
if srcIP != nil && !srcIP.Equal(src.AssignedIP()) {
|
if srcIP != nil {
|
||||||
return 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 dest.IsGlobalUnicast() {
|
||||||
if c := s.findByIP(dest); c != nil && s.allowC2C() {
|
if c := s.findByIP(dest); c != nil && s.allowC2C() {
|
||||||
|
|||||||
+30
-15
@@ -28,18 +28,20 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type tunnelConn struct {
|
type tunnelConn struct {
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
user *model.User
|
user *model.User
|
||||||
svc *VpnService
|
svc *VpnService
|
||||||
assignedIP net.IP
|
assignedIP net.IP
|
||||||
connectedAt time.Time
|
assignedIP6 net.IP
|
||||||
writeMu sync.Mutex
|
connectedAt time.Time
|
||||||
ready atomic.Bool
|
writeMu sync.Mutex
|
||||||
rxBytes atomic.Int64
|
ready atomic.Bool
|
||||||
txBytes atomic.Int64
|
rxBytes atomic.Int64
|
||||||
|
txBytes atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *tunnelConn) AssignedIP() net.IP { return c.assignedIP }
|
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 {
|
func (c *tunnelConn) WritePacket(data []byte) error {
|
||||||
if !c.ready.Load() || len(data) == 0 {
|
if !c.ready.Load() || len(data) == 0 {
|
||||||
@@ -73,11 +75,15 @@ func (c *tunnelConn) close() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *tunnelConn) info() ClientInfo {
|
func (c *tunnelConn) info() ClientInfo {
|
||||||
return ClientInfo{
|
ci := ClientInfo{
|
||||||
Username: c.user.Username,
|
Username: c.user.Username,
|
||||||
IP: c.assignedIP.String(),
|
IP: c.assignedIP.String(),
|
||||||
ConnectedAt: c.connectedAt.Format("2006-01-02 15:04:05"),
|
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) {
|
func runTunnel(conn *websocket.Conn, user *model.User) {
|
||||||
@@ -106,7 +112,7 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
|
|||||||
activeConnsMu.Unlock()
|
activeConnsMu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
ip, err := VPN.Allocate(user)
|
ip4, ip6, err := VPN.Allocate(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = sendJSON(conn, controlMessage{Type: "error", Message: "分配 IP 失败: " + err.Error()})
|
_ = sendJSON(conn, controlMessage{Type: "error", Message: "分配 IP 失败: " + err.Error()})
|
||||||
return
|
return
|
||||||
@@ -116,7 +122,8 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
|
|||||||
conn: conn,
|
conn: conn,
|
||||||
user: user,
|
user: user,
|
||||||
svc: VPN,
|
svc: VPN,
|
||||||
assignedIP: ip,
|
assignedIP: ip4,
|
||||||
|
assignedIP6: ip6,
|
||||||
connectedAt: time.Now(),
|
connectedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,17 +133,25 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
|
|||||||
settings := VPN.Settings()
|
settings := VPN.Settings()
|
||||||
initMsg := initMessage{
|
initMsg := initMessage{
|
||||||
Type: "init",
|
Type: "init",
|
||||||
IP: ip.String(),
|
IP: ip4.String(),
|
||||||
Prefix: VPN.Prefix(),
|
Prefix: VPN.Prefix(),
|
||||||
MTU: settings.MTU,
|
MTU: settings.MTU,
|
||||||
ServerIP: VPN.ServerIP().String(),
|
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 {
|
if err := tc.writeControl(initMsg); err != nil {
|
||||||
log.Printf("用户 %s 发送 init 失败: %v", user.Username, err)
|
log.Printf("用户 %s 发送 init 失败: %v", user.Username, err)
|
||||||
return
|
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.SetReadLimit(maxMessageSize)
|
||||||
conn.SetReadDeadline(time.Now().Add(readyTimeout))
|
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() {
|
if msg.Type == "ready" && !tc.ready.Load() {
|
||||||
tc.ready.Store(true)
|
tc.ready.Store(true)
|
||||||
conn.SetReadDeadline(time.Now().Add(readTimeout))
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user