Files
felhom-controller/controller/internal/storage/attach_linux.go
T
admin 95c821deb2 feat: comprehensive debug logging across all controller modules
Add detailed [DEBUG] logging to every controller module when
logging.level is set to "debug". Each module with stateful debug
uses SetDebug(bool) wired from main.go. Covers stacks, backup,
cloudflare, integrations, system, monitor, settings, scheduler,
web handlers, storage, metrics, API, selfupdate, and assets.

Also includes the app export/import (.fab bundles) feature from
v0.32.0 and its debug page integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:14:43 +01:00

474 lines
16 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 != "" {
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/<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] [storage] 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
}