//go:build linux package storage import ( "fmt" "os" "os/exec" "path/filepath" "strings" ) // MountRaw temporarily mounts a partition at a staging path for browsing. // The partition is mounted read-only so the user can inspect its contents // before choosing a subfolder for the final bind mount. // Returns the raw mount path (e.g., "/mnt/.felhom-raw/hdd_1"). func MountRaw(devicePath string) (string, error) { // --- Validate device path --- if !strings.HasPrefix(devicePath, "/dev/") { return "", fmt.Errorf("érvénytelen eszközútvonal: /dev/-vel kell kezdődnie") } if strings.Contains(devicePath, "..") { return "", fmt.Errorf("érvénytelen eszközútvonal: nem tartalmazhat ..-t") } if _, err := os.Stat(HostDevicePath(devicePath)); err != nil { return "", fmt.Errorf("az eszköz nem létezik: %s", devicePath) } isSystem, err := IsSystemDisk(devicePath) if err != nil { return "", fmt.Errorf("rendszermeghajtó ellenőrzése sikertelen: %w", err) } if isSystem { return "", fmt.Errorf("ez a rendszermeghajtó — nem csatolható") } mounted, err := IsDeviceMounted(devicePath) if err != nil { return "", fmt.Errorf("csatlakoztatási állapot ellenőrzése sikertelen: %w", err) } if mounted { return "", fmt.Errorf("az eszköz már csatlakoztatva van") } // --- Detect filesystem --- fsType, err := getBlkidValue(devicePath, "TYPE") if err != nil || fsType == "" { return "", fmt.Errorf("nincs fájlrendszer az eszközön (%s) — használja az inicializálás varázslót", devicePath) } // Get label for naming the raw mount directory label, _ := getBlkidValue(devicePath, "LABEL") uuid, _ := getBlkidValue(devicePath, "UUID") // Choose a directory name: prefer label, fall back to UUID prefix dirName := label if dirName == "" && uuid != "" { if len(uuid) > 8 { dirName = uuid[:8] } else { dirName = uuid } } if dirName == "" { dirName = filepath.Base(devicePath) // "sdb1" } rawPath := filepath.Join(RawMountBase, dirName) // Check if already raw-mounted (idempotent) if inUse, _ := IsMountPathInUse(rawPath); inUse { return rawPath, nil } // Create staging directory if err := os.MkdirAll(rawPath, 0755); err != nil { return "", fmt.Errorf("nem hozható létre a staging mappa: %w", err) } // Mount read-only for browsing if out, err := exec.Command("mount", "-t", fsType, "-o", "defaults,noatime,ro", HostDevicePath(devicePath), rawPath).CombinedOutput(); err != nil { os.Remove(rawPath) return "", fmt.Errorf("csatlakoztatás sikertelen: %s — %w", string(out), err) } return rawPath, nil } // ListDirectories returns the subdirectories at the given path. // Only directories are returned; files, symlinks, and "lost+found" are excluded. func ListDirectories(basePath string) ([]DirEntry, error) { // Security: only allow browsing under the raw mount staging area cleanPath := filepath.Clean(basePath) if !strings.HasPrefix(cleanPath, RawMountBase) { return nil, fmt.Errorf("érvénytelen útvonal: csak %s alatti mappák böngészhetők", RawMountBase) } entries, err := os.ReadDir(cleanPath) if err != nil { return nil, fmt.Errorf("mappa olvasása sikertelen: %w", err) } var dirs []DirEntry for _, e := range entries { if !e.IsDir() { continue } name := e.Name() // Skip lost+found and hidden directories if name == "lost+found" || strings.HasPrefix(name, ".") { continue } fullPath := filepath.Join(cleanPath, name) // Check if this directory has subdirectories hasChildren := false if subEntries, err := os.ReadDir(fullPath); err == nil { for _, se := range subEntries { if se.IsDir() && se.Name() != "lost+found" && !strings.HasPrefix(se.Name(), ".") { hasChildren = true break } } } dirs = append(dirs, DirEntry{ Name: name, Path: fullPath, HasChildren: hasChildren, }) } return dirs, nil } // CreateDirectory creates a new directory at basePath/name. // The raw mount is remounted read-write if needed. func CreateDirectory(basePath, name string) (string, error) { // Security: only allow creation under the raw mount staging area cleanBase := filepath.Clean(basePath) if !strings.HasPrefix(cleanBase, RawMountBase) { return "", fmt.Errorf("érvénytelen útvonal: csak %s alatti mappák módosíthatók", RawMountBase) } // Validate directory name (same rules as mount names) if err := ValidateMountName(name); err != nil { return "", fmt.Errorf("érvénytelen mappanév: %w", err) } targetPath := filepath.Join(cleanBase, name) // Check if already exists if fi, err := os.Stat(targetPath); err == nil { if fi.IsDir() { return "", fmt.Errorf("a mappa már létezik: %s", name) } return "", fmt.Errorf("a cél már létezik és nem mappa") } // Remount read-write (the raw mount is initially read-only) rawMountPoint := findRawMountPoint(cleanBase) if rawMountPoint != "" { _ = exec.Command("mount", "-o", "remount,rw", rawMountPoint).Run() } if err := os.MkdirAll(targetPath, 0755); err != nil { return "", fmt.Errorf("mappa létrehozása sikertelen: %w", err) } _ = exec.Command("chown", "1000:1000", targetPath).Run() return targetPath, nil } // FinalizeAttach creates the bind mount, fstab entries, and sets up permissions. // Progress updates are sent on the progress channel. // Returns the final mount path (/mnt/) on success. func FinalizeAttach(req AttachRequest, progress chan<- FormatProgress) (string, error) { send := func(step, msg string, pct int) { progress <- FormatProgress{Step: step, Message: msg, Percent: pct} } fail := func(step, msg string, err error) error { errStr := "" if err != nil { errStr = err.Error() } progress <- FormatProgress{Step: "error", Message: msg, Error: errStr, Percent: 0} return fmt.Errorf("%s: %w", msg, err) } dbg := func(format string, args ...interface{}) { if req.Logger != nil && req.Debug { req.Logger.Printf("[DEBUG] FinalizeAttach: "+format, args...) } } mountPath := "/mnt/" + req.MountName dbg("starting: device=%s mountName=%s subPath=%s", req.DevicePath, req.MountName, req.SubPath) // --- Step 1: Validate --- send("validating", "Paraméterek ellenőrzése...", 5) if err := ValidateMountName(req.MountName); err != nil { return "", fail("validating", "Érvénytelen csatlakoztatási név", err) } if !strings.HasPrefix(req.DevicePath, "/dev/") { return "", fail("validating", "Érvénytelen eszközútvonal", fmt.Errorf("must start with /dev/")) } if strings.Contains(req.DevicePath, "..") { return "", fail("validating", "Érvénytelen eszközútvonal", fmt.Errorf("must not contain ..")) } // Validate subpath is under the raw mount area cleanSub := filepath.Clean(req.SubPath) if !strings.HasPrefix(cleanSub, RawMountBase) { return "", fail("validating", "Érvénytelen almappa útvonal", fmt.Errorf("subpath must be under %s", RawMountBase)) } if _, err := os.Stat(cleanSub); err != nil { return "", fail("validating", "Az almappa nem létezik: "+cleanSub, err) } inUse, err := IsMountPathInUse(mountPath) if err != nil { return "", fail("validating", "Csatlakoztatási útvonal ellenőrzése sikertelen", err) } if inUse { return "", fail("validating", "A csatlakoztatási útvonal már használatban van: "+mountPath, fmt.Errorf("mount path in use")) } send("validating", "Ellenőrzés kész", 15) // --- Step 2: Ensure raw mount is read-write --- send("mounting", "Fájlrendszer előkészítése...", 20) rawMountPoint := findRawMountPoint(cleanSub) if rawMountPoint != "" { _ = exec.Command("mount", "-o", "remount,rw", rawMountPoint).Run() } // --- Step 3: Get device info for fstab --- fsType, _ := getBlkidValue(req.DevicePath, "TYPE") if fsType == "" { fsType = "ext4" // fallback } uuid, err := getBlkidValue(req.DevicePath, "UUID") if err != nil || uuid == "" { return "", fail("mounting", "UUID lekérése sikertelen", fmt.Errorf("empty UUID for %s", req.DevicePath)) } // Determine the raw mount directory name (the direct child of RawMountBase) relFromBase := strings.TrimPrefix(cleanSub, RawMountBase+"/") rawDirName := strings.SplitN(relFromBase, "/", 2)[0] rawMountPath := filepath.Join(RawMountBase, rawDirName) // Determine the subfolder relative to the raw mount subRel := strings.TrimPrefix(cleanSub, rawMountPath) subRel = strings.TrimPrefix(subRel, "/") dbg("raw mount path: %s, sub relative: %q", rawMountPath, subRel) send("mounting", "fstab bejegyzések hozzáadása...", 35) // Backup fstab (non-fatal) _ = BackupFstab(FstabPath) // Fstab entry 1: raw partition mount // Use nofail so a missing disk doesn't block boot dbg("fstab entry 1: UUID=%s → %s (fstype=%s)", uuid, rawMountPath, fsType) if err := AppendFstabEntry(FstabPath, uuid, rawMountPath, fsType, "defaults,nofail,noatime"); err != nil { dbg("fstab raw mount entry failed: %v", err) return "", fail("mounting", "fstab bejegyzés hozzáadása sikertelen (raw mount)", err) } // Fstab entry 2: bind mount from subfolder to final path bindSource := cleanSub dbg("fstab entry 2: bind %s → %s", bindSource, mountPath) if err := appendBindFstabEntry(FstabPath, bindSource, mountPath); err != nil { dbg("fstab bind entry failed: %v", err) // Roll back the raw mount fstab entry _ = RemoveFstabEntry(FstabPath, uuid) return "", fail("mounting", "fstab bejegyzés hozzáadása sikertelen (bind mount)", err) } // --- Step 4: Create bind mount --- send("mounting", fmt.Sprintf("Bind mount: %s → %s ...", cleanSub, mountPath), 50) if err := os.MkdirAll(mountPath, 0755); err != nil { _ = RemoveFstabEntry(FstabPath, uuid) _ = removeBindFstabEntry(FstabPath, mountPath) return "", fail("mounting", "Csatlakoztatási mappa nem hozható létre: "+mountPath, err) } dbg("bind mount: mount --bind %s %s", cleanSub, mountPath) if out, err := exec.Command("mount", "--bind", cleanSub, mountPath).CombinedOutput(); err != nil { dbg("bind mount failed: %s", string(out)) _ = RemoveFstabEntry(FstabPath, uuid) _ = removeBindFstabEntry(FstabPath, mountPath) return "", fail("mounting", "Bind mount sikertelen: "+string(out), err) } // Verify bind mount dbg("verifying bind mount with findmnt") verifyOut, verifyErr := exec.Command("findmnt", "-n", "-o", "SOURCE", "--target", mountPath).Output() if verifyErr != nil || strings.TrimSpace(string(verifyOut)) == "" { dbg("bind mount verification failed: findmnt returned %q err=%v", string(verifyOut), verifyErr) _ = exec.Command("umount", mountPath).Run() _ = RemoveFstabEntry(FstabPath, uuid) _ = removeBindFstabEntry(FstabPath, mountPath) return "", fail("mounting", "A bind mount nem ellenőrizhető", fmt.Errorf("mount point %s not found after bind mount", mountPath)) } dbg("bind mount verified: source=%q", strings.TrimSpace(string(verifyOut))) send("mounting", "Csatlakoztatva: "+mountPath, 70) // --- Step 5: Permissions + subdirs --- send("permissions", "Mappák létrehozása és jogosultságok beállítása...", 80) _ = exec.Command("chown", "1000:1000", mountPath).Run() for _, subdir := range []string{"felhom-data", "Dokumentumok"} { dir := filepath.Join(mountPath, subdir) if err := os.MkdirAll(dir, 0755); err == nil { _ = exec.Command("chown", "1000:1000", dir).Run() } } dbg("attach completed successfully: %s", mountPath) send("done", "Meghajtó sikeresen csatolva: "+mountPath, 100) return mountPath, nil } // CleanupRawMount unmounts a staging raw mount and removes its directory. // Called when the user cancels the attach wizard. func CleanupRawMount(rawPath string) error { cleanPath := filepath.Clean(rawPath) if !strings.HasPrefix(cleanPath, RawMountBase) { return fmt.Errorf("érvénytelen útvonal: csak %s alatti csatlakozások távolíthatók el", RawMountBase) } // Unmount _ = exec.Command("umount", cleanPath).Run() // Remove empty directory _ = os.Remove(cleanPath) return nil } // CleanupStaleRawMounts finds and removes raw mounts that have no corresponding // bind mount — i.e., leftovers from an interrupted attach wizard. // A raw mount is considered "in use" if fstab has a bind entry sourcing from it. func CleanupStaleRawMounts() { data, err := os.ReadFile("/proc/mounts") if err != nil { return } // Read fstab to check for bind mount entries fstabData, _ := os.ReadFile(FstabPath) fstabLines := strings.Split(string(fstabData), "\n") for _, line := range strings.Split(string(data), "\n") { fields := strings.Fields(line) if len(fields) < 2 { continue } mountPoint := fields[1] if !strings.HasPrefix(mountPoint, RawMountBase+"/") { continue } // Only consider direct children of RawMountBase (e.g., /mnt/.felhom-raw/hdd_1) rel := strings.TrimPrefix(mountPoint, RawMountBase+"/") if strings.Contains(rel, "/") { continue } // Check if any fstab bind entry sources from this raw mount path inUse := false for _, fl := range fstabLines { fl = strings.TrimSpace(fl) if fl == "" || strings.HasPrefix(fl, "#") { continue } if strings.Contains(fl, "bind") && strings.HasPrefix(fl, mountPoint) { inUse = true break } } if !inUse { _ = exec.Command("umount", mountPoint).Run() _ = os.Remove(mountPoint) } } } // --- helpers --- // getBlkidValue runs blkid to get a single value (TYPE, UUID, LABEL) for a device. func getBlkidValue(devicePath, tag string) (string, error) { out, err := exec.Command("blkid", "-o", "value", "-s", tag, HostDevicePath(devicePath)).Output() if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } // findRawMountPoint finds the mount point for a path under RawMountBase. // E.g., for "/mnt/.felhom-raw/hdd_1/some/sub" it returns "/mnt/.felhom-raw/hdd_1". func findRawMountPoint(path string) string { cleanPath := filepath.Clean(path) if !strings.HasPrefix(cleanPath, RawMountBase+"/") { return "" } rel := strings.TrimPrefix(cleanPath, RawMountBase+"/") parts := strings.SplitN(rel, "/", 2) if len(parts) == 0 || parts[0] == "" { return "" } return filepath.Join(RawMountBase, parts[0]) } // appendBindFstabEntry appends a bind mount fstab entry. func appendBindFstabEntry(fstabPath, source, target string) error { existing, err := os.ReadFile(fstabPath) if err != nil && !os.IsNotExist(err) { return fmt.Errorf("cannot read fstab: %w", err) } entry := fmt.Sprintf("\n# Bind mount (auto-generated by felhom-controller)\n%s\t%s\tnone\tbind,nofail\t0 0\n", source, target) newContent := append(existing, []byte(entry)...) return safeWriteFile(fstabPath, newContent, 0644) } // removeBindFstabEntry removes the bind mount fstab entry for the given target mount path. func removeBindFstabEntry(fstabPath, targetMountPath string) error { data, err := os.ReadFile(fstabPath) if err != nil { return fmt.Errorf("cannot read fstab: %w", err) } lines := strings.Split(string(data), "\n") var kept []string for i := 0; i < len(lines); i++ { line := lines[i] // Remove both the comment line and the bind mount line if strings.Contains(line, "Bind mount (auto-generated by felhom-controller)") { // Check if the next line is the actual bind entry for this target if i+1 < len(lines) && fstabMatchesTarget(lines[i+1], targetMountPath) { i++ // skip the bind line too continue } } if fstabMatchesTarget(line, targetMountPath) && strings.Contains(line, "bind") { continue } kept = append(kept, line) } return safeWriteFile(fstabPath, []byte(strings.Join(kept, "\n")), 0644) } // fstabMatchesTarget parses an fstab line and checks if the mount target (field 2) matches exactly. func fstabMatchesTarget(line, target string) bool { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { return false } fields := strings.Fields(line) if len(fields) < 2 { return false } return fields[1] == target }