271 lines
8.6 KiB
Go
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})
|
||
}
|