feat(client): add authorized WireGuard device enrollment and desktop connection
This commit is contained in:
1 parent
d2f1f5040e
commit
6dd3c7dd45
25 files changed
+3274
-131
No files matched your search
@@ -217,6 +217,20 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
|
||||
api.POST("/login", s.login)
|
||||
api.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
// 终端用户客户端 API(桌面/移动端):独立令牌,与管理会话隔离
|
||||
api.POST("/client/login", s.clientLogin)
|
||||
api.GET("/client/capabilities", func(c *gin.Context) { c.JSON(200, gin.H{"password": true, "oidc": false, "wireguard": true}) })
|
||||
cli := api.Group("/client", clientAuth())
|
||||
{
|
||||
cli.POST("/logout", s.clientLogout)
|
||||
cli.GET("/lines", s.clientLines)
|
||||
cli.GET("/devices", s.clientListDevices)
|
||||
cli.POST("/devices", s.clientRegisterDevice)
|
||||
cli.GET("/devices/:id/config", s.clientDeviceConfig)
|
||||
cli.GET("/devices/:id/status", s.clientDeviceStatus)
|
||||
cli.DELETE("/devices/:id", s.clientRevokeDevice)
|
||||
}
|
||||
|
||||
auth := api.Group("")
|
||||
auth.Use(authMiddleware(), requirePasswordChanged())
|
||||
{
|
||||
@@ -317,6 +331,8 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
|
||||
admin.POST("/vpn-users/:id/mfa/backup-codes", s.vpnUserMFABackupCodes)
|
||||
admin.POST("/vpn-users/:id/mfa/unlock", s.vpnUserMFAUnlock)
|
||||
admin.GET("/vpn-users/:id/mfa/otp-secrets", s.vpnUserOTPSecretsLine)
|
||||
admin.GET("/client-devices", s.adminListClientDevices)
|
||||
admin.POST("/client-devices/:id/revoke", s.adminRevokeClientDevice)
|
||||
admin.POST("/nodes", s.createNode)
|
||||
admin.POST("/nodes/test", s.testNodeConnection)
|
||||
admin.PUT("/nodes/:id", s.updateNode)
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
// client.go 面向桌面/移动客户端的终端用户 API(/api/client/*)。
|
||||
//
|
||||
// 与管理后台 API 完全隔离:独立的令牌表(只存哈希)、不携带管理角色、
|
||||
// 只暴露当前拨号用户被授权的 WireGuard 线路与自己名下的设备。
|
||||
// 设备私钥只在客户端生成与保管,服务端仅登记公钥(外部 Peer)。
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"vpn-platform/backend/internal/ldaputil"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service/wireguard"
|
||||
"vpn-platform/backend/internal/sessionmgr"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
clientTokenTTL = 30 * 24 * time.Hour
|
||||
clientTokenRenewBelow = 15 * 24 * time.Hour // 剩余不足一半时滑动续期
|
||||
maxDevicesPerLine = 5 // 每用户每条线路的在线设备上限
|
||||
)
|
||||
|
||||
var clientMFAMu sync.Mutex
|
||||
|
||||
// ---- 令牌 ----
|
||||
|
||||
func clientTokenHash(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// 会话只授予本次真正完成密码和 MFA 校验的拨号记录;修改凭据后旧授权立即失效。
|
||||
func credentialFingerprint(user *model.VPNUser) string {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%d\x00%s\x00%s\x00%s\x00%s\x00%t", user.ID, user.Password, user.LDAPDN, user.MFASecret, user.MFAPin, user.MFAEnabled)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func grantedUsers(users []model.VPNUser) map[uint]string {
|
||||
grants := make(map[uint]string, len(users))
|
||||
for i := range users {
|
||||
grants[users[i].ID] = credentialFingerprint(&users[i])
|
||||
}
|
||||
return grants
|
||||
}
|
||||
|
||||
func currentGrantedUsers(sess *model.ClientSession) []model.VPNUser {
|
||||
var grants map[uint]string
|
||||
if json.Unmarshal([]byte(sess.Grants), &grants) != nil || len(grants) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]uint, 0, len(grants))
|
||||
for id := range grants {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var users []model.VPNUser
|
||||
if store.DB.Where("id IN ? AND username = ? AND enabled = ?", ids, sess.Username, true).Find(&users).Error != nil {
|
||||
return nil
|
||||
}
|
||||
valid := users[:0]
|
||||
for i := range users {
|
||||
if grants[users[i].ID] == credentialFingerprint(&users[i]) {
|
||||
valid = append(valid, users[i])
|
||||
}
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
func revokeClientSessionsForUser(user *model.VPNUser) error {
|
||||
var sessions []model.ClientSession
|
||||
if err := store.DB.Where("username = ?", user.Username).Find(&sessions).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, session := range sessions {
|
||||
var grants map[uint]string
|
||||
if json.Unmarshal([]byte(session.Grants), &grants) != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := grants[user.ID]; ok {
|
||||
if err := store.DB.Delete(&session).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func issueClientToken(username, deviceName, platform string, users []model.VPNUser) (string, time.Time, error) {
|
||||
grants, err := json.Marshal(grantedUsers(users))
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||
expire := time.Now().Add(clientTokenTTL)
|
||||
sess := model.ClientSession{
|
||||
TokenHash: clientTokenHash(token),
|
||||
Username: username,
|
||||
Grants: string(grants),
|
||||
DeviceName: deviceName,
|
||||
Platform: platform,
|
||||
ExpireAt: expire,
|
||||
}
|
||||
if err := store.DB.Create(&sess).Error; err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
return token, expire, nil
|
||||
}
|
||||
|
||||
// clientAuth 校验客户端令牌;与管理后台 authMiddleware 互不相通。
|
||||
func clientAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "缺少客户端令牌"})
|
||||
return
|
||||
}
|
||||
var sess model.ClientSession
|
||||
if err := store.DB.Where("token_hash = ?", clientTokenHash(token)).First(&sess).Error; err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "令牌无效或已注销"})
|
||||
return
|
||||
}
|
||||
if time.Now().After(sess.ExpireAt) {
|
||||
store.DB.Delete(&sess)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
||||
return
|
||||
}
|
||||
users := currentGrantedUsers(&sess)
|
||||
if len(users) == 0 {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "线路授权已失效,请重新登录"})
|
||||
return
|
||||
}
|
||||
if time.Until(sess.ExpireAt) < clientTokenRenewBelow {
|
||||
sess.ExpireAt = time.Now().Add(clientTokenTTL)
|
||||
store.DB.Model(&sess).Update("expire_at", sess.ExpireAt)
|
||||
}
|
||||
c.Set("client_username", sess.Username)
|
||||
c.Set("client_users", users)
|
||||
c.Set("client_token_hash", sess.TokenHash)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 拨号用户认证 ----
|
||||
|
||||
// verifyVPNUserPassword 与 OpenVPN 拨号校验语义一致:本地用户走 bcrypt,
|
||||
// LDAP 用户用其登记的 DN 直接 bind。
|
||||
func verifyVPNUserPassword(user *model.VPNUser, password string) error {
|
||||
switch user.Source {
|
||||
case "local":
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
|
||||
return fmt.Errorf("密码错误")
|
||||
}
|
||||
return nil
|
||||
case "ldap":
|
||||
if user.LDAPDN == "" {
|
||||
return fmt.Errorf("LDAP 用户缺少 DN")
|
||||
}
|
||||
cfg, err := ldapSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dialURL, err := ldaputil.DialURL(cfg.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn, err := ldap.DialURL(dialURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := conn.Bind(user.LDAPDN, password); err != nil {
|
||||
return fmt.Errorf("密码错误")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("未知认证来源")
|
||||
}
|
||||
|
||||
// verifyVPNUserOTP 校验拨号用户的 TOTP 动态口令(MFASecret 加密保存)。
|
||||
func verifyVPNUserOTP(user *model.VPNUser, otp string) bool {
|
||||
if !user.MFAEnabled {
|
||||
return true
|
||||
}
|
||||
if otp == "" {
|
||||
return false
|
||||
}
|
||||
clientMFAMu.Lock()
|
||||
defer clientMFAMu.Unlock()
|
||||
if store.DB.First(user, user.ID).Error != nil || !user.MFAEnabled {
|
||||
return false
|
||||
}
|
||||
now := time.Now()
|
||||
if user.MFALockedUntil != nil && now.Before(*user.MFALockedUntil) {
|
||||
return false
|
||||
}
|
||||
failure := func() bool {
|
||||
failures := user.MFAFailCount + 1
|
||||
updates := map[string]interface{}{"mfa_fail_count": failures}
|
||||
if failures >= 5 {
|
||||
until := now.Add(5 * time.Minute)
|
||||
updates["mfa_locked_until"] = &until
|
||||
}
|
||||
store.DB.Model(user).Updates(updates)
|
||||
return false
|
||||
}
|
||||
if user.MFAHasPin {
|
||||
pin, err := store.DecryptSecret(user.MFAPin)
|
||||
if err != nil || pin == "" || !strings.HasPrefix(otp, pin) {
|
||||
return failure()
|
||||
}
|
||||
otp = strings.TrimPrefix(otp, pin)
|
||||
}
|
||||
secret, err := store.DecryptSecret(user.MFASecret)
|
||||
if err != nil || secret == "" {
|
||||
return failure()
|
||||
}
|
||||
counter := now.Unix() / 30
|
||||
if !totp.Validate(strings.TrimSpace(otp), secret) || counter <= user.MFALastCounter {
|
||||
return failure()
|
||||
}
|
||||
store.DB.Model(user).Updates(map[string]interface{}{"mfa_last_counter": counter, "mfa_last_used_at": now, "mfa_fail_count": 0, "mfa_locked_until": nil})
|
||||
return true
|
||||
}
|
||||
|
||||
// authorizedLines 返回该拨号用户密码验证通过(且 OTP 满足)的 WireGuard 线路。
|
||||
func authorizedLines(username, password, otp string) ([]model.VPNUser, bool, error) {
|
||||
var users []model.VPNUser
|
||||
if err := store.DB.Where("username = ? AND enabled = ?", username, true).Find(&users).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var passed []model.VPNUser
|
||||
mfaRequired := false
|
||||
for i := range users {
|
||||
u := &users[i]
|
||||
if verifyVPNUserPassword(u, password) != nil {
|
||||
continue
|
||||
}
|
||||
if u.MFAEnabled {
|
||||
mfaRequired = true
|
||||
if otp == "" || !verifyVPNUserOTP(u, otp) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if t, err := store.GetTunnel(u.TunnelID); err != nil || t.Protocol != "wireguard" {
|
||||
continue
|
||||
}
|
||||
passed = append(passed, *u)
|
||||
}
|
||||
return passed, mfaRequired, nil
|
||||
}
|
||||
|
||||
type clientLine struct {
|
||||
TunnelID uint `json:"tunnel_id"`
|
||||
Name string `json:"name"`
|
||||
Remark string `json:"remark"`
|
||||
State string `json:"state"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
ExpireAt *time.Time `json:"expire_at"`
|
||||
}
|
||||
|
||||
// linesForUsers 把授权拨号记录映射为 WireGuard 线路列表。
|
||||
func linesForUsers(users []model.VPNUser) []clientLine {
|
||||
seen := map[uint]bool{}
|
||||
lines := []clientLine{}
|
||||
for _, u := range users {
|
||||
if seen[u.TunnelID] {
|
||||
continue
|
||||
}
|
||||
seen[u.TunnelID] = true
|
||||
t, err := store.GetTunnel(u.TunnelID)
|
||||
if err != nil || t.Protocol != "wireguard" || (t.ExpireAt != nil && time.Now().After(*t.ExpireAt)) {
|
||||
continue
|
||||
}
|
||||
line := clientLine{TunnelID: t.ID, Name: t.Name, Remark: t.Remark, State: t.State, ExpireAt: t.ExpireAt}
|
||||
if cfg, err := store.TunnelConfig(t); err == nil {
|
||||
line.Endpoint = wireguardEndpoint(cfg)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func wireguardEndpoint(cfg map[string]interface{}) string {
|
||||
if ep, ok := cfg["public_endpoint"].(string); ok && ep != "" {
|
||||
return ep
|
||||
}
|
||||
port := 51820
|
||||
if p, ok := cfg["listen_port"].(float64); ok && p > 0 {
|
||||
port = int(p)
|
||||
}
|
||||
return fmt.Sprintf(":%d", port) // 未配置公网地址时仅回传端口,由客户端提示管理员补全
|
||||
}
|
||||
|
||||
// ---- 处理器 ----
|
||||
|
||||
type clientLoginReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
OTP string `json:"otp"`
|
||||
DeviceName string `json:"device_name"`
|
||||
Platform string `json:"platform"`
|
||||
}
|
||||
|
||||
func (s *Server) clientLogin(c *gin.Context) {
|
||||
var req clientLoginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
lockKey := "client|" + c.ClientIP() + "|" + req.Username
|
||||
if remaining := loginLockRemaining(lockKey); remaining > 0 {
|
||||
c.JSON(429, gin.H{"error": fmt.Sprintf("尝试过于频繁,请 %d 秒后重试", int(remaining.Seconds()))})
|
||||
return
|
||||
}
|
||||
passed, mfaRequired, err := authorizedLines(req.Username, req.Password, req.OTP)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "认证服务异常"})
|
||||
return
|
||||
}
|
||||
if len(passed) == 0 {
|
||||
recordLoginFail(lockKey)
|
||||
sessionmgr.LogConnection(0, req.Username, "auth_fail", c.ClientIP(), "客户端登录失败")
|
||||
if mfaRequired && req.OTP == "" {
|
||||
c.JSON(401, gin.H{"error": "该账号已启用动态口令", "mfa_required": true})
|
||||
return
|
||||
}
|
||||
c.JSON(401, gin.H{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
resetLoginFail(lockKey)
|
||||
lines := linesForUsers(passed)
|
||||
if len(lines) == 0 {
|
||||
c.JSON(403, gin.H{"error": "该账号未被授权任何 WireGuard 线路"})
|
||||
return
|
||||
}
|
||||
token, expire, err := issueClientToken(req.Username, req.DeviceName, req.Platform, passed)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "签发令牌失败"})
|
||||
return
|
||||
}
|
||||
displayName := passed[0].DisplayName
|
||||
c.JSON(200, gin.H{
|
||||
"token": token, "expires_at": expire,
|
||||
"username": req.Username, "display_name": displayName,
|
||||
"lines": lines,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) clientLogout(c *gin.Context) {
|
||||
store.DB.Where("token_hash = ?", c.GetString("client_token_hash")).Delete(&model.ClientSession{})
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) clientLines(c *gin.Context) {
|
||||
users, _ := c.Get("client_users")
|
||||
c.JSON(200, gin.H{"lines": linesForUsers(users.([]model.VPNUser))})
|
||||
}
|
||||
|
||||
// clientAuthorizedTunnel 校验当前客户端用户对该 WireGuard 线路有拨号授权。
|
||||
func clientAuthorizedTunnel(users []model.VPNUser, tunnelID uint) (*model.Tunnel, *model.VPNUser, error) {
|
||||
var authorized *model.VPNUser
|
||||
for i := range users {
|
||||
if users[i].TunnelID == tunnelID {
|
||||
authorized = &users[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if authorized == nil {
|
||||
return nil, nil, fmt.Errorf("没有该线路的接入授权")
|
||||
}
|
||||
t, err := store.GetTunnel(tunnelID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("线路不存在")
|
||||
}
|
||||
if t.Protocol != "wireguard" {
|
||||
return nil, nil, fmt.Errorf("客户端目前仅支持 WireGuard 线路")
|
||||
}
|
||||
if t.ExpireAt != nil && time.Now().After(*t.ExpireAt) {
|
||||
return nil, nil, fmt.Errorf("线路已过期")
|
||||
}
|
||||
return t, authorized, nil
|
||||
}
|
||||
|
||||
// allocateClientAddress 在专线网段内为设备分配第一个空闲地址(/32,仅 IPv4)。
|
||||
func allocateClientAddress(serverCIDR string, used map[string]bool) (string, error) {
|
||||
ip, ipnet, err := net.ParseCIDR(serverCIDR)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("专线网段配置无效: %s", serverCIDR)
|
||||
}
|
||||
ip = ip.To4()
|
||||
if ip == nil {
|
||||
return "", fmt.Errorf("客户端自动分配目前仅支持 IPv4 网段")
|
||||
}
|
||||
ones, bits := ipnet.Mask.Size()
|
||||
if bits-ones < 2 {
|
||||
return "", fmt.Errorf("专线网段过小,无法分配地址")
|
||||
}
|
||||
base := ip.Mask(ipnet.Mask)
|
||||
total := 1 << uint(bits-ones)
|
||||
// 跳过网络地址(0)与广播地址(total-1)
|
||||
for i := 1; i < total-1; i++ {
|
||||
candidate := make(net.IP, 4)
|
||||
copy(candidate, base)
|
||||
for b, v := 3, i; b >= 0 && v > 0; b-- {
|
||||
candidate[b] += byte(v & 0xff)
|
||||
v >>= 8
|
||||
}
|
||||
if !used[candidate.String()] {
|
||||
return candidate.String() + "/32", nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("专线网段地址已分配完")
|
||||
}
|
||||
|
||||
type clientRegisterDeviceReq struct {
|
||||
TunnelID uint `json:"tunnel_id" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Platform string `json:"platform"`
|
||||
PublicKey string `json:"public_key" binding:"required"`
|
||||
}
|
||||
|
||||
func (s *Server) clientRegisterDevice(c *gin.Context) {
|
||||
var req clientRegisterDeviceReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
username := c.GetString("client_username")
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" || len(req.Name) > 64 || strings.ContainsAny(req.Name, "\r\n") {
|
||||
c.JSON(400, gin.H{"error": "设备名不能为空、不超过 64 字符且不含换行"})
|
||||
return
|
||||
}
|
||||
if !wireguard.IsWireGuardKey(req.PublicKey) {
|
||||
c.JSON(400, gin.H{"error": "public_key 不是合法的 WireGuard 公钥"})
|
||||
return
|
||||
}
|
||||
granted, _ := c.Get("client_users")
|
||||
t, vpnUser, err := clientAuthorizedTunnel(granted.([]model.VPNUser), req.TunnelID)
|
||||
if err != nil {
|
||||
c.JSON(403, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
mu := lockTunnel(t.ID)
|
||||
defer mu.Unlock()
|
||||
|
||||
var active int64
|
||||
store.DB.Model(&model.ClientDevice{}).
|
||||
Where("vpn_user_id = ? AND revoked = ?", vpnUser.ID, false).
|
||||
Count(&active)
|
||||
if active >= maxDevicesPerLine {
|
||||
c.JSON(400, gin.H{"error": fmt.Sprintf("每条线路最多登记 %d 台设备,请先在客户端或管理端撤销闲置设备", maxDevicesPerLine)})
|
||||
return
|
||||
}
|
||||
var dup int64
|
||||
store.DB.Model(&model.ClientDevice{}).Where("public_key = ?", req.PublicKey).Count(&dup)
|
||||
if dup > 0 {
|
||||
c.JSON(400, gin.H{"error": "该设备密钥已登记过;请在本机重新生成密钥后再注册"})
|
||||
return
|
||||
}
|
||||
|
||||
wg := wireguard.New()
|
||||
used, err := wg.UsedClientAddresses(t)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "读取地址占用失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "读取线路配置失败"})
|
||||
return
|
||||
}
|
||||
endpoint, configured := cfg["public_endpoint"].(string)
|
||||
if !configured || strings.TrimSpace(endpoint) == "" {
|
||||
c.JSON(400, gin.H{"error": "请管理员先为 WireGuard 专线配置公网端点"})
|
||||
return
|
||||
}
|
||||
addr, err := allocateClientAddress(fmt.Sprint(cfg["address"]), used)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
device := model.ClientDevice{
|
||||
TunnelID: t.ID, VPNUserID: vpnUser.ID, Username: username, Name: req.Name,
|
||||
Platform: req.Platform, PublicKey: req.PublicKey, ClientAddress: addr,
|
||||
}
|
||||
if err := store.DB.Create(&device).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "保存设备失败"})
|
||||
return
|
||||
}
|
||||
device.PeerID = fmt.Sprintf("dev-%d", device.ID)
|
||||
if _, err := wg.RegisterDevicePeer(t, device.PeerID, device.Name, device.PublicKey, device.ClientAddress); err != nil {
|
||||
// 已回滚的登记直接删除数据库记录;若 Peer 仍在,保留记录供管理员处理。
|
||||
if _, lookupErr := wg.DeviceConnectionParams(t, device.PeerID); lookupErr != nil {
|
||||
store.DB.Delete(&device)
|
||||
} else {
|
||||
store.DB.Model(&device).Update("peer_id", device.PeerID)
|
||||
store.Audit(username, "client_device_cleanup_failed", t.Name, err.Error())
|
||||
}
|
||||
c.JSON(500, gin.H{"error": "登记设备 Peer 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
store.DB.Model(&device).Update("peer_id", device.PeerID)
|
||||
store.Audit(username, "client_register_device", t.Name, fmt.Sprintf("设备 %q 分配 %s", device.Name, device.ClientAddress))
|
||||
sessionmgr.LogConnection(t.ID, username, "connect", c.ClientIP(), fmt.Sprintf("客户端设备 %q 登记", device.Name))
|
||||
|
||||
params, err := wg.DeviceConnectionParams(t, device.PeerID)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "读取连接参数失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
params["device_id"] = device.ID
|
||||
params["device_name"] = device.Name
|
||||
params["tunnel_id"] = t.ID
|
||||
params["tunnel_name"] = t.Name
|
||||
c.JSON(200, params)
|
||||
}
|
||||
|
||||
func (s *Server) clientListDevices(c *gin.Context) {
|
||||
var devices []model.ClientDevice
|
||||
users, _ := c.Get("client_users")
|
||||
ids := make([]uint, 0, len(users.([]model.VPNUser)))
|
||||
for _, u := range users.([]model.VPNUser) {
|
||||
ids = append(ids, u.ID)
|
||||
}
|
||||
store.DB.Where("vpn_user_id IN ?", ids).Order("id desc").Find(&devices)
|
||||
c.JSON(200, gin.H{"devices": devices})
|
||||
}
|
||||
|
||||
// clientDeviceConfig 重新拉取设备连接参数(端点/密钥轮换后客户端自愈)。
|
||||
func (s *Server) clientDeviceConfig(c *gin.Context) {
|
||||
device, err := s.mustClientDevice(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
t, err := store.GetTunnel(device.TunnelID)
|
||||
if err != nil {
|
||||
c.JSON(404, gin.H{"error": "线路已删除"})
|
||||
return
|
||||
}
|
||||
params, err := wireguard.New().DeviceConnectionParams(t, device.PeerID)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
params["device_id"] = device.ID
|
||||
params["device_name"] = device.Name
|
||||
params["tunnel_id"] = t.ID
|
||||
params["tunnel_name"] = t.Name
|
||||
c.JSON(200, params)
|
||||
}
|
||||
|
||||
// clientDeviceStatus 返回服务端观测到的该设备真实握手与流量。
|
||||
func (s *Server) clientDeviceStatus(c *gin.Context) {
|
||||
device, err := s.mustClientDevice(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
t, err := store.GetTunnel(device.TunnelID)
|
||||
if err != nil {
|
||||
c.JSON(404, gin.H{"error": "线路已删除"})
|
||||
return
|
||||
}
|
||||
proto, err := s.mgr.Get("wireguard")
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "协议引擎不可用"})
|
||||
return
|
||||
}
|
||||
st, err := proto.Status(t)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
for _, p := range st.Peers {
|
||||
if p.PublicKey != device.PublicKey {
|
||||
continue
|
||||
}
|
||||
updates := map[string]interface{}{"rx_bytes": p.RxBytes, "tx_bytes": p.TxBytes}
|
||||
if p.LatestHandshakeAt > 0 {
|
||||
seen := time.Unix(p.LatestHandshakeAt, 0)
|
||||
updates["last_seen_at"] = &seen
|
||||
}
|
||||
store.DB.Model(device).Updates(updates)
|
||||
c.JSON(200, gin.H{
|
||||
"running": st.Running, "connected": p.Connected,
|
||||
"latest_handshake_at": p.LatestHandshakeAt,
|
||||
"rx_bytes": p.RxBytes, "tx_bytes": p.TxBytes,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"running": st.Running, "connected": false, "detail": "网关尚未观测到该设备握手"})
|
||||
}
|
||||
|
||||
func (s *Server) clientRevokeDevice(c *gin.Context) {
|
||||
device, err := s.mustClientDevice(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := s.revokeDevice(device, c.GetString("client_username")); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) mustClientDevice(c *gin.Context) (*model.ClientDevice, error) {
|
||||
var device model.ClientDevice
|
||||
if err := store.DB.First(&device, c.Param("id")).Error; err != nil {
|
||||
c.JSON(404, gin.H{"error": "设备不存在"})
|
||||
return nil, err
|
||||
}
|
||||
users, _ := c.Get("client_users")
|
||||
allowed := false
|
||||
for _, u := range users.([]model.VPNUser) {
|
||||
if u.ID == device.VPNUserID && u.TunnelID == device.TunnelID {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed || device.Username != c.GetString("client_username") {
|
||||
c.JSON(403, gin.H{"error": "只能操作本人设备"})
|
||||
return nil, fmt.Errorf("forbidden")
|
||||
}
|
||||
if device.Revoked {
|
||||
c.JSON(400, gin.H{"error": "设备已撤销"})
|
||||
return nil, fmt.Errorf("revoked")
|
||||
}
|
||||
if _, err := store.GetTunnel(device.TunnelID); err != nil {
|
||||
c.JSON(404, gin.H{"error": "线路不存在"})
|
||||
return nil, err
|
||||
}
|
||||
return &device, nil
|
||||
}
|
||||
|
||||
// revokeDevice 撤销设备:先从网关移除 Peer(配置 + 运行时),再标记撤销。
|
||||
func (s *Server) revokeDevice(device *model.ClientDevice, operator string) error {
|
||||
t, err := store.GetTunnel(device.TunnelID)
|
||||
if err == nil && t.Protocol == "wireguard" && device.PeerID != "" {
|
||||
mu := lockTunnel(t.ID)
|
||||
defer mu.Unlock()
|
||||
if err := wireguard.New().RemoveDevicePeer(t, device.PeerID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := store.DB.Model(device).Update("revoked", true).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
store.Audit(operator, "client_revoke_device", fmt.Sprintf("线路 %d", device.TunnelID),
|
||||
fmt.Sprintf("撤销用户 %s 的设备 %q(%s)", device.Username, device.Name, device.ClientAddress))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 管理端:客户端设备管理 ----
|
||||
|
||||
func (s *Server) adminListClientDevices(c *gin.Context) {
|
||||
type row struct {
|
||||
model.ClientDevice
|
||||
TunnelName string `json:"tunnel_name"`
|
||||
}
|
||||
var devices []model.ClientDevice
|
||||
store.DB.Order("id desc").Find(&devices)
|
||||
rows := make([]row, 0, len(devices))
|
||||
for _, d := range devices {
|
||||
r := row{ClientDevice: d}
|
||||
if t, err := store.GetTunnel(d.TunnelID); err == nil {
|
||||
r.TunnelName = t.Name
|
||||
}
|
||||
rows = append(rows, r)
|
||||
}
|
||||
c.JSON(200, gin.H{"devices": rows})
|
||||
}
|
||||
|
||||
func (s *Server) adminRevokeClientDevice(c *gin.Context) {
|
||||
var device model.ClientDevice
|
||||
if err := store.DB.First(&device, c.Param("id")).Error; err != nil {
|
||||
c.JSON(404, gin.H{"error": "设备不存在"})
|
||||
return
|
||||
}
|
||||
if device.Revoked {
|
||||
c.JSON(400, gin.H{"error": "设备已撤销"})
|
||||
return
|
||||
}
|
||||
if err := s.revokeDevice(&device, c.GetString("username")); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
func TestClientSessionCannotExpandToSameNameOtherLine(t *testing.T) {
|
||||
if err := store.Init(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hashA, _ := bcrypt.GenerateFromPassword([]byte("password-a"), bcrypt.MinCost)
|
||||
hashB, _ := bcrypt.GenerateFromPassword([]byte("password-b"), bcrypt.MinCost)
|
||||
lineA := model.Tunnel{Name: "A", Protocol: "wireguard"}
|
||||
lineB := model.Tunnel{Name: "B", Protocol: "wireguard"}
|
||||
store.DB.Create(&lineA)
|
||||
store.DB.Create(&lineB)
|
||||
uA := model.VPNUser{TunnelID: lineA.ID, Username: "same", Password: string(hashA), Enabled: true, Source: "local"}
|
||||
uB := model.VPNUser{TunnelID: lineB.ID, Username: "same", Password: string(hashB), Enabled: true, Source: "local"}
|
||||
store.DB.Create(&uA)
|
||||
store.DB.Create(&uB)
|
||||
passed, _, err := authorizedLines("same", "password-a", "")
|
||||
if err != nil || len(passed) != 1 || passed[0].ID != uA.ID {
|
||||
t.Fatalf("错误的授权记录: %+v, %v", passed, err)
|
||||
}
|
||||
token, _, err := issueClientToken("same", "laptop", "linux", passed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var session model.ClientSession
|
||||
if err := store.DB.Where("token_hash = ?", clientTokenHash(token)).First(&session).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
granted := currentGrantedUsers(&session)
|
||||
if _, _, err := clientAuthorizedTunnel(granted, lineB.ID); err == nil {
|
||||
t.Fatal("错误密码的同名线路不得获得授权")
|
||||
}
|
||||
if _, _, err := clientAuthorizedTunnel(granted, lineA.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.DB.Model(&uA).Update("password", string(hashB))
|
||||
if got := currentGrantedUsers(&session); len(got) != 0 {
|
||||
t.Fatalf("修改密码后旧授权必须失效: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateClientAddressSkipsUsedAndBoundaries(t *testing.T) {
|
||||
used := map[string]bool{
|
||||
"10.8.0.1": true, // 服务端自身
|
||||
"10.8.0.2": true, // 已占用 Peer
|
||||
}
|
||||
addr, err := allocateClientAddress("10.8.0.1/24", used)
|
||||
if err != nil {
|
||||
t.Fatalf("allocate: %v", err)
|
||||
}
|
||||
if addr != "10.8.0.3/32" {
|
||||
t.Fatalf("应分配第一个空闲地址 10.8.0.3/32,得到 %s", addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateClientAddressSmallSubnet(t *testing.T) {
|
||||
// /30 只有 2 个可用主机位,均被占用时必须报错而不是返回网络/广播地址
|
||||
used := map[string]bool{"10.8.0.1": true, "10.8.0.2": true}
|
||||
if _, err := allocateClientAddress("10.8.0.1/30", used); err == nil {
|
||||
t.Fatal("地址耗尽时必须报错")
|
||||
}
|
||||
if _, err := allocateClientAddress("10.8.0.1/31", map[string]bool{}); err == nil {
|
||||
t.Fatal("/31 无可用主机位,必须报错")
|
||||
}
|
||||
if _, err := allocateClientAddress("fd00::1/64", map[string]bool{}); err == nil {
|
||||
t.Fatal("IPv6 网段暂不支持自动分配,必须明确报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateClientAddressExhausted(t *testing.T) {
|
||||
used := map[string]bool{"192.168.1.1": true, "192.168.1.2": true, "192.168.1.3": true,
|
||||
"192.168.1.4": true, "192.168.1.5": true, "192.168.1.6": true}
|
||||
if _, err := allocateClientAddress("192.168.1.1/29", used); err == nil {
|
||||
t.Fatal("/29 全部占用时必须报错")
|
||||
}
|
||||
}
|
||||
@@ -452,9 +452,9 @@ func (s *Server) createVPNUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
switch t.Protocol {
|
||||
case "openvpn", "l2tp", "ikev2":
|
||||
case "openvpn", "l2tp", "ikev2", "wireguard":
|
||||
default:
|
||||
c.JSON(400, gin.H{"error": "该协议不支持拨号用户(支持 OpenVPN / L2TP / IKEv2)"})
|
||||
c.JSON(400, gin.H{"error": "该协议不支持拨号用户(支持 OpenVPN / L2TP / IKEv2 / WireGuard)"})
|
||||
return
|
||||
}
|
||||
if req.PlatformUserID != 0 {
|
||||
@@ -586,6 +586,14 @@ func (s *Server) batchDeleteVPNUsers(c *gin.Context) {
|
||||
store.DB.Where("id IN ?", req.IDs).Find(&users)
|
||||
tunnels := map[uint]bool{}
|
||||
for _, user := range users {
|
||||
if err := s.revokeClientDevicesForUser(&user, c.GetString("username")); err != nil {
|
||||
c.JSON(500, gin.H{"error": "撤销客户端设备失败,请重试:" + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := revokeClientSessionsForUser(&user); err != nil {
|
||||
c.JSON(500, gin.H{"error": "注销客户端会话失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
revokeOpenVPNCert(&user)
|
||||
tunnels[user.TunnelID] = true
|
||||
}
|
||||
@@ -642,6 +650,16 @@ func (s *Server) updateVPNUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
user.DisplayName, user.Email = req.DisplayName, req.Email
|
||||
if (req.Enabled != nil && !*req.Enabled && user.Enabled) || req.Password != "" {
|
||||
if err := s.revokeClientDevicesForUser(&user, c.GetString("username")); err != nil {
|
||||
c.JSON(500, gin.H{"error": "撤销客户端设备失败,账号未修改:" + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := revokeClientSessionsForUser(&user); err != nil {
|
||||
c.JSON(500, gin.H{"error": "注销客户端会话失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
if !*req.Enabled && user.Enabled {
|
||||
revokeOpenVPNCert(&user)
|
||||
@@ -665,6 +683,14 @@ func (s *Server) deleteVPNUser(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error == nil {
|
||||
if err := s.revokeClientDevicesForUser(&user, c.GetString("username")); err != nil {
|
||||
c.JSON(500, gin.H{"error": "撤销客户端设备失败,用户未删除:" + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := revokeClientSessionsForUser(&user); err != nil {
|
||||
c.JSON(500, gin.H{"error": "注销客户端会话失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
revokeOpenVPNCert(&user)
|
||||
tunnelID := user.TunnelID
|
||||
store.DB.Delete(&model.VPNUser{}, id)
|
||||
@@ -691,7 +717,21 @@ func revokeOpenVPNCert(user *model.VPNUser) {
|
||||
}
|
||||
}
|
||||
|
||||
// vpnUserMFASetup 为 OpenVPN 拨号用户生成 TOTP 密钥(待确认)。
|
||||
// WireGuard Peer 长期有效,用户凭据或授权变化前必须先从网关撤销对应设备。
|
||||
func (s *Server) revokeClientDevicesForUser(user *model.VPNUser, operator string) error {
|
||||
var devices []model.ClientDevice
|
||||
if err := store.DB.Where("vpn_user_id = ? AND revoked = ?", user.ID, false).Find(&devices).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range devices {
|
||||
if err := s.revokeDevice(&devices[i], operator); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// vpnUserMFASetup 为 OpenVPN/WireGuard 拨号用户生成 TOTP 密钥(待确认)。
|
||||
func (s *Server) vpnUserMFASetup(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
@@ -700,13 +740,17 @@ func (s *Server) vpnUserMFASetup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
t, err := store.GetTunnel(user.TunnelID)
|
||||
if err != nil || t.Protocol != "openvpn" {
|
||||
c.JSON(400, gin.H{"error": "仅 OpenVPN 拨号用户支持 MFA"})
|
||||
if err != nil || (t.Protocol != "openvpn" && t.Protocol != "wireguard") {
|
||||
c.JSON(400, gin.H{"error": "仅 OpenVPN / WireGuard 拨号用户支持 MFA"})
|
||||
return
|
||||
}
|
||||
issuer := "OPSX-OpenVPN"
|
||||
if t.Protocol == "wireguard" {
|
||||
issuer = "OPSX VPN"
|
||||
}
|
||||
// TOTP 标准参数(RFC 6238):SHA1、30s、6 位、base32,兼容主流 Authenticator
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: "OPSX-OpenVPN",
|
||||
Issuer: issuer,
|
||||
AccountName: fmt.Sprintf("%s@%s", user.Username, t.Name),
|
||||
Period: 30,
|
||||
Digits: otp.DigitsSix,
|
||||
@@ -775,6 +819,14 @@ func (s *Server) vpnUserMFAEnable(c *gin.Context) {
|
||||
user.MFASecret = secret
|
||||
user.MFABackupCodes = backups
|
||||
user.MFAEnabled = true
|
||||
if err := s.revokeClientDevicesForUser(&user, c.GetString("username")); err != nil {
|
||||
c.JSON(500, gin.H{"error": "撤销已有客户端设备失败,MFA 未启用:" + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := revokeClientSessionsForUser(&user); err != nil {
|
||||
c.JSON(500, gin.H{"error": "注销客户端会话失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := store.DB.Save(&user).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -790,6 +842,14 @@ func (s *Server) vpnUserMFADisable(c *gin.Context) {
|
||||
c.JSON(404, gin.H{"error": "拨号用户不存在"})
|
||||
return
|
||||
}
|
||||
if err := s.revokeClientDevicesForUser(&user, c.GetString("username")); err != nil {
|
||||
c.JSON(500, gin.H{"error": "撤销客户端设备失败,MFA 未关闭:" + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := revokeClientSessionsForUser(&user); err != nil {
|
||||
c.JSON(500, gin.H{"error": "注销客户端会话失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
store.DB.Model(&user).Updates(map[string]interface{}{
|
||||
"mfa_enabled": false, "mfa_secret": "", "mfa_backup_codes": "",
|
||||
"mfa_pin": "", "mfa_has_pin": false, "mfa_fail_count": 0, "mfa_locked_until": nil,
|
||||
@@ -838,6 +898,18 @@ func (s *Server) vpnUserMFAPin(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if req.Pin != "" && (len(req.Pin) < 4 || len(req.Pin) > 32 || strings.ContainsAny(req.Pin, " \t\r\n")) {
|
||||
c.JSON(400, gin.H{"error": "PIN 长度需 4-32 位且不能包含空白字符"})
|
||||
return
|
||||
}
|
||||
if err := s.revokeClientDevicesForUser(&user, c.GetString("username")); err != nil {
|
||||
c.JSON(500, gin.H{"error": "撤销客户端设备失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := revokeClientSessionsForUser(&user); err != nil {
|
||||
c.JSON(500, gin.H{"error": "注销客户端会话失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := openvpn.SetUserMFAPin(&user, req.Pin); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -110,12 +110,12 @@ type Tunnel struct {
|
||||
type Node struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:128" json:"name"`
|
||||
Address string `gorm:"size:256" json:"address"` // 节点地址(IP 或域名)
|
||||
Port int `gorm:"default:22" json:"port"` // SSH 端口
|
||||
Kind string `gorm:"size:32;default:local" json:"kind"` // local / remote
|
||||
Username string `gorm:"size:64" json:"username"` // SSH 登录用户
|
||||
Address string `gorm:"size:256" json:"address"` // 节点地址(IP 或域名)
|
||||
Port int `gorm:"default:22" json:"port"` // SSH 端口
|
||||
Kind string `gorm:"size:32;default:local" json:"kind"` // local / remote
|
||||
Username string `gorm:"size:64" json:"username"` // SSH 登录用户
|
||||
AuthType string `gorm:"size:16;default:password" json:"auth_type"` // password / key
|
||||
Secrets string `gorm:"type:text" json:"-"` // AES-GCM 加密的 {password, private_key} JSON
|
||||
Secrets string `gorm:"type:text" json:"-"` // AES-GCM 加密的 {password, private_key} JSON
|
||||
Status string `gorm:"size:16;default:unknown" json:"status"` // verified / failed / unknown
|
||||
Remark string `gorm:"size:512" json:"remark"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -175,12 +175,45 @@ type ConnectionLog struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ClientDevice 终端用户设备(桌面/移动客户端注册)。
|
||||
// 私钥只在客户端生成与保管,服务端仅保存公钥;撤销后不可恢复,需用新密钥重新注册。
|
||||
type ClientDevice struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
VPNUserID uint `gorm:"index" json:"-"`
|
||||
TunnelID uint `gorm:"index;index:idx_client_device_user" json:"tunnel_id"`
|
||||
Username string `gorm:"size:128;index:idx_client_device_user" json:"username"` // 拨号用户名(VPNUser)
|
||||
Name string `gorm:"size:128" json:"name"` // 设备名,如 "张三的 MacBook"
|
||||
Platform string `gorm:"size:32" json:"platform"` // windows / macos / linux / android / ios
|
||||
PeerID string `gorm:"size:64" json:"peer_id"` // WireGuard Peer ID(dev-<设备ID>)
|
||||
PublicKey string `gorm:"size:64;uniqueIndex" json:"public_key"`
|
||||
ClientAddress string `gorm:"size:64" json:"client_address"` // 分配的 VPN 地址,如 10.8.0.7/32
|
||||
Revoked bool `gorm:"default:false" json:"revoked"`
|
||||
RxBytes int64 `gorm:"default:0" json:"rx_bytes"`
|
||||
TxBytes int64 `gorm:"default:0" json:"tx_bytes"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at"` // 最近一次握手时间
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ClientSession 客户端长期访问令牌(只保存 SHA-256 哈希,滑动续期)。
|
||||
// 与管理后台内存会话完全隔离,不携带任何管理权限。
|
||||
type ClientSession struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TokenHash string `gorm:"size:64;uniqueIndex" json:"-"`
|
||||
Grants string `gorm:"type:text" json:"-"` // 授权的 VPNUser ID 与凭据指纹;旧会话无授权
|
||||
Username string `gorm:"size:128;index" json:"username"`
|
||||
DeviceName string `gorm:"size:128" json:"device_name"`
|
||||
Platform string `gorm:"size:32" json:"platform"`
|
||||
ExpireAt time.Time `gorm:"index" json:"expire_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TrafficPolicy 流量配额与限速策略(挂在 Tunnel 上) // Logan.Li
|
||||
type TrafficPolicy struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TunnelID uint `gorm:"uniqueIndex" json:"tunnel_id"`
|
||||
QuotaBytes int64 `gorm:"default:0" json:"quota_bytes"` // 总配额,0=不限
|
||||
RateLimitMbps int `gorm:"default:0" json:"rate_limit_mbps"` // 限速 Mbps,0=不限
|
||||
QuotaBytes int64 `gorm:"default:0" json:"quota_bytes"` // 总配额,0=不限
|
||||
RateLimitMbps int `gorm:"default:0" json:"rate_limit_mbps"` // 限速 Mbps,0=不限
|
||||
UsedBytes int64 `gorm:"default:0" json:"used_bytes"`
|
||||
ResetCycle string `gorm:"size:16;default:none" json:"reset_cycle"` // daily / monthly / none
|
||||
LastResetAt time.Time `json:"last_reset_at"`
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// device.go 面向终端用户的客户端设备 Peer 管理。
|
||||
//
|
||||
// 与管理端的 AddPeer/RemovePeer 不同,这里登记的 Peer 全部由客户端
|
||||
// 自行生成并保管私钥(外部 Peer),服务端只保存公钥;隧道运行中通过
|
||||
// wg set 增量下发/移除,不重启接口、不中断其他 Peer 的现有连接。
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
// IsWireGuardKey 校验 base64 编码的 32 字节 WireGuard 密钥格式。
|
||||
func IsWireGuardKey(key string) bool { return isWireGuardKey(key) }
|
||||
|
||||
// InterfaceName 返回隧道对应的内核接口名(与 Create/Start 使用的名字一致)。
|
||||
func InterfaceName(t *model.Tunnel) string { return interfaceName(t) }
|
||||
|
||||
// RegisterDevicePeer 把客户端设备公钥登记为外部 Peer 并立即生效。
|
||||
// 返回为该 Peer 生成的 PresharedKey(需随连接参数下发给设备)。
|
||||
func (s *Service) RegisterDevicePeer(t *model.Tunnel, id, name, publicKey, clientAddress string) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secrets, err := store.TunnelSecrets(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
peers = append(peers, peer{
|
||||
ID: id, Name: name, PublicKey: publicKey,
|
||||
ClientAddress: clientAddress, Enabled: true, Managed: false,
|
||||
})
|
||||
if err := validatePeerInput(peers); err != nil {
|
||||
return "", err
|
||||
}
|
||||
peerSecrets, err := s.persistPeers(t, cfg, secrets, peers)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
preshared := peerSecrets[id].PresharedKey
|
||||
if t.State == "running" {
|
||||
if err := s.runtimeAddPeer(t, publicKey, clientAddress, preshared); err != nil {
|
||||
// wg set 可能只执行了部分操作;确保运行接口和持久配置都回到登记前。
|
||||
if _, cleanupErr := service.Run("wg", "set", interfaceName(t), "peer", publicKey, "remove"); cleanupErr != nil {
|
||||
return "", fmt.Errorf("运行时下发失败: %w;清理运行 Peer 失败: %v", err, cleanupErr)
|
||||
}
|
||||
if cleanupErr := s.removePersistedDevicePeer(t, id); cleanupErr != nil {
|
||||
return "", fmt.Errorf("运行时下发失败: %w;清理持久 Peer 失败: %v", err, cleanupErr)
|
||||
}
|
||||
return "", fmt.Errorf("运行时下发失败,已回滚设备登记: %w", err)
|
||||
}
|
||||
}
|
||||
return preshared, nil
|
||||
}
|
||||
|
||||
// RemoveDevicePeer 撤销设备 Peer:从配置与运行时同时移除。
|
||||
// 与管理端一致,不允许移除最后一个 Peer(否则隧道无法重启)。
|
||||
func (s *Service) RemoveDevicePeer(t *model.Tunnel, peerID string) error {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var removed *peer
|
||||
next := make([]peer, 0, len(peers))
|
||||
for i, p := range peers {
|
||||
if p.ID == peerID {
|
||||
removed = &peers[i]
|
||||
continue
|
||||
}
|
||||
next = append(next, p)
|
||||
}
|
||||
if removed == nil {
|
||||
return fmt.Errorf("Peer 不存在: %s", peerID)
|
||||
}
|
||||
if len(next) == 0 {
|
||||
return fmt.Errorf("至少保留一个 Peer,无法移除;可先停用整条专线")
|
||||
}
|
||||
secrets, err := store.TunnelSecrets(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 撤销先作用于运行中的接口:若失败,则不改磁盘配置或设备状态。
|
||||
if t.State == "running" {
|
||||
if _, err := service.Run("wg", "set", interfaceName(t), "peer", removed.PublicKey, "remove"); err != nil {
|
||||
return fmt.Errorf("运行中移除 Peer 失败,未更改持久配置: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := s.persistPeers(t, cfg, secrets, next); err != nil {
|
||||
if t.State == "running" {
|
||||
oldSecrets, _ := store.TunnelSecrets(t)
|
||||
oldPSK := peerSecretsForID(oldSecrets, peerID)
|
||||
if restoreErr := s.runtimeAddPeer(t, removed.PublicKey, removed.ClientAddress, oldPSK); restoreErr != nil {
|
||||
return fmt.Errorf("保存撤销配置失败: %w;恢复运行 Peer 也失败: %v", err, restoreErr)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func peerSecretsForID(secrets map[string]interface{}, id string) string {
|
||||
entries, _ := secrets["peer_secrets"].(map[string]interface{})
|
||||
entry, _ := entries[id].(map[string]interface{})
|
||||
psk, _ := entry["preshared_key"].(string)
|
||||
return psk
|
||||
}
|
||||
|
||||
// 登记出错后,仅移除已写入磁盘的设备,不要求再对不存在的运行接口操作。
|
||||
func (s *Service) removePersistedDevicePeer(t *model.Tunnel, id string) error {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next := make([]peer, 0, len(peers))
|
||||
for _, p := range peers {
|
||||
if p.ID != id {
|
||||
next = append(next, p)
|
||||
}
|
||||
}
|
||||
secrets, err := store.TunnelSecrets(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.persistPeers(t, cfg, secrets, next)
|
||||
return err
|
||||
}
|
||||
|
||||
// persistPeers 归一化 Peer 列表(补齐 PSK 等)、重写服务端 conf 并落库。
|
||||
// 与 Create 的持久化尾部一致,但不做端口/密钥初始化——调用前配置必须已初始化。
|
||||
// 返回归一化后的逐 Peer 密钥(含新生成的 PresharedKey)。
|
||||
func (s *Service) persistPeers(t *model.Tunnel, cfg, secrets map[string]interface{}, peers []peer) (map[string]peerSecret, error) {
|
||||
cfg["peers"] = peersToConfig(peers)
|
||||
normalized, peerSecrets, err := buildPeers(cfg, secrets)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg["peers"] = peersToConfig(normalized)
|
||||
secrets["peer_secrets"] = peerSecretsToConfig(peerSecrets)
|
||||
|
||||
serverPrivate := service.Str(secrets, "server_private_key")
|
||||
if serverPrivate == "" {
|
||||
return nil, fmt.Errorf("专线尚未初始化服务端密钥,请先在管理端保存一次配置")
|
||||
}
|
||||
serverConf := renderServerConfig(cfg, serverPrivate, normalized, peerSecrets)
|
||||
confPath := InstanceConfPath(t)
|
||||
previous, readErr := os.ReadFile(confPath)
|
||||
previousConfig, previousSecrets := t.Config, t.Secrets
|
||||
if _, err := service.WriteConfig(t, interfaceName(t)+".conf", serverConf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
public, sec := service.MergeSecrets(cfg, "server_private_key")
|
||||
for k, v := range secrets {
|
||||
sec[k] = v
|
||||
}
|
||||
if err := service.SaveTunnelConfig(t, public, sec); err != nil {
|
||||
t.Config, t.Secrets = previousConfig, previousSecrets
|
||||
if readErr == nil {
|
||||
_ = os.WriteFile(confPath, previous, 0o600)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return peerSecrets, nil
|
||||
}
|
||||
|
||||
// runtimeAddPeer 用 wg set 把新 Peer 增量下发到运行中的接口,避免重启。
|
||||
func (s *Service) runtimeAddPeer(t *model.Tunnel, publicKey, clientAddress, presharedKey string) error {
|
||||
iface := interfaceName(t)
|
||||
args := []string{"set", iface, "peer", publicKey}
|
||||
var pskFile string
|
||||
if presharedKey != "" {
|
||||
dir := service.InstanceDir(t)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(dir, ".psk-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pskFile = f.Name()
|
||||
if _, err := f.WriteString(presharedKey); err != nil {
|
||||
f.Close()
|
||||
os.Remove(pskFile)
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
_ = os.Chmod(pskFile, 0o600)
|
||||
defer os.Remove(pskFile)
|
||||
args = append(args, "preshared-key", pskFile)
|
||||
}
|
||||
args = append(args, "allowed-ips", clientAddress)
|
||||
_, err := service.Run("wg", args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// UsedClientAddresses 返回服务端自身地址与全部 Peer 已占用的 IPv4/IPv6 地址。
|
||||
func (s *Service) UsedClientAddresses(t *model.Tunnel) (map[string]bool, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
used := map[string]bool{}
|
||||
if ip, _, err := net.ParseCIDR(service.Str(cfg, "address")); err == nil && ip != nil {
|
||||
used[ip.String()] = true
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range peers {
|
||||
if ip, _, err := net.ParseCIDR(p.ClientAddress); err == nil && ip != nil {
|
||||
used[ip.String()] = true
|
||||
}
|
||||
}
|
||||
return used, nil
|
||||
}
|
||||
|
||||
// DeviceConnectionParams 返回下发给指定设备 Peer 的连接参数。
|
||||
// 不含任何客户端私钥(客户端私钥只存在于设备上),PSK 经客户端认证的
|
||||
// HTTPS 通道下发,用于抗量子的一次性对称加固。
|
||||
func (s *Service) DeviceConnectionParams(t *model.Tunnel, peerID string) (map[string]interface{}, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secrets, err := store.TunnelSecrets(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range peers {
|
||||
if p.ID != peerID {
|
||||
continue
|
||||
}
|
||||
allowed := p.AllowedIPs
|
||||
if allowed == "" {
|
||||
allowed = service.StrOr(cfg, "allowed_ips", "0.0.0.0/0, ::/0")
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"peer_id": p.ID,
|
||||
"client_address": p.ClientAddress,
|
||||
"server_public_key": service.Str(cfg, "server_public_key"),
|
||||
"preshared_key": parsePeerSecrets(secrets)[p.ID].PresharedKey,
|
||||
"endpoint": service.StrOr(cfg, "public_endpoint", fmt.Sprintf("your-server-ip:%d", int(service.Num(cfg, "listen_port", defaultPort)))),
|
||||
"listen_port": int(service.Num(cfg, "listen_port", defaultPort)),
|
||||
"allowed_ips": allowed,
|
||||
"dns": service.StrOr(cfg, "dns", service.DefaultDNS()),
|
||||
"mtu": int(service.Num(cfg, "mtu", 0)),
|
||||
"keepalive": int(service.Num(cfg, "keepalive", 25)),
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, fmt.Errorf("设备 Peer 不存在: %s", peerID)
|
||||
}
|
||||
|
||||
// InstanceConfPath 暴露实例服务端配置路径(测试与诊断用)。
|
||||
func InstanceConfPath(t *model.Tunnel) string {
|
||||
return filepath.Join(service.InstanceDir(t), interfaceName(t)+".conf")
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
func setupDeviceTestTunnel(t *testing.T) *model.Tunnel {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
t.Setenv("VPN_DATA_DIR", base)
|
||||
if err := store.Init(base); err != nil {
|
||||
t.Fatalf("store.Init: %v", err)
|
||||
}
|
||||
tun := &model.Tunnel{
|
||||
Name: "客户端测试专线",
|
||||
Protocol: "wireguard",
|
||||
State: "stopped",
|
||||
Config: `{"address":"10.9.0.1/24","listen_port":53999}`,
|
||||
}
|
||||
if err := store.DB.Create(tun).Error; err != nil {
|
||||
t.Fatalf("create tunnel: %v", err)
|
||||
}
|
||||
if err := New().Create(tun); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
return tun
|
||||
}
|
||||
|
||||
func TestRegisterAndRemoveDevicePeer(t *testing.T) {
|
||||
tun := setupDeviceTestTunnel(t)
|
||||
svc := New()
|
||||
|
||||
// 初始 Create 会把无 peers 的旧配置迁移出 client-1 托管 Peer(默认 10.8.0.2/32)
|
||||
used, err := svc.UsedClientAddresses(tun)
|
||||
if err != nil {
|
||||
t.Fatalf("UsedClientAddresses: %v", err)
|
||||
}
|
||||
if !used["10.9.0.1"] || !used["10.8.0.2"] {
|
||||
t.Fatalf("应包含服务端与初始 Peer 地址: %v", used)
|
||||
}
|
||||
|
||||
priv, pub, err := genKey()
|
||||
if err != nil || priv == "" {
|
||||
t.Fatalf("genKey: %v", err)
|
||||
}
|
||||
psk, err := svc.RegisterDevicePeer(tun, "dev-1", "测试手机", pub, "10.9.0.7/32")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterDevicePeer: %v", err)
|
||||
}
|
||||
if !isWireGuardKey(psk) {
|
||||
t.Fatal("应为设备 Peer 生成合法 PresharedKey")
|
||||
}
|
||||
|
||||
params, err := svc.DeviceConnectionParams(tun, "dev-1")
|
||||
if err != nil {
|
||||
t.Fatalf("DeviceConnectionParams: %v", err)
|
||||
}
|
||||
if params["client_address"] != "10.9.0.7/32" || params["preshared_key"] != psk {
|
||||
t.Fatalf("连接参数不含分配地址或 PSK 不匹配: %v", params)
|
||||
}
|
||||
if params["server_public_key"] == "" || params["endpoint"] == "" {
|
||||
t.Fatalf("连接参数缺少服务端公钥或端点: %v", params)
|
||||
}
|
||||
|
||||
// 重复公钥或重复地址必须被拒绝
|
||||
if _, err := svc.RegisterDevicePeer(tun, "dev-2", "重复密钥", pub, "10.9.0.8/32"); err == nil {
|
||||
t.Fatal("重复公钥必须被拒绝")
|
||||
}
|
||||
_, pub2, _ := genKey()
|
||||
if _, err := svc.RegisterDevicePeer(tun, "dev-3", "重复地址", pub2, "10.9.0.7/32"); err == nil {
|
||||
t.Fatal("重复地址必须被拒绝")
|
||||
}
|
||||
|
||||
// 服务端 conf 必须包含新 Peer
|
||||
conf := readFile(t, InstanceConfPath(tun))
|
||||
if !strings.Contains(conf, pub) {
|
||||
t.Fatal("服务端配置未包含设备公钥")
|
||||
}
|
||||
|
||||
if err := svc.RemoveDevicePeer(tun, "dev-1"); err != nil {
|
||||
t.Fatalf("RemoveDevicePeer: %v", err)
|
||||
}
|
||||
if _, err := svc.DeviceConnectionParams(tun, "dev-1"); err == nil {
|
||||
t.Fatal("撤销后不应再能取到连接参数")
|
||||
}
|
||||
conf = readFile(t, InstanceConfPath(tun))
|
||||
if strings.Contains(conf, pub) {
|
||||
t.Fatal("撤销后服务端配置仍包含设备公钥")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveDevicePeerKeepsLastPeer(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
t.Setenv("VPN_DATA_DIR", base)
|
||||
if err := store.Init(base); err != nil {
|
||||
t.Fatalf("store.Init: %v", err)
|
||||
}
|
||||
// 没有任何既有 Peer 的隧道:登记一个设备后它就是唯一 Peer
|
||||
tun := &model.Tunnel{
|
||||
Name: "空专线", Protocol: "wireguard", State: "stopped",
|
||||
Config: `{"address":"10.10.0.1/24","listen_port":53998,"peers":[]}`,
|
||||
}
|
||||
if err := store.DB.Create(tun).Error; err != nil {
|
||||
t.Fatalf("create tunnel: %v", err)
|
||||
}
|
||||
// peers 为空数组时 Create 会拒绝;直接手工初始化密钥再登记设备
|
||||
tun.Config = `{"address":"10.10.0.1/24","listen_port":53998,"peers":[{"id":"seed","name":"种子","public_key":"","client_address":"10.10.0.2/32"}]}`
|
||||
if err := New().Create(tun); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
svc := New()
|
||||
if err := svc.RemoveDevicePeer(tun, "seed"); err == nil {
|
||||
t.Fatal("不允许移除最后一个 Peer")
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func Init(dataDir string) error {
|
||||
}
|
||||
// 兼容旧库:VPNUser 唯一约束曾是 username 单列全局唯一
|
||||
db.Exec("DROP INDEX IF EXISTS idx_vpn_user")
|
||||
if err := db.AutoMigrate(&model.User{}, &model.LDAPConfig{}, &model.MFADevice{}, &model.VPNUser{}, &model.Tunnel{}, &model.Node{}, &model.Plugin{}, &model.AuditLog{}, &model.VPNSession{}, &model.ConnectionLog{}, &model.TrafficPolicy{}, &model.TrafficSample{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.User{}, &model.LDAPConfig{}, &model.MFADevice{}, &model.VPNUser{}, &model.Tunnel{}, &model.Node{}, &model.Plugin{}, &model.AuditLog{}, &model.VPNSession{}, &model.ConnectionLog{}, &model.TrafficPolicy{}, &model.TrafficSample{}, &model.ClientDevice{}, &model.ClientSession{}); err != nil {
|
||||
return err
|
||||
}
|
||||
DB = db
|
||||
|
||||
Reference in new issue
Block a user