6b7ca566df
After an interrupted attach wizard, the raw mount stays behind, causing the device to appear as "mounted" in scan results. Now the scan button calls cancel first, which unmounts any stale raw mounts that have no bind mount in fstab. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
440 lines
14 KiB
Go
440 lines
14 KiB
Go
//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 != "" {
|
|
dirName = uuid[:8] // use first 8 chars of 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 targetPath, nil // already exists, idempotent
|
|
}
|
|
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/<name>) 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)
|
|
}
|
|
|
|
mountPath := "/mnt/" + req.MountName
|
|
|
|
// --- 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, "/")
|
|
|
|
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
|
|
if err := AppendFstabEntry(FstabPath, uuid, rawMountPath, fsType, "defaults,nofail,noatime"); err != nil {
|
|
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
|
|
if err := appendBindFstabEntry(FstabPath, bindSource, mountPath); err != nil {
|
|
// 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)
|
|
}
|
|
|
|
if out, err := exec.Command("mount", "--bind", cleanSub, mountPath).CombinedOutput(); err != nil {
|
|
_ = RemoveFstabEntry(FstabPath, uuid)
|
|
_ = removeBindFstabEntry(FstabPath, mountPath)
|
|
return "", fail("mounting", "Bind mount sikertelen: "+string(out), err)
|
|
}
|
|
|
|
// Verify bind mount
|
|
verifyOut, verifyErr := exec.Command("findmnt", "-n", "-o", "SOURCE", "--target", mountPath).Output()
|
|
if verifyErr != nil || strings.TrimSpace(string(verifyOut)) == "" {
|
|
_ = 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))
|
|
}
|
|
|
|
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{"storage", "Dokumentumok"} {
|
|
dir := filepath.Join(mountPath, subdir)
|
|
if err := os.MkdirAll(dir, 0755); err == nil {
|
|
_ = exec.Command("chown", "1000:1000", dir).Run()
|
|
}
|
|
}
|
|
|
|
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) && strings.Contains(lines[i+1], targetMountPath) {
|
|
i++ // skip the bind line too
|
|
continue
|
|
}
|
|
}
|
|
if strings.Contains(line, targetMountPath) && strings.Contains(line, "bind") {
|
|
continue
|
|
}
|
|
kept = append(kept, line)
|
|
}
|
|
|
|
return safeWriteFile(fstabPath, []byte(strings.Join(kept, "\n")), 0644)
|
|
}
|