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) }