Compare commits

..

19 Commits

Author SHA1 Message Date
henrygd
6b5e6ffa9a agent: small refactoring and tests for battery package (#1872) 2026-04-02 21:07:14 -04:00
henrygd
d656036d3b agent: refactor new battery package (#1872) 2026-04-02 21:07:14 -04:00
svenvg93
80b73c7faf feat: implement the battery diectly instead of depency 2026-04-02 21:07:14 -04:00
Sven van Ginkel
afe9eb7a70 feat(hub): copy existing alerts between systems (#1853)
Co-authored-by: henrygd <hank@henrygd.me>
2026-04-02 18:04:45 -04:00
Sven van Ginkel
7f565a3086 fix(agent): show correct NVMe capacity for Apple SSDs (#1873)
Co-authored-by: henrygd <hank@henrygd.me>
2026-04-02 15:36:05 -04:00
Sven van Ginkel
77862d4cb1 fix(install): use daemon user on OPNsense to survive reboots#1880 2026-04-02 15:34:50 -04:00
Sven van Ginkel
e158a9001b fix(agent): upgrade gopsutil to v4.26.3 to resolve macOS ARM64 crashes (#1881, #796) 2026-04-02 15:23:08 -04:00
henrygd
f670e868e4 agent: add SENSORS_TIMEOUT env var (#1871) 2026-04-02 15:10:49 -04:00
henrygd
0fff699bf6 hub: return error if accessing /api/beszel/universal-token with a superuser account (#1870) 2026-04-01 22:16:47 -04:00
henrygd
ba10da1b9f hub: add additional validation checks for custom api routes
- Validate the user is assigned to system in authenticated routes where
the user passes in system ID. This protects against a somewhat
impractical scenario where an authenticated user cracks a random 15
character alphanumeric ID of a system that doesn't belong to them via
web API.
- Validate that systemd service exists in database before requesting
service details from agent. This protects against authenticated users
getting unit properties of services that aren't explicitly monitored.
- Refactor responses in authenticated routes to prevent enumeration of
other users' random 15 char system IDs.
2026-04-01 16:30:45 -04:00
henrygd
7f4f14b505 fix(agent,windows): raise timeout on first sensor collection to allow LHM to start 2026-03-31 16:10:59 -04:00
henrygd
2fda4ff264 agent: update LibreHardwareMonitorLib to 0.9.6 2026-03-31 15:55:02 -04:00
henrygd
20b0b40ec8 ui: no centered dialog headings and a few other tweaks 2026-03-31 15:40:52 -04:00
Malith Rukshan
d548a012b4 fix(ui): revert CardTitle to text-2xl to fix tailwind-merge class override (#1860) 2026-03-31 14:55:23 -04:00
henrygd
ce5d1217dd fix(hub): cancel pending down status alert if system paused before alert sent 2026-03-31 14:08:44 -04:00
henrygd
cef09d7cb1 fix(agent): fix windows root disk detection if exe not running on root disk (#1863) 2026-03-31 12:58:42 -04:00
henrygd
f6440acb43 fix(ui): hide noop add system btn and smart actions for readonly users 2026-03-30 19:45:12 -04:00
henrygd
5463a38f0f refactor(hub): move api user role checks to middlewares 2026-03-30 19:35:02 -04:00
Sven van Ginkel
80135fdad3 fix(agent): exclude nested virtual fs when mounting host root to /extra-filesystems in Docker (#1859) 2026-03-30 13:48:54 -04:00
37 changed files with 1598 additions and 240 deletions

View File

@@ -1,84 +1,11 @@
//go:build !freebsd // Package battery provides functions to check if the system has a battery and return the charge state and percentage.
// Package battery provides functions to check if the system has a battery and to get the battery stats.
package battery package battery
import ( const (
"errors" stateUnknown uint8 = iota
"log/slog" stateEmpty
"math" stateFull
stateCharging
"github.com/distatus/battery" stateDischarging
stateIdle
) )
var (
systemHasBattery = false
haveCheckedBattery = false
)
// HasReadableBattery checks if the system has a battery and returns true if it does.
func HasReadableBattery() bool {
if haveCheckedBattery {
return systemHasBattery
}
haveCheckedBattery = true
batteries, err := battery.GetAll()
for _, bat := range batteries {
if bat != nil && (bat.Full > 0 || bat.Design > 0) {
systemHasBattery = true
break
}
}
if !systemHasBattery {
slog.Debug("No battery found", "err", err)
}
return systemHasBattery
}
// GetBatteryStats returns the current battery percent and charge state
// percent = (current charge of all batteries) / (sum of designed/full capacity of all batteries)
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
if !HasReadableBattery() {
return batteryPercent, batteryState, errors.ErrUnsupported
}
batteries, err := battery.GetAll()
// we'll handle errors later by skipping batteries with errors, rather
// than skipping everything because of the presence of some errors.
if len(batteries) == 0 {
return batteryPercent, batteryState, errors.New("no batteries")
}
totalCapacity := float64(0)
totalCharge := float64(0)
errs, partialErrs := err.(battery.Errors)
batteryState = math.MaxUint8
for i, bat := range batteries {
if partialErrs && errs[i] != nil {
// if there were some errors, like missing data, skip it
continue
}
if bat == nil || bat.Full == 0 {
// skip batteries with no capacity. Charge is unlikely to ever be zero, but
// we can't guarantee that, so don't skip based on charge.
continue
}
totalCapacity += bat.Full
totalCharge += min(bat.Current, bat.Full)
if bat.State.Raw >= 0 {
batteryState = uint8(bat.State.Raw)
}
}
if totalCapacity == 0 || batteryState == math.MaxUint8 {
// for macs there's sometimes a ghost battery with 0 capacity
// https://github.com/distatus/battery/issues/34
// Instead of skipping over those batteries, we'll check for total 0 capacity
// and return an error. This also prevents a divide by zero.
return batteryPercent, batteryState, errors.New("no battery capacity")
}
batteryPercent = uint8(totalCharge / totalCapacity * 100)
return batteryPercent, batteryState, nil
}

View File

@@ -0,0 +1,96 @@
//go:build darwin
package battery
import (
"errors"
"log/slog"
"math"
"os/exec"
"sync"
"howett.net/plist"
)
type macBattery struct {
CurrentCapacity int `plist:"CurrentCapacity"`
MaxCapacity int `plist:"MaxCapacity"`
FullyCharged bool `plist:"FullyCharged"`
IsCharging bool `plist:"IsCharging"`
ExternalConnected bool `plist:"ExternalConnected"`
}
func readMacBatteries() ([]macBattery, error) {
out, err := exec.Command("ioreg", "-n", "AppleSmartBattery", "-r", "-a").Output()
if err != nil {
return nil, err
}
if len(out) == 0 {
return nil, nil
}
var batteries []macBattery
if _, err := plist.Unmarshal(out, &batteries); err != nil {
return nil, err
}
return batteries, nil
}
// HasReadableBattery checks if the system has a battery and returns true if it does.
var HasReadableBattery = sync.OnceValue(func() bool {
systemHasBattery := false
batteries, err := readMacBatteries()
slog.Debug("Batteries", "batteries", batteries, "err", err)
for _, bat := range batteries {
if bat.MaxCapacity > 0 {
systemHasBattery = true
break
}
}
return systemHasBattery
})
// GetBatteryStats returns the current battery percent and charge state.
// Uses CurrentCapacity/MaxCapacity to match the value macOS displays.
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
if !HasReadableBattery() {
return batteryPercent, batteryState, errors.ErrUnsupported
}
batteries, err := readMacBatteries()
if len(batteries) == 0 {
return batteryPercent, batteryState, errors.New("no batteries")
}
totalCapacity := 0
totalCharge := 0
batteryState = math.MaxUint8
for _, bat := range batteries {
if bat.MaxCapacity == 0 {
// skip ghost batteries with 0 capacity
// https://github.com/distatus/battery/issues/34
continue
}
totalCapacity += bat.MaxCapacity
totalCharge += min(bat.CurrentCapacity, bat.MaxCapacity)
switch {
case !bat.ExternalConnected:
batteryState = stateDischarging
case bat.IsCharging:
batteryState = stateCharging
case bat.CurrentCapacity == 0:
batteryState = stateEmpty
case !bat.FullyCharged:
batteryState = stateIdle
default:
batteryState = stateFull
}
}
if totalCapacity == 0 || batteryState == math.MaxUint8 {
return batteryPercent, batteryState, errors.New("no battery capacity")
}
batteryPercent = uint8(float64(totalCharge) / float64(totalCapacity) * 100)
return batteryPercent, batteryState, nil
}

View File

@@ -0,0 +1,117 @@
//go:build linux
package battery
import (
"errors"
"log/slog"
"math"
"os"
"path/filepath"
"strconv"
"sync"
"github.com/henrygd/beszel/agent/utils"
)
// getBatteryPaths returns the paths of all batteries in /sys/class/power_supply
var getBatteryPaths func() ([]string, error)
// HasReadableBattery checks if the system has a battery and returns true if it does.
var HasReadableBattery func() bool
func init() {
resetBatteryState("/sys/class/power_supply")
}
// resetBatteryState resets the sync.Once functions to a fresh state.
// Tests call this after swapping sysfsPowerSupply so the new path is picked up.
func resetBatteryState(sysfsPowerSupplyPath string) {
getBatteryPaths = sync.OnceValues(func() ([]string, error) {
entries, err := os.ReadDir(sysfsPowerSupplyPath)
if err != nil {
return nil, err
}
var paths []string
for _, e := range entries {
path := filepath.Join(sysfsPowerSupplyPath, e.Name())
if utils.ReadStringFile(filepath.Join(path, "type")) == "Battery" {
paths = append(paths, path)
}
}
return paths, nil
})
HasReadableBattery = sync.OnceValue(func() bool {
systemHasBattery := false
paths, err := getBatteryPaths()
for _, path := range paths {
if _, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity")); ok {
systemHasBattery = true
break
}
}
if !systemHasBattery {
slog.Debug("No battery found", "err", err)
}
return systemHasBattery
})
}
func parseSysfsState(status string) uint8 {
switch status {
case "Empty":
return stateEmpty
case "Full":
return stateFull
case "Charging":
return stateCharging
case "Discharging":
return stateDischarging
case "Not charging":
return stateIdle
default:
return stateUnknown
}
}
// GetBatteryStats returns the current battery percent and charge state.
// Reads /sys/class/power_supply/*/capacity directly so the kernel-reported
// value is used, which is always 0-100 and matches what the OS displays.
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
if !HasReadableBattery() {
return batteryPercent, batteryState, errors.ErrUnsupported
}
paths, err := getBatteryPaths()
if len(paths) == 0 {
return batteryPercent, batteryState, errors.New("no batteries")
}
batteryState = math.MaxUint8
totalPercent := 0
count := 0
for _, path := range paths {
capStr, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity"))
if !ok {
continue
}
cap, parseErr := strconv.Atoi(capStr)
if parseErr != nil {
continue
}
totalPercent += cap
count++
state := parseSysfsState(utils.ReadStringFile(filepath.Join(path, "status")))
if state != stateUnknown {
batteryState = state
}
}
if count == 0 || batteryState == math.MaxUint8 {
return batteryPercent, batteryState, errors.New("no battery capacity")
}
batteryPercent = uint8(totalPercent / count)
return batteryPercent, batteryState, nil
}

View File

@@ -0,0 +1,201 @@
//go:build testing && linux
package battery
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
// setupFakeSysfs creates a temporary sysfs-like tree under t.TempDir(),
// swaps sysfsPowerSupply, resets the sync.Once caches, and restores
// everything on cleanup. Returns a helper to create battery directories.
func setupFakeSysfs(t *testing.T) (tmpDir string, addBattery func(name, capacity, status string)) {
t.Helper()
tmp := t.TempDir()
resetBatteryState(tmp)
write := func(path, content string) {
t.Helper()
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
addBattery = func(name, capacity, status string) {
t.Helper()
batDir := filepath.Join(tmp, name)
write(filepath.Join(batDir, "type"), "Battery")
write(filepath.Join(batDir, "capacity"), capacity)
write(filepath.Join(batDir, "status"), status)
}
return tmp, addBattery
}
func TestParseSysfsState(t *testing.T) {
tests := []struct {
input string
want uint8
}{
{"Empty", stateEmpty},
{"Full", stateFull},
{"Charging", stateCharging},
{"Discharging", stateDischarging},
{"Not charging", stateIdle},
{"", stateUnknown},
{"SomethingElse", stateUnknown},
}
for _, tt := range tests {
assert.Equal(t, tt.want, parseSysfsState(tt.input), "parseSysfsState(%q)", tt.input)
}
}
func TestGetBatteryStats_SingleBattery(t *testing.T) {
_, addBattery := setupFakeSysfs(t)
addBattery("BAT0", "72", "Discharging")
pct, state, err := GetBatteryStats()
assert.NoError(t, err)
assert.Equal(t, uint8(72), pct)
assert.Equal(t, stateDischarging, state)
}
func TestGetBatteryStats_MultipleBatteries(t *testing.T) {
_, addBattery := setupFakeSysfs(t)
addBattery("BAT0", "80", "Charging")
addBattery("BAT1", "40", "Charging")
pct, state, err := GetBatteryStats()
assert.NoError(t, err)
// average of 80 and 40 = 60
assert.EqualValues(t, 60, pct)
assert.Equal(t, stateCharging, state)
}
func TestGetBatteryStats_FullBattery(t *testing.T) {
_, addBattery := setupFakeSysfs(t)
addBattery("BAT0", "100", "Full")
pct, state, err := GetBatteryStats()
assert.NoError(t, err)
assert.Equal(t, uint8(100), pct)
assert.Equal(t, stateFull, state)
}
func TestGetBatteryStats_EmptyBattery(t *testing.T) {
_, addBattery := setupFakeSysfs(t)
addBattery("BAT0", "0", "Empty")
pct, state, err := GetBatteryStats()
assert.NoError(t, err)
assert.Equal(t, uint8(0), pct)
assert.Equal(t, stateEmpty, state)
}
func TestGetBatteryStats_NotCharging(t *testing.T) {
_, addBattery := setupFakeSysfs(t)
addBattery("BAT0", "80", "Not charging")
pct, state, err := GetBatteryStats()
assert.NoError(t, err)
assert.Equal(t, uint8(80), pct)
assert.Equal(t, stateIdle, state)
}
func TestGetBatteryStats_NoBatteries(t *testing.T) {
setupFakeSysfs(t) // empty directory, no batteries
_, _, err := GetBatteryStats()
assert.Error(t, err)
}
func TestGetBatteryStats_NonBatterySupplyIgnored(t *testing.T) {
tmp, addBattery := setupFakeSysfs(t)
// Add a real battery
addBattery("BAT0", "55", "Charging")
// Add an AC adapter (type != Battery) - should be ignored
acDir := filepath.Join(tmp, "AC0")
if err := os.MkdirAll(acDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(acDir, "type"), []byte("Mains"), 0o644); err != nil {
t.Fatal(err)
}
pct, state, err := GetBatteryStats()
assert.NoError(t, err)
assert.Equal(t, uint8(55), pct)
assert.Equal(t, stateCharging, state)
}
func TestGetBatteryStats_InvalidCapacitySkipped(t *testing.T) {
tmp, addBattery := setupFakeSysfs(t)
// One battery with valid capacity
addBattery("BAT0", "90", "Discharging")
// Another with invalid capacity text
badDir := filepath.Join(tmp, "BAT1")
if err := os.MkdirAll(badDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(badDir, "type"), []byte("Battery"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(badDir, "capacity"), []byte("not-a-number"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(badDir, "status"), []byte("Discharging"), 0o644); err != nil {
t.Fatal(err)
}
pct, _, err := GetBatteryStats()
assert.NoError(t, err)
// Only BAT0 counted
assert.Equal(t, uint8(90), pct)
}
func TestGetBatteryStats_UnknownStatusOnly(t *testing.T) {
_, addBattery := setupFakeSysfs(t)
addBattery("BAT0", "50", "SomethingWeird")
_, _, err := GetBatteryStats()
assert.Error(t, err)
}
func TestHasReadableBattery_True(t *testing.T) {
_, addBattery := setupFakeSysfs(t)
addBattery("BAT0", "50", "Charging")
assert.True(t, HasReadableBattery())
}
func TestHasReadableBattery_False(t *testing.T) {
setupFakeSysfs(t) // no batteries
assert.False(t, HasReadableBattery())
}
func TestHasReadableBattery_NoCapacityFile(t *testing.T) {
tmp, _ := setupFakeSysfs(t)
// Battery dir with type file but no capacity file
batDir := filepath.Join(tmp, "BAT0")
err := os.MkdirAll(batDir, 0o755)
assert.NoError(t, err)
err = os.WriteFile(filepath.Join(batDir, "type"), []byte("Battery"), 0o644)
assert.NoError(t, err)
assert.False(t, HasReadableBattery())
}

View File

@@ -1,4 +1,4 @@
//go:build freebsd //go:build !darwin && !linux && !windows
package battery package battery

View File

@@ -0,0 +1,298 @@
//go:build windows
// Most of the Windows battery code is based on
// distatus/battery by Karol 'Kenji Takahashi' Woźniak
package battery
import (
"errors"
"log/slog"
"math"
"sync"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
type batteryQueryInformation struct {
BatteryTag uint32
InformationLevel int32
AtRate int32
}
type batteryInformation struct {
Capabilities uint32
Technology uint8
Reserved [3]uint8
Chemistry [4]uint8
DesignedCapacity uint32
FullChargedCapacity uint32
DefaultAlert1 uint32
DefaultAlert2 uint32
CriticalBias uint32
CycleCount uint32
}
type batteryWaitStatus struct {
BatteryTag uint32
Timeout uint32
PowerState uint32
LowCapacity uint32
HighCapacity uint32
}
type batteryStatus struct {
PowerState uint32
Capacity uint32
Voltage uint32
Rate int32
}
type winGUID struct {
Data1 uint32
Data2 uint16
Data3 uint16
Data4 [8]byte
}
type spDeviceInterfaceData struct {
cbSize uint32
InterfaceClassGuid winGUID
Flags uint32
Reserved uint
}
var guidDeviceBattery = winGUID{
0x72631e54,
0x78A4,
0x11d0,
[8]byte{0xbc, 0xf7, 0x00, 0xaa, 0x00, 0xb7, 0xb3, 0x2a},
}
var (
setupapi = &windows.LazyDLL{Name: "setupapi.dll", System: true}
setupDiGetClassDevsW = setupapi.NewProc("SetupDiGetClassDevsW")
setupDiEnumDeviceInterfaces = setupapi.NewProc("SetupDiEnumDeviceInterfaces")
setupDiGetDeviceInterfaceDetailW = setupapi.NewProc("SetupDiGetDeviceInterfaceDetailW")
setupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList")
)
// winBatteryGet reads one battery by index. Returns (fullCapacity, currentCapacity, state, error).
// Returns error == errNotFound when there are no more batteries.
var errNotFound = errors.New("no more batteries")
func setupDiSetup(proc *windows.LazyProc, nargs, a1, a2, a3, a4, a5, a6 uintptr) (uintptr, error) {
_ = nargs
r1, _, errno := syscall.SyscallN(proc.Addr(), a1, a2, a3, a4, a5, a6)
if windows.Handle(r1) == windows.InvalidHandle {
if errno != 0 {
return 0, error(errno)
}
return 0, syscall.EINVAL
}
return r1, nil
}
func setupDiCall(proc *windows.LazyProc, nargs, a1, a2, a3, a4, a5, a6 uintptr) syscall.Errno {
_ = nargs
r1, _, errno := syscall.SyscallN(proc.Addr(), a1, a2, a3, a4, a5, a6)
if r1 == 0 {
if errno != 0 {
return errno
}
return syscall.EINVAL
}
return 0
}
func readWinBatteryState(powerState uint32) uint8 {
switch {
case powerState&0x00000004 != 0:
return stateCharging
case powerState&0x00000008 != 0:
return stateEmpty
case powerState&0x00000002 != 0:
return stateDischarging
case powerState&0x00000001 != 0:
return stateFull
default:
return stateUnknown
}
}
func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
hdev, err := setupDiSetup(
setupDiGetClassDevsW,
4,
uintptr(unsafe.Pointer(&guidDeviceBattery)),
0, 0,
2|16, // DIGCF_PRESENT|DIGCF_DEVICEINTERFACE
0, 0,
)
if err != nil {
return 0, 0, stateUnknown, err
}
defer syscall.SyscallN(setupDiDestroyDeviceInfoList.Addr(), hdev)
var did spDeviceInterfaceData
did.cbSize = uint32(unsafe.Sizeof(did))
errno := setupDiCall(
setupDiEnumDeviceInterfaces,
5,
hdev, 0,
uintptr(unsafe.Pointer(&guidDeviceBattery)),
uintptr(idx),
uintptr(unsafe.Pointer(&did)),
0,
)
if errno == 259 { // ERROR_NO_MORE_ITEMS
return 0, 0, stateUnknown, errNotFound
}
if errno != 0 {
return 0, 0, stateUnknown, errno
}
var cbRequired uint32
errno = setupDiCall(
setupDiGetDeviceInterfaceDetailW,
6,
hdev,
uintptr(unsafe.Pointer(&did)),
0, 0,
uintptr(unsafe.Pointer(&cbRequired)),
0,
)
if errno != 0 && errno != 122 { // ERROR_INSUFFICIENT_BUFFER
return 0, 0, stateUnknown, errno
}
didd := make([]uint16, cbRequired/2)
cbSize := (*uint32)(unsafe.Pointer(&didd[0]))
if unsafe.Sizeof(uint(0)) == 8 {
*cbSize = 8
} else {
*cbSize = 6
}
errno = setupDiCall(
setupDiGetDeviceInterfaceDetailW,
6,
hdev,
uintptr(unsafe.Pointer(&did)),
uintptr(unsafe.Pointer(&didd[0])),
uintptr(cbRequired),
uintptr(unsafe.Pointer(&cbRequired)),
0,
)
if errno != 0 {
return 0, 0, stateUnknown, errno
}
devicePath := &didd[2:][0]
handle, err := windows.CreateFile(
devicePath,
windows.GENERIC_READ|windows.GENERIC_WRITE,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE,
nil,
windows.OPEN_EXISTING,
windows.FILE_ATTRIBUTE_NORMAL,
0,
)
if err != nil {
return 0, 0, stateUnknown, err
}
defer windows.CloseHandle(handle)
var dwOut uint32
var dwWait uint32
var bqi batteryQueryInformation
err = windows.DeviceIoControl(
handle,
2703424, // IOCTL_BATTERY_QUERY_TAG
(*byte)(unsafe.Pointer(&dwWait)),
uint32(unsafe.Sizeof(dwWait)),
(*byte)(unsafe.Pointer(&bqi.BatteryTag)),
uint32(unsafe.Sizeof(bqi.BatteryTag)),
&dwOut, nil,
)
if err != nil || bqi.BatteryTag == 0 {
return 0, 0, stateUnknown, errors.New("battery tag not returned")
}
var bi batteryInformation
if err = windows.DeviceIoControl(
handle,
2703428, // IOCTL_BATTERY_QUERY_INFORMATION
(*byte)(unsafe.Pointer(&bqi)),
uint32(unsafe.Sizeof(bqi)),
(*byte)(unsafe.Pointer(&bi)),
uint32(unsafe.Sizeof(bi)),
&dwOut, nil,
); err != nil {
return 0, 0, stateUnknown, err
}
bws := batteryWaitStatus{BatteryTag: bqi.BatteryTag}
var bs batteryStatus
if err = windows.DeviceIoControl(
handle,
2703436, // IOCTL_BATTERY_QUERY_STATUS
(*byte)(unsafe.Pointer(&bws)),
uint32(unsafe.Sizeof(bws)),
(*byte)(unsafe.Pointer(&bs)),
uint32(unsafe.Sizeof(bs)),
&dwOut, nil,
); err != nil {
return 0, 0, stateUnknown, err
}
if bs.Capacity == 0xffffffff { // BATTERY_UNKNOWN_CAPACITY
return 0, 0, stateUnknown, errors.New("battery capacity unknown")
}
return bi.FullChargedCapacity, bs.Capacity, readWinBatteryState(bs.PowerState), nil
}
// HasReadableBattery checks if the system has a battery and returns true if it does.
var HasReadableBattery = sync.OnceValue(func() bool {
systemHasBattery := false
full, _, _, err := winBatteryGet(0)
if err == nil && full > 0 {
systemHasBattery = true
}
if !systemHasBattery {
slog.Debug("No battery found", "err", err)
}
return systemHasBattery
})
// GetBatteryStats returns the current battery percent and charge state.
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
if !HasReadableBattery() {
return batteryPercent, batteryState, errors.ErrUnsupported
}
totalFull := uint32(0)
totalCurrent := uint32(0)
batteryState = math.MaxUint8
for i := 0; ; i++ {
full, current, state, bErr := winBatteryGet(i)
if errors.Is(bErr, errNotFound) {
break
}
if bErr != nil || full == 0 {
continue
}
totalFull += full
totalCurrent += min(current, full)
batteryState = state
}
if totalFull == 0 || batteryState == math.MaxUint8 {
return batteryPercent, batteryState, errors.New("no battery capacity")
}
batteryPercent = uint8(float64(totalCurrent) / float64(totalFull) * 100)
return batteryPercent, batteryState, nil
}

View File

@@ -239,9 +239,11 @@ func (d *diskDiscovery) addConfiguredExtraFilesystems(extraFilesystems string) {
// addPartitionExtraFs registers partitions mounted under /extra-filesystems so // addPartitionExtraFs registers partitions mounted under /extra-filesystems so
// their display names can come from the folder name while their I/O keys still // their display names can come from the folder name while their I/O keys still
// prefer the underlying partition device. // prefer the underlying partition device. Only direct children are matched to
// avoid registering nested virtual mounts (e.g. /proc, /sys) that are returned by
// disk.Partitions(true) when the host root is bind-mounted in /extra-filesystems.
func (d *diskDiscovery) addPartitionExtraFs(p disk.PartitionStat) { func (d *diskDiscovery) addPartitionExtraFs(p disk.PartitionStat) {
if !strings.HasPrefix(p.Mountpoint, d.ctx.efPath) { if filepath.Dir(p.Mountpoint) != d.ctx.efPath {
return return
} }
device, customName := extraFilesystemPartitionInfo(p) device, customName := extraFilesystemPartitionInfo(p)
@@ -629,9 +631,17 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
} }
} }
// getRootMountPoint returns the appropriate root mount point for the system // getRootMountPoint returns the appropriate root mount point for the system.
// On Windows it returns the system drive (e.g. "C:").
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of / // For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
func (a *Agent) getRootMountPoint() string { func (a *Agent) getRootMountPoint() string {
if runtime.GOOS == "windows" {
if sd := os.Getenv("SystemDrive"); sd != "" {
return sd
}
return "C:"
}
// 1. Check if /etc/os-release contains indicators of an immutable system // 1. Check if /etc/os-release contains indicators of an immutable system
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil { if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
content := string(osReleaseContent) content := string(osReleaseContent)

View File

@@ -530,6 +530,87 @@ func TestAddExtraFilesystemFolders(t *testing.T) {
}) })
} }
func TestAddPartitionExtraFs(t *testing.T) {
makeDiscovery := func(agent *Agent) diskDiscovery {
return diskDiscovery{
agent: agent,
ctx: fsRegistrationContext{
isWindows: false,
efPath: "/extra-filesystems",
diskIoCounters: map[string]disk.IOCountersStat{
"nvme0n1p1": {Name: "nvme0n1p1"},
"nvme1n1": {Name: "nvme1n1"},
},
},
}
}
t.Run("registers direct child of extra-filesystems", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
d := makeDiscovery(agent)
d.addPartitionExtraFs(disk.PartitionStat{
Device: "/dev/nvme0n1p1",
Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root",
})
stats, exists := agent.fsStats["nvme0n1p1"]
assert.True(t, exists)
assert.Equal(t, "/extra-filesystems/nvme0n1p1__caddy1-root", stats.Mountpoint)
assert.Equal(t, "caddy1-root", stats.Name)
})
t.Run("skips nested mount under extra-filesystem bind mount", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
d := makeDiscovery(agent)
// These simulate the virtual mounts that appear when host / is bind-mounted
// with disk.Partitions(all=true) — e.g. /proc, /sys, /dev visible under the mount.
for _, nested := range []string{
"/extra-filesystems/nvme0n1p1__caddy1-root/proc",
"/extra-filesystems/nvme0n1p1__caddy1-root/sys",
"/extra-filesystems/nvme0n1p1__caddy1-root/dev",
"/extra-filesystems/nvme0n1p1__caddy1-root/run",
} {
d.addPartitionExtraFs(disk.PartitionStat{Device: "tmpfs", Mountpoint: nested})
}
assert.Empty(t, agent.fsStats)
})
t.Run("registers both direct children, skips their nested mounts", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
d := makeDiscovery(agent)
partitions := []disk.PartitionStat{
{Device: "/dev/nvme0n1p1", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root"},
{Device: "/dev/nvme1n1", Mountpoint: "/extra-filesystems/nvme1n1__caddy1-docker"},
{Device: "proc", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/proc"},
{Device: "sysfs", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/sys"},
{Device: "overlay", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/var/lib/docker"},
}
for _, p := range partitions {
d.addPartitionExtraFs(p)
}
assert.Len(t, agent.fsStats, 2)
assert.Equal(t, "caddy1-root", agent.fsStats["nvme0n1p1"].Name)
assert.Equal(t, "caddy1-docker", agent.fsStats["nvme1n1"].Name)
})
t.Run("skips partition not under extra-filesystems", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
d := makeDiscovery(agent)
d.addPartitionExtraFs(disk.PartitionStat{
Device: "/dev/nvme0n1p1",
Mountpoint: "/",
})
assert.Empty(t, agent.fsStats)
})
}
func TestFindIoDevice(t *testing.T) { func TestFindIoDevice(t *testing.T) {
t.Run("matches by device name", func(t *testing.T) { t.Run("matches by device name", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{ ioCounters := map[string]disk.IOCountersStat{

View File

@@ -8,6 +8,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.5" /> <PackageReference Include="LibreHardwareMonitorLib" Version="0.9.6" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -19,13 +19,20 @@ import (
"github.com/shirou/gopsutil/v4/sensors" "github.com/shirou/gopsutil/v4/sensors"
) )
var errTemperatureFetchTimeout = errors.New("temperature collection timed out")
// Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832)
type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error)
type SensorConfig struct { type SensorConfig struct {
context context.Context context context.Context
sensors map[string]struct{} sensors map[string]struct{}
primarySensor string primarySensor string
timeout time.Duration
isBlacklist bool isBlacklist bool
hasWildcards bool hasWildcards bool
skipCollection bool skipCollection bool
firstRun bool
} }
func (a *Agent) newSensorConfig() *SensorConfig { func (a *Agent) newSensorConfig() *SensorConfig {
@@ -33,25 +40,29 @@ func (a *Agent) newSensorConfig() *SensorConfig {
sysSensors, _ := utils.GetEnv("SYS_SENSORS") sysSensors, _ := utils.GetEnv("SYS_SENSORS")
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS") sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS")
skipCollection := sensorsSet && sensorsEnvVal == "" skipCollection := sensorsSet && sensorsEnvVal == ""
sensorsTimeout, _ := utils.GetEnv("SENSORS_TIMEOUT")
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection) return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection)
} }
// Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832)
type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error)
var (
errTemperatureFetchTimeout = errors.New("temperature collection timed out")
temperatureFetchTimeout = 2 * time.Second
)
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables // newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string) // sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal string, skipCollection bool) *SensorConfig { func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection bool) *SensorConfig {
timeout := 2 * time.Second
if sensorsTimeout != "" {
if d, err := time.ParseDuration(sensorsTimeout); err == nil {
timeout = d
} else {
slog.Warn("Invalid SENSORS_TIMEOUT", "value", sensorsTimeout)
}
}
config := &SensorConfig{ config := &SensorConfig{
context: context.Background(), context: context.Background(),
primarySensor: primarySensor, primarySensor: primarySensor,
timeout: timeout,
skipCollection: skipCollection, skipCollection: skipCollection,
firstRun: true,
sensors: make(map[string]struct{}), sensors: make(map[string]struct{}),
} }
@@ -167,6 +178,14 @@ func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureS
err error err error
} }
// Use a longer timeout on the first run to allow for initialization
// (e.g. Windows LHM subprocess startup)
timeout := a.sensorConfig.timeout
if a.sensorConfig.firstRun {
a.sensorConfig.firstRun = false
timeout = 10 * time.Second
}
resultCh := make(chan result, 1) resultCh := make(chan result, 1)
go func() { go func() {
temps, err := a.getTempsWithPanicRecovery(getTemps) temps, err := a.getTempsWithPanicRecovery(getTemps)
@@ -176,7 +195,7 @@ func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureS
select { select {
case res := <-resultCh: case res := <-resultCh:
return res.temps, res.err return res.temps, res.err
case <-time.After(temperatureFetchTimeout): case <-time.After(timeout):
return nil, errTemperatureFetchTimeout return nil, errTemperatureFetchTimeout
} }
} }

View File

@@ -168,6 +168,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
primarySensor string primarySensor string
sysSensors string sysSensors string
sensors string sensors string
sensorsTimeout string
skipCollection bool skipCollection bool
expectedConfig *SensorConfig expectedConfig *SensorConfig
}{ }{
@@ -179,12 +180,37 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{ expectedConfig: &SensorConfig{
context: context.Background(), context: context.Background(),
primarySensor: "", primarySensor: "",
timeout: 2 * time.Second,
sensors: map[string]struct{}{}, sensors: map[string]struct{}{},
isBlacklist: false, isBlacklist: false,
hasWildcards: false, hasWildcards: false,
skipCollection: false, skipCollection: false,
}, },
}, },
{
name: "Custom timeout",
primarySensor: "",
sysSensors: "",
sensors: "",
sensorsTimeout: "5s",
expectedConfig: &SensorConfig{
context: context.Background(),
timeout: 5 * time.Second,
sensors: map[string]struct{}{},
},
},
{
name: "Invalid timeout falls back to default",
primarySensor: "",
sysSensors: "",
sensors: "",
sensorsTimeout: "notaduration",
expectedConfig: &SensorConfig{
context: context.Background(),
timeout: 2 * time.Second,
sensors: map[string]struct{}{},
},
},
{ {
name: "Explicitly set to empty string", name: "Explicitly set to empty string",
primarySensor: "", primarySensor: "",
@@ -194,6 +220,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{ expectedConfig: &SensorConfig{
context: context.Background(), context: context.Background(),
primarySensor: "", primarySensor: "",
timeout: 2 * time.Second,
sensors: map[string]struct{}{}, sensors: map[string]struct{}{},
isBlacklist: false, isBlacklist: false,
hasWildcards: false, hasWildcards: false,
@@ -208,6 +235,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{ expectedConfig: &SensorConfig{
context: context.Background(), context: context.Background(),
primarySensor: "cpu_temp", primarySensor: "cpu_temp",
timeout: 2 * time.Second,
sensors: map[string]struct{}{}, sensors: map[string]struct{}{},
isBlacklist: false, isBlacklist: false,
hasWildcards: false, hasWildcards: false,
@@ -221,6 +249,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{ expectedConfig: &SensorConfig{
context: context.Background(), context: context.Background(),
primarySensor: "cpu_temp", primarySensor: "cpu_temp",
timeout: 2 * time.Second,
sensors: map[string]struct{}{ sensors: map[string]struct{}{
"cpu_temp": {}, "cpu_temp": {},
"gpu_temp": {}, "gpu_temp": {},
@@ -237,6 +266,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{ expectedConfig: &SensorConfig{
context: context.Background(), context: context.Background(),
primarySensor: "cpu_temp", primarySensor: "cpu_temp",
timeout: 2 * time.Second,
sensors: map[string]struct{}{ sensors: map[string]struct{}{
"cpu_temp": {}, "cpu_temp": {},
"gpu_temp": {}, "gpu_temp": {},
@@ -253,6 +283,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{ expectedConfig: &SensorConfig{
context: context.Background(), context: context.Background(),
primarySensor: "cpu_temp", primarySensor: "cpu_temp",
timeout: 2 * time.Second,
sensors: map[string]struct{}{ sensors: map[string]struct{}{
"cpu_*": {}, "cpu_*": {},
"gpu_temp": {}, "gpu_temp": {},
@@ -269,6 +300,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{ expectedConfig: &SensorConfig{
context: context.Background(), context: context.Background(),
primarySensor: "cpu_temp", primarySensor: "cpu_temp",
timeout: 2 * time.Second,
sensors: map[string]struct{}{ sensors: map[string]struct{}{
"cpu_*": {}, "cpu_*": {},
"gpu_temp": {}, "gpu_temp": {},
@@ -284,6 +316,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
sensors: "cpu_temp", sensors: "cpu_temp",
expectedConfig: &SensorConfig{ expectedConfig: &SensorConfig{
primarySensor: "cpu_temp", primarySensor: "cpu_temp",
timeout: 2 * time.Second,
sensors: map[string]struct{}{ sensors: map[string]struct{}{
"cpu_temp": {}, "cpu_temp": {},
}, },
@@ -295,7 +328,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.skipCollection) result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection)
// Check primary sensor // Check primary sensor
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor) assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
@@ -314,6 +347,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
// Check flags // Check flags
assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist) assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist)
assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards) assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards)
assert.Equal(t, tt.expectedConfig.timeout, result.timeout)
// Check context // Check context
if tt.sysSensors != "" { if tt.sysSensors != "" {
@@ -333,12 +367,14 @@ func TestNewSensorConfig(t *testing.T) {
t.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary") t.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary")
t.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path") t.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path")
t.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3") t.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3")
t.Setenv("BESZEL_AGENT_SENSORS_TIMEOUT", "7s")
agent := &Agent{} agent := &Agent{}
result := agent.newSensorConfig() result := agent.newSensorConfig()
// Verify results // Verify results
assert.Equal(t, "test_primary", result.primarySensor) assert.Equal(t, "test_primary", result.primarySensor)
assert.Equal(t, 7*time.Second, result.timeout)
assert.NotNil(t, result.sensors) assert.NotNil(t, result.sensors)
assert.Equal(t, 3, len(result.sensors)) assert.Equal(t, 3, len(result.sensors))
assert.True(t, result.hasWildcards) assert.True(t, result.hasWildcards)
@@ -532,15 +568,10 @@ func TestGetTempsWithTimeout(t *testing.T) {
agent := &Agent{ agent := &Agent{
sensorConfig: &SensorConfig{ sensorConfig: &SensorConfig{
context: context.Background(), context: context.Background(),
timeout: 10 * time.Millisecond,
}, },
} }
originalTimeout := temperatureFetchTimeout
t.Cleanup(func() {
temperatureFetchTimeout = originalTimeout
})
temperatureFetchTimeout = 10 * time.Millisecond
t.Run("returns temperatures before timeout", func(t *testing.T) { t.Run("returns temperatures before timeout", func(t *testing.T) {
temps, err := agent.getTempsWithTimeout(func(ctx context.Context) ([]sensors.TemperatureStat, error) { temps, err := agent.getTempsWithTimeout(func(ctx context.Context) ([]sensors.TemperatureStat, error) {
return []sensors.TemperatureStat{{SensorKey: "cpu_temp", Temperature: 42}}, nil return []sensors.TemperatureStat{{SensorKey: "cpu_temp", Temperature: 42}}, nil
@@ -567,15 +598,13 @@ func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
systemInfo: system.Info{DashboardTemp: 99}, systemInfo: system.Info{DashboardTemp: 99},
sensorConfig: &SensorConfig{ sensorConfig: &SensorConfig{
context: context.Background(), context: context.Background(),
timeout: 10 * time.Millisecond,
}, },
} }
originalTimeout := temperatureFetchTimeout
t.Cleanup(func() { t.Cleanup(func() {
temperatureFetchTimeout = originalTimeout
getSensorTemps = sensors.TemperaturesWithContext getSensorTemps = sensors.TemperaturesWithContext
}) })
temperatureFetchTimeout = 10 * time.Millisecond
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) { getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
return nil, nil return nil, nil

View File

@@ -31,6 +31,9 @@ type SmartManager struct {
lastScanTime time.Time lastScanTime time.Time
smartctlPath string smartctlPath string
excludedDevices map[string]struct{} excludedDevices map[string]struct{}
darwinNvmeOnce sync.Once
darwinNvmeCapacity map[string]uint64 // serial → bytes cache, written once via darwinNvmeOnce
darwinNvmeProvider func() ([]byte, error) // overridable for testing
} }
type scanOutput struct { type scanOutput struct {
@@ -1033,6 +1036,52 @@ func parseScsiGigabytesProcessed(value string) int64 {
return parsed return parsed
} }
// lookupDarwinNvmeCapacity returns the capacity in bytes for a given NVMe serial number on Darwin.
// It uses system_profiler SPNVMeDataType to get capacity since Apple SSDs don't report user_capacity
// via smartctl. Results are cached after the first call via sync.Once.
func (sm *SmartManager) lookupDarwinNvmeCapacity(serial string) uint64 {
sm.darwinNvmeOnce.Do(func() {
sm.darwinNvmeCapacity = make(map[string]uint64)
provider := sm.darwinNvmeProvider
if provider == nil {
provider = func() ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return exec.CommandContext(ctx, "system_profiler", "SPNVMeDataType", "-json").Output()
}
}
out, err := provider()
if err != nil {
slog.Debug("system_profiler NVMe lookup failed", "err", err)
return
}
var result struct {
SPNVMeDataType []struct {
Items []struct {
DeviceSerial string `json:"device_serial"`
SizeInBytes uint64 `json:"size_in_bytes"`
} `json:"_items"`
} `json:"SPNVMeDataType"`
}
if err := json.Unmarshal(out, &result); err != nil {
slog.Debug("system_profiler NVMe parse failed", "err", err)
return
}
for _, controller := range result.SPNVMeDataType {
for _, item := range controller.Items {
if item.DeviceSerial != "" && item.SizeInBytes > 0 {
sm.darwinNvmeCapacity[item.DeviceSerial] = item.SizeInBytes
}
}
}
})
return sm.darwinNvmeCapacity[serial]
}
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap // parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap
// Returns hasValidData and exitStatus // Returns hasValidData and exitStatus
func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) { func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
@@ -1069,6 +1118,9 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
smartData.SerialNumber = data.SerialNumber smartData.SerialNumber = data.SerialNumber
smartData.FirmwareVersion = data.FirmwareVersion smartData.FirmwareVersion = data.FirmwareVersion
smartData.Capacity = data.UserCapacity.Bytes smartData.Capacity = data.UserCapacity.Bytes
if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) {
smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber)
}
smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed) smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name smartData.DiskName = data.Device.Name

View File

@@ -1199,3 +1199,81 @@ func TestIsNvmeControllerPath(t *testing.T) {
}) })
} }
} }
func TestParseSmartForNvmeAppleSSD(t *testing.T) {
// Apple SSDs don't report user_capacity via smartctl; capacity should be fetched
// from system_profiler via the darwinNvmeProvider fallback.
fixturePath := filepath.Join("test-data", "smart", "apple_nvme.json")
data, err := os.ReadFile(fixturePath)
require.NoError(t, err)
providerCalls := 0
fakeProvider := func() ([]byte, error) {
providerCalls++
return []byte(`{
"SPNVMeDataType": [{
"_items": [{
"device_serial": "0ba0147940253c15",
"size_in_bytes": 251000193024
}]
}]
}`), nil
}
sm := &SmartManager{
SmartDataMap: make(map[string]*smart.SmartData),
darwinNvmeProvider: fakeProvider,
}
hasData, _ := sm.parseSmartForNvme(data)
require.True(t, hasData)
deviceData, ok := sm.SmartDataMap["0ba0147940253c15"]
require.True(t, ok)
assert.Equal(t, "APPLE SSD AP0256Q", deviceData.ModelName)
assert.Equal(t, uint64(251000193024), deviceData.Capacity)
assert.Equal(t, uint8(42), deviceData.Temperature)
assert.Equal(t, "PASSED", deviceData.SmartStatus)
assert.Equal(t, 1, providerCalls, "system_profiler should be called once")
// Second parse: provider should NOT be called again (cache hit)
_, _ = sm.parseSmartForNvme(data)
assert.Equal(t, 1, providerCalls, "system_profiler should not be called again after caching")
}
func TestLookupDarwinNvmeCapacityMultipleDisks(t *testing.T) {
fakeProvider := func() ([]byte, error) {
return []byte(`{
"SPNVMeDataType": [
{
"_items": [
{"device_serial": "serial-disk0", "size_in_bytes": 251000193024},
{"device_serial": "serial-disk1", "size_in_bytes": 1000204886016}
]
},
{
"_items": [
{"device_serial": "serial-disk2", "size_in_bytes": 512110190592}
]
}
]
}`), nil
}
sm := &SmartManager{darwinNvmeProvider: fakeProvider}
assert.Equal(t, uint64(251000193024), sm.lookupDarwinNvmeCapacity("serial-disk0"))
assert.Equal(t, uint64(1000204886016), sm.lookupDarwinNvmeCapacity("serial-disk1"))
assert.Equal(t, uint64(512110190592), sm.lookupDarwinNvmeCapacity("serial-disk2"))
assert.Equal(t, uint64(0), sm.lookupDarwinNvmeCapacity("unknown-serial"))
}
func TestLookupDarwinNvmeCapacityProviderError(t *testing.T) {
fakeProvider := func() ([]byte, error) {
return nil, errors.New("system_profiler not found")
}
sm := &SmartManager{darwinNvmeProvider: fakeProvider}
assert.Equal(t, uint64(0), sm.lookupDarwinNvmeCapacity("any-serial"))
// Cache should be initialized even on error so we don't retry (Once already fired)
assert.NotNil(t, sm.darwinNvmeCapacity)
}

View File

@@ -0,0 +1,51 @@
{
"json_format_version": [1, 0],
"smartctl": {
"version": [7, 4],
"argv": ["smartctl", "-aix", "-j", "IOService:/AppleARMPE/arm-io@10F00000/AppleT810xIO/ans@77400000/AppleASCWrapV4/iop-ans-nub/RTBuddy(ANS2)/RTBuddyService/AppleANS3NVMeController/NS_01@1"],
"exit_status": 4
},
"device": {
"name": "IOService:/AppleARMPE/arm-io@10F00000/AppleT810xIO/ans@77400000/AppleASCWrapV4/iop-ans-nub/RTBuddy(ANS2)/RTBuddyService/AppleANS3NVMeController/NS_01@1",
"info_name": "IOService:/AppleARMPE/arm-io@10F00000/AppleT810xIO/ans@77400000/AppleASCWrapV4/iop-ans-nub/RTBuddy(ANS2)/RTBuddyService/AppleANS3NVMeController/NS_01@1",
"type": "nvme",
"protocol": "NVMe"
},
"model_name": "APPLE SSD AP0256Q",
"serial_number": "0ba0147940253c15",
"firmware_version": "555",
"smart_support": {
"available": true,
"enabled": true
},
"smart_status": {
"passed": true,
"nvme": {
"value": 0
}
},
"nvme_smart_health_information_log": {
"critical_warning": 0,
"temperature": 42,
"available_spare": 100,
"available_spare_threshold": 99,
"percentage_used": 1,
"data_units_read": 270189386,
"data_units_written": 166753862,
"host_reads": 7543766995,
"host_writes": 3761621926,
"controller_busy_time": 0,
"power_cycles": 366,
"power_on_hours": 2850,
"unsafe_shutdowns": 195,
"media_errors": 0,
"num_err_log_entries": 0
},
"temperature": {
"current": 42
},
"power_cycle_count": 366,
"power_on_time": {
"hours": 2850
}
}

5
go.mod
View File

@@ -5,7 +5,6 @@ go 1.26.1
require ( require (
github.com/blang/semver v3.5.1+incompatible github.com/blang/semver v3.5.1+incompatible
github.com/coreos/go-systemd/v22 v22.7.0 github.com/coreos/go-systemd/v22 v22.7.0
github.com/distatus/battery v0.11.0
github.com/ebitengine/purego v0.10.0 github.com/ebitengine/purego v0.10.0
github.com/fxamacker/cbor/v2 v2.9.0 github.com/fxamacker/cbor/v2 v2.9.0
github.com/gliderlabs/ssh v0.3.8 github.com/gliderlabs/ssh v0.3.8
@@ -14,7 +13,7 @@ require (
github.com/nicholas-fedor/shoutrrr v0.14.1 github.com/nicholas-fedor/shoutrrr v0.14.1
github.com/pocketbase/dbx v1.12.0 github.com/pocketbase/dbx v1.12.0
github.com/pocketbase/pocketbase v0.36.7 github.com/pocketbase/pocketbase v0.36.7
github.com/shirou/gopsutil/v4 v4.26.2 github.com/shirou/gopsutil/v4 v4.26.3
github.com/spf13/cast v1.10.0 github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
@@ -23,6 +22,7 @@ require (
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
golang.org/x/sys v0.42.0 golang.org/x/sys v0.42.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
howett.net/plist v1.0.1
) )
require ( require (
@@ -61,7 +61,6 @@ require (
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/term v0.41.0 // indirect golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect
howett.net/plist v1.0.1 // indirect
modernc.org/libc v1.70.0 // indirect modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect

4
go.sum
View File

@@ -17,8 +17,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/distatus/battery v0.11.0 h1:KJk89gz90Iq/wJtbjjM9yUzBXV+ASV/EG2WOOL7N8lc=
github.com/distatus/battery v0.11.0/go.mod h1:KmVkE8A8hpIX4T78QRdMktYpEp35QfOL8A8dwZBxq2k=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -109,6 +107,8 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=

View File

@@ -109,6 +109,18 @@ func (am *AlertManager) cancelPendingAlert(alertID string) bool {
return true return true
} }
// CancelPendingStatusAlerts cancels all pending status alert timers for a given system.
// This is called when a system is paused to prevent delayed alerts from firing.
func (am *AlertManager) CancelPendingStatusAlerts(systemID string) {
am.pendingAlerts.Range(func(key, value any) bool {
info := value.(*alertInfo)
if info.alertData.SystemID == systemID {
am.cancelPendingAlert(key.(string))
}
return true
})
}
// processPendingAlert sends a "down" alert if the pending alert has expired and the system is still down. // processPendingAlert sends a "down" alert if the pending alert has expired and the system is still down.
func (am *AlertManager) processPendingAlert(alertID string) { func (am *AlertManager) processPendingAlert(alertID string) {
value, loaded := am.pendingAlerts.LoadAndDelete(alertID) value, loaded := am.pendingAlerts.LoadAndDelete(alertID)

View File

@@ -941,3 +941,68 @@ func TestStatusAlertClearedBeforeSend(t *testing.T) {
assert.EqualValues(t, 0, alertHistoryCount, "Should have no unresolved alert history records since alert never triggered") assert.EqualValues(t, 0, alertHistoryCount, "Should have no unresolved alert history records since alert never triggered")
}) })
} }
func TestCancelPendingStatusAlertsClearsAllAlertsForSystem(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
require.NoError(t, err)
userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`)
require.NoError(t, hub.Save(userSettings))
systemCollection, err := hub.FindCollectionByNameOrId("systems")
require.NoError(t, err)
system1 := core.NewRecord(systemCollection)
system1.Set("name", "system-1")
system1.Set("status", "up")
system1.Set("host", "127.0.0.1")
system1.Set("users", []string{user.Id})
require.NoError(t, hub.Save(system1))
system2 := core.NewRecord(systemCollection)
system2.Set("name", "system-2")
system2.Set("status", "up")
system2.Set("host", "127.0.0.2")
system2.Set("users", []string{user.Id})
require.NoError(t, hub.Save(system2))
alertCollection, err := hub.FindCollectionByNameOrId("alerts")
require.NoError(t, err)
alert1 := core.NewRecord(alertCollection)
alert1.Set("user", user.Id)
alert1.Set("system", system1.Id)
alert1.Set("name", "Status")
alert1.Set("triggered", false)
alert1.Set("min", 5)
require.NoError(t, hub.Save(alert1))
alert2 := core.NewRecord(alertCollection)
alert2.Set("user", user.Id)
alert2.Set("system", system2.Id)
alert2.Set("name", "Status")
alert2.Set("triggered", false)
alert2.Set("min", 5)
require.NoError(t, hub.Save(alert2))
am := alerts.NewTestAlertManagerWithoutWorker(hub)
initialEmailCount := hub.TestMailer.TotalSend()
// Both systems go down
require.NoError(t, am.HandleStatusAlerts("down", system1))
require.NoError(t, am.HandleStatusAlerts("down", system2))
assert.Equal(t, 2, am.GetPendingAlertsCount(), "both systems should have pending alerts")
// System 1 is paused — cancel its pending alerts
am.CancelPendingStatusAlerts(system1.Id)
assert.Equal(t, 1, am.GetPendingAlertsCount(), "only system2 alert should remain pending after pausing system1")
// Expire and process remaining alerts — only system2 should fire
am.ForceExpirePendingAlerts()
processed, err := am.ProcessPendingAlerts()
require.NoError(t, err)
assert.Len(t, processed, 1, "only the non-paused system's alert should be processed")
assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend(), "only system2 should send a down notification")
}

View File

@@ -3,6 +3,7 @@ package hub
import ( import (
"context" "context"
"net/http" "net/http"
"regexp"
"strings" "strings"
"time" "time"
@@ -25,6 +26,32 @@ type UpdateInfo struct {
Url string `json:"url"` Url string `json:"url"`
} }
var containerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
// Middleware to allow only admin role users
var requireAdminRole = customAuthMiddleware(func(e *core.RequestEvent) bool {
return e.Auth.GetString("role") == "admin"
})
// Middleware to exclude readonly users
var excludeReadOnlyRole = customAuthMiddleware(func(e *core.RequestEvent) bool {
return e.Auth.GetString("role") != "readonly"
})
// customAuthMiddleware handles boilerplate for custom authentication middlewares. fn should
// return true if the request is allowed, false otherwise. e.Auth is guaranteed to be non-nil.
func customAuthMiddleware(fn func(*core.RequestEvent) bool) func(*core.RequestEvent) error {
return func(e *core.RequestEvent) error {
if e.Auth == nil {
return e.UnauthorizedError("The request requires valid record authorization token.", nil)
}
if !fn(e) {
return e.ForbiddenError("The authorized record is not allowed to perform this action.", nil)
}
return e.Next()
}
}
// registerMiddlewares registers custom middlewares // registerMiddlewares registers custom middlewares
func (h *Hub) registerMiddlewares(se *core.ServeEvent) { func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
// authorizes request with user matching the provided email // authorizes request with user matching the provided email
@@ -33,7 +60,7 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
return e.Next() return e.Next()
} }
isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost
e.Auth, err = e.App.FindFirstRecordByData("users", "email", email) e.Auth, err = e.App.FindAuthRecordByEmail("users", email)
if err != nil || !isAuthRefresh { if err != nil || !isAuthRefresh {
return e.Next() return e.Next()
} }
@@ -84,19 +111,19 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// send test notification // send test notification
apiAuth.POST("/test-notification", h.SendTestNotification) apiAuth.POST("/test-notification", h.SendTestNotification)
// heartbeat status and test // heartbeat status and test
apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus) apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus).BindFunc(requireAdminRole)
apiAuth.POST("/test-heartbeat", h.testHeartbeat) apiAuth.POST("/test-heartbeat", h.testHeartbeat).BindFunc(requireAdminRole)
// get config.yml content // get config.yml content
apiAuth.GET("/config-yaml", config.GetYamlConfig) apiAuth.GET("/config-yaml", config.GetYamlConfig).BindFunc(requireAdminRole)
// handle agent websocket connection // handle agent websocket connection
apiNoAuth.GET("/agent-connect", h.handleAgentConnect) apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
// get or create universal tokens // get or create universal tokens
apiAuth.GET("/universal-token", h.getUniversalToken) apiAuth.GET("/universal-token", h.getUniversalToken).BindFunc(excludeReadOnlyRole)
// update / delete user alerts // update / delete user alerts
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts) apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts) apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
// refresh SMART devices for a system // refresh SMART devices for a system
apiAuth.POST("/smart/refresh", h.refreshSmartData) apiAuth.POST("/smart/refresh", h.refreshSmartData).BindFunc(excludeReadOnlyRole)
// get systemd service details // get systemd service details
apiAuth.GET("/systemd/info", h.getSystemdInfo) apiAuth.GET("/systemd/info", h.getSystemdInfo)
// /containers routes // /containers routes
@@ -153,6 +180,10 @@ func (info *UpdateInfo) getUpdate(e *core.RequestEvent) error {
// GetUniversalToken handles the universal token API endpoint (create, read, delete) // GetUniversalToken handles the universal token API endpoint (create, read, delete)
func (h *Hub) getUniversalToken(e *core.RequestEvent) error { func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
if e.Auth.IsSuperuser() {
return e.ForbiddenError("Superusers cannot use universal tokens", nil)
}
tokenMap := universalTokenMap.GetMap() tokenMap := universalTokenMap.GetMap()
userID := e.Auth.Id userID := e.Auth.Id
query := e.Request.URL.Query() query := e.Request.URL.Query()
@@ -246,9 +277,6 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
// getHeartbeatStatus returns current heartbeat configuration and whether it's enabled // getHeartbeatStatus returns current heartbeat configuration and whether it's enabled
func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error { func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error {
if e.Auth.GetString("role") != "admin" {
return e.ForbiddenError("Requires admin role", nil)
}
if h.hb == nil { if h.hb == nil {
return e.JSON(http.StatusOK, map[string]any{ return e.JSON(http.StatusOK, map[string]any{
"enabled": false, "enabled": false,
@@ -266,9 +294,6 @@ func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error {
// testHeartbeat triggers a single heartbeat ping and returns the result // testHeartbeat triggers a single heartbeat ping and returns the result
func (h *Hub) testHeartbeat(e *core.RequestEvent) error { func (h *Hub) testHeartbeat(e *core.RequestEvent) error {
if e.Auth.GetString("role") != "admin" {
return e.ForbiddenError("Requires admin role", nil)
}
if h.hb == nil { if h.hb == nil {
return e.JSON(http.StatusOK, map[string]any{ return e.JSON(http.StatusOK, map[string]any{
"err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.", "err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.",
@@ -285,21 +310,18 @@ func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*syst
systemID := e.Request.URL.Query().Get("system") systemID := e.Request.URL.Query().Get("system")
containerID := e.Request.URL.Query().Get("container") containerID := e.Request.URL.Query().Get("container")
if systemID == "" || containerID == "" { if systemID == "" || containerID == "" || !containerIDPattern.MatchString(containerID) {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and container parameters are required"}) return e.BadRequestError("Invalid system or container parameter", nil)
}
if !containerIDPattern.MatchString(containerID) {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid container parameter"})
} }
system, err := h.sm.GetSystem(systemID) system, err := h.sm.GetSystem(systemID)
if err != nil { if err != nil || !system.HasUser(e.App, e.Auth.Id) {
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"}) return e.NotFoundError("", nil)
} }
data, err := fetchFunc(system, containerID) data, err := fetchFunc(system, containerID)
if err != nil { if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) return e.InternalServerError("", err)
} }
return e.JSON(http.StatusOK, map[string]string{responseKey: data}) return e.JSON(http.StatusOK, map[string]string{responseKey: data})
@@ -325,15 +347,23 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
serviceName := query.Get("service") serviceName := query.Get("service")
if systemID == "" || serviceName == "" { if systemID == "" || serviceName == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and service parameters are required"}) return e.BadRequestError("Invalid system or service parameter", nil)
} }
system, err := h.sm.GetSystem(systemID) system, err := h.sm.GetSystem(systemID)
if err != nil || !system.HasUser(e.App, e.Auth.Id) {
return e.NotFoundError("", nil)
}
// verify service exists before fetching details
_, err = e.App.FindFirstRecordByFilter("systemd_services", "system = {:system} && name = {:name}", dbx.Params{
"system": systemID,
"name": serviceName,
})
if err != nil { if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"}) return e.NotFoundError("", err)
} }
details, err := system.FetchSystemdInfoFromAgent(serviceName) details, err := system.FetchSystemdInfoFromAgent(serviceName)
if err != nil { if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) return e.InternalServerError("", err)
} }
e.Response.Header().Set("Cache-Control", "public, max-age=60") e.Response.Header().Set("Cache-Control", "public, max-age=60")
return e.JSON(http.StatusOK, map[string]any{"details": details}) return e.JSON(http.StatusOK, map[string]any{"details": details})
@@ -344,17 +374,16 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
func (h *Hub) refreshSmartData(e *core.RequestEvent) error { func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
systemID := e.Request.URL.Query().Get("system") systemID := e.Request.URL.Query().Get("system")
if systemID == "" { if systemID == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"}) return e.BadRequestError("Invalid system parameter", nil)
} }
system, err := h.sm.GetSystem(systemID) system, err := h.sm.GetSystem(systemID)
if err != nil { if err != nil || !system.HasUser(e.App, e.Auth.Id) {
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"}) return e.NotFoundError("", nil)
} }
// Fetch and save SMART devices
if err := system.FetchAndSaveSmartDevices(); err != nil { if err := system.FetchAndSaveSmartDevices(); err != nil {
return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return e.InternalServerError("", err)
} }
return e.JSON(http.StatusOK, map[string]string{"status": "ok"}) return e.JSON(http.StatusOK, map[string]string{"status": "ok"})

View File

@@ -3,6 +3,7 @@ package hub_test
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"testing" "testing"
@@ -25,33 +26,33 @@ func jsonReader(v any) io.Reader {
} }
func TestApiRoutesAuthentication(t *testing.T) { func TestApiRoutesAuthentication(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir()) hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
hub.StartHub()
// Create test user and get auth token
user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
require.NoError(t, err, "Failed to create test user")
adminUser, err := beszelTests.CreateRecord(hub, "users", map[string]any{
"email": "admin@example.com",
"password": "password123",
"role": "admin",
})
require.NoError(t, err, "Failed to create admin user")
adminUserToken, err := adminUser.NewAuthToken()
// superUser, err := beszelTests.CreateRecord(hub, core.CollectionNameSuperusers, map[string]any{
// "email": "superuser@example.com",
// "password": "password123",
// })
// require.NoError(t, err, "Failed to create superuser")
userToken, err := user.NewAuthToken() userToken, err := user.NewAuthToken()
require.NoError(t, err, "Failed to create auth token") require.NoError(t, err, "Failed to create auth token")
// Create test system for user-alerts endpoints // Create test user and get auth token
user2, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
require.NoError(t, err, "Failed to create test user")
user2Token, err := user2.NewAuthToken()
require.NoError(t, err, "Failed to create user2 auth token")
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
require.NoError(t, err, "Failed to create admin user")
adminUserToken, err := adminUser.NewAuthToken()
readOnlyUser, err := beszelTests.CreateUserWithRole(hub, "readonly@example.com", "password123", "readonly")
require.NoError(t, err, "Failed to create readonly user")
readOnlyUserToken, err := readOnlyUser.NewAuthToken()
require.NoError(t, err, "Failed to create readonly user auth token")
superuser, err := beszelTests.CreateSuperuser(hub, "superuser@example.com", "password123")
require.NoError(t, err, "Failed to create superuser")
superuserToken, err := superuser.NewAuthToken()
require.NoError(t, err, "Failed to create superuser auth token")
// Create test system
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{ system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system", "name": "test-system",
"users": []string{user.Id}, "users": []string{user.Id},
@@ -106,7 +107,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
"Authorization": userToken, "Authorization": userToken,
}, },
ExpectedStatus: 403, ExpectedStatus: 403,
ExpectedContent: []string{"Requires admin"}, ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
}, },
{ {
@@ -136,7 +137,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
"Authorization": userToken, "Authorization": userToken,
}, },
ExpectedStatus: 403, ExpectedStatus: 403,
ExpectedContent: []string{"Requires admin role"}, ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
}, },
{ {
@@ -158,7 +159,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
"Authorization": userToken, "Authorization": userToken,
}, },
ExpectedStatus: 403, ExpectedStatus: 403,
ExpectedContent: []string{"Requires admin role"}, ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
}, },
{ {
@@ -202,6 +203,74 @@ func TestApiRoutesAuthentication(t *testing.T) {
ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"}, ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"},
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
}, },
{
Name: "GET /universal-token - superuser should fail",
Method: http.MethodGet,
URL: "/api/beszel/universal-token",
Headers: map[string]string{
"Authorization": superuserToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"Superusers cannot use universal tokens"},
TestAppFactory: func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
},
},
{
Name: "GET /universal-token - with readonly auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/universal-token",
Headers: map[string]string{
"Authorization": readOnlyUserToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
TestAppFactory: testAppFactory,
},
{
Name: "POST /smart/refresh - missing system should fail 400 with user auth",
Method: http.MethodPost,
URL: "/api/beszel/smart/refresh",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Invalid", "system", "parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "POST /smart/refresh - with readonly auth should fail",
Method: http.MethodPost,
URL: fmt.Sprintf("/api/beszel/smart/refresh?system=%s", system.Id),
Headers: map[string]string{
"Authorization": readOnlyUserToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
TestAppFactory: testAppFactory,
},
{
Name: "POST /smart/refresh - non-user system should fail",
Method: http.MethodPost,
URL: fmt.Sprintf("/api/beszel/smart/refresh?system=%s", system.Id),
Headers: map[string]string{
"Authorization": user2Token,
},
ExpectedStatus: 404,
ExpectedContent: []string{"The requested resource wasn't found."},
TestAppFactory: testAppFactory,
},
{
Name: "POST /smart/refresh - good user should pass validation",
Method: http.MethodPost,
URL: fmt.Sprintf("/api/beszel/smart/refresh?system=%s", system.Id),
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 500,
ExpectedContent: []string{"Something went wrong while processing your request."},
TestAppFactory: testAppFactory,
},
{ {
Name: "POST /user-alerts - no auth should fail", Name: "POST /user-alerts - no auth should fail",
Method: http.MethodPost, Method: http.MethodPost,
@@ -273,20 +342,42 @@ func TestApiRoutesAuthentication(t *testing.T) {
{ {
Name: "GET /containers/logs - no auth should fail", Name: "GET /containers/logs - no auth should fail",
Method: http.MethodGet, Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=test-system&container=test-container", URL: "/api/beszel/containers/logs?system=test-system&container=abababababab",
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"}, ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
}, },
{
Name: "GET /containers/logs - request for valid non-user system should fail",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/containers/logs?system=%s&container=abababababab", system.Id),
ExpectedStatus: 404,
ExpectedContent: []string{"The requested resource wasn't found."},
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": user2Token,
},
},
{
Name: "GET /containers/info - request for valid non-user system should fail",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/containers/info?system=%s&container=abababababab", system.Id),
ExpectedStatus: 404,
ExpectedContent: []string{"The requested resource wasn't found."},
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": user2Token,
},
},
{ {
Name: "GET /containers/logs - with auth but missing system param should fail", Name: "GET /containers/logs - with auth but missing system param should fail",
Method: http.MethodGet, Method: http.MethodGet,
URL: "/api/beszel/containers/logs?container=test-container", URL: "/api/beszel/containers/logs?container=abababababab",
Headers: map[string]string{ Headers: map[string]string{
"Authorization": userToken, "Authorization": userToken,
}, },
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{"system and container parameters are required"}, ExpectedContent: []string{"Invalid", "parameter"},
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
}, },
{ {
@@ -297,7 +388,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
"Authorization": userToken, "Authorization": userToken,
}, },
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{"system and container parameters are required"}, ExpectedContent: []string{"Invalid", "parameter"},
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
}, },
{ {
@@ -308,7 +399,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
"Authorization": userToken, "Authorization": userToken,
}, },
ExpectedStatus: 404, ExpectedStatus: 404,
ExpectedContent: []string{"system not found"}, ExpectedContent: []string{"The requested resource wasn't found."},
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
}, },
{ {
@@ -319,7 +410,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
"Authorization": userToken, "Authorization": userToken,
}, },
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{"invalid container parameter"}, ExpectedContent: []string{"Invalid", "parameter"},
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
}, },
{ {
@@ -330,7 +421,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
"Authorization": userToken, "Authorization": userToken,
}, },
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{"invalid container parameter"}, ExpectedContent: []string{"Invalid", "parameter"},
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
}, },
{ {
@@ -341,9 +432,114 @@ func TestApiRoutesAuthentication(t *testing.T) {
"Authorization": userToken, "Authorization": userToken,
}, },
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{"invalid container parameter"}, ExpectedContent: []string{"Invalid", "parameter"},
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
}, },
{
Name: "GET /containers/logs - good user should pass validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=" + system.Id + "&container=0123456789ab",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 500,
ExpectedContent: []string{"Something went wrong while processing your request."},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/info - good user should pass validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/info?system=" + system.Id + "&container=0123456789ab",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 500,
ExpectedContent: []string{"Something went wrong while processing your request."},
TestAppFactory: testAppFactory,
},
// /systemd routes
{
Name: "GET /systemd/info - no auth should fail",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=nginx.service", system.Id),
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /systemd/info - request for valid non-user system should fail",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=nginx.service", system.Id),
ExpectedStatus: 404,
ExpectedContent: []string{"The requested resource wasn't found."},
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": user2Token,
},
},
{
Name: "GET /systemd/info - with auth but missing system param should fail",
Method: http.MethodGet,
URL: "/api/beszel/systemd/info?service=nginx.service",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Invalid", "parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /systemd/info - with auth but missing service param should fail",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s", system.Id),
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Invalid", "parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /systemd/info - with auth but invalid system should fail",
Method: http.MethodGet,
URL: "/api/beszel/systemd/info?system=invalid-system&service=nginx.service",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 404,
ExpectedContent: []string{"The requested resource wasn't found."},
TestAppFactory: testAppFactory,
},
{
Name: "GET /systemd/info - service not in systemd_services collection should fail",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=notregistered.service", system.Id),
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 404,
ExpectedContent: []string{"The requested resource wasn't found."},
TestAppFactory: testAppFactory,
},
{
Name: "GET /systemd/info - with auth and existing service record should pass validation",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=nginx.service", system.Id),
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 500,
ExpectedContent: []string{"Something went wrong while processing your request."},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.CreateRecord(app, "systemd_services", map[string]any{
"system": system.Id,
"name": "nginx.service",
"state": 0,
"sub": 1,
})
},
},
// Auth Optional Routes - Should work without authentication // Auth Optional Routes - Should work without authentication
{ {

View File

@@ -279,9 +279,6 @@ func createFingerprintRecord(app core.App, systemID, token string) error {
// Returns the current config.yml file as a JSON object // Returns the current config.yml file as a JSON object
func GetYamlConfig(e *core.RequestEvent) error { func GetYamlConfig(e *core.RequestEvent) error {
if e.Auth.GetString("role") != "admin" {
return e.ForbiddenError("Requires admin role", nil)
}
configContent, err := generateYAML(e.App) configContent, err := generateYAML(e.App)
if err != nil { if err != nil {
return err return err

View File

@@ -9,7 +9,6 @@ import (
"net/url" "net/url"
"os" "os"
"path" "path"
"regexp"
"strings" "strings"
"github.com/henrygd/beszel/internal/alerts" "github.com/henrygd/beszel/internal/alerts"
@@ -38,8 +37,6 @@ type Hub struct {
appURL string appURL string
} }
var containerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
// NewHub creates a new Hub instance with default configuration // NewHub creates a new Hub instance with default configuration
func NewHub(app core.App) *Hub { func NewHub(app core.App) *Hub {
hub := &Hub{App: app} hub := &Hub{App: app}

View File

@@ -5,7 +5,6 @@ package hub
import ( import (
"fmt" "fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
@@ -62,7 +61,6 @@ func (rm *responseModifier) modifyHTML(html string) string {
// startServer sets up the development server for Beszel // startServer sets up the development server for Beszel
func (h *Hub) startServer(se *core.ServeEvent) error { func (h *Hub) startServer(se *core.ServeEvent) error {
slog.Info("starting server", "appURL", h.appURL)
proxy := httputil.NewSingleHostReverseProxy(&url.URL{ proxy := httputil.NewSingleHostReverseProxy(&url.URL{
Scheme: "http", Scheme: "http",
Host: "localhost:5173", Host: "localhost:5173",

View File

@@ -8,6 +8,7 @@ import (
"hash/fnv" "hash/fnv"
"math/rand" "math/rand"
"net" "net"
"slices"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -184,7 +185,7 @@ func (sys *System) handlePaused() {
// createRecords updates the system record and adds system_stats and container_stats records // createRecords updates the system record and adds system_stats and container_stats records
func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error) { func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error) {
systemRecord, err := sys.getRecord() systemRecord, err := sys.getRecord(sys.manager.hub)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -343,8 +344,8 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
// getRecord retrieves the system record from the database. // getRecord retrieves the system record from the database.
// If the record is not found, it removes the system from the manager. // If the record is not found, it removes the system from the manager.
func (sys *System) getRecord() (*core.Record, error) { func (sys *System) getRecord(app core.App) (*core.Record, error) {
record, err := sys.manager.hub.FindRecordById("systems", sys.Id) record, err := app.FindRecordById("systems", sys.Id)
if err != nil || record == nil { if err != nil || record == nil {
_ = sys.manager.RemoveSystem(sys.Id) _ = sys.manager.RemoveSystem(sys.Id)
return nil, err return nil, err
@@ -352,6 +353,16 @@ func (sys *System) getRecord() (*core.Record, error) {
return record, nil return record, nil
} }
// HasUser checks if the given user ID is in the system's users list.
func (sys *System) HasUser(app core.App, userID string) bool {
record, err := sys.getRecord(app)
if err != nil {
return false
}
users := record.GetStringSlice("users")
return slices.Contains(users, userID)
}
// setDown marks a system as down in the database. // setDown marks a system as down in the database.
// It takes the original error that caused the system to go down and returns any error // It takes the original error that caused the system to go down and returns any error
// encountered during the process of updating the system status. // encountered during the process of updating the system status.
@@ -359,7 +370,7 @@ func (sys *System) setDown(originalError error) error {
if sys.Status == down || sys.Status == paused { if sys.Status == down || sys.Status == paused {
return nil return nil
} }
record, err := sys.getRecord() record, err := sys.getRecord(sys.manager.hub)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -54,6 +54,7 @@ type hubLike interface {
GetSSHKey(dataDir string) (ssh.Signer, error) GetSSHKey(dataDir string) (ssh.Signer, error)
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
HandleStatusAlerts(status string, systemRecord *core.Record) error HandleStatusAlerts(status string, systemRecord *core.Record) error
CancelPendingStatusAlerts(systemID string)
} }
// NewSystemManager creates a new SystemManager instance with the provided hub. // NewSystemManager creates a new SystemManager instance with the provided hub.
@@ -189,6 +190,7 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
system.closeSSHConnection() system.closeSSHConnection()
} }
_ = deactivateAlerts(e.App, e.Record.Id) _ = deactivateAlerts(e.App, e.Record.Id)
sm.hub.CancelPendingStatusAlerts(e.Record.Id)
return e.Next() return e.Next()
case pending: case pending:
// Resume monitoring, preferring existing WebSocket connection // Resume monitoring, preferring existing WebSocket connection

View File

@@ -20,7 +20,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}> <Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
<BellIcon <BellIcon
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", { className={cn("size-[1.2em] pointer-events-none", {
"fill-primary": hasSystemAlert, "fill-primary": hasSystemAlert,
})} })}
/> />

View File

@@ -2,11 +2,13 @@ import { t } from "@lingui/core/macro"
import { Plural, Trans } from "@lingui/react/macro" import { Plural, Trans } from "@lingui/react/macro"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
import { GlobeIcon, ServerIcon } from "lucide-react" import { ChevronDownIcon, GlobeIcon, ServerIcon } from "lucide-react"
import { lazy, memo, Suspense, useMemo, useState } from "react" import { lazy, memo, Suspense, useMemo, useState } from "react"
import { $router, Link } from "@/components/router" import { $router, Link } from "@/components/router"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
@@ -64,11 +66,57 @@ const deleteAlerts = debounce(async ({ name, systems }: { name: string; systems:
export const AlertDialogContent = memo(function AlertDialogContent({ system }: { system: SystemRecord }) { export const AlertDialogContent = memo(function AlertDialogContent({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts) const alerts = useStore($alerts)
const systems = useStore($systems)
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false) const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
const [currentTab, setCurrentTab] = useState("system") const [currentTab, setCurrentTab] = useState("system")
// copyKey is used to force remount AlertContent components with
// new alert data after copying alerts from another system
const [copyKey, setCopyKey] = useState(0)
const systemAlerts = alerts[system.id] ?? new Map() const systemAlerts = alerts[system.id] ?? new Map()
// Systems that have at least one alert configured (excluding the current system)
const systemsWithAlerts = useMemo(
() => systems.filter((s) => s.id !== system.id && alerts[s.id]?.size),
[systems, alerts, system.id]
)
async function copyAlertsFromSystem(sourceSystemId: string) {
const sourceAlerts = $alerts.get()[sourceSystemId]
if (!sourceAlerts?.size) return
try {
const currentTargetAlerts = $alerts.get()[system.id] ?? new Map()
// Alert names present on target but absent from source should be deleted
const namesToDelete = Array.from(currentTargetAlerts.keys()).filter((name) => !sourceAlerts.has(name))
await Promise.all([
...Array.from(sourceAlerts.values()).map(({ name, value, min }) =>
pb.send<{ success: boolean }>(endpoint, {
method: "POST",
body: { name, value, min, systems: [system.id], overwrite: true },
requestKey: name,
})
),
...namesToDelete.map((name) =>
pb.send<{ success: boolean }>(endpoint, {
method: "DELETE",
body: { name, systems: [system.id] },
requestKey: name,
})
),
])
// Optimistically update the store so components re-mount with correct data
// before the realtime subscription event arrives.
const newSystemAlerts = new Map<string, AlertRecord>()
for (const alert of sourceAlerts.values()) {
newSystemAlerts.set(alert.name, { ...alert, system: system.id, triggered: false })
}
$alerts.setKey(system.id, newSystemAlerts)
setCopyKey((k) => k + 1)
} catch (error) {
failedUpdateToast(error)
}
}
// We need to keep a copy of alerts when we switch to global tab. If we always compare to // We need to keep a copy of alerts when we switch to global tab. If we always compare to
// current alerts, it will only be updated when first checked, then won't be updated because // current alerts, it will only be updated when first checked, then won't be updated because
// after that it exists. // after that it exists.
@@ -93,7 +141,8 @@ export const AlertDialogContent = memo(function AlertDialogContent({ system }: {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Tabs defaultValue="system" onValueChange={setCurrentTab}> <Tabs defaultValue="system" onValueChange={setCurrentTab}>
<TabsList className="mb-1 -mt-0.5"> <div className="flex items-center justify-between mb-1 -mt-0.5">
<TabsList>
<TabsTrigger value="system"> <TabsTrigger value="system">
<ServerIcon className="me-2 h-3.5 w-3.5" /> <ServerIcon className="me-2 h-3.5 w-3.5" />
<span className="truncate max-w-60">{system.name}</span> <span className="truncate max-w-60">{system.name}</span>
@@ -103,8 +152,26 @@ export const AlertDialogContent = memo(function AlertDialogContent({ system }: {
<Trans>All Systems</Trans> <Trans>All Systems</Trans>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
{systemsWithAlerts.length > 0 && currentTab === "system" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="text-muted-foreground text-xs gap-1.5">
<Trans context="Copy alerts from another system">Copy from</Trans>
<ChevronDownIcon className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-100 overflow-auto">
{systemsWithAlerts.map((s) => (
<DropdownMenuItem key={s.id} className="min-w-44" onSelect={() => copyAlertsFromSystem(s.id)}>
{s.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<TabsContent value="system"> <TabsContent value="system">
<div className="grid gap-3"> <div key={copyKey} className="grid gap-3">
{alertKeys.map((name) => ( {alertKeys.map((name) => (
<AlertContent <AlertContent
key={name} key={name}

View File

@@ -63,7 +63,7 @@ export default function Navbar() {
className="p-2 ps-0 me-3 group" className="p-2 ps-0 me-3 group"
onMouseEnter={runOnce(() => import("@/components/routes/home"))} onMouseEnter={runOnce(() => import("@/components/routes/home"))}
> >
<Logo className="h-[1.1rem] md:h-5 fill-foreground" /> <Logo className="h-[1.2rem] md:h-5 fill-foreground" />
</Link> </Link>
<Button <Button
variant="outline" variant="outline"
@@ -125,6 +125,7 @@ export default function Navbar() {
<DropdownMenuSubContent>{AdminLinks}</DropdownMenuSubContent> <DropdownMenuSubContent>{AdminLinks}</DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
)} )}
{!isReadOnlyUser() && (
<DropdownMenuItem <DropdownMenuItem
className="flex items-center" className="flex items-center"
onSelect={() => { onSelect={() => {
@@ -134,6 +135,7 @@ export default function Navbar() {
<PlusIcon className="h-4 w-4 me-2.5" /> <PlusIcon className="h-4 w-4 me-2.5" />
<Trans>Add {{ foo: systemTranslation }}</Trans> <Trans>Add {{ foo: systemTranslation }}</Trans>
</DropdownMenuItem> </DropdownMenuItem>
)}
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
@@ -217,10 +219,12 @@ export default function Navbar() {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{!isReadOnlyUser() && (
<Button variant="outline" className="flex gap-1 ms-2" onClick={() => setAddSystemDialogOpen(true)}> <Button variant="outline" className="flex gap-1 ms-2" onClick={() => setAddSystemDialogOpen(true)}>
<PlusIcon className="h-4 w-4 -ms-1" /> <PlusIcon className="h-4 w-4 -ms-1" />
<Trans>Add {{ foo: systemTranslation }}</Trans> <Trans>Add {{ foo: systemTranslation }}</Trans>
</Button> </Button>
)}
</div> </div>
</div> </div>
) )

View File

@@ -188,6 +188,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} /> <LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
<BandwidthChart {...coreProps} systemStats={systemStats} /> <BandwidthChart {...coreProps} systemStats={systemStats} />
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} /> <TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
<BatteryChart {...coreProps} />
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} /> <SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>} {pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
</div> </div>

View File

@@ -36,7 +36,7 @@ import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { pb } from "@/lib/api" import { isReadOnlyUser, pb } from "@/lib/api"
import type { SmartDeviceRecord, SmartAttribute } from "@/types" import type { SmartDeviceRecord, SmartAttribute } from "@/types"
import { import {
formatBytes, formatBytes,
@@ -492,7 +492,7 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
const tableColumns = useMemo(() => { const tableColumns = useMemo(() => {
const columns = createColumns(longestName, longestModel, longestDevice) const columns = createColumns(longestName, longestModel, longestDevice)
const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns
return [...baseColumns, actionColumn] return isReadOnlyUser() ? baseColumns : [...baseColumns, actionColumn]
}, [systemId, actionColumn, longestName, longestModel, longestDevice]) }, [systemId, actionColumn, longestName, longestModel, longestDevice])
const table = useReactTable({ const table = useReactTable({

View File

@@ -460,14 +460,14 @@ const SystemCard = memo(
} }
)} )}
> >
<CardHeader className="py-1 ps-5 pe-3 bg-muted/30 border-b border-border/60"> <CardHeader className="py-1 ps-4 pe-2 bg-muted/30 border-b border-border/60">
<div className="flex items-center gap-2 w-full overflow-hidden"> <div className="flex items-center gap-1 w-full overflow-hidden">
<CardTitle className="text-base tracking-normal text-primary/90 flex items-center min-w-0 flex-1 gap-2.5"> <h3 className="text-primary/90 min-w-0 flex-1 gap-2.5 font-semibold">
<div className="flex items-center gap-2.5 min-w-0 flex-1"> <div className="flex items-center gap-2.5 min-w-0 flex-1">
<IndicatorDot system={system} /> <IndicatorDot system={system} />
<span className="text-[.95em]/normal tracking-normal text-primary/90 truncate">{system.name}</span> <span className="text-[.95em]/normal tracking-normal text-primary/90 truncate">{system.name}</span>
</div> </div>
</CardTitle> </h3>
{table.getColumn("actions")?.getIsVisible() && ( {table.getColumn("actions")?.getIsVisible() && (
<div className="flex gap-1 shrink-0 relative z-10"> <div className="flex gap-1 shrink-0 relative z-10">
<AlertButton system={system} /> <AlertButton system={system} />

View File

@@ -43,7 +43,7 @@ const AlertDialogContent = React.forwardRef<
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("grid gap-2 text-center sm:text-start", className)} {...props} /> <div className={cn("grid gap-2 text-start", className)} {...props} />
) )
AlertDialogHeader.displayName = "AlertDialogHeader" AlertDialogHeader.displayName = "AlertDialogHeader"

View File

@@ -18,11 +18,7 @@ CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<h3 <h3 ref={ref} className={cn("text-card-title font-semibold leading-none tracking-tight", className)} {...props} />
ref={ref}
className={cn("text-[1.4em] sm:text-2xl font-semibold leading-none tracking-tight", className)}
{...props}
/>
) )
) )
CardTitle.displayName = "CardTitle" CardTitle.displayName = "CardTitle"

View File

@@ -52,7 +52,7 @@ const DialogContent = React.forwardRef<
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("grid gap-1.5 text-center sm:text-start", className)} {...props} /> <div className={cn("grid gap-1.5 text-start", className)} {...props} />
) )
DialogHeader.displayName = "DialogHeader" DialogHeader.displayName = "DialogHeader"

View File

@@ -177,6 +177,10 @@
} }
} }
@utility text-card-title {
@apply text-[1.4rem] sm:text-2xl;
}
.recharts-tooltip-wrapper { .recharts-tooltip-wrapper {
z-index: 51; z-index: 51;
@apply tabular-nums; @apply tabular-nums;

View File

@@ -77,6 +77,16 @@ func CreateUser(app core.App, email string, password string) (*core.Record, erro
return user, app.Save(user) return user, app.Save(user)
} }
// Helper function to create a test superuser for config tests
func CreateSuperuser(app core.App, email string, password string) (*core.Record, error) {
superusersCollection, _ := app.FindCachedCollectionByNameOrId(core.CollectionNameSuperusers)
superuser := core.NewRecord(superusersCollection)
superuser.Set("email", email)
superuser.Set("password", password)
return superuser, app.Save(superuser)
}
func CreateUserWithRole(app core.App, email string, password string, roleName string) (*core.Record, error) { func CreateUserWithRole(app core.App, email string, password string, roleName string) (*core.Record, error) {
user, err := CreateUser(app, email, password) user, err := CreateUser(app, email, password)
if err != nil { if err != nil {

View File

@@ -12,6 +12,10 @@ is_freebsd() {
[ "$(uname -s)" = "FreeBSD" ] [ "$(uname -s)" = "FreeBSD" ]
} }
is_opnsense() {
[ -f /usr/local/etc/opnsense-version ] || [ -f /etc/opnsense-release ]
}
is_glibc() { is_glibc() {
# Prefer glibc-enabled agent (NVML via purego) on linux/amd64 glibc systems. # Prefer glibc-enabled agent (NVML via purego) on linux/amd64 glibc systems.
# Check common dynamic loader paths first (fast + reliable). # Check common dynamic loader paths first (fast + reliable).
@@ -549,6 +553,7 @@ else
fi fi
# Create a dedicated user for the service if it doesn't exist # Create a dedicated user for the service if it doesn't exist
AGENT_USER="beszel"
echo "Configuring the dedicated user for the Beszel Agent service..." echo "Configuring the dedicated user for the Beszel Agent service..."
if is_alpine; then if is_alpine; then
if ! id -u beszel >/dev/null 2>&1; then if ! id -u beszel >/dev/null 2>&1; then
@@ -590,6 +595,10 @@ elif is_openwrt; then
fi fi
elif is_freebsd; then elif is_freebsd; then
if is_opnsense; then
echo "OPNsense detected: skipping user creation (using daemon user instead)"
AGENT_USER="daemon"
else
if ! id -u beszel >/dev/null 2>&1; then if ! id -u beszel >/dev/null 2>&1; then
pw user add beszel -d /nonexistent -s /usr/sbin/nologin -c "beszel user" pw user add beszel -d /nonexistent -s /usr/sbin/nologin -c "beszel user"
fi fi
@@ -598,6 +607,7 @@ elif is_freebsd; then
echo "Adding beszel to wheel group for self-updates" echo "Adding beszel to wheel group for self-updates"
pw group mod wheel -m beszel pw group mod wheel -m beszel
fi fi
fi
else else
if ! id -u beszel >/dev/null 2>&1; then if ! id -u beszel >/dev/null 2>&1; then
@@ -620,7 +630,7 @@ fi
if [ ! -d "$AGENT_DIR" ]; then if [ ! -d "$AGENT_DIR" ]; then
echo "Creating the directory for the Beszel Agent..." echo "Creating the directory for the Beszel Agent..."
mkdir -p "$AGENT_DIR" mkdir -p "$AGENT_DIR"
chown beszel:beszel "$AGENT_DIR" chown "${AGENT_USER}:${AGENT_USER}" "$AGENT_DIR"
chmod 755 "$AGENT_DIR" chmod 755 "$AGENT_DIR"
fi fi
@@ -899,7 +909,7 @@ TOKEN=$TOKEN
HUB_URL=$HUB_URL HUB_URL=$HUB_URL
EOF EOF
chmod 640 "$AGENT_DIR/env" chmod 640 "$AGENT_DIR/env"
chown root:beszel "$AGENT_DIR/env" chown "root:${AGENT_USER}" "$AGENT_DIR/env"
else else
echo "FreeBSD environment file already exists. Skipping creation." echo "FreeBSD environment file already exists. Skipping creation."
fi fi
@@ -917,6 +927,7 @@ EOF
# Enable and start the service # Enable and start the service
echo "Enabling and starting the agent service..." echo "Enabling and starting the agent service..."
sysrc beszel_agent_enable="YES" sysrc beszel_agent_enable="YES"
sysrc beszel_agent_user="${AGENT_USER}"
service beszel-agent restart service beszel-agent restart
# Check if service started successfully # Check if service started successfully