414 lines
16 KiB
Go
414 lines
16 KiB
Go
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)
|
|
}
|