Compare commits

..

200 Commits

Author SHA1 Message Date
hank
73ce72867c New translations en.po (Greek)
[ci skip]
2026-08-18 15:06:28 -04:00
hank
6449533687 New translations en.po (Chinese Traditional, Hong Kong)
[ci skip]
2026-08-18 13:53:07 -04:00
hank
f405c67935 New translations en.po (Uyghur)
[ci skip]
2026-08-18 13:53:05 -04:00
hank
03d3872803 New translations en.po (Croatian)
[ci skip]
2026-08-18 13:53:04 -04:00
hank
ca12e63210 New translations en.po (Thai)
[ci skip]
2026-08-18 13:53:02 -04:00
hank
25a2e057b3 New translations en.po (Persian)
[ci skip]
2026-08-18 13:53:01 -04:00
hank
ac8ec0f4c2 New translations en.po (Indonesian)
[ci skip]
2026-08-18 13:53:00 -04:00
hank
0c87b943b9 New translations en.po (Vietnamese)
[ci skip]
2026-08-18 13:52:58 -04:00
hank
4240927157 New translations en.po (Chinese Traditional)
[ci skip]
2026-08-18 13:52:57 -04:00
hank
8333917b0e New translations en.po (Chinese Simplified)
[ci skip]
2026-08-18 13:52:55 -04:00
hank
7e78bd2afc New translations en.po (Ukrainian)
[ci skip]
2026-08-18 13:52:54 -04:00
hank
76671d32ab New translations en.po (Turkish)
[ci skip]
2026-08-18 13:52:53 -04:00
hank
e2f47f2f7b New translations en.po (Swedish)
[ci skip]
2026-08-18 13:52:51 -04:00
hank
106311339c New translations en.po (Serbian (Cyrillic))
[ci skip]
2026-08-18 13:52:50 -04:00
hank
58e1b601b5 New translations en.po (Slovenian)
[ci skip]
2026-08-18 13:52:49 -04:00
hank
e3d252457e New translations en.po (Polish)
[ci skip]
2026-08-18 13:52:47 -04:00
hank
da4aee9c3f New translations en.po (Norwegian)
[ci skip]
2026-08-18 13:52:46 -04:00
hank
acf17a2d53 New translations en.po (Dutch)
[ci skip]
2026-08-18 13:52:44 -04:00
hank
584feac4e7 New translations en.po (Korean)
[ci skip]
2026-08-18 13:52:43 -04:00
hank
3900557bcc New translations en.po (Japanese)
[ci skip]
2026-08-18 13:52:41 -04:00
hank
f4a8970d44 New translations en.po (Italian)
[ci skip]
2026-08-18 13:52:40 -04:00
hank
d3765015e6 New translations en.po (Hungarian)
[ci skip]
2026-08-18 13:52:39 -04:00
hank
878d465f24 New translations en.po (Hebrew)
[ci skip]
2026-08-18 13:52:37 -04:00
hank
956295bde1 New translations en.po (Greek)
[ci skip]
2026-08-18 13:52:35 -04:00
hank
7f338e3a53 New translations en.po (German)
[ci skip]
2026-08-18 13:52:33 -04:00
hank
03150dfeb5 New translations en.po (Danish)
[ci skip]
2026-08-18 13:52:32 -04:00
hank
8cc5cf2377 New translations en.po (Czech)
[ci skip]
2026-08-18 13:52:31 -04:00
hank
87e40e797b New translations en.po (Bulgarian)
[ci skip]
2026-08-18 13:52:29 -04:00
hank
144ae59275 New translations en.po (Arabic)
[ci skip]
2026-08-18 13:52:28 -04:00
hank
7966d1549c New translations en.po (Spanish)
[ci skip]
2026-08-18 13:52:26 -04:00
hank
d2e750a726 New translations en.po (French)
[ci skip]
2026-08-18 13:52:25 -04:00
hank
552b162799 New translations en.po (Romanian)
[ci skip]
2026-08-18 13:52:24 -04:00
hank
ca1113d029 New translations en.po (Russian)
[ci skip]
2026-08-18 13:52:22 -04:00
hank
873aedc27a New translations en.po (Portuguese)
[ci skip]
2026-08-18 13:52:21 -04:00
hank
1222a73b32 New translations en.po (Chinese Simplified)
[ci skip]
2026-08-18 12:38:38 -04:00
hank
b5e4276c8e New translations en.po (Chinese Traditional)
[ci skip]
2026-08-18 10:01:29 -04:00
hank
533c183fd2 New translations en.po (Ukrainian)
[ci skip]
2026-08-18 10:01:27 -04:00
hank
2c1b4dd15d New translations en.po (Chinese Simplified)
[ci skip]
2026-08-18 07:51:35 -04:00
hank
a67c5e1f8b New translations en.po (Russian)
[ci skip]
2026-08-17 17:43:35 -04:00
hank
43d39a5317 New translations en.po (Chinese Traditional, Hong Kong)
[ci skip]
2026-08-17 16:46:42 -04:00
hank
ae016ebfe7 New translations en.po (Uyghur)
[ci skip]
2026-08-17 16:46:41 -04:00
hank
a6163e0809 New translations en.po (Croatian)
[ci skip]
2026-08-17 16:46:40 -04:00
hank
532e391398 New translations en.po (Thai)
[ci skip]
2026-08-17 16:46:38 -04:00
hank
1d2e714360 New translations en.po (Persian)
[ci skip]
2026-08-17 16:46:37 -04:00
hank
318f2328ef New translations en.po (Indonesian)
[ci skip]
2026-08-17 16:46:36 -04:00
hank
9731a74185 New translations en.po (Vietnamese)
[ci skip]
2026-08-17 16:46:34 -04:00
hank
b1d3d6c7c4 New translations en.po (Chinese Traditional)
[ci skip]
2026-08-17 16:46:33 -04:00
hank
08fc578dec New translations en.po (Chinese Simplified)
[ci skip]
2026-08-17 16:46:32 -04:00
hank
b189aab61b New translations en.po (Ukrainian)
[ci skip]
2026-08-17 16:46:31 -04:00
hank
5a70767bfe New translations en.po (Turkish)
[ci skip]
2026-08-17 16:46:29 -04:00
hank
6072dd6071 New translations en.po (Swedish)
[ci skip]
2026-08-17 16:46:28 -04:00
hank
001ed54d77 New translations en.po (Serbian (Cyrillic))
[ci skip]
2026-08-17 16:46:27 -04:00
hank
58c58072f7 New translations en.po (Slovenian)
[ci skip]
2026-08-17 16:46:25 -04:00
hank
1df49845ed New translations en.po (Polish)
[ci skip]
2026-08-17 16:46:24 -04:00
hank
965f313393 New translations en.po (Norwegian)
[ci skip]
2026-08-17 16:46:23 -04:00
hank
5790e00c2c New translations en.po (Dutch)
[ci skip]
2026-08-17 16:46:22 -04:00
hank
30a57fcd3f New translations en.po (Korean)
[ci skip]
2026-08-17 16:46:20 -04:00
hank
b6c67a1b46 New translations en.po (Japanese)
[ci skip]
2026-08-17 16:46:19 -04:00
hank
8b0b667559 New translations en.po (Italian)
[ci skip]
2026-08-17 16:46:18 -04:00
hank
5502112054 New translations en.po (Hungarian)
[ci skip]
2026-08-17 16:46:16 -04:00
hank
43faf7c5ea New translations en.po (Hebrew)
[ci skip]
2026-08-17 16:46:15 -04:00
hank
e31fd0ebe9 New translations en.po (Greek)
[ci skip]
2026-08-17 16:46:14 -04:00
hank
20a1f36ce5 New translations en.po (German)
[ci skip]
2026-08-17 16:46:12 -04:00
hank
fd2b0ba897 New translations en.po (Danish)
[ci skip]
2026-08-17 16:46:11 -04:00
hank
d6eddcc117 New translations en.po (Czech)
[ci skip]
2026-08-17 16:46:10 -04:00
hank
9578664bd4 New translations en.po (Bulgarian)
[ci skip]
2026-08-17 16:46:08 -04:00
hank
6f3d77fb2e New translations en.po (Arabic)
[ci skip]
2026-08-17 16:46:07 -04:00
hank
a8fdc2ca34 New translations en.po (Spanish)
[ci skip]
2026-08-17 16:46:06 -04:00
hank
1ba9b46b85 New translations en.po (French)
[ci skip]
2026-08-17 16:46:04 -04:00
hank
2d30fd6ada New translations en.po (Romanian)
[ci skip]
2026-08-17 16:46:03 -04:00
hank
b4ac1eef12 New translations en.po (Russian)
[ci skip]
2026-08-17 16:46:02 -04:00
hank
ebde392591 New translations en.po (Portuguese)
[ci skip]
2026-08-17 16:46:00 -04:00
henrygd
19f250c7de ci: change release flow actions/setup-go to use go-version: stable 2026-08-17 16:11:00 -04:00
henrygd
e07f91b920 release 0.18.8 2026-08-17 16:01:51 -04:00
henrygd
218aa8478a update translations 2026-08-17 15:38:22 -04:00
henrygd
90b789aa72 update changelog 2026-08-17 15:33:32 -04:00
hank
2d01d71f46 New Crowdin updates (#1930) 2026-08-17 15:33:16 -04:00
henrygd
f894d188cf ci(helm): update chart versions on new beszel release 2026-08-17 14:52:31 -04:00
henrygd
46cc602d37 ci(helm): publish charts to GHCR instead of creating releases 2026-08-17 14:33:40 -04:00
henrygd
89ad51d4ce fix(agent): distinguish SMART devices sharing the same path
Co-authored-by: Digital <github@crni.xyz>
2026-08-17 13:55:27 -04:00
dependabot[bot]
d5f41af3a6 chore(deps): bump docker/setup-buildx-action from 3 to 4 (#2221)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 13:03:15 -04:00
henrygd
1074503af1 deps: update go packages 2026-08-17 12:57:55 -04:00
henrygd
90f1bdef1e remove language toggle from navbar 2026-08-17 12:54:55 -04:00
henrygd
5f383c0eb1 fix(tests): stop system updaters on app termination 2026-08-17 12:32:34 -04:00
henrygd
ae037b278e agent: simplify FreeBSD temperature sensor names 2026-08-16 21:16:12 -04:00
QuantumFlux21
9f1128933f feat: Add support for reading CPU (dev.cpu.*.temperature) and ACPI thermal zone (hw.acpi.thermal.tz*) temperature sensors on FreeBSD systems. (#2227)
Co-authored-by: roib <roib@elsec.us>
2026-08-16 20:58:41 -04:00
henrygd
dbe10e3a8f ci: update vulncheck workflow to run only on pushes to main, not PRs 2026-08-16 20:33:38 -04:00
hank
7e7bcb3b35 chore: release main 2026-08-16 20:28:39 -04:00
henrygd
418e3f0892 fix(helm): update app version to 0.18.7 2026-08-16 20:26:37 -04:00
henrygd
7556a63378 helm: update version to latest and automate chart app version bumps
- Keep chart documentation aligned with the application version
- Update app versions automatically after application releases
- Restore the main application release as latest after chart publishing
2026-08-16 20:23:34 -04:00
hank
260b082c5e chore: release main 2026-08-16 20:04:02 -04:00
henrygd
55054a75ab fix(helm): publish and index chart releases reliably, maybe
- Serialize release runs to prevent GitHub Pages index races
- Publish drafts using the release ID from Release Please
- Keep Helm chart releases out of the latest release channel
- Publish releases before Chart Releaser indexes them
- Match Chart Releaser to v-prefixed release tags
2026-08-16 20:02:20 -04:00
hank
ccd1735a8e chore: release main 2026-08-16 19:42:36 -04:00
henrygd
c67b69d17e fix(helm): publish immutable chart releases safely, maybe
- Create draft releases before uploading chart packages
- Update the chart index before making releases immutable
- Keep Helm releases out of the latest release channel
- Serialize chart publishing to avoid index races
- Run the workflow only when Helm-related files change
2026-08-16 19:39:25 -04:00
hank
da3ab62d4e chore: release main 2026-08-16 18:58:18 -04:00
Daniel Nikoloski
ec4ec01a39 feat: huge beszel hub and agent helm chart update (#1582) 2026-08-16 18:52:26 -04:00
Donggyu Kwon
ca5497324c feat: add :slim NVIDIA agent container image (#2002, #2003) 2026-08-16 15:42:13 -04:00
Sai Asish Y
adaf6f338d systems: synthesize error when getRecord returns nil record (#1968) 2026-08-16 14:44:30 -04:00
Sven van Ginkel
1ab1229a61 feat: build for armv5 and armv6 (#1884) 2026-08-16 14:33:04 -04:00
henrygd
9a54d844ba dev: remove noisy biome checks from package.json scripts 2026-08-16 14:26:59 -04:00
henrygd
87405c5f10 feat: add multi-battery monitoring
- Report battery data for individual devices
- Select a representative battery for legacy fields and alerts
- Average named battery data independently
- Display multiple batteries in system charts
- Add cross-platform coverage and transport tests
2026-08-16 13:52:29 -04:00
Miłosz Kolber
bfa6a1e361 feat(agent): monitor Intel Arc (xe) GPUs via nvtop (#2223)
intel_gpu_top does not support the xe driver, so skip it for xe devices
and let the existing nvtop last-resort collector handle them. nvtop leaves
device_name unset on xe, so name the GPU from its PCI device id ("Intel GPU
(<id>)"). Adds nvtop to the Intel agent image (gputop already ships with
igt-gpu-tools).
2026-08-16 11:41:28 -04:00
T.J. Tarazevits
3688b2d033 Add Intel sysfs GPU power collector (Xe/i915 hwmon energy counters) (#2020) 2026-08-16 11:20:43 -04:00
mhlas7
b4e1f3fafe fix(install): detect rc.conf.d override that blocks FreeBSD service start and restart (#2219)
* fix(install): detect rc.conf.d override that blocks FreeBSD service start

* fix log file removal
2026-08-15 16:16:33 -04:00
Maxim Lepekha
eebcd56462 feat: fan RPM monitoring (#2032)
Adds fan RPM monitoring as a peer to the existing temperature collection, addressing #1918.

---------

Co-authored-by: henrygd <hank@henrygd.me>
2026-08-15 15:59:07 -04:00
Sven van Ginkel
eb5dd230cf fix(hub): remove stale smart_devices records when a drive is no longer reported (#2178)
* Fix duplicate /dev/sdg-style entries

* only prune devices after complete refreshes

---------

Co-authored-by: henrygd <hank@henrygd.me>
2026-08-15 13:40:30 -04:00
henrygd
3337dff64b ui: possible fix for android bitwarden autofill (#1011) 2026-08-15 12:18:26 -04:00
Sven van Ginkel
b68acea5a8 fix(agent): show all IP:port mappings for containers bound to multiple IPs (#1993)
* feat: support multiple docker ips

* deduplicate wildcard docker bindings

---------

Co-authored-by: henrygd <hank@henrygd.me>
2026-08-15 11:50:20 -04:00
henrygd
66ac62a125 vulncheck: update actions/setup-go to use go-version: stable
https://github.com/actions/setup-go/blob/main/docs/advanced-usage.md#using-the-go-version-input
2026-08-14 19:44:11 -04:00
henrygd
e2a18ec636 deps: update go version to fix vulncheck workflow 2026-08-14 19:37:08 -04:00
henrygd
ffd4fc2c45 test(ghupdate): align missing checksum digest error expectation 2026-08-14 19:34:42 -04:00
henrygd
052489cada ghupdate: add checksum verification and extraction path containment guard 2026-08-14 19:23:08 -04:00
Yvan Wang
d50c09176f fix(agent): honor explicit SMART_DEVICES type hint instead of scan-detected type (#2102)
When SMART_DEVICES specifies an explicit type (e.g. /dev/sda:scsi), the
agent resolved the device type correctly but smartctlArgs dropped the -d
flag for scsi/ata (the #1345 scan-misdetection workaround), so smartctl
re-detected the wrong type (sat) and collection failed on USB drives
whose bridge does not support SAT passthrough.

Mark types that come from an explicit SMART_DEVICES hint and always pass
them through via -d, while still letting scan-detected scsi/ata
auto-detect as before. Adds regression tests for the arg building, the
full parse -> merge -> args path, and flag preservation across rescans.

Fixes #2072
2026-08-14 14:13:33 -04:00
mhlas7
fe84cfa16d Implement OPNsense boot hook for Beszel Agent (#2089)
Add OPNsense boot hook for Beszel Agent startup
2026-08-14 13:21:15 -04:00
Lars Lehtonen
6d0b83f6de fix(cron): log CreateLongerRecords errors (#1926)
chore(cron): comment cron tasks with pocketbase warning
2026-08-14 10:40:28 -04:00
Vito Cappello
cf90249519 Clamp Linux battery capacity percentage (#1957) 2026-08-14 10:17:38 -04:00
Sven van Ginkel
6607d4c0d6 fix(hub): check X-Real-IP header to resolve agent's real source IP (#1973) 2026-08-14 10:13:26 -04:00
henrygd
bc55e249c4 ui: visually truncate public key on tokens settings page 2026-08-14 10:06:24 -04:00
Sven van Ginkel
977826e8f3 feat(hub): show public key in universal token settings (#2028) 2026-08-14 10:04:40 -04:00
henrygd
8450b40e0c fix: tests that were not compatible w/ new pocketbase version 2026-08-14 00:20:45 -04:00
henrygd
ac4436bea3 fix(tests): align error message after merging #2011 2026-08-13 19:27:33 -04:00
Sven van Ginkel
fa5cda83c2 fix(agent): validate HUB_URL host to prevent misleading "lookup api" DNS error (#2011) 2026-08-13 19:25:12 -04:00
Sven van Ginkel
35af36fbd2 bug: fix docker memory chart (#2027) 2026-08-13 19:21:40 -04:00
Sven van Ginkel
e380ab6917 fix(ui): prevent usage bars from overflowing card border in grid view (#2064) 2026-08-13 19:18:43 -04:00
TowyTowy
c3a432101b fix(hub): re-dial SSH-pull agents when a connection silently dies (#2126)
The per-system updater reused an SSH client across ticks and ran the data
exchange with no deadline. If a connection went half-open (dead peer that
never sends RST/FIN) or an agent accepted the session but never wrote a
response, the read in fetchDataViaSSH blocked forever. Because
StartUpdater calls update() synchronously on its ticker, a blocked read
froze the whole per-system goroutine: the ticker's subsequent ticks were
dropped, no error was returned so the system stayed "up", and the agent
was never re-dialed until the hub process restarted.

Bound each SSH data exchange with sshOperationTimeout via runWithTimeout:
on timeout the connection is torn down (unwinding the blocked read) and a
retryable error is returned, so the next tick re-dials. Also enable TCP
keep-alive on dialed connections as a backstop for genuine network death.

Fixes #2041
2026-08-13 19:04:38 -04:00
Ryan Chou
98e86b4c9c fix(agent): treat a backwards container CPU counter as a new baseline (#2205)
CalculateCpuPercentLinux subtracted the stored previous counters from the
current ones without checking direction. When a stats response is processed
after a newer one for the same container, or an accounting counter resets,
the current total reads lower and the unsigned subtraction wraps to ~2^64
instead of going negative.

On the container counter that surfaces as the reported error, and the sample
is discarded along with the container's network stats:

    cpu pct greater than 100: 1.15292150348562e+13

On the system counter it is quieter and worse: the wrapped value inflates the
divisor, so the percentage collapses toward zero and is stored as a healthy
sample rather than rejected. A synthetic rollback measures 2.7e-12 percent.

Both directions are now treated as a new baseline (0% for one sample), which
matches how the function already handles the first-run case.

CalculateCpuPercentWindows had the same unguarded subtraction and is fixed
the same way.

Fixes #2149

Co-authored-by: Ryan Chou <ryanchou1994@users.noreply.github.com>
2026-08-13 18:14:30 -04:00
henrygd
d40372842b fix(agent): guard against unsigned underflow in memory calculations (closes #1978)
Extract host memory derivation into calculateHostMemoryUsage and use
saturating subtraction to prevent uint64 underflow caused by race
conditions when reading /proc/meminfo. Also ensure UsedPercent guards
against division by zero.

Co-authored-by: Sven van Ginkel <svenvanginkel@icloud.com>
2026-08-13 17:06:10 -04:00
Rajkumar
c9b6279e61 feat(agent): add arm64 to beszel-agent-nvidia platform matrix (#2207) 2026-08-11 18:10:36 -04:00
Sven van Ginkel
9887b662ad fix(hub): refresh system details on agent reconnect ssh (#2086) 2026-08-11 18:08:18 -04:00
Kobi Hikri
71bc6f9b9f ci: attach provenance and SBOM attestations to the published images (#2159)
* ci: attach provenance and SBOM attestations to the published images

* ci: restore trailing newline at end of file
2026-08-11 18:04:39 -04:00
dependabot[bot]
87468965dc chore(deps): bump docker/metadata-action from 5 to 6 (#2201)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 17:56:29 -04:00
dependabot[bot]
db0da58ac8 chore(deps): bump docker/login-action from 3 to 4 (#2202)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 17:49:49 -04:00
dependabot[bot]
b9b3a23063 chore(deps): bump actions/setup-dotnet from 4 to 6 (#2203)
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 4 to 6.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 17:49:28 -04:00
dependabot[bot]
ac34e58113 chore(deps): bump actions/checkout from 4 to 7 (#2200)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 17:48:24 -04:00
dependabot[bot]
3c8703d1c0 chore(deps): bump actions/stale from 10 to 11 (#2199)
Bumps [actions/stale](https://github.com/actions/stale) from 10 to 11.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v10...v11)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: '11'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 17:48:11 -04:00
Sven van Ginkel
01efba50a5 fix(agent): remove unsynchronized hubVersions cache causing concurrent map write panic (#2153) 2026-08-11 17:14:59 -04:00
henrygd
d0453f1ca6 update go version and packages 2026-08-11 16:53:23 -04:00
Quadrubo
7ffc6e81ce fix(agent): prevent a malformed Docker response from permanently breaking container stats (#2066)
decode() reused a single json.Decoder across responses. Once a decode errored on a truncated or malformed body (e.g. a container removed mid-request), the decoder stayed desynced and every subsequent decode failed, silently stopping all container stats until the agent was restarted. Decode the buffered bytes with json.Unmarshal instead, which keeps the reusable read buffer but holds no cross-call state.

Regression from d67d638.
2026-08-11 16:40:24 -04:00
Pavel Artsishevsky
1aa9fcd31d Fixes redirect-based OAuth login when OAUTH_DISABLE_POPUP=true is set (#2171)
Switch to localStorage, which is shared across all tabs/windows of the
same origin. Also clean up the stored entry and strip the callback URL
on both success and failure paths.
2026-08-11 16:33:05 -04:00
Vito Cappello
bd52134558 fix(agent): don't warn on mdraid consistency checks (#2067) 2026-08-11 15:50:58 -04:00
Anikesh Thakur
3fac02f0c0 fix: start agent after pfSense reboot (#2214) 2026-08-11 15:44:27 -04:00
dependabot[bot]
e4c0522cae chore(deps): bump github.com/ebitengine/purego from 0.10.0 to 0.10.2 (#2191)
Bumps [github.com/ebitengine/purego](https://github.com/ebitengine/purego) from 0.10.0 to 0.10.2.
- [Release notes](https://github.com/ebitengine/purego/releases)
- [Commits](https://github.com/ebitengine/purego/compare/v0.10.0...v0.10.2)

---
updated-dependencies:
- dependency-name: github.com/ebitengine/purego
  dependency-version: 0.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 18:30:38 -04:00
dependabot[bot]
f2ccaacecb chore(deps): bump github.com/shirou/gopsutil/v4 from 4.26.3 to 4.26.7 (#2189)
Bumps [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) from 4.26.3 to 4.26.7.
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.26.3...v4.26.7)

---
updated-dependencies:
- dependency-name: github.com/shirou/gopsutil/v4
  dependency-version: 4.26.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 18:30:29 -04:00
dependabot[bot]
a81ce14046 chore(deps): bump docker/setup-qemu-action from 3 to 4 (#2188)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 18:29:47 -04:00
dependabot[bot]
6e4a2c8a3d chore(deps): bump docker/build-push-action from 5 to 7 (#2190)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 18:29:36 -04:00
dependabot[bot]
8cf39be6ef chore(deps): bump github.com/lxzan/gws from 1.9.1 to 1.10.1 (#2193)
Bumps [github.com/lxzan/gws](https://github.com/lxzan/gws) from 1.9.1 to 1.10.1.
- [Release notes](https://github.com/lxzan/gws/releases)
- [Commits](https://github.com/lxzan/gws/compare/v1.9.1...v1.10.1)

---
updated-dependencies:
- dependency-name: github.com/lxzan/gws
  dependency-version: 1.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 18:28:56 -04:00
dependabot[bot]
146ca4284c chore(deps): bump actions/setup-go from 5 to 7 (#2192)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 7.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 18:28:35 -04:00
dependabot[bot]
6eae195d9c chore(deps): bump klaasnicolaas/action-inactivity-lock (#2194)
Bumps [klaasnicolaas/action-inactivity-lock](https://github.com/klaasnicolaas/action-inactivity-lock) from 1.1.3 to 2.0.1.
- [Release notes](https://github.com/klaasnicolaas/action-inactivity-lock/releases)
- [Commits](https://github.com/klaasnicolaas/action-inactivity-lock/compare/v1.1.3...v2.0.1)

---
updated-dependencies:
- dependency-name: klaasnicolaas/action-inactivity-lock
  dependency-version: 2.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 18:28:22 -04:00
dependabot[bot]
30bf65991b chore(deps): bump github.com/fxamacker/cbor/v2 from 2.9.0 to 2.9.2 (#2195)
Bumps [github.com/fxamacker/cbor/v2](https://github.com/fxamacker/cbor) from 2.9.0 to 2.9.2.
- [Release notes](https://github.com/fxamacker/cbor/releases)
- [Commits](https://github.com/fxamacker/cbor/compare/v2.9.0...v2.9.2)

---
updated-dependencies:
- dependency-name: github.com/fxamacker/cbor/v2
  dependency-version: 2.9.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 18:28:14 -04:00
dependabot[bot]
f57f5883ce chore(deps): bump goreleaser/goreleaser-action from 6 to 7 (#2187)
Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 6 to 7.
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](https://github.com/goreleaser/goreleaser-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: goreleaser/goreleaser-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 18:26:35 -04:00
Sven van Ginkel
0472343730 chore: add dependabot and update shoutrrr (#2070)
* chore: update shoutrrr
feat; add dependabot

* Upgrade to shoutrrr 0.16.2

* Dump shoutrrr
2026-08-06 18:24:00 -04:00
henrygd
d3a1d61955 chore: update go version and go mod tidy (#2043) 2026-05-28 14:00:27 -04:00
Sven van Ginkel
9708e24fd2 chore: update Go dependencies (#2043)
* Chore update go depencies

* update crypto package
2026-05-28 13:54:03 -04:00
henrygd
17e910a246 update docker-compose.yml examples 2026-05-19 20:20:13 -04:00
Iriome Santana
0e65a2373f feat(docker): add healthcheck to hub docker-compose example (#2001) 2026-05-19 20:13:29 -04:00
henrygd
c1c1cd1bcb ui: fix temperature chart filtering 2026-04-20 20:45:16 -04:00
Yvan Wang
cd9ea51039 fix(hub): use rfcEmail validator to allow IDN/Punycode email addresses (#1935)
valibot's email() action uses a domain-label regex that disallows the
-- sequence, so Punycode ACE labels like xn--mnchen-3ya.de (the ASCII
form of münchen.de) are incorrectly rejected.

Switching to rfcEmail() applies the RFC 5321 domain-label pattern,
which allows hyphens within labels and therefore accepts both standard
and internationalized domain names.
2026-04-18 12:23:14 -04:00
Uğur Tafralı
a71617e058 feat(agent): Add EXIT_ON_DNS_ERROR environment variable (#1929)
Co-authored-by: henrygd <hank@henrygd.me>
2026-04-17 19:26:11 -04:00
henrygd
e5507fa106 refactor(agent): clean up records package and add tests 2026-04-15 16:24:40 -04:00
Lars Lehtonen
a024c3cfd0 fix(cron): log unhandled records cleanup errors (#1909) 2026-04-15 15:33:55 -04:00
henrygd
07466804e7 ui: allow filtering systems by host and agent version (#163) 2026-04-14 16:27:38 -04:00
henrygd
981c788d6f agent: make sure prefixed ALL_PROXY env var works (#1919) 2026-04-14 14:46:43 -04:00
Rafael Marmelo
f5576759de agent: Allow agent to connect to hub via SOCKS5 proxy 2026-04-14 14:46:43 -04:00
Sven van Ginkel
be0b708064 feat(hub): add OAUTH_DISABLE_POPUP env var (#1900)
Co-authored-by: henrygd <hank@henrygd.me>
2026-04-13 20:00:05 -04:00
amarildo
ab3a3de46c deps: update shoutrrr to 0.14.3, fixing some matrix issues (#1906) 2026-04-10 18:35:20 -04:00
Lars Lehtonen
1556e53926 fix(agent): dropped linux battery error (#1908) 2026-04-10 18:33:42 -04:00
henrygd
e3ade3aeb8 hub: optimize System.HasUser check 2026-04-10 18:32:37 -04:00
henrygd
b013f06956 rm deprecated tsconfig baseUrl property 2026-04-10 18:29:20 -04:00
FlintyLemming
3793b27958 fix(agent): use nvme_total_capacity fallback for NVMe disk size (#1899)
Some enterprise NVMe drives (e.g. Dell Ent NVMe CM7 U.2) report capacity
via nvme_total_capacity instead of user_capacity.bytes in smartctl output.
The NVMe SMART parser now falls back to nvme_total_capacity when
user_capacity.bytes is zero.
2026-04-09 15:50:59 -04:00
y0tka
5b02158228 feat(ui): add theme state that follows system/browser theme (#1903) 2026-04-09 15:48:44 -04:00
henrygd
0ae8c42ae0 fix(hub): System.HasUser - return true if SHARE_ALL_SYSTEMS=true (#1891)
- move hub's GetEnv function to new utils package to more easily share
across different hub packages
- change System.HasUser to take core.Record instead of user ID string
- add tests
2026-04-08 20:13:39 -04:00
henrygd
ea80f3c5a2 fix(agent): add safety check for read returning negative bytes (#1799) 2026-04-07 18:41:22 -04:00
henrygd
c3dffff5e4 hub: prevent non-admin users from sending test alerts to internal hosts 2026-04-07 16:08:28 -04:00
henrygd
06fdd0e7a8 refactor(hub,alerts): move api functions/tests to alerts_api.go 2026-04-07 14:47:08 -04:00
henrygd
6e3fd90834 0.18.7 release 2026-04-05 16:14:39 -04:00
henrygd
5ab82183fa comment out unneeded test that returns different status in dev / prod (add todo) 2026-04-05 16:14:28 -04:00
henrygd
a68e02ca84 update translations 2026-04-05 15:22:26 -04:00
henrygd
0f2e16c63c deps: upgrade pocketbase 2026-04-04 18:31:33 -04:00
Sven van Ginkel
c4009f2b43 feat: add more disk I/O metrics (#1866)
Co-authored-by: henrygd <hank@henrygd.me>
2026-04-04 18:28:05 -04:00
Sven van Ginkel
ef0c1420d1 fix(deps): resolve 9 npm Dependabot security alerts (#1882) 2026-04-03 13:23:23 -04:00
henrygd
eb9a8e1ef9 install: update opnsense detection path (#1880) 2026-04-03 12:16:27 -04:00
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
213 changed files with 21097 additions and 3486 deletions

12
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: gomod
directory: /
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly

View File

@@ -41,7 +41,7 @@ jobs:
# henrygd/beszel-agent-nvidia # henrygd/beszel-agent-nvidia
- image: henrygd/beszel-agent-nvidia - image: henrygd/beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia dockerfile: ./internal/dockerfile_agent_nvidia
platforms: linux/amd64 platforms: linux/amd64,linux/arm64
registry: docker.io registry: docker.io
username_secret: DOCKERHUB_USERNAME username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN password_secret: DOCKERHUB_TOKEN
@@ -52,6 +52,19 @@ jobs:
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# henrygd/beszel-agent-nvidia:slim
- image: henrygd/beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia_slim
platforms: linux/amd64,linux/arm64
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
tags: |
type=raw,value=slim
type=semver,pattern={{version}}-slim
type=semver,pattern={{major}}.{{minor}}-slim
type=semver,pattern={{major}}-slim
# henrygd/beszel-agent-intel # henrygd/beszel-agent-intel
- image: henrygd/beszel-agent-intel - image: henrygd/beszel-agent-intel
dockerfile: ./internal/dockerfile_agent_intel dockerfile: ./internal/dockerfile_agent_intel
@@ -96,7 +109,7 @@ jobs:
# ghcr.io/henrygd/beszel-agent-nvidia # ghcr.io/henrygd/beszel-agent-nvidia
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia - image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia dockerfile: ./internal/dockerfile_agent_nvidia
platforms: linux/amd64 platforms: linux/amd64,linux/arm64
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password_secret: GITHUB_TOKEN password_secret: GITHUB_TOKEN
@@ -107,6 +120,19 @@ jobs:
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# ghcr.io/henrygd/beszel-agent-nvidia:slim
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia_slim
platforms: linux/amd64,linux/arm64
registry: ghcr.io
username: ${{ github.actor }}
password_secret: GITHUB_TOKEN
tags: |
type=raw,value=slim
type=semver,pattern={{version}}-slim
type=semver,pattern={{major}}.{{minor}}-slim
type=semver,pattern={{major}}-slim
# ghcr.io/henrygd/beszel-agent-intel # ghcr.io/henrygd/beszel-agent-intel
- image: ghcr.io/${{ github.repository }}/beszel-agent-intel - image: ghcr.io/${{ github.repository }}/beszel-agent-intel
dockerfile: ./internal/dockerfile_agent_intel dockerfile: ./internal/dockerfile_agent_intel
@@ -152,7 +178,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Set up bun - name: Set up bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
@@ -164,14 +190,14 @@ jobs:
run: bun run --cwd ./internal/site build run: bun run --cwd ./internal/site build
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Docker metadata - name: Docker metadata
id: metadata id: metadata
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: ${{ matrix.image }} images: ${{ matrix.image }}
tags: ${{ matrix.tags }} tags: ${{ matrix.tags }}
@@ -181,7 +207,7 @@ jobs:
env: env:
password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }} password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }}
if: github.event_name != 'pull_request' && env.password_secret_exists == 'true' if: github.event_name != 'pull_request' && env.password_secret_exists == 'true'
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ matrix.username || secrets[matrix.username_secret] }} username: ${{ matrix.username || secrets[matrix.username_secret] }}
password: ${{ secrets[matrix.password_secret] }} password: ${{ secrets[matrix.password_secret] }}
@@ -190,11 +216,13 @@ jobs:
# Build and push Docker image with Buildx (don't push on PR) # Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action # https://github.com/docker/build-push-action
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v7
with: with:
context: ./ context: ./
file: ${{ matrix.dockerfile }} file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }} platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7' }}
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }} push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
provenance: mode=max
sbom: true
tags: ${{ steps.metadata.outputs.tags }} tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }} labels: ${{ steps.metadata.outputs.labels }}

109
.github/workflows/helm-charts.yml vendored Normal file
View File

@@ -0,0 +1,109 @@
name: Helm charts
on:
pull_request:
paths:
- "supplemental/helm/**"
push:
branches:
- main
paths:
- "supplemental/helm/**"
permissions:
contents: read
packages: write
env:
OCI_REGISTRY: ghcr.io/henrygd/beszel-charts
jobs:
changes:
name: Detect changed charts
runs-on: ubuntu-latest
outputs:
charts: ${{ steps.changes.outputs.charts }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Detect changed charts
id: changes
env:
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
run: |
charts=()
for name in beszel-agent beszel-hub; do
path="supplemental/helm/$name"
if ! git diff --quiet "$BASE_SHA" "$GITHUB_SHA" -- "$path"; then
charts+=("$name|$path")
fi
done
printf '%s\n' "${charts[@]}" \
| jq -Rsc 'split("\n") | map(select(length > 0) | split("|") | {name: .[0], path: .[1]})' \
| xargs -0 printf 'charts=%s\n' >> "$GITHUB_OUTPUT"
validate-and-publish:
name: ${{ github.event_name == 'push' && 'Publish' || 'Validate' }} ${{ matrix.chart.name }}
needs: changes
if: needs.changes.outputs.charts != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
chart: ${{ fromJSON(needs.changes.outputs.charts) }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Set up Helm
uses: azure/setup-helm@v4
- name: Lint chart
run: helm lint "${{ matrix.chart.path }}" --set env.KEY=ci-placeholder
- name: Render chart
run: helm template "${{ matrix.chart.name }}" "${{ matrix.chart.path }}" --set env.KEY=ci-placeholder > /dev/null
- name: Package chart
id: package
env:
CHART_NAME: ${{ matrix.chart.name }}
CHART_PATH: ${{ matrix.chart.path }}
run: |
version=$(awk '/^version:/ { print $2 }' "$CHART_PATH/Chart.yaml")
test -n "$version"
mkdir -p .helm-packages
helm package "$CHART_PATH" --destination .helm-packages
package=".helm-packages/${CHART_NAME}-${version}.tgz"
test -f "$package"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "package=$package" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
env:
GITHUB_TOKEN: ${{ github.token }}
run: echo "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
- name: Check chart version is unpublished
env:
CHART_NAME: ${{ matrix.chart.name }}
CHART_VERSION: ${{ steps.package.outputs.version }}
run: |
chart="oci://${OCI_REGISTRY}/${CHART_NAME}"
if helm show chart "$chart" --version "$CHART_VERSION" > /dev/null 2>&1; then
echo "${CHART_NAME} ${CHART_VERSION} is already published. Bump version in Chart.yaml." >&2
exit 1
fi
- name: Publish chart
if: github.event_name == 'push'
run: helm push "${{ steps.package.outputs.package }}" "oci://${OCI_REGISTRY}"

View File

@@ -15,7 +15,7 @@ jobs:
name: Lock Inactive Issues name: Lock Inactive Issues
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: klaasnicolaas/action-inactivity-lock@v1.1.3 - uses: klaasnicolaas/action-inactivity-lock@v2.0.1
id: lock id: lock
with: with:
days-inactive-issues: 14 days-inactive-issues: 14
@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Close Stale Issues - name: Close Stale Issues
uses: actions/stale@v10 uses: actions/stale@v11
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -27,12 +27,12 @@ jobs:
run: bun run --cwd ./internal/site build run: bun run --cwd ./internal/site build
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v7
with: with:
go-version: "^1.22.1" go-version: stable
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v6
with: with:
dotnet-version: "9.0.x" dotnet-version: "9.0.x"
@@ -42,7 +42,7 @@ jobs:
shell: bash shell: bash
- name: GoReleaser beszel - name: GoReleaser beszel
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v7
with: with:
workdir: ./ workdir: ./
distribution: goreleaser distribution: goreleaser

101
.github/workflows/update-helm-charts.yml vendored Normal file
View File

@@ -0,0 +1,101 @@
name: Update Helm charts
on:
release:
types:
- published
permissions:
contents: write
pull-requests: write
concurrency:
group: update-helm-charts
cancel-in-progress: false
jobs:
update:
name: Propose chart update
if: ${{ github.repository_owner == 'henrygd' && startsWith(github.event.release.tag_name, 'v') && !github.event.release.prerelease }}
runs-on: ubuntu-latest
env:
BRANCH: automation/update-helm-app-version
RELEASE_TAG: ${{ github.event.release.tag_name }}
AUTOMATION_TOKEN: ${{ secrets.CR_TOKEN || github.token }}
steps:
- name: Checkout main
uses: actions/checkout@v7
with:
ref: main
token: ${{ env.AUTOMATION_TOKEN }}
- name: Update chart versions
id: update
run: |
version="${RELEASE_TAG#v}"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Unsupported software release version: $version" >&2
exit 1
fi
changed=false
for chart in supplemental/helm/beszel-agent supplemental/helm/beszel-hub; do
current_app_version=$(awk -F '"' '/^appVersion:/ { print $2 }' "$chart/Chart.yaml")
if [[ "$current_app_version" == "$version" ]]; then
echo "$chart already uses appVersion $version"
continue
fi
newest_version=$(printf '%s\n' "$current_app_version" "$version" | sort -V | tail -n 1)
if [[ "$newest_version" != "$version" ]]; then
echo "Skipping stale update of $chart from $current_app_version to $version"
continue
fi
chart_version=$(awk '/^version:/ { print $2 }' "$chart/Chart.yaml")
if [[ ! "$chart_version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "Unsupported chart version in $chart/Chart.yaml: $chart_version" >&2
exit 1
fi
next_chart_version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))"
NEW_APP_VERSION="$version" NEW_CHART_VERSION="$next_chart_version" \
perl -pi -e 's/^appVersion:.*$/appVersion: "$ENV{NEW_APP_VERSION}"/; s/^version:.*$/version: $ENV{NEW_CHART_VERSION}/' \
"$chart/Chart.yaml"
OLD_APP_VERSION="$current_app_version" NEW_APP_VERSION="$version" \
perl -pi -e 's/\Q$ENV{OLD_APP_VERSION}\E/$ENV{NEW_APP_VERSION}/g' "$chart/README.md"
echo "$chart: appVersion $current_app_version -> $version, chart $chart_version -> $next_chart_version"
changed=true
done
echo "changed=$changed" >> "$GITHUB_OUTPUT"
- name: Open or update pull request
if: steps.update.outputs.changed == 'true'
env:
GH_TOKEN: ${{ env.AUTOMATION_TOKEN }}
run: |
version="${RELEASE_TAG#v}"
title="chore(helm): update app version to ${version}"
body="Updates the Helm charts for [Beszel ${version}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}) and bumps their chart patch versions. Merging this pull request publishes the updated charts to GHCR."
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$BRANCH"
git add supplemental/helm/beszel-agent/Chart.yaml \
supplemental/helm/beszel-agent/README.md \
supplemental/helm/beszel-hub/Chart.yaml \
supplemental/helm/beszel-hub/README.md
git commit -m "$title"
git fetch origin "$BRANCH" || true
git push --force-with-lease origin "HEAD:refs/heads/${BRANCH}"
pr_number=$(gh pr list --head "$BRANCH" --base main --state open --json number --jq '.[0].number')
if [[ -n "$pr_number" ]]; then
gh pr edit "$pr_number" --title "$title" --body "$body"
else
gh pr create --base main --head "$BRANCH" --title "$title" --body "$body"
fi

View File

@@ -2,10 +2,6 @@
name: VulnCheck name: VulnCheck
on: on:
pull_request:
branches:
- main
push: push:
branches: branches:
- main - main
@@ -19,11 +15,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out code into the Go module directory - name: Check out code into the Go module directory
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version: 1.26.x go-version: stable
# cached: false # cached: false
- name: Get official govulncheck - name: Get official govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest run: go install golang.org/x/vuln/cmd/govulncheck@latest

3
.gitignore vendored
View File

@@ -3,7 +3,6 @@ pb_data
data data
temp temp
.vscode .vscode
beszel-agent
beszel_data beszel_data
beszel_data* beszel_data*
dist dist
@@ -21,3 +20,5 @@ __debug_*
agent/lhm/obj agent/lhm/obj
agent/lhm/bin agent/lhm/bin
dockerfile_agent_dev dockerfile_agent_dev
.cr-release-packages
.tmp

View File

@@ -22,6 +22,9 @@ builds:
- amd64 - amd64
- arm64 - arm64
- arm - arm
goarm:
- "6"
- "7"
ignore: ignore:
- goos: windows - goos: windows
goarch: arm64 goarch: arm64
@@ -31,6 +34,8 @@ builds:
goarch: arm64 goarch: arm64
- goos: freebsd - goos: freebsd
goarch: arm goarch: arm
- goos: darwin
goarch: arm
- id: beszel-agent - id: beszel-agent
binary: beszel-agent binary: beszel-agent
@@ -52,6 +57,10 @@ builds:
- mipsle - mipsle
- mips - mips
- ppc64le - ppc64le
goarm:
- "5"
- "6"
- "7"
gomips: gomips:
- hardfloat - hardfloat
- softfloat - softfloat
@@ -71,6 +80,8 @@ builds:
gomips: hardfloat gomips: hardfloat
- goos: windows - goos: windows
goarch: arm goarch: arm
- goos: darwin
goarch: arm
- goos: darwin - goos: darwin
goarch: riscv64 goarch: riscv64
- goos: windows - goos: windows

View File

@@ -52,7 +52,7 @@ lint:
golangci-lint run golangci-lint run
test: test:
go test -tags=testing ./... go test -tags='testing no_ui' ./...
tidy: tidy:
go mod tidy go mod tidy

View File

@@ -1,84 +1,70 @@
//go:build !freebsd // Package battery provides battery information for the host and connected devices.
// Package battery provides functions to check if the system has a battery and to get the battery stats.
package battery package battery
import ( import (
"errors" "errors"
"log/slog" "sort"
"math" "strconv"
"strings"
"github.com/distatus/battery"
) )
var ( const (
systemHasBattery = false stateUnknown uint8 = iota
haveCheckedBattery = false stateEmpty
stateFull
stateCharging
stateDischarging
stateIdle
) )
// HasReadableBattery checks if the system has a battery and returns true if it does. // Battery is a readable battery reported by the operating system.
func HasReadableBattery() bool { type Battery struct {
if haveCheckedBattery { Name string
return systemHasBattery Percent uint8
} State uint8
haveCheckedBattery = true FullChargeCapacity uint64
batteries, err := battery.GetAll() HasFullChargeCapacity bool
for _, bat := range batteries { System bool
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 var errNoBatteries = errors.New("no readable batteries")
// percent = (current charge of all batteries) / (sum of designed/full capacity of all batteries)
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) { // normalizeBatteries supplies stable fallback names and disambiguates duplicates.
if !HasReadableBattery() { func normalizeBatteries(batteries []Battery) []Battery {
return batteryPercent, batteryState, errors.ErrUnsupported nameCounts := make(map[string]int, len(batteries))
for i := range batteries {
name := strings.TrimSpace(batteries[i].Name)
if name == "" {
name = "Battery " + strconv.Itoa(i+1)
}
nameCounts[name]++
if nameCounts[name] > 1 {
name += " (" + strconv.Itoa(nameCounts[name]) + ")"
}
batteries[i].Name = name
} }
batteries, err := battery.GetAll() return batteries
// we'll handle errors later by skipping batteries with errors, rather }
// than skipping everything because of the presence of some errors.
// Primary returns the representative battery. Reported full-charge capacity wins,
// then system-scoped devices, then name for deterministic ties.
func Primary(batteries []Battery) (Battery, bool) {
if len(batteries) == 0 { if len(batteries) == 0 {
return batteryPercent, batteryState, errors.New("no batteries") return Battery{}, false
} }
ordered := append([]Battery(nil), batteries...)
totalCapacity := float64(0) sort.SliceStable(ordered, func(i, j int) bool {
totalCharge := float64(0) a, b := ordered[i], ordered[j]
errs, partialErrs := err.(battery.Errors) if a.HasFullChargeCapacity != b.HasFullChargeCapacity {
return a.HasFullChargeCapacity
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 { if a.HasFullChargeCapacity && a.FullChargeCapacity != b.FullChargeCapacity {
// skip batteries with no capacity. Charge is unlikely to ever be zero, but return a.FullChargeCapacity > b.FullChargeCapacity
// we can't guarantee that, so don't skip based on charge.
continue
} }
totalCapacity += bat.Full if a.System != b.System {
totalCharge += min(bat.Current, bat.Full) return a.System
if bat.State.Raw >= 0 {
batteryState = uint8(bat.State.Raw)
} }
} return a.Name < b.Name
})
if totalCapacity == 0 || batteryState == math.MaxUint8 { return ordered[0], true
// 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,76 @@
//go:build darwin
package battery
import (
"os/exec"
"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
}
func HasReadableBattery() bool {
batteries, _ := GetBatteryStats()
return len(batteries) > 0
}
// GetBatteryStats returns every readable battery reported by macOS.
func GetBatteryStats() ([]Battery, error) {
batteries, err := readMacBatteries()
if err != nil {
return nil, err
}
if len(batteries) == 0 {
return nil, errNoBatteries
}
result := make([]Battery, 0, len(batteries))
for _, bat := range batteries {
if bat.MaxCapacity <= 0 {
// skip ghost batteries with 0 capacity
// https://github.com/distatus/battery/issues/34
continue
}
percent := min(max(float64(bat.CurrentCapacity)/float64(bat.MaxCapacity)*100, 0), 100)
state := stateUnknown
switch {
case !bat.ExternalConnected:
state = stateDischarging
case bat.IsCharging:
state = stateCharging
case bat.CurrentCapacity == 0:
state = stateEmpty
case !bat.FullyCharged:
state = stateIdle
default:
state = stateFull
}
result = append(result, Battery{Name: "Primary", Percent: uint8(percent), State: state,
FullChargeCapacity: uint64(bat.MaxCapacity), HasFullChargeCapacity: true, System: true})
}
if len(result) == 0 {
return nil, errNoBatteries
}
return normalizeBatteries(result), nil
}

View File

@@ -1,13 +0,0 @@
//go:build freebsd
package battery
import "errors"
func HasReadableBattery() bool {
return false
}
func GetBatteryStats() (uint8, uint8, error) {
return 0, 0, errors.ErrUnsupported
}

View File

@@ -0,0 +1,85 @@
//go:build linux
package battery
import (
"os"
"path/filepath"
"strconv"
"github.com/henrygd/beszel/agent/utils"
)
var batteryRoot = "/sys/class/power_supply"
// HasReadableBattery reports whether collection currently finds a readable battery.
func HasReadableBattery() bool {
batteries, _ := GetBatteryStats()
return len(batteries) > 0
}
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 re-enumerates power supplies and returns every readable battery.
func GetBatteryStats() ([]Battery, error) {
entries, err := os.ReadDir(batteryRoot)
if err != nil {
return nil, err
}
batteries := make([]Battery, 0, len(entries))
for _, entry := range entries {
path := filepath.Join(batteryRoot, entry.Name())
if utils.ReadStringFile(filepath.Join(path, "type")) != "Battery" {
continue
}
capStr, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity"))
if !ok {
continue
}
cap, parseErr := strconv.Atoi(capStr)
if parseErr != nil {
continue
}
cap = min(max(cap, 0), 100)
name := utils.ReadStringFile(filepath.Join(path, "model_name"))
if name == "" {
name = utils.ReadStringFile(filepath.Join(path, "model"))
}
if name == "" {
name = entry.Name()
}
battery := Battery{
Name: name,
Percent: uint8(cap),
State: parseSysfsState(utils.ReadStringFile(filepath.Join(path, "status"))),
System: utils.ReadStringFile(filepath.Join(path, "scope")) != "Device",
}
for _, fullName := range []string{"charge_full", "energy_full"} {
if parsed, ok := utils.ReadUintFile(filepath.Join(path, fullName)); ok && parsed > 0 {
battery.FullChargeCapacity = parsed
battery.HasFullChargeCapacity = true
break
}
}
batteries = append(batteries, battery)
}
if len(batteries) == 0 {
return nil, errNoBatteries
}
return normalizeBatteries(batteries), nil
}

View File

@@ -0,0 +1,109 @@
//go:build testing && linux
package battery
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeBattery struct{ id, name, capacity, status, full, scope string }
func setupFakeSysfs(t *testing.T) (string, func(fakeBattery)) {
t.Helper()
root := t.TempDir()
previousRoot := batteryRoot
batteryRoot = root
t.Cleanup(func() { batteryRoot = previousRoot })
write := func(path, value string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(value), 0o644))
}
add := func(b fakeBattery) {
t.Helper()
dir := filepath.Join(root, b.id)
write(filepath.Join(dir, "type"), "Battery")
if b.capacity != "" {
write(filepath.Join(dir, "capacity"), b.capacity)
}
write(filepath.Join(dir, "status"), b.status)
if b.name != "" {
write(filepath.Join(dir, "model_name"), b.name)
}
if b.full != "" {
write(filepath.Join(dir, "energy_full"), b.full)
}
if b.scope != "" {
write(filepath.Join(dir, "scope"), b.scope)
}
}
return root, add
}
func TestParseSysfsState(t *testing.T) {
assert.Equal(t, stateEmpty, parseSysfsState("Empty"))
assert.Equal(t, stateFull, parseSysfsState("Full"))
assert.Equal(t, stateCharging, parseSysfsState("Charging"))
assert.Equal(t, stateDischarging, parseSysfsState("Discharging"))
assert.Equal(t, stateIdle, parseSysfsState("Not charging"))
assert.Equal(t, stateUnknown, parseSysfsState("SomethingElse"))
}
func TestGetBatteryStatsMultipleNamedAndPrimary(t *testing.T) {
_, add := setupFakeSysfs(t)
add(fakeBattery{id: "BAT0", name: "Primary", capacity: "105", status: "Charging", full: "5000", scope: "System"})
add(fakeBattery{id: "hidpp_battery_0", name: "MX Keys S", capacity: "55", status: "Unknown", full: "900", scope: "Device"})
batteries, err := GetBatteryStats()
require.NoError(t, err)
require.Len(t, batteries, 2)
assert.Equal(t, "Primary", batteries[0].Name)
assert.Equal(t, uint8(100), batteries[0].Percent)
assert.Equal(t, stateUnknown, batteries[1].State)
primary, ok := Primary(batteries)
require.True(t, ok)
assert.Equal(t, "Primary", primary.Name)
}
func TestGetBatteryStatsFallbackDuplicatesAndUnreadable(t *testing.T) {
root, add := setupFakeSysfs(t)
add(fakeBattery{id: "BAT0", name: "Keyboard", capacity: "80", status: "Discharging"})
add(fakeBattery{id: "BAT1", name: "Keyboard", capacity: "-4", status: "SomethingWeird"})
add(fakeBattery{id: "BAT2", capacity: "not-a-number", status: "Charging"})
add(fakeBattery{id: "BAT3", capacity: "42", status: "Full"})
ac := filepath.Join(root, "AC0")
require.NoError(t, os.MkdirAll(ac, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(ac, "type"), []byte("Mains"), 0o644))
batteries, err := GetBatteryStats()
require.NoError(t, err)
require.Len(t, batteries, 3)
assert.Equal(t, "Keyboard", batteries[0].Name)
assert.Equal(t, "Keyboard (2)", batteries[1].Name)
assert.Equal(t, uint8(0), batteries[1].Percent)
assert.Equal(t, "BAT3", batteries[2].Name)
}
func TestGetBatteryStatsHotPlugReenumerates(t *testing.T) {
_, add := setupFakeSysfs(t)
_, err := GetBatteryStats()
assert.Error(t, err)
assert.False(t, HasReadableBattery())
add(fakeBattery{id: "BAT0", capacity: "64", status: "Discharging"})
batteries, err := GetBatteryStats()
require.NoError(t, err)
assert.True(t, HasReadableBattery())
require.Len(t, batteries, 1)
assert.Equal(t, uint8(64), batteries[0].Percent)
}
func TestGetBatteryStatsNoReadableCapacity(t *testing.T) {
_, add := setupFakeSysfs(t)
add(fakeBattery{id: "BAT0", status: "Charging"})
_, err := GetBatteryStats()
assert.Error(t, err)
assert.False(t, HasReadableBattery())
}

View File

@@ -0,0 +1,13 @@
//go:build !darwin && !linux && !windows
package battery
import "errors"
func HasReadableBattery() bool {
return false
}
func GetBatteryStats() ([]Battery, error) {
return nil, errors.ErrUnsupported
}

View File

@@ -0,0 +1,35 @@
package battery
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPrimarySelection(t *testing.T) {
tests := []struct {
name string
bats []Battery
want string
}{
{"largest reported capacity", []Battery{{Name: "Small", FullChargeCapacity: 20, HasFullChargeCapacity: true, System: true}, {Name: "Large", FullChargeCapacity: 80, HasFullChargeCapacity: true}}, "Large"},
{"reported ranks over missing", []Battery{{Name: "Unknown", System: true}, {Name: "Known", FullChargeCapacity: 1, HasFullChargeCapacity: true}}, "Known"},
{"system wins capacity tie", []Battery{{Name: "Peripheral", FullChargeCapacity: 50, HasFullChargeCapacity: true}, {Name: "System", FullChargeCapacity: 50, HasFullChargeCapacity: true, System: true}}, "System"},
{"name resolves final tie", []Battery{{Name: "Zed"}, {Name: "Alpha"}}, "Alpha"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := Primary(tt.bats)
require.True(t, ok)
assert.Equal(t, tt.want, got.Name)
})
}
_, ok := Primary(nil)
assert.False(t, ok)
}
func TestNormalizeBatteriesFallbackNames(t *testing.T) {
bats := normalizeBatteries([]Battery{{}, {}, {Name: "Mouse"}, {Name: "Mouse"}})
assert.Equal(t, []string{"Battery 1", "Battery 2", "Mouse", "Mouse (2)"}, []string{bats[0].Name, bats[1].Name, bats[2].Name, bats[3].Name})
}

View File

@@ -0,0 +1,291 @@
//go:build windows
// Most of the Windows battery code is based on
// distatus/battery by Karol 'Kenji Takahashi' Woźniak
package battery
import (
"errors"
"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 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) (Battery, error) {
hdev, err := setupDiSetup(
setupDiGetClassDevsW,
4,
uintptr(unsafe.Pointer(&guidDeviceBattery)),
0, 0,
2|16, // DIGCF_PRESENT|DIGCF_DEVICEINTERFACE
0, 0,
)
if err != nil {
return Battery{}, 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 Battery{}, errNotFound
}
if errno != 0 {
return Battery{}, 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 Battery{}, 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 Battery{}, 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 Battery{}, 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 Battery{}, 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 Battery{}, err
}
// BatteryDeviceName is optional, so retain the deterministic fallback on error.
name := ""
nameQuery := bqi
nameQuery.InformationLevel = 4 // BatteryDeviceName
nameBuffer := make([]uint16, 128)
if err := windows.DeviceIoControl(
handle, 2703428,
(*byte)(unsafe.Pointer(&nameQuery)), uint32(unsafe.Sizeof(nameQuery)),
(*byte)(unsafe.Pointer(&nameBuffer[0])), uint32(len(nameBuffer)*2),
&dwOut, nil,
); err == nil {
name = windows.UTF16ToString(nameBuffer)
}
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 Battery{}, err
}
if bs.Capacity == 0xffffffff || bi.FullChargedCapacity == 0 || bi.FullChargedCapacity == 0xffffffff {
return Battery{}, errors.New("battery capacity unknown")
}
percent := min(float64(bs.Capacity)/float64(bi.FullChargedCapacity)*100, 100)
return Battery{Name: name, Percent: uint8(percent), State: readWinBatteryState(bs.PowerState),
FullChargeCapacity: uint64(bi.FullChargedCapacity), HasFullChargeCapacity: true, System: true}, nil
}
// HasReadableBattery checks if the system has a battery and returns true if it does.
func HasReadableBattery() bool {
batteries, _ := GetBatteryStats()
return len(batteries) > 0
}
// GetBatteryStats returns every readable battery reported by Windows.
func GetBatteryStats() ([]Battery, error) {
batteries := make([]Battery, 0, 2)
for i := 0; ; i++ {
battery, bErr := winBatteryGet(i)
if errors.Is(bErr, errNotFound) {
break
}
if bErr != nil {
continue
}
batteries = append(batteries, battery)
}
if len(batteries) == 0 {
return nil, errNoBatteries
}
return normalizeBatteries(batteries), nil
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws" "github.com/lxzan/gws"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"golang.org/x/net/proxy"
) )
const ( const (
@@ -52,8 +53,8 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
client = &WebSocketClient{} client = &WebSocketClient{}
client.hubURL, err = url.Parse(hubURLStr) client.hubURL, err = url.Parse(hubURLStr)
if err != nil { if err != nil || client.hubURL.Host == "" {
return nil, errors.New("invalid hub URL") return nil, fmt.Errorf("invalid HUB_URL %q: must include scheme and host (e.g. http://hub.example.com:8090)", hubURLStr)
} }
// get registration token // get registration token
client.token, err = getToken() client.token, err = getToken()
@@ -104,6 +105,11 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
} }
client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect") client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect")
// make sure BESZEL_AGENT_ALL_PROXY works (GWS only checks ALL_PROXY)
if val := os.Getenv("BESZEL_AGENT_ALL_PROXY"); val != "" {
os.Setenv("ALL_PROXY", val)
}
client.options = &gws.ClientOption{ client.options = &gws.ClientOption{
Addr: client.hubURL.String(), Addr: client.hubURL.String(),
TlsConfig: &tls.Config{InsecureSkipVerify: true}, TlsConfig: &tls.Config{InsecureSkipVerify: true},
@@ -112,6 +118,9 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
"X-Token": []string{client.token}, "X-Token": []string{client.token},
"X-Beszel": []string{beszel.Version}, "X-Beszel": []string{beszel.Version},
}, },
NewDialer: func() (gws.Dialer, error) {
return proxy.FromEnvironment(), nil
},
} }
return client.options return client.options
} }

View File

@@ -51,11 +51,18 @@ func TestNewWebSocketClient(t *testing.T) {
errorMsg: "HUB_URL environment variable not set", errorMsg: "HUB_URL environment variable not set",
}, },
{ {
name: "invalid URL", name: "malformed URL",
hubURL: "ht\ttp://invalid", hubURL: "ht\ttp://invalid",
token: "test-token", token: "test-token",
expectError: true, expectError: true,
errorMsg: "invalid hub URL", errorMsg: "invalid HUB_URL",
},
{
name: "URL without host",
hubURL: "http:/api",
token: "test-token",
expectError: true,
errorMsg: "invalid HUB_URL",
}, },
{ {
name: "missing token", name: "missing token",

View File

@@ -4,11 +4,15 @@ import (
"context" "context"
"errors" "errors"
"log/slog" "log/slog"
"net"
"os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"time" "time"
"github.com/henrygd/beszel/agent/health" "github.com/henrygd/beszel/agent/health"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
) )
@@ -111,13 +115,36 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
_ = health.Update() _ = health.Update()
case <-sigCtx.Done(): case <-sigCtx.Done():
slog.Info("Shutting down", "cause", context.Cause(sigCtx)) slog.Info("Shutting down", "cause", context.Cause(sigCtx))
_ = c.agent.StopServer() return c.stop()
c.closeWebSocket()
return health.CleanUp()
} }
} }
} }
// stop does not stop the connection manager itself, just any active connections. The manager will attempt to reconnect after stopping, so this should only be called immediately before shutting down the entire agent.
//
// If we need or want to expose a graceful Stop method in the future, do something like this to actually stop the manager:
//
// func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
// ctx, cancel := context.WithCancel(context.Background())
// c.cancel = cancel
//
// for {
// select {
// case <-ctx.Done():
// return c.stop()
// }
// }
// }
//
// func (c *ConnectionManager) Stop() {
// c.cancel()
// }
func (c *ConnectionManager) stop() error {
_ = c.agent.StopServer()
c.closeWebSocket()
return health.CleanUp()
}
// handleEvent processes connection events and updates the connection state accordingly. // handleEvent processes connection events and updates the connection state accordingly.
func (c *ConnectionManager) handleEvent(event ConnectionEvent) { func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
switch event { switch event {
@@ -185,9 +212,16 @@ func (c *ConnectionManager) connect() {
// Try WebSocket first, if it fails, start SSH server // Try WebSocket first, if it fails, start SSH server
err := c.startWebSocketConnection() err := c.startWebSocketConnection()
if err != nil && c.State == Disconnected { if err != nil {
c.startSSHServer() if shouldExitOnErr(err) {
c.startWsTicker() time.Sleep(2 * time.Second) // prevent tight restart loop
_ = c.stop()
os.Exit(1)
}
if c.State == Disconnected {
c.startSSHServer()
c.startWsTicker()
}
} }
} }
@@ -224,3 +258,14 @@ func (c *ConnectionManager) closeWebSocket() {
c.wsClient.Close() c.wsClient.Close()
} }
} }
// shouldExitOnErr checks if the error is a DNS resolution failure and if the
// EXIT_ON_DNS_ERROR env var is set. https://github.com/henrygd/beszel/issues/1924.
func shouldExitOnErr(err error) bool {
if val, _ := utils.GetEnv("EXIT_ON_DNS_ERROR"); val == "true" {
if opErr, ok := errors.AsType[*net.OpError](err); ok {
return strings.Contains(opErr.Err.Error(), "lookup")
}
}
return false
}

View File

@@ -4,6 +4,7 @@ package agent
import ( import (
"crypto/ed25519" "crypto/ed25519"
"errors"
"fmt" "fmt"
"net" "net"
"net/url" "net/url"
@@ -298,3 +299,65 @@ func TestConnectionManager_ConnectFlow(t *testing.T) {
cm.connect() cm.connect()
}, "Connect should not panic without WebSocket client") }, "Connect should not panic without WebSocket client")
} }
func TestShouldExitOnErr(t *testing.T) {
createDialErr := func(msg string) error {
return &net.OpError{
Op: "dial",
Net: "tcp",
Err: errors.New(msg),
}
}
tests := []struct {
name string
err error
envValue string
expected bool
}{
{
name: "no env var",
err: createDialErr("lookup lkahsdfasdf: no such host"),
envValue: "",
expected: false,
},
{
name: "env var false",
err: createDialErr("lookup lkahsdfasdf: no such host"),
envValue: "false",
expected: false,
},
{
name: "env var true, matching error",
err: createDialErr("lookup lkahsdfasdf: no such host"),
envValue: "true",
expected: true,
},
{
name: "env var true, matching error with extra context",
err: createDialErr("lookup beszel.server.lan on [::1]:53: read udp [::1]:44557->[::1]:53: read: connection refused"),
envValue: "true",
expected: true,
},
{
name: "env var true, non-matching error",
err: errors.New("connection refused"),
envValue: "true",
expected: false,
},
{
name: "env var true, dial but not lookup",
err: createDialErr("connection timeout"),
envValue: "true",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("EXIT_ON_DNS_ERROR", tt.envValue)
result := shouldExitOnErr(tt.err)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -34,6 +34,34 @@ type diskDiscovery struct {
ctx fsRegistrationContext ctx fsRegistrationContext
} }
// prevDisk stores previous per-device disk counters for a given cache interval
type prevDisk struct {
readBytes uint64
writeBytes uint64
readTime uint64 // cumulative ms spent on reads (from ReadTime)
writeTime uint64 // cumulative ms spent on writes (from WriteTime)
ioTime uint64 // cumulative ms spent doing I/O (from IoTime)
weightedIO uint64 // cumulative weighted ms (queue-depth × ms, from WeightedIO)
readCount uint64 // cumulative read operation count
writeCount uint64 // cumulative write operation count
at time.Time
}
// prevDiskFromCounter creates a prevDisk snapshot from a disk.IOCountersStat at time t.
func prevDiskFromCounter(d disk.IOCountersStat, t time.Time) prevDisk {
return prevDisk{
readBytes: d.ReadBytes,
writeBytes: d.WriteBytes,
readTime: d.ReadTime,
writeTime: d.WriteTime,
ioTime: d.IoTime,
weightedIO: d.WeightedIO,
readCount: d.ReadCount,
writeCount: d.WriteCount,
at: t,
}
}
// parseFilesystemEntry parses a filesystem entry in the format "device__customname" // parseFilesystemEntry parses a filesystem entry in the format "device__customname"
// Returns the device/filesystem part and the custom name part // Returns the device/filesystem part and the custom name part
func parseFilesystemEntry(entry string) (device, customName string) { func parseFilesystemEntry(entry string) (device, customName string) {
@@ -239,9 +267,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)
@@ -579,16 +609,29 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
prev, hasPrev := a.diskPrev[cacheTimeMs][name] prev, hasPrev := a.diskPrev[cacheTimeMs][name]
if !hasPrev { if !hasPrev {
// Seed from agent-level fsStats if present, else seed from current // Seed from agent-level fsStats if present, else seed from current
prev = prevDisk{readBytes: stats.TotalRead, writeBytes: stats.TotalWrite, at: stats.Time} prev = prevDisk{
readBytes: stats.TotalRead,
writeBytes: stats.TotalWrite,
readTime: d.ReadTime,
writeTime: d.WriteTime,
ioTime: d.IoTime,
weightedIO: d.WeightedIO,
readCount: d.ReadCount,
writeCount: d.WriteCount,
at: stats.Time,
}
if prev.at.IsZero() { if prev.at.IsZero() {
prev = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now} prev = prevDiskFromCounter(d, now)
} }
} }
msElapsed := uint64(now.Sub(prev.at).Milliseconds()) msElapsed := uint64(now.Sub(prev.at).Milliseconds())
// Update per-interval snapshot
a.diskPrev[cacheTimeMs][name] = prevDiskFromCounter(d, now)
// Avoid division by zero or clock issues
if msElapsed < 100 { if msElapsed < 100 {
// Avoid division by zero or clock issues; update snapshot and continue
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
continue continue
} }
@@ -600,15 +643,38 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
// validate values // validate values
if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 { if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 {
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readMbPerSecond, "write", writeMbPerSecond) slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readMbPerSecond, "write", writeMbPerSecond)
// Reset interval snapshot and seed from current
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
// also refresh agent baseline to avoid future negatives // also refresh agent baseline to avoid future negatives
a.initializeDiskIoStats(ioCounters) a.initializeDiskIoStats(ioCounters)
continue continue
} }
// Update per-interval snapshot // These properties are calculated differently on different platforms,
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now} // but generally represent cumulative time spent doing reads/writes on the device.
// This can surpass 100% if there are multiple concurrent I/O operations.
// Linux kernel docs:
// This is the total number of milliseconds spent by all reads (as
// measured from __make_request() to end_that_request_last()).
// https://www.kernel.org/doc/Documentation/iostats.txt (fields 4, 8)
diskReadTime := utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(msElapsed) * 100)
diskWriteTime := utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(msElapsed) * 100)
// I/O utilization %: fraction of wall time the device had any I/O in progress (0-100).
diskIoUtilPct := utils.TwoDecimals(float64(d.IoTime-prev.ioTime) / float64(msElapsed) * 100)
// Weighted I/O: queue-depth weighted I/O time, normalized to interval (can exceed 100%).
// Linux kernel field 11: incremented by iops_in_progress × ms_since_last_update.
// Used to display queue depth. Multipled by 100 to increase accuracy of digit truncation (divided by 100 in UI).
diskWeightedIO := utils.TwoDecimals(float64(d.WeightedIO-prev.weightedIO) / float64(msElapsed) * 100)
// r_await / w_await: average time per read/write operation in milliseconds.
// Equivalent to r_await and w_await in iostat.
var rAwait, wAwait float64
if deltaReadCount := d.ReadCount - prev.readCount; deltaReadCount > 0 {
rAwait = utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(deltaReadCount))
}
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
wAwait = utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(deltaWriteCount))
}
// Update global fsStats baseline for cross-interval correctness // Update global fsStats baseline for cross-interval correctness
stats.Time = now stats.Time = now
@@ -618,20 +684,40 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
stats.DiskWritePs = writeMbPerSecond stats.DiskWritePs = writeMbPerSecond
stats.DiskReadBytes = diskIORead stats.DiskReadBytes = diskIORead
stats.DiskWriteBytes = diskIOWrite stats.DiskWriteBytes = diskIOWrite
stats.DiskIoStats[0] = diskReadTime
stats.DiskIoStats[1] = diskWriteTime
stats.DiskIoStats[2] = diskIoUtilPct
stats.DiskIoStats[3] = rAwait
stats.DiskIoStats[4] = wAwait
stats.DiskIoStats[5] = diskWeightedIO
if stats.Root { if stats.Root {
systemStats.DiskReadPs = stats.DiskReadPs systemStats.DiskReadPs = stats.DiskReadPs
systemStats.DiskWritePs = stats.DiskWritePs systemStats.DiskWritePs = stats.DiskWritePs
systemStats.DiskIO[0] = diskIORead systemStats.DiskIO[0] = diskIORead
systemStats.DiskIO[1] = diskIOWrite systemStats.DiskIO[1] = diskIOWrite
systemStats.DiskIoStats[0] = diskReadTime
systemStats.DiskIoStats[1] = diskWriteTime
systemStats.DiskIoStats[2] = diskIoUtilPct
systemStats.DiskIoStats[3] = rAwait
systemStats.DiskIoStats[4] = wAwait
systemStats.DiskIoStats[5] = diskWeightedIO
} }
} }
} }
} }
// 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

@@ -65,7 +65,6 @@ type dockerManager struct {
dockerVersionChecked bool // Whether a version probe has completed successfully dockerVersionChecked bool // Whether a version probe has completed successfully
isWindows bool // Whether the Docker Engine API is running on Windows isWindows bool // Whether the Docker Engine API is running on Windows
buf *bytes.Buffer // Buffer to store and read response bodies buf *bytes.Buffer // Buffer to store and read response bodies
decoder *json.Decoder // Reusable JSON decoder that reads from buf
apiStats *container.ApiStats // Reusable API stats object apiStats *container.ApiStats // Reusable API stats object
excludeContainers []string // Patterns to exclude containers by name excludeContainers []string // Patterns to exclude containers by name
usingPodman bool // Whether the Docker Engine API is running on Podman usingPodman bool // Whether the Docker Engine API is running on Podman
@@ -374,16 +373,26 @@ func convertContainerPortsToString(ctr *container.ApiInfo) string {
return "" return ""
} }
sort.Slice(ctr.Ports, func(i, j int) bool { sort.Slice(ctr.Ports, func(i, j int) bool {
return ctr.Ports[i].PublicPort < ctr.Ports[j].PublicPort if ctr.Ports[i].PublicPort != ctr.Ports[j].PublicPort {
return ctr.Ports[i].PublicPort < ctr.Ports[j].PublicPort
}
return ctr.Ports[i].IP < ctr.Ports[j].IP
}) })
var builder strings.Builder var builder strings.Builder
seenPorts := make(map[uint16]struct{}) seen := make(map[string]struct{})
for _, p := range ctr.Ports { for _, p := range ctr.Ports {
_, ok := seenPorts[p.PublicPort] if p.PublicPort == 0 {
if p.PublicPort == 0 || ok {
continue continue
} }
seenPorts[p.PublicPort] = struct{}{} keyIP := p.IP
if keyIP == "0.0.0.0" || keyIP == "::" {
keyIP = ""
}
key := keyIP + ":" + strconv.Itoa(int(p.PublicPort))
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if builder.Len() > 0 { if builder.Len() > 0 {
builder.WriteString(", ") builder.WriteString(", ")
} }
@@ -747,20 +756,18 @@ func (dm *dockerManager) applyDockerVersionInfo(serverHeader string, versionInfo
} }
} }
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe. // Decodes a Docker API JSON response using a reusable buffer. Not thread safe.
func (dm *dockerManager) decode(resp *http.Response, d any) error { func (dm *dockerManager) decode(resp *http.Response, d any) error {
if dm.buf == nil { if dm.buf == nil {
// initialize buffer with 256kb starting size // initialize buffer with 256kb starting size
dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256)) dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256))
dm.decoder = json.NewDecoder(dm.buf)
} }
defer resp.Body.Close() defer resp.Body.Close()
defer dm.buf.Reset() defer dm.buf.Reset()
_, err := dm.buf.ReadFrom(resp.Body) if _, err := dm.buf.ReadFrom(resp.Body); err != nil {
if err != nil {
return err return err
} }
return dm.decoder.Decode(d) return json.Unmarshal(dm.buf.Bytes(), d)
} }
// Test docker / podman sockets and return if one exists // Test docker / podman sockets and return if one exists

View File

@@ -804,6 +804,24 @@ func TestGetDockerStatsRetriesVersionCheckUntilSuccess(t *testing.T) {
assert.Equal(t, 2, requestCounts["/version"]) assert.Equal(t, 2, requestCounts["/version"])
} }
// A failed decode must not break later decodes. Previously the reused json.Decoder
// stayed desynced after one truncated response, breaking decode until restart.
func TestDecodeRecoversFromError(t *testing.T) {
dm := &dockerManager{}
// truncated JSON: body reads fine, decode fails
var bad []container.ApiInfo
err := dm.decode(&http.Response{Body: io.NopCloser(strings.NewReader(`[{"Id":"abc`))}, &bad)
require.Error(t, err)
// the next decode must still succeed
var good []container.ApiInfo
err = dm.decode(&http.Response{Body: io.NopCloser(strings.NewReader(`[{"Id":"abcdef012345","Names":["/ok"]}]`))}, &good)
require.NoError(t, err)
require.Len(t, good, 1)
assert.Equal(t, "abcdef012345", good[0].Id)
}
func TestCycleCpuDeltas(t *testing.T) { func TestCycleCpuDeltas(t *testing.T) {
dm := &dockerManager{ dm := &dockerManager{
lastCpuContainer: map[uint16]map[string]uint64{ lastCpuContainer: map[uint16]map[string]uint64{
@@ -1003,6 +1021,44 @@ func TestCpuPercentageCalculationWithRealData(t *testing.T) {
assert.InDelta(t, expectedPct, actualPct, 0.01) assert.InDelta(t, expectedPct, actualPct, 0.01)
} }
func TestCpuPercentageHandlesCounterRollback(t *testing.T) {
// If a stats response is processed after a newer one for the same container,
// or an accounting counter resets, the current total can be lower than the
// stored previous value. Unsigned subtraction wraps to ~2^64 instead of
// going negative, so the percentage explodes, validateCpuPercentage rejects
// the sample, and the whole collection is discarded - network stats too.
stats := &container.ApiStats{
CPUStats: container.CPUStats{
CPUUsage: container.CPUUsage{TotalUsage: 1_000_000},
SystemUsage: 20_000_000,
},
}
// Container counter went backwards.
assert.Equal(t, 0.0, stats.CalculateCpuPercentLinux(2_000_000, 10_000_000))
// System counter went backwards.
assert.Equal(t, 0.0, stats.CalculateCpuPercentLinux(500_000, 30_000_000))
// A normal forward sample is unaffected: 500000 / 10000000 * 100 = 5%.
assert.InDelta(t, 5.0, stats.CalculateCpuPercentLinux(500_000, 10_000_000), 0.001)
}
func TestCpuPercentageWindowsHandlesCounterRollback(t *testing.T) {
now := time.Now()
stats := &container.ApiStats{
Read: now,
NumProcs: 4,
CPUStats: container.CPUStats{
CPUUsage: container.CPUUsage{TotalUsage: 1_000_000},
},
}
prevRead := now.Add(-time.Second)
// Container counter went backwards.
assert.Equal(t, 0.0, stats.CalculateCpuPercentWindows(2_000_000, prevRead))
// A normal forward sample is unaffected.
assert.Greater(t, stats.CalculateCpuPercentWindows(500_000, prevRead), 0.0)
}
func TestNetworkStatsCalculationWithRealData(t *testing.T) { func TestNetworkStatsCalculationWithRealData(t *testing.T) {
// Create synthetic test data to avoid timing issues // Create synthetic test data to avoid timing issues
apiStats1 := &container.ApiStats{ apiStats1 := &container.ApiStats{
@@ -1864,6 +1920,14 @@ func TestConvertContainerPortsToString(t *testing.T) {
}, },
expected: "80, 443", expected: "80, 443",
}, },
{
name: "ipv4 and ipv6 wildcard bindings are deduplicated",
ports: []port{
{PublicPort: 80, IP: "0.0.0.0"},
{PublicPort: 80, IP: "::"},
},
expected: "80",
},
{ {
name: "multiple ports with different IPs", name: "multiple ports with different IPs",
ports: []port{ ports: []port{
@@ -1872,6 +1936,22 @@ func TestConvertContainerPortsToString(t *testing.T) {
}, },
expected: "80, 1.2.3.4:443", expected: "80, 1.2.3.4:443",
}, },
{
name: "same port bound to multiple IPs shows all entries",
ports: []port{
{PublicPort: 65533, IP: "172.16.151.72"},
{PublicPort: 65533, IP: "172.16.156.25"},
},
expected: "172.16.151.72:65533, 172.16.156.25:65533",
},
{
name: "same port bound to IPv4 and IPv6",
ports: []port{
{PublicPort: 65534, IP: "172.16.151.72"},
{PublicPort: 65534, IP: "fd04:38e2:98c6:3fd::72"},
},
expected: "172.16.151.72:65534, fd04:38e2:98c6:3fd::72:65534",
},
{ {
name: "ports slice is nilled after call", name: "ports slice is nilled after call",
ports: []port{ ports: []port{

101
agent/fans.go Normal file
View File

@@ -0,0 +1,101 @@
package agent
import (
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
type fanSensor struct {
key, path string
}
var getFanSensors = newFanSensorCache(hwmonRoot)
func newFanSensorCache(root string) func() ([]fanSensor, error) {
return sync.OnceValues(func() ([]fanSensor, error) {
return discoverHwmonFans(root)
})
}
// updateFans populates systemStats.Fans from the host's hwmon sysfs tree.
// No-op on platforms where hwmon isn't available (see fans_other.go).
func (a *Agent) updateFans(systemStats *system.Stats) {
if hwmonRoot == "" {
return
}
sensors, err := getFanSensors()
if err != nil {
slog.Debug("Error reading fans", "err", err)
return
}
fans := readFanSensors(sensors)
if len(fans) == 0 {
return
}
systemStats.Fans = fans
// Note: Commented out because we don't currently use this value in the UI.
// Compute the single "dashboard" value used by the FanSpeed alert.
// Per-sensor RPMs live in Stats.Fans and drive the multi-line FanChart
// in the UI; the alert path only needs one number to compare against
// the user's threshold, so we use the highest RPM across all fans
// a.systemInfo.DashboardFan = 0
// for _, rpm := range fans {
// if rpm > a.systemInfo.DashboardFan {
// a.systemInfo.DashboardFan = rpm
// }
// }
}
// readHwmonFans walks the given hwmon root (typically /sys/class/hwmon) and
// returns a map of "<chip>_<label-or-fan-idx>" → RPM for every fan*_input
// file it finds. Zero RPM is retained because it can represent a real fan that
// has stopped; negative and malformed readings are ignored.
func readHwmonFans(root string) (map[string]uint16, error) {
sensors, err := discoverHwmonFans(root)
if err != nil {
return nil, err
}
return readFanSensors(sensors), nil
}
func discoverHwmonFans(root string) ([]fanSensor, error) {
entries, err := os.ReadDir(root)
if err != nil {
return nil, err
}
var sensors []fanSensor
for _, entry := range entries {
chipDir := filepath.Join(root, entry.Name())
chipName := utils.ReadStringFile(filepath.Join(chipDir, "name"))
if chipName == "" {
chipName = entry.Name()
}
inputs, _ := filepath.Glob(filepath.Join(chipDir, "fan*_input"))
for _, inputPath := range inputs {
base := strings.TrimSuffix(filepath.Base(inputPath), "_input")
label := utils.ReadStringFile(filepath.Join(chipDir, base+"_label"))
key := chipName + "_" + base
if label != "" {
key = chipName + "_" + label
}
sensors = append(sensors, fanSensor{key, inputPath})
}
}
return sensors, nil
}
func readFanSensors(sensors []fanSensor) map[string]uint16 {
fans := make(map[string]uint16, len(sensors))
for _, sensor := range sensors {
if rpm, ok := utils.ReadUintFile(sensor.path); ok {
fans[sensor.key] = uint16(rpm)
}
}
return fans
}

8
agent/fans_linux.go Normal file
View File

@@ -0,0 +1,8 @@
//go:build linux
package agent
// hwmonRoot is the sysfs entry point for hardware monitor chips. Each
// subdirectory (hwmon0, hwmon1, …) is one chip; fan*_input files inside it
// expose RPM readings.
const hwmonRoot = "/sys/class/hwmon"

7
agent/fans_other.go Normal file
View File

@@ -0,0 +1,7 @@
//go:build !linux
package agent
// hwmonRoot is empty on non-Linux platforms — fan RPM reporting via sysfs
// hwmon is Linux-specific. updateFans() short-circuits when this is empty.
const hwmonRoot = ""

87
agent/fans_test.go Normal file
View File

@@ -0,0 +1,87 @@
//go:build testing
package agent
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// writeFile creates path with parents and writes contents.
func writeFile(t *testing.T, path, contents string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(contents), 0o644))
}
// TestReadHwmonFans verifies the /sys/class/hwmon walker:
// - picks up fan*_input from every chip,
// - keys entries by chip name + sensor label (or fan idx if no label),
// - retains 0 RPM for stopped fans,
// - tolerates chips with no fan files at all.
func TestReadHwmonFans(t *testing.T) {
root := t.TempDir()
// hwmon0: Raspberry Pi 5 active cooler — one fan, no label.
writeFile(t, filepath.Join(root, "hwmon0", "name"), "pwmfan\n")
writeFile(t, filepath.Join(root, "hwmon0", "fan1_input"), "6500\n")
// hwmon1: a thermal-only chip, no fan files. Must not error.
writeFile(t, filepath.Join(root, "hwmon1", "name"), "cpu_thermal\n")
writeFile(t, filepath.Join(root, "hwmon1", "temp1_input"), "55000\n")
// hwmon2: two fans — one stopped (0 RPM) and one labeled "chassis".
writeFile(t, filepath.Join(root, "hwmon2", "name"), "nct6798\n")
writeFile(t, filepath.Join(root, "hwmon2", "fan1_input"), "0\n")
writeFile(t, filepath.Join(root, "hwmon2", "fan2_input"), "1200\n")
writeFile(t, filepath.Join(root, "hwmon2", "fan2_label"), "chassis\n")
fans, err := readHwmonFans(root)
require.NoError(t, err)
assert.Equal(t, map[string]uint16{
"pwmfan_fan1": 6500,
"nct6798_fan1": 0,
"nct6798_chassis": 1200,
}, fans)
}
// TestReadHwmonFansMissingRoot returns an error rather than panicking when the
// hwmon root doesn't exist (e.g. running on a kernel without hwmon support).
func TestReadHwmonFansMissingRoot(t *testing.T) {
_, err := readHwmonFans(filepath.Join(t.TempDir(), "does-not-exist"))
assert.Error(t, err)
}
// TestReadHwmonFansEmpty returns an empty map (not nil error) when the root
// exists but contains no chips at all.
func TestReadHwmonFansEmpty(t *testing.T) {
root := t.TempDir()
fans, err := readHwmonFans(root)
require.NoError(t, err)
assert.Empty(t, fans)
}
func TestFanDiscoveryCache(t *testing.T) {
root := t.TempDir()
input := filepath.Join(root, "hwmon0", "fan1_input")
writeFile(t, filepath.Join(root, "hwmon0", "name"), "chip\n")
writeFile(t, input, "1000\n")
getSensors := newFanSensorCache(root)
sensors, err := getSensors()
require.NoError(t, err)
fans := readFanSensors(sensors)
assert.Equal(t, uint16(1000), fans["chip_fan1"])
writeFile(t, input, "1200\n")
writeFile(t, filepath.Join(root, "hwmon0", "fan1_label"), "case\n")
sensors, err = getSensors()
require.NoError(t, err)
fans = readFanSensors(sensors)
assert.Equal(t, map[string]uint16{"chip_fan1": 1200}, fans)
}

View File

@@ -48,6 +48,8 @@ type GPUManager struct {
// Per-cache-key tracking for delta calculations // Per-cache-key tracking for delta calculations
// cacheKey -> gpuId -> snapshot of last count/usage/power values // cacheKey -> gpuId -> snapshot of last count/usage/power values
lastSnapshots map[uint16]map[string]*gpuSnapshot lastSnapshots map[uint16]map[string]*gpuSnapshot
// Per-card energy snapshots for Intel sysfs power calculation.
intelSysfsEnergySnapshots map[string]intelSysfsEnergySnapshot
} }
// gpuSnapshot stores the last observed incremental values for delta tracking // gpuSnapshot stores the last observed incremental values for delta tracking
@@ -90,6 +92,7 @@ const (
collectorSourceNVML collectorSource = "nvml" collectorSourceNVML collectorSource = "nvml"
collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd) collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd)
collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd) collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd)
collectorSourceIntelSysfs collectorSource = "intel_sysfs"
collectorSourceAmdSysfs collectorSource = "amd_sysfs" collectorSourceAmdSysfs collectorSource = "amd_sysfs"
collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd) collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd)
collectorSourceMacmon collectorSource = collectorSource(macmonCmd) collectorSourceMacmon collectorSource = collectorSource(macmonCmd)
@@ -106,6 +109,7 @@ func isValidCollectorSource(source collectorSource) bool {
collectorSourceNVML, collectorSourceNVML,
collectorSourceNvidiaSMI, collectorSourceNvidiaSMI,
collectorSourceIntelGpuTop, collectorSourceIntelGpuTop,
collectorSourceIntelSysfs,
collectorSourceAmdSysfs, collectorSourceAmdSysfs,
collectorSourceRocmSMI, collectorSourceRocmSMI,
collectorSourceMacmon, collectorSourceMacmon,
@@ -122,6 +126,8 @@ type gpuCapabilities struct {
hasAmdSysfs bool hasAmdSysfs bool
hasTegrastats bool hasTegrastats bool
hasIntelGpuTop bool hasIntelGpuTop bool
hasXe bool
hasIntelSysfs bool
hasNvtop bool hasNvtop bool
hasMacmon bool hasMacmon bool
hasPowermetrics bool hasPowermetrics bool
@@ -369,12 +375,13 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
gpuAvg.Power = utils.TwoDecimals(deltaPower / float64(deltaCount)) gpuAvg.Power = utils.TwoDecimals(deltaPower / float64(deltaCount))
gpuAvg.PowerPkg = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount))
if gpu.Engines != nil { if gpu.Engines != nil {
// make fresh map for averaged engine metrics to avoid mutating // make fresh map for averaged engine metrics to avoid mutating
// the accumulator map stored in gm.GpuDataMap // the accumulator map stored in gm.GpuDataMap
gpuAvg.Engines = make(map[string]float64, len(gpu.Engines)) gpuAvg.Engines = make(map[string]float64, len(gpu.Engines))
gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount) gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount)
gpuAvg.PowerPkg = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount))
} else { } else {
gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount)) gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount))
} }
@@ -444,6 +451,8 @@ func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uin
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities { func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
caps := gpuCapabilities{ caps := gpuCapabilities{
hasAmdSysfs: gm.hasAmdSysfs(), hasAmdSysfs: gm.hasAmdSysfs(),
hasXe: gm.hasXe(),
hasIntelSysfs: gm.hasIntelSysfs(),
} }
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil { if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
caps.hasNvidiaSmi = true caps.hasNvidiaSmi = true
@@ -472,7 +481,7 @@ func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
} }
func hasAnyGpuCollector(caps gpuCapabilities) bool { func hasAnyGpuCollector(caps gpuCapabilities) bool {
return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasIntelSysfs || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics
} }
func (gm *GPUManager) startIntelCollector() { func (gm *GPUManager) startIntelCollector() {
@@ -563,6 +572,13 @@ func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSo
return true return true
}, },
}, },
collectorSourceIntelSysfs: {
group: collectorGroupIntel,
available: caps.hasIntelSysfs,
start: func(_ func()) bool {
return gm.startIntelSysfsCollector()
},
},
collectorSourceAmdSysfs: { collectorSourceAmdSysfs: {
group: collectorGroupAmd, group: collectorGroupAmd,
available: caps.hasAmdSysfs, available: caps.hasAmdSysfs,
@@ -705,9 +721,12 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
priorities = append(priorities, collectorSourceAmdSysfs) priorities = append(priorities, collectorSourceAmdSysfs)
} }
if caps.hasIntelGpuTop { if caps.hasIntelGpuTop && !caps.hasXe {
priorities = append(priorities, collectorSourceIntelGpuTop) priorities = append(priorities, collectorSourceIntelGpuTop)
} }
if caps.hasIntelSysfs {
priorities = append(priorities, collectorSourceIntelSysfs)
}
// Apple collectors are currently opt-in only for testing. // Apple collectors are currently opt-in only for testing.
// Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics. // Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics.

View File

@@ -156,6 +156,7 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
func readSysfsFloat(path string) (float64, error) { func readSysfsFloat(path string) (float64, error) {
val, err := utils.ReadStringFileLimited(path, 64) val, err := utils.ReadStringFileLimited(path, 64)
if err != nil { if err != nil {
slog.Debug("Failed to read sysfs value", "path", path, "error", err)
return 0, err return 0, err
} }
return strconv.ParseFloat(val, 64) return strconv.ParseFloat(val, 64)

View File

@@ -0,0 +1,280 @@
//go:build linux
package agent
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
var (
drmSysfsRoot = "/sys/class/drm"
intelSysfsNow = time.Now
)
type intelSysfsEnergySnapshot struct {
microjoules uint64
timestamp time.Time
}
type intelSysfsCard struct {
cardPath string
hwmonDir string
}
// hasIntelSysfs returns true if any Intel DRM card exposes an hwmon energy counter.
func (gm *GPUManager) hasIntelSysfs() bool {
cards, err := discoverIntelSysfsCards()
return err == nil && len(cards) > 0
}
// startIntelSysfsCollector starts Intel GPU collection via sysfs.
func (gm *GPUManager) startIntelSysfsCollector() bool {
go func() {
if err := gm.collectIntelSysfsStats(); err != nil {
slog.Warn("Error collecting Intel GPU data via sysfs", "err", err)
}
}()
return true
}
// collectIntelSysfsStats collects Intel GPU metrics directly from DRM sysfs / hwmon.
func (gm *GPUManager) collectIntelSysfsStats() error {
sysfsPollInterval := 3000 * time.Millisecond
cards, err := discoverIntelSysfsCards()
if err != nil {
return err
}
if len(cards) == 0 {
return errNoValidData
}
slog.Debug("Using sysfs for Intel GPU data collection", "cards", len(cards))
for _, card := range cards {
slog.Debug("Intel sysfs card detected", "card", filepath.Base(card.cardPath), "hwmon", card.hwmonDir)
}
failures := 0
for {
hasData := false
for _, card := range cards {
if gm.updateIntelSysfsGpuData(card.cardPath, card.hwmonDir) {
hasData = true
}
}
if !hasData {
failures++
if failures > maxFailureRetries {
return errNoValidData
}
slog.Warn("No Intel GPU data from sysfs", "failures", failures)
time.Sleep(retryWaitTime)
continue
}
failures = 0
time.Sleep(sysfsPollInterval)
}
}
func discoverIntelSysfsCards() ([]intelSysfsCard, error) {
paths, err := filepath.Glob(filepath.Join(drmSysfsRoot, "card*"))
if err != nil {
return nil, err
}
var cards []intelSysfsCard
for _, cardPath := range paths {
if strings.Contains(filepath.Base(cardPath), "-") || !isIntelGpu(cardPath) {
continue
}
hwmonDir := findIntelEnergyHwmon(filepath.Join(cardPath, "device"))
if hwmonDir == "" {
continue
}
cards = append(cards, intelSysfsCard{cardPath: cardPath, hwmonDir: hwmonDir})
}
return cards, nil
}
func isIntelGpu(cardPath string) bool {
vendor, err := utils.ReadStringFileLimited(filepath.Join(cardPath, "device/vendor"), 64)
if err != nil {
return false
}
return strings.EqualFold(strings.TrimSpace(vendor), "0x8086")
}
func findIntelEnergyHwmon(devicePath string) string {
hwmons, _ := filepath.Glob(filepath.Join(devicePath, "hwmon/hwmon*"))
var fallback string
for _, hwmonDir := range hwmons {
if !sysfsFileExists(filepath.Join(hwmonDir, "energy1_input")) {
continue
}
if name, err := utils.ReadStringFileLimited(filepath.Join(hwmonDir, "name"), 64); err == nil && strings.EqualFold(strings.TrimSpace(name), "xe") {
return hwmonDir
}
if fallback == "" {
fallback = hwmonDir
}
}
return fallback
}
func sysfsFileExists(path string) bool {
_, err := utils.ReadStringFileLimited(path, 1)
return err == nil
}
// updateIntelSysfsGpuData reads GPU metrics from sysfs and updates the GPU data map.
// Returns true if the required energy counter was read successfully.
func (gm *GPUManager) updateIntelSysfsGpuData(cardPath, hwmonDir string) bool {
devicePath := filepath.Join(cardPath, "device")
id := filepath.Base(cardPath)
energy, err := readSysfsUint(filepath.Join(hwmonDir, "energy1_input"))
if err != nil {
return false
}
now := intelSysfsNow()
power, hasPower := gm.calculateIntelSysfsPower(id, energy, now)
powerPkg, hasPowerPkg := gm.readIntelSysfsPowerPkg(id, hwmonDir, now)
temp := readIntelSysfsTemperature(hwmonDir)
usage, usageErr := readOptionalSysfsFloat(filepath.Join(devicePath, "gpu_busy_percent"))
memUsed, memUsedErr := readFirstOptionalSysfsFloat(
filepath.Join(devicePath, "mem_info_vram_used"),
filepath.Join(devicePath, "mem_info_lmem_used"),
filepath.Join(devicePath, "mem_info_local_mem_used"),
)
memTotal, memTotalErr := readFirstOptionalSysfsFloat(
filepath.Join(devicePath, "mem_info_vram_total"),
filepath.Join(devicePath, "mem_info_lmem_total"),
filepath.Join(devicePath, "mem_info_local_mem_total"),
)
gm.Lock()
defer gm.Unlock()
gpu, ok := gm.GpuDataMap[id]
if !ok {
gpu = &system.GPUData{Name: getIntelSysfsGpuName(cardPath)}
gm.GpuDataMap[id] = gpu
}
if usageErr == nil {
gpu.Usage += usage
}
if memUsedErr == nil {
gpu.MemoryUsed = utils.BytesToMegabytes(memUsed)
}
if memTotalErr == nil {
gpu.MemoryTotal = utils.BytesToMegabytes(memTotal)
}
if temp > 0 {
gpu.Temperature = temp
}
if hasPower {
gpu.Power += power
slog.Debug("Computed Intel sysfs GPU power", "card", id, "watts", power)
}
if hasPowerPkg {
gpu.PowerPkg += powerPkg
}
gpu.Count++
return true
}
func (gm *GPUManager) calculateIntelSysfsPower(cardID string, microjoules uint64, timestamp time.Time) (float64, bool) {
if gm.intelSysfsEnergySnapshots == nil {
gm.intelSysfsEnergySnapshots = make(map[string]intelSysfsEnergySnapshot)
}
last, ok := gm.intelSysfsEnergySnapshots[cardID]
gm.intelSysfsEnergySnapshots[cardID] = intelSysfsEnergySnapshot{microjoules: microjoules, timestamp: timestamp}
if !ok {
return 0, false
}
if microjoules < last.microjoules {
slog.Debug("Intel sysfs energy counter reset", "card", cardID)
return 0, false
}
elapsed := timestamp.Sub(last.timestamp).Seconds()
if elapsed <= 0 {
return 0, false
}
delta := microjoules - last.microjoules
return float64(delta) / 1_000_000.0 / elapsed, true
}
func (gm *GPUManager) readIntelSysfsPowerPkg(cardID, hwmonDir string, timestamp time.Time) (float64, bool) {
energyPaths, _ := filepath.Glob(filepath.Join(hwmonDir, "energy*_input"))
for _, path := range energyPaths {
if filepath.Base(path) == "energy1_input" {
continue
}
energy, err := readSysfsUint(path)
if err != nil {
continue
}
return gm.calculateIntelSysfsPower(cardID+":"+filepath.Base(path), energy, timestamp)
}
return 0, false
}
func readIntelSysfsTemperature(hwmonDir string) float64 {
tempPaths, _ := filepath.Glob(filepath.Join(hwmonDir, "temp*_input"))
for _, path := range tempPaths {
temp, err := readSysfsFloat(path)
if err == nil && temp > 0 {
return temp / 1000.0
}
}
return 0
}
func readSysfsUint(path string) (uint64, error) {
val, err := utils.ReadStringFileLimited(path, 64)
if err != nil {
slog.Debug("Failed to read sysfs value", "path", path, "error", err)
return 0, err
}
return strconv.ParseUint(strings.TrimSpace(val), 10, 64)
}
func readOptionalSysfsFloat(path string) (float64, error) {
val, err := os.ReadFile(path)
if err != nil {
return 0, err
}
return strconv.ParseFloat(strings.TrimSpace(string(val)), 64)
}
func readFirstOptionalSysfsFloat(paths ...string) (float64, error) {
for _, path := range paths {
val, err := readOptionalSysfsFloat(path)
if err == nil {
return val, nil
}
}
return 0, fmt.Errorf("no sysfs values found")
}
func getIntelSysfsGpuName(cardPath string) string {
devicePath := filepath.Join(cardPath, "device")
if product, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "product_name"), 128); err == nil && strings.TrimSpace(product) != "" {
return strings.TrimSpace(product)
}
if name, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "name"), 128); err == nil && strings.TrimSpace(name) != "" {
return strings.TrimSpace(name)
}
return fmt.Sprintf("Intel GPU %s", filepath.Base(cardPath))
}

View File

@@ -0,0 +1,217 @@
//go:build linux
package agent
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupIntelSysfsTest(t *testing.T) (root, cardPath, hwmonPath string) {
t.Helper()
root = t.TempDir()
oldRoot := drmSysfsRoot
drmSysfsRoot = root
t.Cleanup(func() {
drmSysfsRoot = oldRoot
})
cardPath = filepath.Join(root, "card0")
devicePath := filepath.Join(cardPath, "device")
hwmonPath = filepath.Join(devicePath, "hwmon", "hwmon0")
require.NoError(t, os.MkdirAll(hwmonPath, 0o755))
return root, cardPath, hwmonPath
}
func writeIntelSysfsFile(t *testing.T, basePath, name, content string) {
t.Helper()
require.NoError(t, os.WriteFile(filepath.Join(basePath, name), []byte(content), 0o644))
}
func setIntelSysfsTime(t *testing.T, now time.Time) {
t.Helper()
oldNow := intelSysfsNow
intelSysfsNow = func() time.Time { return now }
t.Cleanup(func() {
intelSysfsNow = oldNow
})
}
func TestIntelSysfsDetectsIntelCardWithEnergy(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
gm := &GPUManager{}
assert.True(t, gm.hasIntelSysfs())
cards, err := discoverIntelSysfsCards()
require.NoError(t, err)
require.Len(t, cards, 1)
assert.Equal(t, cardPath, cards[0].cardPath)
assert.Equal(t, hwmonPath, cards[0].hwmonDir)
}
func TestIntelSysfsRejectsNonIntelCard(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x1002\n")
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
gm := &GPUManager{}
assert.False(t, gm.hasIntelSysfs())
}
func TestIntelSysfsRequiresEnergyInput(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
gm := &GPUManager{}
assert.False(t, gm.hasIntelSysfs())
}
func TestIntelSysfsFirstSampleInitializesWithoutBogusPower(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
setIntelSysfsTime(t, time.Unix(100, 0))
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
ok := gm.updateIntelSysfsGpuData(cardPath, hwmonPath)
require.True(t, ok)
gpu := gm.GpuDataMap["card0"]
require.NotNil(t, gpu)
assert.Equal(t, "Intel GPU card0", gpu.Name)
assert.Equal(t, 0.0, gpu.Power)
assert.Equal(t, 1.0, gpu.Count)
}
func TestIntelSysfsSecondSampleComputesWatts(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
oldNow := intelSysfsNow
intelSysfsNow = func() time.Time { return time.Unix(100, 0) }
t.Cleanup(func() { intelSysfsNow = oldNow })
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "6000000\n")
intelSysfsNow = func() time.Time { return time.Unix(102, 0) }
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
gpu := gm.GpuDataMap["card0"]
require.NotNil(t, gpu)
assert.Equal(t, 2.5, gpu.Power)
assert.Equal(t, 2.0, gpu.Count)
}
func TestIntelSysfsSecondEnergyCounterMapsToPowerPkg(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
writeIntelSysfsFile(t, hwmonPath, "energy2_input", "2000000\n")
oldNow := intelSysfsNow
t.Cleanup(func() { intelSysfsNow = oldNow })
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
intelSysfsNow = func() time.Time { return time.Unix(100, 0) }
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "2000000\n")
writeIntelSysfsFile(t, hwmonPath, "energy2_input", "8000000\n")
intelSysfsNow = func() time.Time { return time.Unix(102, 0) }
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
gpu := gm.GpuDataMap["card0"]
require.NotNil(t, gpu)
assert.Equal(t, 0.5, gpu.Power)
assert.Equal(t, 3.0, gpu.PowerPkg)
}
func TestIntelSysfsCounterResetSkipsOneSample(t *testing.T) {
gm := &GPUManager{}
power, ok := gm.calculateIntelSysfsPower("card0", 5000000, time.Unix(100, 0))
assert.False(t, ok)
assert.Equal(t, 0.0, power)
power, ok = gm.calculateIntelSysfsPower("card0", 1000000, time.Unix(101, 0))
assert.False(t, ok)
assert.Equal(t, 0.0, power)
power, ok = gm.calculateIntelSysfsPower("card0", 3000000, time.Unix(103, 0))
assert.True(t, ok)
assert.Equal(t, 1.0, power)
}
func TestIntelSysfsTempInputMapsToCelsius(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
writeIntelSysfsFile(t, hwmonPath, "temp1_input", "43500\n")
setIntelSysfsTime(t, time.Unix(100, 0))
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
gpu := gm.GpuDataMap["card0"]
require.NotNil(t, gpu)
assert.Equal(t, 43.5, gpu.Temperature)
}
func TestIntelSysfsMissingOptionalFilesDoNotFail(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
setIntelSysfsTime(t, time.Unix(100, 0))
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
gpu := gm.GpuDataMap["card0"]
require.NotNil(t, gpu)
assert.Equal(t, 0.0, gpu.Usage)
assert.Equal(t, 0.0, gpu.MemoryUsed)
assert.Equal(t, 0.0, gpu.MemoryTotal)
assert.Equal(t, 0.0, gpu.Temperature)
}
func TestIntelSysfsMapsOpportunisticMemoryAndUsage(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, devicePath, "gpu_busy_percent", "37\n")
writeIntelSysfsFile(t, devicePath, "mem_info_lmem_used", "1073741824\n")
writeIntelSysfsFile(t, devicePath, "mem_info_lmem_total", "2147483648\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
setIntelSysfsTime(t, time.Unix(100, 0))
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
gpu := gm.GpuDataMap["card0"]
require.NotNil(t, gpu)
assert.Equal(t, 37.0, gpu.Usage)
assert.Equal(t, utils.BytesToMegabytes(1073741824), gpu.MemoryUsed)
assert.Equal(t, utils.BytesToMegabytes(2147483648), gpu.MemoryTotal)
}

View File

@@ -0,0 +1,13 @@
//go:build !linux
package agent
type intelSysfsEnergySnapshot struct{}
func (gm *GPUManager) hasIntelSysfs() bool {
return false
}
func (gm *GPUManager) startIntelSysfsCollector() bool {
return false
}

View File

@@ -5,6 +5,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"os/exec" "os/exec"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -48,9 +49,14 @@ func (gm *GPUManager) updateNvtopSnapshots(snapshots []nvtopSnapshot) bool {
valid := false valid := false
usedIDs := make(map[string]struct{}, len(snapshots)) usedIDs := make(map[string]struct{}, len(snapshots))
var xeName string
for i, sample := range snapshots { for i, sample := range snapshots {
// nvtop leaves device_name unset on xe devices.
if sample.DeviceName == "" { if sample.DeviceName == "" {
continue if xeName == "" {
xeName = xeGpuName()
}
sample.DeviceName = xeName
} }
indexID := "n" + strconv.Itoa(i) indexID := "n" + strconv.Itoa(i)
id := indexID id := indexID
@@ -158,3 +164,38 @@ func (gm *GPUManager) startNvtopCollector(interval string, onFailure func()) {
} }
}() }()
} }
// xeDevicePath returns the sysfs device path of the first xe GPU, or "".
func xeDevicePath() string {
cards, err := filepath.Glob("/sys/class/drm/card*")
if err != nil {
return ""
}
for _, card := range cards {
if strings.Contains(filepath.Base(card), "-") {
continue
}
if uevent, err := utils.ReadStringFileLimited(filepath.Join(card, "device", "uevent"), 4096); err == nil && strings.Contains(uevent, "DRIVER=xe") {
return filepath.Join(card, "device")
}
}
return ""
}
func (gm *GPUManager) hasXe() bool {
return xeDevicePath() != ""
}
// xeGpuName names an xe GPU from its PCI device id; nvtop leaves device_name unset on xe.
func xeGpuName() string {
devicePath := xeDevicePath()
if devicePath == "" {
return "GPU"
}
id, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "device"), 64)
if err != nil {
return "GPU"
}
id = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(id, "0x")))
return "Intel GPU (" + id + ")"
}

View File

@@ -332,11 +332,12 @@ func TestUpdateNvtopSnapshotsKeepsDeviceAssociationWhenOrderChanges(t *testing.T
} }
func TestParseCollectorPriority(t *testing.T) { func TestParseCollectorPriority(t *testing.T) {
got := parseCollectorPriority(" nvml, nvidia-smi, intel_gpu_top, amd_sysfs, nvtop, rocm-smi, bad ") got := parseCollectorPriority(" nvml, nvidia-smi, intel_gpu_top, intel_sysfs, amd_sysfs, nvtop, rocm-smi, bad ")
want := []collectorSource{ want := []collectorSource{
collectorSourceNVML, collectorSourceNVML,
collectorSourceNvidiaSMI, collectorSourceNvidiaSMI,
collectorSourceIntelGpuTop, collectorSourceIntelGpuTop,
collectorSourceIntelSysfs,
collectorSourceAmdSysfs, collectorSourceAmdSysfs,
collectorSourceNVTop, collectorSourceNVTop,
collectorSourceRocmSMI, collectorSourceRocmSMI,

View File

@@ -166,14 +166,16 @@ type GetSmartDataHandler struct{}
func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error { func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.smartManager == nil { if hctx.Agent.smartManager == nil {
// return empty map to indicate no data return hctx.SendResponse(smart.SmartDataResponse{Data: map[string]smart.SmartData{}}, hctx.RequestID)
return hctx.SendResponse(map[string]smart.SmartData{}, hctx.RequestID)
} }
if err := hctx.Agent.smartManager.Refresh(false); err != nil { complete, err := hctx.Agent.smartManager.Refresh(false)
if err != nil {
slog.Debug("smart refresh failed", "err", err) slog.Debug("smart refresh failed", "err", err)
} }
data := hctx.Agent.smartManager.GetCurrentData() return hctx.SendResponse(smart.SmartDataResponse{
return hctx.SendResponse(data, hctx.RequestID) Data: hctx.Agent.smartManager.GetCurrentData(),
Complete: complete,
}, hctx.RequestID)
} }
//////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////

View File

@@ -7,6 +7,7 @@ import (
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -17,6 +18,18 @@ type MockHandler struct {
handleFunc func(ctx *HandlerContext) error handleFunc func(ctx *HandlerContext) error
} }
func TestNewAgentResponseSmartData(t *testing.T) {
response := newAgentResponse(smart.SmartDataResponse{
Data: map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA"},
},
Complete: true,
}, nil)
assert.Equal(t, "AAA", response.SmartData["AAA"].SerialNumber)
assert.True(t, response.SmartComplete)
}
func (m *MockHandler) Handle(ctx *HandlerContext) error { func (m *MockHandler) Handle(ctx *HandlerContext) error {
if m.handleFunc != nil { if m.handleFunc != nil {
return m.handleFunc(ctx) return m.handleFunc(ctx)

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

@@ -180,8 +180,13 @@ func mdraidSmartStatus(health mdraidHealth) string {
if health.degraded > 0 { if health.degraded > 0 {
return "FAILED" return "FAILED"
} }
if health.mismatchCnt > 0 {
return "WARNING"
}
// "check" scans for consistency problems without repairing mismatches.
// With no mismatches, keep it green while reporting progress attributes.
switch syncAction { switch syncAction {
case "check", "repair": case "repair":
return "WARNING" return "WARNING"
} }
switch state { switch state {

View File

@@ -94,6 +94,18 @@ func TestMdraidSmartStatus(t *testing.T) {
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" { if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got) t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got)
} }
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "check"}); got != "PASSED" {
t.Fatalf("mdraidSmartStatus(clean+check) = %q, want PASSED", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "check", mismatchCnt: 1}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(clean+check+mismatch) = %q, want WARNING", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", mismatchCnt: 1}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(clean+mismatch) = %q, want WARNING", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "repair"}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(repair) = %q, want WARNING", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" { if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got) t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
} }

View File

@@ -21,6 +21,9 @@ func newAgentResponse(data any, requestID *uint32) common.AgentResponse {
response.String = &v response.String = &v
case map[string]smart.SmartData: case map[string]smart.SmartData:
response.SmartData = v response.SmartData = v
case smart.SmartDataResponse:
response.SmartData = v.Data
response.SmartComplete = v.Complete
case systemd.ServiceDetails: case systemd.ServiceDetails:
response.ServiceInfo = v response.ServiceInfo = v
default: default:

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

@@ -1,4 +1,4 @@
//go:build !windows //go:build !windows && !freebsd
package agent package agent

14
agent/sensors_freebsd.go Normal file
View File

@@ -0,0 +1,14 @@
//go:build freebsd
package agent
import (
"context"
"github.com/shirou/gopsutil/v4/sensors"
"golang.org/x/sys/unix"
)
var getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
return getFreeBSDSensorTemps(ctx, unix.SysctlUint32)
}

View File

@@ -0,0 +1,81 @@
//go:build freebsd || testing
package agent
import (
"context"
"fmt"
"github.com/shirou/gopsutil/v4/sensors"
)
const (
freebsdZeroCelsiusDeciKelvin = 2731
freebsdAcpiThermalZoneCount = 16
)
type freebsdSysctlUintReader func(name string) (uint32, error)
func getFreeBSDSensorTemps(ctx context.Context, readSysctl freebsdSysctlUintReader) ([]sensors.TemperatureStat, error) {
cpuCount, err := readSysctl("hw.ncpu")
if err != nil {
return nil, err
}
temps := make([]sensors.TemperatureStat, 0, int(cpuCount)+freebsdAcpiThermalZoneCount)
for cpu := range cpuCount {
select {
case <-ctx.Done():
return temps, ctx.Err()
default:
}
sysctlName := fmt.Sprintf("dev.cpu.%d.temperature", cpu)
value, err := readSysctl(sysctlName)
if err != nil {
continue
}
temp, ok := freebsdDeciKelvinToCelsius(value)
if !ok {
continue
}
temps = append(temps, sensors.TemperatureStat{
SensorKey: fmt.Sprintf("cpu.%d", cpu),
Temperature: temp,
})
}
for zone := 0; zone < freebsdAcpiThermalZoneCount; zone++ {
select {
case <-ctx.Done():
return temps, ctx.Err()
default:
}
sysctlName := fmt.Sprintf("hw.acpi.thermal.tz%d.temperature", zone)
value, err := readSysctl(sysctlName)
if err != nil {
continue
}
temp, ok := freebsdDeciKelvinToCelsius(value)
if !ok {
continue
}
temps = append(temps, sensors.TemperatureStat{
SensorKey: fmt.Sprintf("acpi.thermal.tz%d", zone),
Temperature: temp,
})
}
return temps, nil
}
func freebsdDeciKelvinToCelsius(value uint32) (float64, bool) {
if value <= freebsdZeroCelsiusDeciKelvin {
return 0, false
}
temp := float64(int64(value)-freebsdZeroCelsiusDeciKelvin) / 10
if temp <= 0 || temp >= 200 {
return 0, false
}
return temp, true
}

View File

@@ -0,0 +1,167 @@
//go:build testing
package agent
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var errFakeFreeBSDSysctlNotFound = errors.New("sysctl not found")
type fakeFreeBSDSysctls struct {
values map[string]uint32
errs map[string]error
}
func (f fakeFreeBSDSysctls) read(name string) (uint32, error) {
if err, ok := f.errs[name]; ok {
return 0, err
}
if value, ok := f.values[name]; ok {
return value, nil
}
return 0, errFakeFreeBSDSysctlNotFound
}
func TestFreeBSDDeciKelvinToCelsius(t *testing.T) {
tests := []struct {
name string
value uint32
expected float64
ok bool
}{
{
name: "45 Celsius",
value: 3181,
expected: 45,
ok: true,
},
{
name: "fractional Celsius",
value: 3186,
expected: 45.5,
ok: true,
},
{
name: "zero deci-Kelvin",
value: 0,
ok: false,
},
{
name: "zero Celsius",
value: freebsdZeroCelsiusDeciKelvin,
ok: false,
},
{
name: "below zero Celsius",
value: freebsdZeroCelsiusDeciKelvin - 1,
ok: false,
},
{
name: "invalid signed integer",
value: 1<<32 - 1,
ok: false,
},
{
name: "unreasonably high Celsius",
value: freebsdZeroCelsiusDeciKelvin + 2000,
ok: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, ok := freebsdDeciKelvinToCelsius(tt.value)
assert.Equal(t, tt.ok, ok)
assert.InDelta(t, tt.expected, result, 0.001)
})
}
}
func TestGetFreeBSDSensorTemps(t *testing.T) {
reader := fakeFreeBSDSysctls{
values: map[string]uint32{
"hw.ncpu": 4,
"dev.cpu.0.temperature": 3231,
"dev.cpu.1.temperature": 3242,
"dev.cpu.3.temperature": freebsdZeroCelsiusDeciKelvin,
"hw.acpi.thermal.tz0.temperature": 3101,
"hw.acpi.thermal.tz2.temperature": 3116,
"hw.acpi.thermal.tz3.temperature": freebsdZeroCelsiusDeciKelvin,
"unrelated.sensor.value": 9999,
"dev.cpu.99.temperature": 9999,
"dev.amdtemp.0.core0.foo": 9999,
},
}
temps, err := getFreeBSDSensorTemps(context.Background(), reader.read)
require.NoError(t, err)
require.Len(t, temps, 4)
assert.Equal(t, "cpu.0", temps[0].SensorKey)
assert.InDelta(t, 50.0, temps[0].Temperature, 0.001)
assert.Equal(t, "cpu.1", temps[1].SensorKey)
assert.InDelta(t, 51.1, temps[1].Temperature, 0.001)
assert.Equal(t, "acpi.thermal.tz0", temps[2].SensorKey)
assert.InDelta(t, 37.0, temps[2].Temperature, 0.001)
assert.Equal(t, "acpi.thermal.tz2", temps[3].SensorKey)
assert.InDelta(t, 38.5, temps[3].Temperature, 0.001)
}
func TestGetFreeBSDSensorTempsCpuCountError(t *testing.T) {
reader := fakeFreeBSDSysctls{
errs: map[string]error{
"hw.ncpu": errors.New("permission denied"),
},
}
temps, err := getFreeBSDSensorTemps(context.Background(), reader.read)
assert.Nil(t, temps)
assert.EqualError(t, err, "permission denied")
}
func TestGetFreeBSDSensorTempsNoTemperatureSysctls(t *testing.T) {
reader := fakeFreeBSDSysctls{
values: map[string]uint32{"hw.ncpu": 2},
}
temps, err := getFreeBSDSensorTemps(context.Background(), reader.read)
require.NoError(t, err)
assert.Empty(t, temps)
}
func TestGetFreeBSDSensorTempsAcpiOnly(t *testing.T) {
reader := fakeFreeBSDSysctls{
values: map[string]uint32{
"hw.ncpu": 0,
"hw.acpi.thermal.tz0.temperature": 3081,
},
}
temps, err := getFreeBSDSensorTemps(context.Background(), reader.read)
require.NoError(t, err)
require.Len(t, temps, 1)
assert.Equal(t, "acpi.thermal.tz0", temps[0].SensorKey)
assert.InDelta(t, 35.0, temps[0].Temperature, 0.001)
}
func TestGetFreeBSDSensorTempsContextCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
reader := fakeFreeBSDSysctls{
values: map[string]uint32{"hw.ncpu": 2},
}
temps, err := getFreeBSDSensorTemps(ctx, reader.read)
assert.Empty(t, temps)
assert.ErrorIs(t, err, context.Canceled)
}

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

@@ -29,9 +29,6 @@ type ServerOptions struct {
Keys []gossh.PublicKey // SSH public keys for authentication Keys []gossh.PublicKey // SSH public keys for authentication
} }
// hubVersions caches hub versions by session ID to avoid repeated parsing.
var hubVersions map[string]semver.Version
// StartServer starts the SSH server with the provided options. // StartServer starts the SSH server with the provided options.
// It configures the server with secure defaults, sets up authentication, // It configures the server with secure defaults, sets up authentication,
// and begins listening for connections. Returns an error if the server // and begins listening for connections. Returns an error if the server
@@ -99,24 +96,15 @@ func (a *Agent) StartServer(opts ServerOptions) error {
return a.server.Serve(ln) return a.server.Serve(ln)
} }
// getHubVersion retrieves and caches the hub version for a given session. // getHubVersion extracts the hub version from the SSH client version string
// It extracts the version from the SSH client version string and caches // for a given session. Returns a zero version if parsing fails.
// it to avoid repeated parsing. Returns a zero version if parsing fails. func (a *Agent) getHubVersion(sessionCtx ssh.Context) semver.Version {
func (a *Agent) getHubVersion(sessionId string, sessionCtx ssh.Context) semver.Version {
if hubVersions == nil {
hubVersions = make(map[string]semver.Version, 1)
}
hubVersion, ok := hubVersions[sessionId]
if ok {
return hubVersion
}
// Extract hub version from SSH client version
clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion) clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion)
if versionStr, ok := clientVersion.(string); ok { if versionStr, ok := clientVersion.(string); ok {
hubVersion, _ = extractHubVersion(versionStr) hubVersion, _ := extractHubVersion(versionStr)
return hubVersion
} }
hubVersions[sessionId] = hubVersion return semver.Version{}
return hubVersion
} }
// handleSession handles an incoming SSH session by gathering system statistics // handleSession handles an incoming SSH session by gathering system statistics
@@ -127,9 +115,8 @@ func (a *Agent) handleSession(s ssh.Session) {
a.connectionManager.eventChan <- SSHConnect a.connectionManager.eventChan <- SSHConnect
sessionCtx := s.Context() sessionCtx := s.Context()
sessionID := sessionCtx.SessionID()
hubVersion := a.getHubVersion(sessionID, sessionCtx) hubVersion := a.getHubVersion(sessionCtx)
// Legacy one-shot behavior for older hubs // Legacy one-shot behavior for older hubs
if hubVersion.LT(beszel.MinVersionAgentResponse) { if hubVersion.LT(beszel.MinVersionAgentResponse) {

View File

@@ -404,27 +404,23 @@ func TestGetHubVersion(t *testing.T) {
clientVersion: "SSH-2.0-beszel_0.12.0", clientVersion: "SSH-2.0-beszel_0.12.0",
} }
// Test first call - should extract and cache version // Test first call - should extract version
version := agent.getHubVersion("test-session-123", mockCtx) version := agent.getHubVersion(mockCtx)
assert.Equal(t, "0.12.0", version.String()) assert.Equal(t, "0.12.0", version.String())
// Test second call - should return cached version // Test that version reflects the current client version (no stale caching)
mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0" // Change version but should still return cached mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0"
version = agent.getHubVersion("test-session-123", mockCtx) version = agent.getHubVersion(mockCtx)
assert.Equal(t, "0.12.0", version.String()) // Should still be cached version
// Test different session - should extract new version
version = agent.getHubVersion("different-session", mockCtx)
assert.Equal(t, "0.11.0", version.String()) assert.Equal(t, "0.11.0", version.String())
// Test with invalid version string (non-beszel client) // Test with invalid version string (non-beszel client)
mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0" mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0"
version = agent.getHubVersion("invalid-session", mockCtx) version = agent.getHubVersion(mockCtx)
assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients
// Test with no client version // Test with no client version
mockCtx.clientVersion = "" mockCtx.clientVersion = ""
version = agent.getHubVersion("no-version-session", mockCtx) version = agent.getHubVersion(mockCtx)
assert.True(t, version.EQ(semver.Version{})) // Should be empty version assert.True(t, version.EQ(semver.Version{})) // Should be empty version
} }
@@ -501,9 +497,6 @@ func TestWriteToSessionEncoding(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) {
// Reset the global hubVersions map to ensure clean state for each test
hubVersions = nil
agent, err := NewAgent("") agent, err := NewAgent("")
require.NoError(t, err) require.NoError(t, err)
@@ -585,39 +578,28 @@ func createTestCombinedData() *system.CombinedData {
} }
} }
func TestHubVersionCaching(t *testing.T) { // TestGetHubVersionConcurrent guards against a regression of the
// Reset the global hubVersions map to ensure clean state // "concurrent map writes" panic previously caused by a shared, unsynchronized
hubVersions = nil // hubVersions cache (see https://github.com/henrygd/beszel/issues/2128).
// getHubVersion no longer shares mutable state between sessions, so calling
// it concurrently from many goroutines must be safe under `go test -race`.
func TestGetHubVersionConcurrent(t *testing.T) {
agent, err := NewAgent("") agent, err := NewAgent("")
require.NoError(t, err) require.NoError(t, err)
ctx1 := &mockSSHContext{ const goroutines = 50
sessionID: "session1", var wg sync.WaitGroup
clientVersion: "SSH-2.0-beszel_0.12.0", wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func(i int) {
defer wg.Done()
ctx := &mockSSHContext{
sessionID: fmt.Sprintf("session-%d", i),
clientVersion: "SSH-2.0-beszel_0.12.0",
}
version := agent.getHubVersion(ctx)
assert.Equal(t, "0.12.0", version.String())
}(i)
} }
ctx2 := &mockSSHContext{ wg.Wait()
sessionID: "session2",
clientVersion: "SSH-2.0-beszel_0.11.0",
}
// First calls should cache the versions
v1 := agent.getHubVersion("session1", ctx1)
v2 := agent.getHubVersion("session2", ctx2)
assert.Equal(t, "0.12.0", v1.String())
assert.Equal(t, "0.11.0", v2.String())
// Verify caching by changing context but keeping same session ID
ctx1.clientVersion = "SSH-2.0-beszel_0.10.0"
v1Cached := agent.getHubVersion("session1", ctx1)
assert.Equal(t, "0.12.0", v1Cached.String()) // Should still be cached version
// New session should get new version
ctx3 := &mockSSHContext{
sessionID: "session3",
clientVersion: "SSH-2.0-beszel_0.13.0",
}
v3 := agent.getHubVersion("session3", ctx3)
assert.Equal(t, "0.13.0", v3.String())
} }

View File

@@ -25,12 +25,15 @@ import (
// SmartManager manages data collection for SMART devices // SmartManager manages data collection for SMART devices
type SmartManager struct { type SmartManager struct {
sync.Mutex sync.Mutex
SmartDataMap map[string]*smart.SmartData SmartDataMap map[string]*smart.SmartData
SmartDevices []*DeviceInfo SmartDevices []*DeviceInfo
refreshMutex sync.Mutex refreshMutex sync.Mutex
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 {
@@ -52,6 +55,11 @@ type DeviceInfo struct {
typeVerified bool typeVerified bool
// parserType holds the parser type (nvme, sat, scsi) that last succeeded. // parserType holds the parser type (nvme, sat, scsi) that last succeeded.
parserType string parserType string
// explicitType reports whether Type came from an explicit ":type" hint in
// SMART_DEVICES. Such a type is a deliberate user override and must always be
// passed to smartctl via -d, even for scsi/ata where a scan-detected type is
// otherwise left off (see smartctlArgs and issue #1345).
explicitType bool
} }
// deviceKey is a composite key for a device, used to identify a device uniquely. // deviceKey is a composite key for a device, used to identify a device uniquely.
@@ -62,8 +70,9 @@ type deviceKey struct {
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
// Refresh updates SMART data for all known devices // Refresh updates SMART data for all known devices and reports whether every
func (sm *SmartManager) Refresh(forceScan bool) error { // discovered device was collected successfully.
func (sm *SmartManager) Refresh(forceScan bool) (bool, error) {
sm.refreshMutex.Lock() sm.refreshMutex.Lock()
defer sm.refreshMutex.Unlock() defer sm.refreshMutex.Unlock()
@@ -84,7 +93,7 @@ func (sm *SmartManager) Refresh(forceScan bool) error {
} }
} }
return sm.resolveRefreshError(scanErr, collectErr) return scanErr == nil && collectErr == nil, sm.resolveRefreshError(scanErr, collectErr)
} }
// devicesSnapshot returns a copy of the current device slice to avoid iterating // devicesSnapshot returns a copy of the current device slice to avoid iterating
@@ -248,8 +257,9 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
} }
devices = append(devices, &DeviceInfo{ devices = append(devices, &DeviceInfo{
Name: name, Name: name,
Type: devType, Type: devType,
explicitType: devType != "",
}) })
} }
@@ -365,9 +375,15 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
Type string Type string
Parse func([]byte) (bool, int) Parse func([]byte) (bool, int)
}{ }{
{Type: "nvme", Parse: sm.parseSmartForNvme}, {Type: "nvme", Parse: func(output []byte) (bool, int) {
{Type: "sat", Parse: sm.parseSmartForSata}, return sm.parseSmartForNvme(output, deviceInfo.Type)
{Type: "scsi", Parse: sm.parseSmartForScsi}, }},
{Type: "sat", Parse: func(output []byte) (bool, int) {
return sm.parseSmartForSata(output, deviceInfo.Type)
}},
{Type: "scsi", Parse: func(output []byte) (bool, int) {
return sm.parseSmartForScsi(output, deviceInfo.Type)
}},
} }
deviceType := normalizeParserType(deviceInfo.parserType) deviceType := normalizeParserType(deviceInfo.parserType)
@@ -476,10 +492,11 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
return errNoValidSmartData return errNoValidSmartData
} }
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name)) // slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo))
// Check if we have any existing data for this device // Check if we have existing data for this exact device identity. Multiple
hasExistingData := sm.hasDataForDevice(deviceInfo.Name) // bridge slots can share a path, so a name-only match is not sufficient.
hasExistingData := sm.hasDataForDevice(deviceInfo)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
@@ -555,7 +572,9 @@ func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool
deviceType = strings.ToLower(deviceInfo.Type) deviceType = strings.ToLower(deviceInfo.Type)
parserType = strings.ToLower(deviceInfo.parserType) parserType = strings.ToLower(deviceInfo.parserType)
// types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345 // types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345
if deviceType != "" && deviceType != "scsi" && deviceType != "ata" { // An explicit SMART_DEVICES ":type" hint is a deliberate override, so always
// pass it through; otherwise scsi/ata are left off so smartctl can auto-detect.
if deviceType != "" && (deviceInfo.explicitType || (deviceType != "scsi" && deviceType != "ata")) {
args = append(args, "-d", deviceInfo.Type) args = append(args, "-d", deviceInfo.Type)
} }
} }
@@ -580,14 +599,18 @@ func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool
return args return args
} }
// hasDataForDevice checks if we have cached SMART data for a specific device // hasDataForDevice checks if we have cached SMART data for a specific device identity.
func (sm *SmartManager) hasDataForDevice(deviceName string) bool { func (sm *SmartManager) hasDataForDevice(deviceInfo *DeviceInfo) bool {
if deviceInfo == nil {
return false
}
sm.Lock() sm.Lock()
defer sm.Unlock() defer sm.Unlock()
// Check if any cached data has this device name deviceKey := makeDeviceKey(deviceInfo.Name, deviceInfo.Type)
for _, data := range sm.SmartDataMap { for _, data := range sm.SmartDataMap {
if data != nil && data.DiskName == deviceName { if data != nil && makeDeviceKey(data.DiskName, data.DiskType) == deviceKey {
return true return true
} }
} }
@@ -660,6 +683,9 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
target.Type = prev.Type target.Type = prev.Type
target.typeVerified = true target.typeVerified = true
target.parserType = prev.parserType target.parserType = prev.parserType
if prev.explicitType {
target.explicitType = true
}
} }
// applyConfiguredMetadata updates a matched device with any configured // applyConfiguredMetadata updates a matched device with any configured
@@ -673,6 +699,9 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
existingDev.typeVerified = false existingDev.typeVerified = false
existingDev.parserType = normalizeParserType(newType) existingDev.parserType = normalizeParserType(newType)
} }
if configuredDev.explicitType {
existingDev.explicitType = true
}
if configuredDev.InfoName != "" { if configuredDev.InfoName != "" {
existingDev.InfoName = configuredDev.InfoName existingDev.InfoName = configuredDev.InfoName
} }
@@ -729,7 +758,14 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
continue continue
} }
if existingDev := deviceIndexByName[configuredDevice.Name]; existingDev != nil { if existingDev := deviceIndexByName[configuredDevice.Name]; existingDev != nil {
oldKey := makeDeviceKey(existingDev.Name, existingDev.Type)
if prev := existingIndex[key]; prev != nil {
preserveVerifiedType(existingDev, prev)
}
applyConfiguredMetadata(existingDev, configuredDevice) applyConfiguredMetadata(existingDev, configuredDevice)
delete(deviceIndex, oldKey)
deviceIndex[makeDeviceKey(existingDev.Name, existingDev.Type)] = existingDev
delete(deviceIndexByName, configuredDevice.Name)
continue continue
} }
@@ -833,9 +869,11 @@ func (sm *SmartManager) isVirtualDeviceFromStrings(fields ...string) bool {
return false return false
} }
// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap // parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap.
// deviceType is the exact type used to identify and query the device; when set,
// it takes precedence over the generic type reported by smartctl.
// Returns hasValidData and exitStatus // Returns hasValidData and exitStatus
func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) { func (sm *SmartManager) parseSmartForSata(output []byte, deviceType string) (bool, int) {
var data smart.SmartInfoForSata var data smart.SmartInfoForSata
if err := json.Unmarshal(output, &data); err != nil { if err := json.Unmarshal(output, &data); err != nil {
@@ -874,6 +912,9 @@ func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
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
smartData.DiskType = data.Device.Type smartData.DiskType = data.Device.Type
if deviceType != "" {
smartData.DiskType = deviceType
}
// get values from ata_device_statistics if necessary // get values from ata_device_statistics if necessary
var ataDeviceStats smart.AtaDeviceStatistics var ataDeviceStats smart.AtaDeviceStatistics
@@ -947,7 +988,7 @@ func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *
return nil return nil
} }
func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) { func (sm *SmartManager) parseSmartForScsi(output []byte, deviceType string) (bool, int) {
var data smart.SmartInfoForScsi var data smart.SmartInfoForScsi
if err := json.Unmarshal(output, &data); err != nil { if err := json.Unmarshal(output, &data); err != nil {
@@ -982,6 +1023,9 @@ func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) {
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
smartData.DiskType = data.Device.Type smartData.DiskType = data.Device.Type
if deviceType != "" {
smartData.DiskType = deviceType
}
attributes := make([]*smart.SmartAttribute, 0, 10) attributes := make([]*smart.SmartAttribute, 0, 10)
attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours}) attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours})
@@ -1033,9 +1077,57 @@ func parseScsiGigabytesProcessed(value string) int64 {
return parsed return parsed
} }
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap // 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.
// deviceType is the exact type used to identify and query the device; when set,
// it takes precedence over the generic type reported by smartctl.
// Returns hasValidData and exitStatus // Returns hasValidData and exitStatus
func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) { func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (bool, int) {
data := &smart.SmartInfoForNvme{} data := &smart.SmartInfoForNvme{}
if err := json.Unmarshal(output, &data); err != nil { if err := json.Unmarshal(output, &data); err != nil {
@@ -1069,10 +1161,19 @@ 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 {
smartData.Capacity = data.NVMeTotalCapacity
}
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
smartData.DiskType = data.Device.Type smartData.DiskType = data.Device.Type
if deviceType != "" {
smartData.DiskType = deviceType
}
// nvme attributes does not follow the same format as ata attributes, // nvme attributes does not follow the same format as ata attributes,
// so we manually map each field to SmartAttributes // so we manually map each field to SmartAttributes

View File

@@ -24,7 +24,7 @@ func TestParseSmartForScsi(t *testing.T) {
SmartDataMap: make(map[string]*smart.SmartData), SmartDataMap: make(map[string]*smart.SmartData),
} }
hasData, exitStatus := sm.parseSmartForScsi(data) hasData, exitStatus := sm.parseSmartForScsi(data, "")
if !hasData { if !hasData {
t.Fatalf("expected SCSI data to parse successfully") t.Fatalf("expected SCSI data to parse successfully")
} }
@@ -69,7 +69,7 @@ func TestParseSmartForSata(t *testing.T) {
SmartDataMap: make(map[string]*smart.SmartData), SmartDataMap: make(map[string]*smart.SmartData),
} }
hasData, exitStatus := sm.parseSmartForSata(data) hasData, exitStatus := sm.parseSmartForSata(data, "")
require.True(t, hasData) require.True(t, hasData)
assert.Equal(t, 64, exitStatus) assert.Equal(t, 64, exitStatus)
@@ -112,7 +112,7 @@ func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
}`) }`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)} sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload) hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
require.True(t, hasData) require.True(t, hasData)
assert.Equal(t, 0, exitStatus) assert.Equal(t, 0, exitStatus)
@@ -147,7 +147,7 @@ func TestParseSmartForSataAtaDeviceStatistics(t *testing.T) {
}`) }`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)} sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload) hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
require.True(t, hasData) require.True(t, hasData)
assert.Equal(t, 0, exitStatus) assert.Equal(t, 0, exitStatus)
@@ -184,7 +184,7 @@ func TestParseSmartForSataNegativeDeviceStatistics(t *testing.T) {
}`) }`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)} sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload) hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
require.True(t, hasData) require.True(t, hasData)
assert.Equal(t, 0, exitStatus) assert.Equal(t, 0, exitStatus)
@@ -223,7 +223,7 @@ func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)} sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload) hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
require.True(t, hasData) require.True(t, hasData)
assert.Equal(t, 0, exitStatus) assert.Equal(t, 0, exitStatus)
@@ -245,7 +245,7 @@ func TestParseSmartForNvme(t *testing.T) {
SmartDataMap: make(map[string]*smart.SmartData), SmartDataMap: make(map[string]*smart.SmartData),
} }
hasData, exitStatus := sm.parseSmartForNvme(data) hasData, exitStatus := sm.parseSmartForNvme(data, "")
require.True(t, hasData) require.True(t, hasData)
assert.Equal(t, 0, exitStatus) assert.Equal(t, 0, exitStatus)
@@ -268,13 +268,15 @@ func TestParseSmartForNvme(t *testing.T) {
func TestHasDataForDevice(t *testing.T) { func TestHasDataForDevice(t *testing.T) {
sm := &SmartManager{ sm := &SmartManager{
SmartDataMap: map[string]*smart.SmartData{ SmartDataMap: map[string]*smart.SmartData{
"serial-1": {DiskName: "/dev/sda"}, "serial-1": {DiskName: "/dev/sda", DiskType: "jms56x,0"},
"serial-2": nil, "serial-2": nil,
}, },
} }
assert.True(t, sm.hasDataForDevice("/dev/sda")) assert.True(t, sm.hasDataForDevice(&DeviceInfo{Name: "/dev/sda", Type: "jms56x,0"}))
assert.False(t, sm.hasDataForDevice("/dev/sdb")) assert.False(t, sm.hasDataForDevice(&DeviceInfo{Name: "/dev/sda", Type: "jms56x,1"}))
assert.False(t, sm.hasDataForDevice(&DeviceInfo{Name: "/dev/sdb", Type: "jms56x,0"}))
assert.False(t, sm.hasDataForDevice(nil))
} }
func TestDevicesSnapshotReturnsCopy(t *testing.T) { func TestDevicesSnapshotReturnsCopy(t *testing.T) {
@@ -392,6 +394,81 @@ func TestSmartctlArgs(t *testing.T) {
) )
} }
// TestSmartctlArgsExplicitType verifies that an explicit SMART_DEVICES type hint
// is always passed to smartctl via -d, while a scan-detected scsi/ata type is
// still left off so smartctl can auto-detect it (see issue #1345).
func TestSmartctlArgsExplicitType(t *testing.T) {
sm := &SmartManager{}
// Scan-detected scsi: -d is intentionally omitted.
scanScsi := &DeviceInfo{Name: "/dev/sda", Type: "scsi"}
assert.Equal(t,
[]string{"-a", "--json=c", "/dev/sda"},
sm.smartctlArgs(scanScsi, false),
)
// Explicit scsi from SMART_DEVICES: -d scsi must be passed.
explicitScsi := &DeviceInfo{Name: "/dev/sda", Type: "scsi", explicitType: true}
assert.Equal(t,
[]string{"-d", "scsi", "-a", "--json=c", "/dev/sda"},
sm.smartctlArgs(explicitScsi, false),
)
// Explicit ata from SMART_DEVICES: -d ata must be passed (devstat still added).
explicitAta := &DeviceInfo{Name: "/dev/sdb", Type: "ata", explicitType: true}
assert.Equal(t,
[]string{"-d", "ata", "-a", "--json=c", "-l", "devstat", "/dev/sdb"},
sm.smartctlArgs(explicitAta, false),
)
}
// TestSmartDevicesExplicitTypeFlowsToSmartctlArgs is a regression test for
// issue #2072: an explicit SMART_DEVICES type (e.g. /dev/sda:scsi) must win over
// a wrong scan-detected type (sat) and be handed to smartctl as -d scsi.
func TestSmartDevicesExplicitTypeFlowsToSmartctlArgs(t *testing.T) {
sm := &SmartManager{}
configured, err := sm.parseConfiguredDevices("/dev/sda:scsi")
require.NoError(t, err)
require.Len(t, configured, 1)
assert.True(t, configured[0].explicitType)
// smartctl --scan misreports this USB drive as sat, which fails on it.
scanned := []*DeviceInfo{
{Name: "/dev/sda", Type: "sat", Protocol: "ATA"},
}
merged := mergeDeviceLists(nil, scanned, configured)
require.Len(t, merged, 1)
device := merged[0]
assert.Equal(t, "scsi", device.Type, "configured type should win over scan-detected sat")
assert.True(t, device.explicitType, "explicit hint must survive the merge")
assert.Equal(t,
[]string{"-d", "scsi", "-a", "--json=c", "/dev/sda"},
sm.smartctlArgs(device, false),
"explicit scsi type must be passed to smartctl, not dropped",
)
}
// TestMergeDeviceListsPreservesExplicitTypeAcrossRescan ensures a verified,
// explicitly-typed device keeps its explicit flag when a later scan re-reports
// it with a different auto-detected type.
func TestMergeDeviceListsPreservesExplicitTypeAcrossRescan(t *testing.T) {
existing := []*DeviceInfo{
{Name: "/dev/sda", Type: "scsi", parserType: "scsi", typeVerified: true, explicitType: true},
}
scanned := []*DeviceInfo{
{Name: "/dev/sda", Type: "sat"},
}
merged := mergeDeviceLists(existing, scanned, nil)
require.Len(t, merged, 1)
assert.Equal(t, "scsi", merged[0].Type)
assert.True(t, merged[0].explicitType, "explicit type flag should survive a rescan")
}
func TestResolveRefreshError(t *testing.T) { func TestResolveRefreshError(t *testing.T) {
scanErr := errors.New("scan failed") scanErr := errors.New("scan failed")
collectErr := errors.New("collect failed") collectErr := errors.New("collect failed")
@@ -534,6 +611,74 @@ func TestMergeDeviceListsPrefersConfigured(t *testing.T) {
assert.Equal(t, "sat", byName["/dev/sdb"].Type) assert.Equal(t, "sat", byName["/dev/sdb"].Type)
} }
func TestMergeDeviceListsExpandsConfiguredDevicesWithSamePath(t *testing.T) {
scanned := []*DeviceInfo{
{Name: "/dev/sdb", Type: "sat", InfoName: "scan-info", Protocol: "ATA"},
}
configured := []*DeviceInfo{
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
{Name: "/dev/sdb", Type: "jms56x,1", explicitType: true},
}
merged := mergeDeviceLists(nil, scanned, configured)
require.Len(t, merged, 2)
byKey := make(map[deviceKey]*DeviceInfo, len(merged))
for _, device := range merged {
byKey[makeDeviceKey(device.Name, device.Type)] = device
}
first := byKey[makeDeviceKey("/dev/sdb", "jms56x,0")]
require.NotNil(t, first)
assert.Equal(t, "scan-info", first.InfoName)
assert.Equal(t, "ATA", first.Protocol)
assert.True(t, first.explicitType)
second := byKey[makeDeviceKey("/dev/sdb", "jms56x,1")]
require.NotNil(t, second)
assert.True(t, second.explicitType)
assert.NotContains(t, byKey, makeDeviceKey("/dev/sdb", "sat"))
}
func TestMergeDeviceListsPreservesSamePathVerificationAcrossRescan(t *testing.T) {
existing := []*DeviceInfo{
{Name: "/dev/sdb", Type: "jms56x,0", parserType: "sat", typeVerified: true, explicitType: true},
{Name: "/dev/sdb", Type: "jms56x,1", parserType: "sat", typeVerified: true, explicitType: true},
}
scanned := []*DeviceInfo{
{Name: "/dev/sdb", Type: "sat", Protocol: "ATA"},
}
configured := []*DeviceInfo{
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
{Name: "/dev/sdb", Type: "jms56x,1", explicitType: true},
}
merged := mergeDeviceLists(existing, scanned, configured)
require.Len(t, merged, 2)
byKey := make(map[deviceKey]*DeviceInfo, len(merged))
for _, device := range merged {
byKey[makeDeviceKey(device.Name, device.Type)] = device
assert.True(t, device.typeVerified, device.Type)
assert.Equal(t, "sat", device.parserType, device.Type)
assert.True(t, device.explicitType, device.Type)
}
assert.Contains(t, byKey, makeDeviceKey("/dev/sdb", "jms56x,0"))
assert.Contains(t, byKey, makeDeviceKey("/dev/sdb", "jms56x,1"))
}
func TestMergeDeviceListsDeduplicatesConfiguredIdentityAfterRekey(t *testing.T) {
scanned := []*DeviceInfo{{Name: "/dev/sdb", Type: "sat"}}
configured := []*DeviceInfo{
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
}
merged := mergeDeviceLists(nil, scanned, configured)
require.Len(t, merged, 1)
assert.Equal(t, "/dev/sdb", merged[0].Name)
assert.Equal(t, "jms56x,0", merged[0].Type)
}
func TestMergeDeviceListsPreservesVerification(t *testing.T) { func TestMergeDeviceListsPreservesVerification(t *testing.T) {
existing := []*DeviceInfo{ existing := []*DeviceInfo{
{Name: "/dev/sda", Type: "sat+megaraid", parserType: "sat", typeVerified: true}, {Name: "/dev/sda", Type: "sat+megaraid", parserType: "sat", typeVerified: true},
@@ -678,6 +823,20 @@ func TestParseSmartOutputKeepsCustomType(t *testing.T) {
assert.Equal(t, "sat+megaraid", device.Type) assert.Equal(t, "sat+megaraid", device.Type)
assert.Equal(t, "sat", device.parserType) assert.Equal(t, "sat", device.parserType)
assert.True(t, device.typeVerified) assert.True(t, device.typeVerified)
assert.Equal(t, "sat+megaraid", sm.SmartDataMap["9C40918040082"].DiskType)
}
func TestParseSmartOutputDoesNotNormalizeDeviceIdentity(t *testing.T) {
fixturePath := filepath.Join("test-data", "smart", "sda.json")
data, err := os.ReadFile(fixturePath)
require.NoError(t, err)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
device := &DeviceInfo{Name: "/dev/sda", Type: "ata", explicitType: true}
require.True(t, sm.parseSmartOutput(device, data))
assert.Equal(t, "sat", device.parserType)
assert.Equal(t, "ata", sm.SmartDataMap["9C40918040082"].DiskType)
} }
func TestParseSmartOutputResetsVerificationOnFailure(t *testing.T) { func TestParseSmartOutputResetsVerificationOnFailure(t *testing.T) {
@@ -1199,3 +1358,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

@@ -8,7 +8,6 @@ import (
"os" "os"
"runtime" "runtime"
"strings" "strings"
"time"
"github.com/henrygd/beszel" "github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/battery" "github.com/henrygd/beszel/agent/battery"
@@ -23,13 +22,6 @@ import (
"github.com/shirou/gopsutil/v4/mem" "github.com/shirou/gopsutil/v4/mem"
) )
// prevDisk stores previous per-device disk counters for a given cache interval
type prevDisk struct {
readBytes uint64
writeBytes uint64
at time.Time
}
// Sets initial / non-changing values about the host system // Sets initial / non-changing values about the host system
func (a *Agent) refreshSystemDetails() { func (a *Agent) refreshSystemDetails() {
a.systemInfo.AgentVersion = beszel.Version a.systemInfo.AgentVersion = beszel.Version
@@ -140,9 +132,14 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
var systemStats system.Stats var systemStats system.Stats
// battery // battery
if batteryPercent, batteryState, err := battery.GetBatteryStats(); err == nil { if batteries, err := battery.GetBatteryStats(); err == nil {
systemStats.Battery[0] = batteryPercent systemStats.Batteries = make(map[string]uint8, len(batteries))
systemStats.Battery[1] = batteryState for _, device := range batteries {
systemStats.Batteries[device.Name] = device.Percent
}
if primary, ok := battery.Primary(batteries); ok {
systemStats.Battery = [2]uint8{primary.Percent, primary.State}
}
} }
// cpu metrics // cpu metrics
@@ -177,21 +174,11 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// memory // memory
if v, err := mem.VirtualMemory(); err == nil { if v, err := mem.VirtualMemory(); err == nil {
used, cacheBuff, swapUsed := calculateHostMemoryUsage(v, a.memCalc == "htop")
// swap // swap
systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal) systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal)
systemStats.SwapUsed = utils.BytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached) systemStats.SwapUsed = utils.BytesToGigabytes(swapUsed)
// cache + buffers value for default mem calculation v.Used = used
// note: gopsutil automatically adds SReclaimable to v.Cached
cacheBuff := v.Cached + v.Buffers - v.Shared
if cacheBuff <= 0 {
cacheBuff = max(v.Total-v.Free-v.Used, 0)
}
// htop memory calculation overrides (likely outdated as of mid 2025)
if a.memCalc == "htop" {
// cacheBuff = v.Cached + v.Buffers - v.Shared
v.Used = v.Total - (v.Free + cacheBuff)
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
}
// if a.memCalc == "legacy" { // if a.memCalc == "legacy" {
// v.Used = v.Total - v.Free - v.Buffers - v.Cached // v.Used = v.Total - v.Free - v.Buffers - v.Cached
// cacheBuff = v.Total - v.Free - v.Used // cacheBuff = v.Total - v.Free - v.Used
@@ -201,10 +188,14 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
if a.zfs { if a.zfs {
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used { if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used {
v.Used = v.Used - arcSize v.Used = v.Used - arcSize
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize) systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize)
} }
} }
if v.Total > 0 {
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
} else {
v.UsedPercent = 0
}
systemStats.Mem = utils.BytesToGigabytes(v.Total) systemStats.Mem = utils.BytesToGigabytes(v.Total)
systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff) systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff)
systemStats.MemUsed = utils.BytesToGigabytes(v.Used) systemStats.MemUsed = utils.BytesToGigabytes(v.Used)
@@ -224,6 +215,9 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// TODO: maybe refactor to methods on systemStats // TODO: maybe refactor to methods on systemStats
a.updateTemperatures(&systemStats) a.updateTemperatures(&systemStats)
// fan speeds (Linux-only; sysfs hwmon)
a.updateFans(&systemStats)
// GPU data // GPU data
if a.gpuManager != nil { if a.gpuManager != nil {
// reset high gpu percent // reset high gpu percent
@@ -271,6 +265,38 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
return systemStats return systemStats
} }
// calculateHostMemoryUsage derives counters defensively because /proc/meminfo may
// change while gopsutil reads it. Invalid unsigned subtractions saturate at zero.
func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheBuff, swapUsed uint64) {
used = v.Used
if used > v.Total {
used = saturatingSub(v.Total, v.Available)
}
// gopsutil automatically adds SReclaimable to Cached.
cacheBuff = min(v.Cached, v.Total)
cacheBuff += min(v.Buffers, v.Total-cacheBuff)
cacheBuff = saturatingSub(cacheBuff, min(v.Shared, v.Total))
if v.Cached == 0 && v.Buffers == 0 {
cacheBuff = saturatingSub(v.Total, v.Free, used)
}
if htop {
used = saturatingSub(v.Total, v.Free, cacheBuff)
}
return used, cacheBuff, saturatingSub(v.SwapTotal, v.SwapFree, v.SwapCached)
}
// saturatingSub subtracts each value, returning zero on underflow.
func saturatingSub(value uint64, subtrahends ...uint64) uint64 {
for _, subtrahend := range subtrahends {
if subtrahend > value {
return 0
}
value -= subtrahend
}
return value
}
// getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems // getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
func getOsPrettyName() (string, error) { func getOsPrettyName() (string, error) {
file, err := os.Open("/etc/os-release") file, err := os.Open("/etc/os-release")

View File

@@ -5,6 +5,7 @@ import (
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/mem"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -33,6 +34,59 @@ func TestGatherStatsDoesNotAttachDetailsToCachedRequests(t *testing.T) {
assert.Nil(t, secondResponse.Details) assert.Nil(t, secondResponse.Details)
} }
func TestCalculateHostMemoryUsage(t *testing.T) {
tests := []struct {
name string
memory mem.VirtualMemoryStat
htop bool
used, cacheBuff, swapUsed uint64
}{
{
name: "normal",
memory: mem.VirtualMemoryStat{Total: 100, Available: 40, Used: 60, Free: 20, Cached: 25, Buffers: 10, Shared: 5, SwapTotal: 20, SwapFree: 8, SwapCached: 2},
used: 60,
cacheBuff: 30,
swapUsed: 10,
},
{
name: "inconsistent counters saturate",
memory: mem.VirtualMemoryStat{Total: 100, Available: 110, Used: ^uint64(0) - 9, Free: 90, Cached: 5, Buffers: 10, Shared: 20, SwapTotal: 10, SwapFree: 9, SwapCached: 2},
used: 0,
cacheBuff: 0,
swapUsed: 0,
},
{
name: "htop subtraction saturates",
memory: mem.VirtualMemoryStat{Total: 100, Available: 20, Used: 80, Free: 90, Cached: 20, Buffers: 5, SwapTotal: 30, SwapFree: 10, SwapCached: 5},
htop: true,
used: 0,
cacheBuff: 25,
swapUsed: 15,
},
{
name: "zero cache from shared cancellation does not fall back",
memory: mem.VirtualMemoryStat{Total: 100, Used: 60, Free: 10, Cached: 20, Buffers: 10, Shared: 30},
used: 60,
cacheBuff: 0,
},
{
name: "absent cache counters use fallback",
memory: mem.VirtualMemoryStat{Total: 100, Used: 60, Free: 10},
used: 60,
cacheBuff: 30,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
used, cacheBuff, swapUsed := calculateHostMemoryUsage(&tt.memory, tt.htop)
assert.Equal(t, tt.used, used)
assert.Equal(t, tt.cacheBuff, cacheBuff)
assert.Equal(t, tt.swapUsed, swapUsed)
})
}
}
func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) { func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) {
agent := &Agent{} agent := &Agent{}

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
}
}

