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

315 lines
13 KiB
Go

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"
"sync"
"sync/atomic"
"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
computeMu sync.Mutex
computeRevision string
computeRuntime atomic.Pointer[proxmox.Client]
}
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.SetTrustedProxies(cfg.TrustedProxies)
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.POST("/config/ack", s.policyAck)
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)
t.POST("/desktop-prepare", s.prepareGPUDesktop)
t.GET("/desktop-prepare/:id", s.gpuPrepareStatus)
a := r.Group("/api/v1", s.adminAuth, s.refreshComputeRequest)
a.GET("/auth/me", func(c *gin.Context) { c.JSON(200, c.MustGet("admin")) })
a.POST("/auth/logout", s.logout)
a.POST("/auth/password", s.changeOwnPassword)
a.GET("/auth/mfa", s.mfaStatus)
a.POST("/auth/mfa/setup", s.beginMFA)
a.POST("/auth/mfa/confirm", s.confirmMFA)
a.POST("/auth/mfa/recovery", func(c *gin.Context) { s.changeMFA(c, false) })
a.DELETE("/auth/mfa", func(c *gin.Context) { s.changeMFA(c, true) })
a.GET("/administrators", s.allow("super_admin"), s.administratorList)
a.POST("/administrators", s.allow("super_admin"), s.createAdministrator)
a.PUT("/administrators/:id", s.allow("super_admin"), s.updateAdministrator)
a.POST("/administrators/:id/password", s.allow("super_admin"), s.resetAdministratorPassword)
a.GET("/administrators/:id/sessions", s.allow("super_admin"), s.administratorSessions)
a.DELETE("/administrators/:id/sessions/:sessionKey", s.allow("super_admin"), s.revokeAdministratorSession)
a.GET("/overview", s.overview)
a.GET("/sessions/history", s.sessionHistory)
a.GET("/monitor/series", s.resourceSeries)
a.GET("/monitor/rules", s.alertRules)
a.POST("/monitor/rules", s.allow("super_admin"), s.saveAlertRule)
a.PUT("/monitor/rules/:id", s.allow("super_admin"), s.saveAlertRule)
a.GET("/monitor/alerts", s.alerts)
a.POST("/monitor/alerts/:id/acknowledge", s.allow("super_admin", "operator"), s.acknowledgeAlert)
a.POST("/imports/preview", s.allow("super_admin"), s.previewImport)
a.POST("/imports/commit", s.allow("super_admin"), s.commitImport)
a.GET("/settings", s.settings)
a.GET("/settings/pve", s.allow("super_admin"), s.getPVEConnection)
a.POST("/settings/pve/check", s.allow("super_admin"), s.checkPVEConnection)
a.PUT("/settings/pve", s.allow("super_admin"), s.savePVEConnection)
a.GET("/desktops/:id/hardware", s.desktopHardware)
a.GET("/desktops/:id/snapshots", s.snapshotView)
a.POST("/desktops/:id/snapshots/check", s.allow("super_admin"), s.checkSnapshot)
a.POST("/desktops/:id/snapshots/actions", s.allow("super_admin"), s.queueSnapshot)
a.POST("/tasks/:id/snapshot-reconcile", s.allow("super_admin"), s.reconcileSnapshot)
a.POST("/tasks/:id/gpu-reconcile", s.allow("super_admin", "operator"), s.reconcileGPUPrepare)
a.POST("/desktops/:id/hardware/check", s.allow("super_admin"), s.checkHardware)
a.POST("/desktops/:id/hardware", s.allow("super_admin"), s.queueHardware)
a.GET("/resources", s.resources)
a.GET("/resources/gpus", s.gpuInventory)
a.GET("/resources/vms", s.discoverVMs)
a.GET("/resources/templates", s.discoverTemplates)
a.GET("/resources/storage", s.storageList)
a.GET("/templates", s.templateList)
a.POST("/templates", s.allow("super_admin"), s.registerTemplate)
a.PATCH("/templates/:id", s.allow("super_admin"), s.updateTemplate)
a.POST("/templates/:id/validate", s.allow("super_admin"), s.validateTemplate)
a.GET("/pools", s.poolList)
a.POST("/pools", s.allow("super_admin"), s.savePool)
a.PUT("/pools/:id", s.allow("super_admin"), s.savePool)
a.PUT("/pools/:id/members", s.allow("super_admin"), s.poolMembers)
a.POST("/desktops/clone/check", s.allow("super_admin"), s.checkClone)
a.POST("/desktops/clone", s.allow("super_admin"), s.cloneDesktop)
a.POST("/resources/sync", s.allow("super_admin"), s.syncResources)
a.GET("/desktop-batches", s.powerBatches)
a.GET("/desktop-batches/:id", s.getPowerBatch)
a.POST("/desktop-batches/check", s.allow("super_admin", "operator"), s.checkPowerBatch)
a.POST("/desktop-batches", s.allow("super_admin", "operator"), s.queuePowerBatch)
a.POST("/tasks/:id/batch-power-reconcile", s.allow("super_admin", "operator"), s.reconcileBatchPower)
a.GET("/desktops", s.desktops)
a.POST("/desktops/import", s.allow("super_admin"), s.importDesktop)
a.GET("/desktops/:id", s.desktop)
a.PUT("/desktops/:id/connection", s.allow("super_admin"), s.configureDesktop)
a.POST("/desktops/:id/actions", s.allow("super_admin", "operator"), s.desktopAction)
a.GET("/terminals", s.terminals)
a.GET("/terminals/:id/metrics", s.terminalMetrics)
a.GET("/terminal-policy-templates", s.policyTemplates)
a.POST("/terminal-policy-templates", s.allow("super_admin"), s.createPolicyTemplate)
a.GET("/terminal-policy-states", s.policyStates)
a.GET("/terminal-policy-deployments", s.policyDeployments)
a.POST("/terminal-policy-deployments", s.allow("super_admin"), s.deployPolicy)
a.GET("/terminal-policy-deployments/:id/results", s.policyResults)
a.POST("/terminals", s.allow("super_admin"), s.enroll)
a.PUT("/terminals/:id", s.allow("super_admin"), s.updateTerminal)
a.POST("/terminals/:id/disable", s.allow("super_admin"), s.disableTerminal)
a.POST("/terminals/:id/reactivate", s.allow("super_admin"), s.reactivateTerminal)
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", s.allow("super_admin"), s.updateUser)
a.POST("/users/batch-bindings", s.allow("super_admin"), s.batchBindings)
a.PUT("/users/:id/bindings", s.allow("super_admin"), s.bindings)
a.GET("/tasks", s.tasks)
a.GET("/tasks/export", s.exportTasks)
a.GET("/tasks/:id", s.task)
a.POST("/tasks/:id/hardware-reconcile", s.allow("super_admin"), s.reconcileHardware)
a.POST("/tasks/:id/reconcile", s.allow("super_admin"), s.reconcileProvision)
a.GET("/tasks/:id/diagnostics", s.allow("super_admin", "operator"), s.taskDiagnostics)
a.GET("/diagnostics", s.allow("super_admin", "operator"), s.diagnostics)
a.GET("/diagnostics/export", s.allow("super_admin", "operator"), s.exportDiagnostics)
a.GET("/audit-logs", s.audits)
a.GET("/audit-logs/export", s.exportAudits)
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 a.enabled 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"`
Code string `json:"code" binding:"max=80"`
}
if !s.decode(c, &input) {
return
}
if !s.allowLoginAttempt(c, input.Username) {
return
}
var a model.Administrator
tx, err := s.DB.BeginTxx(c.Request.Context(), nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
// Serialize session creation against account disable, role changes and password reset.
err = tx.GetContext(c.Request.Context(), &a, "SELECT * FROM administrators WHERE username=$1 AND enabled FOR UPDATE", input.Username)
if err != nil || bcrypt.CompareHashAndPassword([]byte(a.PasswordHash), []byte(input.Password)) != nil {
s.fail(c, 401, "invalid_credentials", "用户名或密码错误")
return
}
if !s.loginMFA(c, tx, a.ID, input.Code) {
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 = tx.ExecContext(c.Request.Context(), "INSERT INTO admin_sessions VALUES($1,$2,$3)", id, a.ID, expires); err != nil {
s.dbError(c, err)
return
}
if _, err = tx.ExecContext(c.Request.Context(), "INSERT INTO admin_session_details(session_id,client_ip,user_agent) VALUES($1,$2,$3)", id, c.ClientIP(), sessionUserAgent(c.GetHeader("User-Agent"))); err != nil {
s.dbError(c, err)
return
}
if err = tx.Commit(); 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")})
}