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>
419 lines
14 KiB
Go
419 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 {
|
|
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".
|
|
// 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
|
|
}
|
|
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
|
|
}
|