View File

@@ -1,6 +1,8 @@
// Package utils provides utility functions for the agent.
package utils package utils
import ( import (
"fmt"
"io" "io"
"math" "math"
"os" "os"
@@ -68,6 +70,9 @@ func ReadStringFileLimited(path string, maxSize int) (string, error) {
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
return "", err return "", err
} }
if n < 0 {
return "", fmt.Errorf("%s returned negative bytes: %d", path, n)
}
return strings.TrimSpace(string(buf[:n])), nil return strings.TrimSpace(string(buf[:n])), nil
} }

View File

@@ -6,7 +6,7 @@ import "github.com/blang/semver"
const ( const (
// Version is the current version of the application. // Version is the current version of the application.
Version = "0.18.6" Version = "0.18.8"
// AppName is the name of the application. // AppName is the name of the application.
AppName = "beszel" AppName = "beszel"
) )

64
go.mod
View File

@@ -1,69 +1,67 @@
module github.com/henrygd/beszel module github.com/henrygd/beszel
go 1.26.1 go 1.26.6
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.2
github.com/ebitengine/purego v0.10.0 github.com/fxamacker/cbor/v2 v2.9.2
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gliderlabs/ssh v0.3.8 github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.9.1 github.com/lxzan/gws v1.10.1
github.com/nicholas-fedor/shoutrrr v0.14.1 github.com/nicholas-fedor/shoutrrr v0.17.0
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.39.11
github.com/shirou/gopsutil/v4 v4.26.2 github.com/shirou/gopsutil/v4 v4.26.7
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
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.12.0
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.55.0
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297
golang.org/x/sys v0.42.0 golang.org/x/net v0.58.0
golang.org/x/sys v0.47.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
howett.net/plist v1.0.1
) )
require ( require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/disintegration/imaging v1.6.2 // indirect github.com/disintegration/imaging v1.6.2 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eclipse/paho.golang v0.23.0 // indirect github.com/eclipse/paho.golang v0.23.0 // indirect
github.com/fatih/color v1.19.0 // indirect github.com/fatih/color v1.19.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/ganigeorgiev/fexpr v0.5.0 // indirect github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/ganigeorgiev/fexpr v0.6.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-sql-driver/mysql v1.9.1 // indirect github.com/go-sql-driver/mysql v1.9.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/compress v1.19.2 // indirect
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/image v0.38.0 // indirect golang.org/x/image v0.45.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/term v0.41.0 // indirect golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.41.0 // indirect
howett.net/plist v1.0.1 // indirect modernc.org/libc v1.74.1 // 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.12.0 // indirect
modernc.org/sqlite v1.46.2 // indirect modernc.org/sqlite v1.55.0 // indirect
) )

