From 8ef3e8c63117228975203fd1191dda8024a3b162 Mon Sep 17 00:00:00 2001 From: openclaw Date: Mon, 20 Apr 2026 13:07:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20PVE=20=E8=99=9A=E6=8B=9F=E6=9C=BA?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=20(F001)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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 🐾] --- server/db/db.go | 14 + server/handlers/machines.go | 18 +- server/handlers/pve.go | 266 +++++++++++++++++++ server/main.go | 13 + server/models/models.go | 26 ++ server/services/pve.go | 425 ++++++++++++++++++++++++++++++ web/src/api/index.js | 12 + web/src/components/MainLayout.vue | 6 +- web/src/router/index.js | 6 + web/src/views/MachineDetail.vue | 27 +- web/src/views/MachineList.vue | 28 +- web/src/views/PVEHosts.vue | 192 ++++++++++++++ 12 files changed, 1019 insertions(+), 14 deletions(-) create mode 100644 server/handlers/pve.go create mode 100644 server/services/pve.go create mode 100644 web/src/views/PVEHosts.vue diff --git a/server/db/db.go b/server/db/db.go index 5c42d5a..589018d 100644 --- a/server/db/db.go +++ b/server/db/db.go @@ -107,6 +107,20 @@ func migrate() error { )`, `CREATE INDEX IF NOT EXISTS idx_offline_logs_machine ON offline_logs(machine_id)`, `CREATE INDEX IF NOT EXISTS idx_offline_logs_started ON offline_logs(started_at)`, + // PVE 相关表 + `CREATE TABLE IF NOT EXISTS pve_hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + hostname TEXT NOT NULL, + port INTEGER DEFAULT 8006, + username TEXT NOT NULL, + password_enc TEXT NOT NULL, + verify_ssl INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `ALTER TABLE machines ADD COLUMN pve_host_id INTEGER REFERENCES pve_hosts(id) ON DELETE SET NULL`, + `ALTER TABLE machines ADD COLUMN pve_vmid TEXT`, } for _, s := range stmts { if _, err := DB.Exec(s); err != nil { diff --git a/server/handlers/machines.go b/server/handlers/machines.go index 5b0fad7..db95ed2 100644 --- a/server/handlers/machines.go +++ b/server/handlers/machines.go @@ -31,6 +31,8 @@ func sanitizeMachine(m *models.Machine) { m.ListenPorts = models.NullString{} m.CreatedAt = time.Time{} m.UpdatedAt = time.Time{} + m.PVEHostID = nil + m.PVEVMID = models.NullString{} } func (h *MachineHandler) List(c *gin.Context) { @@ -38,7 +40,7 @@ func (h *MachineHandler) List(c *gin.Context) { osFilter := c.Query("os_type") search := c.Query("search") - query := `SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, offline_count, total_offline_seconds, last_offline_at, last_offline_reason, created_at, updated_at FROM machines WHERE 1=1` + query := `SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, offline_count, total_offline_seconds, last_offline_at, last_offline_reason, created_at, updated_at, pve_host_id, pve_vmid FROM machines WHERE 1=1` args := []interface{}{} if osFilter != "" { query += ` AND os_type = ?` @@ -67,7 +69,7 @@ func (h *MachineHandler) List(c *gin.Context) { var m models.Machine var lp, ss *time.Time var lo *time.Time - err := rows.Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.OfflineCount, &m.TotalOfflineSeconds, &lo, &m.LastOfflineReason, &m.CreatedAt, &m.UpdatedAt) + err := rows.Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.OfflineCount, &m.TotalOfflineSeconds, &lo, &m.LastOfflineReason, &m.CreatedAt, &m.UpdatedAt, &m.PVEHostID, &m.PVEVMID) if err != nil { continue } @@ -116,8 +118,8 @@ func (h *MachineHandler) Get(c *gin.Context) { var m models.Machine var lp, ss *time.Time var lo *time.Time - err = db.DB.QueryRow(`SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, offline_count, total_offline_seconds, last_offline_at, last_offline_reason, created_at, updated_at FROM machines WHERE id = ?`, id). - Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.OfflineCount, &m.TotalOfflineSeconds, &lo, &m.LastOfflineReason, &m.CreatedAt, &m.UpdatedAt) + err = db.DB.QueryRow(`SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, offline_count, total_offline_seconds, last_offline_at, last_offline_reason, created_at, updated_at, pve_host_id, pve_vmid FROM machines WHERE id = ?`, id). + Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.OfflineCount, &m.TotalOfflineSeconds, &lo, &m.LastOfflineReason, &m.CreatedAt, &m.UpdatedAt, &m.PVEHostID, &m.PVEVMID) m.LastOfflineAt = lo if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) @@ -205,8 +207,8 @@ func (h *MachineHandler) Create(c *gin.Context) { passField := models.NullString{} passField.String = encPass passField.Valid = encPass != "" - res, err := db.DB.Exec(`INSERT INTO machines (hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - m.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, passField) + res, err := db.DB.Exec(`INSERT INTO machines (hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, pve_host_id, pve_vmid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + m.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, passField, m.PVEHostID, m.PVEVMID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -251,8 +253,8 @@ func (h *MachineHandler) Update(c *gin.Context) { passToSave = encPass } - _, err = db.DB.Exec(`UPDATE machines SET hostname=?, ip=?, mac=?, os_type=?, os_version=?, notes=?, ssh_port=?, ssh_username=?, ssh_password=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`, - m.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, passToSave, id) + _, err = db.DB.Exec(`UPDATE machines SET hostname=?, ip=?, mac=?, os_type=?, os_version=?, notes=?, ssh_port=?, ssh_username=?, ssh_password=?, pve_host_id=?, pve_vmid=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`, + m.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, passToSave, m.PVEHostID, m.PVEVMID, id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/server/handlers/pve.go b/server/handlers/pve.go new file mode 100644 index 0000000..4d1dfc8 --- /dev/null +++ b/server/handlers/pve.go @@ -0,0 +1,266 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "net/http" + "strconv" + + "lan-manager/server/db" + "lan-manager/server/middleware" + "lan-manager/server/models" + "lan-manager/server/services" +) + +type PVEHandler struct { + service *services.PVEHostService +} + +func NewPVEHandler() *PVEHandler { + return &PVEHandler{ + service: services.NewPVEHostService(), + } +} + +// List 获取 PVE 主机列表 +func (h *PVEHandler) List(w http.ResponseWriter, r *http.Request) { + if !middleware.IsAdmin(r) { + http.Error(w, "admin only", http.StatusForbidden) + return + } + + hosts, err := h.service.GetAll() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // 密码不返回给前端 + for i := range hosts { + hosts[i].PasswordEnc = "" + } + + middleware.JSON(w, hosts) +} + +// Get 获取单个 PVE 主机 +func (h *PVEHandler) Get(w http.ResponseWriter, r *http.Request) { + if !middleware.IsAdmin(r) { + http.Error(w, "admin only", http.StatusForbidden) + return + } + + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + host, err := h.service.GetByID(id) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + host.PasswordEnc = "" + middleware.JSON(w, host) +} + +// Create 创建 PVE 主机 +func (h *PVEHandler) Create(w http.ResponseWriter, r *http.Request) { + if !middleware.IsAdmin(r) { + http.Error(w, "admin only", http.StatusForbidden) + return + } + + var host models.PVEHost + if err := json.NewDecoder(r.Body).Decode(&host); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + if host.Name == "" || host.Hostname == "" || host.Username == "" || host.Password == "" { + http.Error(w, "missing required fields", http.StatusBadRequest) + return + } + + if host.Port == 0 { + host.Port = 8006 + } + + if err := h.service.Create(&host); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + host.PasswordEnc = "" + middleware.JSON(w, host) +} + +// Update 更新 PVE 主机 +func (h *PVEHandler) Update(w http.ResponseWriter, r *http.Request) { + if !middleware.IsAdmin(r) { + http.Error(w, "admin only", http.StatusForbidden) + return + } + + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var host models.PVEHost + if err := json.NewDecoder(r.Body).Decode(&host); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + host.ID = id + + // 如果密码为空,说明不更新密码 + if host.Password == "" { + existing, err := h.service.GetByID(id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + host.PasswordEnc = existing.PasswordEnc + } + + if err := h.service.Update(&host); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + host.PasswordEnc = "" + middleware.JSON(w, host) +} + +// Delete 删除 PVE 主机 +func (h *PVEHandler) Delete(w http.ResponseWriter, r *http.Request) { + if !middleware.IsAdmin(r) { + http.Error(w, "admin only", http.StatusForbidden) + return + } + + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + if err := h.service.Delete(id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + middleware.JSON(w, map[string]string{"message": "deleted"}) +} + +// VMStatus 获取虚拟机状态 +func (h *PVEHandler) VMStatus(w http.ResponseWriter, r *http.Request) { + if !middleware.IsAdmin(r) { + http.Error(w, "admin only", http.StatusForbidden) + return + } + + machineID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var pveHostID *int64 + var pveVMID sql.NullString + err = db.DB.QueryRow(`SELECT pve_host_id, pve_vmid FROM machines WHERE id = ?`, machineID).Scan(&pveHostID, &pveVMID) + if err != nil { + http.Error(w, "machine not found", http.StatusNotFound) + return + } + + if pveHostID == nil || !pveVMID.Valid || pveVMID.String == "" { + http.Error(w, "machine not linked to PVE VM", http.StatusBadRequest) + return + } + + status, err := h.service.GetVMStatus(*pveHostID, pveVMID.String) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + middleware.JSON(w, status) +} + +// VMStart 启动虚拟机 +func (h *PVEHandler) VMStart(w http.ResponseWriter, r *http.Request) { + if !middleware.IsAdmin(r) { + http.Error(w, "admin only", http.StatusForbidden) + return + } + + machineID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var pveHostID *int64 + var pveVMID sql.NullString + err = db.DB.QueryRow(`SELECT pve_host_id, pve_vmid FROM machines WHERE id = ?`, machineID).Scan(&pveHostID, &pveVMID) + if err != nil { + http.Error(w, "machine not found", http.StatusNotFound) + return + } + + if pveHostID == nil || !pveVMID.Valid || pveVMID.String == "" { + http.Error(w, "machine not linked to PVE VM", http.StatusBadRequest) + return + } + + if err := h.service.StartVM(*pveHostID, pveVMID.String); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + middleware.JSON(w, map[string]string{"message": "started"}) +} + +// VMStop 停止虚拟机 +func (h *PVEHandler) VMStop(w http.ResponseWriter, r *http.Request) { + if !middleware.IsAdmin(r) { + http.Error(w, "admin only", http.StatusForbidden) + return + } + + machineID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var pveHostID *int64 + var pveVMID sql.NullString + err = db.DB.QueryRow(`SELECT pve_host_id, pve_vmid FROM machines WHERE id = ?`, machineID).Scan(&pveHostID, &pveVMID) + if err != nil { + http.Error(w, "machine not found", http.StatusNotFound) + return + } + + if pveHostID == nil || !pveVMID.Valid || pveVMID.String == "" { + http.Error(w, "machine not linked to PVE VM", http.StatusBadRequest) + return + } + + if err := h.service.StopVM(*pveHostID, pveVMID.String); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + middleware.JSON(w, map[string]string{"message": "stopped"}) +} diff --git a/server/main.go b/server/main.go index 19f06f0..08e11dd 100644 --- a/server/main.go +++ b/server/main.go @@ -94,6 +94,19 @@ func main() { admin.GET("/logs", handlers.NewLogHandler().List) + // PVE 主机管理 + pveH := handlers.NewPVEHandler() + admin.GET("/pve/hosts", pveH.List) + admin.GET("/pve/hosts/:id", pveH.Get) + admin.POST("/pve/hosts", pveH.Create) + admin.PUT("/pve/hosts/:id", pveH.Update) + admin.DELETE("/pve/hosts/:id", pveH.Delete) + + // 虚拟机操作 + admin.GET("/machines/:id/vm-status", pveH.VMStatus) + admin.POST("/machines/:id/vm-start", pveH.VMStart) + admin.POST("/machines/:id/vm-stop", pveH.VMStop) + eh := handlers.NewExportHandler() admin.GET("/export", eh.Export) admin.POST("/import", eh.Import) diff --git a/server/models/models.go b/server/models/models.go index 0a3c35d..52f126c 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -58,6 +58,10 @@ type Machine struct { TotalOfflineSeconds int `json:"total_offline_seconds"` LastOfflineAt *time.Time `json:"last_offline_at"` LastOfflineReason NullString `json:"last_offline_reason"` + // PVE 相关字段 + PVEHostID *int64 `json:"pve_host_id"` + PVEVMID NullString `json:"pve_vmid"` + PVEVMStatus string `json:"pve_vm_status"` // "running", "stopped", "" } type OfflineLog struct { @@ -125,3 +129,25 @@ type SSHInfoResult struct { RawMemoryInfo string `json:"raw_memory_info"` RawDiskInfo string `json:"raw_disk_info"` } + +type PVEHost struct { + ID int64 `json:"id"` + Name string `json:"name"` + Hostname string `json:"hostname"` + Port int `json:"port"` + Username string `json:"username"` + PasswordEnc string `json:"password_enc"` + VerifySSL bool `json:"verify_ssl"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + // 运行时使用,不存储 + Password string `json:"-"` +} + +type PVEVMStatus struct { + Status string `json:"status"` // "running", "stopped" + Uptime int64 `json:"uptime"` + CPU float64 `json:"cpu"` + MemoryUsed int64 `json:"memory_used"` + MemoryTotal int64 `json:"memory_total"` +} diff --git a/server/services/pve.go b/server/services/pve.go new file mode 100644 index 0000000..c93dd3c --- /dev/null +++ b/server/services/pve.go @@ -0,0 +1,425 @@ +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 +} diff --git a/web/src/api/index.js b/web/src/api/index.js index bbf8440..3e66162 100644 --- a/web/src/api/index.js +++ b/web/src/api/index.js @@ -62,3 +62,15 @@ export const exportData = () => api.get('/export', { responseType: 'blob' }) export const importData = (formData) => api.post('/import', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }) + +// PVE 主机管理 +export const fetchPVEHosts = () => api.get('/pve/hosts') +export const fetchPVEHost = (id) => api.get(`/pve/hosts/${id}`) +export const createPVEHost = (data) => api.post('/pve/hosts', data) +export const updatePVEHost = (id, data) => api.put(`/pve/hosts/${id}`, data) +export const deletePVEHost = (id) => api.delete(`/pve/hosts/${id}`) + +// 虚拟机操作 +export const fetchVMStatus = (machineId) => api.get(`/machines/${machineId}/vm-status`) +export const startVM = (machineId) => api.post(`/machines/${machineId}/vm-start`) +export const stopVM = (machineId) => api.post(`/machines/${machineId}/vm-stop`) diff --git a/web/src/components/MainLayout.vue b/web/src/components/MainLayout.vue index e141522..e1aa0bc 100644 --- a/web/src/components/MainLayout.vue +++ b/web/src/components/MainLayout.vue @@ -18,6 +18,10 @@ 操作日志 + + + PVE 主机 +
@@ -46,7 +50,7 @@ + +