feat: 新增 IPv6 双栈支持

- 协议: InitMessage 增加 ip6/prefix6/server_ip6 字段(omitempty,向后兼容)
- TUN: Device 接口新增 ConfigureIPv6, macOS 用 ifconfig inet6, Linux 用 ip -6 addr add
- 路由: full 模式自动添加 ::/1+8000::/1, v6 服务器旁路 /128, resolveHosts 双栈解析
- 会话: setupTUN 解析 v6 字段并配置 TUN+路由, connectOnce 传 v6 给 stats/日志
- 统计: SetConnected(ip,ip6), Snapshot.AssignedIP6
- DB: connection_logs 增加 assigned_ip6 列 + migrateV3 幂等迁移
- UI: 状态卡片新增独立 IPv6 行, 始终可见(未获取显示 IPv6: —)
- 文档: 同步 client-development.md 至服务端 IPv6 版本
This commit is contained in:
2026-07-07 12:24:20 +08:00
parent e867c8e248
commit 8f6daf9f49
18 changed files with 478 additions and 148 deletions
+137 -37
View File
@@ -1,13 +1,16 @@
// Package route manages VPN routing on the client. It supports three
// modes:
//
// - Full tunnel: all traffic (0.0.0.0/0) via the TUN interface,
// with a bypass route for the server's public IP so
// the WebSocket connection stays on the physical NIC
// - Split tunnel: only the VPN virtual subnet via the TUN interface
// - Full tunnel: all traffic (0.0.0.0/0 and ::/0) via the TUN
// interface, with bypass routes for the server's
// public IP (v4 and v6) so the WebSocket connection
// stays on the physical NIC
// - Split tunnel: only the VPN virtual subnet (v4 and v6) via TUN
// - Custom: user-specified CIDRs via the TUN interface
//
// All routes are tracked so they can be cleanly removed on disconnect.
// IPv6 routes are applied automatically when the server assigned an
// IPv6 address (Config.VPNIP6 != ""). All routes are tracked so they
// can be cleanly removed on disconnect.
package route
import (
@@ -29,8 +32,10 @@ const (
type Config struct {
Mode Mode
InterfaceName string // e.g. "utun4"
VPNIP string // assigned tunnel IP, e.g. "192.168.77.5"
VPNPrefix int // subnet prefix, e.g. 24
VPNIP string // assigned tunnel IPv4, e.g. "192.168.77.5"
VPNPrefix int // IPv4 subnet prefix, e.g. 24
VPNIP6 string // assigned tunnel IPv6 (empty = v4-only)
VPNPrefix6 int // IPv6 subnet prefix
ServerHost string // server hostname/IP (for full-tunnel bypass)
CustomCIDRs []string // for ModeCustom
}
@@ -38,10 +43,15 @@ type Config struct {
// Manager applies and removes routes. It tracks all added routes so
// they can be cleaned up deterministically.
type Manager struct {
cfg Config
addedRoutes []string // route specs added, for deletion
serverBypass bool
originalGateway string
cfg Config
addedRoutes []string // v4 route specs added, for deletion
addedRoutes6 []string // v6 route specs added, for deletion
serverBypass bool
serverBypass6 bool
originalGateway string
originalGateway6 string
serverIP string // resolved v4 (for bypass delete)
serverIP6 string // resolved v6 (for bypass delete)
}
// NewManager creates a route manager for the given configuration.
@@ -72,12 +82,24 @@ func (m *Manager) Cleanup() error {
}
}
m.addedRoutes = nil
for _, r := range m.addedRoutes6 {
if err := deleteRoute6(r, m.cfg.InterfaceName); err != nil {
errs = append(errs, err.Error())
}
}
m.addedRoutes6 = nil
if m.serverBypass {
if err := m.deleteServerBypass(); err != nil {
errs = append(errs, err.Error())
}
m.serverBypass = false
}
if m.serverBypass6 {
if err := m.deleteServerBypass6(); err != nil {
errs = append(errs, err.Error())
}
m.serverBypass6 = false
}
if len(errs) > 0 {
return fmt.Errorf("cleanup errors: %s", strings.Join(errs, "; "))
}
@@ -85,26 +107,44 @@ func (m *Manager) Cleanup() error {
}
func (m *Manager) applyFull() error {
// Capture the current default gateway before modifying routes.
// Capture the current default gateways before modifying routes.
gw, err := defaultGateway()
if err != nil {
return fmt.Errorf("get default gateway: %w", err)
}
m.originalGateway = gw
// Resolve server host to an IP for the bypass route.
serverIP, err := resolveHost(m.cfg.ServerHost)
// IPv6 default gateway is best-effort: it may be absent on v4-only
// networks, in which case v6 bypass/routing is skipped.
gw6, _ := defaultGateway6()
m.originalGateway6 = gw6
// Resolve server host to v4 + v6 IPs for bypass routes.
v4, v6, err := resolveHosts(m.cfg.ServerHost)
if err != nil {
return fmt.Errorf("resolve server host %s: %w", m.cfg.ServerHost, err)
}
m.serverIP = v4
m.serverIP6 = v6
// Bypass: server's public IP via the original gateway (so the WS
// Bypass: server's public IPv4 via the original gateway (so the WS
// connection doesn't loop through the tunnel).
bypassSpec := serverIP + "/32"
if err := addRouteVia(bypassSpec, gw); err != nil {
return fmt.Errorf("add server bypass route: %w", err)
if v4 != "" {
bypassSpec := v4 + "/32"
if err := addRouteVia(bypassSpec, gw); err != nil {
return fmt.Errorf("add server bypass route: %w", err)
}
m.serverBypass = true
}
// Bypass: server's public IPv6 via the original v6 gateway.
if v6 != "" && gw6 != "" {
bypassSpec := v6 + "/128"
if err := addRouteVia6(bypassSpec, gw6); err != nil {
return fmt.Errorf("add server bypass6 route: %w", err)
}
m.serverBypass6 = true
}
m.serverBypass = true
// Two /1 routes cover the entire IPv4 space and are more specific
// than the default route (0.0.0.0/0), so they take precedence
@@ -115,15 +155,35 @@ func (m *Manager) applyFull() error {
}
m.addedRoutes = append(m.addedRoutes, cidr)
}
// IPv6 full tunnel: ::/1 + 8000::/1 cover the entire IPv6 space,
// more specific than ::/0. Only applied when the server assigned
// an IPv6 address.
if m.cfg.VPNIP6 != "" {
for _, cidr := range []string{"::/1", "8000::/1"} {
if err := addRoute6(cidr, m.cfg.InterfaceName); err != nil {
return fmt.Errorf("add route6 %s: %w", cidr, err)
}
m.addedRoutes6 = append(m.addedRoutes6, cidr)
}
}
return nil
}
func (m *Manager) applySplit() error {
subnet := vpnSubnet(m.cfg.VPNIP, m.cfg.VPNPrefix)
subnet := vpnSubnet(m.cfg.VPNIP, m.cfg.VPNPrefix, false)
if err := addRoute(subnet, m.cfg.InterfaceName); err != nil {
return fmt.Errorf("add split route %s: %w", subnet, err)
}
m.addedRoutes = append(m.addedRoutes, subnet)
if m.cfg.VPNIP6 != "" {
subnet6 := vpnSubnet(m.cfg.VPNIP6, m.cfg.VPNPrefix6, true)
if err := addRoute6(subnet6, m.cfg.InterfaceName); err != nil {
return fmt.Errorf("add split route6 %s: %w", subnet6, err)
}
m.addedRoutes6 = append(m.addedRoutes6, subnet6)
}
return nil
}
@@ -133,46 +193,86 @@ func (m *Manager) applyCustom() error {
if cidr == "" {
continue
}
if err := addRoute(cidr, m.cfg.InterfaceName); err != nil {
return fmt.Errorf("add custom route %s: %w", cidr, err)
if isIPv6CIDR(cidr) {
if err := addRoute6(cidr, m.cfg.InterfaceName); err != nil {
return fmt.Errorf("add custom route6 %s: %w", cidr, err)
}
m.addedRoutes6 = append(m.addedRoutes6, cidr)
} else {
if err := addRoute(cidr, m.cfg.InterfaceName); err != nil {
return fmt.Errorf("add custom route %s: %w", cidr, err)
}
m.addedRoutes = append(m.addedRoutes, cidr)
}
m.addedRoutes = append(m.addedRoutes, cidr)
}
return nil
}
func (m *Manager) deleteServerBypass() error {
serverIP, err := resolveHost(m.cfg.ServerHost)
if err != nil {
return nil // best-effort
if m.serverIP == "" {
return nil
}
return deleteRouteVia(serverIP+"/32", m.originalGateway)
return deleteRouteVia(m.serverIP+"/32", m.originalGateway)
}
func (m *Manager) deleteServerBypass6() error {
if m.serverIP6 == "" || m.originalGateway6 == "" {
return nil
}
return deleteRouteVia6(m.serverIP6+"/128", m.originalGateway6)
}
// vpnSubnet computes the network CIDR from an IP and prefix.
func vpnSubnet(ipStr string, prefix int) string {
func vpnSubnet(ipStr string, prefix int, ipv6 bool) string {
ip := net.ParseIP(ipStr)
if ip == nil {
return ipStr + "/" + fmt.Sprint(prefix)
}
mask := net.CIDRMask(prefix, 32)
bits := 32
if ipv6 {
bits = 128
}
mask := net.CIDRMask(prefix, bits)
network := ip.Mask(mask)
return fmt.Sprintf("%s/%d", network.String(), prefix)
}
// resolveHost resolves a hostname to an IP address. If already an IP,
// returns it directly.
func resolveHost(host string) (string, error) {
if net.ParseIP(host) != nil {
return host, nil
// resolveHosts resolves a hostname to its first IPv4 and IPv6 addresses.
// If host is already an IP literal, it is returned directly. Either
// result may be empty if no address of that family is available.
func resolveHosts(host string) (v4, v6 string, err error) {
if ip := net.ParseIP(host); ip != nil {
if ip.To4() != nil {
return ip.String(), "", nil
}
return "", ip.String(), nil
}
// Strip port if present.
if h, _, err := net.SplitHostPort(host); err == nil {
if h, _, e := net.SplitHostPort(host); e == nil {
host = h
}
ips, err := net.LookupIP(host)
if err != nil || len(ips) == 0 {
return "", fmt.Errorf("lookup %s: %w", host, err)
return "", "", fmt.Errorf("lookup %s: %w", host, err)
}
return ips[0].String(), nil
for _, ip := range ips {
if v4 == "" && ip.To4() != nil {
v4 = ip.String()
} else if v6 == "" && ip.To4() == nil {
v6 = ip.String()
}
}
if v4 == "" && v6 == "" {
return "", "", fmt.Errorf("lookup %s: no addresses", host)
}
return v4, v6, nil
}
// isIPv6CIDR reports whether the CIDR string is IPv6.
func isIPv6CIDR(cidr string) bool {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
return false
}
return ipNet.IP.To4() == nil
}
+35 -3
View File
@@ -9,7 +9,8 @@ import (
)
// addRoute adds a route via a network interface (macOS route command).
// route add -inet -net <cidr> -interface <iface>
//
// route add -inet -net <cidr> -interface <iface>
func addRoute(cidr, iface string) error {
return runRoute("add", "-inet", "-net", cidr, "-interface", iface)
}
@@ -29,13 +30,44 @@ func deleteRouteVia(cidr, gateway string) error {
return runRoute("delete", "-inet", "-net", cidr, gateway)
}
// defaultGateway returns the current default gateway IP.
// --- IPv6 variants ---
func addRoute6(cidr, iface string) error {
return runRoute("add", "-inet6", "-net", cidr, "-interface", iface)
}
func deleteRoute6(cidr, iface string) error {
return runRoute("delete", "-inet6", "-net", cidr, "-interface", iface)
}
func addRouteVia6(cidr, gateway string) error {
return runRoute("add", "-inet6", "-net", cidr, gateway)
}
func deleteRouteVia6(cidr, gateway string) error {
return runRoute("delete", "-inet6", "-net", cidr, gateway)
}
// defaultGateway returns the current IPv4 default gateway IP.
func defaultGateway() (string, error) {
out, err := exec.Command("route", "-n", "get", "default").Output()
if err != nil {
return "", err
}
for _, line := range strings.Split(string(out), "\n") {
return parseGateway(string(out))
}
// defaultGateway6 returns the current IPv6 default gateway IP.
func defaultGateway6() (string, error) {
out, err := exec.Command("route", "-n", "get", "-inet6", "default").Output()
if err != nil {
return "", err
}
return parseGateway(string(out))
}
func parseGateway(out string) (string, error) {
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "gateway:") {
parts := strings.SplitN(line, ":", 2)
+31 -1
View File
@@ -24,12 +24,42 @@ func deleteRouteVia(cidr, gateway string) error {
return runCmd("ip", "route", "del", cidr, "via", gateway)
}
// --- IPv6 variants ---
func addRoute6(cidr, iface string) error {
return runCmd("ip", "-6", "route", "add", cidr, "dev", iface)
}
func deleteRoute6(cidr, iface string) error {
return runCmd("ip", "-6", "route", "del", cidr, "dev", iface)
}
func addRouteVia6(cidr, gateway string) error {
return runCmd("ip", "-6", "route", "add", cidr, "via", gateway)
}
func deleteRouteVia6(cidr, gateway string) error {
return runCmd("ip", "-6", "route", "del", cidr, "via", gateway)
}
func defaultGateway() (string, error) {
out, err := exec.Command("ip", "route", "show", "default").Output()
if err != nil {
return "", err
}
fields := strings.Fields(strings.TrimSpace(string(out)))
return parseViaGateway(string(out))
}
func defaultGateway6() (string, error) {
out, err := exec.Command("ip", "-6", "route", "show", "default").Output()
if err != nil {
return "", err
}
return parseViaGateway(string(out))
}
func parseViaGateway(out string) (string, error) {
fields := strings.Fields(strings.TrimSpace(out))
for i, f := range fields {
if f == "via" && i+1 < len(fields) {
return fields[i+1], nil