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
|
||||
|
||||
Generated
+88
-4
@@ -330,7 +330,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -486,6 +486,32 @@ version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.24.1"
|
||||
@@ -772,6 +798,12 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@@ -1062,7 +1094,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -1244,6 +1276,17 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hostname"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.39.0"
|
||||
@@ -2152,10 +2195,16 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
name = "opsx-vpn-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"hostname",
|
||||
"rand_core 0.6.4",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"x25519-dalek",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2514,7 +2563,16 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2529,7 +2587,7 @@ version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4573,6 +4631,18 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x25519-dalek"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
@@ -4622,6 +4692,20 @@ name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
|
||||
@@ -15,4 +15,10 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
base64 = "0.22"
|
||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
hostname = "0.4"
|
||||
sha2 = "0.10"
|
||||
@@ -0,0 +1,295 @@
|
||||
// 控制面 API:所有请求在 Rust 侧完成,令牌不进入 WebView。
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::{redirect::Policy, StatusCode, Url};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Line {
|
||||
pub tunnel_id: u64,
|
||||
pub name: String,
|
||||
pub remark: String,
|
||||
pub state: String,
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoginResult {
|
||||
pub token: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub lines: Vec<Line>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceConfig {
|
||||
pub device_id: u64,
|
||||
pub device_name: String,
|
||||
pub tunnel_id: u64,
|
||||
pub tunnel_name: String,
|
||||
pub peer_id: String,
|
||||
pub client_address: String,
|
||||
pub server_public_key: String,
|
||||
pub preshared_key: String,
|
||||
pub endpoint: String,
|
||||
pub allowed_ips: String,
|
||||
pub dns: String,
|
||||
pub mtu: u32,
|
||||
pub keepalive: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceInfo {
|
||||
pub id: u64,
|
||||
pub tunnel_id: u64,
|
||||
pub name: String,
|
||||
pub platform: String,
|
||||
pub client_address: String,
|
||||
pub revoked: bool,
|
||||
pub last_seen_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceStatus {
|
||||
pub running: bool,
|
||||
pub connected: bool,
|
||||
pub latest_handshake_at: Option<i64>,
|
||||
pub rx_bytes: Option<i64>,
|
||||
pub tx_bytes: Option<i64>,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ApiError {
|
||||
/// 保留 HTTP 状态,只有明确 401 才应清除本地登录。
|
||||
Message(StatusCode, String, bool),
|
||||
Transport(String),
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn message(&self) -> String {
|
||||
match self {
|
||||
ApiError::Message(_, m, _) => m.clone(),
|
||||
ApiError::Transport(m) => m.clone(),
|
||||
}
|
||||
}
|
||||
pub fn mfa_required(&self) -> bool {
|
||||
matches!(self, ApiError::Message(_, _, true))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorBody {
|
||||
error: Option<String>,
|
||||
mfa_required: Option<bool>,
|
||||
}
|
||||
|
||||
fn client() -> Result<reqwest::Client, ApiError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.redirect(Policy::none())
|
||||
.build()
|
||||
.map_err(|err| ApiError::Transport(format!("无法创建网络客户端:{err}")))
|
||||
}
|
||||
|
||||
/// 复用健康检查同样的根地址校验,再拼上 API 路径。
|
||||
fn api_url(base_url: &str, path: &str) -> Result<Url, ApiError> {
|
||||
let mut url = Url::parse(base_url.trim())
|
||||
.map_err(|_| ApiError::Transport("服务端地址无效".to_string()))?;
|
||||
if url.scheme() != "https" && url.scheme() != "http" {
|
||||
return Err(ApiError::Transport(
|
||||
"服务端地址须以 http:// 或 https:// 开头".to_string(),
|
||||
));
|
||||
}
|
||||
url.set_path(path);
|
||||
url.set_query(None);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn parse_response<T: DeserializeOwned>(resp: reqwest::Response) -> Result<T, ApiError> {
|
||||
let status = resp.status();
|
||||
if resp
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.is_some_and(|v| v.to_str().unwrap_or("").starts_with("text/html"))
|
||||
{
|
||||
return Err(ApiError::Transport("服务端尚未提供客户端登录接口,请先更新 opsx-vpn 服务端;/users 是管理账号,客户端需使用 VPN 拨号用户。".into()));
|
||||
}
|
||||
if status.is_success() {
|
||||
return resp
|
||||
.json::<T>()
|
||||
.await
|
||||
.map_err(|_| ApiError::Transport("服务端响应格式无法解析".to_string()));
|
||||
}
|
||||
let body = resp.json::<ErrorBody>().await.unwrap_or(ErrorBody {
|
||||
error: None,
|
||||
mfa_required: None,
|
||||
});
|
||||
let message = body
|
||||
.error
|
||||
.unwrap_or_else(|| format!("服务端返回 HTTP {status}"));
|
||||
Err(ApiError::Message(
|
||||
status,
|
||||
message,
|
||||
body.mfa_required.unwrap_or(false),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
base_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
otp: &str,
|
||||
device_name: &str,
|
||||
) -> Result<LoginResult, ApiError> {
|
||||
// 旧版服务端会把未知 /api/client/* 路径返回 200 HTML;先确认能力,避免发送密码。
|
||||
let capabilities = client()?
|
||||
.get(api_url(base_url, "/api/client/capabilities")?)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
let supported: serde_json::Value = parse_response(capabilities).await?;
|
||||
if supported.get("password").and_then(|v| v.as_bool()) != Some(true) {
|
||||
return Err(ApiError::Transport("服务端尚未启用客户端密码登录".into()));
|
||||
}
|
||||
let resp = client()?
|
||||
.post(api_url(base_url, "/api/client/login")?)
|
||||
.json(&serde_json::json!({
|
||||
"username": username,
|
||||
"password": password,
|
||||
"otp": otp,
|
||||
"device_name": device_name,
|
||||
"platform": std::env::consts::OS,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
parse_response(resp).await
|
||||
}
|
||||
|
||||
pub async fn logout(base_url: &str, token: &str) -> Result<(), ApiError> {
|
||||
let resp = client()?
|
||||
.post(api_url(base_url, "/api/client/logout")?)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
if resp.status() == StatusCode::UNAUTHORIZED {
|
||||
return Ok(()); // 令牌已失效,本地照常清理
|
||||
}
|
||||
let _: serde_json::Value = parse_response(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn lines(base_url: &str, token: &str) -> Result<Vec<Line>, ApiError> {
|
||||
#[derive(Deserialize)]
|
||||
struct Resp {
|
||||
lines: Vec<Line>,
|
||||
}
|
||||
let resp = client()?
|
||||
.get(api_url(base_url, "/api/client/lines")?)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
Ok(parse_response::<Resp>(resp).await?.lines)
|
||||
}
|
||||
|
||||
pub async fn register_device(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
tunnel_id: u64,
|
||||
name: &str,
|
||||
public_key: &str,
|
||||
) -> Result<DeviceConfig, ApiError> {
|
||||
let resp = client()?
|
||||
.post(api_url(base_url, "/api/client/devices")?)
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({
|
||||
"tunnel_id": tunnel_id,
|
||||
"name": name,
|
||||
"platform": std::env::consts::OS,
|
||||
"public_key": public_key,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
parse_response(resp).await
|
||||
}
|
||||
|
||||
pub async fn device_config(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
device_id: u64,
|
||||
) -> Result<DeviceConfig, ApiError> {
|
||||
let resp = client()?
|
||||
.get(api_url(
|
||||
base_url,
|
||||
&format!("/api/client/devices/{device_id}/config"),
|
||||
)?)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
parse_response(resp).await
|
||||
}
|
||||
|
||||
pub async fn device_status(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
device_id: u64,
|
||||
) -> Result<DeviceStatus, ApiError> {
|
||||
let resp = client()?
|
||||
.get(api_url(
|
||||
base_url,
|
||||
&format!("/api/client/devices/{device_id}/status"),
|
||||
)?)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
parse_response(resp).await
|
||||
}
|
||||
|
||||
pub async fn list_devices(base_url: &str, token: &str) -> Result<Vec<DeviceInfo>, ApiError> {
|
||||
#[derive(Deserialize)]
|
||||
struct Resp {
|
||||
devices: Vec<DeviceInfo>,
|
||||
}
|
||||
let resp = client()?
|
||||
.get(api_url(base_url, "/api/client/devices")?)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
Ok(parse_response::<Resp>(resp).await?.devices)
|
||||
}
|
||||
|
||||
pub async fn revoke_device(base_url: &str, token: &str, device_id: u64) -> Result<(), ApiError> {
|
||||
let resp = client()?
|
||||
.delete(api_url(
|
||||
base_url,
|
||||
&format!("/api/client/devices/{device_id}"),
|
||||
)?)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
let _: serde_json::Value = parse_response(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn api_url_appends_path_to_validated_root() {
|
||||
let url = api_url("https://vpn.example.com:8443/", "/api/client/login").unwrap();
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://vpn.example.com:8443/api/client/login"
|
||||
);
|
||||
assert!(api_url("ftp://vpn.example.com", "/api/client/lines").is_err());
|
||||
}
|
||||
}
|
||||
+480
-4
@@ -1,12 +1,470 @@
|
||||
mod api;
|
||||
mod localstore;
|
||||
mod server;
|
||||
mod system;
|
||||
mod tunnel;
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||
tray::TrayIconBuilder,
|
||||
Manager,
|
||||
AppHandle, Manager,
|
||||
};
|
||||
|
||||
use api::{DeviceConfig, DeviceInfo, DeviceStatus, Line};
|
||||
|
||||
// ---- 全局连接状态(UI 与托盘共用同一事实来源)----
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum ConnPhase {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ConnState {
|
||||
phase: ConnPhase,
|
||||
detail: String,
|
||||
tunnel_name: String,
|
||||
client_address: String,
|
||||
endpoint: String,
|
||||
device_id: u64,
|
||||
since_unix: i64,
|
||||
}
|
||||
|
||||
impl Default for ConnState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
phase: ConnPhase::Disconnected,
|
||||
detail: String::new(),
|
||||
tunnel_name: String::new(),
|
||||
client_address: String::new(),
|
||||
endpoint: String::new(),
|
||||
device_id: 0,
|
||||
since_unix: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static CONN: Mutex<Option<ConnState>> = Mutex::new(None);
|
||||
static TRAY_MENU: Mutex<Option<tauri::menu::Menu<tauri::Wry>>> = Mutex::new(None);
|
||||
|
||||
fn conn_state() -> ConnState {
|
||||
CONN.lock()
|
||||
.unwrap()
|
||||
.get_or_insert_with(ConnState::default)
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn set_conn(app: Option<&AppHandle>, state: ConnState) {
|
||||
*CONN.lock().unwrap() = Some(state.clone());
|
||||
if let Some(app) = app {
|
||||
update_tray(app, &state);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_tray(app: &AppHandle, state: &ConnState) {
|
||||
let (label, tip) = match state.phase {
|
||||
ConnPhase::Connected => (
|
||||
format!("VPN:已连接 · {}", state.tunnel_name),
|
||||
format!("OpsX VPN · 已连接 {}", state.tunnel_name),
|
||||
),
|
||||
ConnPhase::Connecting => ("VPN:连接中…".to_string(), "OpsX VPN · 连接中".to_string()),
|
||||
ConnPhase::Disconnected => (
|
||||
"VPN:尚未连接".to_string(),
|
||||
"OpsX VPN · 尚未连接".to_string(),
|
||||
),
|
||||
};
|
||||
if let Some(tray) = app.tray_by_id("tray") {
|
||||
let _ = tray.set_tooltip(Some(&tip));
|
||||
}
|
||||
if let Some(menu) = TRAY_MENU.lock().unwrap().as_ref() {
|
||||
if let Some(tauri::menu::MenuItemKind::MenuItem(item)) = menu.get("status") {
|
||||
let _ = item.set_text(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 辅助 ----
|
||||
|
||||
fn data_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map_err(|err| format!("无法定位应用数据目录:{err}"))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn require_session(app: &AppHandle) -> Result<localstore::Session, String> {
|
||||
localstore::load(&data_dir(app)?).ok_or_else(|| "尚未登录".to_string())
|
||||
}
|
||||
|
||||
fn default_device_name() -> String {
|
||||
hostname::get()
|
||||
.ok()
|
||||
.and_then(|h| h.into_string().ok())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| format!("{} 设备", std::env::consts::OS))
|
||||
}
|
||||
|
||||
// ---- 视图类型(返回给 UI)----
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SessionView {
|
||||
username: String,
|
||||
display_name: String,
|
||||
device_name: String,
|
||||
lines: Vec<Line>,
|
||||
offline: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StatusView {
|
||||
conn: ConnState,
|
||||
server: Option<DeviceStatus>,
|
||||
}
|
||||
|
||||
// ---- Tauri 命令 ----
|
||||
|
||||
#[tauri::command]
|
||||
async fn client_login(
|
||||
app: AppHandle,
|
||||
base_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
otp: String,
|
||||
device_name: String,
|
||||
) -> Result<SessionView, String> {
|
||||
let device_name = if device_name.trim().is_empty() {
|
||||
default_device_name()
|
||||
} else {
|
||||
device_name.trim().to_string()
|
||||
};
|
||||
let result = api::login(&base_url, &username, &password, &otp, &device_name)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if err.mfa_required() {
|
||||
"该账号需要动态口令,请输入 PIN+动态码(若未设置 PIN,只输入动态码)".to_string()
|
||||
} else {
|
||||
err.message()
|
||||
}
|
||||
})?;
|
||||
let session = localstore::Session {
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
token: result.token,
|
||||
username: result.username.clone(),
|
||||
display_name: result.display_name.clone(),
|
||||
device_name: device_name.clone(),
|
||||
lines: result.lines.clone(),
|
||||
};
|
||||
localstore::save(&data_dir(&app)?, &session)?;
|
||||
Ok(SessionView {
|
||||
username: result.username,
|
||||
display_name: result.display_name,
|
||||
device_name,
|
||||
lines: result.lines,
|
||||
offline: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 启动时恢复会话:401 清除本地会话返回 None;网络不可达时离线返回(线路为空)。
|
||||
#[tauri::command]
|
||||
async fn client_resume(app: AppHandle) -> Result<Option<SessionView>, String> {
|
||||
let dir = data_dir(&app)?;
|
||||
let Some(session) = localstore::load(&dir) else {
|
||||
return Ok(None);
|
||||
};
|
||||
match api::lines(&session.base_url, &session.token).await {
|
||||
Ok(lines) => {
|
||||
let view = SessionView {
|
||||
username: session.username.clone(),
|
||||
display_name: session.display_name.clone(),
|
||||
device_name: session.device_name.clone(),
|
||||
lines: lines.clone(),
|
||||
offline: false,
|
||||
};
|
||||
let mut updated = session;
|
||||
updated.lines = lines;
|
||||
let _ = localstore::save(&dir, &updated);
|
||||
Ok(Some(view))
|
||||
}
|
||||
Err(api::ApiError::Message(reqwest::StatusCode::UNAUTHORIZED, _, _)) => {
|
||||
// 令牌失效/被撤销:清除本地会话
|
||||
if let Err(err) = tunnel::down(&dir) {
|
||||
let mut state = conn_state();
|
||||
state.phase = ConnPhase::Connecting; // 保留 UI 的断开入口
|
||||
state.detail = format!("登录已失效,但本机隧道未拆除:{err}");
|
||||
set_conn(Some(&app), state);
|
||||
return Ok(Some(SessionView {
|
||||
username: session.username,
|
||||
display_name: session.display_name,
|
||||
device_name: session.device_name,
|
||||
lines: vec![],
|
||||
offline: true,
|
||||
}));
|
||||
}
|
||||
set_conn(Some(&app), ConnState::default());
|
||||
localstore::clear(&dir);
|
||||
Ok(None)
|
||||
}
|
||||
Err(_) => Ok(Some(SessionView {
|
||||
username: session.username,
|
||||
display_name: session.display_name,
|
||||
device_name: session.device_name,
|
||||
lines: vec![],
|
||||
offline: true,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn client_logout(app: AppHandle) -> Result<(), String> {
|
||||
let dir = data_dir(&app)?;
|
||||
if conn_state().phase != ConnPhase::Disconnected {
|
||||
// 保留记录与会话,确保拆隧道失败后可再次操作。
|
||||
tunnel::down(&dir)?;
|
||||
set_conn(Some(&app), ConnState::default());
|
||||
}
|
||||
if let Some(session) = localstore::load(&dir) {
|
||||
let _ = api::logout(&session.base_url, &session.token).await;
|
||||
}
|
||||
localstore::clear(&dir);
|
||||
tunnel::clear_record(&dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn client_lines(app: AppHandle) -> Result<Vec<Line>, String> {
|
||||
let session = require_session(&app)?;
|
||||
api::lines(&session.base_url, &session.token)
|
||||
.await
|
||||
.map_err(|err| err.message())
|
||||
}
|
||||
|
||||
/// 一键连接:已登记设备则刷新参数,否则生成密钥对并注册;随后提权起隧道。
|
||||
#[tauri::command]
|
||||
async fn client_connect(app: AppHandle, tunnel_id: u64) -> Result<ConnState, String> {
|
||||
let session = require_session(&app)?;
|
||||
let fresh_lines = api::lines(&session.base_url, &session.token)
|
||||
.await
|
||||
.map_err(|err| err.message())?;
|
||||
let line = fresh_lines
|
||||
.iter()
|
||||
.find(|l| l.tunnel_id == tunnel_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| "没有该线路的接入授权".to_string())?;
|
||||
if line.state != "running" {
|
||||
return Err(format!("线路「{}」未在运行,请联系管理员启动", line.name));
|
||||
}
|
||||
let dir = data_dir(&app)?;
|
||||
if conn_state().phase != ConnPhase::Disconnected {
|
||||
// 切换线路前先拆旧接口;拆除失败时保持原连接和本机记录。
|
||||
tunnel::down(&dir)?;
|
||||
set_conn(Some(&app), ConnState::default());
|
||||
}
|
||||
|
||||
let mut state = conn_state();
|
||||
state.phase = ConnPhase::Connecting;
|
||||
state.detail = "正在准备设备接入参数".to_string();
|
||||
state.tunnel_name = line.name.clone();
|
||||
set_conn(Some(&app), state);
|
||||
|
||||
let result = do_connect(&dir, &session, tunnel_id, &session.device_name).await;
|
||||
match result {
|
||||
Ok(config) => {
|
||||
let next = ConnState {
|
||||
phase: ConnPhase::Connecting, // 只有服务端观测到握手后才转为 Connected
|
||||
detail: "隧道已建立,等待握手确认".to_string(),
|
||||
tunnel_name: config.tunnel_name.clone(),
|
||||
client_address: config.client_address.clone(),
|
||||
endpoint: config.endpoint.clone(),
|
||||
device_id: config.device_id,
|
||||
since_unix: now_unix(),
|
||||
};
|
||||
set_conn(Some(&app), next.clone());
|
||||
Ok(next)
|
||||
}
|
||||
Err(err) => {
|
||||
let mut back = conn_state();
|
||||
back.phase = ConnPhase::Disconnected;
|
||||
back.detail = err.clone();
|
||||
set_conn(Some(&app), back);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_connect(
|
||||
dir: &std::path::Path,
|
||||
session: &localstore::Session,
|
||||
tunnel_id: u64,
|
||||
device_name: &str,
|
||||
) -> Result<DeviceConfig, String> {
|
||||
// 已登记同线路设备:刷新连接参数;否则生成新密钥对并注册
|
||||
let record_dir = tunnel::device_dir(dir, &session.base_url, &session.username);
|
||||
let record = tunnel::load_record_for(&record_dir, tunnel_id).ok();
|
||||
let config = match record {
|
||||
Some(rec) if rec.config.tunnel_id == tunnel_id => {
|
||||
let cfg = api::device_config(&session.base_url, &session.token, rec.config.device_id)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
format!("刷新设备参数失败(设备可能已被撤销):{}", err.message())
|
||||
})?;
|
||||
tunnel::save_record(
|
||||
&record_dir,
|
||||
&tunnel::TunnelRecord {
|
||||
private_key: rec.private_key,
|
||||
config: cfg.clone(),
|
||||
},
|
||||
)?;
|
||||
cfg
|
||||
}
|
||||
_ => {
|
||||
let (private_key, public_key) = tunnel::generate_keypair()?;
|
||||
let cfg = api::register_device(
|
||||
&session.base_url,
|
||||
&session.token,
|
||||
tunnel_id,
|
||||
device_name,
|
||||
&public_key,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| err.message())?;
|
||||
tunnel::save_record(
|
||||
&record_dir,
|
||||
&tunnel::TunnelRecord {
|
||||
private_key,
|
||||
config: cfg.clone(),
|
||||
},
|
||||
)?;
|
||||
cfg
|
||||
}
|
||||
};
|
||||
tunnel::up(
|
||||
dir,
|
||||
&tunnel::load_record_for(&record_dir, tunnel_id)?.private_key,
|
||||
&config,
|
||||
)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn client_disconnect(app: AppHandle) -> Result<(), String> {
|
||||
let dir = data_dir(&app)?;
|
||||
tunnel::down(&dir)?;
|
||||
set_conn(Some(&app), ConnState::default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 连接状态:以服务端观测到的真实握手为准,不凭本机进程猜。
|
||||
#[tauri::command]
|
||||
async fn client_status(app: AppHandle) -> Result<StatusView, String> {
|
||||
let mut conn = conn_state();
|
||||
if conn.phase == ConnPhase::Disconnected {
|
||||
return Ok(StatusView { conn, server: None });
|
||||
}
|
||||
let session = require_session(&app)?;
|
||||
match api::device_status(&session.base_url, &session.token, conn.device_id).await {
|
||||
Ok(status) => {
|
||||
let fresh_handshake = status.connected
|
||||
&& status
|
||||
.latest_handshake_at
|
||||
.is_some_and(|at| at >= conn.since_unix);
|
||||
if fresh_handshake && conn.phase != ConnPhase::Connected {
|
||||
conn.phase = ConnPhase::Connected;
|
||||
conn.detail = "握手已确认".to_string();
|
||||
set_conn(Some(&app), conn.clone());
|
||||
} else if !status.running {
|
||||
conn.phase = ConnPhase::Connecting; // 保留断开按钮,便于恢复本机路由
|
||||
conn.detail = "网关线路未运行".to_string();
|
||||
} else if conn.phase == ConnPhase::Connected && !fresh_handshake {
|
||||
conn.phase = ConnPhase::Connecting;
|
||||
conn.detail = "握手超时,连接可能已断开".to_string();
|
||||
} else if !fresh_handshake && now_unix() - conn.since_unix > 20 {
|
||||
conn.detail = "尚未观测到握手,请检查公网端点与防火墙".to_string();
|
||||
}
|
||||
set_conn(Some(&app), conn.clone());
|
||||
Ok(StatusView {
|
||||
conn,
|
||||
server: Some(status),
|
||||
})
|
||||
}
|
||||
Err(err) => Ok(StatusView {
|
||||
conn,
|
||||
server: Some(DeviceStatus {
|
||||
running: true,
|
||||
connected: false,
|
||||
latest_handshake_at: None,
|
||||
rx_bytes: None,
|
||||
tx_bytes: None,
|
||||
detail: Some(format!("状态查询失败:{}", err.message())),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn client_devices(app: AppHandle) -> Result<Vec<DeviceInfo>, String> {
|
||||
let session = require_session(&app)?;
|
||||
api::list_devices(&session.base_url, &session.token)
|
||||
.await
|
||||
.map_err(|err| err.message())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn client_revoke_device(app: AppHandle, device_id: u64) -> Result<(), String> {
|
||||
let session = require_session(&app)?;
|
||||
let dir = data_dir(&app)?;
|
||||
let devices = api::list_devices(&session.base_url, &session.token)
|
||||
.await
|
||||
.map_err(|err| err.message())?;
|
||||
let device = devices
|
||||
.iter()
|
||||
.find(|d| d.id == device_id)
|
||||
.ok_or("设备不存在")?;
|
||||
let record_dir = tunnel::device_dir(&dir, &session.base_url, &session.username);
|
||||
let local = tunnel::load_record_for(&record_dir, device.tunnel_id).ok();
|
||||
if local
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.config.device_id == device_id)
|
||||
&& conn_state().device_id == device_id
|
||||
{
|
||||
tunnel::down(&dir)?;
|
||||
set_conn(Some(&app), ConnState::default());
|
||||
}
|
||||
api::revoke_device(&session.base_url, &session.token, device_id)
|
||||
.await
|
||||
.map_err(|err| err.message())?;
|
||||
if local
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.config.device_id == device_id)
|
||||
{
|
||||
tunnel::clear_record_for(&record_dir, device.tunnel_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn client_device_name() -> String {
|
||||
default_device_name()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn client_conn_state() -> ConnState {
|
||||
conn_state()
|
||||
}
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
@@ -18,7 +476,8 @@ pub fn run() {
|
||||
app,
|
||||
&[&status, &open, &PredefinedMenuItem::separator(app)?, &quit],
|
||||
)?;
|
||||
TrayIconBuilder::new()
|
||||
*TRAY_MENU.lock().unwrap() = Some(menu.clone());
|
||||
TrayIconBuilder::with_id("tray")
|
||||
.icon(app.default_window_icon().expect("desktop app icon").clone())
|
||||
.tooltip("OpsX VPN · 尚未连接")
|
||||
.menu(&menu)
|
||||
@@ -30,7 +489,13 @@ pub fn run() {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"quit" => app.exit(0),
|
||||
"quit" => {
|
||||
// 退出前尽力拆除本机隧道,避免残留接口与路由
|
||||
if let Ok(dir) = app.path().app_config_dir() {
|
||||
let _ = tunnel::down(&dir);
|
||||
}
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.build(app)?;
|
||||
@@ -38,7 +503,18 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
server::check_server,
|
||||
system::get_system_readiness
|
||||
system::get_system_readiness,
|
||||
client_login,
|
||||
client_resume,
|
||||
client_logout,
|
||||
client_lines,
|
||||
client_connect,
|
||||
client_disconnect,
|
||||
client_status,
|
||||
client_devices,
|
||||
client_revoke_device,
|
||||
client_device_name,
|
||||
client_conn_state,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("failed to start OpsX VPN client");
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// 本机会话持久化:令牌只存本机数据目录(0600),不进入 WebView 或日志。
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::Line;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub base_url: String,
|
||||
pub token: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub device_name: String,
|
||||
pub lines: Vec<Line>,
|
||||
}
|
||||
|
||||
fn session_path(dir: &Path) -> PathBuf {
|
||||
dir.join("session.json")
|
||||
}
|
||||
|
||||
pub fn save(dir: &Path, session: &Session) -> Result<(), String> {
|
||||
std::fs::create_dir_all(dir).map_err(|err| format!("无法创建数据目录:{err}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
|
||||
.map_err(|err| format!("无法限制数据目录权限:{err}"))?;
|
||||
}
|
||||
let raw = serde_json::to_string_pretty(session).map_err(|err| err.to_string())?;
|
||||
write_private(&session_path(dir), raw.as_bytes())
|
||||
}
|
||||
|
||||
pub fn load(dir: &Path) -> Option<Session> {
|
||||
let raw = std::fs::read_to_string(session_path(dir)).ok()?;
|
||||
serde_json::from_str(&raw).ok()
|
||||
}
|
||||
|
||||
pub fn clear(dir: &Path) {
|
||||
let _ = std::fs::remove_file(session_path(dir));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.map_err(|err| format!("无法写入会话文件:{err}"))?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|err| format!("无法限制会话文件权限:{err}"))?;
|
||||
file.write_all(bytes)
|
||||
.map_err(|err| format!("写入会话文件失败:{err}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
std::fs::write(path, bytes).map_err(|err| format!("无法写入会话文件:{err}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn session_roundtrip_and_clear() {
|
||||
let dir = std::env::temp_dir().join(format!("opsx-session-test-{}", std::process::id()));
|
||||
let session = Session {
|
||||
base_url: "https://vpn.example.com".to_string(),
|
||||
token: "token-abc".to_string(),
|
||||
username: "alice".to_string(),
|
||||
display_name: "爱丽丝".to_string(),
|
||||
device_name: "alice-mac".to_string(),
|
||||
lines: vec![],
|
||||
};
|
||||
save(&dir, &session).unwrap();
|
||||
let loaded = load(&dir).unwrap();
|
||||
assert_eq!(loaded.token, "token-abc");
|
||||
assert_eq!(loaded.username, "alice");
|
||||
clear(&dir);
|
||||
assert!(load(&dir).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// 本机 WireGuard 隧道:密钥生成、配置渲染与提权启停。
|
||||
//
|
||||
// 私钥只在本机生成、只保存在本机数据目录(0600),绝不上传。
|
||||
// 启停需要系统权限:Linux 走 pkexec,macOS 走 osascript 管理员授权;
|
||||
// Windows 暂无受控提权通道,明确报错而不是静默失败。
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::DeviceConfig;
|
||||
|
||||
/// 生成 WireGuard 密钥对(X25519),返回 (私钥, 公钥),均为 base64。
|
||||
pub fn generate_keypair() -> Result<(String, String), String> {
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
let secret = StaticSecret::random_from_rng(rand_core::OsRng);
|
||||
let public = PublicKey::from(&secret);
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
Ok((b64.encode(secret.to_bytes()), b64.encode(public.to_bytes())))
|
||||
}
|
||||
|
||||
/// 用设备私钥 + 服务端下发的参数渲染 wg-quick 配置文本。
|
||||
pub fn render_config(private_key: &str, cfg: &DeviceConfig) -> String {
|
||||
let mut out = format!(
|
||||
"[Interface]\nPrivateKey = {private_key}\nAddress = {}\n",
|
||||
cfg.client_address
|
||||
);
|
||||
if !cfg.dns.is_empty() {
|
||||
out.push_str(&format!("DNS = {}\n", cfg.dns));
|
||||
}
|
||||
if cfg.mtu > 0 {
|
||||
out.push_str(&format!("MTU = {}\n", cfg.mtu));
|
||||
}
|
||||
out.push_str(&format!(
|
||||
"\n[Peer]\nPublicKey = {}\nPresharedKey = {}\nEndpoint = {}\nAllowedIPs = {}\nPersistentKeepalive = {}\n",
|
||||
cfg.server_public_key, cfg.preshared_key, cfg.endpoint, cfg.allowed_ips, cfg.keepalive
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TunnelRecord {
|
||||
pub private_key: String,
|
||||
pub config: DeviceConfig,
|
||||
}
|
||||
|
||||
/// 把含私钥的隧道记录写入本机数据目录(0600)。
|
||||
pub fn save_record(dir: &Path, record: &TunnelRecord) -> Result<PathBuf, String> {
|
||||
std::fs::create_dir_all(dir).map_err(|err| format!("无法创建数据目录:{err}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
|
||||
.map_err(|err| format!("无法限制数据目录权限:{err}"))?;
|
||||
}
|
||||
let path = record_path(dir, record.config.tunnel_id);
|
||||
let raw = serde_json::to_string_pretty(record).map_err(|err| err.to_string())?;
|
||||
write_private(&path, raw.as_bytes())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn record_path(dir: &Path, tunnel_id: u64) -> PathBuf {
|
||||
dir.join(format!("tunnel-{tunnel_id}.json"))
|
||||
}
|
||||
|
||||
/// 不同服务器、用户的设备密钥各自保存,切换线路不会遗失原设备登记。
|
||||
pub fn device_dir(dir: &Path, base_url: &str, username: &str) -> PathBuf {
|
||||
use sha2::{Digest, Sha256};
|
||||
let digest = Sha256::digest(format!("{base_url}\0{username}").as_bytes());
|
||||
let key: String = digest.iter().map(|b| format!("{b:02x}")).collect();
|
||||
dir.join("devices").join(key)
|
||||
}
|
||||
|
||||
pub fn load_record_for(dir: &Path, tunnel_id: u64) -> Result<TunnelRecord, String> {
|
||||
let raw = std::fs::read_to_string(record_path(dir, tunnel_id))
|
||||
.map_err(|_| "本机没有已登记的隧道记录".to_string())?;
|
||||
serde_json::from_str(&raw).map_err(|err| format!("本机隧道记录损坏:{err}"))
|
||||
}
|
||||
|
||||
pub fn clear_record_for(dir: &Path, tunnel_id: u64) {
|
||||
let _ = std::fs::remove_file(record_path(dir, tunnel_id));
|
||||
}
|
||||
|
||||
pub fn clear_record(dir: &Path) {
|
||||
let _ = std::fs::remove_file(dir.join("tunnel.json"));
|
||||
let _ = std::fs::remove_file(dir.join("opsx-wg.conf"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.map_err(|err| format!("无法写入 {}:{err}", path.display()))?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|err| format!("无法限制文件权限:{err}"))?;
|
||||
use std::io::Write;
|
||||
file.write_all(bytes)
|
||||
.map_err(|err| format!("写入失败:{err}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
std::fs::write(path, bytes).map_err(|err| format!("无法写入 {}:{err}", path.display()))
|
||||
}
|
||||
|
||||
fn write_conf(dir: &Path, text: &str) -> Result<PathBuf, String> {
|
||||
std::fs::create_dir_all(dir).map_err(|err| format!("无法创建数据目录:{err}"))?;
|
||||
let path = dir.join("opsx-wg.conf");
|
||||
write_private(&path, text.as_bytes())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn valid_key(value: &str) -> bool {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(value)
|
||||
.is_ok_and(|v| v.len() == 32)
|
||||
}
|
||||
|
||||
fn valid_cidrs(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.split(',').all(|part| {
|
||||
let Some((ip, mask)) = part.trim().split_once('/') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(address) = ip.parse::<std::net::IpAddr>() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(prefix) = mask.parse::<u8>() else {
|
||||
return false;
|
||||
};
|
||||
prefix <= if address.is_ipv4() { 32 } else { 128 }
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_config(private_key: &str, cfg: &DeviceConfig) -> Result<(), String> {
|
||||
if !valid_key(private_key)
|
||||
|| !valid_key(&cfg.server_public_key)
|
||||
|| !valid_key(&cfg.preshared_key)
|
||||
{
|
||||
return Err("WireGuard 密钥格式无效".into());
|
||||
}
|
||||
if !valid_cidrs(&cfg.client_address) || !valid_cidrs(&cfg.allowed_ips) {
|
||||
return Err("服务端下发的地址或路由无效".into());
|
||||
}
|
||||
if !cfg.dns.is_empty()
|
||||
&& !cfg
|
||||
.dns
|
||||
.split(',')
|
||||
.all(|ip| ip.trim().parse::<std::net::IpAddr>().is_ok())
|
||||
{
|
||||
return Err("服务端下发的 DNS 地址无效".into());
|
||||
}
|
||||
let Some((host, port)) = cfg.endpoint.rsplit_once(':') else {
|
||||
return Err("缺少 VPN 公网端点".into());
|
||||
};
|
||||
if host.is_empty()
|
||||
|| host
|
||||
.chars()
|
||||
.any(|c| !(c.is_ascii_alphanumeric() || ".-[]:".contains(c)))
|
||||
|| port.parse::<u16>().unwrap_or(0) == 0
|
||||
{
|
||||
return Err("VPN 公网端点格式无效".into());
|
||||
}
|
||||
if (cfg.mtu != 0 && !(576..=9000).contains(&cfg.mtu)) || cfg.keepalive > 65535 {
|
||||
return Err("服务端下发的 MTU 或保活参数无效".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_binary(name: &str) -> Result<String, String> {
|
||||
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())
|
||||
.map(|dir| dir.join(name))
|
||||
.find(|p| p.is_file())
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.ok_or_else(|| format!("未在本机 PATH 找到 {name},请先安装 wireguard-tools"))
|
||||
}
|
||||
|
||||
/// 以系统授权方式执行 wg-quick up/down。
|
||||
/// 命令参数全部由本机生成,不拼接任何用户输入。
|
||||
fn run_wg_quick(action: &str, conf: &Path) -> Result<(), String> {
|
||||
let wg_quick = find_binary("wg-quick")?;
|
||||
let conf_str = conf.to_string_lossy().to_string();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let pkexec = find_binary("pkexec").map_err(|_| {
|
||||
"未找到 pkexec,无法安全提权;请安装 polkit 或手动配置 sudo 权限".to_string()
|
||||
})?;
|
||||
let status = std::process::Command::new(pkexec)
|
||||
.args([&wg_quick, action, &conf_str])
|
||||
.status()
|
||||
.map_err(|err| format!("无法启动提权授权:{err}"))?;
|
||||
return if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"wg-quick {action} 执行失败(可能被取消授权或系统策略拒绝)"
|
||||
))
|
||||
};
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if conf_str.chars().any(char::is_control) || wg_quick.chars().any(char::is_control) {
|
||||
return Err("配置路径包含非法字符".into());
|
||||
}
|
||||
let command = format!(
|
||||
"{} {} {}",
|
||||
shell_escape(&wg_quick),
|
||||
action,
|
||||
shell_escape(&conf_str)
|
||||
);
|
||||
let literal = command.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let script = format!("do shell script \"{literal}\" with administrator privileges");
|
||||
let status = std::process::Command::new("osascript")
|
||||
.args(["-e", &script])
|
||||
.status()
|
||||
.map_err(|err| format!("无法请求管理员授权:{err}"))?;
|
||||
return if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("wg-quick {action} 执行失败(可能被取消授权)"))
|
||||
};
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = (wg_quick, conf_str);
|
||||
Err(
|
||||
"Windows 隧道提权通道尚未实现,请使用 WireGuard 官方客户端导入配置,或等待后续版本"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn shell_escape(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
/// 建立隧道:渲染配置 → 0600 落盘 → 提权 wg-quick up。
|
||||
pub fn up(dir: &Path, private_key: &str, cfg: &DeviceConfig) -> Result<PathBuf, String> {
|
||||
validate_config(private_key, cfg)?;
|
||||
let path = write_conf(dir, &render_config(private_key, cfg))?;
|
||||
// 重复连接时先尽力清理旧接口,忽略失败(接口可能本就不存在)
|
||||
let _ = run_wg_quick("down", &path);
|
||||
run_wg_quick("up", &path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// 拆除隧道。配置文件可能已被清理,尽力而为。
|
||||
pub fn down(dir: &Path) -> Result<(), String> {
|
||||
let path = dir.join("opsx-wg.conf");
|
||||
if !path.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
run_wg_quick("down", &path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_config() -> DeviceConfig {
|
||||
DeviceConfig {
|
||||
device_id: 1,
|
||||
device_name: "测试设备".to_string(),
|
||||
tunnel_id: 4,
|
||||
tunnel_name: "测试专线".to_string(),
|
||||
peer_id: "dev-1".to_string(),
|
||||
client_address: "10.8.0.7/32".to_string(),
|
||||
server_public_key: "serverpub".to_string(),
|
||||
preshared_key: "psk".to_string(),
|
||||
endpoint: "vpn.example.com:51820".to_string(),
|
||||
allowed_ips: "0.0.0.0/0, ::/0".to_string(),
|
||||
dns: "223.5.5.5".to_string(),
|
||||
mtu: 1420,
|
||||
keepalive: 25,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_keypair_is_base64_x25519() {
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
let (private_key, public_key) = generate_keypair().unwrap();
|
||||
assert_eq!(b64.decode(&private_key).unwrap().len(), 32);
|
||||
assert_eq!(b64.decode(&public_key).unwrap().len(), 32);
|
||||
// 同一私钥每次派生相同公钥,且两把私钥的公钥不同
|
||||
let (private2, public2) = generate_keypair().unwrap();
|
||||
assert_ne!(public_key, public2);
|
||||
assert_ne!(private_key, private2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_config_contains_all_fields() {
|
||||
let conf = render_config("private-key", &sample_config());
|
||||
for needle in [
|
||||
"PrivateKey = private-key",
|
||||
"Address = 10.8.0.7/32",
|
||||
"DNS = 223.5.5.5",
|
||||
"MTU = 1420",
|
||||
"PublicKey = serverpub",
|
||||
"PresharedKey = psk",
|
||||
"Endpoint = vpn.example.com:51820",
|
||||
"AllowedIPs = 0.0.0.0/0, ::/0",
|
||||
"PersistentKeepalive = 25",
|
||||
] {
|
||||
assert!(conf.contains(needle), "配置缺少 {needle}:\n{conf}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_injected_server_config_before_root_helper() {
|
||||
let key = base64::engine::general_purpose::STANDARD.encode([7_u8; 32]);
|
||||
let mut cfg = sample_config();
|
||||
cfg.server_public_key = key.clone();
|
||||
cfg.preshared_key = key.clone();
|
||||
assert!(validate_config(&key, &cfg).is_ok());
|
||||
cfg.dns = "1.1.1.1\nPostUp = touch /tmp/unsafe".into();
|
||||
assert!(validate_config(&key, &cfg).is_err());
|
||||
cfg.dns = "1.1.1.1".into();
|
||||
cfg.allowed_ips = "0.0.0.0/0\nPostUp = touch /tmp/unsafe".into();
|
||||
assert!(validate_config(&key, &cfg).is_err());
|
||||
cfg.allowed_ips = "0.0.0.0/0".into();
|
||||
cfg.endpoint = "vpn.example.com:51820\n[Interface]".into();
|
||||
assert!(validate_config(&key, &cfg).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_roundtrip_and_clear() {
|
||||
let dir = std::env::temp_dir().join(format!("opsx-test-{}", std::process::id()));
|
||||
let record = TunnelRecord {
|
||||
private_key: "private".to_string(),
|
||||
config: sample_config(),
|
||||
};
|
||||
save_record(&dir, &record).unwrap();
|
||||
let loaded = load_record_for(&dir, sample_config().tunnel_id).unwrap();
|
||||
assert_eq!(loaded.private_key, "private");
|
||||
assert_eq!(loaded.config.client_address, "10.8.0.7/32");
|
||||
clear_record_for(&dir, sample_config().tunnel_id);
|
||||
assert!(load_record_for(&dir, sample_config().tunnel_id).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+369
-102
@@ -1,10 +1,16 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { checkServer, getSystemReadiness, type SystemReadiness } from './platform'
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
checkServer, clientConnect, clientDeviceName, clientDevices, clientDisconnect,
|
||||
clientLines, clientLogin, clientLogout, clientResume, clientRevokeDevice, clientStatus,
|
||||
getSystemReadiness,
|
||||
type ConnState, type DeviceInfo, type Line, type SessionView, type StatusView,
|
||||
type SystemReadiness,
|
||||
} from './platform'
|
||||
import './system.css'
|
||||
|
||||
type Page = 'overview' | 'routes' | 'settings'
|
||||
type CheckState = 'idle' | 'checking' | 'reachable' | 'error'
|
||||
function Icon({ name, size = 22 }: { name: 'home' | 'globe' | 'settings' | 'power' | 'shield' | 'server' | 'check' | 'close'; size?: number }) {
|
||||
|
||||
function Icon({ name, size = 22 }: { name: 'home' | 'globe' | 'settings' | 'power' | 'shield' | 'server' | 'check' | 'close' | 'user' | 'device'; size?: number }) {
|
||||
const paths = {
|
||||
home: <path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1z" />,
|
||||
globe: <><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3c-5 5-5 13 0 18M12 3c5 5 5 13 0 18" /></>,
|
||||
@@ -14,112 +20,140 @@ function Icon({ name, size = 22 }: { name: 'home' | 'globe' | 'settings' | 'powe
|
||||
server: <><rect x="3" y="3" width="18" height="7" rx="2" /><rect x="3" y="14" width="18" height="7" rx="2" /><path d="M7 6.5h.01M7 17.5h.01" /></>,
|
||||
check: <path d="m5 12 5 5 9-10" />,
|
||||
close: <path d="M5 5 19 19M19 5 5 19" />,
|
||||
user: <><circle cx="12" cy="8" r="4" /><path d="M4 21c0-4 3.6-6.5 8-6.5s8 2.5 8 6.5" /></>,
|
||||
device: <><rect x="5" y="2" width="14" height="20" rx="2" /><path d="M12 18h.01" /></>,
|
||||
}
|
||||
return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{paths[name]}</svg>
|
||||
}
|
||||
|
||||
function formatBytes(n?: number | null) {
|
||||
if (n === null || n === undefined) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = n, unit = 0
|
||||
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++ }
|
||||
return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`
|
||||
}
|
||||
|
||||
function formatDuration(sinceUnix: number, now: number) {
|
||||
if (!sinceUnix) return '—'
|
||||
const seconds = Math.max(0, now - sinceUnix)
|
||||
const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60
|
||||
return h > 0 ? `${h} 小时 ${m} 分` : m > 0 ? `${m} 分 ${s} 秒` : `${s} 秒`
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState<SessionView | null>(null)
|
||||
const [booting, setBooting] = useState(true)
|
||||
const [page, setPage] = useState<Page>('overview')
|
||||
const [address, setAddress] = useState(() => localStorage.getItem('opsx_server_address') || '')
|
||||
const [savedAddress, setSavedAddress] = useState(() => localStorage.getItem('opsx_server_address') || '')
|
||||
const [state, setState] = useState<CheckState>('idle')
|
||||
const [error, setError] = useState('')
|
||||
const [checkedAddress, setCheckedAddress] = useState('')
|
||||
const [responseMs, setResponseMs] = useState<number | null>(null)
|
||||
const [readiness, setReadiness] = useState<SystemReadiness | null>(null)
|
||||
const [readinessError, setReadinessError] = useState('')
|
||||
const [connectionGuide, setConnectionGuide] = useState('')
|
||||
const [conn, setConn] = useState<ConnState | null>(null)
|
||||
const [status, setStatus] = useState<StatusView | null>(null)
|
||||
const [selectedLine, setSelectedLine] = useState<number | null>(null)
|
||||
const [lines, setLines] = useState<Line[]>([])
|
||||
const [actionError, setActionError] = useState('')
|
||||
const [now, setNow] = useState(() => Math.floor(Date.now() / 1000))
|
||||
|
||||
// 启动时恢复本机会话
|
||||
useEffect(() => {
|
||||
if (page !== 'overview' || !savedAddress) return
|
||||
clientResume()
|
||||
.then(view => {
|
||||
if (view) {
|
||||
setSession(view)
|
||||
setLines(view.lines)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setBooting(false))
|
||||
}, [])
|
||||
|
||||
// 连接状态轮询:已连接/连接中时每 5 秒以服务端握手为准核对一次
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
let active = true
|
||||
async function refresh() {
|
||||
const start = performance.now()
|
||||
setState('checking')
|
||||
async function poll() {
|
||||
try {
|
||||
const health = await checkServer(savedAddress)
|
||||
const view = await clientStatus()
|
||||
if (!active) return
|
||||
setCheckedAddress(health.address)
|
||||
setResponseMs(Math.round(performance.now() - start))
|
||||
setState(health.reachable ? 'reachable' : 'error')
|
||||
} catch (cause) {
|
||||
if (!active) return
|
||||
setState('error')
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
setResponseMs(null)
|
||||
}
|
||||
setStatus(view)
|
||||
setConn(view.conn)
|
||||
} catch { /* 未连接时无状态可查 */ }
|
||||
}
|
||||
void refresh()
|
||||
const timer = window.setInterval(refresh, 30_000)
|
||||
void poll()
|
||||
const timer = window.setInterval(poll, 5000)
|
||||
return () => { active = false; window.clearInterval(timer) }
|
||||
}, [page, savedAddress])
|
||||
}, [session])
|
||||
|
||||
// 已连接时刷新时长显示
|
||||
useEffect(() => {
|
||||
if (conn?.phase !== 'connected') return
|
||||
const timer = window.setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [conn?.phase])
|
||||
|
||||
const refreshLines = useCallback(async () => {
|
||||
try {
|
||||
const list = await clientLines()
|
||||
setLines(list)
|
||||
} catch { /* 离线时保留缓存 */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (page !== 'settings') return
|
||||
let active = true
|
||||
getSystemReadiness().then(result => {
|
||||
if (active) { setReadiness(result); setReadinessError('') }
|
||||
}).catch(() => {
|
||||
if (active) setReadinessError('本机检测仅在 Tauri 桌面应用中可用。')
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [page])
|
||||
if (session && page === 'routes') void refreshLines()
|
||||
}, [session, page, refreshLines])
|
||||
|
||||
async function handleCheck(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setState('checking')
|
||||
setError('')
|
||||
setResponseMs(null)
|
||||
const start = performance.now()
|
||||
try {
|
||||
const health = await checkServer(address)
|
||||
setCheckedAddress(health.address)
|
||||
setResponseMs(Math.round(performance.now() - start))
|
||||
if (!health.reachable) throw new Error('服务端未就绪')
|
||||
localStorage.setItem('opsx_server_address', health.address)
|
||||
setAddress(health.address)
|
||||
setSavedAddress(health.address)
|
||||
setState('reachable')
|
||||
} catch (cause) {
|
||||
setState('error')
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
}
|
||||
}
|
||||
|
||||
function saveAddress() {
|
||||
try {
|
||||
const url = new URL(address.trim())
|
||||
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
||||
throw new Error('请输入 HTTP 或 HTTPS 服务端根地址,不包含路径、账号或参数。')
|
||||
}
|
||||
const root = url.href.replace(/\/$/, '')
|
||||
localStorage.setItem('opsx_server_address', root)
|
||||
setAddress(root)
|
||||
setSavedAddress(root)
|
||||
setError('')
|
||||
setState('idle')
|
||||
} catch (cause) {
|
||||
setError(cause instanceof TypeError ? '请输入完整的 HTTP 或 HTTPS 服务端地址。' : String(cause))
|
||||
setState('error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
if (!savedAddress) {
|
||||
setConnectionGuide('请先到“设置”检测并保存服务端地址。')
|
||||
async function handleConnect(tunnelId?: number) {
|
||||
setActionError('')
|
||||
const target = tunnelId ?? selectedLine ?? lines.find(l => l.state === 'running')?.tunnel_id ?? lines[0]?.tunnel_id
|
||||
if (!target) {
|
||||
setActionError('没有可用线路,请先在“线路”页查看授权。')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await getSystemReadiness()
|
||||
setReadiness(result)
|
||||
setConnectionGuide(result.wg_found && result.wg_quick_found
|
||||
? '本机已发现 WireGuard 工具。普通用户设备授权和自动下发尚未接入服务端,因此当前不能安全地直接拨号。'
|
||||
: '本机尚未发现完整的 WireGuard 工具;普通用户设备授权和自动下发也尚未接入。当前不能直接拨号。')
|
||||
} catch {
|
||||
setConnectionGuide('当前是浏览器预览,不能修改本机网络。自动连接需要桌面客户端的原生隧道和设备授权接口。')
|
||||
const next = await clientConnect(target)
|
||||
setConn(next)
|
||||
setSelectedLine(target)
|
||||
setPage('overview')
|
||||
} catch (cause) {
|
||||
setActionError(cause instanceof Error ? cause.message : String(cause))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
setActionError('')
|
||||
try {
|
||||
await clientDisconnect()
|
||||
setConn(prev => prev ? { ...prev, phase: 'disconnected' } : prev)
|
||||
setStatus(null)
|
||||
} catch (cause) {
|
||||
setActionError(cause instanceof Error ? cause.message : String(cause))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try { await clientLogout() } catch (cause) {
|
||||
setActionError(cause instanceof Error ? cause.message : String(cause))
|
||||
return
|
||||
}
|
||||
setSession(null)
|
||||
setConn(null)
|
||||
setStatus(null)
|
||||
setLines([])
|
||||
setSelectedLine(null)
|
||||
setPage('overview')
|
||||
}
|
||||
|
||||
if (booting) {
|
||||
return <div className="app-shell"><div className="workspace"><main className="page"><p className="hint">正在启动…</p></main></div></div>
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return <LoginView onLoggedIn={view => { setSession(view); setLines(view.lines) }} />
|
||||
}
|
||||
|
||||
const phase = conn?.phase ?? 'disconnected'
|
||||
const connected = phase === 'connected'
|
||||
const connecting = phase === 'connecting'
|
||||
const currentLineName = conn?.tunnel_name || lines.find(l => l.tunnel_id === selectedLine)?.name || ''
|
||||
|
||||
return <div className="app-shell">
|
||||
<aside className="sidebar" aria-label="主导航">
|
||||
<div className="brand-symbol" aria-label="OpsX VPN"><span /><i /></div>
|
||||
@@ -130,29 +164,262 @@ export default function App() {
|
||||
<div className="sidebar-bottom">OX <small>/ VPN</small></div>
|
||||
</aside>
|
||||
<div className="workspace">
|
||||
<header className="topbar"><div className="topbar-brand"><span className="brand-dot" />OpsX <strong>VPN</strong><span className="divider" />桌面客户端</div><span className="preview-pill">● 客户端 0.1</span></header>
|
||||
<header className="topbar">
|
||||
<div className="topbar-brand"><span className="brand-dot" />OpsX <strong>VPN</strong><span className="divider" />{session.display_name || session.username}</div>
|
||||
<span className={`preview-pill ${connected ? 'online' : ''}`}>● {connected ? '已连接' : connecting ? '连接中' : '未连接'}</span>
|
||||
</header>
|
||||
|
||||
{page === 'overview' && <main className="page">
|
||||
<div className="section-intro"><div><span className="overline">WORKSPACE / OVERVIEW</span><h1>连接概览</h1><p>查看本客户端的服务检测与接入状态。</p></div><span className="status-tag">● 本客户端未连接</span></div>
|
||||
<div className="section-intro">
|
||||
<div><span className="overline">WORKSPACE / OVERVIEW</span><h1>连接概览</h1><p>一键接入企业网络;连接状态以网关真实握手为准。</p></div>
|
||||
<span className="status-tag">● {connected ? `已连接 · ${currentLineName}` : connecting ? '连接中…' : '本客户端未连接'}</span>
|
||||
</div>
|
||||
<div className="overview-grid">
|
||||
<section className="connection-card" aria-label="VPN 连接状态">
|
||||
<div className="card-top"><div><span className="kicker">安全连接</span><h2>企业网络</h2></div><span className="protocol"><Icon name="shield" size={15} /> WIREGUARD</span></div>
|
||||
<div className="connect-visual"><div className="orbit outer" /><div className="orbit inner" /><button type="button" className="power-display" aria-label="查看 VPN 连接准备步骤" onClick={handleConnect}><Icon name="power" size={42} /></button></div>
|
||||
<div className="connection-message"><strong>本客户端未连接</strong><span>尚未配置自动设备授权和本机隧道</span></div>{connectionGuide && <div className="connection-guide" role="status">{connectionGuide}<button type="button" onClick={() => setPage('settings')}>查看设置 →</button></div>}
|
||||
<div className="node-selector"><span className="node-icon"><Icon name="globe" size={19} /></span><span className="node-selector-copy"><small>已授权 VPN 线路</small><strong>尚未获取</strong></span></div>
|
||||
<div className="card-top"><div><span className="kicker">安全连接</span><h2>{currentLineName || '企业网络'}</h2></div><span className="protocol"><Icon name="shield" size={15} /> WIREGUARD</span></div>
|
||||
<div className="connect-visual">
|
||||
<div className="orbit outer" /><div className="orbit inner" />
|
||||
<button type="button" className={`power-display ${connected ? 'on' : ''} ${connecting ? 'busy' : ''}`}
|
||||
aria-label={connected ? '断开 VPN 连接' : '连接 VPN'}
|
||||
disabled={connecting}
|
||||
onClick={() => connected ? void handleDisconnect() : void handleConnect()}>
|
||||
<Icon name="power" size={42} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="connection-message">
|
||||
<strong>{connected ? '已连接' : connecting ? '连接中…' : '本客户端未连接'}</strong>
|
||||
<span>{connected
|
||||
? `${conn?.client_address ?? ''} · 已连接 ${formatDuration(conn?.since_unix ?? 0, now)}`
|
||||
: connecting ? (conn?.detail || '正在建立隧道') : (conn?.detail || actionError || '点击上方按钮接入企业网络')}</span>
|
||||
</div>
|
||||
{connected && status?.server && <div className="traffic-grid">
|
||||
<span>下行 {formatBytes(status.server.rx_bytes)}</span>
|
||||
<span>上行 {formatBytes(status.server.tx_bytes)}</span>
|
||||
<span>端点 {conn?.endpoint}</span>
|
||||
</div>}
|
||||
{actionError && !connecting && <div className="connection-guide" role="alert">{actionError}</div>}
|
||||
{status?.server?.detail && <div className="connection-guide" role="status">{status.server.detail}</div>}
|
||||
<div className="node-selector" role="button" tabIndex={0} onClick={() => setPage('routes')} onKeyDown={e => e.key === 'Enter' && setPage('routes')}>
|
||||
<span className="node-icon"><Icon name="globe" size={19} /></span>
|
||||
<span className="node-selector-copy"><small>当前线路</small><strong>{currentLineName || (lines[0]?.name ?? '暂无授权线路')}</strong></span>
|
||||
<small className="node-switch">切换 →</small>
|
||||
</div>
|
||||
</section>
|
||||
<div className="right-column">
|
||||
<section className="info-card"><div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>服务端状态</h2><p>管理服务可达性</p></div></div><p className="server-address">{savedAddress || '尚未设置服务端地址'}</p><span className={`service-state ${state}`}>{state === 'reachable' ? `服务可达 · 控制面往返约 ${responseMs} ms` : state === 'error' ? '检测失败' : state === 'checking' ? '检测中…' : '尚未检测'}</span><p className="hint">控制面检测不代表 VPN 已连接;概览每 30 秒更新一次。</p><button className="secondary-action" onClick={() => setPage('settings')}>在设置中配置 →</button></section>
|
||||
<section className="info-card progress-card"><div className="info-heading"><span className="icon-tile green"><Icon name="shield" size={20} /></span><div><h2>接入进度</h2><p>桌面端能力逐步开放</p></div></div>
|
||||
<div className="milestone"><span className={`milestone-mark ${state === 'reachable' ? 'done' : ''}`}>{state === 'reachable' ? '✓' : '1'}</span><span><strong>服务可达性</strong><small>{state === 'reachable' ? checkedAddress : '尚未完成健康检查'}</small></span><em>{state === 'reachable' ? '可达' : '未确认'}</em></div>
|
||||
<div className="milestone"><span className="milestone-mark">2</span><span><strong>用户与设备授权</strong><small>服务器尚无客户端专用接口</small></span><em>未接入</em></div>
|
||||
<div className="milestone"><span className="milestone-mark">3</span><span><strong>WireGuard 隧道</strong><small>未创建本机隧道或验证握手</small></span><em>未连接</em></div>
|
||||
<section className="info-card">
|
||||
<div className="info-heading"><span className="icon-tile"><Icon name="user" size={20} /></span><div><h2>当前账号</h2><p>拨号用户身份</p></div></div>
|
||||
<div className="system-grid">
|
||||
<span>用户名</span><strong>{session.username}</strong>
|
||||
<span>本机设备</span><strong>{session.device_name}</strong>
|
||||
<span>授权线路</span><strong>{session.offline ? '服务端暂不可达' : `${lines.length} 条`}</strong>
|
||||
</div>
|
||||
<button className="secondary-action" onClick={() => void handleLogout()}>退出登录</button>
|
||||
</section>
|
||||
<ServerCard baseUrl={undefined} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="footnote">管理服务健康检查仅反映控制面状态;本客户端尚未建立 VPN 隧道。</p>
|
||||
<p className="footnote">连接状态由网关侧真实握手确认;控制面检测耗时不等于 VPN 节点延时。</p>
|
||||
</main>}
|
||||
{page === 'routes' && <main className="page subpage"><span className="overline">WORKSPACE / ROUTES</span><h1>线路</h1><p className="subpage-intro">这里只显示经当前账户授权、由服务端提供的真实线路。</p><section className="info-card route-preview"><span className="icon-tile large"><Icon name="globe" size={29} /></span><div><h2>暂时无法获取线路</h2><p>服务端尚未提供终端用户身份和设备授权接口,因此无法查询线路或测量真实节点延时。</p></div><button className="secondary-action" onClick={() => setPage('settings')}>检查服务端 →</button></section></main>}
|
||||
{page === 'settings' && <main className="page subpage"><span className="overline">WORKSPACE / SETTINGS</span><h1>设置</h1><p className="subpage-intro">管理服务端入口并查看本机环境。这里不会保存账号或修改本机网络。</p><section className="info-card"><div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>服务端地址</h2><p>检测成功后自动保存,下次打开仍可使用</p></div></div><form onSubmit={handleCheck}><label htmlFor="server-url">HTTP / HTTPS 根地址</label><div className="input-row"><input id="server-url" type="url" required placeholder="https://test-link.appx365.com 或 http://121.4.111.208:18080" value={address} onChange={event => { setAddress(event.target.value); setState('idle'); setError(''); setResponseMs(null) }} /><button type="submit" disabled={state === 'checking'}>{state === 'checking' ? '检测中…' : '检测服务'}</button><button className="save-button" type="button" onClick={saveAddress}>保存地址</button></div></form>{savedAddress && <p className="saved-address" role="status">✓ 已保存:{savedAddress}</p>}{address.trim().toLowerCase().startsWith('http://') && <p className="http-warning">HTTP 是明文传输;健康检查可用,但今后登录和设备授权建议改用 HTTPS。</p>}<p className="hint">只填写服务端根地址;检测成功会自动保存,也可直接点击“保存地址”。</p>{state === 'reachable' && <div className="result success" role="status"><Icon name="check" size={17} /><span>服务可达 · 控制面往返约 {responseMs} ms<br /><small>{checkedAddress}。这不是 VPN 节点延时或已连接状态。</small></span></div>}{state === 'error' && <div className="result failure" role="alert"><Icon name="close" size={17} /><span>{error || '服务端没有返回有效的健康响应。'}</span></div>}</section><section className="info-card route-preview system-card"><span className="icon-tile large"><Icon name="shield" size={27} /></span><div><h2>安全接入</h2><p>用户认证、设备授权与系统隧道设置将在服务端接口具备后开放。</p></div><span className="status-tag">待接入</span></section><section className="info-card system-card"><div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>本机环境</h2><p>只读检查,不代表已经具备 VPN 拨号能力</p></div></div>{readiness ? <div className="system-grid"><span>操作系统</span><strong>{readiness.platform}</strong><span>wg 命令</span><strong>{readiness.wg_found ? 'PATH 中已发现' : 'PATH 中未发现'}</strong><span>wg-quick 命令</span><strong>{readiness.wg_quick_found ? 'PATH 中已发现' : 'PATH 中未发现'}</strong></div> : <p className="hint" role="status">{readinessError || '正在检测本机环境…'}</p>}<p className="hint">仅检查文件是否存在;没有检查执行权限、驱动、系统授权或 WireGuard 隧道状态。</p></section></main>}
|
||||
|
||||
{page === 'routes' && <main className="page subpage">
|
||||
<span className="overline">WORKSPACE / ROUTES</span><h1>线路</h1>
|
||||
<p className="subpage-intro">这里只显示当前账号被授权的真实线路。</p>
|
||||
{lines.length === 0 && <section className="info-card route-preview">
|
||||
<span className="icon-tile large"><Icon name="globe" size={29} /></span>
|
||||
<div><h2>{session.offline ? '服务端暂不可达' : '暂无授权线路'}</h2><p>{session.offline ? '恢复网络后自动重试。' : '请联系管理员在 WireGuard 专线中为您创建拨号用户。'}</p></div>
|
||||
<button className="secondary-action" onClick={() => void refreshLines()}>刷新</button>
|
||||
</section>}
|
||||
{lines.map(line => <section key={line.tunnel_id} className="info-card line-card">
|
||||
<span className="icon-tile"><Icon name="globe" size={20} /></span>
|
||||
<div className="line-info">
|
||||
<h2>{line.name} {selectedLine === line.tunnel_id && <em className="line-current">当前</em>}</h2>
|
||||
<p>{line.remark || line.endpoint || '—'}</p>
|
||||
</div>
|
||||
<span className={`service-state ${line.state === 'running' ? 'reachable' : 'error'}`}>{line.state === 'running' ? '运行中' : '未运行'}</span>
|
||||
<button className="secondary-action" disabled={line.state !== 'running' || connecting}
|
||||
onClick={() => { setSelectedLine(line.tunnel_id); void handleConnect(line.tunnel_id) }}>
|
||||
{connected && conn?.tunnel_name === line.name ? '已连接' : '连接'}
|
||||
</button>
|
||||
</section>)}
|
||||
</main>}
|
||||
|
||||
{page === 'settings' && <SettingsPage session={session} onLogout={handleLogout} />}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// ---- 登录页 ----
|
||||
|
||||
function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void }) {
|
||||
const [address, setAddress] = useState(() => localStorage.getItem('opsx_server_address') || '')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [otp, setOtp] = useState('')
|
||||
const [mfaRequired, setMfaRequired] = useState(false)
|
||||
const [deviceName, setDeviceName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [health, setHealth] = useState<'idle' | 'checking' | 'reachable' | 'error'>('idle')
|
||||
|
||||
useEffect(() => { clientDeviceName().then(setDeviceName).catch(() => undefined) }, [])
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
const view = await clientLogin(address, username, password, otp, deviceName)
|
||||
localStorage.setItem('opsx_server_address', address.trim().replace(/\/$/, ''))
|
||||
onLoggedIn(view)
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause)
|
||||
setError(message)
|
||||
if (message.includes('动态口令')) setMfaRequired(true)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCheck() {
|
||||
setHealth('checking')
|
||||
try {
|
||||
const result = await checkServer(address)
|
||||
setHealth(result.reachable ? 'reachable' : 'error')
|
||||
} catch { setHealth('error') }
|
||||
}
|
||||
|
||||
return <div className="app-shell login-shell">
|
||||
<main className="login-card">
|
||||
<div className="topbar-brand"><span className="brand-dot" />OpsX <strong>VPN</strong><span className="divider" />企业网络接入</div>
|
||||
<h1>登录</h1>
|
||||
<p className="hint">使用管理员为您开通的 VPN 拨号账号登录;首次连接会自动为本机登记设备。</p>
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
<label htmlFor="login-server">服务端地址</label>
|
||||
<div className="input-row">
|
||||
<input id="login-server" type="url" required placeholder="https://vpn.example.com" value={address}
|
||||
onChange={e => { setAddress(e.target.value); setHealth('idle') }} />
|
||||
<button type="button" className="save-button" onClick={() => void handleCheck()} disabled={health === 'checking'}>
|
||||
{health === 'checking' ? '检测中…' : '检测'}
|
||||
</button>
|
||||
</div>
|
||||
{health === 'reachable' && <p className="saved-address">✓ 服务可达</p>}
|
||||
{health === 'error' && <p className="http-warning">服务不可达,请确认地址与网络。</p>}
|
||||
{address.trim().toLowerCase().startsWith('http://') && <p className="http-warning">HTTP 为明文传输,登录口令可能被窃听,建议使用可信 HTTPS 域名。</p>}
|
||||
<label htmlFor="login-username">用户名</label>
|
||||
<div className="input-row"><input id="login-username" required autoComplete="username" value={username} onChange={e => setUsername(e.target.value)} /></div>
|
||||
<label htmlFor="login-password">密码</label>
|
||||
<div className="input-row"><input id="login-password" type="password" required autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} /></div>
|
||||
{mfaRequired && <>
|
||||
<label htmlFor="login-otp">动态口令(设置了 PIN 时输入 PIN+动态码)</label>
|
||||
<div className="input-row"><input id="login-otp" autoComplete="one-time-code" value={otp} onChange={e => setOtp(e.target.value)} /></div>
|
||||
</>}
|
||||
<label htmlFor="login-device">设备名(用于在管理端识别本机)</label>
|
||||
<div className="input-row"><input id="login-device" value={deviceName} onChange={e => setDeviceName(e.target.value)} placeholder="自动填充本机主机名" /></div>
|
||||
{error && <div className="result failure" role="alert"><Icon name="close" size={17} /><span>{error}</span></div>}
|
||||
<button className="login-submit" type="submit" disabled={busy}>{busy ? '登录中…' : '登录'}</button>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
|
||||
// ---- 服务端健康卡片(概览右栏)----
|
||||
|
||||
function ServerCard({ baseUrl }: { baseUrl?: string }) {
|
||||
const saved = baseUrl || localStorage.getItem('opsx_server_address') || ''
|
||||
const [state, setState] = useState<'idle' | 'checking' | 'reachable' | 'error'>('idle')
|
||||
const [responseMs, setResponseMs] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!saved) return
|
||||
let active = true
|
||||
async function refresh() {
|
||||
const start = performance.now()
|
||||
setState('checking')
|
||||
try {
|
||||
const health = await checkServer(saved)
|
||||
if (!active) return
|
||||
setResponseMs(Math.round(performance.now() - start))
|
||||
setState(health.reachable ? 'reachable' : 'error')
|
||||
} catch { if (active) { setState('error'); setResponseMs(null) } }
|
||||
}
|
||||
void refresh()
|
||||
const timer = window.setInterval(refresh, 30_000)
|
||||
return () => { active = false; window.clearInterval(timer) }
|
||||
}, [saved])
|
||||
|
||||
return <section className="info-card">
|
||||
<div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>服务端状态</h2><p>管理服务可达性</p></div></div>
|
||||
<p className="server-address">{saved || '未设置'}</p>
|
||||
<span className={`service-state ${state}`}>{state === 'reachable' ? `服务可达 · 控制面往返约 ${responseMs} ms` : state === 'error' ? '检测失败' : state === 'checking' ? '检测中…' : '尚未检测'}</span>
|
||||
<p className="hint">控制面检测不代表 VPN 已连接。</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
// ---- 设置页 ----
|
||||
|
||||
function SettingsPage({ session, onLogout }: { session: SessionView; onLogout: () => void }) {
|
||||
const [readiness, setReadiness] = useState<SystemReadiness | null>(null)
|
||||
const [readinessError, setReadinessError] = useState('')
|
||||
const [devices, setDevices] = useState<DeviceInfo[]>([])
|
||||
const [deviceError, setDeviceError] = useState('')
|
||||
|
||||
const loadDevices = useCallback(() => {
|
||||
clientDevices().then(setDevices).catch(err => setDeviceError(String(err?.message ?? err)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
getSystemReadiness()
|
||||
.then(result => { if (active) setReadiness(result) })
|
||||
.catch(() => { if (active) setReadinessError('本机检测仅在 Tauri 桌面应用中可用。') })
|
||||
loadDevices()
|
||||
return () => { active = false }
|
||||
}, [loadDevices])
|
||||
|
||||
async function revoke(id: number, name: string) {
|
||||
if (!window.confirm(`确定撤销设备「${name}」?该设备将立即失去接入能力,需要重新登记。`)) return
|
||||
setDeviceError('')
|
||||
try {
|
||||
await clientRevokeDevice(id)
|
||||
loadDevices()
|
||||
} catch (cause) {
|
||||
setDeviceError(cause instanceof Error ? cause.message : String(cause))
|
||||
}
|
||||
}
|
||||
|
||||
return <main className="page subpage">
|
||||
<span className="overline">WORKSPACE / SETTINGS</span><h1>设置</h1>
|
||||
<p className="subpage-intro">管理本机设备与本机环境。服务端地址在退出登录后可修改。</p>
|
||||
|
||||
<section className="info-card">
|
||||
<div className="info-heading"><span className="icon-tile"><Icon name="user" size={20} /></span><div><h2>账号</h2><p>当前登录的拨号用户</p></div></div>
|
||||
<div className="system-grid">
|
||||
<span>用户名</span><strong>{session.username}</strong>
|
||||
<span>姓名</span><strong>{session.display_name || '—'}</strong>
|
||||
<span>服务端</span><strong>{localStorage.getItem('opsx_server_address') || '—'}</strong>
|
||||
</div>
|
||||
<button className="secondary-action" onClick={onLogout}>退出登录(同时断开 VPN)</button>
|
||||
</section>
|
||||
|
||||
<section className="info-card system-card">
|
||||
<div className="info-heading"><span className="icon-tile"><Icon name="device" size={20} /></span><div><h2>我的设备</h2><p>撤销后该设备立即无法接入</p></div></div>
|
||||
{deviceError && <div className="result failure" role="alert"><Icon name="close" size={17} /><span>{deviceError}</span></div>}
|
||||
{devices.length === 0 && <p className="hint">尚未登记设备;首次连接时会自动登记本机。</p>}
|
||||
{devices.map(d => <div key={d.id} className="milestone">
|
||||
<span className={`milestone-mark ${d.revoked ? '' : 'done'}`}>{d.revoked ? '×' : '✓'}</span>
|
||||
<span><strong>{d.name} <small className="device-platform">{d.platform}</small></strong><small>{d.client_address}{d.last_seen_at ? ` · 最近握手 ${new Date(d.last_seen_at).toLocaleString()}` : ''}</small></span>
|
||||
{d.revoked ? <em>已撤销</em> : <button className="secondary-action revoke-button" onClick={() => void revoke(d.id, d.name)}>撤销</button>}
|
||||
</div>)}
|
||||
</section>
|
||||
|
||||
<section className="info-card system-card">
|
||||
<div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>本机环境</h2><p>只读检查,不代表已具备拨号能力</p></div></div>
|
||||
{readiness ? <div className="system-grid">
|
||||
<span>操作系统</span><strong>{readiness.platform}</strong>
|
||||
<span>wg 命令</span><strong>{readiness.wg_found ? 'PATH 中已发现' : 'PATH 中未发现'}</strong>
|
||||
<span>wg-quick 命令</span><strong>{readiness.wg_quick_found ? 'PATH 中已发现' : 'PATH 中未发现'}</strong>
|
||||
</div> : <p className="hint" role="status">{readinessError || '正在检测本机环境…'}</p>}
|
||||
<p className="hint">建立隧道时系统会弹出提权授权;Windows 暂请使用 WireGuard 官方客户端导入配置。</p>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
+94
-2
@@ -3,12 +3,104 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
export type ServerHealth = { address: string; reachable: boolean }
|
||||
export type SystemReadiness = { platform: string; wg_found: boolean; wg_quick_found: boolean }
|
||||
|
||||
export type Line = {
|
||||
tunnel_id: number
|
||||
name: string
|
||||
remark: string
|
||||
state: string
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
export type SessionView = {
|
||||
username: string
|
||||
display_name: string
|
||||
device_name: string
|
||||
lines: Line[]
|
||||
offline: boolean
|
||||
}
|
||||
|
||||
export type ConnPhase = 'disconnected' | 'connecting' | 'connected'
|
||||
|
||||
export type ConnState = {
|
||||
phase: ConnPhase
|
||||
detail: string
|
||||
tunnel_name: string
|
||||
client_address: string
|
||||
endpoint: string
|
||||
device_id: number
|
||||
since_unix: number
|
||||
}
|
||||
|
||||
export type DeviceStatus = {
|
||||
running: boolean
|
||||
connected: boolean
|
||||
latest_handshake_at?: number | null
|
||||
rx_bytes?: number | null
|
||||
tx_bytes?: number | null
|
||||
detail?: string | null
|
||||
}
|
||||
|
||||
export type StatusView = { conn: ConnState; server: DeviceStatus | null }
|
||||
|
||||
export type DeviceInfo = {
|
||||
id: number
|
||||
tunnel_id: number
|
||||
name: string
|
||||
platform: string
|
||||
client_address: string
|
||||
revoked: boolean
|
||||
last_seen_at?: string | null
|
||||
}
|
||||
|
||||
const isDesktop = () => '__TAURI_INTERNALS__' in window
|
||||
const desktopOnly = () => Promise.reject(new Error('浏览器预览不支持该操作,请使用桌面客户端。'))
|
||||
|
||||
// All desktop-to-native calls pass through this boundary. The WebView never
|
||||
// handles a management token or a WireGuard private key.
|
||||
export function checkServer(baseUrl: string): Promise<ServerHealth> {
|
||||
return invoke<ServerHealth>('check_server', { baseUrl })
|
||||
return isDesktop() ? invoke<ServerHealth>('check_server', { baseUrl }) : desktopOnly()
|
||||
}
|
||||
|
||||
export function getSystemReadiness(): Promise<SystemReadiness> {
|
||||
return invoke<SystemReadiness>('get_system_readiness')
|
||||
return isDesktop() ? invoke<SystemReadiness>('get_system_readiness') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientLogin(baseUrl: string, username: string, password: string, otp: string, deviceName: string): Promise<SessionView> {
|
||||
return isDesktop() ? invoke<SessionView>('client_login', { baseUrl, username, password, otp, deviceName }) : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientResume(): Promise<SessionView | null> {
|
||||
return isDesktop() ? invoke<SessionView | null>('client_resume') : Promise.resolve(null)
|
||||
}
|
||||
|
||||
export function clientLogout(): Promise<void> {
|
||||
return isDesktop() ? invoke<void>('client_logout') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientLines(): Promise<Line[]> {
|
||||
return isDesktop() ? invoke<Line[]>('client_lines') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientConnect(tunnelId: number): Promise<ConnState> {
|
||||
return isDesktop() ? invoke<ConnState>('client_connect', { tunnelId }) : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientDisconnect(): Promise<void> {
|
||||
return isDesktop() ? invoke<void>('client_disconnect') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientStatus(): Promise<StatusView> {
|
||||
return isDesktop() ? invoke<StatusView>('client_status') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientDevices(): Promise<DeviceInfo[]> {
|
||||
return isDesktop() ? invoke<DeviceInfo[]>('client_devices') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientRevokeDevice(deviceId: number): Promise<void> {
|
||||
return isDesktop() ? invoke<void>('client_revoke_device', { deviceId }) : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientDeviceName(): Promise<string> {
|
||||
return isDesktop() ? invoke<string>('client_device_name') : Promise.resolve('')
|
||||
}
|
||||
@@ -17,3 +17,34 @@
|
||||
.power-display:focus-visible { outline: 3px solid #144db6; outline-offset: 4px; }
|
||||
.connection-guide { margin: -11px 0 20px; padding: 11px 13px; border: 1px solid #dae7ff; border-radius: 9px; background: #f3f7ff; color: #43618f; font-size: 12px; line-height: 1.6; }
|
||||
.connection-guide button { display: block; padding: 3px 0 0; background: none; border: 0; color: #306eee; font-weight: 600; }
|
||||
|
||||
/* ---- 登录页 ---- */
|
||||
.login-shell { align-items: center; justify-content: center; background: linear-gradient(160deg, #eef4ff, #f8faff 60%, #f0f6ff); }
|
||||
.login-card { width: 420px; max-width: calc(100vw - 48px); background: #fff; border: 1px solid #e8edf5; border-radius: 20px; box-shadow: 0 18px 48px rgba(29, 72, 137, .08); padding: 36px 38px 30px; }
|
||||
.login-card h1 { font-size: 24px; margin: 22px 0 6px; }
|
||||
.login-form { display: grid; gap: 4px; margin-top: 18px; }
|
||||
.login-form label { font-size: 11px; font-weight: 700; color: #5d6e85; margin: 10px 0 6px; }
|
||||
.login-submit { margin-top: 22px; height: 44px; border: 0; border-radius: 10px; background: #3476f5; color: #fff; font-size: 14px; font-weight: 700; }
|
||||
.login-submit:hover { background: #205ed9; }
|
||||
.login-submit:disabled { opacity: .6; cursor: wait; }
|
||||
|
||||
/* ---- 连接状态 ---- */
|
||||
.power-display.on { background: linear-gradient(145deg, #2fce88, #17a268); box-shadow: 0 16px 30px #17a26849, inset 0 1px 2px #ffffff66; }
|
||||
.power-display.busy { opacity: .75; cursor: wait; animation: pulse 1.4s ease-in-out infinite; }
|
||||
@keyframes pulse { 50% { transform: scale(.96); } }
|
||||
.preview-pill.online { border-color: #bdebd6; background: #ebf8f2; color: #168861; }
|
||||
.traffic-grid { display: flex; justify-content: center; gap: 18px; margin: -8px 0 18px; font-size: 11px; color: #63738a; }
|
||||
.node-switch { color: #3977e8; font-size: 11px; white-space: nowrap; }
|
||||
.node-selector { cursor: pointer; }
|
||||
.node-selector:hover { background: #f3f7ff; }
|
||||
|
||||
/* ---- 线路卡片 ---- */
|
||||
.line-card { display: flex; align-items: center; gap: 16px; margin-bottom: 14px; }
|
||||
.line-info { flex: 1; min-width: 0; }
|
||||
.line-info h2 { display: flex; align-items: center; gap: 8px; }
|
||||
.line-current { font-size: 10px; font-style: normal; color: #168861; background: #ebf8f2; border-radius: 100px; padding: 3px 8px; }
|
||||
|
||||
/* ---- 设备列表 ---- */
|
||||
.device-platform { color: #93a1b4; font-weight: 400; }
|
||||
.revoke-button { padding: 7px 12px; font-size: 11px; color: #b34444; border-color: #f3d2d0; background: #fff7f6; }
|
||||
.revoke-button:hover { background: #ffe9e7; }
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| 版本 / 变更级别 | 0.1 / S3(未来涉及设备凭据、系统路由和隧道权限) |
|
||||
| 设计状态 | Draft |
|
||||
| 实现状态 | 客户端骨架、健康检查后自动保存地址、概览实时检测、无模拟线路、连接按钮诊断提示、只读本机工具检测及托盘入口;自动下发与拨号功能未实现 |
|
||||
| 实现状态 | 第一阶段骨架已完成;第二阶段终端用户 API、设备 Peer 登记/撤销与桌面连接代码已集成,尚待目标 OS 真实握手验收 |
|
||||
| 负责人 / 日期 | 待指定 / 2026-10-21 |
|
||||
| 关联需求 | 仓库根目录新增 `client/`,Rust + Tauri v2 桌面客户端,优先对接 WireGuard |
|
||||
| 批准状态 | 用户要求评估并新增客户端;后续接口契约尚待确认 |
|
||||
@@ -99,3 +99,20 @@ Rust 系统隧道适配层(后续) ← 仅在本机保存的 WireGuard 私
|
||||
| 企业账号映射到 VPN 权限、MFA/OIDC 与设备配额 | 阻塞自动拨号 | 稳定用户 ID + 单独设备授权策略,不按同名邮箱猜测 |
|
||||
| 会话断开是否自动注销 Peer | 阻塞自动拨号 | 区分临时连接与已授权设备;明确租约、续期与撤销 |
|
||||
| 是否需要代理模式/TCP/飞连兼容 | 不阻塞标准 WireGuard UDP | 先证明原生 WG 客户端与当前网关互通 |
|
||||
|
||||
## 8. 第二阶段:终端用户自动接入(2026-10-21)
|
||||
|
||||
**实现范围**:新增 `/api/client/login` 及令牌保护的线路、设备登记、配置、状态、撤销接口;管理后台展示设备和撤销入口。登录按 VPNUser 的实际密码与 TOTP 验证,令牌内的授权快照绑定拨号记录 ID 和凭据指纹,后续逐次核对记录仍存在、已启用、凭据未变化。管理员禁用/删除/改密或改变 MFA 时先撤销对应 WireGuard 设备,再注销相关令牌。客户端在 Rust 生成私钥,本机按服务器和用户名隔离设备记录,支持 Linux/macOS `wg-quick` 一键连接;未实现 Windows 系统隧道。
|
||||
|
||||
**账号入口与版本检测**:`/users` 是后台管理员,`/vpn-users` 才是客户端拨号身份。客户端先请求匿名 `/api/client/capabilities` 确认服务端支持,再发送密码;旧服务端将未知 API 返回 200 HTML 时明确提示升级。`oidc: false` 只表示 WireGuard 客户端浏览器免密码登录尚未实现,不影响 OpenVPN 独立插件的 OIDC 模式。后续可用浏览器 Authorization Code + PKCE、稳定飞书身份与 VPNUser 授权映射、一次性设备注册授权实现免密码;`openvpn-auth-oauth2` 的 OpenVPN 管理接口不能直接用于 WireGuard。是否有标准 OIDC issuer、回调域名及身份映射规则需接入前确定。
|
||||
|
||||
**持久化与兼容**:`AutoMigrate` 新增 `client_devices`、`client_sessions` 表;现有 VPNUser、Tunnel 及老 Peer 不重写。终端令牌仅存 SHA-256 哈希,旧客户端不存在有效令牌;设备记录绑定 VPNUser ID,同名但密码不同的线路不能借授权互访。用户密码/MFA 变更前的 Peer 永久撤销,需要客户端再次登录并重新登记设备。客户端允许 HTTP 控制面地址,但明确显示明文风险;生产建议 HTTPS。
|
||||
|
||||
**风险与恢复**:设备增删先协调运行态与磁盘配置;下发失败会尝试清理部分创建的 Peer,清理失败保留设备记录及审计,管理员应核对 `wg show` 和管理设备页。变更前备份 SQLite 数据、WireGuard 专线目录;如迁移或启动失败,回退二进制与数据库备份,不能只回滚二进制并假定新 Peer 自动失效。关闭新客户端 API 或撤销客户端令牌不等于断开已登记 Peer,须同时撤销设备/停止对应专线。Mac 提权调用与本机路由/DNS 回滚须在目标系统实机验收;Windows 暂不支持拨号。
|
||||
|
||||
| AC | 对应需求 | 操作与预期 | 当前证据 |
|
||||
|---|---|---|---|
|
||||
| AC-03 | FR-03 | 同名用户两线路使用不同密码,旧令牌不得访问另一线路;修改凭据后令牌失效 | 后端回归测试通过,真实 LDAP/TOTP 流程待验 |
|
||||
| AC-04 | FR-04 | 公钥登记/撤销后持久配置与运行接口一致,旧用户被禁用时撤销 Peer | 后端 Peer 单测通过,运行态故障注入待验 |
|
||||
| AC-05 | FR-05 | Linux/macOS 授权后连接及断开,路由和 DNS 恢复 | Rust 单测通过;目标系统实机未验 |
|
||||
| AC-06 | FR-06 | 自动领取配置;握手超时显示重新连接中;更换线路不丢原设备密钥 | Rust 单测和前端构建通过;真实网关握手未验 |
|
||||
@@ -334,6 +334,7 @@
|
||||
},
|
||||
"users": {
|
||||
"title": "User Management",
|
||||
"clientLoginHint": "These accounts sign in to the admin console. Desktop VPN clients use VPN dial-in users assigned to a WireGuard line.",
|
||||
"batchImport": "Batch Import",
|
||||
"ldapImport": "LDAP Import",
|
||||
"addUser": "Add User",
|
||||
@@ -396,6 +397,20 @@
|
||||
"emptyUsername": "(empty)",
|
||||
"errorDetails": "Error details:"
|
||||
},
|
||||
"clientDevices": {
|
||||
"title": "Client Devices",
|
||||
"hint": "Devices registered by desktop/mobile clients; revocation takes effect immediately",
|
||||
"deviceName": "Device",
|
||||
"platform": "Platform",
|
||||
"tunnel": "Line",
|
||||
"address": "Assigned IP",
|
||||
"lastHandshake": "Last Handshake",
|
||||
"revoked": "Revoked",
|
||||
"revoke": "Revoke",
|
||||
"revokeConfirm": "The device will be disconnected immediately and cannot reconnect. Continue?",
|
||||
"revokeDone": "Device revoked",
|
||||
"empty": "No client devices registered"
|
||||
},
|
||||
"vpnUsers": {
|
||||
"title": "VPN Dial-in Users",
|
||||
"mfaHint": "OpenVPN Two-Factor Auth (TOTP / Static Challenge / PIN+OTP)",
|
||||
@@ -718,6 +733,7 @@
|
||||
"nodes": "Nodes",
|
||||
"users": "Users",
|
||||
"vpnUsers": "VPN Dial-in Users",
|
||||
"clientDevices": "Client Devices",
|
||||
"plugins": "Protocol Plugins",
|
||||
"settings": "Settings",
|
||||
"logs": "Log Center",
|
||||
|
||||
@@ -334,6 +334,7 @@
|
||||
},
|
||||
"users": {
|
||||
"title": "管理用户",
|
||||
"clientLoginHint": "此处账号用于登录管理后台;桌面 VPN 客户端请使用“VPN 拨号用户”中的账号,并分配 WireGuard 专线。",
|
||||
"batchImport": "批量导入",
|
||||
"ldapImport": "LDAP 导入",
|
||||
"addUser": "添加用户",
|
||||
@@ -396,6 +397,20 @@
|
||||
"emptyUsername": "(空)",
|
||||
"errorDetails": "失败详情:"
|
||||
},
|
||||
"clientDevices": {
|
||||
"title": "客户端设备",
|
||||
"hint": "桌面/移动客户端登记的接入设备;撤销后立即失去接入能力",
|
||||
"deviceName": "设备名",
|
||||
"platform": "平台",
|
||||
"tunnel": "所属线路",
|
||||
"address": "分配地址",
|
||||
"lastHandshake": "最近握手",
|
||||
"revoked": "已撤销",
|
||||
"revoke": "撤销",
|
||||
"revokeConfirm": "撤销后该设备立即断开且无法重连,确认撤销?",
|
||||
"revokeDone": "设备已撤销",
|
||||
"empty": "暂无客户端设备登记"
|
||||
},
|
||||
"vpnUsers": {
|
||||
"title": "VPN 拨号用户",
|
||||
"mfaHint": "OpenVPN 双因素认证(TOTP / 静态挑战 / PIN+OTP)",
|
||||
@@ -718,6 +733,7 @@
|
||||
"nodes": "节点管理",
|
||||
"users": "用户管理",
|
||||
"vpnUsers": "VPN 拨号用户",
|
||||
"clientDevices": "客户端设备",
|
||||
"plugins": "协议插件",
|
||||
"settings": "系统设置",
|
||||
"logs": "日志中心",
|
||||
|
||||
@@ -18,6 +18,7 @@ const routes = [
|
||||
{ path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { titleKey: 'router.ipsecPeer' } },
|
||||
{ path: 'users', component: () => import('./views/Users.vue'), meta: { titleKey: 'router.users', requiresAdmin: true } },
|
||||
{ path: 'vpn-users', component: () => import('./views/VPNUsers.vue'), meta: { titleKey: 'router.vpnUsers', requiresAdmin: true } },
|
||||
{ path: 'client-devices', component: () => import('./views/ClientDevices.vue'), meta: { titleKey: 'router.clientDevices', requiresAdmin: true } },
|
||||
{ path: 'settings', component: () => import('./views/Settings.vue'), meta: { titleKey: 'router.settings' } },
|
||||
{ path: ':pathMatch(.*)*', component: () => import('./views/NotFound.vue'), meta: { titleKey: 'router.notFound' } }
|
||||
]
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="vpn-header">
|
||||
<div>
|
||||
<b>{{ $t('clientDevices.title') }}</b>
|
||||
<span style="margin-left:8px;color:#909399;font-size:12px">
|
||||
{{ $t('clientDevices.hint') }}
|
||||
</span>
|
||||
</div>
|
||||
<el-button :icon="Refresh" circle :loading="loading" @click="load" />
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="devices" size="small" v-loading="loading">
|
||||
<el-table-column prop="username" :label="$t('common.username')" width="140" />
|
||||
<el-table-column prop="name" :label="$t('clientDevices.deviceName')" min-width="150" />
|
||||
<el-table-column prop="platform" :label="$t('clientDevices.platform')" width="100" />
|
||||
<el-table-column prop="tunnel_name" :label="$t('clientDevices.tunnel')" min-width="140" />
|
||||
<el-table-column prop="client_address" :label="$t('clientDevices.address')" width="130" />
|
||||
<el-table-column :label="$t('common.traffic')" width="170">
|
||||
<template #default="{ row }">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('clientDevices.lastHandshake')" width="170">
|
||||
<template #default="{ row }">{{ row.last_seen_at ? new Date(row.last_seen_at).toLocaleString() : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('common.status')" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.revoked ? 'info' : 'success'" size="small">
|
||||
{{ row.revoked ? $t('clientDevices.revoked') : $t('common.enabled') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('common.operation')" width="110" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-popconfirm v-if="!row.revoked" :title="$t('clientDevices.revokeConfirm')" @confirm="revoke(row)">
|
||||
<template #reference>
|
||||
<el-button type="danger" size="small" plain>{{ $t('clientDevices.revoke') }}</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
<span v-else style="color:#c0c4cc">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>{{ $t('clientDevices.empty') }}</template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '../api'
|
||||
import { fmtBytes } from '../utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
const devices = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/client-devices')
|
||||
devices.value = data.devices || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(row) {
|
||||
await api.post(`/client-devices/${row.id}/revoke`)
|
||||
ElMessage.success(t('clientDevices.revokeDone'))
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -15,6 +15,7 @@
|
||||
</el-menu-item-group>
|
||||
<el-menu-item-group v-if="auth.isAdmin()" :title="$t('router.sidebarResource')">
|
||||
<el-menu-item index="/vpn-users"><el-icon><Iphone /></el-icon>{{ $t('router.vpnUsers') }}</el-menu-item>
|
||||
<el-menu-item index="/client-devices"><el-icon><Monitor /></el-icon>{{ $t('router.clientDevices') }}</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
<el-menu-item-group :title="$t('router.sidebarSystem')">
|
||||
<el-menu-item v-if="auth.isAdmin()" index="/users"><el-icon><UserFilled /></el-icon>{{ $t('router.users') }}</el-menu-item>
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<el-row :gutter="16">
|
||||
<el-col :xs="24" :lg="15">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-alert :title="$t('users.clientLoginHint')" type="info" :closable="false" style="margin-bottom:12px">
|
||||
<template #default><router-link to="/vpn-users">{{ $t('router.vpnUsers') }} →</router-link></template>
|
||||
</el-alert>
|
||||
<template #header>
|
||||
<div class="row-between">
|
||||
<b>{{ $t('users.title') }}</b>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<el-table-column prop="source" :label="$t('common.source')" width="90" />
|
||||
<el-table-column :label="$t('vpnUsers.otpMfa')" width="130">
|
||||
<template #default="{ row }">
|
||||
<template v-if="isOpenVPN(row.tunnel_id)">
|
||||
<template v-if="supportsMFA(row.tunnel_id)">
|
||||
<el-tag :type="row.mfa_enabled ? 'success' : 'info'" size="small">
|
||||
{{ row.mfa_enabled ? $t('vpnUsers.bound') : $t('vpnUsers.unbound') }}
|
||||
</el-tag>
|
||||
@@ -61,8 +61,8 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('common.operation')" width="280">
|
||||
<template #default="{ row }">
|
||||
<el-button link size="small" @click="download(row)">{{ $t('vpnUsers.downloadConfig') }}</el-button>
|
||||
<el-button v-if="isOpenVPN(row.tunnel_id)" link size="small" type="primary" @click="openOTPManager(row)">{{ $t('vpnUsers.otpManagement') }}</el-button>
|
||||
<el-button v-if="tunnels.find(x => x.id === row.tunnel_id)?.protocol !== 'wireguard'" link size="small" @click="download(row)">{{ $t('vpnUsers.downloadConfig') }}</el-button>
|
||||
<el-button v-if="supportsMFA(row.tunnel_id)" link size="small" type="primary" @click="openOTPManager(row)">{{ $t('vpnUsers.otpManagement') }}</el-button>
|
||||
<el-button link size="small" @click="toggle(row)">{{ row.enabled ? $t('common.disabled') : $t('common.enabled') }}</el-button>
|
||||
<el-button type="danger" link size="small" @click="remove(row)">{{ $t('common.delete') }}</el-button>
|
||||
</template>
|
||||
@@ -201,6 +201,7 @@ const tunnelProto = id => {
|
||||
return tun ? t(protocolDisplay(tun.protocol)) : ''
|
||||
}
|
||||
const isOpenVPN = id => tunnels.value.find(x => x.id === id)?.protocol === 'openvpn'
|
||||
const supportsMFA = id => ['openvpn', 'wireguard'].includes(tunnels.value.find(x => x.id === id)?.protocol)
|
||||
const isLocked = row => row.mfa_locked_until && new Date(row.mfa_locked_until) > new Date()
|
||||
const filteredUsers = computed(() => users.value.filter(user => {
|
||||
const matchKeyword = !keyword.value || [user.username, user.display_name, user.email].some(v => (v || '').toLowerCase().includes(keyword.value.toLowerCase()))
|
||||
@@ -212,7 +213,7 @@ async function load() {
|
||||
try {
|
||||
const [u, all] = await Promise.all([api.get('/vpn-users'), api.get('/tunnels')])
|
||||
users.value = u.data || []
|
||||
tunnels.value = (all.data || []).filter(tun => ['openvpn', 'l2tp', 'ikev2'].includes(tun.protocol))
|
||||
tunnels.value = (all.data || []).filter(tun => ['openvpn', 'l2tp', 'ikev2', 'wireguard'].includes(tun.protocol))
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user