From 44b51b3b04cdb4082fa4478be818429b5c96786b Mon Sep 17 00:00:00 2001 From: kevin Date: Fri, 3 Jul 2026 14:49:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=AC=AC=E4=B8=89?= =?UTF-8?q?=E5=B1=82=20TUN=20VPN=20=E6=9C=8D=E5=8A=A1=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=90=8E=E5=8F=B0=20IP=20=E5=88=86=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 TUN 设备层(water 库),分 linux/darwin 平台配置 IP/路由/MTU - 实现 IP 分配管理:动态池自动分配 + 按用户静态预留,支持热更新 - 实现 PacketSwitch 共享 TUN 包转发:源 IP 防伪、按目的 IP 查表转发、allow-c2c - 重写隧道:自研简化 WS 协议(文本帧 JSON 控制 init/ready,二进制帧=原始 IP 包) - VpnService 单例管理 TUN 生命周期,子网变更踢线重建,预留增删热更新 - 新增 vpn_settings/vpn_reservations 表,AutoMigrate + 默认设置 seed - 新增 Admin API:settings 读写、status、clients、reservations CRUD - 前端新增 VpnView(/admin/vpn):状态面板/设置表单/在线客户端/静态预留 - main.go 启动时按 DB 设置初始化 VPN 服务 --- frontend/src/router/index.ts | 6 + frontend/src/views/AdminView.vue | 1 + frontend/src/views/VpnView.vue | 339 +++++++++++++++++++++++++++++++ go.mod | 16 +- go.sum | 18 +- internal/db/db.go | 23 ++- internal/handler/vpn.go | 312 ++++++++++++++++++++++++++++ internal/model/vpn.go | 32 +++ internal/router/router.go | 7 + internal/vpn/alloc.go | 131 ++++++++++++ internal/vpn/protocol.go | 14 ++ internal/vpn/service.go | 269 ++++++++++++++++++++++++ internal/vpn/switch.go | 141 +++++++++++++ internal/vpn/tun.go | 42 ++++ internal/vpn/tun_darwin.go | 39 ++++ internal/vpn/tun_linux.go | 33 +++ internal/vpn/tun_other.go | 20 ++ internal/vpn/tunnel.go | 146 ++++++++++++- main.go | 7 + 19 files changed, 1571 insertions(+), 25 deletions(-) create mode 100644 frontend/src/views/VpnView.vue create mode 100644 internal/handler/vpn.go create mode 100644 internal/model/vpn.go create mode 100644 internal/vpn/alloc.go create mode 100644 internal/vpn/protocol.go create mode 100644 internal/vpn/service.go create mode 100644 internal/vpn/switch.go create mode 100644 internal/vpn/tun.go create mode 100644 internal/vpn/tun_darwin.go create mode 100644 internal/vpn/tun_linux.go create mode 100644 internal/vpn/tun_other.go diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 7a2d2f9..8c0e2d0 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -38,6 +38,12 @@ const router = createRouter({ component: () => import('../views/UserManageView.vue'), meta: { requiresAuth: true, adminOnly: true }, }, + { + path: '/admin/vpn', + name: 'vpn', + component: () => import('../views/VpnView.vue'), + meta: { requiresAuth: true, adminOnly: true }, + }, ], }) diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 889c364..7517ad3 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -11,6 +11,7 @@ const stats = ref([ { label: '今日流量', value: '--', unit: 'GB', icon: 'M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z', route: '' }, { label: '在线节点', value: '--', unit: '', icon: 'M5 12h14M12 5l7 7-7 7', route: '' }, { label: '用户总数', value: '--', unit: '', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z', route: '/admin/users' }, + { label: 'VPN 管理', value: '配置', unit: '', icon: 'M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 4.05A12.884 12.884 0 0015 11a4 4 0 10-8 0c0 1.017.07 2.019.203 3M3 3l18 18', route: '/admin/vpn' }, ]) const userCount = ref(null) diff --git a/frontend/src/views/VpnView.vue b/frontend/src/views/VpnView.vue new file mode 100644 index 0000000..b73f26d --- /dev/null +++ b/frontend/src/views/VpnView.vue @@ -0,0 +1,339 @@ + + + diff --git a/go.mod b/go.mod index eb7dab9..07c9b0f 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,17 @@ module lmvpn go 1.26.4 require ( + github.com/apparentlymart/go-cidr v1.1.1 github.com/gin-gonic/gin v1.12.0 + github.com/glebarez/sqlite v1.11.0 github.com/goccy/go-yaml v1.19.2 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.3.0 + github.com/gorilla/websocket v1.5.3 + github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 + golang.org/x/crypto v0.53.0 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.2 ) require ( @@ -17,15 +26,11 @@ require ( github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect - github.com/glebarez/sqlite v1.11.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.5 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect - github.com/google/uuid v1.3.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -42,13 +47,10 @@ require ( github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.53.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect google.golang.org/protobuf v1.36.10 // indirect - gorm.io/driver/mysql v1.6.0 // indirect - gorm.io/gorm v1.31.2 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect diff --git a/go.sum b/go.sum index 90730b6..689ee1a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/apparentlymart/go-cidr v1.1.1 h1:oEEk8CE0HP0YpHxsegk/TaOtR2FLHdWv4p3eM4ceUwg= +github.com/apparentlymart/go-cidr v1.1.1/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= @@ -42,6 +44,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -58,6 +62,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -74,6 +80,8 @@ github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRC github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8= +github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -95,21 +103,13 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= @@ -120,6 +120,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= diff --git a/internal/db/db.go b/internal/db/db.go index cce3e2a..550a1e1 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -40,10 +40,14 @@ func Init(cfg *config.DatabaseConfig) error { return fmt.Errorf("数据库连接失败: %w", err) } - if err := DB.AutoMigrate(&model.User{}, &model.Session{}); err != nil { + if err := DB.AutoMigrate(&model.User{}, &model.Session{}, &model.VpnSetting{}, &model.VpnReservation{}); err != nil { return fmt.Errorf("数据库迁移失败: %w", err) } + if err := seedDefaultVpnSettings(); err != nil { + return fmt.Errorf("初始化 VPN 设置失败: %w", err) + } + if err := seedDefaultAdmin(cfg); err != nil { return fmt.Errorf("创建默认管理员失败: %w", err) } @@ -98,6 +102,23 @@ func seedDefaultAdmin(cfg *config.DatabaseConfig) error { return nil } +func seedDefaultVpnSettings() error { + var s model.VpnSetting + if err := DB.First(&s, model.VpnSettingSingletonID).Error; err == nil { + return nil + } + s = model.VpnSetting{ + ID: model.VpnSettingSingletonID, + Enabled: false, + Subnet: "192.168.3.0/24", + MTU: 1420, + InterfaceName: "", + DoLocalIPConfig: true, + DoRemoteIPConfig: true, + } + return DB.Create(&s).Error +} + func generateRandomPassword(length int) (string, error) { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" b := make([]byte, length) diff --git a/internal/handler/vpn.go b/internal/handler/vpn.go new file mode 100644 index 0000000..f925cc4 --- /dev/null +++ b/internal/handler/vpn.go @@ -0,0 +1,312 @@ +package handler + +import ( + "net" + "net/http" + "strconv" + + "lmvpn/internal/db" + "lmvpn/internal/model" + "lmvpn/internal/vpn" + + "github.com/apparentlymart/go-cidr/cidr" + "github.com/gin-gonic/gin" +) + +type vpnSettingsResponse struct { + Enabled bool `json:"enabled"` + Subnet string `json:"subnet"` + MTU int `json:"mtu"` + InterfaceName string `json:"interface_name"` + AllowClientToClient bool `json:"allow_client_to_client"` + DoLocalIPConfig bool `json:"do_local_ip_config"` + DoRemoteIPConfig bool `json:"do_remote_ip_config"` +} + +type updateVpnSettingsRequest struct { + Enabled *bool `json:"enabled"` + Subnet *string `json:"subnet"` + MTU *int `json:"mtu"` + InterfaceName *string `json:"interface_name"` + AllowClientToClient *bool `json:"allow_client_to_client"` + DoLocalIPConfig *bool `json:"do_local_ip_config"` + DoRemoteIPConfig *bool `json:"do_remote_ip_config"` +} + +func loadVpnSettings() (model.VpnSetting, error) { + var s model.VpnSetting + err := db.DB.First(&s, model.VpnSettingSingletonID).Error + return s, err +} + +func loadReservationsMap() (map[uint]string, error) { + var rows []model.VpnReservation + if err := db.DB.Find(&rows).Error; err != nil { + return nil, err + } + out := make(map[uint]string, len(rows)) + for _, r := range rows { + out[r.UserID] = r.IPAddress + } + return out, nil +} + +func ApplyVpnFromDB(svc *vpn.VpnService) error { + s, err := loadVpnSettings() + if err != nil { + return err + } + reservations, err := loadReservationsMap() + if err != nil { + return err + } + return svc.ApplySettings(s, reservations) +} + +func validateSubnet(subnet string) error { + ip, ipNet, err := net.ParseCIDR(subnet) + if err != nil { + return err + } + if ip.To4() == nil { + return errIPv4Only + } + ones, _ := ipNet.Mask.Size() + if ones > 30 { + return errSubnetTooSmall + } + return nil +} + +var ( + errIPv4Only = errStr("仅支持 IPv4 子网") + errSubnetTooSmall = errStr("子网前缀长度不能大于 /30") + errIPNotInSubnet = errStr("IP 不在子网范围内") + errIPReserved = errStr("该 IP 已被预留") + errIPIsServer = errStr("该 IP 为服务器 IP,不可预留") +) + +type errStr string + +func (e errStr) Error() string { return string(e) } + +func GetVpnSettings(c *gin.Context) { + s, err := loadVpnSettings() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载设置失败"}) + return + } + c.JSON(http.StatusOK, vpnSettingsResponse{ + Enabled: s.Enabled, + Subnet: s.Subnet, + MTU: s.MTU, + InterfaceName: s.InterfaceName, + AllowClientToClient: s.AllowClientToClient, + DoLocalIPConfig: s.DoLocalIPConfig, + DoRemoteIPConfig: s.DoRemoteIPConfig, + }) +} + +func UpdateVpnSettings(c *gin.Context) { + var req updateVpnSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + + s, err := loadVpnSettings() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载设置失败"}) + return + } + + if req.Subnet != nil && *req.Subnet != s.Subnet { + if err := validateSubnet(*req.Subnet); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + s.Subnet = *req.Subnet + } + if req.MTU != nil { + if *req.MTU < 500 || *req.MTU > 65535 { + c.JSON(http.StatusBadRequest, gin.H{"error": "MTU 范围 500-65535"}) + return + } + s.MTU = *req.MTU + } + if req.InterfaceName != nil { + s.InterfaceName = *req.InterfaceName + } + if req.Enabled != nil { + s.Enabled = *req.Enabled + } + if req.AllowClientToClient != nil { + s.AllowClientToClient = *req.AllowClientToClient + } + if req.DoLocalIPConfig != nil { + s.DoLocalIPConfig = *req.DoLocalIPConfig + } + if req.DoRemoteIPConfig != nil { + s.DoRemoteIPConfig = *req.DoRemoteIPConfig + } + + if err := db.DB.Save(&s).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存设置失败"}) + return + } + + if err := ApplyVpnFromDB(vpn.VPN); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "应用设置失败: " + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "设置已更新"}) +} + +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"` +} + +func GetVpnStatus(c *gin.Context) { + s, err := loadVpnSettings() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载设置失败"}) + return + } + used, cap := vpn.VPN.AllocStats() + clients := vpn.VPN.ClientList() + c.JSON(http.StatusOK, vpnStatusResponse{ + Enabled: s.Enabled, + Online: len(clients), + UsedIPs: used, + Capacity: cap, + Clients: clients, + }) +} + +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"` +} + +func ListVpnReservations(c *gin.Context) { + var rows []model.VpnReservation + if err := db.DB.Order("id asc").Find(&rows).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载预留失败"}) + return + } + userIDs := make([]uint, 0, len(rows)) + for _, r := range rows { + userIDs = append(userIDs, r.UserID) + } + nameMap := make(map[uint]string) + if len(userIDs) > 0 { + var users []model.User + db.DB.Where("id IN ?", userIDs).Find(&users) + for _, u := range users { + nameMap[u.ID] = u.Username + } + } + 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"), + } + } + 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"` +} + +func CreateVpnReservation(c *gin.Context) { + var req createReservationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + + var user model.User + if err := db.DB.First(&user, req.UserID).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "用户不存在"}) + return + } + + s, err := loadVpnSettings() + if err != nil { + 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 + } + + 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 + } + 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} + 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) + } + c.JSON(http.StatusOK, gin.H{"message": "预留已创建"}) +} + +func DeleteVpnReservation(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + var r model.VpnReservation + if err := db.DB.First(&r, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "预留不存在"}) + return + } + if err := db.DB.Delete(&r).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"}) + return + } + if vpn.VPN.Running() { + vpn.VPN.RemoveReservation(r.UserID) + } + c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) +} diff --git a/internal/model/vpn.go b/internal/model/vpn.go new file mode 100644 index 0000000..4f2b042 --- /dev/null +++ b/internal/model/vpn.go @@ -0,0 +1,32 @@ +package model + +import "time" + +const VpnSettingSingletonID = 1 + +type VpnSetting struct { + ID uint `gorm:"primaryKey"` + Enabled bool `gorm:"default:false"` + Subnet string `gorm:"size:64;not null"` + MTU int `gorm:"default:1420"` + InterfaceName string `gorm:"size:16"` + AllowClientToClient bool `gorm:"default:false"` + DoLocalIPConfig bool `gorm:"default:true"` + DoRemoteIPConfig bool `gorm:"default:true"` + UpdatedAt time.Time +} + +func (VpnSetting) TableName() string { + return "vpn_settings" +} + +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"` +} + +func (VpnReservation) TableName() string { + return "vpn_reservations" +} diff --git a/internal/router/router.go b/internal/router/router.go index 959f3dc..abf4ba0 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -34,6 +34,13 @@ func Setup(r *gin.Engine) { admin.PUT("/users/:id", handler.UpdateUser) admin.DELETE("/users/:id", handler.DeleteUser) admin.DELETE("/users/:id/sessions", handler.AdminRevokeUserSessions) + + admin.GET("/vpn/settings", handler.GetVpnSettings) + admin.PUT("/vpn/settings", handler.UpdateVpnSettings) + admin.GET("/vpn/status", handler.GetVpnStatus) + admin.GET("/vpn/reservations", handler.ListVpnReservations) + admin.POST("/vpn/reservations", handler.CreateVpnReservation) + admin.DELETE("/vpn/reservations/:id", handler.DeleteVpnReservation) } distDir := http.Dir("./dist") diff --git a/internal/vpn/alloc.go b/internal/vpn/alloc.go new file mode 100644 index 0000000..b3152ae --- /dev/null +++ b/internal/vpn/alloc.go @@ -0,0 +1,131 @@ +package vpn + +import ( + "errors" + "fmt" + "net" + "sync" + + "github.com/apparentlymart/go-cidr/cidr" +) + +type AllocationManager struct { + mu sync.Mutex + net *net.IPNet + serverIP net.IP + used map[string]bool + reservedByUser map[uint]string + reservedSet map[string]bool +} + +func NewAllocationManager(ipNet *net.IPNet, serverIP net.IP, reservations map[uint]string) *AllocationManager { + m := &AllocationManager{ + net: ipNet, + serverIP: serverIP, + used: make(map[string]bool), + reservedByUser: make(map[uint]string), + reservedSet: make(map[string]bool), + } + for uid, ip := range reservations { + m.reservedByUser[uid] = ip + m.reservedSet[ip] = true + } + return m +} + +func (m *AllocationManager) ServerIP() net.IP { return m.serverIP } +func (m *AllocationManager) Subnet() *net.IPNet { return m.net } + +func (m *AllocationManager) Allocate(userID uint) (net.IP, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if ipStr, ok := m.reservedByUser[userID]; ok { + if m.used[ipStr] { + return nil, fmt.Errorf("用户预留 IP %s 已被占用", ipStr) + } + m.used[ipStr] = true + return net.ParseIP(ipStr), nil + } + + count := cidr.AddressCount(m.net) + maxIndex := int(count - 1) + for i := 2; i < maxIndex; i++ { + ip, err := cidr.Host(m.net, i) + if err != nil { + continue + } + ipStr := ip.String() + if m.used[ipStr] || m.reservedSet[ipStr] { + continue + } + m.used[ipStr] = true + return ip, nil + } + return nil, errors.New("可用 IP 地址已耗尽") +} + +func (m *AllocationManager) Release(ip net.IP) { + if ip == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + delete(m.used, ip.String()) +} + +func (m *AllocationManager) IsReserved(ipStr string) bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.reservedSet[ipStr] +} + +func (m *AllocationManager) ReservedByUser(userID uint) (string, bool) { + m.mu.Lock() + defer m.mu.Unlock() + ip, ok := m.reservedByUser[userID] + return ip, ok +} + +func (m *AllocationManager) ReservedList() map[uint]string { + m.mu.Lock() + defer m.mu.Unlock() + out := make(map[uint]string, len(m.reservedByUser)) + for k, v := range m.reservedByUser { + out[k] = v + } + return out +} + +func (m *AllocationManager) UsedCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.used) +} + +func (m *AllocationManager) Capacity() uint64 { + count := cidr.AddressCount(m.net) + if count < 3 { + return 0 + } + return count - 3 +} + +func (m *AllocationManager) AddReservation(userID uint, ipStr string) { + m.mu.Lock() + defer m.mu.Unlock() + if old, ok := m.reservedByUser[userID]; ok { + delete(m.reservedSet, old) + } + m.reservedByUser[userID] = ipStr + m.reservedSet[ipStr] = true +} + +func (m *AllocationManager) RemoveReservation(userID uint) { + m.mu.Lock() + defer m.mu.Unlock() + if old, ok := m.reservedByUser[userID]; ok { + delete(m.reservedByUser, userID) + delete(m.reservedSet, old) + } +} diff --git a/internal/vpn/protocol.go b/internal/vpn/protocol.go new file mode 100644 index 0000000..8be2fe7 --- /dev/null +++ b/internal/vpn/protocol.go @@ -0,0 +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 controlMessage struct { + Type string `json:"type"` + Message string `json:"message,omitempty"` +} diff --git a/internal/vpn/service.go b/internal/vpn/service.go new file mode 100644 index 0000000..96bde40 --- /dev/null +++ b/internal/vpn/service.go @@ -0,0 +1,269 @@ +package vpn + +import ( + "errors" + "fmt" + "log" + "net" + "sync" + + "lmvpn/internal/model" + + "github.com/apparentlymart/go-cidr/cidr" +) + +type VpnService struct { + mu sync.RWMutex + settings model.VpnSetting + net *net.IPNet + serverIP net.IP + prefix int + alloc *AllocationManager + switchx *PacketSwitch + tun *TUNInterface + tunDone chan struct{} + running bool + clients map[*tunnelConn]struct{} +} + +func NewVpnService() *VpnService { + return &VpnService{ + clients: make(map[*tunnelConn]struct{}), + } +} + +func (s *VpnService) Running() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.running +} + +func (s *VpnService) Settings() model.VpnSetting { + s.mu.RLock() + defer s.mu.RUnlock() + return s.settings +} + +func (s *VpnService) parseNet(subnet string) (*net.IPNet, net.IP, int, error) { + _, ipNet, err := net.ParseCIDR(subnet) + if err != nil { + return nil, nil, 0, fmt.Errorf("子网格式错误: %w", err) + } + ones, _ := ipNet.Mask.Size() + serverIP, err := cidr.Host(ipNet, 1) + if err != nil { + return nil, nil, 0, fmt.Errorf("计算服务器 IP 失败: %w", err) + } + return ipNet, serverIP, ones, nil +} + +func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations map[uint]string) error { + s.mu.Lock() + if s.running { + s.mu.Unlock() + _ = s.Stop() + s.mu.Lock() + } + s.settings = settings + s.mu.Unlock() + + if !settings.Enabled { + return nil + } + + ipNet, serverIP, prefix, err := s.parseNet(settings.Subnet) + if err != nil { + return err + } + + tun, err := CreateTUN(settings.InterfaceName) + if err != nil { + return err + } + + var peerIP net.IP = serverIP + if settings.DoLocalIPConfig { + if err := tun.Configure(serverIP, prefix, peerIP); err != nil { + _ = tun.Close() + return fmt.Errorf("配置 TUN 失败: %w", err) + } + } + if err := tun.SetMTU(settings.MTU); err != nil { + log.Printf("警告: 设置 MTU 失败: %v", err) + } + + s.mu.Lock() + s.net = ipNet + s.serverIP = serverIP + s.prefix = prefix + s.alloc = NewAllocationManager(ipNet, serverIP, reservations) + s.switchx = NewPacketSwitch(settings.AllowClientToClient) + s.tun = tun + s.tunDone = make(chan struct{}) + s.running = true + 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) + return nil +} + +func (s *VpnService) serveTUN() { + s.mu.RLock() + tun := s.tun + switchx := s.switchx + done := s.tunDone + bufSize := s.settings.MTU + 64 + s.mu.RUnlock() + + packet := make([]byte, bufSize) + for { + n, err := tun.Iface.Read(packet) + if err != nil { + log.Printf("TUN 读取结束: %v", err) + close(done) + return + } + if n < 1 { + continue + } + targets := switchx.RouteFromTUN(packet[:n]) + for _, t := range targets { + _ = t.WritePacket(packet[:n]) + } + } +} + +func (s *VpnService) Stop() error { + s.mu.Lock() + if !s.running { + s.mu.Unlock() + return nil + } + s.running = false + tun := s.tun + done := s.tunDone + clients := s.clients + s.clients = make(map[*tunnelConn]struct{}) + s.mu.Unlock() + + for c := range clients { + c.close() + } + if tun != nil { + _ = tun.Close() + if done != nil { + <-done + } + } + log.Printf("VPN 服务已停止") + return nil +} + +func (s *VpnService) Allocate(user *model.User) (net.IP, error) { + s.mu.RLock() + alloc := s.alloc + s.mu.RUnlock() + if alloc == nil { + return nil, errors.New("VPN 服务未运行") + } + return alloc.Allocate(user.ID) +} + +func (s *VpnService) WriteToTUN(packet []byte) error { + s.mu.RLock() + tun := s.tun + s.mu.RUnlock() + if tun == nil { + return errors.New("TUN 未就绪") + } + _, err := tun.Iface.Write(packet) + return err +} + +func (s *VpnService) RouteFromClient(src SwitchConn, packet []byte) []SwitchConn { + s.mu.RLock() + switchx := s.switchx + s.mu.RUnlock() + if switchx == nil { + return nil + } + return switchx.RouteFromClient(src, packet) +} + +func (s *VpnService) registerClient(c *tunnelConn) { + s.mu.Lock() + s.switchx.Register(c) + s.clients[c] = struct{}{} + s.mu.Unlock() +} + +func (s *VpnService) unregisterClient(c *tunnelConn) { + s.mu.Lock() + if s.switchx != nil { + s.switchx.Unregister(c) + } + delete(s.clients, c) + if s.alloc != nil { + s.alloc.Release(c.assignedIP) + } + s.mu.Unlock() +} + +func (s *VpnService) ServerIP() net.IP { + s.mu.RLock() + defer s.mu.RUnlock() + return s.serverIP +} + +func (s *VpnService) Prefix() int { + s.mu.RLock() + defer s.mu.RUnlock() + return s.prefix +} + +func (s *VpnService) AllocStats() (used int, capacity uint64) { + s.mu.RLock() + alloc := s.alloc + s.mu.RUnlock() + if alloc == nil { + return 0, 0 + } + return alloc.UsedCount(), alloc.Capacity() +} + +func (s *VpnService) AddReservation(userID uint, ipStr string) { + s.mu.RLock() + alloc := s.alloc + s.mu.RUnlock() + if alloc != nil { + alloc.AddReservation(userID, ipStr) + } +} + +func (s *VpnService) RemoveReservation(userID uint) { + s.mu.RLock() + alloc := s.alloc + s.mu.RUnlock() + if alloc != nil { + alloc.RemoveReservation(userID) + } +} + +func (s *VpnService) ClientList() []ClientInfo { + s.mu.RLock() + out := make([]ClientInfo, 0, len(s.clients)) + for c := range s.clients { + out = append(out, c.info()) + } + s.mu.RUnlock() + return out +} + +type ClientInfo struct { + Username string `json:"username"` + IP string `json:"ip"` + ConnectedAt string `json:"connected_at"` +} + +var VPN *VpnService diff --git a/internal/vpn/switch.go b/internal/vpn/switch.go new file mode 100644 index 0000000..77f506f --- /dev/null +++ b/internal/vpn/switch.go @@ -0,0 +1,141 @@ +package vpn + +import ( + "net" + "sync" + + "github.com/songgao/water/waterutil" +) + +type SwitchConn interface { + WritePacket(data []byte) error + AssignedIP() net.IP +} + +type ipKey [16]byte + +func ipToKey(ip net.IP) ipKey { + var k ipKey + copy(k[:], ip.To16()) + return k +} + +type PacketSwitch struct { + allowClientToClient bool + mu sync.RWMutex + table map[ipKey]SwitchConn +} + +func NewPacketSwitch(allowClientToClient bool) *PacketSwitch { + return &PacketSwitch{ + allowClientToClient: allowClientToClient, + table: make(map[ipKey]SwitchConn), + } +} + +func (s *PacketSwitch) SetAllowClientToClient(v bool) { + s.mu.Lock() + s.allowClientToClient = v + s.mu.Unlock() +} + +func (s *PacketSwitch) Register(c SwitchConn) { + k := ipToKey(c.AssignedIP()) + s.mu.Lock() + s.table[k] = 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) + } + s.mu.Unlock() +} + +func (s *PacketSwitch) findByIP(ip net.IP) SwitchConn { + s.mu.RLock() + c := s.table[ipToKey(ip)] + s.mu.RUnlock() + return c +} + +func (s *PacketSwitch) allExcept(skip SwitchConn) []SwitchConn { + s.mu.RLock() + out := make([]SwitchConn, 0, len(s.table)) + for _, c := range s.table { + if c == skip { + continue + } + out = append(out, c) + } + s.mu.RUnlock() + return out +} + +func parseIPAddrs(packet []byte) (src, dest net.IP, ok bool) { + if len(packet) < 1 { + return nil, nil, false + } + switch { + case waterutil.IsIPv4(packet): + if len(packet) < 20 { + return nil, nil, false + } + return waterutil.IPv4Source(packet), waterutil.IPv4Destination(packet), true + case waterutil.IsIPv6(packet): + if len(packet) < 40 { + return nil, nil, false + } + src = make(net.IP, 16) + copy(src, packet[8:24]) + dest = make(net.IP, 16) + copy(dest, packet[24:40]) + return src, dest, true + } + return nil, nil, false +} + +func (s *PacketSwitch) allowC2C() bool { + s.mu.RLock() + v := s.allowClientToClient + s.mu.RUnlock() + return v +} + +func (s *PacketSwitch) RouteFromClient(src SwitchConn, packet []byte) []SwitchConn { + srcIP, dest, ok := parseIPAddrs(packet) + if !ok { + return nil + } + // anti-spoof: enforce assigned source IP + if srcIP != nil && !srcIP.Equal(src.AssignedIP()) { + return nil + } + if dest.IsGlobalUnicast() { + if c := s.findByIP(dest); c != nil && s.allowC2C() { + return []SwitchConn{c} + } + return nil + } + if s.allowC2C() { + return s.allExcept(src) + } + return nil +} + +func (s *PacketSwitch) RouteFromTUN(packet []byte) []SwitchConn { + _, dest, ok := parseIPAddrs(packet) + if !ok { + return nil + } + if dest.IsGlobalUnicast() { + if c := s.findByIP(dest); c != nil { + return []SwitchConn{c} + } + return nil + } + return s.allExcept(nil) +} diff --git a/internal/vpn/tun.go b/internal/vpn/tun.go new file mode 100644 index 0000000..1338315 --- /dev/null +++ b/internal/vpn/tun.go @@ -0,0 +1,42 @@ +package vpn + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/songgao/water" +) + +type TUNInterface struct { + Iface *water.Interface +} + +func CreateTUN(name string) (*TUNInterface, error) { + cfg := water.Config{DeviceType: water.TUN} + cfg.Name = name + ifce, err := water.New(cfg) + if err != nil { + return nil, fmt.Errorf("创建 TUN 设备失败: %w", err) + } + return &TUNInterface{Iface: ifce}, nil +} + +func (t *TUNInterface) Name() string { + return t.Iface.Name() +} + +func (t *TUNInterface) Close() error { + return t.Iface.Close() +} + +func execCmd(name string, arg ...string) error { + cmd := exec.Command(name, arg...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("command %s %s: %w", name, strings.Join(arg, " "), err) + } + return nil +} diff --git a/internal/vpn/tun_darwin.go b/internal/vpn/tun_darwin.go new file mode 100644 index 0000000..b82fae4 --- /dev/null +++ b/internal/vpn/tun_darwin.go @@ -0,0 +1,39 @@ +//go:build darwin + +package vpn + +import ( + "fmt" + "net" +) + +func inetFamily(ip net.IP) string { + if ip.To4() == nil { + return "inet6" + } + return "inet" +} + +func (t *TUNInterface) Configure(localIP net.IP, prefix int, peerIP net.IP) error { + if localIP == nil { + return execCmd("ifconfig", t.Name(), "up") + } + localCidr := fmt.Sprintf("%s/%d", localIP.String(), prefix) + inetType := inetFamily(localIP) + var err error + if t.Iface.IsTUN() && inetType == "inet" { + err = execCmd("ifconfig", t.Name(), inetType, localCidr, peerIP.String(), "up") + } else { + err = execCmd("ifconfig", t.Name(), inetType, localCidr, "up") + } + return err +} + +func (t *TUNInterface) AddSubnetRoute(subnet *net.IPNet) error { + inetType := inetFamily(subnet.IP) + return execCmd("route", "add", fmt.Sprintf("-%s", inetType), "-net", subnet.String(), "-interface", t.Name()) +} + +func (t *TUNInterface) SetMTU(mtu int) error { + return execCmd("ifconfig", t.Name(), "mtu", fmt.Sprintf("%d", mtu)) +} diff --git a/internal/vpn/tun_linux.go b/internal/vpn/tun_linux.go new file mode 100644 index 0000000..f253632 --- /dev/null +++ b/internal/vpn/tun_linux.go @@ -0,0 +1,33 @@ +//go:build linux + +package vpn + +import ( + "fmt" + "net" +) + +func (t *TUNInterface) Configure(localIP net.IP, prefix int, peerIP net.IP) error { + if err := execCmd("ip", "link", "set", "dev", t.Name(), "up"); err != nil { + return err + } + if localIP == nil { + return nil + } + localCidr := fmt.Sprintf("%s/%d", localIP.String(), prefix) + args := []string{"addr", "add", "dev", t.Name(), localCidr, "peer", peerIP.String()} + if err := execCmd("ip", args...); err != nil { + if err2 := execCmd("ip", "addr", "add", "dev", t.Name(), localCidr); err2 != nil { + return err + } + } + return nil +} + +func (t *TUNInterface) AddSubnetRoute(subnet *net.IPNet) error { + return execCmd("ip", "route", "add", subnet.String(), "dev", t.Name()) +} + +func (t *TUNInterface) SetMTU(mtu int) error { + return execCmd("ip", "link", "set", "dev", t.Name(), "mtu", fmt.Sprintf("%d", mtu)) +} diff --git a/internal/vpn/tun_other.go b/internal/vpn/tun_other.go new file mode 100644 index 0000000..58644c8 --- /dev/null +++ b/internal/vpn/tun_other.go @@ -0,0 +1,20 @@ +//go:build !linux && !darwin + +package vpn + +import ( + "errors" + "net" +) + +func (t *TUNInterface) Configure(localIP net.IP, prefix int, peerIP net.IP) error { + return errors.New("TUN 配置当前平台不支持") +} + +func (t *TUNInterface) AddSubnetRoute(subnet *net.IPNet) error { + return errors.New("TUN 路由当前平台不支持") +} + +func (t *TUNInterface) SetMTU(mtu int) error { + return errors.New("TUN MTU 当前平台不支持") +} diff --git a/internal/vpn/tunnel.go b/internal/vpn/tunnel.go index 524b9db..77662b9 100644 --- a/internal/vpn/tunnel.go +++ b/internal/vpn/tunnel.go @@ -1,8 +1,11 @@ package vpn import ( + "encoding/json" "log" + "net" "sync" + "sync/atomic" "time" "lmvpn/internal/model" @@ -13,6 +16,7 @@ import ( const ( readTimeout = 60 * time.Second writeTimeout = 10 * time.Second + readyTimeout = 30 * time.Second pingPeriod = 30 * time.Second maxMessageSize = 1 << 20 maxConnsPerUser = 3 @@ -23,13 +27,71 @@ var ( activeConnsMu sync.Mutex ) +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 +} + +func (c *tunnelConn) AssignedIP() net.IP { return c.assignedIP } + +func (c *tunnelConn) WritePacket(data []byte) error { + if !c.ready.Load() || len(data) == 0 { + return nil + } + c.writeMu.Lock() + defer c.writeMu.Unlock() + c.conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + if err := c.conn.WriteMessage(websocket.BinaryMessage, data); err != nil { + return err + } + c.txBytes.Add(int64(len(data))) + return nil +} + +func (c *tunnelConn) writeControl(v interface{}) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + c.writeMu.Lock() + defer c.writeMu.Unlock() + c.conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + return c.conn.WriteMessage(websocket.TextMessage, data) +} + +func (c *tunnelConn) close() { + c.writeMu.Lock() + _ = c.conn.Close() + c.writeMu.Unlock() +} + +func (c *tunnelConn) info() ClientInfo { + return ClientInfo{ + Username: c.user.Username, + IP: c.assignedIP.String(), + ConnectedAt: c.connectedAt.Format("2006-01-02 15:04:05"), + } +} + func runTunnel(conn *websocket.Conn, user *model.User) { defer conn.Close() + if VPN == nil || !VPN.Running() { + _ = sendJSON(conn, controlMessage{Type: "error", Message: "VPN 服务未启用"}) + return + } + activeConnsMu.Lock() if activeConns[user.ID] >= maxConnsPerUser { activeConnsMu.Unlock() - sendJSON(conn, authResponse{Type: "auth_err", Message: "连接数已达上限"}) + _ = sendJSON(conn, controlMessage{Type: "error", Message: "连接数已达上限"}) return } activeConns[user.ID]++ @@ -44,18 +106,52 @@ func runTunnel(conn *websocket.Conn, user *model.User) { activeConnsMu.Unlock() }() - log.Printf("用户 %s 已连接", user.Username) + ip, err := VPN.Allocate(user) + if err != nil { + _ = sendJSON(conn, controlMessage{Type: "error", Message: "分配 IP 失败: " + err.Error()}) + return + } + + tc := &tunnelConn{ + conn: conn, + user: user, + svc: VPN, + assignedIP: ip, + connectedAt: time.Now(), + } + + VPN.registerClient(tc) + defer VPN.unregisterClient(tc) + + settings := VPN.Settings() + initMsg := initMessage{ + Type: "init", + IP: ip.String(), + Prefix: VPN.Prefix(), + MTU: settings.MTU, + ServerIP: VPN.ServerIP().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()) conn.SetReadLimit(maxMessageSize) + conn.SetReadDeadline(time.Now().Add(readyTimeout)) + readyDeadline := time.Now().Add(readyTimeout) go func() { ticker := time.NewTicker(pingPeriod) defer ticker.Stop() for range ticker.C { - conn.SetWriteDeadline(time.Now().Add(writeTimeout)) - if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + tc.writeMu.Lock() + if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeTimeout)); err != nil { + tc.writeMu.Unlock() return } + tc.writeMu.Unlock() } }() @@ -65,17 +161,49 @@ func runTunnel(conn *websocket.Conn, user *model.User) { }) for { - conn.SetReadDeadline(time.Now().Add(readTimeout)) messageType, data, err := conn.ReadMessage() if err != nil { log.Printf("用户 %s 断开连接: %v", user.Username, err) return } - conn.SetWriteDeadline(time.Now().Add(writeTimeout)) - if err := conn.WriteMessage(messageType, data); err != nil { - log.Printf("用户 %s 发送失败: %v", user.Username, err) - return + if messageType == websocket.TextMessage { + var msg controlMessage + if err := json.Unmarshal(data, &msg); err != nil { + continue + } + 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()) + } + continue + } + + if messageType != websocket.BinaryMessage { + continue + } + + if !tc.ready.Load() { + if time.Now().After(readyDeadline) { + log.Printf("用户 %s 等待 ready 超时", user.Username) + return + } + conn.SetReadDeadline(readyDeadline) + continue + } + + tc.rxBytes.Add(int64(len(data))) + + targets := VPN.RouteFromClient(tc, data) + if len(targets) == 0 { + if err := VPN.WriteToTUN(data); err != nil { + log.Printf("用户 %s 写入 TUN 失败: %v", user.Username, err) + } + continue + } + for _, t := range targets { + _ = t.WritePacket(data) } } } diff --git a/main.go b/main.go index f39f040..c2d3b9a 100644 --- a/main.go +++ b/main.go @@ -11,8 +11,10 @@ import ( "lmvpn/internal/config" "lmvpn/internal/db" + "lmvpn/internal/handler" "lmvpn/internal/middleware" "lmvpn/internal/router" + "lmvpn/internal/vpn" "github.com/gin-gonic/gin" ) @@ -29,6 +31,11 @@ func main() { log.Fatalf("数据库初始化失败: %v", err) } + vpn.VPN = vpn.NewVpnService() + if err := handler.ApplyVpnFromDB(vpn.VPN); err != nil { + log.Printf("警告: 应用 VPN 设置失败: %v", err) + } + r := gin.Default() router.Setup(r)