172
go.sum
View File

@@ -1,7 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
@@ -13,39 +13,35 @@ github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
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=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk= github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk=
github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04= github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk= github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE= github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI= github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
@@ -58,8 +54,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -68,47 +64,47 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A= github.com/jarcoal/httpmock v1.4.2 h1:dKwiP/9zITCPfBLsDn3kchbSOu16JrnxtVEmL0fPRcI=
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= github.com/jarcoal/httpmock v1.4.2/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 h1:Qj3hTcdWH8uMZDI41HNuTuJN525C7NBrbtH5kSO6fPk= github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lxzan/gws v1.9.1 h1:4lbIp4cW0hOLP3ejFHR/uWRy741AURx7oKkNNi2OT9o= github.com/lxzan/gws v1.10.1 h1:1xG+tDOV0lgDeVPf0wNT74u3cn0K3LpcavRrTPTrMwQ=
github.com/lxzan/gws v1.9.1/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc= github.com/lxzan/gws v1.10.1/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicholas-fedor/shoutrrr v0.14.1 h1:6sx4cJNfNuUtD6ygGlB0dqcCQ+abfsUh+b+6jgujf6A= github.com/nicholas-fedor/shoutrrr v0.17.0 h1:xfp3z5QbE8jXvUhUEwWDk47SJ/b912VoB8MJJDU+q4E=
github.com/nicholas-fedor/shoutrrr v0.14.1/go.mod h1:U7IywBkLpBV7rgn8iLbQ9/LklJG1gm24bFv5cXXsDKs= github.com/nicholas-fedor/shoutrrr v0.17.0/go.mod h1:s4ldyLs6uwBy9lIjYrY+8lyTqJtPvZSrILw0CyMLock=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA= github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.36.7 h1:MrViB7BptPYrf2Nt25pJEYBqUdFjuhRKu1p5GTrkvPA= github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs=
github.com/pocketbase/pocketbase v0.36.7/go.mod h1:qX4HuVjoKXtEg41fSJVM0JLfGWXbBmHxVv/FaE446r4= github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/pocketbase/pocketbase v0.39.11 h1:cl/Kh13ukof/4BAEku3OozYrLl85M5/bmH62Ny4szFc=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/pocketbase/pocketbase v0.39.11/go.mod h1:5CaCvp/52fZJ5/qyYsqpCGyVue/kjez889cQAATD2cY=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
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.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
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=
@@ -120,53 +116,53 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 h1:YXnL44eJ77R+ji4/ooy8UsXIhz+lbi2Qgdlc8iRN0gY=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297/go.mod h1:Mkmymgv+uMpSQ/XxJ/7GpdrdYoqm3u72jEbpCLiJmNk=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -177,30 +173,30 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.12.0 h1:twkmYNkGXCvtYWzoux02jtK6eovjZbdI0uHFUYp6kuU=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.12.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.2 h1:gkXQ6R0+AjxFC/fTDaeIVLbNLNrRoOK7YYVz5BKhTcE= modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
modernc.org/sqlite v1.46.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -55,6 +55,7 @@ type SystemAlertStats struct {
Temperatures map[string]float32 `json:"t"` Temperatures map[string]float32 `json:"t"`
LoadAvg [3]float64 `json:"la"` LoadAvg [3]float64 `json:"la"`
Battery [2]uint8 `json:"bat"` Battery [2]uint8 `json:"bat"`
Batteries map[string]uint8 `json:"bats"`
ExtraFs map[string]SystemAlertFsStats `json:"efs"` ExtraFs map[string]SystemAlertFsStats `json:"efs"`
} }
@@ -302,21 +303,6 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
return nil return nil
} }
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
var data struct {
URL string `json:"url"`
}
err := e.BindBody(&data)
if err != nil || data.URL == "" {
return e.BadRequestError("URL is required", err)
}
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
if err != nil {
return e.JSON(200, map[string]string{"err": err.Error()})
}
return e.JSON(200, map[string]bool{"err": false})
}
// setAlertTriggered updates the "triggered" status of an alert record in the database // setAlertTriggered updates the "triggered" status of an alert record in the database
func (am *AlertManager) setAlertTriggered(alert CachedAlertData, triggered bool) error { func (am *AlertManager) setAlertTriggered(alert CachedAlertData, triggered bool) error {
alertRecord, err := am.hub.FindRecordById("alerts", alert.Id) alertRecord, err := am.hub.FindRecordById("alerts", alert.Id)

View File

@@ -3,7 +3,11 @@ package alerts
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"net"
"net/http" "net/http"
"net/url"
"slices"
"strings"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
@@ -117,3 +121,72 @@ func DeleteUserAlerts(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted}) return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
} }
// SendTestNotification handles API request to send a test notification to a specified Shoutrrr URL
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
var data struct {
URL string `json:"url"`
}
err := e.BindBody(&data)
if err != nil || data.URL == "" {
return e.BadRequestError("URL is required", err)
}
// Only allow admins to send test notifications to internal URLs
if !e.Auth.IsSuperuser() && e.Auth.GetString("role") != "admin" {
internalURL, err := isInternalURL(data.URL)
if err != nil {
return e.BadRequestError(err.Error(), nil)
}
if internalURL {
return e.ForbiddenError("Only admins can send to internal destinations", nil)
}
}
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
if err != nil {
return e.JSON(200, map[string]string{"err": err.Error()})
}
return e.JSON(200, map[string]bool{"err": false})
}
// isInternalURL checks if the given shoutrrr URL points to an internal destination (localhost or private IP)
func isInternalURL(rawURL string) (bool, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return false, err
}
host := parsedURL.Hostname()
if host == "" {
return false, nil
}
if strings.EqualFold(host, "localhost") {
return true, nil
}
if ip := net.ParseIP(host); ip != nil {
return isInternalIP(ip), nil
}
// Some Shoutrrr URLs use the host position for service identifiers rather than a
// network hostname (for example, discord://token@webhookid). Restrict DNS lookups
// to names that look like actual hostnames so valid service URLs keep working.
if !strings.Contains(host, ".") {
return false, nil
}
ips, err := net.LookupIP(host)
if err != nil {
return false, nil
}
if slices.ContainsFunc(ips, isInternalIP) {
return true, nil
}
return false, nil
}
func isInternalIP(ip net.IP) bool {
return ip.IsPrivate() || ip.IsLoopback() || ip.IsUnspecified()
}

