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

271 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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