package server import ( "context" "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "encoding/json" "errors" "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" "github.com/jmoiron/sqlx" "io" "os" "strings" "unicode" "vdi-cloud/platform/internal/store" "vdi-cloud/platform/pkg/proxmox" ) type pveInput struct { URL string `json:"url" binding:"required,max=250"` TokenID string `json:"tokenId" binding:"required,max=200"` TokenSecret string `json:"tokenSecret" binding:"max=200"` CAPEM string `json:"caPem" binding:"max=32768"` Revision string `json:"revision" binding:"max=80"` } // New configuration writes reject fields outside the supported API contract. func (s *Server) decodeConfiguration(c *gin.Context, value any) bool { decoder := json.NewDecoder(c.Request.Body) decoder.DisallowUnknownFields() err := decoder.Decode(value) if err == nil { var trailing any if decoder.Decode(&trailing) != io.EOF { err = errors.New("trailing JSON") } } if err == nil { err = binding.Validator.ValidateStruct(value) } if err != nil { s.fail(c, 400, "invalid_request", "请检查必填字段和格式,仅接受当前支持的配置参数") return false } return true } func (s *Server) cryptPVE(value string, seal bool) (string, error) { key := sha256.Sum256([]byte("vdi-compute-config-v1:" + s.Config.JWTSecret)) block, _ := aes.NewCipher(key[:]) gcm, _ := cipher.NewGCM(block) aad := []byte("pve_connection:v1") if seal { nonce := make([]byte, gcm.NonceSize()) if _, err := rand.Read(nonce); err != nil { return "", err } return base64.StdEncoding.EncodeToString(gcm.Seal(nonce, nonce, []byte(value), aad)), nil } data, err := base64.StdEncoding.DecodeString(value) if err != nil || len(data) < gcm.NonceSize() { return "", errors.New("invalid encrypted connection") } data, err = gcm.Open(nil, data[:gcm.NonceSize()], data[gcm.NonceSize():], aad) return string(data), err } func (s *Server) effectivePVE(ctx context.Context, db sqlx.ExtContext) (pveInput, error) { var row struct { Revision string `db:"revision"` Encrypted string `db:"encrypted_config"` } var in pveInput err := sqlx.GetContext(ctx, db, &row, "SELECT revision,encrypted_config FROM pve_connection WHERE singleton") if err == sql.ErrNoRows { in = pveInput{URL: s.Config.PVEURL, TokenID: s.Config.PVETokenID, TokenSecret: s.Config.PVETokenSecret, Revision: "environment"} if s.Config.PVECAFile != "" { data, e := os.ReadFile(s.Config.PVECAFile) if e != nil { return in, e } in.CAPEM = string(data) } return in, nil } if err != nil { return in, err } plain, err := s.cryptPVE(row.Encrypted, false) if err != nil { return in, err } err = json.Unmarshal([]byte(plain), &in) in.Revision = row.Revision return in, err } func (s *Server) currentPVE() *proxmox.Client { if p := s.computeRuntime.Load(); p != nil { return p } return s.PVE } func (s *Server) RefreshCompute(ctx context.Context) error { s.computeMu.Lock() defer s.computeMu.Unlock() var revision string err := s.DB.GetContext(ctx, &revision, "SELECT revision FROM pve_connection WHERE singleton") if err == sql.ErrNoRows { s.computeRuntime.Store(nil) s.computeRevision = "" return nil } if err != nil { return err } if revision == s.computeRevision { return nil } in, err := s.effectivePVE(ctx, s.DB) if err != nil { return err } client, err := proxmox.NewWithCA(in.URL, in.TokenID, in.TokenSecret, in.CAPEM) if err != nil { return err } s.computeRuntime.Store(client) s.computeRevision = in.Revision return nil } func (s *Server) refreshComputeRequest(c *gin.Context) { if err := s.RefreshCompute(c.Request.Context()); err != nil { s.fail(c, 503, "compute_config_unavailable", "无法加载计算平台配置,请检查服务端密钥和数据库") return } c.Next() } func (s *Server) getPVEConnection(c *gin.Context) { in, err := s.effectivePVE(c.Request.Context(), s.DB) if err != nil { s.fail(c, 503, "compute_config_unavailable", "无法读取接入配置") return } c.JSON(200, gin.H{"url": in.URL, "tokenId": in.TokenID, "caPem": in.CAPEM, "revision": in.Revision, "tokenConfigured": in.TokenSecret != "", "source": map[bool]string{true: "environment", false: "database"}[in.Revision == "environment"]}) } func (s *Server) resolvePVEInput(c *gin.Context, in *pveInput, db sqlx.ExtContext) bool { old, err := s.effectivePVE(c.Request.Context(), db) if err != nil { s.fail(c, 503, "compute_config_unavailable", "无法读取原接入配置") return false } in.URL = strings.TrimRight(strings.TrimSpace(in.URL), "/") in.TokenID = strings.TrimSpace(in.TokenID) if in.Revision != old.Revision { s.fail(c, 409, "connection_changed", "配置已变化,请重新加载") return false } if !strings.Contains(in.TokenID, "@") || !strings.Contains(in.TokenID, "!") || strings.ContainsFunc(in.TokenID, unicode.IsSpace) || strings.ContainsFunc(in.TokenSecret, unicode.IsControl) { s.fail(c, 400, "invalid_connection", "请输入有效的 API Token ID 和密钥") return false } if in.TokenSecret == "" { if in.TokenID != old.TokenID || in.URL != strings.TrimRight(old.URL, "/") { s.fail(c, 400, "token_required", "更换接入地址或 Token ID 时必须填写新密钥") return false } in.TokenSecret = old.TokenSecret } // Once attached, keep one cluster identity even if inventory is currently empty. // This prevents an in-flight sync/import from writing the previous cluster's data. if old.URL != "" && in.URL != strings.TrimRight(old.URL, "/") { s.fail(c, 409, "compute_in_use", "已配置接入地址,本期仅支持维护同一地址的凭据与证书;切换集群需离线迁移") return false } if in.TokenSecret == "" { s.fail(c, 400, "token_required", "请填写 API Token 密钥") return false } return true } func (s *Server) testPVE(c *gin.Context, in pveInput) bool { client, err := proxmox.NewWithCA(in.URL, in.TokenID, in.TokenSecret, in.CAPEM) if err != nil { s.fail(c, 400, "invalid_connection", "请检查 HTTPS 地址和 PEM CA 证书") return false } resources, err := client.Resources(c.Request.Context()) if err != nil { s.fail(c, 502, "pve_connection_failed", "连接检查失败,请检查网络、TLS 证书与 Token 的资源查看权限") return false } nodes, vms := 0, 0 for _, r := range resources { if r.Type == "node" { nodes++ } if r.Type == "qemu" { vms++ } } if nodes == 0 { s.fail(c, 403, "pve_audit_required", "未读取到节点,请核查 Token 的资源查看权限") return false } c.Set("connectionNodes", nodes) c.Set("connectionVMs", vms) return true } func (s *Server) checkPVEConnection(c *gin.Context) { var in pveInput if !s.decodeConfiguration(c, &in) || !s.resolvePVEInput(c, &in, s.DB) || !s.testPVE(c, in) { return } c.JSON(200, gin.H{"available": true, "nodes": c.GetInt("connectionNodes"), "vms": c.GetInt("connectionVMs"), "message": "TLS、身份与资源读取检查通过"}) } func (s *Server) savePVEConnection(c *gin.Context) { var in pveInput if !s.decodeConfiguration(c, &in) { return } tx, actor := s.accountTransaction(c, true) if tx == nil { return } defer tx.Rollback() if _, err := tx.ExecContext(c.Request.Context(), "SELECT pg_advisory_xact_lock(76511102027)"); err != nil { s.dbError(c, err) return } if !s.resolvePVEInput(c, &in, tx) { return } var busy bool if err := tx.GetContext(c.Request.Context(), &busy, "SELECT EXISTS(SELECT 1 FROM tasks WHERE kind IN ('desktop','hardware','snapshot','batch-desktop','provision','resource') AND status IN ('queued','running'))"); err != nil { s.dbError(c, err) return } if busy { s.fail(c, 409, "compute_busy", "计算任务执行中,请完成后再修改接入配置") return } if !s.testPVE(c, in) { return } in.Revision = store.ID("connection_") plain, _ := json.Marshal(in) encrypted, err := s.cryptPVE(string(plain), true) if err == nil { _, err = tx.ExecContext(c.Request.Context(), "INSERT INTO pve_connection(singleton,revision,encrypted_config) VALUES(true,$1,$2) ON CONFLICT(singleton) DO UPDATE SET revision=$1,encrypted_config=$2,updated_at=now()", in.Revision, encrypted) } if err == nil { err = accountAudit(c.Request.Context(), tx, actor.Username, "save_pve_connection", "pve", c.GetString("requestId")) } if err == nil { err = tx.Commit() } if err != nil { s.dbError(c, err) return } if err = s.RefreshCompute(c.Request.Context()); err != nil { s.fail(c, 503, "compute_reload_failed", "配置已保存,加载失败,请检查服务状态") return } c.JSON(200, gin.H{"accepted": true}) }