package setup import ( "bufio" "context" "encoding/json" "fmt" "log" "os" "os/exec" "path/filepath" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" ) // DriveBackup represents a found infra backup on a drive. type DriveBackup struct { Device string `json:"device"` Label string `json:"label"` MountPoint string `json:"mount_point"` CustomerID string `json:"customer_id"` Timestamp string `json:"timestamp"` CtrlVersion string `json:"controller_version"` IntegrityOK bool `json:"integrity_ok"` Error string `json:"error,omitempty"` WasTempMounted bool `json:"-"` } // lsblkOutput represents the JSON output of lsblk. type lsblkOutput struct { Blockdevices []lsblkDevice `json:"blockdevices"` } type lsblkDevice struct { Name string `json:"name"` Path string `json:"path"` FSType *string `json:"fstype"` MountPoint *string `json:"mountpoint"` Label *string `json:"label"` Size interface{} `json:"size"` // string or int Type string `json:"type"` // "disk", "part" Children []lsblkDevice `json:"children,omitempty"` } // ScanDrivesForInfraBackups scans all block devices for .felhom-infra-backup/ directories. func ScanDrivesForInfraBackups(logger *log.Logger) ([]DriveBackup, error) { logger.Printf("[INFO] Setup: scanning drives for infra backups...") // Read currently mounted filesystems mountedFS := readMountedFilesystems() // Get root device to skip rootDevices := getRootDevices() // Run lsblk ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "lsblk", "-J", "-o", "NAME,PATH,FSTYPE,MOUNTPOINT,LABEL,SIZE,TYPE").Output() if err != nil { return nil, fmt.Errorf("lsblk failed: %w", err) } var lsblk lsblkOutput if err := json.Unmarshal(out, &lsblk); err != nil { return nil, fmt.Errorf("parsing lsblk: %w", err) } var results []DriveBackup // Flatten all partitions var partitions []lsblkDevice for _, disk := range lsblk.Blockdevices { if disk.Type == "part" { partitions = append(partitions, disk) } for _, child := range disk.Children { if child.Type == "part" { partitions = append(partitions, child) } } } for _, part := range partitions { // Skip partitions without filesystem if part.FSType == nil || *part.FSType == "" || *part.FSType == "swap" { continue } // Skip LUKS encrypted partitions if *part.FSType == "crypto_LUKS" { logger.Printf("[DEBUG] Setup: skipping LUKS partition %s", part.Path) continue } // Skip LVM if part.Type == "lvm" { logger.Printf("[DEBUG] Setup: skipping LVM volume %s", part.Path) continue } // Skip root partitions if isRootPartition(part.Path, rootDevices) { continue } result := scanPartition(part, mountedFS, logger) if result != nil { results = append(results, *result) } } logger.Printf("[INFO] Setup: drive scan complete — found %d backup(s)", countValid(results)) return results, nil } // CleanupTempMounts unmounts any partitions that were temporarily mounted during scanning. func CleanupTempMounts(results []DriveBackup, logger *log.Logger) { for _, r := range results { if r.WasTempMounted && r.MountPoint != "" { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) exec.CommandContext(ctx, "umount", r.MountPoint).Run() cancel() os.Remove(r.MountPoint) logger.Printf("[DEBUG] Setup: unmounted temp mount %s", r.MountPoint) } } } func scanPartition(part lsblkDevice, mountedFS map[string]string, logger *log.Logger) *DriveBackup { label := "" if part.Label != nil { label = *part.Label } // Check if already mounted var mountPoint string var tempMounted bool if part.MountPoint != nil && *part.MountPoint != "" { mountPoint = *part.MountPoint } else if mp, ok := mountedFS[part.Path]; ok { mountPoint = mp } else { // Try to mount temporarily tmpDir := filepath.Join("/mnt", ".felhom-scan", part.Name) if err := os.MkdirAll(tmpDir, 0700); err != nil { logger.Printf("[DEBUG] Setup: skip %s — cannot create temp dir: %v", part.Path, err) return nil } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Try read-only mount err := exec.CommandContext(ctx, "mount", "-o", "ro", part.Path, tmpDir).Run() if err != nil { // Retry with noload for journal errors err = exec.CommandContext(ctx, "mount", "-o", "ro,noload", part.Path, tmpDir).Run() } if err != nil { os.Remove(tmpDir) logger.Printf("[DEBUG] Setup: skip %s — mount failed: %v", part.Path, err) return nil } mountPoint = tmpDir tempMounted = true } // Check for .felhom-infra-backup/ infraDir := backup.InfraBackupDir(mountPoint) if _, err := os.Stat(infraDir); os.IsNotExist(err) { if tempMounted { exec.Command("umount", mountPoint).Run() os.Remove(mountPoint) } return nil } // Found backup — read and validate _, meta, err := backup.ReadLocalInfraBackup(mountPoint) result := &DriveBackup{ Device: part.Path, Label: label, MountPoint: mountPoint, WasTempMounted: tempMounted, } if err != nil { result.IntegrityOK = false result.Error = err.Error() if meta != nil { result.CustomerID = meta.CustomerID result.Timestamp = meta.Timestamp result.CtrlVersion = meta.ControllerVersion } } else { result.IntegrityOK = true result.CustomerID = meta.CustomerID result.Timestamp = meta.Timestamp result.CtrlVersion = meta.ControllerVersion } logger.Printf("[INFO] Setup: found infra backup on %s (%s) — customer=%s, integrity=%v", part.Path, label, result.CustomerID, result.IntegrityOK) return result } func readMountedFilesystems() map[string]string { result := make(map[string]string) f, err := os.Open("/proc/mounts") if err != nil { return result } defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) >= 2 { result[fields[0]] = fields[1] } } return result } func getRootDevices() map[string]bool { result := make(map[string]bool) mountedFS := readMountedFilesystems() for dev, mp := range mountedFS { if mp == "/" || mp == "/boot" || mp == "/boot/efi" { result[dev] = true } } return result } func isRootPartition(devPath string, rootDevices map[string]bool) bool { return rootDevices[devPath] } func countValid(results []DriveBackup) int { n := 0 for _, r := range results { if r.IntegrityOK { n++ } } return n } // runDriveScan runs the scan asynchronously and stores results on the Server. func (s *Server) runDriveScan() { results, err := ScanDrivesForInfraBackups(s.logger) s.scanMu.Lock() defer s.scanMu.Unlock() s.scanRunning = false s.scanDone = true if err != nil { s.scanError = err.Error() } else { s.scanResults = results } }