View File

@@ -0,0 +1,501 @@
//go:build testing
package alerts_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"github.com/henrygd/beszel/internal/alerts"
beszelTests "github.com/henrygd/beszel/internal/tests"
pbTests "github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
)
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
func jsonReader(v any) io.Reader {
data, err := json.Marshal(v)
if err != nil {
panic(err)
}
return bytes.NewReader(data)
}
func TestIsInternalURL(t *testing.T) {
testCases := []struct {
name string
url string
internal bool
}{
{name: "loopback ipv4", url: "generic://127.0.0.1", internal: true},
{name: "localhost hostname", url: "generic://localhost", internal: true},
{name: "localhost hostname", url: "generic+http://localhost/api/v1/postStuff", internal: true},
{name: "localhost hostname", url: "generic+http://127.0.0.1:8080/api/v1/postStuff", internal: true},
{name: "localhost hostname", url: "generic+https://beszel.dev/api/v1/postStuff", internal: false},
{name: "public ipv4", url: "generic://8.8.8.8", internal: false},
{name: "token style service url", url: "discord://abc123@123456789", internal: false},
{name: "single label service url", url: "slack://token@team/channel", internal: false},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
internal, err := alerts.IsInternalURL(testCase.url)
assert.NoError(t, err)
assert.Equal(t, testCase.internal, internal)
})
}
}
func TestUserAlertsApi(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password")
user1Token, _ := user1.NewAuthToken()
user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password")
user2Token, _ := user2.NewAuthToken()
system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "system1",
"users": []string{user1.Id},
"host": "127.0.0.1",
})
system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "system2",
"users": []string{user1.Id, user2.Id},
"host": "127.0.0.2",
})
userRecords, _ := hub.CountRecords("users")
assert.EqualValues(t, 2, userRecords, "all users should be created")
systemRecords, _ := hub.CountRecords("systems")
assert.EqualValues(t, 2, systemRecords, "all systems should be created")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
// {
// Name: "GET not implemented - returns index",
// Method: http.MethodGet,
// URL: "/api/beszel/user-alerts",
// ExpectedStatus: 200,
// ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
// TestAppFactory: testAppFactory,
// },
{
Name: "POST no auth",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "POST no body",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Bad data"},
TestAppFactory: testAppFactory,
},
{
Name: "POST bad data",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Bad data"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"invalidField": "this should cause validation error",
"threshold": "not a number",
}),
},
{
Name: "POST malformed JSON",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Bad data"},
TestAppFactory: testAppFactory,
Body: strings.NewReader(`{"alertType": "cpu", "threshold": 80, "enabled": true,}`),
},
{
Name: "POST valid alert data multiple systems",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 69,
"min": 9,
"systems": []string{system1.Id, system2.Id},
"overwrite": false,
}),
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
// check total alerts
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
// check alert has correct values
matchingAlerts, _ := app.CountRecords("alerts", dbx.HashExp{"name": "CPU", "user": user1.Id, "system": system1.Id, "value": 69, "min": 9})
assert.EqualValues(t, 1, matchingAlerts, "should have 1 alert")
},
},
{
Name: "POST valid alert data single system",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "Memory",
"systems": []string{system1.Id},
"value": 90,
"min": 10,
}),
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
user1Alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
},
},
{
Name: "Overwrite: false, should not overwrite existing alert",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 45,
"min": 5,
"systems": []string{system1.Id},
"overwrite": false,
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
"user": user1.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 1, alerts, "should have 1 alert")
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user1.Id})
assert.EqualValues(t, 80, alert.Get("value"), "should have 80 as value")
},
},
{
Name: "Overwrite: true, should overwrite existing alert",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user2Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 45,
"min": 5,
"systems": []string{system2.Id},
"overwrite": true,
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system2.Id,
"user": user2.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 1, alerts, "should have 1 alert")
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user2.Id})
assert.EqualValues(t, 45, alert.Get("value"), "should have 45 as value")
},
},
{
Name: "DELETE no auth",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system1.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
"user": user1.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 1, alerts, "should have 1 alert")
},
},
{
Name: "DELETE alert",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system1.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
"user": user1.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.Zero(t, alerts, "should have 0 alerts")
},
},
{
Name: "DELETE alert multiple systems",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"count\":2", "\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "Memory",
"systems": []string{system1.Id, system2.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
for _, systemId := range []string{system1.Id, system2.Id} {
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "Memory",
"system": systemId,
"user": user1.Id,
"value": 90,
"min": 10,
})
assert.NoError(t, err, "should create alert")
}
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.Zero(t, alerts, "should have 0 alerts")
},
},
{
Name: "User 2 should not be able to delete alert of user 1",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user2Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system2.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
for _, user := range []string{user1.Id, user2.Id} {
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system2.Id,
"user": user,
"value": 80,
"min": 10,
})
}
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
assert.EqualValues(t, 1, user2AlertCount, "should have 1 alert")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
assert.Zero(t, user2AlertCount, "should have 0 alerts")
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestSendTestNotification(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
userToken, err := user.NewAuthToken()
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
assert.NoError(t, err, "Failed to create admin user")
adminUserToken, err := adminUser.NewAuthToken()
superuser, err := beszelTests.CreateSuperuser(hub, "superuser@example.com", "password123")
assert.NoError(t, err, "Failed to create superuser")
superuserToken, err := superuser.NewAuthToken()
assert.NoError(t, err, "Failed to create superuser auth token")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "POST /test-notification - no auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
},
{
Name: "POST /test-notification - with external auth should succeed",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": userToken,
},
Body: jsonReader(map[string]any{
"url": "generic://8.8.8.8",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"\"err\":"},
},
{
Name: "POST /test-notification - local url with user auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": userToken,
},
Body: jsonReader(map[string]any{
"url": "generic://localhost:8010",
}),
ExpectedStatus: 403,
ExpectedContent: []string{"Only admins"},
},
{
Name: "POST /test-notification - internal url with user auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": userToken,
},
Body: jsonReader(map[string]any{
"url": "generic+http://192.168.0.5",
}),
ExpectedStatus: 403,
ExpectedContent: []string{"Only admins"},
},
{
Name: "POST /test-notification - internal url with admin auth should succeed",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": adminUserToken,
},
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"\"err\":"},
},
{
Name: "POST /test-notification - internal url with superuser auth should succeed",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": superuserToken,
},
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"\"err\":"},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}

