Files
vdi-cloud/platform/internal/server/compute_connection.go
T

271 lines
8.6 KiB
Go

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})
}