Files
shirainbown 6f507b319e feat: merge PVE VM management + local features
- Merge PVE (Proxmox VE) virtual machine management from remote
  - PVE host CRUD API
  - VM status query / start / stop operations
  - PVE database tables and models
  - Frontend VM status display and control buttons
  - SPA routing fix for nested asset paths

- Keep local features:
  - Change password functionality
  - Log cleanup service
  - Docker containerization (Dockerfile + docker-compose)
  - Empty machine ping log spam fix
  - settings table for admin password storage

- Update machines handlers to support PVE fields
- Update frontend API client with PVE endpoints
2026-06-18 22:33:45 +08:00

502 lines
12 KiB
Go
Raw Permalink 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"
"net/url"
"strconv"
"strings"
"time"
"lan-manager/server/db"
"lan-manager/server/models"
"lan-manager/server/utils"
)
type PVEClient struct {
NodeName string
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,
NodeName: host.NodeName,
VerifySSL: host.VerifySSL,
httpClient: client,
}
}
func (c *PVEClient) baseURL() string {
// PVE 始终使用 HTTPSverify_ssl 只控制是否验证证书
return fmt.Sprintf("https://%s:%d", c.Host, c.Port)
}
// Login 获取 PVE 认证ticket
func (c *PVEClient) Login() error {
loginURL := c.baseURL() + "/api2/json/access/ticket"
formData := url.Values{}
formData.Set("username", c.Username)
formData.Set("password", c.Password)
req, err := http.NewRequest("POST", loginURL, strings.NewReader(formData.Encode()))
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"`
Ticket string `json:"ticket"`
} `json:"data"`
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read login response failed: %v", err)
}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("decode login response failed: %v, body: %s", err, string(body))
}
c.CSRFToken = result.Data.CSRFToken
c.Cookie = "PVEAuthCookie=" + result.Data.Ticket
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/" + c.NodeName + "/qemu/" + vmid + "/status/current"
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)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body failed: %v", err)
}
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.Unmarshal(body, &result); err != nil {
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/" + c.NodeName + "/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/" + c.NodeName + "/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/nodes/" + c.NodeName + "/qemu"
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 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("get vm list failed, status: %d, body: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read vm list body failed: %v", err)
}
var result struct {
Data []struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("decode vm list failed: %v, body: %s", err, string(body))
}
// vmid 是 int转换为 string
vms := make([]struct {
VMID string `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
}, len(result.Data))
for i, vm := range result.Data {
vms[i] = struct {
VMID string `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
}{
VMID: strconv.Itoa(vm.VMID),
Name: vm.Name,
Status: vm.Status,
}
}
return vms, nil
}
// PVEHostService PVE 主机服务
type PVEHostService struct{}
func NewPVEHostService() *PVEHostService {
return &PVEHostService{}
}
// GetAll 获取所有 PVE 主机
func (s *PVEHostService) GetAll() ([]models.PVEHost, error) {
rows, err := db.DB.Query(`SELECT id, name, hostname, port, username, node_name, password_enc, verify_ssl, created_at, updated_at FROM pve_hosts ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
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.NodeName, &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.DB.QueryRow(`SELECT id, name, hostname, port, username, node_name, 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.NodeName, &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.DB.Exec(`INSERT INTO pve_hosts (name, hostname, port, username, node_name, password_enc, verify_ssl) VALUES (?, ?, ?, ?, ?, ?, ?)`,
host.Name, host.Hostname, host.Port, host.Username, host.NodeName, 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.DB.Exec(`UPDATE pve_hosts SET name=?, hostname=?, port=?, username=?, node_name=?, password_enc=?, verify_ssl=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
host.Name, host.Hostname, host.Port, host.Username, host.NodeName, encPassword, boolToInt(host.VerifySSL), host.ID)
return err
}
_, err := db.DB.Exec(`UPDATE pve_hosts SET name=?, hostname=?, port=?, username=?, node_name=?, verify_ssl=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
host.Name, host.Hostname, host.Port, host.Username, host.NodeName, boolToInt(host.VerifySSL), host.ID)
return err
}
// Delete 删除 PVE 主机
func (s *PVEHostService) Delete(id int64) error {
// 先清除关联机器的 PVE 字段
if _, err := db.DB.Exec(`UPDATE machines SET pve_host_id = NULL, pve_vmid = NULL WHERE pve_host_id = ?`, id); err != nil {
return err
}
_, err := db.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)
}
// GetHostStatus 检测 PVE 节点连接状态
func (s *PVEHostService) GetHostStatus(hostID int64) (string, error) {
host, err := s.GetByID(hostID)
if err != nil {
return "offline", err
}
password, err := s.GetDecryptedPassword(host)
if err != nil {
return "offline", err
}
client := NewPVEClient(host, password)
if err := client.Login(); err != nil {
return "offline", nil
}
return "online", nil
}
// GetHostVMList 获取 PVE 节点上的虚拟机列表
func (s *PVEHostService) GetHostVMList(hostID int64) ([]struct {
VMID string `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
}, 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.GetVMList()
}
// 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
}