View File

@@ -322,8 +322,9 @@ func TestAlertSilencedMultiUser(t *testing.T) {
} }
func TestAlertSilencedWithActualAlert(t *testing.T) { func TestAlertSilencedWithActualAlert(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
// Create a system // Create a system

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

@@ -29,8 +29,9 @@ func setStatusAlertEmail(t *testing.T, hub core.App, userID, email string) {
} }
func TestStatusAlerts(t *testing.T) { func TestStatusAlerts(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
systems, err := beszelTests.CreateSystems(hub, 4, user.Id, "paused") systems, err := beszelTests.CreateSystems(hub, 4, user.Id, "paused")
@@ -234,8 +235,9 @@ func TestHandleStatusAlertsDoesNotSendRecoveryWhileDownIsOnlyPending(t *testing.
} }
func TestStatusAlertTimerCancellationPreventsBoundaryDelivery(t *testing.T) { func TestStatusAlertTimerCancellationPreventsBoundaryDelivery(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id}) userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
@@ -336,8 +338,9 @@ func TestStatusAlertDownFiresAfterDelayExpires(t *testing.T) {
} }
func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) { func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) {
hub, user1 := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user1 := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com") setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
@@ -423,8 +426,9 @@ func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) {
} }
func TestStatusAlertMultipleUsersRecoveryBetweenMinutesOnlyAlertsEarlierUser(t *testing.T) { func TestStatusAlertMultipleUsersRecoveryBetweenMinutesOnlyAlertsEarlierUser(t *testing.T) {
hub, user1 := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user1 := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com") setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
@@ -816,8 +820,9 @@ func TestResolveStatusAlerts(t *testing.T) {
} }
func TestAlertsHistoryStatus(t *testing.T) { func TestAlertsHistoryStatus(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
// Create a system // Create a system
@@ -882,8 +887,9 @@ func TestAlertsHistoryStatus(t *testing.T) {
} }
func TestStatusAlertClearedBeforeSend(t *testing.T) { func TestStatusAlertClearedBeforeSend(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
// Create a system // Create a system
@@ -941,3 +947,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

@@ -63,7 +63,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
case "GPU": case "GPU":
val = data.Info.GpuPct val = data.Info.GpuPct
case "Battery": case "Battery":
if data.Stats.Battery[0] == 0 { if !hasRepresentativeBattery(data.Stats.Battery, data.Stats.Batteries) {
continue continue
} }
val = float64(data.Stats.Battery[0]) val = float64(data.Stats.Battery[0])
@@ -167,6 +167,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
stat := systemStats[i] stat := systemStats[i]
// subtract 10 seconds to give a small time buffer // subtract 10 seconds to give a small time buffer
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10) systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
stats = SystemAlertStats{}
if err := json.Unmarshal(stat.Stats, &stats); err != nil { if err := json.Unmarshal(stat.Stats, &stats); err != nil {
return err return err
} }
@@ -235,6 +236,9 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
} }
alert.val += maxUsage alert.val += maxUsage
case "Battery": case "Battery":
if !hasRepresentativeBattery(stats.Battery, stats.Batteries) {
continue
}
alert.val += float64(stats.Battery[0]) alert.val += float64(stats.Battery[0])
default: default:
continue continue
@@ -297,6 +301,10 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
return nil return nil
} }
func hasRepresentativeBattery(legacy [2]uint8, batteries map[string]uint8) bool {
return legacy != [2]uint8{} || len(batteries) > 0
}
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) { func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold) // log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
systemName := alert.systemRecord.GetString("name") systemName := alert.systemRecord.GetString("name")

