95c821deb2
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>
409 lines
13 KiB
Go
409 lines
13 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 {
|
|
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"
|
|
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"
|
|
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"
|
|
} 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"
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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") {
|
|
debugf("[DEBUG] [system] ProbeStoragePath: path=%q — disconnected: %v (%s)", path, res.err, elapsed)
|
|
return ProbeResult{Status: ProbeDisconnected, Err: 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):
|
|
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
|
|
}
|