323 lines
13 KiB
Go
323 lines
13 KiB
Go
package server
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"vdi-cloud/platform/internal/store"
|
|
)
|
|
|
|
// These settings affect management polling only, never the active stream.
|
|
type PolicySettings struct {
|
|
HeartbeatInterval int `json:"heartbeatInterval" db:"heartbeat_interval" binding:"min=15,max=60"`
|
|
CommandPollInterval int `json:"commandPollInterval" db:"command_poll_interval" binding:"min=3,max=30"`
|
|
DesktopRefreshInterval int `json:"desktopRefreshInterval" db:"desktop_refresh_interval" binding:"min=5,max=120"`
|
|
}
|
|
type terminalPolicy struct {
|
|
ID string `json:"id" db:"id"`
|
|
Name string `json:"name" db:"name" binding:"required,max=80"`
|
|
Version string `json:"version" db:"version" binding:"required,max=40"`
|
|
PolicySettings
|
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
|
}
|
|
type policyDeployment struct {
|
|
ID string `json:"id" db:"id"`
|
|
TemplateID string `json:"templateId" db:"template_id"`
|
|
TemplateName string `json:"templateName" db:"template_name"`
|
|
TemplateVersion string `json:"templateVersion" db:"template_version"`
|
|
Actor string `json:"actor" db:"actor"`
|
|
TargetCount int `json:"targetCount" db:"target_count"`
|
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
|
}
|
|
type policyResult struct {
|
|
ID string `json:"id" db:"id"`
|
|
TerminalID string `json:"terminalId" db:"terminal_id"`
|
|
TerminalName string `json:"terminalName" db:"terminal_name"`
|
|
Group string `json:"group" db:"group_name"`
|
|
Protocol int `json:"protocol" db:"protocol"`
|
|
DeploymentID string `json:"deploymentId" db:"deployment_id"`
|
|
TemplateName string `json:"templateName" db:"template_name"`
|
|
TemplateVersion string `json:"templateVersion" db:"template_version"`
|
|
ConfigVersion int `json:"configVersion" db:"config_version"`
|
|
Status string `json:"status" db:"status"`
|
|
ErrorCode string `json:"errorCode" db:"error_code"`
|
|
AcknowledgedAt *time.Time `json:"acknowledgedAt" db:"acknowledged_at"`
|
|
Online bool `json:"online" db:"online"`
|
|
Enabled bool `json:"enabled" db:"enabled"`
|
|
Registered bool `json:"registered" db:"registered"`
|
|
}
|
|
|
|
const deploymentSQL = `SELECT d.id,d.template_id,p.name template_name,p.version template_version,d.actor,
|
|
(SELECT count(*) FROM terminal_policy_deliveries r WHERE r.deployment_id=d.id) target_count,d.created_at
|
|
FROM terminal_policy_deployments d JOIN terminal_policy_templates p ON p.id=d.template_id`
|
|
const resultSQL = `SELECT COALESCE(r.id,'') id,t.id terminal_id,t.name terminal_name,t.group_name,
|
|
COALESCE(c.protocol,0) protocol,COALESCE(r.deployment_id,'') deployment_id,
|
|
COALESCE(p.name,'') template_name,COALESCE(p.version,'') template_version,
|
|
t.config_version,COALESCE(r.status,'unconfigured') status,COALESCE(r.error_code,'') error_code,r.acknowledged_at,
|
|
COALESCE(t.last_seen>now()-interval '90 seconds',false) online,t.enabled,(t.token_hash IS NOT NULL) registered
|
|
FROM terminals t LEFT JOIN terminal_policy_capabilities c ON c.terminal_id=t.id
|
|
LEFT JOIN terminal_policy_deliveries r ON r.terminal_id=t.id AND r.config_version=t.config_version
|
|
LEFT JOIN terminal_policy_deployments d ON d.id=r.deployment_id
|
|
LEFT JOIN terminal_policy_templates p ON p.id=d.template_id`
|
|
|
|
func (s *Server) policyTemplates(c *gin.Context) {
|
|
items := []terminalPolicy{}
|
|
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM terminal_policy_templates ORDER BY created_at DESC,id"); err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
list(c, items)
|
|
}
|
|
func (s *Server) createPolicyTemplate(c *gin.Context) {
|
|
var in terminalPolicy
|
|
if !s.decode(c, &in) {
|
|
return
|
|
}
|
|
in.Name = strings.TrimSpace(in.Name)
|
|
in.Version = strings.TrimSpace(in.Version)
|
|
if in.Name == "" || in.Version == "" {
|
|
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()
|
|
err = tx.GetContext(c.Request.Context(), &in, `INSERT INTO terminal_policy_templates(id,name,version,heartbeat_interval,command_poll_interval,desktop_refresh_interval)
|
|
VALUES($1,$2,$3,$4,$5,$6) RETURNING *`, store.ID("policy_"), in.Name, in.Version, in.HeartbeatInterval, in.CommandPollInterval, in.DesktopRefreshInterval)
|
|
if conflict(err) {
|
|
s.fail(c, 409, "policy_exists", "该策略名称和版本已存在,请创建新版本")
|
|
return
|
|
}
|
|
if err == nil {
|
|
err = accountAudit(c.Request.Context(), tx, s.admin(c).Username, "create_terminal_policy", in.ID, c.GetString("requestId"))
|
|
}
|
|
if err == nil {
|
|
err = tx.Commit()
|
|
}
|
|
if err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
c.JSON(201, in)
|
|
}
|
|
func (s *Server) policyDeployments(c *gin.Context) {
|
|
items := []policyDeployment{}
|
|
if err := s.DB.SelectContext(c.Request.Context(), &items, deploymentSQL+" ORDER BY d.created_at DESC,d.id"); err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
list(c, items)
|
|
}
|
|
func (s *Server) policyStates(c *gin.Context) {
|
|
items := []policyResult{}
|
|
if err := s.DB.SelectContext(c.Request.Context(), &items, resultSQL+" ORDER BY t.name,t.id"); err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
list(c, items)
|
|
}
|
|
func (s *Server) policyResults(c *gin.Context) {
|
|
var exists bool
|
|
if err := s.DB.GetContext(c.Request.Context(), &exists, "SELECT EXISTS(SELECT 1 FROM terminal_policy_deployments WHERE id=$1)", c.Param("id")); err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
if !exists {
|
|
s.fail(c, 404, "not_found", "下发记录不存在")
|
|
return
|
|
}
|
|
// Historical results retain their own version and status after a newer dispatch.
|
|
items := []policyResult{}
|
|
query := `SELECT r.id,t.id terminal_id,t.name terminal_name,t.group_name,COALESCE(c.protocol,0) protocol,
|
|
r.deployment_id,p.name template_name,p.version template_version,r.config_version,r.status,r.error_code,r.acknowledged_at,
|
|
COALESCE(t.last_seen>now()-interval '90 seconds',false) online,t.enabled,(t.token_hash IS NOT NULL) registered
|
|
FROM terminal_policy_deliveries r JOIN terminals t ON t.id=r.terminal_id
|
|
LEFT JOIN terminal_policy_capabilities c ON c.terminal_id=t.id
|
|
JOIN terminal_policy_deployments d ON d.id=r.deployment_id JOIN terminal_policy_templates p ON p.id=d.template_id
|
|
WHERE r.deployment_id=$1 ORDER BY t.name,t.id`
|
|
if err := s.DB.SelectContext(c.Request.Context(), &items, query, c.Param("id")); err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
list(c, items)
|
|
}
|
|
func (s *Server) deployPolicy(c *gin.Context) {
|
|
var in struct {
|
|
TemplateID string `json:"templateId" binding:"required,max=100"`
|
|
TargetMode string `json:"targetMode" binding:"required,oneof=devices group"`
|
|
TerminalIDs []string `json:"terminalIds" binding:"max=200,dive,required,max=100"`
|
|
Group string `json:"group" binding:"max=80"`
|
|
}
|
|
if !s.decode(c, &in) {
|
|
return
|
|
}
|
|
in.Group = strings.TrimSpace(in.Group)
|
|
in.TerminalIDs = uniqueIDs(in.TerminalIDs)
|
|
sort.Strings(in.TerminalIDs)
|
|
if (in.TargetMode == "devices" && (len(in.TerminalIDs) == 0 || in.Group != "")) || (in.TargetMode == "group" && (in.Group == "" || len(in.TerminalIDs) > 0)) {
|
|
s.fail(c, 400, "invalid_request", "请选择设备或一个非空分组")
|
|
return
|
|
}
|
|
key := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
|
|
if key == "" || len(key) > 100 {
|
|
s.fail(c, 400, "invalid_request", "下发需要有效的请求标识")
|
|
return
|
|
}
|
|
raw, _ := json.Marshal(in)
|
|
fingerprint := store.Hash(string(raw))
|
|
ctx := c.Request.Context()
|
|
actor := s.admin(c).Username
|
|
tx, err := s.DB.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err = tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1,765110))", actor+":"+key); err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
var old struct {
|
|
ID string `db:"id"`
|
|
Hash string `db:"request_hash"`
|
|
}
|
|
err = tx.GetContext(ctx, &old, "SELECT id,request_hash FROM terminal_policy_deployments WHERE actor=$1 AND idempotency_key=$2", actor, key)
|
|
if err == nil {
|
|
if old.Hash != fingerprint {
|
|
s.fail(c, 409, "idempotency_conflict", "请求标识已用于其他下发参数")
|
|
return
|
|
}
|
|
var out policyDeployment
|
|
if err = tx.GetContext(ctx, &out, deploymentSQL+" WHERE d.id=$1", old.ID); err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
c.JSON(202, out)
|
|
return
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
var template terminalPolicy
|
|
if err = tx.GetContext(ctx, &template, "SELECT * FROM terminal_policy_templates WHERE id=$1", in.TemplateID); err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
var targets []struct {
|
|
ID string `db:"id"`
|
|
Enabled bool `db:"enabled"`
|
|
Registered bool `db:"registered"`
|
|
Protocol int `db:"protocol"`
|
|
}
|
|
query := `SELECT t.id,t.enabled,(t.token_hash IS NOT NULL) registered,COALESCE(c.protocol,0) protocol FROM terminals t
|
|
LEFT JOIN terminal_policy_capabilities c ON c.terminal_id=t.id WHERE `
|
|
var arg any = in.TerminalIDs
|
|
if in.TargetMode == "group" {
|
|
query += "t.group_name=$1"
|
|
arg = in.Group
|
|
} else {
|
|
query += "t.id=ANY($1)"
|
|
}
|
|
if err = tx.SelectContext(ctx, &targets, query+" ORDER BY t.id FOR UPDATE OF t", arg); err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
if len(targets) == 0 || len(targets) > 200 || (in.TargetMode == "devices" && len(targets) != len(in.TerminalIDs)) {
|
|
s.fail(c, 400, "invalid_targets", "目标为空、设备不存在或超过 200 台,请缩小范围")
|
|
return
|
|
}
|
|
for _, t := range targets {
|
|
if !t.Enabled || !t.Registered || t.Protocol != 1 {
|
|
s.fail(c, 409, "policy_unsupported", "所选设备包含已停用、未激活或不支持策略的终端,请检查设备结果")
|
|
return
|
|
}
|
|
}
|
|
id := store.ID("dispatch_")
|
|
if _, err = tx.ExecContext(ctx, "INSERT INTO terminal_policy_deployments(id,template_id,actor,idempotency_key,request_hash) VALUES($1,$2,$3,$4,$5)", id, in.TemplateID, actor, key, fingerprint); err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
for _, t := range targets {
|
|
var version int
|
|
if err = tx.GetContext(ctx, &version, "UPDATE terminals SET config_version=config_version+1 WHERE id=$1 RETURNING config_version", t.ID); err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
if _, err = tx.ExecContext(ctx, "UPDATE terminal_policy_deliveries SET status='superseded' WHERE terminal_id=$1 AND status IN ('pending','failed')", t.ID); err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
if _, err = tx.ExecContext(ctx, "INSERT INTO terminal_policy_deliveries(id,deployment_id,terminal_id,config_version) VALUES($1,$2,$3,$4)", store.ID("delivery_"), id, t.ID, version); err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
}
|
|
var out policyDeployment
|
|
if err = tx.GetContext(ctx, &out, deploymentSQL+" WHERE d.id=$1", id); err == nil {
|
|
err = accountAudit(ctx, tx, actor, "deploy_terminal_policy", id, c.GetString("requestId"))
|
|
}
|
|
if err == nil {
|
|
err = tx.Commit()
|
|
}
|
|
if err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
c.JSON(202, out)
|
|
}
|
|
|
|
func (s *Server) policyAck(c *gin.Context) {
|
|
var in struct {
|
|
DeploymentID string `json:"deploymentId" binding:"required,max=100"`
|
|
ConfigVersion int `json:"configVersion" binding:"min=2"`
|
|
Status string `json:"status" binding:"required,oneof=applied failed"`
|
|
ErrorCode string `json:"errorCode" binding:"omitempty,oneof=invalid_policy persist_failed runtime_unavailable"`
|
|
}
|
|
if !s.decode(c, &in) {
|
|
return
|
|
}
|
|
if (in.Status == "applied" && in.ErrorCode != "") || (in.Status == "failed" && in.ErrorCode == "") {
|
|
s.fail(c, 400, "invalid_request", "请提供匹配应用结果的错误代码")
|
|
return
|
|
}
|
|
tx := deviceTx(c)
|
|
ctx := c.Request.Context()
|
|
var row struct {
|
|
ID string `db:"id"`
|
|
Status string `db:"status"`
|
|
ErrorCode string `db:"error_code"`
|
|
}
|
|
err := tx.GetContext(ctx, &row, `SELECT r.id,r.status,r.error_code FROM terminal_policy_deliveries r JOIN terminals t ON t.id=r.terminal_id
|
|
WHERE r.terminal_id=$1 AND r.deployment_id=$2 AND r.config_version=$3 AND t.config_version=r.config_version`, c.GetString("terminalId"), in.DeploymentID, in.ConfigVersion)
|
|
if err == sql.ErrNoRows {
|
|
s.fail(c, 409, "stale_policy", "该策略已被更新,请重新拉取配置")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
// A successful application cannot be downgraded by a delayed failed response.
|
|
if row.Status == "applied" || (row.Status == in.Status && row.ErrorCode == in.ErrorCode) {
|
|
s.deviceJSON(c, 200, gin.H{"accepted": true})
|
|
return
|
|
}
|
|
if _, err = tx.ExecContext(ctx, "UPDATE terminal_policy_deliveries SET status=$1,error_code=$2,acknowledged_at=now() WHERE id=$3", in.Status, in.ErrorCode, row.ID); err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
if err = accountAudit(ctx, tx, "device", "terminal_policy_"+in.Status, row.ID, c.GetString("requestId")); err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
s.deviceJSON(c, 200, gin.H{"accepted": true})
|
|
}
|