Files
deploy-felhom-compose/controller/internal/system/mounts_linux.go
T
admin f19c6fb0c9 fix: USB badge detection for bind-mounted drives + graceful Tier2 backup on disconnected destinations
- IsUSBDevice/diskModel: strip findmnt bind-mount suffix [/subdir] before
  parsing device path (fixes USB badge not showing for attach-wizard drives)
- crossdrive.go: skip disconnected src/dest drives with WARN log instead of
  returning error (prevents noisy error status in settings.json)
- handlers.go: detect Tier2 destination disconnection, set yellow status dot
  instead of red, skip ValidateDestination for disconnected paths
- backups.html: new template branch showing "Cél meghajtó leválasztva" badge
  with grayed-out info and hidden "Futtatás most" button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:59:29 +01:00

428 lines
14 KiB
Go

//go:build linux
package system
import (
"bufio"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
)
// IsMountPoint checks if a path is on a different device than its parent.
// Returns true if the path is a mount point (different device ID from parent).
func IsMountPoint(path string) bool {
var pathStat, parentStat syscall.Stat_t
if err := syscall.Stat(path, &pathStat); err != nil {
return false
}
parent := filepath.Dir(path)
if err := syscall.Stat(parent, &parentStat); err != nil {
return false
}
return pathStat.Dev != parentStat.Dev
}
// IsWritable checks if the given path is writable by attempting to create+remove a temp file.
func IsWritable(path string) bool {
testFile := filepath.Join(path, ".felhom-write-test")
f, err := os.Create(testFile)
if err != nil {
return false
}
f.Close()
os.Remove(testFile)
return true
}
// PathsOverlap returns true if one path is a parent or child of the other.
func PathsOverlap(a, b string) bool {
a = filepath.Clean(a)
b = filepath.Clean(b)
if a == b {
return true
}
aSep := a + string(os.PathSeparator)
bSep := b + string(os.PathSeparator)
return strings.HasPrefix(aSep, bSep) || strings.HasPrefix(bSep, aSep)
}
// DiskUsageInfo holds disk usage statistics for a path.
type DiskUsageInfo struct {
TotalGB float64
UsedGB float64
AvailGB float64
UsedPercent float64
TotalHuman string
UsedHuman string
}
// GetDiskUsage returns disk usage info for a path, or nil on error.
func GetDiskUsage(path string) *DiskUsageInfo {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
debugf("[DEBUG] [system] GetDiskUsage: statfs(%q) failed: %v", path, err)
return nil
}
bsize := uint64(stat.Bsize)
total := stat.Blocks * bsize
avail := stat.Bavail * bsize
used := total - (stat.Bfree * bsize)
const gb = 1024 * 1024 * 1024
info := &DiskUsageInfo{
TotalGB: float64(total) / float64(gb),
UsedGB: float64(used) / float64(gb),
AvailGB: float64(avail) / float64(gb),
}
if total > 0 {
info.UsedPercent = float64(used) / float64(total) * 100
}
info.TotalHuman = formatGB(info.TotalGB)
info.UsedHuman = formatGB(info.UsedGB)
debugf("[DEBUG] [system] GetDiskUsage: path=%q total=%s used=%s avail=%.1fGB (%.1f%%)",
path, info.TotalHuman, info.UsedHuman, info.AvailGB, info.UsedPercent)
return info
}
func formatGB(gb float64) string {
if gb >= 1000 {
return fmt.Sprintf("%.1f TB", gb/1024)
}
return fmt.Sprintf("%.1f GB", gb)
}
// FSInfo holds filesystem type, device, and disk model info.
type FSInfo struct {
FSType string // "ext4", "btrfs"
Device string // "/dev/sda1"
Model string // "WD Elements 25A2" (best-effort from sysfs)
}
// GetFSInfo returns filesystem info for a path using findmnt, or nil on error.
func GetFSInfo(path string) *FSInfo {
out, err := exec.Command("findmnt", "-n", "-o", "SOURCE,FSTYPE", "--target", path).Output()
if err != nil {
debugf("[DEBUG] [system] GetFSInfo: findmnt(%q) failed: %v", path, err)
return nil
}
fields := strings.Fields(strings.TrimSpace(string(out)))
if len(fields) < 2 {
debugf("[DEBUG] [system] GetFSInfo: findmnt(%q) returned unexpected output: %q", path, strings.TrimSpace(string(out)))
return nil
}
info := &FSInfo{
Device: fields[0],
FSType: fields[1],
}
// Try to get disk model from sysfs
info.Model = diskModel(info.Device)
debugf("[DEBUG] [system] GetFSInfo: path=%q device=%s fstype=%s model=%q", path, info.Device, info.FSType, info.Model)
return info
}
// DestinationHealth holds the result of a tiered backup destination check.
type DestinationHealth struct {
Exists bool
Writable bool
MountPoint bool // true if path is on a different device from its parent
SystemDrive bool // true if path is on the same device as /
UsedPercent float64 // disk usage percentage (0 if unknown)
FreeGB float64
Warning string // human-readable warning message in Hungarian (empty = ok)
Blocked bool // if true, backup must not run
Severity string // "ok", "warning", "critical"
}
// CheckBackupDestination performs tiered validation of a cross-drive backup destination.
// Returns a DestinationHealth describing any issues found.
func CheckBackupDestination(path string) DestinationHealth {
debugf("[DEBUG] [system] CheckBackupDestination: path=%q", path)
h := DestinationHealth{Severity: "ok"}
// Tier 1: path must exist
if _, err := os.Stat(path); os.IsNotExist(err) {
h.Warning = "A cél tárhely (" + path + ") nem létezik!"
h.Blocked = true
h.Severity = "critical"
log.Printf("[WARN] [system] Backup destination %s is not safe: path does not exist", path)
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier1 FAIL (not exists)", path)
return h
}
h.Exists = true
// Tier 2: path must be writable
if !IsWritable(path) {
h.Warning = "A cél tárhely (" + path + ") nem írható! Ellenőrizd a jogosultságokat."
h.Blocked = true
h.Severity = "critical"
log.Printf("[WARN] [system] Backup destination %s is not safe: not writable", path)
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier2 FAIL (not writable)", path)
return h
}
h.Writable = true
// Tier 3: detect if source and destination are on the same block device
// (stronger than IsMountPoint — catches e.g. bind mounts within same device)
if isSameBlockDevice(path, "/") {
h.SystemDrive = true
// This is a warning, not a block — user data still protected against software errors
h.Warning = "A cél tárhely (" + path + ") a rendszermeghajtón van. " +
"Meghajtóhiba esetén az eredeti adat és a mentés is elveszhet. " +
"Külső meghajtó használata javasolt."
h.Severity = "warning"
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier3 WARN (same block device as /)", path)
// Don't return early — also check disk usage
} else {
h.MountPoint = true
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier3 OK (different block device)", path)
}
// Tier 4: disk usage checks
if di := GetDiskUsage(path); di != nil {
h.UsedPercent = di.UsedPercent
h.FreeGB = di.AvailGB
if h.SystemDrive {
// System drive: stricter limits to protect OS stability
if di.AvailGB < 10 {
h.Warning = fmt.Sprintf("A rendszermeghajtón csak %.1f GB szabad — legalább 10 GB szükséges a rendszer stabilitásához!", di.AvailGB)
h.Blocked = true
h.Severity = "critical"
log.Printf("[WARN] [system] Backup destination %s is not safe: system drive low space (%.1f GB free)", path, di.AvailGB)
} else if di.UsedPercent >= 90 {
h.Warning = fmt.Sprintf("A rendszermeghajtó %.0f%%-ban megtelt — maximum 90%% megengedett.", di.UsedPercent)
h.Blocked = true
h.Severity = "critical"
log.Printf("[WARN] [system] Backup destination %s is not safe: system drive %.0f%% full", path, di.UsedPercent)
}
// If neither triggers, keep the Tier 3 system-drive warning
} else {
// External drive: original thresholds
if di.UsedPercent >= 95 {
h.Warning = fmt.Sprintf("A mentési meghajtó megtelt (%.0f%% használt)!", di.UsedPercent)
h.Blocked = true
h.Severity = "critical"
log.Printf("[WARN] [system] Backup destination %s is not safe: drive %.0f%% full", path, di.UsedPercent)
} else if di.UsedPercent >= 90 {
h.Warning = fmt.Sprintf("A mentési meghajtó majdnem megtelt (%.0f%% használt).", di.UsedPercent)
h.Severity = "warning"
}
}
}
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — result: severity=%s blocked=%v freeGB=%.1f usedPct=%.1f%%",
path, h.Severity, h.Blocked, h.FreeGB, h.UsedPercent)
return h
}
// isSameBlockDevice returns true if pathA and pathB are on the same block device.
func isSameBlockDevice(pathA, pathB string) bool {
var statA, statB syscall.Stat_t
if err := syscall.Stat(pathA, &statA); err != nil {
return false
}
if err := syscall.Stat(pathB, &statB); err != nil {
return false
}
return statA.Dev == statB.Dev
}
// stripPartition strips the partition suffix from a device name.
// e.g., "sda1" → "sda", "nvme0n1p1" → "nvme0n1", "mmcblk0p1" → "mmcblk0".
func stripPartition(base string) string {
if strings.HasPrefix(base, "nvme") || strings.HasPrefix(base, "mmcblk") {
if idx := strings.LastIndex(base, "p"); idx > 4 {
return base[:idx]
}
} else {
return strings.TrimRight(base, "0123456789")
}
return base
}
// diskModel reads the disk model from /sys/block/<dev>/device/model.
func diskModel(device string) string {
// Strip bind-mount subdir suffix (e.g., "/dev/sdb1[/felhom_data]" → "/dev/sdb1")
if idx := strings.IndexByte(device, '['); idx >= 0 {
device = device[:idx]
}
disk := stripPartition(filepath.Base(device))
modelPath := "/sys/block/" + disk + "/device/model"
data, err := os.ReadFile(modelPath)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
// ProbeStatus represents the result of a storage path probe.
type ProbeStatus int
const (
ProbeConnected ProbeStatus = iota
ProbeDisconnected
ProbeTimeout
)
// ProbeResult holds the outcome of a storage path probe.
type ProbeResult struct {
Status ProbeStatus
Err error
}
// ProbeStoragePath checks if a storage path is responsive.
// Uses a goroutine with a 3-second timeout to avoid blocking on dead mounts.
func ProbeStoragePath(path string) ProbeResult {
start := time.Now()
// Quick check: does the path exist at all?
if _, err := os.Lstat(path); os.IsNotExist(err) {
log.Printf("[WARN] [system] Storage path %s probe failed: %v", path, err)
debugf("[DEBUG] [system] ProbeStoragePath: path=%q — not exists (%s)", path, time.Since(start).Round(time.Millisecond))
return ProbeResult{Status: ProbeDisconnected, Err: err}
}
type statResult struct {
err error
}
ch := make(chan statResult, 1)
go func() {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
ch <- statResult{err: err}
}()
select {
case res := <-ch:
elapsed := time.Since(start).Round(time.Millisecond)
if res.err == nil {
debugf("[DEBUG] [system] ProbeStoragePath: path=%q — connected (%s)", path, elapsed)
return ProbeResult{Status: ProbeConnected}
}
errStr := res.err.Error()
if strings.Contains(errStr, "transport endpoint") ||
strings.Contains(errStr, "input/output error") ||
strings.Contains(errStr, "no such device") {
log.Printf("[WARN] [system] Storage path %s probe failed: %v", path, res.err)
debugf("[DEBUG] [system] ProbeStoragePath: path=%q — disconnected: %v (%s)", path, res.err, elapsed)
return ProbeResult{Status: ProbeDisconnected, Err: res.err}
}
log.Printf("[WARN] [system] Storage path %s probe failed: %v", path, res.err)
debugf("[DEBUG] [system] ProbeStoragePath: path=%q — disconnected (other error): %v (%s)", path, res.err, elapsed)
return ProbeResult{Status: ProbeDisconnected, Err: res.err}
case <-time.After(3 * time.Second):
log.Printf("[WARN] [system] Storage path %s probe failed: stat timed out after 3s", path)
debugf("[DEBUG] [system] ProbeStoragePath: path=%q — TIMEOUT (3s)", path)
return ProbeResult{Status: ProbeTimeout, Err: fmt.Errorf("stat timed out after 3s")}
}
}
// IsUSBDevice checks if a block device is connected via USB.
// devicePath should be like "/dev/sdb" or "/dev/sdb1".
// Also handles bind-mount suffixes from findmnt (e.g., "/dev/sdb1[/felhom_data]").
// Checks the sysfs symlink for the disk — if the path contains "/usb", it's a USB device.
func IsUSBDevice(devicePath string) bool {
// Strip bind-mount subdir suffix (e.g., "/dev/sdb1[/felhom_data]" → "/dev/sdb1")
if idx := strings.IndexByte(devicePath, '['); idx >= 0 {
devicePath = devicePath[:idx]
}
disk := stripPartition(filepath.Base(devicePath))
if disk == "" {
return false
}
// Try /host/sys first (Docker mount), then /sys (native)
for _, prefix := range []string{"/host/sys", "/sys"} {
link, err := os.Readlink(prefix + "/block/" + disk)
if err != nil {
continue
}
isUSB := strings.Contains(link, "/usb")
debugf("[DEBUG] [system] IsUSBDevice: device=%q disk=%q sysfs=%s → usb=%v", devicePath, disk, link, isUSB)
return isUSB
}
debugf("[DEBUG] [system] IsUSBDevice: device=%q disk=%q — no sysfs entry found", devicePath, disk)
return false
}
// ParseFstabUUID extracts the UUID for a given mount point from an fstab file.
// Returns empty string if the mount point is not found or has no UUID.
func ParseFstabUUID(fstabPath, mountPath string) string {
f, err := os.Open(fstabPath)
if err != nil {
return ""
}
defer f.Close()
cleanMount := filepath.Clean(mountPath)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
if filepath.Clean(fields[1]) != cleanMount {
continue
}
source := fields[0]
if strings.HasPrefix(source, "UUID=") {
return strings.TrimPrefix(source, "UUID=")
}
}
return ""
}
// HasFelhomRawMount checks if a mount path was set up via the attach wizard
// (which uses a raw mount at /mnt/.felhom-raw/<x> + a bind mount).
// Returns the raw mount path if found, e.g., "/mnt/.felhom-raw/hdd_1".
func HasFelhomRawMount(fstabPath, mountPath string) (rawPath string, ok bool) {
f, err := os.Open(fstabPath)
if err != nil {
return "", false
}
defer f.Close()
cleanMount := filepath.Clean(mountPath)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
// Look for a bind mount line targeting our mount path
// Format: /mnt/.felhom-raw/hdd_1/subfolder /mnt/hdd_1 none bind,...
target := filepath.Clean(fields[1])
if target != cleanMount {
continue
}
source := fields[0]
if !strings.Contains(source, ".felhom-raw") {
continue
}
// Extract the raw mount path: /mnt/.felhom-raw/hdd_1/subfolder → /mnt/.felhom-raw/hdd_1
// The raw mount is the first two components after /mnt/.felhom-raw/
parts := strings.Split(filepath.Clean(source), string(os.PathSeparator))
// parts: ["", "mnt", ".felhom-raw", "hdd_1", "subfolder"]
for i, p := range parts {
if p == ".felhom-raw" && i+1 < len(parts) {
rawPath = string(os.PathSeparator) + filepath.Join(parts[1:i+2]...)
return rawPath, true
}
}
}
return "", false
}