diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..4c0bde6
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,68 @@
+# Git
+.git
+.gitignore
+.gitattributes
+
+# Node.js
+web/node_modules/
+web/dist/
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Go
+server/lan-manager
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+*.test
+*.out
+vendor/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+.DS_Store
+
+# 构建产物
+lan-manager
+lan-manager-darwin
+lan-manager-linux
+lan-manager.exe
+server/static/
+
+# 数据(构建时不需要,运行时通过 volume 挂载)
+data/
+*.db
+*.db-journal
+*.db-wal
+*.db-shm
+
+# 部署包
+deploy/
+*.tar.gz
+
+# 日志
+*.log
+logs/
+
+# Playwright MCP
+.playwright-mcp/
+
+# 测试
+coverage/
+*.cover
+*.coverage
+
+# 文档(构建时不需要)
+docs/
+
+# 临时文件
+tmp/
+temp/
+*.tmp
diff --git a/.qwen/settings.json b/.qwen/settings.json
new file mode 100644
index 0000000..316bfa1
--- /dev/null
+++ b/.qwen/settings.json
@@ -0,0 +1,16 @@
+{
+ "permissions": {
+ "allow": [
+ "Read(//Users/songshiyu/fsdownload/**)",
+ "Read(//Users/songshiyu/Downloads/**)",
+ "WebFetch(docs.qwen.ai)",
+ "WebFetch(github.com)",
+ "Bash(qwen *)",
+ "Bash(find *)",
+ "Bash(npm *)",
+ "Bash(ls *)",
+ "Bash(npx *)"
+ ]
+ },
+ "$version": 3
+}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..f7962a9
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,83 @@
+# 构建阶段 1:构建前端
+FROM node:20-alpine AS web-builder
+
+WORKDIR /app/web
+
+# 复制前端依赖文件
+COPY web/package*.json ./
+RUN npm ci
+
+# 复制前端源码并构建
+COPY web/ ./
+RUN npm run build
+
+# 构建阶段 2:构建 Go 后端
+FROM golang:1.26-alpine AS server-builder
+
+WORKDIR /app/server
+
+# 安装构建依赖
+RUN apk add --no-cache git gcc musl-dev
+
+# 复制 Go 模块文件
+COPY go.mod go.sum ./
+RUN go mod download
+
+# 复制后端源码
+COPY server/ ./
+
+# 复制前端构建产物到 static 目录
+COPY --from=web-builder /app/web/dist ./static/
+
+# 构建 Go 二进制(静态链接,无 CGO)
+RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/lan-manager .
+
+# 运行阶段:最小化镜像
+FROM alpine:3.21
+
+LABEL maintainer="LAN Manager"
+LABEL description="局域网机器管理后台"
+
+WORKDIR /app
+
+# 安装必要的运行时依赖
+# iputils 提供 ping 命令(用于网络检测)
+# ca-certificates 用于 HTTPS 请求
+RUN apk add --no-cache \
+ ca-certificates \
+ iputils \
+ libcap \
+ && rm -rf /var/cache/apk/*
+
+# 从构建阶段复制二进制文件
+COPY --from=server-builder /app/lan-manager /app/lan-manager
+
+# 赋予 ping 权限(不需要 root 也能执行 ICMP ping)
+RUN setcap cap_net_raw+ep /bin/ping || true
+
+# 创建非 root 用户运行服务
+RUN adduser -D -s /bin/sh lanmgr && \
+ mkdir -p /app/data && \
+ chown -R lanmgr:lanmgr /app
+
+# 切换到非 root 用户
+USER lanmgr
+
+# 暴露端口
+EXPOSE 8080
+
+# 健康检查
+HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1
+
+# 设置数据目录环境变量
+ENV DATA_DIR=/app/data
+ENV DB_PATH=/app/data/lan-manager.db
+ENV HOST=0.0.0.0
+ENV PORT=8080
+
+# 数据卷(持久化 SQLite 数据库)
+VOLUME ["/app/data"]
+
+# 启动命令
+ENTRYPOINT ["/app/lan-manager"]
diff --git a/README.md b/README.md
index 16ad01d..e03e219 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,52 @@ make build
./lan-manager
```
+## Docker 部署(推荐)
+
+### 方式一:使用 Docker Compose(推荐)
+
+```bash
+# 1. 克隆项目后,直接启动
+docker-compose up -d
+
+# 2. 查看日志
+docker-compose logs -f
+
+# 3. 停止服务
+docker-compose down
+```
+
+服务将运行在 `http://localhost:8080`。
+
+数据持久化在 `./data/` 目录(通过 volume 挂载)。
+
+### 方式二:手动构建和运行
+
+```bash
+# 构建镜像
+docker build -t lan-manager .
+
+# 运行容器
+docker run -d \
+ --name lan-manager \
+ -p 8080:8080 \
+ -v $(pwd)/data:/app/data \
+ -e ADMIN_PASS=your-secure-password \
+ -e ENCRYPT_KEY=your-encrypt-key \
+ lan-manager
+```
+
+### 方式三:使用预构建镜像(如果已发布到镜像仓库)
+
+```bash
+docker run -d \
+ --name lan-manager \
+ -p 8080:8080 \
+ -v $(pwd)/data:/app/data \
+ -e ADMIN_PASS=your-secure-password \
+ your-registry/lan-manager:latest
+```
+
## Debian 12 一键部署
```bash
@@ -122,6 +168,8 @@ lan-manager/
├── web/ # Vue 前端
│ ├── src/
│ └── dist/
+├── Dockerfile # Docker 构建文件
+├── docker-compose.yml # Docker Compose 配置
├── Makefile
├── go.mod
└── README.md
@@ -134,3 +182,10 @@ lan-manager/
- **自动同步 SSH**:若机器已保存 SSH 用户名和密码,后台每次 Ping 检测成功后会自动通过 SSH 抓取系统信息并同步到数据库
- **在线检测机制**:每分钟对所有机器 IP 执行 ICMP Ping,3 秒内无响应视为离线
- **磁盘显示策略**:机器列表卡片仅展示 `/` 根分区占用;详情页展示所有真实磁盘分区
+
+## Docker 部署注意事项
+
+1. **数据持久化**:通过 volume 挂载 `./data` 目录到容器,确保数据在容器重建后不丢失
+2. **网络访问**:容器需要访问宿主机网络或目标局域网,默认使用 bridge 网络模式
+3. **Ping 权限**:容器内可能需要 `NET_RAW` 能力才能执行 ICMP ping,已在 Dockerfile 中通过 `setcap` 配置
+4. **环境变量**:建议通过 `.env` 文件或 Docker Compose 管理敏感配置
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..1a7b237
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,53 @@
+version: '3.8'
+
+services:
+ lan-manager:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: lan-manager:latest
+ container_name: lan-manager
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ volumes:
+ # 数据持久化:SQLite 数据库和上传文件
+ - ./data:/app/data
+ environment:
+ # 基础配置
+ - HOST=0.0.0.0
+ - PORT=8080
+ - DATA_DIR=/app/data
+ - DB_PATH=/app/data/lan-manager.db
+
+ # 管理员配置(⚠️ 生产环境务必修改)
+ - ADMIN_USER=admin
+ - ADMIN_PASS=admin
+
+ # 安全密钥(⚠️ 生产环境务必修改)
+ - SESSION_SECRET=lan-manager-secret-change-in-production
+ - ENCRYPT_KEY=lan-manager-default-key-change-in-production
+
+ # 功能配置
+ - PING_INTERVAL=60
+ - SSH_TIMEOUT=10
+ - LOG_RETENTION_DAYS=0
+ - UI_REFRESH_INTERVAL=10000
+
+ # 日志级别
+ - LOG_LEVEL=info
+
+ # 如果需要容器能 ping 通局域网其他机器,可以使用 host 网络模式
+ # 或者使用 macvlan 网络让容器获得独立 IP
+ # network_mode: host
+
+ # 如果需要 ICMP ping 权限,可以添加 capabilities
+ # cap_add:
+ # - NET_RAW
+
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/health"]
+ interval: 30s
+ timeout: 5s
+ start_period: 10s
+ retries: 3
diff --git a/server/config/config.go b/server/config/config.go
index d1adec4..07c6970 100644
--- a/server/config/config.go
+++ b/server/config/config.go
@@ -1,6 +1,7 @@
package config
import (
+ "database/sql"
"log"
"os"
"strconv"
@@ -47,6 +48,14 @@ func Load() *Config {
return cfg
}
+func LoadPasswordFromDB(cfg *Config, db *sql.DB) {
+ var stored string
+ err := db.QueryRow(`SELECT value FROM settings WHERE key = 'admin_password'`).Scan(&stored)
+ if err == nil && stored != "" {
+ cfg.AdminPass = stored
+ }
+}
+
func getEnv(key, defaultValue string) string {
if v := os.Getenv(key); v != "" {
return v
diff --git a/server/db/db.go b/server/db/db.go
index 5c42d5a..759b399 100644
--- a/server/db/db.go
+++ b/server/db/db.go
@@ -107,6 +107,26 @@ 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)`,
+ `CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT
+ )`,
+ // 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,
+ node_name TEXT DEFAULT 'pve',
+ 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`,
+ `ALTER TABLE machines ADD COLUMN pve_vm_status TEXT`,
}
for _, s := range stmts {
if _, err := DB.Exec(s); err != nil {
diff --git a/server/handlers/auth.go b/server/handlers/auth.go
index 9af47e1..70a103a 100644
--- a/server/handlers/auth.go
+++ b/server/handlers/auth.go
@@ -5,7 +5,9 @@ import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
+ "golang.org/x/crypto/bcrypt"
"lan-manager/server/config"
+ "lan-manager/server/db"
"lan-manager/server/middleware"
"lan-manager/server/models"
)
@@ -24,7 +26,11 @@ func (h *AuthHandler) Login(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- if req.Username != h.Cfg.AdminUser || req.Password != h.Cfg.AdminPass {
+ if req.Username != h.Cfg.AdminUser {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
+ return
+ }
+ if !checkPassword(req.Password, h.Cfg.AdminPass) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
@@ -53,3 +59,41 @@ func (h *AuthHandler) Me(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"is_admin": true, "username": user.(string), "ui_refresh_interval": h.Cfg.UIRefreshInterval})
}
+
+func (h *AuthHandler) ChangePassword(c *gin.Context) {
+ var req models.ChangePasswordRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if !checkPassword(req.OldPassword, h.Cfg.AdminPass) {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "旧密码错误"})
+ return
+ }
+
+ hashed, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
+ return
+ }
+
+ _, err = db.DB.Exec(`INSERT INTO settings (key, value) VALUES ('admin_password', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, string(hashed))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "保存密码失败"})
+ return
+ }
+
+ h.Cfg.AdminPass = string(hashed)
+ c.JSON(http.StatusOK, gin.H{"message": "密码修改成功"})
+}
+
+func checkPassword(plain, stored string) bool {
+ if stored == "" {
+ return plain == ""
+ }
+ // bcrypt hashes start with "$2a$", "$2b$", or "$2x$" and have a fixed format
+ if len(stored) > 4 && (stored[:4] == "$2a$" || stored[:4] == "$2b$" || stored[:4] == "$2x$") {
+ return bcrypt.CompareHashAndPassword([]byte(stored), []byte(plain)) == nil
+ }
+ return plain == stored
+}
diff --git a/server/handlers/machines.go b/server/handlers/machines.go
index 5b0fad7..50083bd 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
@@ -235,10 +237,17 @@ func (h *MachineHandler) Update(c *gin.Context) {
var old models.Machine
var oldPass string
var lp, ss *time.Time
- _ = 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, created_at, updated_at FROM machines WHERE id = ?`, id).
- Scan(&old.ID, &old.Hostname, &old.IP, &old.MAC, &old.OsType, &old.OsVersion, &old.Notes, &old.SSHPort, &old.SSHUsername, &oldPass, &old.IsOnline, &lp, &old.CPUInfo, &old.MemoryInfo, &old.DiskInfo, &old.Uptime, &old.ListenPorts, &ss, &old.CreatedAt, &old.UpdatedAt)
+ var oldPVEHostID sql.NullInt64
+ var oldPVEVMID models.NullString
+ _ = 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, created_at, updated_at, pve_host_id, pve_vmid FROM machines WHERE id = ?`, id).
+ Scan(&old.ID, &old.Hostname, &old.IP, &old.MAC, &old.OsType, &old.OsVersion, &old.Notes, &old.SSHPort, &old.SSHUsername, &oldPass, &old.IsOnline, &lp, &old.CPUInfo, &old.MemoryInfo, &old.DiskInfo, &old.Uptime, &old.ListenPorts, &ss, &old.CreatedAt, &old.UpdatedAt, &oldPVEHostID, &oldPVEVMID)
old.LastPingAt = lp
old.SSHSyncedAt = ss
+ if oldPVEHostID.Valid {
+ v := oldPVEHostID.Int64
+ old.PVEHostID = &v
+ }
+ old.PVEVMID = oldPVEVMID
// Keep old password if new one is empty
passToSave := oldPass
@@ -251,8 +260,18 @@ 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)
+ // Preserve PVE fields if not provided in request
+ pveHostID := m.PVEHostID
+ if pveHostID == nil && old.PVEHostID != nil {
+ pveHostID = old.PVEHostID
+ }
+ pveVMID := m.PVEVMID
+ if !pveVMID.Valid && old.PVEVMID.Valid {
+ pveVMID = old.PVEVMID
+ }
+
+ _, 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, pveHostID, 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..fef92f3
--- /dev/null
+++ b/server/handlers/pve.go
@@ -0,0 +1,339 @@
+package handlers
+
+import (
+ "database/sql"
+ "net/http"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+ "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(c *gin.Context) {
+ if !middleware.IsAdmin(c) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
+ return
+ }
+
+ hosts, err := h.service.GetAll()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ // 密码不返回给前端
+ for i := range hosts {
+ hosts[i].PasswordEnc = ""
+ }
+
+ c.JSON(http.StatusOK, hosts)
+}
+
+// Get 获取单个 PVE 主机
+func (h *PVEHandler) Get(c *gin.Context) {
+ if !middleware.IsAdmin(c) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
+ return
+ }
+
+ id, err := strconv.ParseInt(c.Param("id"), 10, 64)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
+ return
+ }
+
+ host, err := h.service.GetByID(id)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ host.PasswordEnc = ""
+ host.Password = ""
+ c.JSON(http.StatusOK, host)
+}
+
+// Create 创建 PVE 主机
+func (h *PVEHandler) Create(c *gin.Context) {
+ if !middleware.IsAdmin(c) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
+ return
+ }
+
+ var host models.PVEHost
+ if err := c.ShouldBindJSON(&host); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
+ return
+ }
+
+ if host.Name == "" || host.Hostname == "" || host.Username == "" || host.Password == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "missing required fields"})
+ return
+ }
+
+ if host.Port == 0 {
+ host.Port = 8006
+ }
+
+ if err := h.service.Create(&host); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ idPtr := host.ID
+ middleware.LogOperation("create", "pve_host", &idPtr, host.Name, "", host.Hostname+":"+strconv.Itoa(host.Port), c.ClientIP(), middleware.CurrentUser(c))
+
+ host.PasswordEnc = ""
+ host.Password = ""
+ c.JSON(http.StatusCreated, host)
+}
+
+// Update 更新 PVE 主机
+func (h *PVEHandler) Update(c *gin.Context) {
+ if !middleware.IsAdmin(c) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
+ return
+ }
+
+ id, err := strconv.ParseInt(c.Param("id"), 10, 64)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
+ return
+ }
+
+ var host models.PVEHost
+ if err := c.ShouldBindJSON(&host); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
+ return
+ }
+
+ host.ID = id
+
+ // 如果密码为空,说明不更新密码
+ if host.Password == "" {
+ existing, err := h.service.GetByID(id)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ host.PasswordEnc = existing.PasswordEnc
+ }
+
+ if err := h.service.Update(&host); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ middleware.LogOperation("update", "pve_host", &id, host.Name, "", host.Hostname+":"+strconv.Itoa(host.Port), c.ClientIP(), middleware.CurrentUser(c))
+
+ host.PasswordEnc = ""
+ c.JSON(http.StatusOK, host)
+}
+
+// Delete 删除 PVE 主机
+func (h *PVEHandler) Delete(c *gin.Context) {
+ if !middleware.IsAdmin(c) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
+ return
+ }
+
+ id, err := strconv.ParseInt(c.Param("id"), 10, 64)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
+ return
+ }
+
+ // 获取主机名用于日志
+ host, _ := h.service.GetByID(id)
+ hostName := ""
+ if host != nil {
+ hostName = host.Name
+ }
+
+ if err := h.service.Delete(id); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ middleware.LogOperation("delete", "pve_host", &id, hostName, "", "", c.ClientIP(), middleware.CurrentUser(c))
+
+ c.JSON(http.StatusOK, gin.H{"message": "deleted"})
+}
+
+// HostStatus 检测 PVE 节点连接状态
+func (h *PVEHandler) HostStatus(c *gin.Context) {
+ if !middleware.IsAdmin(c) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
+ return
+ }
+
+ id, err := strconv.ParseInt(c.Param("id"), 10, 64)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
+ return
+ }
+
+ status, err := h.service.GetHostStatus(id)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"status": "offline", "error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"status": status})
+}
+
+// HostVMList 获取 PVE 节点上的虚拟机列表
+func (h *PVEHandler) HostVMList(c *gin.Context) {
+ if !middleware.IsAdmin(c) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
+ return
+ }
+
+ id, err := strconv.ParseInt(c.Param("id"), 10, 64)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
+ return
+ }
+
+ vms, err := h.service.GetHostVMList(id)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, vms)
+}
+
+// VMStatus 获取虚拟机状态
+func (h *PVEHandler) VMStatus(c *gin.Context) {
+ if !middleware.IsAdmin(c) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
+ return
+ }
+
+ machineID, err := strconv.ParseInt(c.Param("id"), 10, 64)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
+ 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 {
+ c.JSON(http.StatusNotFound, gin.H{"error": "machine not found"})
+ return
+ }
+
+ if pveHostID == nil || !pveVMID.Valid || pveVMID.String == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "machine not linked to PVE VM"})
+ return
+ }
+
+ status, err := h.service.GetVMStatus(*pveHostID, pveVMID.String)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, status)
+}
+
+// VMStart 启动虚拟机
+func (h *PVEHandler) VMStart(c *gin.Context) {
+ if !middleware.IsAdmin(c) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
+ return
+ }
+
+ machineID, err := strconv.ParseInt(c.Param("id"), 10, 64)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
+ 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 {
+ c.JSON(http.StatusNotFound, gin.H{"error": "machine not found"})
+ return
+ }
+
+ if pveHostID == nil || !pveVMID.Valid || pveVMID.String == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "machine not linked to PVE VM"})
+ return
+ }
+
+ if err := h.service.StartVM(*pveHostID, pveVMID.String); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ // 记录操作日志
+ machineName := ""
+ db.DB.QueryRow("SELECT hostname FROM machines WHERE id = ?", machineID).Scan(&machineName)
+ idPtr := machineID
+ middleware.LogOperation("start", "vm", &idPtr, machineName, "", "vmid:"+pveVMID.String, c.ClientIP(), middleware.CurrentUser(c))
+
+ c.JSON(http.StatusOK, gin.H{"message": "started"})
+}
+
+// VMStop 停止虚拟机
+func (h *PVEHandler) VMStop(c *gin.Context) {
+ if !middleware.IsAdmin(c) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
+ return
+ }
+
+ machineID, err := strconv.ParseInt(c.Param("id"), 10, 64)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
+ 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 {
+ c.JSON(http.StatusNotFound, gin.H{"error": "machine not found"})
+ return
+ }
+
+ if pveHostID == nil || !pveVMID.Valid || pveVMID.String == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "machine not linked to PVE VM"})
+ return
+ }
+
+ if err := h.service.StopVM(*pveHostID, pveVMID.String); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ // 记录操作日志
+ machineName := ""
+ db.DB.QueryRow("SELECT hostname FROM machines WHERE id = ?", machineID).Scan(&machineName)
+ idPtr := machineID
+ middleware.LogOperation("stop", "vm", &idPtr, machineName, "", "vmid:"+pveVMID.String, c.ClientIP(), middleware.CurrentUser(c))
+
+ c.JSON(http.StatusOK, gin.H{"message": "stopped"})
+}
+
diff --git a/server/main.go b/server/main.go
index 19f06f0..2f2617d 100644
--- a/server/main.go
+++ b/server/main.go
@@ -33,6 +33,8 @@ func main() {
os.Exit(1)
}
+ config.LoadPasswordFromDB(cfg, db.DB)
+
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.RedirectTrailingSlash = false
@@ -55,6 +57,7 @@ func main() {
api.POST("/auth/login", authH.Login)
api.POST("/auth/logout", authH.Logout)
api.GET("/auth/me", authH.Me)
+ api.POST("/auth/change-password", middleware.AuthRequired(), authH.ChangePassword)
api.GET("/health", handlers.NewHealthHandler().Check)
@@ -94,6 +97,21 @@ 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("/pve/hosts/:id/status", pveH.HostStatus)
+ admin.GET("/pve/hosts/:id/vms", pveH.HostVMList)
+
+ // 虚拟机操作
+ 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)
@@ -115,9 +133,18 @@ func main() {
fileServer := http.FileServer(http.FS(staticFS))
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
- if path != "/" && path != "" && !strings.HasPrefix(path, "/assets/") && path != "/favicon.ico" {
- c.Request.URL.Path = "/"
+ // Handle assets at any path depth (e.g. /logs/assets/... from relative dynamic imports)
+ if idx := strings.Index(path, "/assets/"); idx >= 0 {
+ c.Request.URL.Path = path[idx:]
+ fileServer.ServeHTTP(c.Writer, c.Request)
+ return
}
+ if path == "/favicon.ico" {
+ fileServer.ServeHTTP(c.Writer, c.Request)
+ return
+ }
+ // SPA fallback: all other paths serve index.html
+ c.Request.URL.Path = "/"
fileServer.ServeHTTP(c.Writer, c.Request)
})
}
diff --git a/server/models/models.go b/server/models/models.go
index 0a3c35d..1d56ce4 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 {
@@ -108,6 +112,11 @@ type LoginRequest struct {
Password string `json:"password" binding:"required"`
}
+type ChangePasswordRequest struct {
+ OldPassword string `json:"old_password" binding:"required"`
+ NewPassword string `json:"new_password" binding:"required,min=6"`
+}
+
type SSHInfoRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password"`
@@ -125,3 +134,26 @@ 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"`
+ NodeName string `json:"node_name"`
+ VerifySSL bool `json:"verify_ssl"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ // 运行时使用,不存储
+ Password string `json:"password"`
+}
+
+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/ping.go b/server/services/ping.go
index c16c88b..b35ae75 100644
--- a/server/services/ping.go
+++ b/server/services/ping.go
@@ -280,6 +280,11 @@ func StartPingService(interval int) {
}
rows.Close()
+ if len(list) == 0 {
+ time.Sleep(step)
+ continue
+ }
+
fmt.Printf("[Ping] start round, %d machines, interval %v each\n", len(list), step)
for i := range list {
diff --git a/server/services/pve.go b/server/services/pve.go
new file mode 100644
index 0000000..1daa304
--- /dev/null
+++ b/server/services/pve.go
@@ -0,0 +1,501 @@
+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 始终使用 HTTPS,verify_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
+}
+
diff --git a/web/src/api/index.js b/web/src/api/index.js
index bbf8440..0a84597 100644
--- a/web/src/api/index.js
+++ b/web/src/api/index.js
@@ -2,6 +2,9 @@ import axios from 'axios'
import { refreshAuth } from '@/router'
import { ElMessage } from 'element-plus'
+export const changePassword = (data) => api.post('/auth/change-password', data)
+
+
const api = axios.create({
baseURL: '/api',
timeout: 30000,
@@ -58,6 +61,18 @@ export const deleteRelationship = (id) => api.delete(`/relationships/${id}`)
export const fetchLogs = (params) => api.get('/logs', { params })
+// PVE API
+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 fetchPVEHostStatus = (id) => api.get(`/pve/hosts/${id}/status`)
+export const fetchPVEHostVMs = (id) => api.get(`/pve/hosts/${id}/vms`)
+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`)
+
export const exportData = () => api.get('/export', { responseType: 'blob' })
export const importData = (formData) => api.post('/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
diff --git a/web/src/components/MainLayout.vue b/web/src/components/MainLayout.vue
index e141522..ce1ea25 100644
--- a/web/src/components/MainLayout.vue
+++ b/web/src/components/MainLayout.vue
@@ -28,6 +28,10 @@
{{ isAdmin ? '管理员' : '访客' }}
+
+
+ 修改密码
+
退出
@@ -40,15 +44,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 确定
+
+
+
diff --git a/web/src/views/MachineList.vue b/web/src/views/MachineList.vue
index 2e1f574..2915a92 100644
--- a/web/src/views/MachineList.vue
+++ b/web/src/views/MachineList.vue
@@ -33,6 +33,9 @@
{{ m.os_type }}
{{ m.os_version }}
{{ m.uptime }}
+
+ {{ m.pve_vm_status === 'running' ? 'VM运行中' : m.pve_vm_status === 'stopped' ? 'VM已停止' : 'VM检测中' }}
+
@@ -114,6 +117,15 @@
+ PVE 配置(可选)
+
+
+
+
+
+
+
+
取消
@@ -127,23 +139,31 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Plus, Search } from '@element-plus/icons-vue'
-import { fetchMachines, createMachine, updateMachine, checkAuth, uiRefreshInterval, exportData, importData } from '@/api'
+import { fetchMachines, createMachine, updateMachine, checkAuth, uiRefreshInterval, exportData, importData, fetchPVEHosts, fetchVMStatus } from '@/api'
import { getAuth } from '@/router'
import { ElMessage } from 'element-plus'
const router = useRouter()
const isAdmin = getAuth().is_admin
const machines = ref([])
+const pveHosts = ref([])
const search = ref('')
const osFilter = ref('')
const dialogVisible = ref(false)
-const editing = ref({ hostname: '', ip: '', mac: '', os_type: 'Linux', os_version: '', notes: '', ssh_port: 22, ssh_username: '', ssh_password: '' })
+const editing = ref({ hostname: '', ip: '', mac: '', os_type: 'Linux', os_version: '', notes: '', ssh_port: 22, ssh_username: '', ssh_password: '', pve_host_id: null, pve_vmid: '' })
const importFileRef = ref(null)
let timer = null
onMounted(async () => {
await load()
await checkAuth()
+ // 加载 PVE 主机列表
+ if (isAdmin) {
+ try {
+ const res = await fetchPVEHosts()
+ pveHosts.value = res.data
+ } catch (e) {}
+ }
const interval = uiRefreshInterval || 10000
if (interval > 0) {
timer = setInterval(load, interval)
@@ -157,6 +177,18 @@ onUnmounted(() => {
async function load() {
const res = await fetchMachines({ search: search.value, os_type: osFilter.value })
machines.value = res.data
+ // 并行查询有关联 PVE 的机器的 VM 实时状态
+ if (isAdmin) {
+ const pveMachines = machines.value.filter(m => m.pve_host_id && m.pve_vmid)
+ await Promise.all(pveMachines.map(async (m) => {
+ try {
+ const statusRes = await fetchVMStatus(m.id)
+ m.pve_vm_status = statusRes.data.status
+ } catch (e) {
+ m.pve_vm_status = 'unknown'
+ }
+ }))
+ }
}
function goDetail(id) {
@@ -165,8 +197,8 @@ function goDetail(id) {
function openEdit(item) {
editing.value = item
- ? { ...item, ssh_port: item.ssh_port || 22, ssh_username: item.ssh_username || '', ssh_password: '' }
- : { hostname: '', ip: '', mac: '', os_type: 'Linux', os_version: '', notes: '', ssh_port: 22, ssh_username: '', ssh_password: '' }
+ ? { ...item, ssh_port: item.ssh_port || 22, ssh_username: item.ssh_username || '', ssh_password: '', pve_host_id: item.pve_host_id || null, pve_vmid: item.pve_vmid || '' }
+ : { hostname: '', ip: '', mac: '', os_type: 'Linux', os_version: '', notes: '', ssh_port: 22, ssh_username: '', ssh_password: '', pve_host_id: null, pve_vmid: '' }
dialogVisible.value = true
}
@@ -509,4 +541,28 @@ html.dark .status-badge.offline {
font-size: 11px;
color: var(--text-muted);
}
+
+.vm-status {
+ font-size: 11px;
+ font-weight: 600;
+ padding: 1px 7px;
+ border-radius: 999px;
+}
+.vm-status.running {
+ background: rgba(34,197,94,0.12);
+ color: #15803d;
+}
+.vm-status.stopped {
+ background: rgba(239,68,68,0.10);
+ color: #b91c1c;
+}
+html.dark .vm-status.running {
+ background: rgba(52,211,153,0.15);
+ color: #34d399;
+}
+html.dark .vm-status.stopped {
+ background: rgba(248,113,113,0.15);
+ color: #f87171;
+}
+