package server import ( "crypto/subtle" "database/sql" "encoding/json" "github.com/gin-gonic/gin" "github.com/jmoiron/sqlx" "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 enabled AND 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}) } // Keep identity validation and device work under the same row lock. A revoked // token cannot resume an in-flight request after reactivation changes the row. func (s *Server) deviceAuth(c *gin.Context) { token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") if token == "" { s.fail(c, 401, "device_unauthorized", "终端身份无效") return } tx, err := s.DB.BeginTxx(c.Request.Context(), nil) if err != nil { s.dbError(c, err) return } defer tx.Rollback() if strings.HasSuffix(c.FullPath(), "/connect-ticket") || strings.HasSuffix(c.FullPath(), "/connect-ticket/consume") || strings.HasSuffix(c.FullPath(), "/desktop-prepare") { if !s.lockAuthorization(c, tx) { return } } var id string if tx.GetContext(c.Request.Context(), &id, "SELECT id FROM terminals WHERE token_hash=$1 AND enabled FOR UPDATE", store.Hash(token)) != nil { s.fail(c, 401, "device_unauthorized", "终端身份无效或已停用") return } c.Set("terminalId", id) c.Set("deviceTx", tx) c.Next() } func deviceTx(c *gin.Context) *sqlx.Tx { return c.MustGet("deviceTx").(*sqlx.Tx) } func (s *Server) commitDevice(c *gin.Context) bool { if err := deviceTx(c).Commit(); err != nil { s.dbError(c, err) return false } return true } func (s *Server) deviceJSON(c *gin.Context, status int, value any) { if s.commitDevice(c) { c.JSON(status, value) } } func (s *Server) heartbeat(c *gin.Context) { var in struct { AgentVersion string `json:"agentVersion" binding:"required,max=80"` PolicyProtocol int `json:"policyProtocol" binding:"min=0,max=1"` FPS *float64 `json:"fps" binding:"omitempty,min=0,max=1000"` RTT *float64 `json:"rtt" binding:"omitempty,min=0,max=60000"` SystemMetrics *terminalSystemMetrics `json:"systemMetrics"` } if !s.decode(c, &in) { return } _, err := deviceTx(c).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 } if err = saveTerminalMetrics(c.Request.Context(), deviceTx(c), c.GetString("terminalId"), in.SystemMetrics); err != nil { s.dbError(c, err) return } if _, err = deviceTx(c).ExecContext(c.Request.Context(), "INSERT INTO terminal_policy_capabilities(terminal_id,protocol) VALUES($1,$2) ON CONFLICT(terminal_id) DO UPDATE SET protocol=EXCLUDED.protocol", c.GetString("terminalId"), in.PolicyProtocol); err != nil { s.dbError(c, err) return } if _, err = deviceTx(c).ExecContext(c.Request.Context(), "UPDATE session_history SET last_reported_at=now() WHERE terminal_id=$1 AND ended_at IS NULL", c.GetString("terminalId")); err != nil { s.dbError(c, err) return } // An upgraded or temporarily unreachable device may already have a ready // session. Start a new observed segment without inventing its original start. if _, err = deviceTx(c).ExecContext(c.Request.Context(), `INSERT INTO session_history(id,session_id,terminal_id,terminal_name,desktop_id,desktop_name,username,display_name,state,source,connected_at) SELECT $1,t.session_id,t.id,t.name,d.id,d.name,COALESCE(u.username,''),COALESCE(u.display_name,''),'connected','observed',now() FROM terminals t JOIN desktops d ON d.id=t.session_desktop_id LEFT JOIN users u ON u.terminal_id=t.id WHERE t.id=$2 AND t.session_state='connected' AND t.session_id!='' AND NOT EXISTS(SELECT 1 FROM session_history h WHERE h.terminal_id=t.id AND h.ended_at IS NULL)`, store.ID("history_"), c.GetString("terminalId")); err != nil { s.dbError(c, err) return } s.deviceJSON(c, 200, gin.H{"accepted": true, "serverTime": time.Now().UTC()}) } func (s *Server) deviceConfig(c *gin.Context) { var version int if err := deviceTx(c).GetContext(c.Request.Context(), &version, "SELECT config_version FROM terminals WHERE id=$1", c.GetString("terminalId")); err != nil { s.dbError(c, err) return } var item struct { DeploymentID string `db:"deployment_id"` HeartbeatInterval int `db:"heartbeat_interval"` CommandPollInterval int `db:"command_poll_interval"` DesktopRefreshInterval int `db:"desktop_refresh_interval"` } err := deviceTx(c).GetContext(c.Request.Context(), &item, `SELECT r.deployment_id,p.heartbeat_interval,p.command_poll_interval,p.desktop_refresh_interval FROM terminal_policy_deliveries r JOIN terminal_policy_deployments d ON d.id=r.deployment_id JOIN terminal_policy_templates p ON p.id=d.template_id WHERE r.terminal_id=$1 AND r.config_version=$2`, c.GetString("terminalId"), version) if err != nil && err != sql.ErrNoRows { s.dbError(c, err) return } if err == sql.ErrNoRows { item.HeartbeatInterval = 30 item.CommandPollInterval = 3 item.DesktopRefreshInterval = 15 } s.deviceJSON(c, 200, gin.H{"configVersion": version, "deploymentId": item.DeploymentID, "policyProtocol": 1, "identityMode": "device", "commandTransport": "https-poll", "heartbeatInterval": item.HeartbeatInterval, "commandPollInterval": item.CommandPollInterval, "desktopRefreshInterval": item.DesktopRefreshInterval}) } func (s *Server) deviceDesktops(c *gin.Context) { items := []model.Desktop{} err := deviceTx(c).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 } if s.commitDevice(c) { type item struct { model.Desktop PowerOnConnect bool `json:"powerOnConnect"` } result := make([]item, 0, len(items)) for _, d := range items { result = append(result, item{d, s.exclusiveGPU(d) && !d.SetupRequired && d.Endpoint != ""}) } list(c, result) } } func (s *Server) deviceCommands(c *gin.Context) { items := []model.Task{} err := deviceTx(c).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 } if s.commitDevice(c) { 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 := deviceTx(c).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 { s.deviceJSON(c, 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 = deviceTx(c).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.completeTx(c.Request.Context(), deviceTx(c), task, in.Status, "已收到终端执行回执"); err != nil { s.dbError(c, err) return } s.deviceJSON(c, 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 := deviceTx(c).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 = deviceTx(c).GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", in.DesktopID); err != nil { s.dbError(c, err) return } if !s.hardwareAllowsConnection(c, d.ID) { return } if !s.gpuAllowsConnection(c, d) { return } if d.SetupRequired || (d.PowerState != "running" && !s.exclusiveGPU(d)) || d.Endpoint == "" { s.fail(c, 409, "desktop_not_ready", "桌面未开机或尚未配置连接地址") return } token := store.ID("ticket_") expires := time.Now().Add(45 * time.Second) _, err = deviceTx(c).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 } s.deviceJSON(c, 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 := deviceTx(c) var err error 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 !s.hardwareAllowsConnection(c, d.ID) { return } if !s.gpuAllowsConnection(c, d) { return } if d.SetupRequired || (d.PowerState != "running" && !s.exclusiveGPU(d)) || 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 = closeSessionRecords(ctx, tx, row.TerminalID, "replaced") } if err == nil { err = startSessionRecord(ctx, tx, row.TerminalID, sessionID, d.ID, "connecting", "ticket") } if err != nil { s.dbError(c, err) return } s.deviceJSON(c, 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 := deviceTx(c).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 } tx := deviceTx(c) ctx := c.Request.Context() if in.State == "ended" { _, err = tx.ExecContext(ctx, "UPDATE session_history SET state='ended',ended_at=now(),last_reported_at=now(),end_reason='client_ended' WHERE terminal_id=$1 AND session_id=$2 AND ended_at IS NULL", c.GetString("terminalId"), in.SessionID) } else { var result sql.Result result, err = tx.ExecContext(ctx, "UPDATE session_history SET state='connected',connected_at=COALESCE(connected_at,now()),last_reported_at=now() WHERE terminal_id=$1 AND session_id=$2 AND ended_at IS NULL", c.GetString("terminalId"), in.SessionID) if err == nil { var count int64 count, err = result.RowsAffected() if err == nil && count == 0 { var desktop string err = tx.GetContext(ctx, &desktop, "SELECT session_desktop_id FROM terminals WHERE id=$1", c.GetString("terminalId")) if err == nil { err = startSessionRecord(ctx, tx, c.GetString("terminalId"), in.SessionID, desktop, "connected", "observed") } } } } if err != nil { s.dbError(c, err) return } s.deviceJSON(c, 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) }