diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index ec407f3..67049f3 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -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) diff --git a/backend/internal/api/client.go b/backend/internal/api/client.go new file mode 100644 index 0000000..0f973f2 --- /dev/null +++ b/backend/internal/api/client.go @@ -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}) +} diff --git a/backend/internal/api/client_test.go b/backend/internal/api/client_test.go new file mode 100644 index 0000000..40bfc89 --- /dev/null +++ b/backend/internal/api/client_test.go @@ -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 全部占用时必须报错") + } +} diff --git a/backend/internal/api/identity.go b/backend/internal/api/identity.go index 0faba44..b9321af 100644 --- a/backend/internal/api/identity.go +++ b/backend/internal/api/identity.go @@ -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 diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index e89c537..264ea3f 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -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"` diff --git a/backend/internal/service/wireguard/device.go b/backend/internal/service/wireguard/device.go new file mode 100644 index 0000000..bf56296 --- /dev/null +++ b/backend/internal/service/wireguard/device.go @@ -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") +} diff --git a/backend/internal/service/wireguard/device_test.go b/backend/internal/service/wireguard/device_test.go new file mode 100644 index 0000000..7d8f797 --- /dev/null +++ b/backend/internal/service/wireguard/device_test.go @@ -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) +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 092cc81..a9629a4 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -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 diff --git a/client/src-tauri/Cargo.lock b/client/src-tauri/Cargo.lock index 5df9c98..76b9d30 100644 --- a/client/src-tauri/Cargo.lock +++ b/client/src-tauri/Cargo.lock @@ -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" diff --git a/client/src-tauri/Cargo.toml b/client/src-tauri/Cargo.toml index 11f209f..2fc26c9 100644 --- a/client/src-tauri/Cargo.toml +++ b/client/src-tauri/Cargo.toml @@ -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" diff --git a/client/src-tauri/src/api.rs b/client/src-tauri/src/api.rs new file mode 100644 index 0000000..a2fdd86 --- /dev/null +++ b/client/src-tauri/src/api.rs @@ -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, +} + +#[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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceStatus { + pub running: bool, + pub connected: bool, + pub latest_handshake_at: Option, + pub rx_bytes: Option, + pub tx_bytes: Option, + pub detail: Option, +} + +#[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, + mfa_required: Option, +} + +fn client() -> Result { + 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 { + 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(resp: reqwest::Response) -> Result { + 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::() + .await + .map_err(|_| ApiError::Transport("服务端响应格式无法解析".to_string())); + } + let body = resp.json::().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 { + // 旧版服务端会把未知 /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, ApiError> { + #[derive(Deserialize)] + struct Resp { + lines: Vec, + } + 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).await?.lines) +} + +pub async fn register_device( + base_url: &str, + token: &str, + tunnel_id: u64, + name: &str, + public_key: &str, +) -> Result { + 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 { + 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 { + 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, ApiError> { + #[derive(Deserialize)] + struct Resp { + devices: Vec, + } + 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).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()); + } +} diff --git a/client/src-tauri/src/lib.rs b/client/src-tauri/src/lib.rs index e4fd91c..1e1af1c 100644 --- a/client/src-tauri/src/lib.rs +++ b/client/src-tauri/src/lib.rs @@ -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> = Mutex::new(None); +static TRAY_MENU: Mutex>> = 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 { + let dir = app + .path() + .app_config_dir() + .map_err(|err| format!("无法定位应用数据目录:{err}"))?; + Ok(dir) +} + +fn require_session(app: &AppHandle) -> Result { + 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, + offline: bool, +} + +#[derive(Serialize)] +struct StatusView { + conn: ConnState, + server: Option, +} + +// ---- Tauri 命令 ---- + +#[tauri::command] +async fn client_login( + app: AppHandle, + base_url: String, + username: String, + password: String, + otp: String, + device_name: String, +) -> Result { + 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, 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, 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 { + 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 { + // 已登记同线路设备:刷新连接参数;否则生成新密钥对并注册 + 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 { + 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, 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"); diff --git a/client/src-tauri/src/localstore.rs b/client/src-tauri/src/localstore.rs new file mode 100644 index 0000000..83b421c --- /dev/null +++ b/client/src-tauri/src/localstore.rs @@ -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, +} + +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 { + 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); + } +} diff --git a/client/src-tauri/src/tunnel.rs b/client/src-tauri/src/tunnel.rs new file mode 100644 index 0000000..49e9df5 --- /dev/null +++ b/client/src-tauri/src/tunnel.rs @@ -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 { + 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 { + 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 { + 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::() else { + return false; + }; + let Ok(prefix) = mask.parse::() 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::().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::().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 { + 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 { + 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); + } +} diff --git a/client/src/App.tsx b/client/src/App.tsx index 8d9893b..606e5c3 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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: , globe: <>, @@ -14,112 +20,140 @@ function Icon({ name, size = 22 }: { name: 'home' | 'globe' | 'settings' | 'powe server: <>, check: , close: , + user: <>, + device: <>, } return } +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(null) + const [booting, setBooting] = useState(true) const [page, setPage] = useState('overview') - const [address, setAddress] = useState(() => localStorage.getItem('opsx_server_address') || '') - const [savedAddress, setSavedAddress] = useState(() => localStorage.getItem('opsx_server_address') || '') - const [state, setState] = useState('idle') - const [error, setError] = useState('') - const [checkedAddress, setCheckedAddress] = useState('') - const [responseMs, setResponseMs] = useState(null) - const [readiness, setReadiness] = useState(null) - const [readinessError, setReadinessError] = useState('') - const [connectionGuide, setConnectionGuide] = useState('') + const [conn, setConn] = useState(null) + const [status, setStatus] = useState(null) + const [selectedLine, setSelectedLine] = useState(null) + const [lines, setLines] = useState([]) + 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

正在启动…

+ } + + if (!session) { + return { 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
-
OpsX VPN桌面客户端
●  客户端 0.1
+
+
OpsX VPN{session.display_name || session.username}
+ ●  {connected ? '已连接' : connecting ? '连接中' : '未连接'} +
+ {page === 'overview' &&
-
WORKSPACE / OVERVIEW

连接概览

查看本客户端的服务检测与接入状态。

●  本客户端未连接
+
+
WORKSPACE / OVERVIEW

连接概览

一键接入企业网络;连接状态以网关真实握手为准。

+ ● {connected ? `已连接 · ${currentLineName}` : connecting ? '连接中…' : '本客户端未连接'} +
-
安全连接

企业网络

WIREGUARD
-
-
本客户端未连接尚未配置自动设备授权和本机隧道
{connectionGuide &&
{connectionGuide}
} -
已授权 VPN 线路尚未获取
+
安全连接

{currentLineName || '企业网络'}

WIREGUARD
+
+
+ +
+
+ {connected ? '已连接' : connecting ? '连接中…' : '本客户端未连接'} + {connected + ? `${conn?.client_address ?? ''} · 已连接 ${formatDuration(conn?.since_unix ?? 0, now)}` + : connecting ? (conn?.detail || '正在建立隧道') : (conn?.detail || actionError || '点击上方按钮接入企业网络')} +
+ {connected && status?.server &&
+ 下行 {formatBytes(status.server.rx_bytes)} + 上行 {formatBytes(status.server.tx_bytes)} + 端点 {conn?.endpoint} +
} + {actionError && !connecting &&
{actionError}
} + {status?.server?.detail &&
{status.server.detail}
} +
setPage('routes')} onKeyDown={e => e.key === 'Enter' && setPage('routes')}> + + 当前线路{currentLineName || (lines[0]?.name ?? '暂无授权线路')} + 切换 → +
-

服务端状态

管理服务可达性

{savedAddress || '尚未设置服务端地址'}

{state === 'reachable' ? `服务可达 · 控制面往返约 ${responseMs} ms` : state === 'error' ? '检测失败' : state === 'checking' ? '检测中…' : '尚未检测'}

控制面检测不代表 VPN 已连接;概览每 30 秒更新一次。

-

接入进度

桌面端能力逐步开放

-
{state === 'reachable' ? '✓' : '1'}服务可达性{state === 'reachable' ? checkedAddress : '尚未完成健康检查'}{state === 'reachable' ? '可达' : '未确认'}
-
2用户与设备授权服务器尚无客户端专用接口未接入
-
3WireGuard 隧道未创建本机隧道或验证握手未连接
+
+

当前账号

拨号用户身份

+
+ 用户名{session.username} + 本机设备{session.device_name} + 授权线路{session.offline ? '服务端暂不可达' : `${lines.length} 条`} +
+
+
-

管理服务健康检查仅反映控制面状态;本客户端尚未建立 VPN 隧道。

+

连接状态由网关侧真实握手确认;控制面检测耗时不等于 VPN 节点延时。

} - {page === 'routes' &&
WORKSPACE / ROUTES

线路

这里只显示经当前账户授权、由服务端提供的真实线路。

暂时无法获取线路

服务端尚未提供终端用户身份和设备授权接口,因此无法查询线路或测量真实节点延时。

} - {page === 'settings' &&
WORKSPACE / SETTINGS

设置

管理服务端入口并查看本机环境。这里不会保存账号或修改本机网络。

服务端地址

检测成功后自动保存,下次打开仍可使用

{ setAddress(event.target.value); setState('idle'); setError(''); setResponseMs(null) }} />
{savedAddress &&

✓ 已保存:{savedAddress}

}{address.trim().toLowerCase().startsWith('http://') &&

HTTP 是明文传输;健康检查可用,但今后登录和设备授权建议改用 HTTPS。

}

只填写服务端根地址;检测成功会自动保存,也可直接点击“保存地址”。

{state === 'reachable' &&
服务可达 · 控制面往返约 {responseMs} ms
{checkedAddress}。这不是 VPN 节点延时或已连接状态。
}{state === 'error' &&
{error || '服务端没有返回有效的健康响应。'}
}

安全接入

用户认证、设备授权与系统隧道设置将在服务端接口具备后开放。

待接入

本机环境

只读检查,不代表已经具备 VPN 拨号能力

{readiness ?
操作系统{readiness.platform}wg 命令{readiness.wg_found ? 'PATH 中已发现' : 'PATH 中未发现'}wg-quick 命令{readiness.wg_quick_found ? 'PATH 中已发现' : 'PATH 中未发现'}
:

{readinessError || '正在检测本机环境…'}

}

仅检查文件是否存在;没有检查执行权限、驱动、系统授权或 WireGuard 隧道状态。

} + + {page === 'routes' &&
+ WORKSPACE / ROUTES

线路

+

这里只显示当前账号被授权的真实线路。

+ {lines.length === 0 &&
+ +

{session.offline ? '服务端暂不可达' : '暂无授权线路'}

{session.offline ? '恢复网络后自动重试。' : '请联系管理员在 WireGuard 专线中为您创建拨号用户。'}

+ +
} + {lines.map(line =>
+ +
+

{line.name} {selectedLine === line.tunnel_id && 当前}

+

{line.remark || line.endpoint || '—'}

+
+ {line.state === 'running' ? '运行中' : '未运行'} + +
)} +
} + + {page === 'settings' && }
} + +// ---- 登录页 ---- + +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
+
+
OpsX VPN企业网络接入
+

登录

+

使用管理员为您开通的 VPN 拨号账号登录;首次连接会自动为本机登记设备。

+
+ +
+ { setAddress(e.target.value); setHealth('idle') }} /> + +
+ {health === 'reachable' &&

✓ 服务可达

} + {health === 'error' &&

服务不可达,请确认地址与网络。

} + {address.trim().toLowerCase().startsWith('http://') &&

HTTP 为明文传输,登录口令可能被窃听,建议使用可信 HTTPS 域名。

} + +
setUsername(e.target.value)} />
+ +
setPassword(e.target.value)} />
+ {mfaRequired && <> + +
setOtp(e.target.value)} />
+ } + +
setDeviceName(e.target.value)} placeholder="自动填充本机主机名" />
+ {error &&
{error}
} + +
+
+
+} + +// ---- 服务端健康卡片(概览右栏)---- + +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(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
+

服务端状态

管理服务可达性

+

{saved || '未设置'}

+ {state === 'reachable' ? `服务可达 · 控制面往返约 ${responseMs} ms` : state === 'error' ? '检测失败' : state === 'checking' ? '检测中…' : '尚未检测'} +

控制面检测不代表 VPN 已连接。

+
+} + +// ---- 设置页 ---- + +function SettingsPage({ session, onLogout }: { session: SessionView; onLogout: () => void }) { + const [readiness, setReadiness] = useState(null) + const [readinessError, setReadinessError] = useState('') + const [devices, setDevices] = useState([]) + 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
+ WORKSPACE / SETTINGS

设置

+

管理本机设备与本机环境。服务端地址在退出登录后可修改。

+ +
+

账号

当前登录的拨号用户

+
+ 用户名{session.username} + 姓名{session.display_name || '—'} + 服务端{localStorage.getItem('opsx_server_address') || '—'} +
+ +
+ +
+

我的设备

撤销后该设备立即无法接入

+ {deviceError &&
{deviceError}
} + {devices.length === 0 &&

尚未登记设备;首次连接时会自动登记本机。

} + {devices.map(d =>
+ {d.revoked ? '×' : '✓'} + {d.name} {d.platform}{d.client_address}{d.last_seen_at ? ` · 最近握手 ${new Date(d.last_seen_at).toLocaleString()}` : ''} + {d.revoked ? 已撤销 : } +
)} +
+ +
+

本机环境

只读检查,不代表已具备拨号能力

+ {readiness ?
+ 操作系统{readiness.platform} + wg 命令{readiness.wg_found ? 'PATH 中已发现' : 'PATH 中未发现'} + wg-quick 命令{readiness.wg_quick_found ? 'PATH 中已发现' : 'PATH 中未发现'} +
:

{readinessError || '正在检测本机环境…'}

} +

建立隧道时系统会弹出提权授权;Windows 暂请使用 WireGuard 官方客户端导入配置。

+
+
+} diff --git a/client/src/platform.ts b/client/src/platform.ts index 31ae835..c4c214e 100644 --- a/client/src/platform.ts +++ b/client/src/platform.ts @@ -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 { - return invoke('check_server', { baseUrl }) + return isDesktop() ? invoke('check_server', { baseUrl }) : desktopOnly() } export function getSystemReadiness(): Promise { - return invoke('get_system_readiness') + return isDesktop() ? invoke('get_system_readiness') : desktopOnly() +} + +export function clientLogin(baseUrl: string, username: string, password: string, otp: string, deviceName: string): Promise { + return isDesktop() ? invoke('client_login', { baseUrl, username, password, otp, deviceName }) : desktopOnly() +} + +export function clientResume(): Promise { + return isDesktop() ? invoke('client_resume') : Promise.resolve(null) +} + +export function clientLogout(): Promise { + return isDesktop() ? invoke('client_logout') : desktopOnly() +} + +export function clientLines(): Promise { + return isDesktop() ? invoke('client_lines') : desktopOnly() +} + +export function clientConnect(tunnelId: number): Promise { + return isDesktop() ? invoke('client_connect', { tunnelId }) : desktopOnly() +} + +export function clientDisconnect(): Promise { + return isDesktop() ? invoke('client_disconnect') : desktopOnly() +} + +export function clientStatus(): Promise { + return isDesktop() ? invoke('client_status') : desktopOnly() +} + +export function clientDevices(): Promise { + return isDesktop() ? invoke('client_devices') : desktopOnly() +} + +export function clientRevokeDevice(deviceId: number): Promise { + return isDesktop() ? invoke('client_revoke_device', { deviceId }) : desktopOnly() +} + +export function clientDeviceName(): Promise { + return isDesktop() ? invoke('client_device_name') : Promise.resolve('') } diff --git a/client/src/system.css b/client/src/system.css index 32c3f6d..8c84e06 100644 --- a/client/src/system.css +++ b/client/src/system.css @@ -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; } diff --git a/docs/specs/SDD-002-desktop-wireguard-client.md b/docs/specs/SDD-002-desktop-wireguard-client.md index df3c003..9d13625 100644 --- a/docs/specs/SDD-002-desktop-wireguard-client.md +++ b/docs/specs/SDD-002-desktop-wireguard-client.md @@ -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 单测和前端构建通过;真实网关握手未验 | diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index 27b74dd..86ab991 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -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", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index bbb6efe..a3f32a8 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -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": "日志中心", diff --git a/frontend/src/router.js b/frontend/src/router.js index 24edeb0..051a987 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -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' } } ] diff --git a/frontend/src/views/ClientDevices.vue b/frontend/src/views/ClientDevices.vue new file mode 100644 index 0000000..9aa8c5c --- /dev/null +++ b/frontend/src/views/ClientDevices.vue @@ -0,0 +1,77 @@ + + + diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index 1f2775c..fd452e8 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.vue @@ -15,6 +15,7 @@ {{ $t('router.vpnUsers') }} + {{ $t('router.clientDevices') }} {{ $t('router.users') }} diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue index 2045758..ac6cee8 100644 --- a/frontend/src/views/Users.vue +++ b/frontend/src/views/Users.vue @@ -2,6 +2,9 @@ + + +