View File

@@ -95,11 +95,10 @@ func waitForSystemAlert(d time.Duration) {
func testOneMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, setValue systemAlertValueSetter[T], triggerValue, resolveValue T) { func testOneMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, setValue systemAlertValueSetter[T], triggerValue, resolveValue T) {
t.Helper() t.Helper()
fixture := newSystemAlertTestFixture(t, alertName, 1, threshold)
defer fixture.cleanup()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
fixture := newSystemAlertTestFixture(t, alertName, 1, threshold)
defer fixture.cleanup()
submitValue(fixture, t, triggerValue, setValue) submitValue(fixture, t, triggerValue, setValue)
waitForSystemAlert(time.Second) waitForSystemAlert(time.Second)
@@ -118,11 +117,10 @@ func testOneMinuteSystemAlert[T any](t *testing.T, alertName string, threshold f
func testMultiMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, min int, setValue systemAlertValueSetter[T], baselineValue, triggerValue, resolveValue T) { func testMultiMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, min int, setValue systemAlertValueSetter[T], baselineValue, triggerValue, resolveValue T) {
t.Helper() t.Helper()
fixture := newSystemAlertTestFixture(t, alertName, min, threshold)
defer fixture.cleanup()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
fixture := newSystemAlertTestFixture(t, alertName, min, threshold)
defer fixture.cleanup()
submitValue(fixture, t, baselineValue, setValue) submitValue(fixture, t, baselineValue, setValue)
waitForSystemAlert(time.Minute + time.Second) waitForSystemAlert(time.Minute + time.Second)
fixture.assertTriggered(t, false, "Alert should not be triggered yet") fixture.assertTriggered(t, false, "Alert should not be triggered yet")
@@ -201,7 +199,7 @@ func TestSystemAlertsOneMin(t *testing.T) {
testOneMinuteSystemAlert(t, "LoadAvg1", 4, setLoadAvgAlertValue, [3]float64{4.1, 0, 0}, [3]float64{3.9, 0, 0}) testOneMinuteSystemAlert(t, "LoadAvg1", 4, setLoadAvgAlertValue, [3]float64{4.1, 0, 0}, [3]float64{3.9, 0, 0})
testOneMinuteSystemAlert(t, "LoadAvg5", 4, setLoadAvgAlertValue, [3]float64{0, 4.1, 0}, [3]float64{0, 3.9, 0}) testOneMinuteSystemAlert(t, "LoadAvg5", 4, setLoadAvgAlertValue, [3]float64{0, 4.1, 0}, [3]float64{0, 3.9, 0})
testOneMinuteSystemAlert(t, "LoadAvg15", 4, setLoadAvgAlertValue, [3]float64{0, 0, 4.1}, [3]float64{0, 0, 3.9}) testOneMinuteSystemAlert(t, "LoadAvg15", 4, setLoadAvgAlertValue, [3]float64{0, 0, 4.1}, [3]float64{0, 0, 3.9})
testOneMinuteSystemAlert(t, "Battery", 20, setBatteryAlertValue, [2]uint8{19, 0}, [2]uint8{21, 0}) testOneMinuteSystemAlert(t, "Battery", 20, setBatteryAlertValue, [2]uint8{0, 1}, [2]uint8{21, 0})
} }
func TestSystemAlertsTwoMin(t *testing.T) { func TestSystemAlertsTwoMin(t *testing.T) {

View File

@@ -3,11 +3,6 @@
package alerts_test package alerts_test
import ( import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"testing" "testing"
"testing/synctest" "testing/synctest"
"time" "time"
@@ -16,362 +11,13 @@ import (
"github.com/henrygd/beszel/internal/alerts" "github.com/henrygd/beszel/internal/alerts"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
pbTests "github.com/pocketbase/pocketbase/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
func jsonReader(v any) io.Reader {
data, err := json.Marshal(v)
if err != nil {
panic(err)
}
return bytes.NewReader(data)
}
func TestUserAlertsApi(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password")
user1Token, _ := user1.NewAuthToken()
user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password")
user2Token, _ := user2.NewAuthToken()
system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "system1",
"users": []string{user1.Id},
"host": "127.0.0.1",
})
system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "system2",
"users": []string{user1.Id, user2.Id},
"host": "127.0.0.2",
})
userRecords, _ := hub.CountRecords("users")
assert.EqualValues(t, 2, userRecords, "all users should be created")
systemRecords, _ := hub.CountRecords("systems")
assert.EqualValues(t, 2, systemRecords, "all systems should be created")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
// {
// Name: "GET not implemented - returns index",
// Method: http.MethodGet,
// URL: "/api/beszel/user-alerts",
// ExpectedStatus: 200,
// ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
// TestAppFactory: testAppFactory,
// },
{
Name: "POST no auth",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "POST no body",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Bad data"},
TestAppFactory: testAppFactory,
},
{
Name: "POST bad data",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Bad data"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"invalidField": "this should cause validation error",
"threshold": "not a number",
}),
},
{
Name: "POST malformed JSON",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Bad data"},
TestAppFactory: testAppFactory,
Body: strings.NewReader(`{"alertType": "cpu", "threshold": 80, "enabled": true,}`),
},
{
Name: "POST valid alert data multiple systems",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 69,
"min": 9,
"systems": []string{system1.Id, system2.Id},
"overwrite": false,
}),
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
// check total alerts
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
// check alert has correct values
matchingAlerts, _ := app.CountRecords("alerts", dbx.HashExp{"name": "CPU", "user": user1.Id, "system": system1.Id, "value": 69, "min": 9})
assert.EqualValues(t, 1, matchingAlerts, "should have 1 alert")
},
},
{
Name: "POST valid alert data single system",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "Memory",
"systems": []string{system1.Id},
"value": 90,
"min": 10,
}),
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
user1Alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
},
},
{
Name: "Overwrite: false, should not overwrite existing alert",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 45,
"min": 5,
"systems": []string{system1.Id},
"overwrite": false,
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
"user": user1.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 1, alerts, "should have 1 alert")
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user1.Id})
assert.EqualValues(t, 80, alert.Get("value"), "should have 80 as value")
},
},
{
Name: "Overwrite: true, should overwrite existing alert",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user2Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 45,
"min": 5,
"systems": []string{system2.Id},
"overwrite": true,
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system2.Id,
"user": user2.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 1, alerts, "should have 1 alert")
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user2.Id})
assert.EqualValues(t, 45, alert.Get("value"), "should have 45 as value")
},
},
{
Name: "DELETE no auth",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system1.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
"user": user1.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 1, alerts, "should have 1 alert")
},
},
{
Name: "DELETE alert",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system1.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
"user": user1.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.Zero(t, alerts, "should have 0 alerts")
},
},
{
Name: "DELETE alert multiple systems",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"count\":2", "\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "Memory",
"systems": []string{system1.Id, system2.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
for _, systemId := range []string{system1.Id, system2.Id} {
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "Memory",
"system": systemId,
"user": user1.Id,
"value": 90,
"min": 10,
})
assert.NoError(t, err, "should create alert")
}
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.Zero(t, alerts, "should have 0 alerts")
},
},
{
Name: "User 2 should not be able to delete alert of user 1",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user2Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system2.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
for _, user := range []string{user1.Id, user2.Id} {
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system2.Id,
"user": user,
"value": 80,
"min": 10,
})
}
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
assert.EqualValues(t, 1, user2AlertCount, "should have 1 alert")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
assert.Zero(t, user2AlertCount, "should have 0 alerts")
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestAlertsHistory(t *testing.T) { func TestAlertsHistory(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
// Create systems and alerts // Create systems and alerts

View File

@@ -95,3 +95,7 @@ func (am *AlertManager) RestorePendingStatusAlerts() error {
func (am *AlertManager) SetAlertTriggered(alert CachedAlertData, triggered bool) error { func (am *AlertManager) SetAlertTriggered(alert CachedAlertData, triggered bool) error {
return am.setAlertTriggered(alert, triggered) return am.setAlertTriggered(alert, triggered)
} }
func IsInternalURL(rawURL string) (bool, error) {
return isInternalURL(rawURL)
}

View File

@@ -195,6 +195,6 @@ func main() {
} }
if err := a.Start(serverConfig); err != nil { if err := a.Start(serverConfig); err != nil {
log.Fatal("Failed to start server: ", err) log.Fatal("Failed to start: ", err)
} }
} }

