功能:新增云管API与Web控制台并完成现场接入
This commit is contained in:
1 parent
18a9ce995d
commit
3fb7b4da8c
79 files changed
+17367
-3
No files matched your search
@@ -0,0 +1,39 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Mode, Address, DatabaseURL, JWTSecret, AdminUsername, AdminPassword string
|
||||
PVEURL, PVETokenID, PVETokenSecret, PVECAFile string
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
c := Config{Mode: env("VDI_MODE", "production"), Address: env("VDI_LISTEN", "127.0.0.1:8080"), DatabaseURL: os.Getenv("DATABASE_URL"), JWTSecret: os.Getenv("VDI_JWT_SECRET"), AdminUsername: os.Getenv("VDI_ADMIN_USERNAME"), AdminPassword: os.Getenv("VDI_ADMIN_PASSWORD"), PVEURL: os.Getenv("PVE_URL"), PVETokenID: os.Getenv("PVE_TOKEN_ID"), PVETokenSecret: os.Getenv("PVE_TOKEN_SECRET"), PVECAFile: os.Getenv("PVE_CA_FILE")}
|
||||
if c.Mode != "demo" && c.Mode != "production" {
|
||||
return c, fmt.Errorf("VDI_MODE must be demo or production")
|
||||
}
|
||||
if c.DatabaseURL == "" {
|
||||
return c, fmt.Errorf("DATABASE_URL is required")
|
||||
}
|
||||
if len(c.JWTSecret) < 32 {
|
||||
return c, fmt.Errorf("VDI_JWT_SECRET must contain at least 32 characters")
|
||||
}
|
||||
if c.Mode == "production" && strings.TrimSpace(c.AdminUsername) == "" {
|
||||
return c, fmt.Errorf("production requires VDI_ADMIN_USERNAME")
|
||||
}
|
||||
if c.PVEURL != "" && (c.PVETokenID == "" || c.PVETokenSecret == "") {
|
||||
return c, fmt.Errorf("PVE_TOKEN_ID and PVE_TOKEN_SECRET are required with PVE_URL")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Administrator struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Username string `json:"username" db:"username"`
|
||||
DisplayName string `json:"displayName" db:"display_name"`
|
||||
Role string `json:"role" db:"role"`
|
||||
PasswordHash string `json:"-" db:"password_hash"`
|
||||
}
|
||||
type Desktop struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
OS string `json:"os" db:"os"`
|
||||
Node string `json:"node" db:"node"`
|
||||
VMID int `json:"vmid" db:"vmid"`
|
||||
Pool string `json:"pool" db:"pool"`
|
||||
PowerState string `json:"powerState" db:"power_state"`
|
||||
ServiceState string `json:"serviceState" db:"service_state"`
|
||||
Provider string `json:"provider" db:"provider"`
|
||||
Endpoint string `json:"endpoint" db:"endpoint"`
|
||||
App string `json:"app" db:"app"`
|
||||
Width int `json:"width" db:"width"`
|
||||
Height int `json:"height" db:"height"`
|
||||
FPS int `json:"fps" db:"fps"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
type Terminal struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
SN string `json:"sn" db:"sn"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Group string `json:"group" db:"group_name"`
|
||||
Status string `json:"status" db:"status"`
|
||||
AgentVersion string `json:"agentVersion" db:"agent_version"`
|
||||
LastSeen *time.Time `json:"lastSeen" db:"last_seen"`
|
||||
SessionDesktopID *string `json:"sessionDesktopId" db:"session_desktop_id"`
|
||||
SessionState string `json:"sessionState" db:"session_state"`
|
||||
ConfigVersion int `json:"configVersion" db:"config_version"`
|
||||
FPS *float64 `json:"fps" db:"fps"`
|
||||
RTT *float64 `json:"rtt" db:"rtt"`
|
||||
}
|
||||
type User struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Username string `json:"username" db:"username"`
|
||||
DisplayName string `json:"displayName" db:"display_name"`
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
TerminalID *string `json:"terminalId" db:"terminal_id"`
|
||||
DesktopIDs []string `json:"desktopIds"`
|
||||
}
|
||||
type Resource struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Status string `json:"status" db:"status"`
|
||||
CPU float64 `json:"cpu" db:"cpu"`
|
||||
Memory float64 `json:"memory" db:"memory"`
|
||||
Storage float64 `json:"storage" db:"storage"`
|
||||
Provider string `json:"provider" db:"provider"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
type Task struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Kind string `json:"kind" db:"kind"`
|
||||
Action string `json:"action" db:"action"`
|
||||
TargetID string `json:"targetId" db:"target_id"`
|
||||
TargetName string `json:"targetName" db:"target_name"`
|
||||
Actor string `json:"actor" db:"actor"`
|
||||
Status string `json:"status" db:"status"`
|
||||
Message string `json:"message" db:"message"`
|
||||
ProviderRef string `json:"providerRef" db:"provider_ref"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
IdempotencyKey string `json:"-" db:"idempotency_key"`
|
||||
}
|
||||
type Audit struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Actor string `json:"actor" db:"actor"`
|
||||
Action string `json:"action" db:"action"`
|
||||
Target string `json:"target" db:"target"`
|
||||
Result string `json:"result" db:"result"`
|
||||
RequestID string `json:"requestId" db:"request_id"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
}
|
||||
|
||||
type DeviceDiagnostics struct {
|
||||
AgentVersion string `json:"agentVersion" binding:"required,max=80"`
|
||||
BootID string `json:"bootId" binding:"required,max=80"`
|
||||
UptimeSeconds float64 `json:"uptimeSeconds" binding:"min=0"`
|
||||
KioskState string `json:"kioskState" binding:"required,max=80"`
|
||||
LogTail string `json:"logTail" binding:"max=16384"`
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
"vdi-cloud/platform/internal/model"
|
||||
"vdi-cloud/platform/internal/store"
|
||||
"vdi-cloud/platform/pkg/proxmox"
|
||||
)
|
||||
|
||||
func (s *Server) overview(c *gin.Context) {
|
||||
var counts struct {
|
||||
Terminals int `db:"terminals"`
|
||||
Online int `db:"online"`
|
||||
Desktops int `db:"desktops"`
|
||||
Running int `db:"running"`
|
||||
Sessions int `db:"sessions"`
|
||||
Failed int `db:"failed"`
|
||||
}
|
||||
err := s.DB.GetContext(c.Request.Context(), &counts, `SELECT (SELECT count(*) FROM terminals) terminals,(SELECT count(*) FROM terminals WHERE last_seen>now()-interval '90 seconds') online,(SELECT count(*) FROM desktops) desktops,(SELECT count(*) FROM desktops WHERE power_state='running') running,(SELECT count(*) FROM terminals WHERE session_state='connected' AND session_desktop_id IS NOT NULL AND last_seen>now()-interval '90 seconds') sessions,(SELECT count(*) FROM tasks WHERE status IN ('failed','timeout') AND created_at>now()-interval '24 hours') failed`)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"terminalCount": counts.Terminals, "onlineTerminals": counts.Online, "desktopCount": counts.Desktops, "runningDesktops": counts.Running, "activeSessions": counts.Sessions, "failedTasks": counts.Failed, "mode": s.Config.Mode})
|
||||
}
|
||||
func (s *Server) settings(c *gin.Context) {
|
||||
provider := "unconfigured"
|
||||
if s.Config.Mode == "demo" {
|
||||
provider = "demo"
|
||||
}
|
||||
if s.PVE != nil {
|
||||
provider = "proxmox"
|
||||
}
|
||||
c.JSON(200, gin.H{"mode": s.Config.Mode, "computeProvider": provider, "pveConfigured": s.PVE != nil, "terminalTransport": "https-poll", "capabilities": []string{"auth", "resources", "desktops", "terminals", "bindings", "tasks", "audit"}})
|
||||
}
|
||||
func (s *Server) resources(c *gin.Context) {
|
||||
items := []model.Resource{}
|
||||
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM resources ORDER BY name"); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
list(c, items)
|
||||
}
|
||||
func (s *Server) desktops(c *gin.Context) {
|
||||
items := []model.Desktop{}
|
||||
q := "%" + c.Query("keyword") + "%"
|
||||
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT * FROM desktops WHERE (name ILIKE $1 OR vmid::text ILIKE $1) AND ($2='' OR power_state=$2) ORDER BY name`, q, c.Query("status"))
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
list(c, items)
|
||||
}
|
||||
func (s *Server) desktop(c *gin.Context) {
|
||||
var d model.Desktop
|
||||
if err := s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", c.Param("id")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, d)
|
||||
}
|
||||
func (s *Server) terminals(c *gin.Context) {
|
||||
items := []model.Terminal{}
|
||||
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT id,sn,name,group_name,CASE WHEN last_seen>now()-interval '90 seconds' THEN 'online' WHEN status='unregistered' THEN 'unregistered' ELSE 'offline' END status,agent_version,last_seen,CASE WHEN last_seen>now()-interval '90 seconds' THEN session_desktop_id ELSE NULL END session_desktop_id,session_state,config_version,fps,rtt FROM terminals WHERE (name ILIKE $1 OR sn ILIKE $1) ORDER BY name`, "%"+c.Query("keyword")+"%")
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
list(c, items)
|
||||
}
|
||||
func (s *Server) users(c *gin.Context) {
|
||||
items, err := store.Users(c.Request.Context(), s.DB)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
list(c, items)
|
||||
}
|
||||
func (s *Server) createUser(c *gin.Context) {
|
||||
var in struct {
|
||||
Username string `json:"username" binding:"required,min=2,max=80"`
|
||||
DisplayName string `json:"displayName" binding:"required,max=80"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
in.Username = strings.TrimSpace(in.Username)
|
||||
in.DisplayName = strings.TrimSpace(in.DisplayName)
|
||||
if in.Username == "" || in.DisplayName == "" {
|
||||
s.fail(c, 400, "invalid_request", "账号和姓名不能为空")
|
||||
return
|
||||
}
|
||||
id := store.ID("user_")
|
||||
_, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO users(id,username,display_name) VALUES($1,$2,$3)", id, in.Username, in.DisplayName)
|
||||
if err != nil {
|
||||
if conflict(err) {
|
||||
s.fail(c, 409, "conflict", "该账号已存在")
|
||||
} else {
|
||||
s.dbError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
s.log(c, "create_user", id, "success")
|
||||
c.JSON(201, model.User{ID: id, Username: in.Username, DisplayName: in.DisplayName, Enabled: true, DesktopIDs: []string{}})
|
||||
}
|
||||
func conflict(err error) bool {
|
||||
var e *pgconn.PgError
|
||||
return errors.As(err, &e) && (e.Code == "23505" || e.Code == "23503")
|
||||
}
|
||||
func (s *Server) bindings(c *gin.Context) {
|
||||
var in struct {
|
||||
TerminalID *string `json:"terminalId"`
|
||||
DesktopIDs []string `json:"desktopIds" binding:"max=100"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
if in.TerminalID != nil && *in.TerminalID == "" {
|
||||
in.TerminalID = nil
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var user model.User
|
||||
if err = tx.GetContext(ctx, &user, "SELECT * FROM users WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
user.Enabled = *in.Enabled
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "UPDATE users SET terminal_id=$1,enabled=$2 WHERE id=$3", in.TerminalID, user.Enabled, user.ID)
|
||||
if err != nil {
|
||||
if conflict(err) {
|
||||
s.fail(c, 409, "binding_conflict", "终端不存在或已关联其他用户")
|
||||
} else {
|
||||
s.dbError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "DELETE FROM bindings WHERE user_id=$1", user.ID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, id := range in.DesktopIDs {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
if _, err = tx.ExecContext(ctx, "INSERT INTO bindings VALUES($1,$2)", user.ID, id); err != nil {
|
||||
if conflict(err) {
|
||||
s.fail(c, 400, "invalid_desktop", "所选桌面不存在")
|
||||
} else {
|
||||
s.dbError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,'update_bindings',$3,'success',$4,now())", store.ID("audit_"), s.admin(c).Username, user.ID, c.GetString("requestId"))
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
user.TerminalID = in.TerminalID
|
||||
user.DesktopIDs = in.DesktopIDs
|
||||
if user.DesktopIDs == nil {
|
||||
user.DesktopIDs = []string{}
|
||||
}
|
||||
c.JSON(200, user)
|
||||
}
|
||||
func (s *Server) tasks(c *gin.Context) {
|
||||
items := []model.Task{}
|
||||
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM tasks ORDER BY created_at DESC LIMIT 1000"); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
list(c, items)
|
||||
}
|
||||
func (s *Server) task(c *gin.Context) {
|
||||
var t model.Task
|
||||
if err := s.DB.GetContext(c.Request.Context(), &t, "SELECT * FROM tasks WHERE id=$1", c.Param("id")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, t)
|
||||
}
|
||||
func (s *Server) audits(c *gin.Context) {
|
||||
items := []model.Audit{}
|
||||
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 1000"); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
list(c, items)
|
||||
}
|
||||
func (s *Server) discoverVMs(c *gin.Context) {
|
||||
if s.PVE == nil {
|
||||
s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox 接入,演示环境不发现真实 VM")
|
||||
return
|
||||
}
|
||||
items, err := s.PVE.Resources(c.Request.Context())
|
||||
if err != nil {
|
||||
s.fail(c, 502, "pve_unavailable", "Proxmox 查询失败,请检查连接、证书和访问权限")
|
||||
return
|
||||
}
|
||||
vms := []gin.H{}
|
||||
for _, r := range items {
|
||||
if r.Type == "qemu" && r.Template == 0 {
|
||||
vms = append(vms, gin.H{"vmid": r.VMID, "node": r.Node, "name": r.Name, "status": r.Status})
|
||||
}
|
||||
}
|
||||
list(c, vms)
|
||||
}
|
||||
func (s *Server) syncResources(c *gin.Context) {
|
||||
if s.PVE == nil {
|
||||
s.fail(c, 503, "pve_unconfigured", "请通过服务端环境变量配置 Proxmox 接入")
|
||||
return
|
||||
}
|
||||
s.submitTask(c, "resource", "sync", "cluster", "Proxmox 集群")
|
||||
}
|
||||
func (s *Server) importDesktop(c *gin.Context) {
|
||||
var in struct {
|
||||
Name string `json:"name" binding:"required,max=80"`
|
||||
OS string `json:"os" binding:"required,oneof=Windows Ubuntu 麒麟"`
|
||||
Node string `json:"node" binding:"required,max=100"`
|
||||
VMID int `json:"vmid" binding:"min=100"`
|
||||
Endpoint string `json:"endpoint" binding:"max=253"`
|
||||
Width int `json:"width" binding:"omitempty,min=640,max=7680"`
|
||||
Height int `json:"height" binding:"omitempty,min=480,max=4320"`
|
||||
FPS int `json:"fps" binding:"omitempty,min=10,max=120"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
if s.PVE == nil {
|
||||
s.fail(c, 503, "pve_unconfigured", "真实 VM 纳管需要先配置 Proxmox")
|
||||
return
|
||||
}
|
||||
if in.Endpoint != "" && net.ParseIP(in.Endpoint) == nil {
|
||||
for _, part := range strings.Split(in.Endpoint, ".") {
|
||||
if part == "" || strings.ContainsAny(part, "/:@ \\") {
|
||||
s.fail(c, 400, "invalid_endpoint", "连接地址应为 IP 或主机名")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
power, err := s.PVE.Power(c.Request.Context(), in.Node, in.VMID)
|
||||
if err != nil {
|
||||
s.fail(c, 502, "pve_unavailable", "无法验证所选 VM,请检查节点和 VMID")
|
||||
return
|
||||
}
|
||||
id := store.ID("desk_")
|
||||
if in.Width == 0 {
|
||||
in.Width = 2560
|
||||
}
|
||||
if in.Height == 0 {
|
||||
in.Height = 1440
|
||||
}
|
||||
if in.FPS == 0 {
|
||||
in.FPS = 30
|
||||
}
|
||||
_, err = s.DB.ExecContext(c.Request.Context(), `INSERT INTO desktops(id,name,os,node,vmid,power_state,provider,endpoint,width,height,fps) VALUES($1,$2,$3,$4,$5,$6,'proxmox',$7,$8,$9,$10)`, id, in.Name, in.OS, in.Node, in.VMID, power, in.Endpoint, in.Width, in.Height, in.FPS)
|
||||
if err != nil {
|
||||
if conflict(err) {
|
||||
s.fail(c, 409, "already_managed", "该 VM 已纳管")
|
||||
} else {
|
||||
s.dbError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
s.log(c, "import_desktop", id, "success")
|
||||
c.Params = append(c.Params, gin.Param{Key: "id", Value: id})
|
||||
s.desktop(c)
|
||||
}
|
||||
func (s *Server) desktopAction(c *gin.Context) {
|
||||
var in struct {
|
||||
Action string `json:"action" binding:"required,oneof=start shutdown reboot"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
var d model.Desktop
|
||||
if err := s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", c.Param("id")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if d.Provider == "demo" && s.Config.Mode != "demo" {
|
||||
s.fail(c, 409, "demo_disabled", "正式模式不执行演示桌面任务")
|
||||
return
|
||||
}
|
||||
if d.Provider == "proxmox" && s.PVE == nil {
|
||||
s.fail(c, 503, "pve_unconfigured", "Proxmox 尚未配置")
|
||||
return
|
||||
}
|
||||
s.submitTask(c, "desktop", in.Action, d.ID, d.Name)
|
||||
}
|
||||
func (s *Server) terminalCommand(c *gin.Context) {
|
||||
var in struct {
|
||||
Action string `json:"action" binding:"required,oneof=reboot reset-session collect-logs"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
var name string
|
||||
if err := s.DB.GetContext(c.Request.Context(), &name, "SELECT name FROM terminals WHERE id=$1", c.Param("id")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
s.submitTask(c, "terminal", in.Action, c.Param("id"), name)
|
||||
}
|
||||
func (s *Server) submitTask(c *gin.Context, kind, action, targetID, targetName string) {
|
||||
key := c.GetHeader("Idempotency-Key")
|
||||
if key == "" || len(key) > 100 {
|
||||
s.fail(c, 400, "idempotency_required", "请提供不超过 100 字符的 Idempotency-Key")
|
||||
return
|
||||
}
|
||||
actor := s.admin(c).Username
|
||||
ctx := c.Request.Context()
|
||||
var existing model.Task
|
||||
err := s.DB.GetContext(ctx, &existing, "SELECT * FROM tasks WHERE actor=$1 AND idempotency_key=$2", actor, key)
|
||||
if err == nil {
|
||||
if existing.Kind != kind || existing.Action != action || existing.TargetID != targetID {
|
||||
s.fail(c, 409, "idempotency_conflict", "相同请求标识不能用于不同操作")
|
||||
return
|
||||
}
|
||||
c.JSON(202, existing)
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var task model.Task
|
||||
err = tx.GetContext(ctx, &task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,$2,$3,$4,$5,$6,'queued','已提交,等待执行回执',$7) RETURNING *`, store.ID("task_"), kind, action, targetID, targetName, actor, key)
|
||||
if err != nil {
|
||||
if conflict(err) {
|
||||
s.fail(c, 409, "task_conflict", "已有相同请求或该对象有任务执行中,请刷新任务列表")
|
||||
} else {
|
||||
s.dbError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,$3,$4,'submitted',$5,now())", store.ID("audit_"), actor, action, targetName, c.GetString("requestId"))
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(202, task)
|
||||
}
|
||||
|
||||
// Complete persists the final result and its audit entry together.
|
||||
func (s *Server) complete(ctx context.Context, t model.Task, status, message string) error {
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(ctx, "UPDATE tasks SET status=$1,message=$2,updated_at=now() WHERE id=$3 AND status IN ('running','queued')", status, message, t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
// Session release is scoped to a session ID by the device event endpoint.
|
||||
// A delayed command acknowledgement must not clear a newer connection.
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,$3,$4,$5,$6,now())", store.ID("audit_"), t.Actor, t.Action, t.TargetName, status, t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
func ratio(a, b float64) float64 {
|
||||
if b <= 0 {
|
||||
return 0
|
||||
}
|
||||
return a / b
|
||||
}
|
||||
func (s *Server) sync(ctx context.Context) error {
|
||||
items, err := s.PVE.Resources(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, "DELETE FROM resources WHERE provider='proxmox'"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range items {
|
||||
if r.Type == "node" {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO resources VALUES($1,$1,$2,$3,$4,$5,'proxmox',now()) ON CONFLICT(id) DO UPDATE SET status=$2,cpu=$3,memory=$4,storage=$5,provider='proxmox',updated_at=now()`, r.Node, r.Status, r.CPU, ratio(r.Memory, r.MaxMemory), ratio(r.Disk, r.MaxDisk)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if r.Type == "qemu" {
|
||||
if _, err = tx.ExecContext(ctx, "UPDATE desktops SET power_state=$1,updated_at=now() WHERE provider='proxmox' AND node=$2 AND vmid=$3", r.Status, r.Node, r.VMID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Server) RunWorker(ctx context.Context) {
|
||||
ticker := time.NewTicker(300 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Unknown outcomes are never automatically retried after a worker restart.
|
||||
var expired []model.Task
|
||||
if s.DB.SelectContext(ctx, &expired, "SELECT * FROM tasks WHERE status IN ('queued','running') AND created_at<now()-interval '2 minutes'") == nil {
|
||||
for _, t := range expired {
|
||||
_ = s.complete(ctx, t, "timeout", "结果未确认,请核对设备或 PVE 实际状态;不会自动重放")
|
||||
}
|
||||
}
|
||||
var t model.Task
|
||||
err := s.DB.GetContext(ctx, &t, `UPDATE tasks SET status='running',updated_at=now(),message='正在执行,等待最终回执' WHERE id=(SELECT id FROM tasks WHERE status='queued' AND kind!='terminal' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s.execute(ctx, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resource reads are independent of the command queue and never change VM power.
|
||||
func (s *Server) RunSync(ctx context.Context) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
readCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
if err := s.sync(readCtx); err != nil {
|
||||
log.Printf("PVE resource sync failed: %v", err)
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
func (s *Server) execute(parent context.Context, t model.Task) {
|
||||
ctx, cancel := context.WithTimeout(parent, 90*time.Second)
|
||||
defer cancel()
|
||||
var err error
|
||||
uncertain := false
|
||||
message := "已收到执行结果"
|
||||
if t.Kind == "resource" {
|
||||
err = s.sync(ctx)
|
||||
} else {
|
||||
var d model.Desktop
|
||||
err = s.DB.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1", t.TargetID)
|
||||
if err == nil && d.Provider == "demo" {
|
||||
if s.Config.Mode != "demo" {
|
||||
err = errors.New("demo disabled")
|
||||
} else {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
err = ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
if err == nil {
|
||||
power := "running"
|
||||
if t.Action == "shutdown" {
|
||||
power = "stopped"
|
||||
}
|
||||
_, err = s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, d.ID)
|
||||
message = "演示计算任务已完成;未检测真实连接服务"
|
||||
}
|
||||
}
|
||||
} else if err == nil {
|
||||
if s.PVE == nil {
|
||||
err = errors.New("PVE unconfigured")
|
||||
} else {
|
||||
var upid string
|
||||
upid, err = s.PVE.Action(ctx, d.Node, d.VMID, t.Action)
|
||||
uncertain = true
|
||||
var httpError *proxmox.HTTPError
|
||||
if errors.As(err, &httpError) && httpError.Status >= 400 && httpError.Status < 500 {
|
||||
uncertain = false
|
||||
}
|
||||
if err == nil {
|
||||
_, err = s.DB.ExecContext(ctx, "UPDATE tasks SET provider_ref=$1 WHERE id=$2", upid, t.ID)
|
||||
}
|
||||
if err == nil {
|
||||
err = s.PVE.Wait(ctx, d.Node, upid, time.Second)
|
||||
}
|
||||
if err == nil {
|
||||
var power string
|
||||
power, err = s.PVE.Power(ctx, d.Node, d.VMID)
|
||||
if err == nil {
|
||||
expected := "running"
|
||||
if t.Action == "shutdown" {
|
||||
expected = "stopped"
|
||||
}
|
||||
if power != expected {
|
||||
err = fmt.Errorf("power result not confirmed")
|
||||
} else {
|
||||
_, err = s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, d.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
status := "success"
|
||||
if err != nil {
|
||||
log.Printf("task execution error taskId=%s error=%v", t.ID, err)
|
||||
status = "failed"
|
||||
message = "执行失败,请检查服务日志及 PVE 任务;未自动重试"
|
||||
if (uncertain && !errors.Is(err, proxmox.ErrTaskFailed)) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
status = "timeout"
|
||||
message = "结果未确认,请核对实际状态;未自动重试"
|
||||
}
|
||||
}
|
||||
finalCtx, finalCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer finalCancel()
|
||||
_ = s.complete(finalCtx, t, status, message)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"vdi-cloud/platform/internal/config"
|
||||
"vdi-cloud/platform/internal/model"
|
||||
"vdi-cloud/platform/internal/store"
|
||||
"vdi-cloud/platform/pkg/proxmox"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Config config.Config
|
||||
DB *sqlx.DB
|
||||
PVE *proxmox.Client
|
||||
Router *gin.Engine
|
||||
}
|
||||
type claims struct {
|
||||
AdminID string `json:"adminId"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func New(cfg config.Config, db *sqlx.DB, pve *proxmox.Client) *Server {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
s := &Server{Config: cfg, DB: db, PVE: pve}
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery(), func(c *gin.Context) {
|
||||
c.Set("requestId", store.ID("req_"))
|
||||
c.Header("X-Request-ID", c.GetString("requestId"))
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
|
||||
c.Next()
|
||||
})
|
||||
s.Router = r
|
||||
r.GET("/healthz", func(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if db.PingContext(ctx) != nil {
|
||||
s.fail(c, 503, "dependency_unavailable", "数据库暂不可用")
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"status": "ok", "mode": cfg.Mode})
|
||||
})
|
||||
r.POST("/api/v1/auth/login", s.login)
|
||||
r.POST("/api/v1/terminal/register", s.register)
|
||||
t := r.Group("/api/v1/terminal", s.deviceAuth)
|
||||
t.POST("/heartbeat", s.heartbeat)
|
||||
t.GET("/config", s.deviceConfig)
|
||||
t.GET("/desktops", s.deviceDesktops)
|
||||
t.GET("/commands", s.deviceCommands)
|
||||
t.POST("/commands/:id/ack", s.deviceAck)
|
||||
t.POST("/connect-ticket", s.createTicket)
|
||||
t.POST("/connect-ticket/consume", s.consumeTicket)
|
||||
t.POST("/session", s.deviceSession)
|
||||
a := r.Group("/api/v1", s.adminAuth)
|
||||
a.GET("/auth/me", func(c *gin.Context) { c.JSON(200, c.MustGet("admin")) })
|
||||
a.POST("/auth/logout", s.logout)
|
||||
a.GET("/overview", s.overview)
|
||||
a.GET("/settings", s.settings)
|
||||
a.GET("/resources", s.resources)
|
||||
a.GET("/resources/vms", s.discoverVMs)
|
||||
a.POST("/resources/sync", s.allow("super_admin"), s.syncResources)
|
||||
a.GET("/desktops", s.desktops)
|
||||
a.POST("/desktops/import", s.allow("super_admin"), s.importDesktop)
|
||||
a.GET("/desktops/:id", s.desktop)
|
||||
a.POST("/desktops/:id/actions", s.allow("super_admin", "operator"), s.desktopAction)
|
||||
a.GET("/terminals", s.terminals)
|
||||
a.POST("/terminals", s.allow("super_admin"), s.enroll)
|
||||
a.POST("/terminals/:id/commands", s.allow("super_admin", "operator"), s.terminalCommand)
|
||||
a.GET("/users", s.users)
|
||||
a.POST("/users", s.allow("super_admin"), s.createUser)
|
||||
a.PUT("/users/:id/bindings", s.allow("super_admin"), s.bindings)
|
||||
a.GET("/tasks", s.tasks)
|
||||
a.GET("/tasks/:id", s.task)
|
||||
a.GET("/tasks/:id/diagnostics", s.allow("super_admin", "operator"), s.taskDiagnostics)
|
||||
a.GET("/audit-logs", s.audits)
|
||||
r.NoRoute(func(c *gin.Context) { s.fail(c, 404, "not_found", "接口不存在") })
|
||||
return s
|
||||
}
|
||||
func (s *Server) fail(c *gin.Context, status int, code, message string) {
|
||||
c.AbortWithStatusJSON(status, gin.H{"code": code, "message": message, "requestId": c.GetString("requestId")})
|
||||
}
|
||||
func (s *Server) dbError(c *gin.Context, err error) {
|
||||
log.Printf("database request failed requestId=%s error=%v", c.GetString("requestId"), err)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
s.fail(c, 404, "not_found", "记录不存在")
|
||||
} else {
|
||||
s.fail(c, 500, "database_error", "操作未完成,请根据请求 ID 检查服务日志")
|
||||
}
|
||||
}
|
||||
func (s *Server) decode(c *gin.Context, v any) bool {
|
||||
if err := c.ShouldBindJSON(v); err != nil {
|
||||
s.fail(c, 400, "invalid_request", "请检查必填字段和数据格式")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (s *Server) admin(c *gin.Context) model.Administrator {
|
||||
return c.MustGet("admin").(model.Administrator)
|
||||
}
|
||||
func (s *Server) log(c *gin.Context, action, target, result string) {
|
||||
actor := "device"
|
||||
if a, ok := c.Get("admin"); ok {
|
||||
actor = a.(model.Administrator).Username
|
||||
}
|
||||
_, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,$3,$4,$5,$6)", store.ID("audit_"), actor, action, target, result, c.GetString("requestId"))
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
}
|
||||
}
|
||||
func (s *Server) allow(roles ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
for _, role := range roles {
|
||||
if role == s.admin(c).Role {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
s.log(c, c.Request.Method+" "+c.FullPath(), c.Param("id"), "denied")
|
||||
s.fail(c, 403, "forbidden", "当前账号没有该操作权限")
|
||||
}
|
||||
}
|
||||
func (s *Server) adminAuth(c *gin.Context) {
|
||||
raw := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
cl := &claims{}
|
||||
token, err := jwt.ParseWithClaims(raw, cl, func(t *jwt.Token) (any, error) { return []byte(s.Config.JWTSecret), nil }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithIssuer("vdi-platform"), jwt.WithAudience("vdi-console"), jwt.WithExpirationRequired())
|
||||
if err != nil || !token.Valid {
|
||||
s.fail(c, 401, "unauthorized", "登录已失效,请重新登录")
|
||||
return
|
||||
}
|
||||
var a model.Administrator
|
||||
err = s.DB.GetContext(c.Request.Context(), &a, `SELECT a.* FROM administrators a JOIN admin_sessions x ON x.admin_id=a.id WHERE x.id=$1 AND a.id=$2 AND x.expires_at>now()`, cl.ID, cl.AdminID)
|
||||
if err != nil {
|
||||
s.fail(c, 401, "unauthorized", "登录已失效,请重新登录")
|
||||
return
|
||||
}
|
||||
c.Set("admin", a)
|
||||
c.Set("sessionId", cl.ID)
|
||||
c.Next()
|
||||
}
|
||||
func (s *Server) login(c *gin.Context) {
|
||||
var input struct {
|
||||
Username string `json:"username" binding:"required,max=80"`
|
||||
Password string `json:"password" binding:"required,max=200"`
|
||||
}
|
||||
if !s.decode(c, &input) {
|
||||
return
|
||||
}
|
||||
var a model.Administrator
|
||||
err := s.DB.GetContext(c.Request.Context(), &a, "SELECT * FROM administrators WHERE username=$1", input.Username)
|
||||
if err != nil || bcrypt.CompareHashAndPassword([]byte(a.PasswordHash), []byte(input.Password)) != nil {
|
||||
s.fail(c, 401, "invalid_credentials", "用户名或密码错误")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
id := store.ID("sess_")
|
||||
expires := now.Add(time.Hour)
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{AdminID: a.ID, RegisteredClaims: jwt.RegisteredClaims{Issuer: "vdi-platform", Audience: jwt.ClaimStrings{"vdi-console"}, ID: id, Subject: a.ID, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(expires)}}).SignedString([]byte(s.Config.JWTSecret))
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if _, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO admin_sessions VALUES($1,$2,$3)", id, a.ID, expires); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.Set("admin", a)
|
||||
s.log(c, "login", a.Username, "success")
|
||||
c.JSON(200, gin.H{"accessToken": token, "expiresIn": 3600, "user": a})
|
||||
}
|
||||
func (s *Server) logout(c *gin.Context) {
|
||||
if _, err := s.DB.ExecContext(c.Request.Context(), "DELETE FROM admin_sessions WHERE id=$1", c.GetString("sessionId")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
s.log(c, "logout", s.admin(c).Username, "success")
|
||||
c.Status(204)
|
||||
}
|
||||
func list[T any](c *gin.Context, items []T) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
total := len(items)
|
||||
start := (page - 1) * size
|
||||
if start < 0 || start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + size
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
if items == nil {
|
||||
items = []T{}
|
||||
}
|
||||
c.JSON(200, gin.H{"items": items[start:end], "page": page, "pageSize": size, "total": total, "requestId": c.GetString("requestId")})
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"vdi-cloud/platform/internal/config"
|
||||
"vdi-cloud/platform/internal/model"
|
||||
"vdi-cloud/platform/internal/store"
|
||||
"vdi-cloud/platform/pkg/proxmox"
|
||||
)
|
||||
|
||||
type fixture struct {
|
||||
server *Server
|
||||
db *sqlx.DB
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
url := os.Getenv("VDI_TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("set VDI_TEST_DATABASE_URL to a dedicated empty test database")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := store.Open(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = db.Exec(`TRUNCATE connection_tickets,audit_logs,tasks,bindings,users,terminals,desktops,resources,admin_sessions,administrators CASCADE`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.Bootstrap(ctx, db, "demo", "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := New(config.Config{Mode: "demo", JWTSecret: strings.Repeat("test-", 10)}, db, nil)
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
go s.RunWorker(ctx)
|
||||
t.Cleanup(func() { cancel(); time.Sleep(30 * time.Millisecond); db.Close() })
|
||||
return &fixture{s, db, t}
|
||||
}
|
||||
func (f *fixture) request(method, path, token string, body any, key string) (int, map[string]any) {
|
||||
f.t.Helper()
|
||||
var encoded []byte
|
||||
if body != nil {
|
||||
encoded, _ = json.Marshal(body)
|
||||
}
|
||||
r := httptest.NewRequest(method, "/api/v1"+path, bytes.NewReader(encoded))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if key != "" {
|
||||
r.Header.Set("Idempotency-Key", key)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
f.server.Router.ServeHTTP(w, r)
|
||||
out := map[string]any{}
|
||||
if w.Body.Len() > 0 {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return w.Code, out
|
||||
}
|
||||
func (f *fixture) login(user string) string {
|
||||
f.t.Helper()
|
||||
status, out := f.request("POST", "/auth/login", "", map[string]any{"username": user, "password": "Demo@2026"}, "")
|
||||
if status != 200 {
|
||||
f.t.Fatalf("login failed %d %v", status, out)
|
||||
}
|
||||
return out["accessToken"].(string)
|
||||
}
|
||||
func expect(t *testing.T, want, got int, out any) {
|
||||
t.Helper()
|
||||
if want != got {
|
||||
t.Fatalf("want status %d got %d: %v", want, got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACLogoutAndDurableTask(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
viewer := f.login("viewer")
|
||||
operator := f.login("operator")
|
||||
admin := f.login("admin")
|
||||
status, out := f.request("GET", "/desktops", viewer, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", viewer, map[string]any{"action": "start"}, "viewer-1")
|
||||
expect(t, 403, status, out)
|
||||
status, out = f.request("PUT", "/users/user_demo_wang/bindings", operator, map[string]any{"desktopIds": []string{}}, "")
|
||||
expect(t, 403, status, out)
|
||||
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", operator, map[string]any{"action": "start"}, "start-1")
|
||||
expect(t, 202, status, out)
|
||||
id := out["id"].(string)
|
||||
status, again := f.request("POST", "/desktops/desk_demo_kylin/actions", operator, map[string]any{"action": "start"}, "start-1")
|
||||
expect(t, 202, status, again)
|
||||
if again["id"] != id {
|
||||
t.Fatal("duplicate task created")
|
||||
}
|
||||
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", operator, map[string]any{"action": "shutdown"}, "start-1")
|
||||
expect(t, 409, status, out)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
status, out = f.request("GET", "/tasks/"+id, admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["status"] == "success" {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("task never completed", out)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
var power, service string
|
||||
if err := f.db.QueryRow("SELECT power_state,service_state FROM desktops WHERE id='desk_demo_kylin'").Scan(&power, &service); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if power != "running" || service != "unknown" {
|
||||
t.Fatal("power and service state confused")
|
||||
}
|
||||
status, out = f.request("POST", "/auth/logout", admin, nil, "")
|
||||
expect(t, 204, status, out)
|
||||
status, out = f.request("GET", "/auth/me", admin, nil, "")
|
||||
expect(t, 401, status, out)
|
||||
var count int
|
||||
f.db.Get(&count, "SELECT count(*) FROM tasks WHERE id=$1", id)
|
||||
if count != 1 {
|
||||
t.Fatal("task not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceIdentityBindingTicketAndAcknowledgement(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
admin := f.login("admin")
|
||||
enroll := func(sn string) (string, string) {
|
||||
status, out := f.request("POST", "/terminals", admin, map[string]any{"sn": sn, "name": sn, "group": "测试"}, "")
|
||||
expect(t, 201, status, out)
|
||||
secret := out["activationSecret"].(string)
|
||||
id := out["id"].(string)
|
||||
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": sn, "activationSecret": secret, "agentVersion": "test"}, "")
|
||||
expect(t, 200, status, out)
|
||||
token := out["accessToken"].(string)
|
||||
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": sn, "activationSecret": secret, "agentVersion": "test"}, "")
|
||||
expect(t, 401, status, out)
|
||||
return id, token
|
||||
}
|
||||
id, device := enroll("TEST-DEVICE-001")
|
||||
otherID, other := enroll("TEST-DEVICE-002")
|
||||
status, out := f.request("GET", "/users", device, nil, "")
|
||||
expect(t, 401, status, out)
|
||||
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
|
||||
expect(t, 403, status, out)
|
||||
status, out = f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": id, "desktopIds": []string{"desk_demo_windows", "desk_demo_ubuntu"}, "enabled": true}, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("GET", "/terminal/desktops", device, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if len(out["items"].([]any)) != 2 {
|
||||
t.Fatal("missing allowed desktops")
|
||||
}
|
||||
f.db.Exec("UPDATE desktops SET endpoint='192.0.2.100' WHERE id='desk_demo_windows'")
|
||||
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
|
||||
expect(t, 201, status, out)
|
||||
ticket := out["ticket"].(string)
|
||||
status, out = f.request("POST", "/terminal/connect-ticket/consume", other, map[string]any{"ticket": ticket}, "")
|
||||
expect(t, 401, status, out)
|
||||
status, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": ticket}, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["credentialMode"] != "existing-pairing" {
|
||||
t.Fatal("must declare existing Sunshine pairing")
|
||||
}
|
||||
sessionID := out["sessionId"].(string)
|
||||
status, out = f.request("GET", "/overview", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["activeSessions"] != float64(0) {
|
||||
t.Fatal("reservation counted as active stream")
|
||||
}
|
||||
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": sessionID, "state": "connected"}, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("GET", "/overview", admin, nil, "")
|
||||
if out["activeSessions"] != float64(1) {
|
||||
t.Fatal("stream-ready event missing")
|
||||
}
|
||||
status, out = f.request("POST", "/terminal/session", other, map[string]any{"sessionId": sessionID, "state": "ended"}, "")
|
||||
expect(t, 409, status, out)
|
||||
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": sessionID, "state": "ended"}, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": sessionID, "state": "connected"}, "")
|
||||
expect(t, 409, status, out)
|
||||
status, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": ticket}, "")
|
||||
expect(t, 401, status, out)
|
||||
status, out = f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": id, "desktopIds": []string{}, "enabled": true}, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
|
||||
expect(t, 403, status, out)
|
||||
status, out = f.request("POST", fmt.Sprintf("/terminals/%s/commands", id), admin, map[string]any{"action": "collect-logs"}, "cmd-1")
|
||||
expect(t, 202, status, out)
|
||||
taskID := out["id"].(string)
|
||||
status, out = f.request("GET", "/terminal/commands", other, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if len(out["items"].([]any)) != 0 {
|
||||
t.Fatal("command leaked to other terminal")
|
||||
}
|
||||
status, out = f.request("POST", "/terminal/commands/"+taskID+"/ack", other, map[string]any{"status": "success"}, "")
|
||||
expect(t, 404, status, out)
|
||||
status, out = f.request("GET", "/terminal/commands", device, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if len(out["items"].([]any)) != 1 {
|
||||
t.Fatal("device did not receive command")
|
||||
}
|
||||
status, out = f.request("POST", "/terminal/commands/"+taskID+"/ack", device, map[string]any{"status": "failed", "message": "private device log"}, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("GET", "/tasks/"+taskID, admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["status"] != "failed" {
|
||||
t.Fatal("must wait for device ack")
|
||||
}
|
||||
if strings.Contains(out["message"].(string), "private") {
|
||||
t.Fatal("raw device detail leaked")
|
||||
}
|
||||
status, out = f.request("POST", "/terminals/"+id+"/commands", admin, map[string]any{"action": "collect-logs"}, "diagnostics-2")
|
||||
expect(t, 202, status, out)
|
||||
diagnosticID := out["id"].(string)
|
||||
status, out = f.request("GET", "/terminal/commands", device, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("POST", "/terminal/commands/"+diagnosticID+"/ack", device, map[string]any{"status": "success"}, "")
|
||||
expect(t, 400, status, out)
|
||||
status, out = f.request("POST", "/terminal/commands/"+diagnosticID+"/ack", device, map[string]any{"status": "success", "diagnostics": map[string]any{"agentVersion": "qt-test", "bootId": "test-boot", "kioskState": "DESKTOP_LIST", "uptimeSeconds": 100, "logTail": "private diagnostic"}}, "")
|
||||
expect(t, 200, status, out)
|
||||
viewer := f.login("viewer")
|
||||
status, out = f.request("GET", "/tasks/"+diagnosticID+"/diagnostics", viewer, nil, "")
|
||||
expect(t, 403, status, out)
|
||||
status, out = f.request("GET", "/tasks/"+diagnosticID+"/diagnostics", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["logTail"] != "private diagnostic" {
|
||||
t.Fatal("diagnostic not retained")
|
||||
}
|
||||
_ = otherID
|
||||
}
|
||||
|
||||
func TestUnknownTerminalOutcomeAndBindingRollback(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
admin := f.login("admin")
|
||||
status, out := f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": "term_demo_001", "desktopIds": []string{"missing"}}, "")
|
||||
expect(t, 400, status, out)
|
||||
var n int
|
||||
f.db.Get(&n, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'")
|
||||
if n != 2 {
|
||||
t.Fatal("failed binding update lost original authorization")
|
||||
}
|
||||
status, out = f.request("POST", "/terminals/term_demo_001/commands", admin, map[string]any{"action": "reboot"}, "offline-1")
|
||||
expect(t, 202, status, out)
|
||||
id := out["id"].(string)
|
||||
f.db.Exec("UPDATE tasks SET created_at=now()-interval '3 minutes' WHERE id=$1", id)
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
status, out = f.request("GET", "/tasks/"+id, admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["status"] == "timeout" {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("offline task was not timed out", out)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
status, out = f.request(http.MethodPost, "/resources/sync", admin, nil, "sync-1")
|
||||
expect(t, 503, status, out)
|
||||
}
|
||||
|
||||
func TestRestartPreservesRevokedBindingsAndRejectsModeChange(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
if _, err := f.db.Exec("DELETE FROM bindings WHERE user_id='user_demo_wang'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Bootstrap(context.Background(), f.db, "demo", "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err := f.db.Get(&count, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'"); err != nil || count != 0 {
|
||||
t.Fatalf("restart restored revoked bindings: count=%d err=%v", count, err)
|
||||
}
|
||||
if err := store.Bootstrap(context.Background(), f.db, "production", "prod-admin", "long-password-2026"); err == nil {
|
||||
t.Fatal("demo database accepted production bootstrap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialPasswordPolicyDoesNotRejectExistingAccountOnRestart(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
if _, err := f.db.Exec("UPDATE platform_metadata SET mode='production'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.db.Exec("UPDATE platform_metadata SET mode='demo'")
|
||||
ctx := context.Background()
|
||||
if err := store.Bootstrap(ctx, f.db, "production", "new-admin", "short"); err == nil {
|
||||
t.Fatal("weak initial password accepted")
|
||||
}
|
||||
if err := store.Bootstrap(ctx, f.db, "production", "new-admin", "strong-initial-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var before, after string
|
||||
if err := f.db.Get(&before, "SELECT password_hash FROM administrators WHERE username='new-admin'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Bootstrap(ctx, f.db, "production", "new-admin", "short"); err != nil {
|
||||
t.Fatal("existing account blocked startup", err)
|
||||
}
|
||||
if err := f.db.Get(&after, "SELECT password_hash FROM administrators WHERE username='new-admin'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before != after {
|
||||
t.Fatal("restart changed the account password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLateCommandAcknowledgementPreservesNewSession(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
if _, err := f.db.Exec("UPDATE terminals SET session_id='new-session',session_state='connected',session_desktop_id='desk_demo_windows' WHERE id='term_demo_001'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var task model.Task
|
||||
if err := f.db.Get(&task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES('task-late','terminal','reset-session','term_demo_001','test','admin','running','','late') RETURNING *`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.server.complete(context.Background(), task, "success", "ack"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var session string
|
||||
if err := f.db.Get(&session, "SELECT session_id FROM terminals WHERE id='term_demo_001'"); err != nil || session != "new-session" {
|
||||
t.Fatalf("new session lost: %q %v", session, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVEConfirmedFailureAndUncertainSubmission(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
if _, err := f.db.Exec("UPDATE desktops SET provider='proxmox' WHERE id='desk_demo_kylin'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
submission int
|
||||
exit string
|
||||
want string
|
||||
}{{"submission unavailable", 503, "", "timeout"}, {"permission denied", 403, "", "failed"}, {"explicit PVE task failure", 200, "ERROR", "failed"}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pve := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Method == http.MethodPost {
|
||||
w.WriteHeader(tc.submission)
|
||||
fmt.Fprint(w, `{"data":"UPID:test"}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, `{"data":{"status":"stopped","exitstatus":%q}}`, tc.exit)
|
||||
}))
|
||||
defer pve.Close()
|
||||
f.server.PVE = &proxmox.Client{BaseURL: pve.URL, HTTP: pve.Client()}
|
||||
var task model.Task
|
||||
if err := f.db.Get(&task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,'desktop','start','desk_demo_kylin','test VM','admin','running','test',$1) RETURNING *`, store.ID("task_")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.server.execute(context.Background(), task)
|
||||
var status string
|
||||
if err := f.db.Get(&status, "SELECT status FROM tasks WHERE id=$1", task.ID); err != nil || status != tc.want {
|
||||
t.Fatalf("want %s, got %s err=%v", tc.want, status, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
CREATE TABLE IF NOT EXISTS platform_metadata (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK(singleton), mode text NOT NULL CHECK(mode IN ('demo','production'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS administrators (
|
||||
id text PRIMARY KEY, username text NOT NULL UNIQUE, display_name text NOT NULL,
|
||||
role text NOT NULL CHECK (role IN ('super_admin','operator','viewer')), password_hash text NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||
id text PRIMARY KEY, admin_id text NOT NULL REFERENCES administrators(id), expires_at timestamptz NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS desktops (
|
||||
id text PRIMARY KEY, name text NOT NULL, os text NOT NULL, node text NOT NULL, vmid integer NOT NULL,
|
||||
pool text NOT NULL DEFAULT '', power_state text NOT NULL, service_state text NOT NULL DEFAULT 'unknown',
|
||||
provider text NOT NULL CHECK (provider IN ('demo','proxmox')), endpoint text NOT NULL DEFAULT '', app text NOT NULL DEFAULT 'Desktop',
|
||||
width integer NOT NULL DEFAULT 2560, height integer NOT NULL DEFAULT 1440, fps integer NOT NULL DEFAULT 30,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(provider,node,vmid)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS terminals (
|
||||
id text PRIMARY KEY, sn text NOT NULL UNIQUE, name text NOT NULL, group_name text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'unregistered', agent_version text NOT NULL DEFAULT '', last_seen timestamptz,
|
||||
session_desktop_id text REFERENCES desktops(id), config_version integer NOT NULL DEFAULT 1,
|
||||
bootstrap_hash text NOT NULL, token_hash text UNIQUE, fps double precision, rtt double precision
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id text PRIMARY KEY, username text NOT NULL UNIQUE, display_name text NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true, terminal_id text UNIQUE REFERENCES terminals(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS bindings (
|
||||
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
desktop_id text NOT NULL REFERENCES desktops(id), PRIMARY KEY(user_id,desktop_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS resources (
|
||||
id text PRIMARY KEY, name text NOT NULL, status text NOT NULL, cpu double precision NOT NULL,
|
||||
memory double precision NOT NULL, storage double precision NOT NULL, provider text NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id text PRIMARY KEY, kind text NOT NULL, action text NOT NULL, target_id text NOT NULL, target_name text NOT NULL,
|
||||
actor text NOT NULL, status text NOT NULL, message text NOT NULL DEFAULT '', provider_ref text NOT NULL DEFAULT '',
|
||||
idempotency_key text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(actor,idempotency_key)
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS tasks_active_target ON tasks(kind,target_id) WHERE status IN ('queued','running');
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id text PRIMARY KEY, actor text NOT NULL, action text NOT NULL, target text NOT NULL, result text NOT NULL,
|
||||
request_id text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS connection_tickets (
|
||||
token_hash text PRIMARY KEY, terminal_id text NOT NULL REFERENCES terminals(id), desktop_id text NOT NULL REFERENCES desktops(id),
|
||||
expires_at timestamptz NOT NULL, consumed_at timestamptz
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS audit_created ON audit_logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS tasks_created ON tasks(created_at DESC);
|
||||
ALTER TABLE terminals ADD COLUMN IF NOT EXISTS session_id text NOT NULL DEFAULT '';
|
||||
ALTER TABLE terminals ADD COLUMN IF NOT EXISTS session_state text NOT NULL DEFAULT 'idle';
|
||||
CREATE TABLE IF NOT EXISTS task_diagnostics (
|
||||
task_id text PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
content jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,119 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"time"
|
||||
"vdi-cloud/platform/internal/model"
|
||||
)
|
||||
|
||||
//go:embed schema.sql
|
||||
var schema string
|
||||
|
||||
func Open(ctx context.Context, url string) (*sqlx.DB, error) {
|
||||
db, err := sqlx.ConnectContext(ctx, "pgx", url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(12)
|
||||
db.SetMaxIdleConns(4)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
if _, err = db.ExecContext(ctx, schema); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
func ID(prefix string) string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return prefix + hex.EncodeToString(b)
|
||||
}
|
||||
func Hash(secret string) string { h := sha256.Sum256([]byte(secret)); return hex.EncodeToString(h[:]) }
|
||||
func Bootstrap(ctx context.Context, db *sqlx.DB, mode, username, password string) error {
|
||||
if _, err := db.ExecContext(ctx, "INSERT INTO platform_metadata(singleton,mode) VALUES(true,$1) ON CONFLICT DO NOTHING", mode); err != nil {
|
||||
return err
|
||||
}
|
||||
var existingMode string
|
||||
if err := db.GetContext(ctx, &existingMode, "SELECT mode FROM platform_metadata WHERE singleton=true"); err != nil {
|
||||
return err
|
||||
}
|
||||
if existingMode != mode {
|
||||
return fmt.Errorf("database mode is %s; use a separate database for %s", existingMode, mode)
|
||||
}
|
||||
admins := []struct{ username, name, role, password string }{{username, "平台管理员", "super_admin", password}}
|
||||
if mode == "demo" {
|
||||
admins = []struct{ username, name, role, password string }{{"admin", "系统管理员", "super_admin", "Demo@2026"}, {"operator", "运维人员", "operator", "Demo@2026"}, {"viewer", "只读人员", "viewer", "Demo@2026"}}
|
||||
}
|
||||
for _, a := range admins {
|
||||
var exists bool
|
||||
if err := db.GetContext(ctx, &exists, "SELECT EXISTS(SELECT 1 FROM administrators WHERE username=$1)", a.username); err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
if mode == "production" && len(a.password) < 12 {
|
||||
return fmt.Errorf("initial administrator password must contain at least 12 characters")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(a.password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = db.ExecContext(ctx, "INSERT INTO administrators VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING", ID("adm_"), a.username, a.name, a.role, string(hash)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if mode != "demo" {
|
||||
return nil
|
||||
}
|
||||
// Seed the demo only once; restarting must not restore revoked bindings.
|
||||
var seeded bool
|
||||
if err := db.GetContext(ctx, &seeded, "SELECT EXISTS(SELECT 1 FROM desktops WHERE provider='demo')"); err != nil {
|
||||
return err
|
||||
}
|
||||
if seeded {
|
||||
return nil
|
||||
}
|
||||
tx, err := db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
statements := []string{
|
||||
`INSERT INTO desktops(id,name,os,node,vmid,pool,power_state,service_state,provider) VALUES ('desk_demo_windows','设计专属桌面','Windows','demo-node-a',201,'设计专属池','running','unknown','demo'),('desk_demo_ubuntu','研发办公桌面','Ubuntu','demo-node-a',202,'研发专属池','running','unknown','demo'),('desk_demo_kylin','国产办公桌面','麒麟','demo-node-a',203,'通用办公池','stopped','unknown','demo') ON CONFLICT DO NOTHING`,
|
||||
`INSERT INTO terminals(id,sn,name,group_name,status,agent_version,last_seen,bootstrap_hash) VALUES ('term_demo_001','DEMO-RK-001','设计工位 01','设计部','offline','演示版本',now()-interval '2 hours','disabled'),('term_demo_002','DEMO-RK-002','研发工位 02','研发部','offline','演示版本',now()-interval '3 hours','disabled') ON CONFLICT DO NOTHING`,
|
||||
`INSERT INTO users(id,username,display_name,terminal_id) VALUES ('user_demo_wang','wang.demo','王工','term_demo_001'),('user_demo_chen','chen.demo','陈工','term_demo_002') ON CONFLICT DO NOTHING`,
|
||||
`INSERT INTO bindings VALUES ('user_demo_wang','desk_demo_windows'),('user_demo_wang','desk_demo_ubuntu'),('user_demo_chen','desk_demo_ubuntu') ON CONFLICT DO NOTHING`,
|
||||
`INSERT INTO resources VALUES ('demo-node-a','demo-node-a','online',0.24,0.48,0.35,'demo',now()) ON CONFLICT DO NOTHING`,
|
||||
}
|
||||
for _, q := range statements {
|
||||
if _, err = tx.ExecContext(ctx, q); err != nil {
|
||||
return fmt.Errorf("demo seed: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func Users(ctx context.Context, db *sqlx.DB) ([]model.User, error) {
|
||||
users := []model.User{}
|
||||
if err := db.SelectContext(ctx, &users, "SELECT * FROM users ORDER BY username"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range users {
|
||||
users[i].DesktopIDs = []string{}
|
||||
if err := db.SelectContext(ctx, &users[i].DesktopIDs, "SELECT desktop_id FROM bindings WHERE user_id=$1 ORDER BY desktop_id", users[i].ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
Reference in new issue
Block a user