v0.15.0: Attach existing drive wizard (bind mount, no format)

New Settings wizard to attach drives with existing filesystems without
formatting. Mounts partition at staging path, lets user browse and pick
a subfolder, then bind-mounts it at /mnt/<name> with fstab entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 21:12:02 +01:00
parent e54e097d02
commit 98834dd7e8
8 changed files with 1311 additions and 0 deletions
+408
View File
@@ -0,0 +1,408 @@
//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
}
// --- 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)...)
tmpPath := fstabPath + ".tmp"
if err := os.WriteFile(tmpPath, newContent, 0644); err != nil {
return fmt.Errorf("cannot write fstab tmp file: %w", err)
}
if err := os.Rename(tmpPath, fstabPath); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("cannot rename fstab tmp file: %w", err)
}
return nil
}
// 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)
}
newContent := strings.Join(kept, "\n")
tmpPath := fstabPath + ".tmp"
if err := os.WriteFile(tmpPath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("cannot write fstab tmp file: %w", err)
}
if err := os.Rename(tmpPath, fstabPath); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("cannot rename fstab tmp file: %w", err)
}
return nil
}