Files
deploy-felhom-compose/controller/internal/storage/attach_linux.go
T
admin 7abd1c5954 v0.26.0: Storage namespace felhom-data/ + test node wipe script
All felhom-managed data on external drives now lives under felhom-data/
subdirectory, cleanly separating controller data from user files.

- backup/paths.go: add FelhomDataDir constant, update 8 path helpers
- stacks/delete.go: add local felhomDataDir constant (circular import
  boundary), update ProtectedHDDPaths + GetStackBackupData
- storage/migrate_drive.go: import backup pkg, fix conflict check, verify,
  rsync excludes (felhom-data/backups/*/restic/), size estimation
- storage/migrate.go: import backup pkg, fix DB dump paths
- web/handlers.go: fix legacy 'storage' path -> backup.AppDataDir()
- storage/format_linux.go: create felhom-data/ instead of storage/
- storage/attach_linux.go: create felhom-data/ instead of storage/
- scripts/felhom-wipe.sh: new multi-level test node wipe script
  (soft/controller/full/nuclear)
- CHANGELOG.md, controller/README.md, scripts/README.md: updated docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 10:10:51 +01:00

457 lines
15 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 "", 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/<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)
}
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) && 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)
}