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 配置(可选) + + + + + + + +