215 lines
7.4 KiB
Go
215 lines
7.4 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"
|
|
"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")})
|
|
}
|