新增 PVE 虚拟机管理功能,允许通过管理面板直接启动/关闭 PVE 宿主机上的虚拟机。
后端:
- 新增 pve_hosts 表存储 PVE 主机配置(密码加密存储)
- 机器表扩展 pve_host_id 和 pve_vmid 字段
- 实现 PVE 主机 CRUD API
- 实现 PVE API 客户端(认证/启动/停止/状态查询)
- 虚拟机操作 API: /api/machines/:id/vm-status/start/stop
前端:
- 新增 PVE 主机管理页面 (/pve-hosts)
- 机器编辑弹窗增加 PVE 配置区块
- 机器详情页增加虚拟机启动/关闭按钮
- 机器列表页显示虚拟机运行状态
安全:
- 密码使用 AES-GCM 加密存储,不返回明文
- 仅管理员可访问 PVE 相关功能
[宪宪/glm-5 🐾]
426 lines
10 KiB
Go
426 lines
10 KiB
Go
package services
|
||
|
||
import (
|
||
"crypto/tls"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/cookiejar"
|
||
"strings"
|
||
"time"
|
||
|
||
"lan-manager/server/models"
|
||
"lan-manager/server/utils"
|
||
)
|
||
|
||
type PVEClient struct {
|
||
Host string
|
||
Port int
|
||
Username string
|
||
Password string
|
||
VerifySSL bool
|
||
CSRFToken string
|
||
Cookie string
|
||
httpClient *http.Client
|
||
}
|
||
|
||
func NewPVEClient(host *models.PVEHost, password string) *PVEClient {
|
||
jar, _ := cookiejar.New(nil)
|
||
client := &http.Client{
|
||
Jar: jar,
|
||
Timeout: 30 * time.Second,
|
||
}
|
||
if !host.VerifySSL {
|
||
client.Transport = &http.Transport{
|
||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||
}
|
||
}
|
||
return &PVEClient{
|
||
Host: host.Hostname,
|
||
Port: host.Port,
|
||
Username: host.Username,
|
||
Password: password,
|
||
VerifySSL: host.VerifySSL,
|
||
httpClient: client,
|
||
}
|
||
}
|
||
|
||
func (c *PVEClient) baseURL() string {
|
||
scheme := "https"
|
||
if !c.VerifySSL {
|
||
scheme = "http"
|
||
}
|
||
return fmt.Sprintf("%s://%s:%d", scheme, c.Host, c.Port)
|
||
}
|
||
|
||
// Login 获取 PVE 认证ticket
|
||
func (c *PVEClient) Login() error {
|
||
loginURL := c.baseURL() + "/api2/json/access/ticket"
|
||
data := fmt.Sprintf("username=%s&password=%s", c.Username, c.Password)
|
||
|
||
req, err := http.NewRequest("POST", loginURL, strings.NewReader(data))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
||
resp, err := c.httpClient.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return fmt.Errorf("login failed, status: %d", resp.StatusCode)
|
||
}
|
||
|
||
var result struct {
|
||
Data struct {
|
||
CSRFToken string `json:"CSRFPreventionToken"`
|
||
Cookie string `json:"PVEAuthCookie"`
|
||
} `json:"data"`
|
||
}
|
||
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return err
|
||
}
|
||
|
||
c.CSRFToken = result.Data.CSRFToken
|
||
c.Cookie = result.Data.Cookie
|
||
return nil
|
||
}
|
||
|
||
// GetVMStatus 获取虚拟机状态
|
||
func (c *PVEClient) GetVMStatus(vmid string) (*models.PVEVMStatus, error) {
|
||
if c.CSRFToken == "" {
|
||
if err := c.Login(); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
// 获取 node 名称(通常是 pve)
|
||
statusURL := c.baseURL() + "/api2/json/nodes/pve/qemu/" + vmid + "/status"
|
||
|
||
req, err := http.NewRequest("GET", statusURL, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Cookie", c.Cookie)
|
||
req.Header.Set("CSRFPreventionToken", c.CSRFToken)
|
||
|
||
resp, err := c.httpClient.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode == 401 {
|
||
// token 过期,重新登录
|
||
if err := c.Login(); err != nil {
|
||
return nil, err
|
||
}
|
||
return c.GetVMStatus(vmid)
|
||
}
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("get vm status failed, status: %d", resp.StatusCode)
|
||
}
|
||
|
||
var result struct {
|
||
Data struct {
|
||
Status string `json:"status"`
|
||
Uptime int64 `json:"uptime"`
|
||
CPU float64 `json:"cpu"`
|
||
MemoryUsed int64 `json:"mem"`
|
||
MemoryTotal int64 `json:"maxmem"`
|
||
} `json:"data"`
|
||
}
|
||
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return nil, fmt.Errorf("decode failed: %v, body: %s", err, string(body))
|
||
}
|
||
|
||
return &models.PVEVMStatus{
|
||
Status: result.Data.Status,
|
||
Uptime: result.Data.Uptime,
|
||
CPU: result.Data.CPU * 100,
|
||
MemoryUsed: result.Data.MemoryUsed,
|
||
MemoryTotal: result.Data.MemoryTotal,
|
||
}, nil
|
||
}
|
||
|
||
// StartVM 启动虚拟机
|
||
func (c *PVEClient) StartVM(vmid string) error {
|
||
if c.CSRFToken == "" {
|
||
if err := c.Login(); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
startURL := c.baseURL() + "/api2/json/nodes/pve/qemu/" + vmid + "/status/start"
|
||
|
||
req, err := http.NewRequest("POST", startURL, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Cookie", c.Cookie)
|
||
req.Header.Set("CSRFPreventionToken", c.CSRFToken)
|
||
|
||
resp, err := c.httpClient.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode == 401 {
|
||
if err := c.Login(); err != nil {
|
||
return err
|
||
}
|
||
return c.StartVM(vmid)
|
||
}
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("start vm failed, status: %d, body: %s", resp.StatusCode, string(body))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// StopVM 停止虚拟机
|
||
func (c *PVEClient) StopVM(vmid string) error {
|
||
if c.CSRFToken == "" {
|
||
if err := c.Login(); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
stopURL := c.baseURL() + "/api2/json/nodes/pve/qemu/" + vmid + "/status/stop"
|
||
|
||
req, err := http.NewRequest("POST", stopURL, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Cookie", c.Cookie)
|
||
req.Header.Set("CSRFPreventionToken", c.CSRFToken)
|
||
|
||
resp, err := c.httpClient.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode == 401 {
|
||
if err := c.Login(); err != nil {
|
||
return err
|
||
}
|
||
return c.StopVM(vmid)
|
||
}
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("stop vm failed, status: %d, body: %s", resp.StatusCode, string(body))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// GetVMList 获取虚拟机列表
|
||
func (c *PVEClient) GetVMList() ([]struct {
|
||
VMID string `json:"vmid"`
|
||
Name string `json:"name"`
|
||
Status string `json:"status"`
|
||
}, error) {
|
||
if c.CSRFToken == "" {
|
||
if err := c.Login(); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
listURL := c.baseURL() + "/api2/json/cluster/resources?type=vm"
|
||
|
||
req, err := http.NewRequest("GET", listURL, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Cookie", c.Cookie)
|
||
req.Header.Set("CSRFPreventionToken", c.CSRFToken)
|
||
|
||
resp, err := c.httpClient.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("get vm list failed, status: %d", resp.StatusCode)
|
||
}
|
||
|
||
var result struct {
|
||
Data []struct {
|
||
VMID string `json:"vmid"`
|
||
Name string `json:"name"`
|
||
Status string `json:"status"`
|
||
} `json:"data"`
|
||
}
|
||
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return result.Data, nil
|
||
}
|
||
|
||
// PVEHostService PVE 主机服务
|
||
type PVEHostService struct{}
|
||
|
||
func NewPVEHostService() *PVEHostService {
|
||
return &PVEHostService{}
|
||
}
|
||
|
||
// GetAll 获取所有 PVE 主机
|
||
func (s *PVEHostService) GetAll() ([]models.PVEHost, error) {
|
||
rows, err := DB.Query(`SELECT id, name, hostname, port, username, password_enc, verify_ssl, created_at, updated_at FROM pve_hosts ORDER BY id`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var hosts []models.PVEHost
|
||
for rows.Next() {
|
||
var h models.PVEHost
|
||
var verifySSL int
|
||
if err := rows.Scan(&h.ID, &h.Name, &h.Hostname, &h.Port, &h.Username, &h.PasswordEnc, &verifySSL, &h.CreatedAt, &h.UpdatedAt); err != nil {
|
||
return nil, err
|
||
}
|
||
h.VerifySSL = verifySSL == 1
|
||
hosts = append(hosts, h)
|
||
}
|
||
return hosts, nil
|
||
}
|
||
|
||
// GetByID 根据 ID 获取 PVE 主机
|
||
func (s *PVEHostService) GetByID(id int64) (*models.PVEHost, error) {
|
||
var h models.PVEHost
|
||
var verifySSL int
|
||
err := DB.QueryRow(`SELECT id, name, hostname, port, username, password_enc, verify_ssl, created_at, updated_at FROM pve_hosts WHERE id = ?`, id).
|
||
Scan(&h.ID, &h.Name, &h.Hostname, &h.Port, &h.Username, &h.PasswordEnc, &verifySSL, &h.CreatedAt, &h.UpdatedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
h.VerifySSL = verifySSL == 1
|
||
return &h, nil
|
||
}
|
||
|
||
// Create 创建 PVE 主机
|
||
func (s *PVEHostService) Create(host *models.PVEHost) error {
|
||
// 加密密码
|
||
encPassword, err := utils.Encrypt(host.Password)
|
||
if err != nil {
|
||
return fmt.Errorf("encrypt password failed: %w", err)
|
||
}
|
||
|
||
result, err := DB.Exec(`INSERT INTO pve_hosts (name, hostname, port, username, password_enc, verify_ssl) VALUES (?, ?, ?, ?, ?, ?)`,
|
||
host.Name, host.Hostname, host.Port, host.Username, encPassword, boolToInt(host.VerifySSL))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
id, err := result.LastInsertId()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
host.ID = id
|
||
return nil
|
||
}
|
||
|
||
// Update 更新 PVE 主机
|
||
func (s *PVEHostService) Update(host *models.PVEHost) error {
|
||
if host.Password != "" {
|
||
// 密码有更新,重新加密
|
||
encPassword, err := utils.Encrypt(host.Password)
|
||
if err != nil {
|
||
return fmt.Errorf("encrypt password failed: %w", err)
|
||
}
|
||
_, err = DB.Exec(`UPDATE pve_hosts SET name=?, hostname=?, port=?, username=?, password_enc=?, verify_ssl=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
|
||
host.Name, host.Hostname, host.Port, host.Username, encPassword, boolToInt(host.VerifySSL), host.ID)
|
||
return err
|
||
}
|
||
|
||
_, err := DB.Exec(`UPDATE pve_hosts SET name=?, hostname=?, port=?, username=?, verify_ssl=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
|
||
host.Name, host.Hostname, host.Port, host.Username, boolToInt(host.VerifySSL), host.ID)
|
||
return err
|
||
}
|
||
|
||
// Delete 删除 PVE 主机
|
||
func (s *PVEHostService) Delete(id int64) error {
|
||
// 先清除关联机器的 PVE 字段
|
||
if _, err := DB.Exec(`UPDATE machines SET pve_host_id = NULL, pve_vmid = NULL WHERE pve_host_id = ?`, id); err != nil {
|
||
return err
|
||
}
|
||
_, err := DB.Exec(`DELETE FROM pve_hosts WHERE id = ?`, id)
|
||
return err
|
||
}
|
||
|
||
// GetDecryptedPassword 获取解密后的密码
|
||
func (s *PVEHostService) GetDecryptedPassword(host *models.PVEHost) (string, error) {
|
||
return utils.Decrypt(host.PasswordEnc)
|
||
}
|
||
|
||
// GetVMStatus 获取虚拟机状态
|
||
func (s *PVEHostService) GetVMStatus(hostID int64, vmid string) (*models.PVEVMStatus, error) {
|
||
host, err := s.GetByID(hostID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
password, err := s.GetDecryptedPassword(host)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
client := NewPVEClient(host, password)
|
||
return client.GetVMStatus(vmid)
|
||
}
|
||
|
||
// StartVM 启动虚拟机
|
||
func (s *PVEHostService) StartVM(hostID int64, vmid string) error {
|
||
host, err := s.GetByID(hostID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
password, err := s.GetDecryptedPassword(host)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
client := NewPVEClient(host, password)
|
||
return client.StartVM(vmid)
|
||
}
|
||
|
||
// StopVM 停止虚拟机
|
||
func (s *PVEHostService) StopVM(hostID int64, vmid string) error {
|
||
host, err := s.GetByID(hostID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
password, err := s.GetDecryptedPassword(host)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
client := NewPVEClient(host, password)
|
||
return client.StopVM(vmid)
|
||
}
|
||
|
||
func boolToInt(b bool) int {
|
||
if b {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|