8e61cd7ec4
Add structured operational logging at INFO, WARN, and ERROR levels to every controller module. Standardize custom prefixes ([GEO], [SCHED], [SYNC]) to use [INFO/WARN/ERROR] [module] format. Fix misleveled logs (WARN->ERROR for data loss scenarios, WARN->INFO for routine operations). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
483 lines
16 KiB
Go
483 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}
|
|
if req.Logger != nil {
|
|
req.Logger.Printf("[ERROR] [storage] Failed to attach disk: %v", err)
|
|
}
|
|
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
|
|
if req.Logger != nil {
|
|
req.Logger.Printf("[INFO] [storage] Attaching disk %s at %s", req.DevicePath, mountPath)
|
|
}
|
|
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)
|
|
if req.Logger != nil {
|
|
req.Logger.Printf("[INFO] [storage] Disk attached successfully")
|
|
}
|
|
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
|
|
}
|