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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user