190 lines
5.5 KiB
Go
190 lines
5.5 KiB
Go
package handlers
|
||
|
||
import (
|
||
"crypto/ed25519"
|
||
"crypto/rand"
|
||
"encoding/base64"
|
||
"errors"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
|
||
"everypublish/server/internal/api/response"
|
||
"everypublish/server/internal/models"
|
||
"everypublish/server/internal/ws"
|
||
)
|
||
|
||
// AgentHandler 设备配对与列表
|
||
type AgentHandler struct {
|
||
DB *gorm.DB
|
||
Hub *ws.Hub
|
||
}
|
||
|
||
const pairAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||
|
||
var errPairCodeUsed = errors.New("pair code already used")
|
||
|
||
func randCode(n int) string {
|
||
b := make([]byte, n)
|
||
_, _ = rand.Read(b)
|
||
for i := range b {
|
||
b[i] = pairAlphabet[int(b[i])%len(pairAlphabet)]
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
// PairCode 生成配对码(5 分钟一次性)
|
||
func (h *AgentHandler) PairCode(c *gin.Context) {
|
||
pc := models.PairingCode{
|
||
WorkspaceID: c.GetUint64("wsid"),
|
||
Code: randCode(6),
|
||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||
}
|
||
if err := h.DB.Create(&pc).Error; err != nil {
|
||
response.Fail(c, http.StatusInternalServerError, 5000, "生成配对码失败")
|
||
return
|
||
}
|
||
response.Audit(c, "agent.pair_code", "pairing:"+itoa(pc.ID), gin.H{"code": pc.Code})
|
||
response.OK(c, gin.H{"code": pc.Code, "expiresAt": pc.ExpiresAt})
|
||
}
|
||
|
||
// Pair 设备配对(无鉴权,凭一次性码;注册 Ed25519 公钥)
|
||
func (h *AgentHandler) Pair(c *gin.Context) {
|
||
var req struct {
|
||
Code string `json:"code"`
|
||
DeviceName string `json:"deviceName"`
|
||
OS string `json:"os"`
|
||
Version string `json:"version"`
|
||
PublicKey string `json:"publicKey"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.Fail(c, http.StatusBadRequest, 1001, "请求体必须是 JSON")
|
||
return
|
||
}
|
||
req.Code = strings.ToUpper(strings.TrimSpace(req.Code))
|
||
req.DeviceName = strings.TrimSpace(req.DeviceName)
|
||
req.PublicKey = strings.TrimSpace(req.PublicKey)
|
||
if req.Code == "" {
|
||
response.Fail(c, http.StatusBadRequest, 1001, "连接码不能为空")
|
||
return
|
||
}
|
||
if !validPairCode(req.Code) {
|
||
response.Fail(c, http.StatusBadRequest, 1001, "配对码应为 6 位大写字母或数字")
|
||
return
|
||
}
|
||
if req.PublicKey == "" {
|
||
response.Fail(c, http.StatusBadRequest, 1001, "设备公钥不能为空,请重启客户端后重试")
|
||
return
|
||
}
|
||
publicKey, keyErr := base64.StdEncoding.DecodeString(req.PublicKey)
|
||
if keyErr != nil || len(publicKey) != ed25519.PublicKeySize {
|
||
response.Fail(c, http.StatusBadRequest, 1001, "设备公钥格式不合法")
|
||
return
|
||
}
|
||
if req.DeviceName == "" {
|
||
req.DeviceName = "macOS Agent"
|
||
}
|
||
var pc models.PairingCode
|
||
if err := h.DB.Where("code = ? and used = ?", req.Code, false).First(&pc).Error; err != nil {
|
||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||
response.Fail(c, http.StatusInternalServerError, 5000, "查询配对码失败")
|
||
return
|
||
}
|
||
response.Fail(c, http.StatusBadRequest, 2006, "配对码无效")
|
||
return
|
||
}
|
||
if time.Now().After(pc.ExpiresAt) {
|
||
response.Fail(c, http.StatusGone, 2006, "配对码已过期")
|
||
return
|
||
}
|
||
device := models.AgentDevice{
|
||
WorkspaceID: pc.WorkspaceID,
|
||
Name: req.DeviceName,
|
||
OS: req.OS,
|
||
Version: req.Version,
|
||
PublicKey: req.PublicKey,
|
||
Status: "offline",
|
||
PairedAt: time.Now(),
|
||
}
|
||
err := h.DB.Transaction(func(tx *gorm.DB) error {
|
||
// 原子占用:仅当仍 unused 且未过期时置 used=true;RowsAffected==1 才视为抢到,
|
||
// 防止并发用同一码配出多台设备。
|
||
res := tx.Model(&models.PairingCode{}).
|
||
Where("id = ? and used = ? and expires_at > ?", pc.ID, false, time.Now()).
|
||
Update("used", true)
|
||
if res.Error != nil {
|
||
return res.Error
|
||
}
|
||
if res.RowsAffected != 1 {
|
||
return errPairCodeUsed
|
||
}
|
||
if err := tx.Create(&device).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Model(&models.PairingCode{}).Where("id = ?", pc.ID).
|
||
Update("device_id", device.ID).Error; err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
if err == errPairCodeUsed {
|
||
response.Fail(c, http.StatusBadRequest, 2006, "配对码已被使用")
|
||
return
|
||
}
|
||
response.Fail(c, http.StatusInternalServerError, 5000, "配对失败")
|
||
return
|
||
}
|
||
response.OK(c, gin.H{"deviceId": device.ID, "workspaceId": device.WorkspaceID})
|
||
}
|
||
|
||
func validPairCode(code string) bool {
|
||
if len(code) != 6 {
|
||
return false
|
||
}
|
||
for _, r := range code {
|
||
if !strings.ContainsRune(pairAlphabet, r) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// Devices 设备列表(在线状态来自 Hub,此处以 DB 状态兜底)
|
||
func (h *AgentHandler) Devices(c *gin.Context) {
|
||
var list []models.AgentDevice
|
||
h.DB.Where("workspace_id = ?", c.GetUint64("wsid")).Order("id desc").Find(&list)
|
||
response.OK(c, gin.H{"list": list})
|
||
}
|
||
|
||
// Revoke 吊销设备并强制断开(owner/admin)
|
||
func (h *AgentHandler) Revoke(c *gin.Context) {
|
||
if !isAdminRole(c.GetString("mrole")) {
|
||
response.Fail(c, http.StatusForbidden, 1003, "仅管理员可吊销设备")
|
||
return
|
||
}
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
|
||
return
|
||
}
|
||
var device models.AgentDevice
|
||
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&device).Error; err != nil {
|
||
response.Fail(c, http.StatusNotFound, 1004, "设备不存在")
|
||
return
|
||
}
|
||
if err = h.DB.Model(&device).Updates(map[string]interface{}{"revoked": true, "status": "offline"}).Error; err != nil {
|
||
response.Fail(c, http.StatusInternalServerError, 5000, "吊销失败")
|
||
return
|
||
}
|
||
if h.Hub != nil {
|
||
h.Hub.KickAgent(device.ID)
|
||
}
|
||
response.Audit(c, "agent.revoke", "device:"+itoa(id), nil)
|
||
response.OK(c, nil)
|
||
}
|