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

264 lines
8.3 KiB
Go

package server
import (
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
"strings"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
)
const terminalListSQL = `SELECT id,sn,name,group_name,enabled,
CASE WHEN NOT enabled THEN 'disabled' WHEN status='unregistered' THEN 'unregistered'
WHEN last_seen>now()-interval '90 seconds' THEN 'online' ELSE 'offline' END status,
agent_version,last_seen,CASE WHEN enabled AND last_seen>now()-interval '90 seconds' THEN session_desktop_id ELSE NULL END session_desktop_id,
session_state,config_version,fps,rtt FROM terminals`
// Only authorization and identity changes share this lock. Device heartbeats and
// command delivery use their own terminal row and remain independent.
func (s *Server) lockAuthorization(c *gin.Context, tx *sqlx.Tx) bool {
if _, err := tx.ExecContext(c.Request.Context(), "SELECT pg_advisory_xact_lock(7651092029)"); err != nil {
s.dbError(c, err)
return false
}
return true
}
func (s *Server) updateTerminal(c *gin.Context) {
var in struct {
Name string `json:"name" binding:"required,max=80"`
Group string `json:"group" binding:"max=80"`
}
if !s.decode(c, &in) {
return
}
in.Name = strings.TrimSpace(in.Name)
in.Group = strings.TrimSpace(in.Group)
if in.Name == "" {
s.fail(c, 400, "invalid_request", "终端名称不能为空")
return
}
tx, err := s.DB.BeginTxx(c.Request.Context(), nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
var id string
if err = tx.GetContext(c.Request.Context(), &id, "UPDATE terminals SET name=$1,group_name=$2 WHERE id=$3 RETURNING id", in.Name, in.Group, c.Param("id")); err != nil {
s.dbError(c, err)
return
}
if err = accountAudit(c.Request.Context(), tx, s.admin(c).Username, "update_terminal", id, c.GetString("requestId")); err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"accepted": true})
}
func (s *Server) disableTerminal(c *gin.Context) { s.resetTerminalIdentity(c, false) }
func (s *Server) reactivateTerminal(c *gin.Context) { s.resetTerminalIdentity(c, true) }
func (s *Server) resetTerminalIdentity(c *gin.Context, reactivate bool) {
ctx := c.Request.Context()
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
if !s.lockAuthorization(c, tx) {
return
}
var state struct {
ID string `db:"id"`
Enabled bool `db:"enabled"`
Active bool `db:"active"`
}
err = tx.GetContext(ctx, &state, "SELECT id,enabled,(session_desktop_id IS NOT NULL AND last_seen>now()-interval '90 seconds') active FROM terminals WHERE id=$1 FOR UPDATE", c.Param("id"))
if err != nil {
s.dbError(c, err)
return
}
if !state.Enabled && !reactivate {
c.JSON(200, gin.H{"accepted": true})
return
}
if state.Active {
s.fail(c, 409, "terminal_in_use", "终端有活动会话或连接预约,请先结束或重置会话")
return
}
var pending bool
if err = tx.GetContext(ctx, &pending, "SELECT EXISTS(SELECT 1 FROM tasks WHERE kind='terminal' AND target_id=$1 AND status IN ('queued','running'))", state.ID); err != nil {
s.dbError(c, err)
return
}
if pending {
s.fail(c, 409, "terminal_task_pending", "终端有未完成命令,请等待回执或核对超时结果")
return
}
secret, bootstrap, status, action := "", "", "disabled", "disable_terminal"
if reactivate {
secret = store.ID("activate_")
bootstrap = store.Hash(secret)
status = "unregistered"
action = "reactivate_terminal"
}
_, err = tx.ExecContext(ctx, `UPDATE terminals SET enabled=$1,status=$2,token_hash=NULL,bootstrap_hash=$3,
last_seen=NULL,agent_version='',fps=NULL,rtt=NULL,session_id='',session_state='idle',session_desktop_id=NULL WHERE id=$4`, reactivate, status, bootstrap, state.ID)
if err == nil {
_, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE terminal_id=$1", state.ID)
}
if err == nil {
_, err = tx.ExecContext(ctx, "DELETE FROM terminal_policy_capabilities WHERE terminal_id=$1", state.ID)
}
if err == nil {
_, err = tx.ExecContext(ctx, "DELETE FROM terminal_metrics_current WHERE terminal_id=$1", state.ID)
}
if err == nil {
err = closeSessionRecords(ctx, tx, state.ID, "identity_reset")
}
if err == nil {
_, err = tx.ExecContext(ctx, `UPDATE terminal_policy_deliveries SET status='pending',error_code='',acknowledged_at=NULL WHERE terminal_id=$1 AND config_version=(SELECT config_version FROM terminals WHERE id=$1)`, state.ID)
}
if err == nil {
err = accountAudit(ctx, tx, s.admin(c).Username, action, state.ID, c.GetString("requestId"))
}
if err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
if reactivate {
c.JSON(200, gin.H{"id": state.ID, "activationSecret": secret})
} else {
c.JSON(200, gin.H{"accepted": true})
}
}
func (s *Server) updateUser(c *gin.Context) {
var in struct {
DisplayName string `json:"displayName" binding:"required,max=80"`
Enabled *bool `json:"enabled" binding:"required"`
}
if !s.decode(c, &in) {
return
}
in.DisplayName = strings.TrimSpace(in.DisplayName)
if in.DisplayName == "" {
s.fail(c, 400, "invalid_request", "用户姓名不能为空")
return
}
ctx := c.Request.Context()
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
if !s.lockAuthorization(c, tx) {
return
}
var user model.User
err = tx.GetContext(ctx, &user, "UPDATE users SET display_name=$1,enabled=$2 WHERE id=$3 RETURNING *", in.DisplayName, *in.Enabled, c.Param("id"))
if err != nil {
s.dbError(c, err)
return
}
if _, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE terminal_id=$1 AND consumed_at IS NULL", user.TerminalID); err != nil {
s.dbError(c, err)
return
}
user.DesktopIDs = []string{}
if err = tx.SelectContext(ctx, &user.DesktopIDs, "SELECT desktop_id FROM bindings WHERE user_id=$1 ORDER BY desktop_id", user.ID); err != nil {
s.dbError(c, err)
return
}
if err = accountAudit(ctx, tx, s.admin(c).Username, "update_user", user.ID, c.GetString("requestId")); err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
c.JSON(200, user)
}
func uniqueIDs(items []string) []string {
result := []string{}
seen := map[string]bool{}
for _, id := range items {
if !seen[id] {
seen[id] = true
result = append(result, id)
}
}
return result
}
func (s *Server) batchBindings(c *gin.Context) {
var in struct {
UserIDs []string `json:"userIds" binding:"required,min=1,max=100,dive,required,max=100"`
DesktopIDs []string `json:"desktopIds" binding:"required,min=1,max=100,dive,required,max=100"`
Action string `json:"action" binding:"required,oneof=grant revoke"`
}
if !s.decode(c, &in) {
return
}
in.UserIDs = uniqueIDs(in.UserIDs)
in.DesktopIDs = uniqueIDs(in.DesktopIDs)
ctx := c.Request.Context()
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
if !s.lockAuthorization(c, tx) {
return
}
var ids []string
if err = tx.SelectContext(ctx, &ids, "SELECT id FROM users WHERE id=ANY($1) ORDER BY id FOR UPDATE", in.UserIDs); err != nil {
s.dbError(c, err)
return
}
if len(ids) != len(in.UserIDs) {
s.fail(c, 400, "invalid_user", "所选用户不存在,请刷新列表")
return
}
var count int
if err = tx.GetContext(ctx, &count, "SELECT count(*) FROM desktops WHERE id=ANY($1)", in.DesktopIDs); err != nil {
s.dbError(c, err)
return
}
if count != len(in.DesktopIDs) {
s.fail(c, 400, "invalid_desktop", "所选桌面不存在,请刷新列表")
return
}
for _, id := range ids {
if in.Action == "grant" {
_, err = tx.ExecContext(ctx, "INSERT INTO bindings(user_id,desktop_id) SELECT $1,unnest($2::text[]) ON CONFLICT DO NOTHING", id, in.DesktopIDs)
} else {
_, err = tx.ExecContext(ctx, "DELETE FROM bindings WHERE user_id=$1 AND desktop_id=ANY($2)", id, in.DesktopIDs)
}
if err == nil {
_, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE consumed_at IS NULL AND terminal_id=(SELECT terminal_id FROM users WHERE id=$1)", id)
}
if err == nil {
err = accountAudit(ctx, tx, s.admin(c).Username, "batch_"+in.Action+"_bindings", id, c.GetString("requestId"))
}
if err != nil {
s.dbError(c, err)
return
}
}
if err = tx.Commit(); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"accepted": true, "userCount": len(ids)})
}