Files
lan-manager/server/services/pve.go
openclaw 8ef3e8c631 feat: PVE 虚拟机管理功能 (F001)
新增 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 🐾]
2026-04-20 13:07:22 +08:00

426 lines
10 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 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
}