bdbe170a54
New storage watchdog monitors registered storage paths every 5s. On disconnect (3 consecutive probe failures), auto-stops affected apps, lazy-unmounts stale VFS entries, fires alerts/notifications/hub report. On reconnect (UUID detected), auto-remounts via fstab, cleans stale restic locks, offers app restart. Safe disconnect UI for USB drives: confirmation dialog, stop apps, sync, unmount. Disconnected state visible across all pages (dashboard, settings, backups, monitoring) with hatched red bars and badges. Backup guards skip disconnected drives. 22 files changed (1 new: monitor/watchdog.go), ~1500 lines added. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
388 lines
11 KiB
Go
388 lines
11 KiB
Go
//go:build linux
|
|
|
|
package system
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"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 {
|
|
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)
|
|
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 {
|
|
return nil
|
|
}
|
|
fields := strings.Fields(strings.TrimSpace(string(out)))
|
|
if len(fields) < 2 {
|
|
return nil
|
|
}
|
|
info := &FSInfo{
|
|
Device: fields[0],
|
|
FSType: fields[1],
|
|
}
|
|
// Try to get disk model from sysfs
|
|
info.Model = diskModel(info.Device)
|
|
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 {
|
|
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"
|
|
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"
|
|
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"
|
|
// Don't return early — also check disk usage
|
|
} else {
|
|
h.MountPoint = true
|
|
}
|
|
|
|
// 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"
|
|
} 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"
|
|
}
|
|
// 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"
|
|
} else if di.UsedPercent >= 90 {
|
|
h.Warning = fmt.Sprintf("A mentési meghajtó majdnem megtelt (%.0f%% használt).", di.UsedPercent)
|
|
h.Severity = "warning"
|
|
}
|
|
}
|
|
}
|
|
|
|
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".
|
|
func stripPartition(base string) string {
|
|
if strings.HasPrefix(base, "nvme") {
|
|
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 {
|
|
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 {
|
|
// Quick check: does the path exist at all?
|
|
if _, err := os.Lstat(path); os.IsNotExist(err) {
|
|
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:
|
|
if res.err == nil {
|
|
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") {
|
|
return ProbeResult{Status: ProbeDisconnected, Err: res.err}
|
|
}
|
|
return ProbeResult{Status: ProbeDisconnected, Err: res.err}
|
|
case <-time.After(3 * time.Second):
|
|
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".
|
|
// Checks the sysfs symlink for the disk — if the path contains "/usb", it's a USB device.
|
|
func IsUSBDevice(devicePath string) bool {
|
|
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
|
|
}
|
|
if strings.Contains(link, "/usb") {
|
|
return true
|
|
}
|
|
return false // found the sysfs entry, but not USB
|
|
}
|
|
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
|
|
}
|