View File

@@ -42,7 +42,8 @@ type AgentResponse struct {
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"` // Legacy (<= 0.17) SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"` // Legacy (<= 0.17) ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
// Data is the generic response payload for new endpoints (0.18+) // Data is the generic response payload for new endpoints (0.18+)
Data cbor.RawMessage `cbor:"7,keyasint,omitempty,omitzero"` Data cbor.RawMessage `cbor:"7,keyasint,omitempty,omitzero"`
SmartComplete bool `cbor:"8,keyasint,omitempty,omitzero"`
} }
type FingerprintRequest struct { type FingerprintRequest struct {

View File

@@ -20,7 +20,7 @@ FROM alpine:3.23
COPY --from=builder /agent /agent COPY --from=builder /agent /agent
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools smartmontools RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools nvtop smartmontools
# Ensure data persistence across container recreations # Ensure data persistence across container recreations
VOLUME ["/var/lib/beszel-agent"] VOLUME ["/var/lib/beszel-agent"]

View File

@@ -0,0 +1,90 @@
FROM --platform=$BUILDPLATFORM golang:bookworm AS builder
WORKDIR /app
COPY ../go.mod ../go.sum ./
RUN go mod download
# Copy source files
COPY . ./
# Build
ARG TARGETOS=linux
ARG TARGETARCH
ARG TARGETVARIANT
RUN set -eux; \
if [ "$TARGETARCH" = "arm" ] && [ -n "$TARGETVARIANT" ]; then \
export GOARM="${TARGETVARIANT#v}"; \
fi; \
CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -tags glibc -ldflags "-w -s" -o /agent ./internal/cmd/agent
# --------------------------
# Smartmontools builder stage
# --------------------------
FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS smartmontools-builder
# Keep smartmontools 7.5 built from source to match the current NVIDIA agent image behavior.
# A simpler Debian package based approach is also possible:
#
# RUN apt-get update && apt-get install -y --no-install-recommends \
# smartmontools \
# && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
ca-certificates \
build-essential \
make \
g++ \
&& wget https://downloads.sourceforge.net/project/smartmontools/smartmontools/7.5/smartmontools-7.5.tar.gz \
&& tar zxvf smartmontools-7.5.tar.gz \
&& cd smartmontools-7.5 \
&& ./configure --prefix=/usr --sysconfdir=/etc \
&& make \
&& make install \
&& rm -rf /smartmontools-7.5* \
&& apt-get remove -y wget build-essential \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# Copy smartmontools binary, data files, and required runtime libraries
RUN set -eux; \
mkdir -p /out/rootfs/usr/share /out/rootfs/lib /out/rootfs/lib64 /out/rootfs/usr/lib; \
if [ -d /usr/share/smartmontools ]; then \
cp -a /usr/share/smartmontools /out/rootfs/usr/share/; \
fi; \
ldd /usr/sbin/smartctl \
| awk '{print $3}' \
| grep '^/' \
| xargs -r -I '{}' sh -c 'mkdir -p "/out/rootfs$(dirname "{}")"; cp -v "{}" "/out/rootfs{}"'; \
interp="$(ldd /usr/sbin/smartctl | awk "/ld-linux/ {print \$1}")"; \
if [ -n "$interp" ] && [ -e "$interp" ]; then \
mkdir -p "/out/rootfs$(dirname "$interp")"; \
cp -v "$interp" "/out/rootfs$interp"; \
fi
# --------------------------
# Final image: lightweight multi-arch NVIDIA agent (slim)
# --------------------------
FROM --platform=$TARGETPLATFORM gcr.io/distroless/base-debian12
COPY --from=builder /agent /agent
# AMD GPU name lookup (used by agent on hybrid laptops when /usr/share/libdrm/amdgpu.ids is read)
COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
# Copy smartmontools binaries and config files
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
COPY --from=smartmontools-builder /out/rootfs/ /
# nvidia-smi is intentionally not bundled.
# Mount the host binary instead, for example:
# - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro
# Ensure data persistence across container recreations
VOLUME ["/var/lib/beszel-agent"]
WORKDIR /var/lib/beszel-agent
ENTRYPOINT ["/agent"]

View File

@@ -52,6 +52,17 @@ type HostInfo struct {
} }
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 { func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
// A counter can read lower than the stored previous value when a stats
// response is processed after a newer one for the same container, or when an
// accounting counter resets. Unsigned subtraction wraps to ~2^64 instead of
// going negative: on the container counter that surfaces as an absurd
// percentage the caller rejects, discarding the whole sample; on the system
// counter it inflates the divisor and silently reports near-zero CPU.
// Treat either direction as a new baseline.
if s.CPUStats.CPUUsage.TotalUsage < prevCpuContainer || s.CPUStats.SystemUsage < prevCpuSystem {
return 0.0
}
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
@@ -70,7 +81,11 @@ func (s *ApiStats) CalculateCpuPercentWindows(prevCpuUsage uint64, prevRead time
possIntervals /= 100 // Convert to number of 100ns intervals possIntervals /= 100 // Convert to number of 100ns intervals
possIntervals *= uint64(s.NumProcs) // Multiple by the number of processors possIntervals *= uint64(s.NumProcs) // Multiple by the number of processors
// Intervals used // Intervals used. Same rollback guard as the Linux path: an out-of-order or
// reset counter would wrap the subtraction to ~2^64.
if s.CPUStats.CPUUsage.TotalUsage < prevCpuUsage {
return 0.0
}
intervalsUsed := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage intervalsUsed := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage
// Percentage avoiding divide-by-zero // Percentage avoiding divide-by-zero

View File

@@ -494,7 +494,7 @@ type SmartInfoForNvme struct {
FirmwareVersion string `json:"firmware_version"` FirmwareVersion string `json:"firmware_version"`
// NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"` // NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"`
// NVMeIEEEOUIIdentifier uint32 `json:"nvme_ieee_oui_identifier"` // NVMeIEEEOUIIdentifier uint32 `json:"nvme_ieee_oui_identifier"`
// NVMeTotalCapacity uint64 `json:"nvme_total_capacity"` NVMeTotalCapacity uint64 `json:"nvme_total_capacity"`
// NVMeUnallocatedCapacity uint64 `json:"nvme_unallocated_capacity"` // NVMeUnallocatedCapacity uint64 `json:"nvme_unallocated_capacity"`
// NVMeControllerID uint16 `json:"nvme_controller_id"` // NVMeControllerID uint16 `json:"nvme_controller_id"`
// NVMeVersion VersionStringInfo `json:"nvme_version"` // NVMeVersion VersionStringInfo `json:"nvme_version"`
@@ -531,6 +531,13 @@ type SmartData struct {
Attributes []*SmartAttribute `json:"a,omitempty" cbor:"9,keyasint,omitempty"` Attributes []*SmartAttribute `json:"a,omitempty" cbor:"9,keyasint,omitempty"`
} }
// SmartDataResponse contains the collected data and whether every discovered
// device was collected. Older agents omit Complete, so hubs must not prune from it.
type SmartDataResponse struct {
Data map[string]SmartData `json:"data" cbor:"0,keyasint"`
Complete bool `json:"complete" cbor:"1,keyasint,omitempty"` // Whether every discovered device was collected
}
type SmartAttribute struct { type SmartAttribute struct {
ID uint16 `json:"id,omitempty" cbor:"0,keyasint,omitempty"` ID uint16 `json:"id,omitempty" cbor:"0,keyasint,omitempty"`
Name string `json:"n" cbor:"1,keyasint"` Name string `json:"n" cbor:"1,keyasint"`

View File

@@ -33,6 +33,8 @@ type Stats struct {
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"-"` MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"-"`
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"` MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"`
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"` Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
Fans map[string]uint16 `json:"f,omitempty" cbor:"36,keyasint,omitempty"`
Batteries map[string]uint8 `json:"bats,omitempty" cbor:"37,keyasint,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"` ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"` GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
// LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"` // LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
@@ -42,12 +44,14 @@ type Stats struct {
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"-"` // [sent bytes, recv bytes] MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"-"` // [sent bytes, recv bytes]
// TODO: remove other load fields in future release in favor of load avg array // TODO: remove other load fields in future release in favor of load avg array
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"` LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current] Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state]
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download] NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes] DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes]
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes] MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
CpuBreakdown []float64 `json:"cpub,omitempty" cbor:"33,keyasint,omitempty"` // [user, system, iowait, steal, idle] CpuBreakdown []float64 `json:"cpub,omitempty" cbor:"33,keyasint,omitempty"` // [user, system, iowait, steal, idle]
CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..] CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..]
DiskIoStats [6]float64 `json:"dios,omitzero" cbor:"35,keyasint,omitzero"` // [read time %, write time %, io utilization %, r_await ms, w_await ms, weighted io %]
MaxDiskIoStats [6]float64 `json:"diosm,omitzero" cbor:"-"` // max values for DiskIoStats
} }
// Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient. // Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient.
@@ -93,10 +97,12 @@ type FsStats struct {
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"` MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"`
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"` MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"`
// TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes // TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"` DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"` DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
MaxDiskReadBytes uint64 `json:"rbm,omitempty" cbor:"-"` MaxDiskReadBytes uint64 `json:"rbm,omitempty" cbor:"-"`
MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"` MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"`
DiskIoStats [6]float64 `json:"dios,omitzero" cbor:"8,keyasint,omitzero"` // [read time %, write time %, io utilization %, r_await ms, w_await ms, weighted io %]
MaxDiskIoStats [6]float64 `json:"diosm,omitzero" cbor:"-"` // max values for DiskIoStats
} }
type NetIoStats struct { type NetIoStats struct {

View File

@@ -0,0 +1,37 @@
package system
import (
"encoding/json"
"testing"
"github.com/fxamacker/cbor/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStatsBatteryTransport(t *testing.T) {
stats := Stats{Battery: [2]uint8{0, 1}, Batteries: map[string]uint8{"Primary": 0, "Mouse": 75}}
jsonData, err := json.Marshal(stats)
require.NoError(t, err)
var jsonPayload map[string]any
require.NoError(t, json.Unmarshal(jsonData, &jsonPayload))
assert.Equal(t, []any{float64(0), float64(1)}, jsonPayload["bat"])
assert.Equal(t, map[string]any{"Primary": float64(0), "Mouse": float64(75)}, jsonPayload["bats"])
cborData, err := cbor.Marshal(stats)
require.NoError(t, err)
var decoded Stats
require.NoError(t, cbor.Unmarshal(cborData, &decoded))
assert.Equal(t, stats.Battery, decoded.Battery)
assert.Equal(t, stats.Batteries, decoded.Batteries)
}
func TestStatsLegacyBatteryPayload(t *testing.T) {
data, err := json.Marshal(Stats{Battery: [2]uint8{50, 4}})
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(data, &payload))
assert.Contains(t, payload, "bat")
assert.NotContains(t, payload, "bats")
}

View File

@@ -0,0 +1,43 @@
package ghupdate
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"strings"
)
func verifyAssetChecksum(path, digest string) error {
algorithm, expectedHex, ok := strings.Cut(digest, ":")
if !ok || algorithm == "" || expectedHex == "" {
return fmt.Errorf("invalid release digest %q", digest)
}
if !strings.EqualFold(algorithm, "sha256") {
return fmt.Errorf("unsupported release digest algorithm %q", algorithm)
}
expected, err := hex.DecodeString(expectedHex)
if err != nil || len(expected) != sha256.Size {
return fmt.Errorf("invalid SHA-256 release digest %q", digest)
}
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open release for checksum verification: %w", err)
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return fmt.Errorf("failed to calculate release checksum: %w", err)
}
actual := hash.Sum(nil)
if !bytes.Equal(actual, expected) {
return fmt.Errorf("release checksum mismatch: expected %s, got %s", expectedHex, hex.EncodeToString(actual))
}
return nil
}

View File

@@ -0,0 +1,57 @@
package ghupdate
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestVerifyAssetChecksum(t *testing.T) {
path := filepath.Join(t.TempDir(), "asset")
if err := os.WriteFile(path, []byte("beszel release asset"), 0600); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
digest string
wantErr string
}{
{
name: "valid",
digest: "sha256:2316f86af2c3af2f0ef595ad5359cdd19b329d4829a5425460ca9ffbf92671ab",
},
{
name: "mismatch",
digest: "sha256:0316f86af2c3af2f0ef595ad5359cdd19b329d4829a5425460ca9ffbf92671ab",
wantErr: "checksum mismatch",
},
{
name: "malformed",
digest: "sha256:not-a-checksum",
wantErr: "invalid SHA-256",
},
{
name: "missing",
wantErr: "invalid release digest",
},
{
name: "unsupported algorithm",
digest: "sha512:2316f86af2c3af2f0ef595ad5359cdd19b329d4829a5425460ca9ffbf92671ab",
wantErr: "unsupported",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := verifyAssetChecksum(path, tt.digest)
if tt.wantErr == "" && err != nil {
t.Fatalf("expected checksum to verify, got %v", err)
}
if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
})
}
}

View File

@@ -46,18 +46,23 @@ func extractTarGz(srcPath, destDir string) error {
return err return err
} }
path, err := archivePath(destDir, header.Name)
if err != nil {
return err
}
if header.Typeflag == tar.TypeDir { if header.Typeflag == tar.TypeDir {
if err := os.MkdirAll(filepath.Join(destDir, header.Name), 0755); err != nil { if err := os.MkdirAll(path, 0755); err != nil {
return err return err
} }
continue continue
} }
if err := os.MkdirAll(filepath.Dir(filepath.Join(destDir, header.Name)), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err return err
} }
outFile, err := os.Create(filepath.Join(destDir, header.Name)) outFile, err := os.Create(path)
if err != nil { if err != nil {
return err return err
} }
@@ -72,6 +77,14 @@ func extractTarGz(srcPath, destDir string) error {
return nil return nil
} }
// archivePath returns a path within destDir, rejecting path traversal entries.
func archivePath(destDir, name string) (string, error) {
if !filepath.IsLocal(name) {
return "", fmt.Errorf("invalid file path: %q", name)
}
return filepath.Join(destDir, name), nil
}
// extractZip extracts the zip archive at "src" to "dest". // extractZip extracts the zip archive at "src" to "dest".
// //
// Note that only dirs and regular files will be extracted. // Note that only dirs and regular files will be extracted.
@@ -84,9 +97,6 @@ func extractZip(src, dest string) error {
} }
defer zr.Close() defer zr.Close()
// normalize dest path to check later for Zip Slip
dest = filepath.Clean(dest) + string(os.PathSeparator)
for _, f := range zr.File { for _, f := range zr.File {
err := extractFile(f, dest) err := extractFile(f, dest)
if err != nil { if err != nil {
@@ -100,11 +110,9 @@ func extractZip(src, dest string) error {
// extractFile extracts the provided zipFile into "basePath/zipFileName" path, // extractFile extracts the provided zipFile into "basePath/zipFileName" path,
// creating all the necessary path directories. // creating all the necessary path directories.
func extractFile(zipFile *zip.File, basePath string) error { func extractFile(zipFile *zip.File, basePath string) error {
path := filepath.Join(basePath, zipFile.Name) path, err := archivePath(basePath, zipFile.Name)
if err != nil {
// check for Zip Slip return err
if !strings.HasPrefix(path, basePath) {
return fmt.Errorf("invalid file path: %s", path)
} }
r, err := zipFile.Open() r, err := zipFile.Open()

View File

@@ -135,21 +135,33 @@ func (p *updater) update() (updated bool, err error) {
return false, err return false, err
} }
releaseDir := filepath.Join(p.config.DataDir, ".beszel_update") if err := os.MkdirAll(p.config.DataDir, 0755); err != nil {
return false, fmt.Errorf("failed to create update data directory: %w", err)
}
releaseDir, err := os.MkdirTemp(p.config.DataDir, ".beszel_update-")
if err != nil {
return false, fmt.Errorf("failed to create update directory: %w", err)
}
defer os.RemoveAll(releaseDir) defer os.RemoveAll(releaseDir)
ColorPrintf(ColorYellow, "Downloading %s...", asset.Name) ColorPrintf(ColorYellow, "Downloading %s...", asset.Name)
// download the release asset // download the release asset
assetPath := filepath.Join(releaseDir, asset.Name) assetPath, err := archivePath(releaseDir, asset.Name)
if err != nil {
return false, err
}
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, p.config.UseMirror); err != nil { if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, p.config.UseMirror); err != nil {
return false, err return false, err
} }
ColorPrint(ColorYellow, "Verifying checksum...")
if err := verifyAssetChecksum(assetPath, asset.Digest); err != nil {
return false, err
}
ColorPrintf(ColorYellow, "Extracting %s...", asset.Name) ColorPrintf(ColorYellow, "Extracting %s...", asset.Name)
extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name) extractDir := filepath.Join(releaseDir, "extracted")
defer os.RemoveAll(extractDir)
// Extract the archive (automatically detects format) // Extract the archive (automatically detects format)
if err := extract(assetPath, extractDir); err != nil { if err := extract(assetPath, extractDir); err != nil {

View File

@@ -1,6 +1,9 @@
package ghupdate package ghupdate
import ( import (
"archive/tar"
"compress/gzip"
"os"
"path/filepath" "path/filepath"
"testing" "testing"
) )
@@ -43,3 +46,59 @@ func TestExtractFailure(t *testing.T) {
t.Fatal("Expected Extract to fail due to missing tar.gz file") t.Fatal("Expected Extract to fail due to missing tar.gz file")
} }
} }
func TestArchivePath(t *testing.T) {
destDir := t.TempDir()
for _, name := range []string{
"",
"..",
filepath.Join("..", "file"),
filepath.Join("dir", "..", "..", "file"),
string(os.PathSeparator) + filepath.Join("tmp", "file"),
} {
if _, err := archivePath(destDir, name); err == nil {
t.Errorf("expected %q to be rejected", name)
}
}
name := filepath.Join("dir", "file")
if path, err := archivePath(destDir, name); err != nil || path != filepath.Join(destDir, name) {
t.Errorf("archivePath(%q) = %q, %v", name, path, err)
}
}
func TestExtractTarGzRejectsPathTraversal(t *testing.T) {
testDir := t.TempDir()
archivePath := filepath.Join(testDir, "malicious.tar.gz")
destDir := filepath.Join(testDir, "extract")
escapedPath := filepath.Join(testDir, "escaped")
archive, err := os.Create(archivePath)
if err != nil {
t.Fatal(err)
}
gz := gzip.NewWriter(archive)
tw := tar.NewWriter(gz)
if err := tw.WriteHeader(&tar.Header{Name: "../escaped", Mode: 0600, Size: 1}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte("x")); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
if err := archive.Close(); err != nil {
t.Fatal(err)
}
if err := extract(archivePath, destDir); err == nil {
t.Fatal("expected path traversal archive to be rejected")
}
if _, err := os.Stat(escapedPath); !os.IsNotExist(err) {
t.Fatalf("path traversal wrote %s", escapedPath)
}
}

View File

@@ -8,6 +8,7 @@ import (
type releaseAsset struct { type releaseAsset struct {
Name string `json:"name"` Name string `json:"name"`
DownloadUrl string `json:"browser_download_url"` DownloadUrl string `json:"browser_download_url"`
Digest string `json:"digest"`
Id int `json:"id"` Id int `json:"id"`
Size int `json:"size"` Size int `json:"size"`
} }

View File

@@ -317,6 +317,9 @@ func getRealIP(r *http.Request) string {
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
return ip return ip
} }
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
if ip := r.Header.Get("X-Forwarded-For"); ip != "" { if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
// X-Forwarded-For can contain a comma-separated list: "client_ip, proxy1, proxy2" // X-Forwarded-For can contain a comma-separated list: "client_ip, proxy1, proxy2"
// Take the first one // Take the first one

View File

@@ -1796,6 +1796,24 @@ func TestGetRealIP(t *testing.T) {
remoteAddr: "127.0.0.1:12345", remoteAddr: "127.0.0.1:12345",
expectedIP: "192.168.1.8", expectedIP: "192.168.1.8",
}, },
{
name: "X-Real-IP header",
headers: map[string]string{"X-Real-IP": "10.8.0.4"},
remoteAddr: "172.21.0.1:12345",
expectedIP: "10.8.0.4",
},
{
name: "X-Real-IP takes precedence over X-Forwarded-For",
headers: map[string]string{"X-Real-IP": "10.8.0.4", "X-Forwarded-For": "10.8.0.5"},
remoteAddr: "172.21.0.1:12345",
expectedIP: "10.8.0.4",
},
{
name: "CF-Connecting-IP takes precedence over X-Real-IP",
headers: map[string]string{"CF-Connecting-IP": "1.2.3.4", "X-Real-IP": "10.8.0.4"},
remoteAddr: "172.21.0.1:12345",
expectedIP: "1.2.3.4",
},
} }
for _, tc := range testCases { for _, tc := range testCases {

View File

@@ -3,6 +3,7 @@ package hub
import ( import (
"context" "context"
"net/http" "net/http"
"regexp"
"strings" "strings"
"time" "time"
@@ -13,6 +14,7 @@ import (
"github.com/henrygd/beszel/internal/ghupdate" "github.com/henrygd/beszel/internal/ghupdate"
"github.com/henrygd/beszel/internal/hub/config" "github.com/henrygd/beszel/internal/hub/config"
"github.com/henrygd/beszel/internal/hub/systems" "github.com/henrygd/beszel/internal/hub/systems"
"github.com/henrygd/beszel/internal/hub/utils"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
@@ -25,6 +27,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 +61,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()
} }
@@ -43,13 +71,13 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
return e.Next() return e.Next()
} }
// authenticate with trusted header // authenticate with trusted header
if autoLogin, _ := GetEnv("AUTO_LOGIN"); autoLogin != "" { if autoLogin, _ := utils.GetEnv("AUTO_LOGIN"); autoLogin != "" {
se.Router.BindFunc(func(e *core.RequestEvent) error { se.Router.BindFunc(func(e *core.RequestEvent) error {
return authorizeRequestWithEmail(e, autoLogin) return authorizeRequestWithEmail(e, autoLogin)
}) })
} }
// authenticate with trusted header // authenticate with trusted header
if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" { if trustedHeader, _ := utils.GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
se.Router.BindFunc(func(e *core.RequestEvent) error { se.Router.BindFunc(func(e *core.RequestEvent) error {
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader)) return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
}) })
@@ -77,30 +105,30 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
apiAuth.GET("/info", h.getInfo) apiAuth.GET("/info", h.getInfo)
apiAuth.GET("/getkey", h.getInfo) // deprecated - keep for compatibility w/ integrations apiAuth.GET("/getkey", h.getInfo) // deprecated - keep for compatibility w/ integrations
// check for updates // check for updates
if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" { if optIn, _ := utils.GetEnv("CHECK_UPDATES"); optIn == "true" {
var updateInfo UpdateInfo var updateInfo UpdateInfo
apiAuth.GET("/update", updateInfo.getUpdate) apiAuth.GET("/update", updateInfo.getUpdate)
} }
// 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
if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" { if enabled, _ := utils.GetEnv("CONTAINER_DETAILS"); enabled != "false" {
// get container logs // get container logs
apiAuth.GET("/containers/logs", h.getContainerLogs) apiAuth.GET("/containers/logs", h.getContainerLogs)
// get container info // get container info
@@ -120,7 +148,7 @@ func (h *Hub) getInfo(e *core.RequestEvent) error {
Key: h.pubKey, Key: h.pubKey,
Version: beszel.Version, Version: beszel.Version,
} }
if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" { if optIn, _ := utils.GetEnv("CHECK_UPDATES"); optIn == "true" {
info.CheckUpdate = true info.CheckUpdate = true
} }
return e.JSON(http.StatusOK, info) return e.JSON(http.StatusOK, info)
@@ -153,6 +181,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 +278,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 +295,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 +311,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) {
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 +348,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) {
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 +375,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) {
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},
@@ -65,31 +66,6 @@ func TestApiRoutesAuthentication(t *testing.T) {
scenarios := []beszelTests.ApiScenario{ scenarios := []beszelTests.ApiScenario{
// Auth Protected Routes - Should require authentication // Auth Protected Routes - Should require authentication
{
Name: "POST /test-notification - no auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
},
{
Name: "POST /test-notification - with auth should succeed",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": userToken,
},
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"sending message"},
},
{ {
Name: "GET /config-yaml - no auth should fail", Name: "GET /config-yaml - no auth should fail",
Method: http.MethodGet, Method: http.MethodGet,
@@ -106,7 +82,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 +112,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 +134,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 +178,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 +317,59 @@ 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/info - SHARE_ALL_SYSTEMS allows non-member user",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/containers/info?system=%s&container=abababababab", system.Id),
ExpectedStatus: 500,
ExpectedContent: []string{"Something went wrong while processing your request."},
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": user2Token,
},
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
t.Setenv("SHARE_ALL_SYSTEMS", "true")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
t.Setenv("SHARE_ALL_SYSTEMS", "")
},
},
{ {
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 +380,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 +391,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 +402,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 +413,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 +424,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
{ {
@@ -434,13 +622,17 @@ func TestApiRoutesAuthentication(t *testing.T) {
"systems": []string{system.Id}, "systems": []string{system.Id},
}), }),
}, },
{ // this works but diff behavior on prod vs dev.
Name: "GET /update - shouldn't exist without CHECK_UPDATES env var", // dev returns 502; prod returns 200 with static html page 404
Method: http.MethodGet, // TODO: align dev and prod behavior and re-enable this test
URL: "/api/beszel/update", // {
ExpectedStatus: 502, // Name: "GET /update - shouldn't exist without CHECK_UPDATES env var",
TestAppFactory: testAppFactory, // Method: http.MethodGet,
}, // URL: "/api/beszel/update",
// NotExpectedContent: []string{"v:", "\"v\":"},
// ExpectedStatus: 502,
// TestAppFactory: testAppFactory,
// },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {

View File

@@ -1,6 +1,9 @@
package hub package hub
import "github.com/pocketbase/pocketbase/core" import (
"github.com/henrygd/beszel/internal/hub/utils"
"github.com/pocketbase/pocketbase/core"
)
type collectionRules struct { type collectionRules struct {
list *string list *string
@@ -22,11 +25,11 @@ func setCollectionAuthSettings(app core.App) error {
} }
// disable email auth if DISABLE_PASSWORD_AUTH env var is set // disable email auth if DISABLE_PASSWORD_AUTH env var is set
disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH") disablePasswordAuth, _ := utils.GetEnv("DISABLE_PASSWORD_AUTH")
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true" usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
usersCollection.PasswordAuth.IdentityFields = []string{"email"} usersCollection.PasswordAuth.IdentityFields = []string{"email"}
// allow oauth user creation if USER_CREATION is set // allow oauth user creation if USER_CREATION is set
if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" { if userCreation, _ := utils.GetEnv("USER_CREATION"); userCreation == "true" {
cr := "@request.context = 'oauth2'" cr := "@request.context = 'oauth2'"
usersCollection.CreateRule = &cr usersCollection.CreateRule = &cr
} else { } else {
@@ -34,7 +37,7 @@ func setCollectionAuthSettings(app core.App) error {
} }
// enable mfaOtp mfa if MFA_OTP env var is set // enable mfaOtp mfa if MFA_OTP env var is set
mfaOtp, _ := GetEnv("MFA_OTP") mfaOtp, _ := utils.GetEnv("MFA_OTP")
usersCollection.OTP.Length = 6 usersCollection.OTP.Length = 6
superusersCollection.OTP.Length = 6 superusersCollection.OTP.Length = 6
usersCollection.OTP.Enabled = mfaOtp == "true" usersCollection.OTP.Enabled = mfaOtp == "true"
@@ -50,7 +53,7 @@ func setCollectionAuthSettings(app core.App) error {
// When SHARE_ALL_SYSTEMS is enabled, any authenticated user can read // When SHARE_ALL_SYSTEMS is enabled, any authenticated user can read
// system-scoped data. Write rules continue to block readonly users. // system-scoped data. Write rules continue to block readonly users.
shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS") shareAllSystems, _ := utils.GetEnv("SHARE_ALL_SYSTEMS")
authenticatedRule := "@request.auth.id != \"\"" authenticatedRule := "@request.auth.id != \"\""
systemsMemberRule := authenticatedRule + " && users.id ?= @request.auth.id" systemsMemberRule := authenticatedRule + " && users.id ?= @request.auth.id"

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

@@ -64,7 +64,7 @@ func createConfigTestFingerprint(app core.App, systemID, token, fingerprint stri
// TestConfigSyncWithTokens tests the config.SyncSystems function with various token scenarios // TestConfigSyncWithTokens tests the config.SyncSystems function with various token scenarios
func TestConfigSyncWithTokens(t *testing.T) { func TestConfigSyncWithTokens(t *testing.T) {
testHub, err := tests.NewTestHub() testHub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err) require.NoError(t, err)
defer testHub.Cleanup() defer testHub.Cleanup()

View File

@@ -9,13 +9,13 @@ import (
"net/url" "net/url"
"os" "os"
"path" "path"
"regexp"
"strings" "strings"
"github.com/henrygd/beszel/internal/alerts" "github.com/henrygd/beszel/internal/alerts"
"github.com/henrygd/beszel/internal/hub/config" "github.com/henrygd/beszel/internal/hub/config"
"github.com/henrygd/beszel/internal/hub/heartbeat" "github.com/henrygd/beszel/internal/hub/heartbeat"
"github.com/henrygd/beszel/internal/hub/systems" "github.com/henrygd/beszel/internal/hub/systems"
"github.com/henrygd/beszel/internal/hub/utils"
"github.com/henrygd/beszel/internal/records" "github.com/henrygd/beszel/internal/records"
"github.com/henrygd/beszel/internal/users" "github.com/henrygd/beszel/internal/users"
@@ -38,8 +38,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}
@@ -47,7 +45,7 @@ func NewHub(app core.App) *Hub {
hub.um = users.NewUserManager(hub) hub.um = users.NewUserManager(hub)
hub.rm = records.NewRecordManager(hub) hub.rm = records.NewRecordManager(hub)
hub.sm = systems.NewSystemManager(hub) hub.sm = systems.NewSystemManager(hub)
hub.hb = heartbeat.New(app, GetEnv) hub.hb = heartbeat.New(app, utils.GetEnv)
if hub.hb != nil { if hub.hb != nil {
hub.hbStop = make(chan struct{}) hub.hbStop = make(chan struct{})
} }
@@ -55,15 +53,6 @@ func NewHub(app core.App) *Hub {
return hub return hub
} }
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
func GetEnv(key string) (value string, exists bool) {
if value, exists = os.LookupEnv("BESZEL_HUB_" + key); exists {
return value, exists
}
// Fallback to the old unprefixed key
return os.LookupEnv(key)
}
// onAfterBootstrapAndMigrations ensures the provided function runs after the database is set up and migrations are applied. // onAfterBootstrapAndMigrations ensures the provided function runs after the database is set up and migrations are applied.
// This is a workaround for behavior in PocketBase where onBootstrap runs before migrations, forcing use of onServe for this purpose. // This is a workaround for behavior in PocketBase where onBootstrap runs before migrations, forcing use of onServe for this purpose.
// However, PB's tests.TestApp is already bootstrapped, generally doesn't serve, but does handle migrations. // However, PB's tests.TestApp is already bootstrapped, generally doesn't serve, but does handle migrations.
@@ -134,7 +123,7 @@ func (h *Hub) initialize(app core.App) error {
// batch requests (for alerts) // batch requests (for alerts)
settings.Batch.Enabled = true settings.Batch.Enabled = true
// set URL if APP_URL env is set // set URL if APP_URL env is set
if appURL, isSet := GetEnv("APP_URL"); isSet { if appURL, isSet := utils.GetEnv("APP_URL"); isSet {
h.appURL = appURL h.appURL = appURL
settings.Meta.AppURL = appURL settings.Meta.AppURL = appURL
} }

42
internal/hub/server.go Normal file
View File

@@ -0,0 +1,42 @@
package hub
import (
"encoding/json"
"net/url"
"strings"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/hub/utils"
)
// PublicAppInfo defines the structure of the public app information that will be injected into the HTML
type PublicAppInfo struct {
BASE_PATH string
HUB_VERSION string
HUB_URL string
OAUTH_DISABLE_POPUP bool `json:"OAUTH_DISABLE_POPUP,omitempty"`
}
// modifyIndexHTML injects the public app information into the index.html content
func modifyIndexHTML(hub *Hub, html []byte) string {
info := getPublicAppInfo(hub)
content, err := json.Marshal(info)
if err != nil {
return string(html)
}
htmlContent := strings.ReplaceAll(string(html), "./", info.BASE_PATH)
return strings.Replace(htmlContent, "\"{info}\"", string(content), 1)
}
func getPublicAppInfo(hub *Hub) PublicAppInfo {
parsedURL, _ := url.Parse(hub.appURL)
info := PublicAppInfo{
BASE_PATH: strings.TrimSuffix(parsedURL.Path, "/") + "/",
HUB_VERSION: beszel.Version,
HUB_URL: hub.appURL,
}
if val, _ := utils.GetEnv("OAUTH_DISABLE_POPUP"); val == "true" {
info.OAUTH_DISABLE_POPUP = true
}
return info
}

View File

@@ -5,14 +5,11 @@ package hub
import ( import (
"fmt" "fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"strings" "strings"
"github.com/henrygd/beszel"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/osutils" "github.com/pocketbase/pocketbase/tools/osutils"
) )
@@ -39,7 +36,7 @@ func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error)
} }
resp.Body.Close() resp.Body.Close()
// Create a new response with the modified body // Create a new response with the modified body
modifiedBody := rm.modifyHTML(string(body)) modifiedBody := modifyIndexHTML(rm.hub, body)
resp.Body = io.NopCloser(strings.NewReader(modifiedBody)) resp.Body = io.NopCloser(strings.NewReader(modifiedBody))
resp.ContentLength = int64(len(modifiedBody)) resp.ContentLength = int64(len(modifiedBody))
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody))) resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody)))
@@ -47,22 +44,8 @@ func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error)
return resp, nil return resp, nil
} }
func (rm *responseModifier) modifyHTML(html string) string {
parsedURL, err := url.Parse(rm.hub.appURL)
if err != nil {
return html
}
// fix base paths in html if using subpath
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
html = strings.ReplaceAll(html, "./", basePath)
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
html = strings.Replace(html, "{{HUB_URL}}", rm.hub.appURL, 1)
return html
}
// 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

@@ -5,10 +5,9 @@ package hub
import ( import (
"io/fs" "io/fs"
"net/http" "net/http"
"net/url"
"strings" "strings"
"github.com/henrygd/beszel" "github.com/henrygd/beszel/internal/hub/utils"
"github.com/henrygd/beszel/internal/site" "github.com/henrygd/beszel/internal/site"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
@@ -17,22 +16,13 @@ import (
// startServer sets up the production server for Beszel // startServer sets up the production server for Beszel
func (h *Hub) startServer(se *core.ServeEvent) error { func (h *Hub) startServer(se *core.ServeEvent) error {
// parse app url
parsedURL, err := url.Parse(h.appURL)
if err != nil {
return err
}
// fix base paths in html if using subpath
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html") indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
html := strings.ReplaceAll(string(indexFile), "./", basePath) html := modifyIndexHTML(h, indexFile)
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
html = strings.Replace(html, "{{HUB_URL}}", h.appURL, 1)
// set up static asset serving // set up static asset serving
staticPaths := [2]string{"/static/", "/assets/"} staticPaths := [2]string{"/static/", "/assets/"}
serveStatic := apis.Static(site.DistDirFS, false) serveStatic := apis.Static(site.DistDirFS, false)
// get CSP configuration // get CSP configuration
csp, cspExists := GetEnv("CSP") csp, cspExists := utils.GetEnv("CSP")
// add route // add route
se.Router.GET("/{path...}", func(e *core.RequestEvent) error { se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
// serve static assets if path is in staticPaths // serve static assets if path is in staticPaths

View File

@@ -0,0 +1,56 @@
//go:build testing
package systems
import (
"errors"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/assert"
)
// TestRunWithTimeout covers the guard added for issue #2041: the per-system SSH
// data exchange must never block the updater indefinitely on a dead connection.
func TestRunWithTimeout(t *testing.T) {
t.Run("returns the operation result when it completes before the timeout", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
wantErr := errors.New("boom")
onTimeoutCalled := false
retry, err := runWithTimeout(10*time.Second, func() (bool, error) {
return true, wantErr
}, func() { onTimeoutCalled = true })
assert.True(t, retry, "should return the operation's retry value")
assert.Equal(t, wantErr, err, "should return the operation's error")
assert.False(t, onTimeoutCalled, "onTimeout must not fire when the op completes")
})
})
t.Run("times out and tears down the connection when the op blocks", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// unblock simulates a half-open connection: the op is stuck reading a
// response that never arrives until the connection is torn down.
unblock := make(chan struct{})
onTimeoutCalled := false
start := time.Now()
retry, err := runWithTimeout(5*time.Second, func() (bool, error) {
<-unblock
return false, nil
}, func() {
onTimeoutCalled = true
close(unblock) // tearing down the connection releases the blocked read
})
assert.Equal(t, 5*time.Second, time.Since(start), "should return exactly at the timeout")
assert.True(t, retry, "a timeout should be retryable so the next tick re-dials")
assert.Error(t, err, "a timeout must surface an error so the system is set down")
assert.True(t, onTimeoutCalled, "onTimeout must fire so the dead connection is closed")
synctest.Wait() // ensure the released op goroutine exits cleanly
})
})
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/hub/transport" "github.com/henrygd/beszel/internal/hub/transport"
"github.com/henrygd/beszel/internal/hub/utils"
"github.com/henrygd/beszel/internal/hub/ws" "github.com/henrygd/beszel/internal/hub/ws"
"github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/container"
@@ -55,7 +56,7 @@ func (sm *SystemManager) NewSystem(systemId string) *System {
Id: systemId, Id: systemId,
data: &system.CombinedData{}, data: &system.CombinedData{},
} }
system.ctx, system.cancel = system.getContext() system.ctx, system.cancel = system.getContext(sm.ctx)
return system return system
} }
@@ -78,7 +79,10 @@ func (sys *System) StartUpdater() {
} else { } else {
// if the system does not have a websocket connection, wait before updating // if the system does not have a websocket connection, wait before updating
// to allow the agent to connect via websocket (makes sure fingerprint is set). // to allow the agent to connect via websocket (makes sure fingerprint is set).
time.Sleep(11 * time.Second) if !waitForContext(sys.ctx, 11*time.Second) {
return
}
} }
// update immediately if system is not paused (only for ws connections) // update immediately if system is not paused (only for ws connections)
@@ -184,7 +188,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,36 +347,67 @@ 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)
if err == nil {
err = fmt.Errorf("system record %s not found", sys.Id)
}
return nil, err return nil, err
} }
return record, nil return record, nil
} }
// HasUser checks if the given user is in the system's users list.
// Returns true if SHARE_ALL_SYSTEMS is enabled (any authenticated user can access any system).
func (sys *System) HasUser(app core.App, user *core.Record) bool {
if user == nil {
return false
}
if v, _ := utils.GetEnv("SHARE_ALL_SYSTEMS"); v == "true" {
return true
}
var recordData = struct {
Users string
}{}
err := app.DB().NewQuery("SELECT users FROM systems WHERE id={:id}").
Bind(dbx.Params{"id": sys.Id}).
One(&recordData)
if err != nil || recordData.Users == "" {
return false
}
return strings.Contains(recordData.Users, user.Id)
}
// 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.
// It is a no-op if the system's context has been cancelled.
func (sys *System) setDown(originalError error) error { 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() // the updater can race shutdown, and the app may already be disposed by the
// time we get here, so don't touch the database once the context is cancelled
if sys.ctx != nil && sys.ctx.Err() != nil {
return sys.ctx.Err()
}
record, err := sys.getRecord(sys.manager.hub)
if err != nil { if err != nil {
return err return err
} }
if originalError != nil { if originalError != nil {
sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", originalError) sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", originalError)
} }
sys.detailsFetched.Store(false)
record.Set("status", down) record.Set("status", down)
return sys.manager.hub.SaveNoValidate(record) return sys.manager.hub.SaveNoValidate(record)
} }
func (sys *System) getContext() (context.Context, context.CancelFunc) { func (sys *System) getContext(ctx context.Context) (context.Context, context.CancelFunc) {
if sys.ctx == nil { if sys.ctx == nil {
sys.ctx, sys.cancel = context.WithCancel(context.Background()) sys.ctx, sys.cancel = context.WithCancel(ctx)
} }
return sys.ctx, sys.cancel return sys.ctx, sys.cancel
} }
@@ -509,11 +544,16 @@ func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.Servic
return result, err return result, err
} }
// FetchSmartDataFromAgent fetches SMART data from the agent // FetchSmartDataFromAgent fetches SMART data from the agent.
func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error) { func (sys *System) FetchSmartDataFromAgent() (smart.SmartDataResponse, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
var result map[string]smart.SmartData if sys.agentVersion.LT(beszel.MinVersionAgentResponse) {
var data map[string]smart.SmartData
err := sys.request(ctx, common.GetSmartData, nil, &data)
return smart.SmartDataResponse{Data: data}, err
}
var result smart.SmartDataResponse
err := sys.request(ctx, common.GetSmartData, nil, &result) err := sys.request(ctx, common.GetSmartData, nil, &result)
return result, err return result, err
} }
@@ -601,10 +641,17 @@ func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation
continue continue
} }
retry, opErr := func() (bool, error) { // Bound the whole operation. A half-open TCP connection (a dead peer that
// never sends RST/FIN) or a wedged agent that accepts the session but
// never writes a response would otherwise block the read forever. Because
// StartUpdater runs update() synchronously on its ticker, that stalls the
// per-system updater indefinitely with no error and no re-dial until the
// hub is restarted (issue #2041). On timeout we tear down the connection
// so the blocked read unwinds and the system is re-dialed on the next tick.
retry, opErr := runWithTimeout(sshOperationTimeout, func() (bool, error) {
defer session.Close() defer session.Close()
return operation(session) return operation(session)
}() }, sys.closeSSHConnection)
if opErr == nil { if opErr == nil {
return nil return nil
@@ -623,6 +670,43 @@ func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation
return fmt.Errorf("ssh operation failed") return fmt.Errorf("ssh operation failed")
} }
// sshOperationTimeout bounds a single SSH data exchange (send request, read
// response, wait for the remote command to exit). It is more generous than the
// session-creation timeout to tolerate briefly slow agents, but is kept well
// under the collection interval so a stalled connection is detected and
// re-dialed within one cycle (see issue #2041).
const sshOperationTimeout = 20 * time.Second
// runWithTimeout runs op in a goroutine and returns its result, or, if op does
// not finish within timeout, calls onTimeout (used to tear down the connection
// so a blocked op can unwind) and returns a retryable timeout error. This
// guarantees the caller can never block indefinitely on a dead SSH connection.
func runWithTimeout(timeout time.Duration, op func() (bool, error), onTimeout func()) (retry bool, err error) {
type opResult struct {
retry bool
err error
}
// Buffered so the op goroutine never leaks even when we return on timeout.
done := make(chan opResult, 1)
go func() {
r, e := op()
done <- opResult{retry: r, err: e}
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case res := <-done:
return res.retry, res.err
case <-timer.C:
if onTimeout != nil {
onTimeout()
}
return true, fmt.Errorf("ssh operation timed out after %s", timeout)
}
}
// createSSHClient creates a new SSH client for the system // createSSHClient creates a new SSH client for the system
func (s *System) createSSHClient() error { func (s *System) createSSHClient() error {
if s.manager.sshConfig == nil { if s.manager.sshConfig == nil {
@@ -638,7 +722,7 @@ func (s *System) createSSHClient() error {
host = net.JoinHostPort(host, s.Port) host = net.JoinHostPort(host, s.Port)
} }
var err error var err error
s.client, err = ssh.Dial(network, host, s.manager.sshConfig) s.client, err = dialSSHWithKeepAlive(network, host, s.manager.sshConfig)
if err != nil { if err != nil {
return err return err
} }
@@ -647,6 +731,34 @@ func (s *System) createSSHClient() error {
return nil return nil
} }
// sshKeepAliveInterval is the TCP keep-alive idle interval for SSH connections
// to agents. Enabling OS-level keep-alives lets the hub eventually detect a
// dead peer on an otherwise idle connection instead of trusting it forever.
// This is a backstop for genuine network death; an application-level wedge
// (agent process hung while its kernel keeps ACKing) is caught by the
// per-operation timeout in runSSHOperation instead (see issue #2041).
const sshKeepAliveInterval = 30 * time.Second
// dialSSHWithKeepAlive dials an SSH connection like ssh.Dial, but enables TCP
// keep-alive on the underlying connection so half-open connections are
// eventually detected by the operating system.
func dialSSHWithKeepAlive(network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
dialer := net.Dialer{
Timeout: config.Timeout,
KeepAlive: sshKeepAliveInterval,
}
conn, err := dialer.Dial(network, addr)
if err != nil {
return nil, err
}
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
if err != nil {
_ = conn.Close()
return nil, err
}
return ssh.NewClient(sshConn, chans, reqs), nil
}
// createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging // createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging
// in case of network issues // in case of network issues
func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) { func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) {

View File

@@ -1,6 +1,7 @@
package systems package systems
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"time" "time"
@@ -45,6 +46,8 @@ type SystemManager struct {
systems *store.Store[string, *System] // Thread-safe store of active systems systems *store.Store[string, *System] // Thread-safe store of active systems
sshConfig *ssh.ClientConfig // SSH client configuration for system connections sshConfig *ssh.ClientConfig // SSH client configuration for system connections
smartFetchMap *expirymap.ExpiryMap[smartFetchState] // Stores last SMART fetch time/result; TTL is only for cleanup smartFetchMap *expirymap.ExpiryMap[smartFetchState] // Stores last SMART fetch time/result; TTL is only for cleanup
ctx context.Context // Cancelled when the app terminates
cancel context.CancelFunc // Cancels ctx and all child system contexts
} }
// hubLike defines the interface requirements for the hub dependency. // hubLike defines the interface requirements for the hub dependency.
@@ -54,16 +57,19 @@ 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.
// The hub must implement the hubLike interface to provide database and alert functionality. // The hub must implement the hubLike interface to provide database and alert functionality.
func NewSystemManager(hub hubLike) *SystemManager { func NewSystemManager(hub hubLike) *SystemManager {
return &SystemManager{ sm := &SystemManager{
systems: store.New(map[string]*System{}), systems: store.New(map[string]*System{}),
hub: hub, hub: hub,
smartFetchMap: expirymap.New[smartFetchState](time.Hour), smartFetchMap: expirymap.New[smartFetchState](time.Hour),
} }
sm.ctx, sm.cancel = context.WithCancel(context.Background())
return sm
} }
// GetSystem returns a system by ID from the store // GetSystem returns a system by ID from the store
@@ -102,7 +108,9 @@ func (sm *SystemManager) Initialize() error {
sleepTime := time.Duration(delta) * time.Millisecond sleepTime := time.Duration(delta) * time.Millisecond
for _, system := range systems { for _, system := range systems {
time.Sleep(sleepTime) if !waitForContext(sm.ctx, sleepTime) {
return
}
_ = sm.AddSystem(system) _ = sm.AddSystem(system)
} }
}() }()
@@ -120,6 +128,13 @@ func (sm *SystemManager) bindEventHooks() {
sm.hub.OnRecordAfterUpdateSuccess("fingerprints").BindFunc(sm.onTokenRotated) sm.hub.OnRecordAfterUpdateSuccess("fingerprints").BindFunc(sm.onTokenRotated)
sm.hub.OnRealtimeSubscribeRequest().BindFunc(sm.onRealtimeSubscribeRequest) sm.hub.OnRealtimeSubscribeRequest().BindFunc(sm.onRealtimeSubscribeRequest)
sm.hub.OnRealtimeConnectRequest().BindFunc(sm.onRealtimeConnectRequest) sm.hub.OnRealtimeConnectRequest().BindFunc(sm.onRealtimeConnectRequest)
sm.hub.OnTerminate().BindFunc(sm.onTerminate)
}
// onTerminate cancels SystemManager context on app shutdown
func (sm *SystemManager) onTerminate(e *core.TerminateEvent) error {
sm.cancel()
return e.Next()
} }
// onTokenRotated handles fingerprint token rotation events. // onTokenRotated handles fingerprint token rotation events.
@@ -189,6 +204,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
@@ -245,7 +261,7 @@ func (sm *SystemManager) AddSystem(sys *System) error {
// Initialize system for monitoring // Initialize system for monitoring
sys.manager = sm sys.manager = sm
sys.ctx, sys.cancel = sys.getContext() sys.ctx, sys.cancel = sys.getContext(sm.ctx)
sys.data = &system.CombinedData{} sys.data = &system.CombinedData{}
sm.systems.Set(sys.Id, sys) sm.systems.Set(sys.Id, sys)
@@ -370,3 +386,15 @@ func deactivateAlerts(app core.App, systemID string) error {
} }
return nil return nil
} }
// waitForContext waits for delay or returns early when ctx is cancelled.
func waitForContext(ctx context.Context, delay time.Duration) bool {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}

View File

@@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/henrygd/beszel/internal/entities/smart" "github.com/henrygd/beszel/internal/entities/smart"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
) )
@@ -17,13 +18,13 @@ type smartFetchState struct {
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database // FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
func (sys *System) FetchAndSaveSmartDevices() error { func (sys *System) FetchAndSaveSmartDevices() error {
smartData, err := sys.FetchSmartDataFromAgent() response, err := sys.FetchSmartDataFromAgent()
if err != nil { if err != nil {
sys.recordSmartFetchResult(err, 0) sys.recordSmartFetchResult(err, 0)
return err return err
} }
err = sys.saveSmartDevices(smartData) err = sys.saveSmartDevices(response.Data, response.Complete)
sys.recordSmartFetchResult(err, len(smartData)) sys.recordSmartFetchResult(err, len(response.Data))
return err return err
} }
@@ -61,8 +62,9 @@ func (sys *System) smartFetchInterval() time.Duration {
return time.Hour return time.Hour
} }
// saveSmartDevices saves SMART device data to the smart_devices collection // saveSmartDevices saves SMART device data and, after a complete refresh,
func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData) error { // removes rows for devices that are no longer reported.
func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData, complete bool) error {
if len(smartData) == 0 { if len(smartData) == 0 {
return nil return nil
} }
@@ -73,20 +75,49 @@ func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData) error
return err return err
} }
for deviceKey, device := range smartData { currentIDs := make(map[string]struct{}, len(smartData))
if err := sys.upsertSmartDeviceRecord(collection, deviceKey, device); err != nil { for deviceKey := range smartData {
return err currentIDs[makeStableHashId(sys.Id, deviceKey)] = struct{}{}
}
} }
return nil err = hub.RunInTransaction(func(txApp core.App) error {
if complete {
existing, err := txApp.FindRecordsByFilter(
collection,
"system = {:system}",
"",
0,
0,
dbx.Params{"system": sys.Id},
)
if err != nil {
return err
}
for _, record := range existing {
if _, ok := currentIDs[record.Id]; ok {
continue
}
if err := txApp.Delete(record); err != nil {
return err
}
}
}
for deviceKey, device := range smartData {
if err := sys.upsertSmartDeviceRecord(txApp, collection, deviceKey, device); err != nil {
return err
}
}
return nil
})
return err
} }
func (sys *System) upsertSmartDeviceRecord(collection *core.Collection, deviceKey string, device smart.SmartData) error { func (sys *System) upsertSmartDeviceRecord(app core.App, collection *core.Collection, deviceKey string, device smart.SmartData) error {
hub := sys.manager.hub
recordID := makeStableHashId(sys.Id, deviceKey) recordID := makeStableHashId(sys.Id, deviceKey)
record, err := hub.FindRecordById(collection, recordID) record, err := app.FindRecordById(collection, recordID)
if err != nil { if err != nil {
if !errors.Is(err, sql.ErrNoRows) { if !errors.Is(err, sql.ErrNoRows) {
return err return err
@@ -114,7 +145,7 @@ func (sys *System) upsertSmartDeviceRecord(collection *core.Collection, deviceKe
record.Set("cycles", powerCycles) record.Set("cycles", powerCycles)
record.Set("attributes", device.Attributes) record.Set("attributes", device.Attributes)
return hub.SaveNoValidate(record) return app.SaveNoValidate(record)
} }
// extractPowerMetrics extracts power on hours and power cycles from SMART attributes // extractPowerMetrics extracts power on hours and power cycles from SMART attributes

View File

@@ -7,10 +7,53 @@ import (
"testing" "testing"
"time" "time"
"github.com/henrygd/beszel/internal/entities/smart"
esystem "github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/hub/expirymap" "github.com/henrygd/beszel/internal/hub/expirymap"
_ "github.com/henrygd/beszel/internal/migrations"
"github.com/pocketbase/pocketbase/core"
pbtests "github.com/pocketbase/pocketbase/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
) )
// stubHub implements hubLike using a plain pocketbase test app, so
// smart-device DB tests can run in-package without an import cycle to
// internal/hub (which imports this package).
type stubHub struct{ core.App }
func (stubHub) GetSSHKey(dataDir string) (ssh.Signer, error) { return nil, nil }
func (stubHub) HandleSystemAlerts(systemRecord *core.Record, data *esystem.CombinedData) error {
return nil
}
func (stubHub) HandleStatusAlerts(status string, systemRecord *core.Record) error { return nil }
func (stubHub) CancelPendingStatusAlerts(systemID string) {}
// newTestSystemWithHub creates a System backed by a real (temp) database, along
// with a matching "systems" record, for tests that need to exercise DB reads/writes.
func newTestSystemWithHub(t *testing.T) (*System, *pbtests.TestApp) {
t.Helper()
testApp, err := pbtests.NewTestApp(t.TempDir())
require.NoError(t, err)
t.Cleanup(testApp.Cleanup)
sm := &SystemManager{hub: stubHub{testApp}, smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
t.Cleanup(sm.smartFetchMap.StopCleaner)
col, err := testApp.FindCachedCollectionByNameOrId("systems")
require.NoError(t, err)
systemRecord := core.NewRecord(col)
systemRecord.Set("name", "test-system")
systemRecord.Set("host", "127.0.0.1")
systemRecord.Set("port", "45876")
require.NoError(t, testApp.SaveNoValidate(systemRecord))
sys := &System{Id: systemRecord.Id, manager: sm}
return sys, testApp
}
func TestRecordSmartFetchResult(t *testing.T) { func TestRecordSmartFetchResult(t *testing.T) {
sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)} sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
t.Cleanup(sm.smartFetchMap.StopCleaner) t.Cleanup(sm.smartFetchMap.StopCleaner)
@@ -92,3 +135,95 @@ func TestResetFailedSmartFetchState(t *testing.T) {
_, ok = sm.smartFetchMap.GetOk("system-1") _, ok = sm.smartFetchMap.GetOk("system-1")
assert.True(t, ok, "expected successful smart fetch state to be preserved") assert.True(t, ok, "expected successful smart fetch state to be preserved")
} }
// countSmartDeviceRecords returns the number of smart_devices rows for the given system.
func countSmartDeviceRecords(t *testing.T, app core.App, systemID string) []*core.Record {
t.Helper()
records, err := app.FindAllRecords("smart_devices", nil)
require.NoError(t, err)
var forSystem []*core.Record
for _, r := range records {
if r.GetString("system") == systemID {
forSystem = append(forSystem, r)
}
}
return forSystem
}
func TestSaveSmartDevices_RemovesStaleDevices(t *testing.T) {
sys, testApp := newTestSystemWithHub(t)
// first fetch reports two devices: sda (serial AAA) and sdb (serial BBB)
err := sys.saveSmartDevices(map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA", DiskName: "sda", ModelName: "Disk A"},
"BBB": {SerialNumber: "BBB", DiskName: "sdb", ModelName: "Disk B"},
}, true)
require.NoError(t, err)
records := countSmartDeviceRecords(t, testApp, sys.Id)
require.Len(t, records, 2, "expected both devices to be saved")
var recordA *core.Record
for _, r := range records {
if r.GetString("serial") == "AAA" {
recordA = r
}
}
require.NotNil(t, recordA, "expected to find device AAA")
originalID := recordA.Id
deleteEvents := 0
testApp.OnRecordAfterDeleteSuccess("smart_devices").BindFunc(func(e *core.RecordEvent) error {
deleteEvents++
return e.Next()
})
// A complete refresh confirms that BBB is gone, so remove it through
// PocketBase and notify realtime subscribers.
err = sys.saveSmartDevices(map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA", DiskName: "sda", ModelName: "Disk A", Temperature: 42},
}, true)
require.NoError(t, err)
records = countSmartDeviceRecords(t, testApp, sys.Id)
require.Len(t, records, 1, "expected stale device BBB to be removed")
assert.Equal(t, "AAA", records[0].GetString("serial"))
assert.Equal(t, originalID, records[0].Id, "expected existing device to be updated in place, not recreated")
assert.EqualValues(t, 42, records[0].GetInt("temp"))
assert.Equal(t, 1, deleteEvents, "expected PocketBase delete hooks to run")
}
func TestSaveSmartDevices_IncompleteDataDoesNotRemoveDevices(t *testing.T) {
sys, testApp := newTestSystemWithHub(t)
require.NoError(t, sys.saveSmartDevices(map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA", DiskName: "sda"},
"BBB": {SerialNumber: "BBB", DiskName: "sdb"},
}, true))
// AAA was collected but BBB failed. The response is useful for updating AAA,
// but it is not authoritative enough to remove BBB.
require.NoError(t, sys.saveSmartDevices(map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA", DiskName: "sda", Temperature: 42},
}, false))
assert.Len(t, countSmartDeviceRecords(t, testApp, sys.Id), 2)
recordA, err := testApp.FindRecordById("smart_devices", makeStableHashId(sys.Id, "AAA"))
require.NoError(t, err)
assert.EqualValues(t, 42, recordA.GetInt("temp"))
}
func TestSaveSmartDevices_EmptyDataIsNoop(t *testing.T) {
sys, testApp := newTestSystemWithHub(t)
err := sys.saveSmartDevices(map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA", DiskName: "sda"},
}, true)
require.NoError(t, err)
err = sys.saveSmartDevices(map[string]smart.SmartData{}, true)
require.NoError(t, err)
records := countSmartDeviceRecords(t, testApp, sys.Id)
assert.Len(t, records, 1, "empty fetch result should not delete existing devices")
}

Some files were not shown because too many files have changed in this diff Show More