vdi-cloud/platform/internal/server/terminal.go

292 lines
11 KiB
Go

package server
import (
"crypto/subtle"
"encoding/json"
"github.com/gin-gonic/gin"
"strings"
"time"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
)
func (s *Server) enroll(c *gin.Context) {
var in struct {
SN string `json:"sn" binding:"required,min=3,max=100"`
Name string `json:"name" binding:"required,max=80"`
Group string `json:"group" binding:"max=80"`
}
if !s.decode(c, &in) {
return
}
in.SN = strings.TrimSpace(in.SN)
in.Name = strings.TrimSpace(in.Name)
if in.SN == "" || in.Name == "" {
s.fail(c, 400, "invalid_request", "SN 和终端名称不能为空")
return
}
id, secret := store.ID("term_"), store.ID("activate_")
_, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO terminals(id,sn,name,group_name,bootstrap_hash) VALUES($1,$2,$3,$4,$5)", id, in.SN, in.Name, in.Group, store.Hash(secret))
if err != nil {
if conflict(err) {
s.fail(c, 409, "sn_exists", "该 SN 已登记")
} else {
s.dbError(c, err)
}
return
}
s.log(c, "enroll_terminal", id, "success")
c.JSON(201, gin.H{"id": id, "activationSecret": secret})
}
func (s *Server) register(c *gin.Context) {
var in struct {
SN string `json:"sn" binding:"required,max=100"`
ActivationSecret string `json:"activationSecret" binding:"required,max=200"`
AgentVersion string `json:"agentVersion" binding:"required,max=80"`
}
if !s.decode(c, &in) {
return
}
token := store.ID("device_")
var id string
err := s.DB.GetContext(c.Request.Context(), &id, `UPDATE terminals SET token_hash=$1,bootstrap_hash='',status='online',agent_version=$2,last_seen=now() WHERE sn=$3 AND bootstrap_hash=$4 AND bootstrap_hash!='' RETURNING id`, store.Hash(token), in.AgentVersion, in.SN, store.Hash(in.ActivationSecret))
if err != nil {
s.fail(c, 401, "invalid_activation", "激活信息无效或已使用,请联系管理员")
return
}
c.JSON(200, gin.H{"terminalId": id, "accessToken": token, "heartbeatInterval": 30})
}
func (s *Server) deviceAuth(c *gin.Context) {
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
var id string
if token == "" || s.DB.GetContext(c.Request.Context(), &id, "SELECT id FROM terminals WHERE token_hash=$1", store.Hash(token)) != nil {
s.fail(c, 401, "device_unauthorized", "终端身份无效")
return
}
c.Set("terminalId", id)
c.Next()
}
func (s *Server) heartbeat(c *gin.Context) {
var in struct {
AgentVersion string `json:"agentVersion" binding:"required,max=80"`
FPS *float64 `json:"fps" binding:"omitempty,min=0,max=1000"`
RTT *float64 `json:"rtt" binding:"omitempty,min=0,max=60000"`
}
if !s.decode(c, &in) {
return
}
_, err := s.DB.ExecContext(c.Request.Context(), "UPDATE terminals SET last_seen=now(),status='online',agent_version=$1,fps=$2,rtt=$3 WHERE id=$4", in.AgentVersion, in.FPS, in.RTT, c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"accepted": true, "serverTime": time.Now().UTC()})
}
func (s *Server) deviceConfig(c *gin.Context) {
var version int
if err := s.DB.GetContext(c.Request.Context(), &version, "SELECT config_version FROM terminals WHERE id=$1", c.GetString("terminalId")); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"configVersion": version, "identityMode": "device", "commandTransport": "https-poll", "heartbeatInterval": 30})
}
func (s *Server) deviceDesktops(c *gin.Context) {
items := []model.Desktop{}
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT d.* FROM desktops d JOIN bindings b ON b.desktop_id=d.id JOIN users u ON u.id=b.user_id WHERE u.terminal_id=$1 AND u.enabled=true ORDER BY d.name`, c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) deviceCommands(c *gin.Context) {
items := []model.Task{}
err := s.DB.SelectContext(c.Request.Context(), &items, `UPDATE tasks SET status='running',updated_at=now(),message='终端已领取,等待执行回执' WHERE id IN (SELECT id FROM tasks WHERE kind='terminal' AND target_id=$1 AND status='queued' AND created_at>now()-interval '2 minutes' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 10) RETURNING *`, c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) deviceAck(c *gin.Context) {
var in struct {
Status string `json:"status" binding:"required,oneof=success failed"`
Message string `json:"message" binding:"max=300"`
Diagnostics *model.DeviceDiagnostics `json:"diagnostics"`
}
if !s.decode(c, &in) {
return
}
var task model.Task
err := s.DB.GetContext(c.Request.Context(), &task, "SELECT * FROM tasks WHERE id=$1 AND kind='terminal' AND target_id=$2", c.Param("id"), c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
if task.Status == in.Status {
c.JSON(200, gin.H{"accepted": true})
return
}
if task.Status != "running" {
s.fail(c, 409, "invalid_task_state", "该命令尚未领取或已经结束")
return
}
if task.Action == "collect-logs" && in.Status == "success" {
if in.Diagnostics == nil {
s.fail(c, 400, "diagnostics_required", "收集日志成功需要上传诊断结果")
return
}
data, marshalErr := json.Marshal(in.Diagnostics)
if marshalErr != nil {
s.dbError(c, marshalErr)
return
}
if _, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO task_diagnostics(task_id,content) VALUES($1,$2) ON CONFLICT(task_id) DO NOTHING", task.ID, string(data)); err != nil {
s.dbError(c, err)
return
}
}
// Agent text can contain sensitive device details. Keep the public audit message fixed.
if err = s.complete(c.Request.Context(), task, in.Status, "已收到终端执行回执"); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"accepted": true})
}
func (s *Server) createTicket(c *gin.Context) {
var in struct {
DesktopID string `json:"desktopId" binding:"required,max=100"`
}
if !s.decode(c, &in) {
return
}
var allowed bool
err := s.DB.GetContext(c.Request.Context(), &allowed, `SELECT EXISTS(SELECT 1 FROM bindings b JOIN users u ON u.id=b.user_id WHERE b.desktop_id=$1 AND u.terminal_id=$2 AND u.enabled=true)`, in.DesktopID, c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
if !allowed {
s.fail(c, 403, "desktop_forbidden", "该终端没有桌面使用权限")
return
}
var d model.Desktop
if err = s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", in.DesktopID); err != nil {
s.dbError(c, err)
return
}
if d.PowerState != "running" || d.Endpoint == "" {
s.fail(c, 409, "desktop_not_ready", "桌面未开机或尚未配置连接地址")
return
}
token := store.ID("ticket_")
expires := time.Now().Add(45 * time.Second)
_, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO connection_tickets(token_hash,terminal_id,desktop_id,expires_at) VALUES($1,$2,$3,$4)", store.Hash(token), c.GetString("terminalId"), in.DesktopID, expires)
if err != nil {
s.dbError(c, err)
return
}
c.JSON(201, gin.H{"ticket": token, "desktopId": in.DesktopID, "expiresAt": expires, "credentialMode": "existing-pairing"})
}
func (s *Server) consumeTicket(c *gin.Context) {
var in struct {
Ticket string `json:"ticket" binding:"required,max=200"`
}
if !s.decode(c, &in) {
return
}
ctx := c.Request.Context()
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
var row struct {
TokenHash string `db:"token_hash"`
TerminalID string `db:"terminal_id"`
DesktopID string `db:"desktop_id"`
}
err = tx.GetContext(ctx, &row, `SELECT token_hash,terminal_id,desktop_id FROM connection_tickets WHERE token_hash=$1 AND consumed_at IS NULL AND expires_at>now() FOR UPDATE`, store.Hash(in.Ticket))
if err != nil || subtle.ConstantTimeCompare([]byte(row.TerminalID), []byte(c.GetString("terminalId"))) != 1 {
s.fail(c, 401, "invalid_ticket", "票据已失效或不属于当前终端")
return
}
var d model.Desktop
if err = tx.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1 FOR UPDATE", row.DesktopID); err != nil {
s.dbError(c, err)
return
}
var allowed bool
err = tx.GetContext(ctx, &allowed, `SELECT EXISTS(SELECT 1 FROM users u JOIN bindings b ON b.user_id=u.id WHERE u.terminal_id=$1 AND u.enabled AND b.desktop_id=$2)`, row.TerminalID, d.ID)
if err != nil {
s.dbError(c, err)
return
}
if !allowed {
s.fail(c, 403, "desktop_forbidden", "桌面授权已撤销")
return
}
if d.PowerState != "running" || d.Endpoint == "" {
s.fail(c, 409, "desktop_not_ready", "桌面当前不可连接")
return
}
var busy bool
if err = tx.GetContext(ctx, &busy, `SELECT EXISTS(SELECT 1 FROM terminals WHERE session_desktop_id=$1 AND id!=$2 AND last_seen>now()-interval '90 seconds')`, d.ID, row.TerminalID); err != nil {
s.dbError(c, err)
return
}
if busy {
s.fail(c, 409, "desktop_busy", "桌面已被其他终端占用")
return
}
sessionID := store.ID("session_")
_, err = tx.ExecContext(ctx, "UPDATE connection_tickets SET consumed_at=now() WHERE token_hash=$1", row.TokenHash)
if err == nil {
_, err = tx.ExecContext(ctx, "UPDATE terminals SET session_desktop_id=$1,last_seen=now(),session_id=$3,session_state='connecting' WHERE id=$2", d.ID, row.TerminalID, sessionID)
}
if err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"desktopId": d.ID, "sessionId": sessionID, "protocol": "moonlight", "endpoint": d.Endpoint, "app": d.App, "width": d.Width, "height": d.Height, "fps": d.FPS, "credentialMode": "existing-pairing"})
}
func (s *Server) deviceSession(c *gin.Context) {
var in struct {
SessionID string `json:"sessionId" binding:"required,max=100"`
State string `json:"state" binding:"required,oneof=connected ended"`
}
if !s.decode(c, &in) {
return
}
query := "UPDATE terminals SET session_state='connected',last_seen=now() WHERE id=$1 AND session_id=$2 AND session_desktop_id IS NOT NULL"
if in.State == "ended" {
query = "UPDATE terminals SET session_state='idle',session_id='',session_desktop_id=NULL WHERE id=$1 AND session_id=$2"
}
result, err := s.DB.ExecContext(c.Request.Context(), query, c.GetString("terminalId"), in.SessionID)
if err != nil {
s.dbError(c, err)
return
}
n, _ := result.RowsAffected()
if n == 0 {
s.fail(c, 409, "stale_session", "连接预约已结束或已被新会话替代")
return
}
c.JSON(200, gin.H{"accepted": true})
}
func (s *Server) taskDiagnostics(c *gin.Context) {
var data []byte
if err := s.DB.GetContext(c.Request.Context(), &data, "SELECT content FROM task_diagnostics WHERE task_id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
s.log(c, "read_diagnostics", c.Param("id"), "success")
c.Data(200, "application/json", data)
}