package handlers import ( "database/sql" "encoding/json" "net/http" "strconv" "time" "github.com/gin-gonic/gin" "lan-manager/server/db" "lan-manager/server/middleware" "lan-manager/server/models" "lan-manager/server/services" "lan-manager/server/utils" ) type MachineHandler struct{} func NewMachineHandler() *MachineHandler { return &MachineHandler{} } // sanitizeMachine removes sensitive fields for guest users func sanitizeMachine(m *models.Machine) { m.IP = "" m.MAC = models.NullString{} m.Notes = models.NullString{} m.SSHUsername = models.NullString{} m.SSHPassword = models.NullString{} 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) { isAdmin := middleware.IsAdmin(c) 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, pve_host_id, pve_vmid FROM machines WHERE 1=1` args := []interface{}{} if osFilter != "" { query += ` AND os_type = ?` args = append(args, osFilter) } if search != "" { if isAdmin { query += ` AND (hostname LIKE ? OR ip LIKE ?)` args = append(args, "%"+search+"%", "%"+search+"%") } else { query += ` AND hostname LIKE ?` args = append(args, "%"+search+"%") } } query += ` ORDER BY created_at DESC` rows, err := db.DB.Query(query, args...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } defer rows.Close() machines := []models.Machine{} for rows.Next() { 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, &m.PVEHostID, &m.PVEVMID) if err != nil { continue } m.LastPingAt = lp m.SSHSyncedAt = ss m.LastOfflineAt = lo m.SSHPassword = models.NullString{} // never return password if !isAdmin { sanitizeMachine(&m) } machines = append(machines, m) } rows.Close() // Fetch service counts if len(machines) > 0 { scRows, err := db.DB.Query(`SELECT machine_id, COUNT(*) FROM services GROUP BY machine_id`) if err == nil { defer scRows.Close() for scRows.Next() { var mid int64 var cnt int if err := scRows.Scan(&mid, &cnt); err == nil { for i := range machines { if machines[i].ID == mid { machines[i].ServiceCount = cnt break } } } } } } c.JSON(http.StatusOK, machines) } func (h *MachineHandler) Get(c *gin.Context) { isAdmin := middleware.IsAdmin(c) id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } 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, 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"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } m.LastPingAt = lp m.SSHSyncedAt = ss services := []models.Service{} if rows, err := db.DB.Query(`SELECT id, machine_id, name, port, protocol, notes FROM services WHERE machine_id = ?`, id); err == nil { defer rows.Close() for rows.Next() { var s models.Service if err := rows.Scan(&s.ID, &s.MachineID, &s.Name, &s.Port, &s.Protocol, &s.Notes); err == nil { if !isAdmin { s.Notes = "" } services = append(services, s) } } } relationships := []models.Relationship{} if isAdmin { if rows, err := db.DB.Query(`SELECT id, source_machine_id, target_machine_id, relation_type, source_port, target_port, notes FROM relationships WHERE source_machine_id = ? OR target_machine_id = ?`, id, id); err == nil { defer rows.Close() for rows.Next() { var r models.Relationship if err := rows.Scan(&r.ID, &r.SourceMachineID, &r.TargetMachineID, &r.RelationType, &r.SourcePort, &r.TargetPort, &r.Notes); err == nil { relationships = append(relationships, r) } } } } m.SSHPassword = models.NullString{} // never return password if !isAdmin { sanitizeMachine(&m) } c.JSON(http.StatusOK, gin.H{ "machine": m, "services": services, "relationships": relationships, }) } func (h *MachineHandler) OfflineLogs(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } rows, err := db.DB.Query(`SELECT id, machine_id, reason, started_at, ended_at, duration_seconds FROM offline_logs WHERE machine_id = ? ORDER BY started_at DESC LIMIT 50`, id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } defer rows.Close() logs := []models.OfflineLog{} for rows.Next() { var l models.OfflineLog var endedAt *time.Time var dur *int if err := rows.Scan(&l.ID, &l.MachineID, &l.Reason, &l.StartedAt, &endedAt, &dur); err == nil { l.EndedAt = endedAt l.DurationSeconds = dur logs = append(logs, l) } } c.JSON(http.StatusOK, logs) } func (h *MachineHandler) Create(c *gin.Context) { var m models.Machine if err := c.ShouldBindJSON(&m); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } encPass, _ := utils.Encrypt(m.SSHPassword.String) 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, 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 } id, _ := res.LastInsertId() m.ID = id m.CreatedAt = time.Now() m.UpdatedAt = time.Now() middleware.LogOperation("create", "machine", &m.ID, m.Hostname, "", middleware.ToJSON(m), c.ClientIP(), middleware.CurrentUser(c)) c.JSON(http.StatusCreated, m) } func (h *MachineHandler) Update(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var m models.Machine if err := c.ShouldBindJSON(&m); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var old models.Machine var oldPass string var lp, ss *time.Time 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 if m.SSHPassword.String != "" { encPass, err := utils.Encrypt(m.SSHPassword.String) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "encrypt failed"}) return } passToSave = encPass } // 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 } m.ID = id old.SSHPassword = models.NullString{} m.SSHPassword = models.NullString{} middleware.LogOperation("update", "machine", &m.ID, m.Hostname, middleware.ToJSON(old), middleware.ToJSON(m), c.ClientIP(), middleware.CurrentUser(c)) c.JSON(http.StatusOK, m) } func (h *MachineHandler) Delete(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var name string _ = db.DB.QueryRow(`SELECT hostname FROM machines WHERE id = ?`, id).Scan(&name) _, err = db.DB.Exec(`DELETE FROM machines WHERE id = ?`, id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } middleware.LogOperation("delete", "machine", &id, name, "", "", c.ClientIP(), middleware.CurrentUser(c)) c.JSON(http.StatusOK, gin.H{"message": "deleted"}) } func (h *MachineHandler) SSHInfo(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var req models.SSHInfoRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var ip string var sshPort int var savedUser, savedPass string if err := db.DB.QueryRow(`SELECT ip, ssh_port, ssh_username, ssh_password FROM machines WHERE id = ?`, id).Scan(&ip, &sshPort, &savedUser, &savedPass); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "machine not found"}) return } user := req.Username pass := req.Password // 如果前端没传密码,尝试用数据库保存的凭据 if pass == "" { if savedUser == "" || savedPass == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "未保存 SSH 凭据,请手动输入"}) return } plainPass, decErr := utils.Decrypt(savedPass) if decErr != nil || plainPass == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "未保存 SSH 凭据,请手动输入"}) return } user = savedUser pass = plainPass } result, err := services.GetSSHInfo(ip, sshPort, user, pass) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Save credentials to machine record for auto-sync (only when user explicitly provided password) if req.Password != "" { encPass, _ := utils.Encrypt(req.Password) _, _ = db.DB.Exec(`UPDATE machines SET ssh_username=?, ssh_password=? WHERE id=?`, req.Username, encPass, id) } c.JSON(http.StatusOK, result) } func (h *MachineHandler) SyncSSH(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var req models.SSHInfoResult if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } portsStr := "" if len(req.ListenPorts) > 0 { b, _ := json.Marshal(req.ListenPorts) portsStr = string(b) // remove brackets if len(portsStr) > 2 { portsStr = portsStr[1 : len(portsStr)-1] } } _, err = db.DB.Exec(`UPDATE machines SET os_version=?, cpu_info=?, memory_info=?, disk_info=?, uptime=?, listen_ports=?, ssh_synced_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?`, req.OsVersion, req.RawCPUInfo, req.RawMemoryInfo, req.RawDiskInfo, req.Uptime, portsStr, id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } middleware.LogOperation("update", "machine", &id, "sync-ssh", "", "", c.ClientIP(), middleware.CurrentUser(c)) c.JSON(http.StatusOK, gin.H{"message": "synced"}) }