Compare commits

...

13 Commits

Author SHA1 Message Date
henrygd
98687be2f2 fix(hub): reject null systemd service entries to prevent hub panic 2026-09-08 10:08:33 -04:00
henrygd
e39e153ca0 fix(hub): prevent readonly users listing tokens of their shared systems via api 2026-09-08 09:57:58 -04:00
Santhi Prakash
5b87f7d7cb fix(hub): always clear auth store when encountring 4xx after token expires (#2310)
Co-authored-by: henrygd <hank@henrygd.me>
2026-09-08 08:21:00 -04:00
hank
9a0aa5a89e fix: prevent possible dns rebinding for alert notifications directing to internal sevices (#2314)
- Validate resolved IPs immediately before connecting.
- Guard Shoutrrr requests during initialization and delivery.
- Require admins for services without HTTP client support.
- Test DNS rebinding, redirects, internal hosts, and authorization.
- Upgrade Shoutrrr to 0.20.0 to support custom dialcontext
2026-09-08 08:05:47 -04:00
henrygd
997adc19bb fix: show idle GPU utilization in systems table (#2312) 2026-09-07 13:13:05 -04:00
Sven van Ginkel
08d813620c Add ZFS pool/dataset utilities (zpool, zfs) to the full and slim nvidia agent images (#2311) 2026-09-07 11:05:02 -04:00
henrygd
6cb302fcf6 fix(hub): bound realtime metric fetching and enforce access control by system
- synchronize realtime subscription state
- prevent overlapping fetches per system
- add realtime worker lifecycle tests
- reject subscriptions without system access
- test access revocation and shared-system permissions
2026-09-06 13:24:53 -04:00
Santhi Prakash
59eed073c3 fix(install): add beszel to operator group on FreeBSD for SMART device access (#2286)
On FreeBSD the beszel agent needs to be a member of the operator group
to access SMART-capable devices via smartctl (e.g. /dev/nvme*,
/dev/xpt0*). The wheel group alone does not grant that access, so the
install script now adds the beszel user to operator when that group
exists, mirroring the Linux disk-group handling.

Fixes #1431

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-06 11:34:03 -04:00
hank
266a74bab8 Update security policy for vulnerability reporting 2026-09-06 10:16:40 -04:00
Matthew D'Souza
ad24484caa feat(site): display NVMe data units as human-readable GB/TB (#2303) 2026-09-05 14:44:03 -04:00
Ryan Chou
027d0c204d fix(agent): extend WebSocket deadline for slow collections (#2297)
The agent resets its WebSocket deadline to 70s, but the hub's default
collection interval is 60s, so a single slow collection cycle is enough to
trip the deadline and start a reconnect loop even though the hub is still
serving the agent. Raise the deadline to 120s and add a regression test that
keeps the slow-collection window from being lowered below two minutes.

Verified with go test -tags=testing ./agent (focused tests and the full agent
suite minus the container-only TestDirectoryIsWritable case), go vet, the
agent build and gofmt.

Closes #2294
2026-09-05 13:09:25 -04:00
henrygd
46d94a9804 fix: improve agent installer error handling (#1971, #1972)
- Enable set -eu and handle expected failures explicitly
- Validate platform and service manager before making changes
- Fix argument parsing, version fallback, cron setup, and prompt EOF
- Verify downloads before stopping the existing agent
- Add atomic binary replacement, rollback, and cleanup
- Add regression tests for installation failure paths
2026-09-05 13:06:34 -04:00
Elan Ruusamäe
82fc772882 Fix: Enable immediate exit on errors in install-agent.sh (#1972)
Add error handling to ensure script exits on errors.
2026-09-05 13:06:34 -04:00
27 changed files with 1286 additions and 285 deletions

View File

@@ -2,6 +2,8 @@
## Reporting a Vulnerability ## Reporting a Vulnerability
If you find a vulnerability in the latest version, please [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new). **PLEASE ONLY USE SECURITY ADVISORIES FOR REAL HIGH SEVERITY VULNERABILITIES.**
If it's low severity (use best judgement) you may open an issue instead of an advisory. If you find a vulnerability in the latest version, and it is not high severity, open an issue instead of an advisory.
I am overwhelmed with advisories, often erroneous, which are clearly found and written by AI. I don't have the capacity to review all of them.

View File

@@ -25,7 +25,9 @@ import (
) )
const ( const (
wsDeadline = 70 * time.Second // Keep the connection alive long enough for a slow collection cycle to
// finish before the hub considers the agent disconnected.
wsDeadline = 120 * time.Second
) )
type caCertFileError struct { type caCertFileError struct {

View File

@@ -700,3 +700,11 @@ func TestGetToken(t *testing.T) {
assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content") assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content")
}) })
} }
func TestWebSocketDeadlineCoversSlowCollection(t *testing.T) {
const minimumDeadline = 120 * time.Second
if wsDeadline < minimumDeadline {
t.Fatalf("WebSocket deadline %s is shorter than the slow-collection window of %s", wsDeadline, minimumDeadline)
}
}

2
go.mod
View File

@@ -10,7 +10,7 @@ require (
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.10.1 github.com/lxzan/gws v1.10.1
github.com/nicholas-fedor/shoutrrr v0.19.0 github.com/nicholas-fedor/shoutrrr v0.20.0
github.com/pocketbase/dbx v1.12.0 github.com/pocketbase/dbx v1.12.0
github.com/pocketbase/pocketbase v0.40.2 github.com/pocketbase/pocketbase v0.40.2
github.com/shirou/gopsutil/v4 v4.26.8 github.com/shirou/gopsutil/v4 v4.26.8

8
go.sum
View File

@@ -54,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-20260902005441-ca85771921e4 h1:/6mPXfWmhv8eKck12I0YNIcIjwHtxP3YRIMKiEgTjWg= github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe h1:QAinXoAFJdGQYztXn3VpFey7KCwpedbZ/EkzbplQ0cY=
github.com/google/pprof v0.0.0-20260902005441-ca85771921e4/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe/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=
@@ -83,8 +83,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= 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.19.0 h1:Rl6bpK3DXuR2Trtx2JV8t+wjUwkHdRHrc8nBKoEpHr0= github.com/nicholas-fedor/shoutrrr v0.20.0 h1:hMAxIYlfAeZ1FcTDgU0kUOvVXUsOirWo8IWlnzGLkac=
github.com/nicholas-fedor/shoutrrr v0.19.0/go.mod h1:Glfdi8AGTbnEn2k2+hW62n8oL0i9vqRVFtXaUIthNks= github.com/nicholas-fedor/shoutrrr v0.20.0/go.mod h1:hgde37yNWCXh8+N6WemyDRMNYLOFTf326GsBx8Z7CFA=
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw= github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU= github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU=

View File

@@ -20,10 +20,10 @@ type hubLike interface {
} }
type AlertManager struct { type AlertManager struct {
hub hubLike hub hubLike
stopOnce sync.Once stopOnce sync.Once
pendingAlerts sync.Map pendingAlerts sync.Map
alertsCache *AlertsCache alertsCache *AlertsCache
} }
type AlertMessageData struct { type AlertMessageData struct {
@@ -231,8 +231,20 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
am.hub.Logger().Error("Failed to unmarshal user settings", "err", err) am.hub.Logger().Error("Failed to unmarshal user settings", "err", err)
} }
// send alerts via webhooks // send alerts via webhooks
send := sendPublicNotification
if len(userAlertSettings.Webhooks) > 0 {
// Read the owner's current role at delivery time, including for URLs
// saved before an admin was demoted. Never fall back on lookup failure.
owner, err := am.hub.FindRecordById("users", data.UserID)
if err != nil {
return fmt.Errorf("load notification owner: %w", err)
}
if owner.GetString("role") == "admin" {
send = shoutrrr.Send
}
}
for _, webhook := range userAlertSettings.Webhooks { for _, webhook := range userAlertSettings.Webhooks {
if err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText); err != nil { if err := am.sendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText, send); err != nil {
am.hub.Logger().Error("Failed to send shoutrrr alert", "err", err) am.hub.Logger().Error("Failed to send shoutrrr alert", "err", err)
} }
} }
@@ -263,6 +275,10 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
// SendShoutrrrAlert sends an alert via a Shoutrrr URL // SendShoutrrrAlert sends an alert via a Shoutrrr URL
func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error { func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error {
return am.sendShoutrrrAlert(notificationUrl, title, message, link, linkText, shoutrrr.Send)
}
func (am *AlertManager) sendShoutrrrAlert(notificationUrl, title, message, link, linkText string, send func(string, string) error) error {
// Parse the URL // Parse the URL
parsedURL, err := url.Parse(notificationUrl) parsedURL, err := url.Parse(notificationUrl)
if err != nil { if err != nil {
@@ -305,7 +321,7 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
parsedURL.RawQuery = queryParams.Encode() parsedURL.RawQuery = queryParams.Encode()
// log.Println("URL after modification:", parsedURL.String()) // log.Println("URL after modification:", parsedURL.String())
err = shoutrrr.Send(parsedURL.String(), message) err = send(parsedURL.String(), message)
if err == nil { if err == nil {
am.hub.Logger().Info("Sent shoutrrr alert", "title", title) am.hub.Logger().Info("Sent shoutrrr alert", "title", title)

View File

@@ -3,13 +3,11 @@ package alerts
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"net"
"net/http" "net/http"
"net/url"
"slices" "slices"
"strings"
"github.com/henrygd/beszel/internal/hub/utils" "github.com/henrygd/beszel/internal/hub/utils"
"github.com/nicholas-fedor/shoutrrr"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
) )
@@ -147,72 +145,16 @@ func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
if err != nil || data.URL == "" { if err != nil || data.URL == "" {
return e.BadRequestError("URL is required", err) return e.BadRequestError("URL is required", err)
} }
// Only allow admins to send test notifications to internal URLs send := shoutrrr.Send
if !e.Auth.IsSuperuser() && e.Auth.GetString("role") != "admin" { if !e.Auth.IsSuperuser() && e.Auth.GetString("role") != "admin" {
internalURL, err := isInternalURL(data.URL) send = sendPublicNotification
if err != nil { }
return e.BadRequestError(err.Error(), nil) err = am.sendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel", send)
} if errors.Is(err, errInternalDestination) || errors.Is(err, errUnrestrictedService) {
if internalURL { return e.ForbiddenError(err.Error(), nil)
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 { if err != nil {
return e.JSON(200, map[string]string{"err": err.Error()}) return e.JSON(200, map[string]string{"err": err.Error()})
} }
return e.JSON(200, map[string]bool{"err": false}) 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
}
var cgnatNetwork = &net.IPNet{
IP: net.IPv4(100, 64, 0, 0),
Mask: net.CIDRMask(10, 32),
}
func isInternalIP(ip net.IP) bool {
return ip.IsPrivate() ||
ip.IsLoopback() ||
ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() ||
ip.IsMulticast() ||
cgnatNetwork.Contains(ip)
}

View File

@@ -7,10 +7,11 @@ import (
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
"net/http/httptest"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"github.com/henrygd/beszel/internal/alerts"
beszelTests "github.com/henrygd/beszel/internal/tests" beszelTests "github.com/henrygd/beszel/internal/tests"
pbTests "github.com/pocketbase/pocketbase/tests" pbTests "github.com/pocketbase/pocketbase/tests"
@@ -29,43 +30,6 @@ func jsonReader(v any) io.Reader {
return bytes.NewReader(data) 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: "private ipv4", url: "generic://10.0.0.1", internal: true},
{name: "localhost hostname", url: "generic://localhost", internal: true},
{name: "localhost with path", url: "generic+http://localhost/api/v1/postStuff", internal: true},
{name: "loopback with port and path", url: "generic+http://127.0.0.1:8080/api/v1/postStuff", internal: true},
{name: "public hostname", url: "generic+https://beszel.dev/api/v1/postStuff", internal: false},
{name: "cloud metadata ipv4", url: "generic://169.254.169.254", internal: true},
{name: "link-local ipv4", url: "generic://169.254.1.1", internal: true},
{name: "link-local ipv6", url: "generic://[fe80::1]", internal: true},
{name: "mapped link-local ipv4", url: "generic://[::ffff:169.254.169.254]", internal: true},
{name: "cgnat lower boundary", url: "generic://100.64.0.0", internal: true},
{name: "cgnat upper boundary", url: "generic://100.127.255.255", internal: true},
{name: "below cgnat", url: "generic://100.63.255.255", internal: false},
{name: "above cgnat", url: "generic://100.128.0.0", internal: false},
{name: "multicast ipv4", url: "generic://224.0.0.1", internal: true},
{name: "multicast ipv6", url: "generic://[ff02::1]", internal: true},
{name: "public ipv4", url: "generic://8.8.8.8", internal: false},
{name: "public ipv6", url: "generic://[2001:4860:4860::8888]", 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) { func TestUserAlertsApi(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir()) hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup() defer hub.Cleanup()
@@ -457,6 +421,17 @@ func TestSendTestNotification(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t) hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
var delivered atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
delivered.Add(1)
}))
defer server.Close()
localURL := "generic+" + server.URL
readonlyUser, err := beszelTests.CreateUserWithRole(hub, "readonly@example.com", "password123", "readonly")
assert.NoError(t, err)
readonlyToken, err := readonlyUser.NewAuthToken()
assert.NoError(t, err)
userToken, err := user.NewAuthToken() userToken, err := user.NewAuthToken()
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin") adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
@@ -481,11 +456,11 @@ func TestSendTestNotification(t *testing.T) {
ExpectedContent: []string{"requires valid"}, ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{ Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1", "url": localURL,
}), }),
}, },
{ {
Name: "POST /test-notification - with external auth should succeed", Name: "POST /test-notification - invalid service reports error",
Method: http.MethodPost, Method: http.MethodPost,
URL: "/api/beszel/test-notification", URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
@@ -493,7 +468,7 @@ func TestSendTestNotification(t *testing.T) {
"Authorization": userToken, "Authorization": userToken,
}, },
Body: jsonReader(map[string]any{ Body: jsonReader(map[string]any{
"url": "generic://8.8.8.8", "url": "unknown://example.com",
}), }),
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{"\"err\":"}, ExpectedContent: []string{"\"err\":"},
@@ -535,10 +510,10 @@ func TestSendTestNotification(t *testing.T) {
"Authorization": adminUserToken, "Authorization": adminUserToken,
}, },
Body: jsonReader(map[string]any{ Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1", "url": localURL,
}), }),
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{"\"err\":"}, ExpectedContent: []string{"\"err\":false"},
}, },
{ {
Name: "POST /test-notification - internal url with superuser auth should succeed", Name: "POST /test-notification - internal url with superuser auth should succeed",
@@ -549,14 +524,28 @@ func TestSendTestNotification(t *testing.T) {
"Authorization": superuserToken, "Authorization": superuserToken,
}, },
Body: jsonReader(map[string]any{ Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1", "url": localURL,
}), }),
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{"\"err\":"}, ExpectedContent: []string{"\"err\":"},
}, },
} }
for _, url := range []string{localURL, "smtp://user:pass@127.0.0.1/?fromAddress=sender@example.com&toAddresses=recipient@example.com", "mqtt://127.0.0.1/topic"} {
scenarios = append(scenarios, beszelTests.ApiScenario{
Name: "readonly cannot send to " + url,
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{"Authorization": readonlyToken},
Body: jsonReader(map[string]any{"url": url}),
ExpectedStatus: 403,
ExpectedContent: []string{"Only admins"},
})
}
for _, scenario := range scenarios { for _, scenario := range scenarios {
scenario.Test(t) scenario.Test(t)
} }
assert.EqualValues(t, 2, delivered.Load(), "only admin and superuser requests should reach the server")
} }

View File

@@ -100,10 +100,6 @@ func (am *AlertManager) SetAlertTriggered(alert CachedAlertData, triggered bool)
return am.setAlertTriggered(alert, triggered) return am.setAlertTriggered(alert, triggered)
} }
func IsInternalURL(rawURL string) (bool, error) {
return isInternalURL(rawURL)
}
// BuildContainerLogExcerpt exposes buildContainerLogExcerpt for testing. // BuildContainerLogExcerpt exposes buildContainerLogExcerpt for testing.
func BuildContainerLogExcerpt(raw string) string { func BuildContainerLogExcerpt(raw string) string {
return buildContainerLogExcerpt(raw) return buildContainerLogExcerpt(raw)

View File

@@ -0,0 +1,66 @@
//go:build testing
package alerts_test
import (
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/henrygd/beszel/internal/alerts"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/pocketbase/dbx"
"github.com/stretchr/testify/require"
)
func TestPersistedWebhooksUseCurrentOwnerRole(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
am := alerts.NewTestAlertManagerWithoutWorker(hub)
var delivered atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
delivered.Add(1)
}))
defer server.Close()
settings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", dbx.Params{"user": user.Id})
require.NoError(t, err)
settings.Set("settings", alerts.UserNotificationSettings{Webhooks: []string{"generic+" + server.URL}})
require.NoError(t, hub.Save(settings))
message := alerts.AlertMessageData{UserID: user.Id, Title: "Test", Message: "Persisted webhook"}
// Keep the same URL and manager while changing roles, so cached privileges
// or treating previously saved URLs as trusted would fail this test.
for _, tc := range []struct {
name string
role string
want int32
}{
{"regular user", "user", 0},
{"readonly user", "readonly", 0},
{"promoted admin", "admin", 1},
{"demoted admin", "user", 1},
} {
t.Run(tc.name, func(t *testing.T) {
user.Set("role", tc.role)
require.NoError(t, hub.Save(user))
// Webhook errors are logged; SendAlert continues to email delivery.
require.NoError(t, am.SendAlert(message))
require.Equal(t, tc.want, delivered.Load())
})
}
t.Run("missing owner fails closed", func(t *testing.T) {
// Model an orphaned settings record without deleting it through the
// normal user deletion cascade.
const missingOwner = "missingowner123"
settings.Set("user", missingOwner)
require.NoError(t, hub.SaveNoValidate(settings))
message.UserID = missingOwner
err := am.SendAlert(message)
require.ErrorContains(t, err, "load notification owner")
require.EqualValues(t, 1, delivered.Load())
})
}

View File

@@ -0,0 +1,150 @@
package alerts
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"sync/atomic"
"syscall"
"time"
"github.com/nicholas-fedor/shoutrrr/pkg/router"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
var (
errInternalDestination = errors.New("Only admins can send to internal destinations")
errUnrestrictedService = errors.New("Only admins can use this notification service") // Restrict services w/o custom connection support
publicNotificationDialer = &net.Dialer{
Timeout: 10 * time.Second,
// Control checks each resolved address immediately before connecting.
Control: func(_, address string, _ syscall.RawConn) error { return checkNotificationAddress(address) },
}
publicNotificationClient = newPublicNotificationClient()
)
func newPublicNotificationClient() *http.Client {
return &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
// Do not use proxies: they can resolve the target themselves and
// bypass the destination check on our socket.
DialContext: publicNotificationDialer.DialContext,
TLSHandshakeTimeout: 10 * time.Second,
IdleConnTimeout: 90 * time.Second,
},
}
}
func checkNotificationAddress(address string) error {
addr, err := netip.ParseAddrPort(address)
if err != nil || addr.Addr().Zone() != "" {
return errInternalDestination
}
ip := net.IP(addr.Addr().AsSlice())
if !ip.IsGlobalUnicast() || isInternalIP(ip) {
return errInternalDestination
}
return nil
}
func sendPublicNotification(rawURL, message string) error {
client := &notificationClient{Client: publicNotificationClient}
service, err := newPublicNotificationService(rawURL, types.SenderOptions{HTTPClient: client, DialContext: client.dialContext})
if err == nil {
if closer, ok := service.(io.Closer); ok {
defer closer.Close()
}
err = service.Send(message, &types.Params{})
}
// Some services format errors without preserving their error chain.
if client.blocked.Load() {
return errInternalDestination
}
return err
}
type notificationClient struct {
*http.Client
blocked atomic.Bool
}
func (c *notificationClient) Do(req *http.Request) (*http.Response, error) {
response, err := c.Client.Do(req)
if errors.Is(err, errInternalDestination) {
c.blocked.Store(true)
}
return response, err
}
func (c *notificationClient) dialContext(ctx context.Context, network, address string) (net.Conn, error) {
conn, err := publicNotificationDialer.DialContext(ctx, network, address)
if errors.Is(err, errInternalDestination) {
c.blocked.Store(true)
}
return conn, err
}
func newPublicNotificationService(rawURL string, opts types.SenderOptions) (types.Service, error) {
r := &router.ServiceRouter{}
scheme, serviceURL, err := r.ExtractServiceName(rawURL)
if err != nil {
return nil, err
}
service, err := r.NewService(scheme)
if err != nil {
return nil, err
}
httpSetter, httpOK := service.(types.HTTPClientSetter)
dialSetter, dialOK := service.(types.DialContextSetter)
if (!httpOK || opts.HTTPClient == nil) && (!dialOK || opts.DialContext == nil) {
return nil, errUnrestrictedService
}
if serviceURL.Scheme != scheme {
custom, ok := service.(types.CustomURLService)
if !ok {
return nil, fmt.Errorf("%w: %s", router.ErrCustomURLsNotSupported, scheme)
}
serviceURL, err = custom.GetServiceURLFromCustom(serviceURL)
if err != nil {
return nil, err
}
}
// Shoutrrr v0.20.0 CreateSenderWithOptions injects only AFTER Initialize.
// Matrix can log in during Initialize, so inject before it as well.
if httpOK {
httpSetter.SetHTTPClient(opts.HTTPClient)
}
if dialOK {
dialSetter.SetDialContext(opts.DialContext)
}
if err := service.Initialize(serviceURL, nil); err != nil {
return nil, err
}
// Some initializers replace their HTTP client with a default client.
if httpOK {
httpSetter.SetHTTPClient(opts.HTTPClient)
}
if dialOK {
dialSetter.SetDialContext(opts.DialContext)
}
return service, nil
}
var cgnatNetwork = &net.IPNet{
IP: net.IPv4(100, 64, 0, 0),
Mask: net.CIDRMask(10, 32),
}
func isInternalIP(ip net.IP) bool {
return ip.IsPrivate() ||
ip.IsLoopback() ||
ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() ||
ip.IsMulticast() ||
cgnatNetwork.Contains(ip)
}

View File

@@ -0,0 +1,217 @@
package alerts
import (
"context"
"encoding/binary"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
"golang.org/x/net/dns/dnsmessage"
)
func TestCheckNotificationAddress(t *testing.T) {
for _, host := range []string{"127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.0.1", "169.254.169.254", "100.64.0.0", "100.127.255.255", "0.0.0.0", "224.0.0.1", "255.255.255.255", "::1", "::", "fc00::1", "fe80::1", "ff02::1", "::ffff:127.0.0.1", "::ffff:169.254.169.254", "fe80::1%lo", "localhost", "consul"} {
t.Run(host, func(t *testing.T) {
if err := checkNotificationAddress(net.JoinHostPort(host, "80")); !errors.Is(err, errInternalDestination) {
t.Fatalf("expected blocked address, got %v", err)
}
})
}
for _, host := range []string{"8.8.8.8", "100.63.255.255", "100.128.0.0", "2001:4860:4860::8888"} {
if err := checkNotificationAddress(net.JoinHostPort(host, "443")); err != nil {
t.Errorf("public address %s: %v", host, err)
}
}
}
func TestPublicNotificationBlocksInternalRequests(t *testing.T) {
var hits atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
}))
defer server.Close()
host := strings.TrimPrefix(server.URL, "http://")
for _, rawURL := range []string{
"generic+http://" + host,
"generic+https://" + host,
"generic+http://localhost:" + strings.Split(host, ":")[1],
"matrix://user:password@" + host + "/room?disabletls=yes",
"mattermost://" + host + "/token?disabletls=yes",
} {
t.Run(rawURL, func(t *testing.T) {
if err := sendPublicNotification(rawURL, "test"); !errors.Is(err, errInternalDestination) {
t.Fatalf("expected internal destination error, got %v", err)
}
})
}
if hits.Load() != 0 {
t.Fatal("internal server received a request")
}
}
type notificationRoundTripper func(*http.Request) (*http.Response, error)
func (f notificationRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestPublicNotificationRedirect(t *testing.T) {
client := newPublicNotificationClient()
defer client.CloseIdleConnections()
transport := client.Transport
client.Transport = notificationRoundTripper(func(r *http.Request) (*http.Response, error) {
if r.URL.Host == "public.example" {
return &http.Response{StatusCode: 307, Header: http.Header{"Location": {"http://127.0.0.1/"}}, Body: io.NopCloser(strings.NewReader("")), Request: r}, nil
}
return transport.RoundTrip(r)
})
_, err := client.Get("http://public.example/")
if !errors.Is(err, errInternalDestination) {
t.Fatalf("expected redirect to be blocked, got %v", err)
}
}
func TestPublicNotificationServiceClient(t *testing.T) {
for _, rawURL := range []string{"generic+http://public.example/path", "discord://token@123456789", "slack://hook:AAAAAAAAA-BBBBBBBBB-123456789123456789123456@webhook"} {
t.Run(rawURL, func(t *testing.T) {
var hits int
client := &http.Client{Transport: notificationRoundTripper(func(r *http.Request) (*http.Response, error) {
hits++
body := `{"ok":true}`
if strings.HasPrefix(rawURL, "slack:") {
body = "ok"
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil
})}
service, err := newPublicNotificationService(rawURL, types.SenderOptions{HTTPClient: client})
if err != nil {
t.Fatal(err)
}
if err := service.Send("test", &types.Params{}); err != nil {
t.Fatal(err)
}
if hits == 0 {
t.Fatal("injected client was not used")
}
})
}
}
func TestPublicNotificationDNS(t *testing.T) {
// Supply deterministic DNS responses over an in-memory TCP connection.
// The first lookup sees a public IP; subsequent lookups see loopback.
var rebound atomic.Bool
resolver := &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
client, server := net.Pipe()
go func() {
defer server.Close()
var size [2]byte
if _, err := io.ReadFull(server, size[:]); err != nil {
return
}
buf := make([]byte, binary.BigEndian.Uint16(size[:]))
if _, err := io.ReadFull(server, buf); err != nil {
return
}
var msg dnsmessage.Message
if err := msg.Unpack(buf); err != nil {
return
}
msg.Header.Response = true
msg.Header.RecursionAvailable = true
q := msg.Questions[0]
if q.Type == dnsmessage.TypeA {
ip := [4]byte{8, 8, 8, 8}
if rebound.Load() {
ip = [4]byte{127, 0, 0, 1}
}
msg.Answers = []dnsmessage.Resource{{Header: dnsmessage.ResourceHeader{Name: q.Name, Type: q.Type, Class: dnsmessage.ClassINET}, Body: &dnsmessage.AResource{A: ip}}}
}
buf, err := msg.Pack()
if err != nil {
return
}
binary.BigEndian.PutUint16(size[:], uint16(len(buf)))
server.Write(append(size[:], buf...))
}()
return client, nil
}}
// These tests do not run in parallel; restore the process resolver afterward.
previous := net.DefaultResolver
net.DefaultResolver = resolver
t.Cleanup(func() { net.DefaultResolver = previous })
ips, err := resolver.LookupIP(context.Background(), "ip4", "rebind.example")
if err != nil || len(ips) != 1 || !ips[0].Equal(net.IPv4(8, 8, 8, 8)) {
t.Fatalf("initial DNS lookup: %v, %v", ips, err)
}
rebound.Store(true)
client := newPublicNotificationClient()
defer client.CloseIdleConnections()
for _, host := range []string{"rebind.example", "consul"} {
guarded := &notificationClient{Client: client}
conn, dialErr := guarded.dialContext(context.Background(), "tcp", net.JoinHostPort(host, "25"))
if conn != nil {
conn.Close()
}
if !errors.Is(dialErr, errInternalDestination) || !guarded.blocked.Load() {
t.Errorf("expected TCP dial-time rejection for %s, got %v", host, dialErr)
}
_, err := client.Get("http://" + host + "/")
if !errors.Is(err, errInternalDestination) {
t.Errorf("expected dial-time rejection for %s, got %v", host, err)
}
}
}
func TestPublicNotificationTCP(t *testing.T) {
for _, rawURL := range []string{
"smtp://user:pass@HOST:25/?fromAddress=sender@example.com&toAddresses=recipient@example.com",
"smtp://user:pass@HOST:465/?fromAddress=sender@example.com&toAddresses=recipient@example.com",
"mqtt://HOST:1883/topic",
"mqtts://HOST:8883/topic",
} {
t.Run(rawURL, func(t *testing.T) {
t.Parallel()
t.Run("internal destination", func(t *testing.T) {
err := sendPublicNotification(strings.ReplaceAll(rawURL, "HOST", "127.0.0.1"), "test")
if !errors.Is(err, errInternalDestination) {
t.Fatalf("expected blocked destination, got %v", err)
}
})
t.Run("public destination uses injected dialer", func(t *testing.T) {
var calls atomic.Int32
stopped := errors.New("test dial stopped")
service, err := newPublicNotificationService(strings.ReplaceAll(rawURL, "HOST", "8.8.8.8"), types.SenderOptions{
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
calls.Add(1)
if network != "tcp" || !strings.HasPrefix(address, "8.8.8.8:") {
t.Errorf("unexpected dial: %s %s", network, address)
}
if err := checkNotificationAddress(address); err != nil {
t.Error(err)
}
return nil, stopped
},
})
if err != nil {
t.Fatal(err)
}
if closer, ok := service.(io.Closer); ok {
defer closer.Close()
}
if err := service.Send("test", &types.Params{}); err == nil {
t.Fatal("expected dial failure")
}
if calls.Load() == 0 {
t.Fatal("custom dialer was not used")
}
})
})
}
}

View File

@@ -43,6 +43,11 @@ COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
# Copy smartmontools binaries and config files # Copy smartmontools binaries and config files
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
# Install ZFS userspace utilities (zpool, zfs) for pool/dataset monitoring
RUN apt-get update && apt-get install -y --no-install-recommends \
zfsutils-linux \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Ensure data persistence across container recreations # Ensure data persistence across container recreations
VOLUME ["/var/lib/beszel-agent"] VOLUME ["/var/lib/beszel-agent"]

View File

@@ -65,6 +65,32 @@ RUN set -eux; \
cp -v "$interp" "/out/rootfs$interp"; \ cp -v "$interp" "/out/rootfs$interp"; \
fi fi
# --------------------------
# ZFS utilities builder stage
# --------------------------
FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS zfsutils-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
zfsutils-linux \
&& rm -rf /var/lib/apt/lists/*
# Copy the zpool/zfs binaries and their required runtime libraries
RUN set -eux; \
mkdir -p /out/rootfs/lib /out/rootfs/lib64 /out/rootfs/usr/lib; \
for bin in /usr/sbin/zpool /usr/sbin/zfs; do \
mkdir -p "/out/rootfs$(dirname "$bin")"; \
cp -v "$bin" "/out/rootfs$bin"; \
ldd "$bin" \
| awk '{print $3}' \
| grep '^/' \
| xargs -r -I '{}' sh -c 'mkdir -p "/out/rootfs$(dirname "{}")"; cp -v "{}" "/out/rootfs{}"'; \
interp="$(ldd "$bin" | awk "/ld-linux/ {print \$1}")"; \
if [ -n "$interp" ] && [ -e "$interp" ]; then \
mkdir -p "/out/rootfs$(dirname "$interp")"; \
cp -v "$interp" "/out/rootfs$interp"; \
fi; \
done
# -------------------------- # --------------------------
# Final image: lightweight multi-arch NVIDIA agent (slim) # Final image: lightweight multi-arch NVIDIA agent (slim)
# -------------------------- # --------------------------
@@ -78,6 +104,9 @@ COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
COPY --from=smartmontools-builder /out/rootfs/ / COPY --from=smartmontools-builder /out/rootfs/ /
# Copy ZFS utilities (zpool, zfs) binaries and required runtime libraries
COPY --from=zfsutils-builder /out/rootfs/ /
# nvidia-smi is intentionally not bundled. # nvidia-smi is intentionally not bundled.
# Mount the host binary instead, for example: # Mount the host binary instead, for example:
# - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro # - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro

View File

@@ -99,8 +99,8 @@ func setCollectionAuthSettings(app core.App) error {
} }
if err := applyCollectionRules(app, []string{"fingerprints"}, collectionRules{ if err := applyCollectionRules(app, []string{"fingerprints"}, collectionRules{
list: &systemScopedReadRule, list: &systemScopedWriteRule,
view: &systemScopedReadRule, view: &systemScopedWriteRule,
create: &systemScopedWriteRule, create: &systemScopedWriteRule,
update: &systemScopedWriteRule, update: &systemScopedWriteRule,
delete: &systemScopedWriteRule, delete: &systemScopedWriteRule,

View File

@@ -88,8 +88,8 @@ func TestCollectionRulesDefault(t *testing.T) {
// fingerprints collection // fingerprints collection
fingerprintsCollection, err := hub.FindCollectionByNameOrId("fingerprints") fingerprintsCollection, err := hub.FindCollectionByNameOrId("fingerprints")
require.NoError(t, err, "Failed to find fingerprints collection") require.NoError(t, err, "Failed to find fingerprints collection")
assert.Equal(t, isUserInSystemUsers, *fingerprintsCollection.ListRule) assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.ListRule)
assert.Equal(t, isUserInSystemUsers, *fingerprintsCollection.ViewRule) assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.ViewRule)
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.CreateRule) assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.CreateRule)
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.UpdateRule) assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.UpdateRule)
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.DeleteRule) assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.DeleteRule)
@@ -216,8 +216,8 @@ func TestCollectionRulesShareAllSystems(t *testing.T) {
// fingerprints collection // fingerprints collection
fingerprintsCollection, err := hub.FindCollectionByNameOrId("fingerprints") fingerprintsCollection, err := hub.FindCollectionByNameOrId("fingerprints")
require.NoError(t, err, "Failed to find fingerprints collection") require.NoError(t, err, "Failed to find fingerprints collection")
assert.Equal(t, isUser, *fingerprintsCollection.ListRule) assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.ListRule)
assert.Equal(t, isUser, *fingerprintsCollection.ViewRule) assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.ViewRule)
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.CreateRule) assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.CreateRule)
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.UpdateRule) assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.UpdateRule)
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.DeleteRule) assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.DeleteRule)

View File

@@ -272,7 +272,15 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
// update system record (do this last because it triggers alerts and we need above records to be inserted first) // update system record (do this last because it triggers alerts and we need above records to be inserted first)
systemRecord.Set("status", up) systemRecord.Set("status", up)
systemRecord.Set("info", data.Info) // Distinguish an idle GPU from a system without GPU data (#2312)
info := struct {
system.Info
GpuPct *float64 `json:"g,omitempty"`
}{Info: data.Info}
if len(data.Stats.GPUData) > 0 {
info.GpuPct = &data.Info.GpuPct
}
systemRecord.Set("info", info)
if err := txApp.SaveNoValidate(systemRecord); err != nil { if err := txApp.SaveNoValidate(systemRecord); err != nil {
return err return err
} }
@@ -322,6 +330,11 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
valueStrings := make([]string, 0, len(data)) valueStrings := make([]string, 0, len(data))
for i, service := range data { for i, service := range data {
// Agent payloads can contain null entries. Reject the snapshot before
// executing any queries so existing service records remain intact.
if service == nil {
return fmt.Errorf("null systemd service at index %d", i)
}
suffix := fmt.Sprintf("%d", i) suffix := fmt.Sprintf("%d", i)
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix)) valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
params["id"+suffix] = makeStableHashId(systemId, service.Name) params["id"+suffix] = makeStableHashId(systemId, service.Name)

View File

@@ -0,0 +1,44 @@
//go:build testing
package systems
import (
"testing"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateRecordsGPUUtilization(t *testing.T) {
sys, app := newTestSystemWithHub(t)
for _, tc := range []struct {
name string
gpu bool
usage float64
}{
{"no GPU", false, 0},
{"active GPU", true, 42.5},
{"idle GPU", true, 0},
{"GPU removed", false, 0},
} {
t.Run(tc.name, func(t *testing.T) {
data := &system.CombinedData{Info: system.Info{GpuPct: tc.usage, Cpu: 12.5}}
if tc.gpu {
data.Stats.GPUData = map[string]system.GPUData{"0": {Name: "GPU", Usage: tc.usage}}
}
_, err := sys.createRecords(data)
require.NoError(t, err)
record, err := app.FindRecordById("systems", sys.Id)
require.NoError(t, err)
var info map[string]any
require.NoError(t, record.UnmarshalJSONField("info", &info))
assert.Equal(t, 12.5, info["cpu"])
if tc.gpu {
assert.Equal(t, tc.usage, info["g"])
} else {
assert.NotContains(t, info, "g")
}
})
}
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"sync"
"time" "time"
"github.com/henrygd/beszel/internal/hub/ws" "github.com/henrygd/beszel/internal/hub/ws"
@@ -42,13 +43,17 @@ var errSystemExists = errors.New("system exists")
// SystemManager manages a collection of monitored systems and their connections. // SystemManager manages a collection of monitored systems and their connections.
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections. // It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
type SystemManager struct { type SystemManager struct {
hub hubLike // Hub interface for database and alert operations hub hubLike // Hub interface for database and alert operations
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
zfsFetchMap *expirymap.ExpiryMap[zfsFetchState] // Stores last ZFS fetch time/result; TTL is only for cleanup zfsFetchMap *expirymap.ExpiryMap[zfsFetchState] // Stores last ZFS fetch time/result; TTL is only for cleanup
ctx context.Context // Cancelled when the app terminates realtimeMutex sync.Mutex // Protects all realtime worker and subscription state
cancel context.CancelFunc // Cancels ctx and all child system contexts activeSubscriptions map[string]*subscriptionInfo // Realtime subscriptions keyed by system ID
realtimeWorkerStop chan struct{} // Stops the current realtime worker generation
realtimeWorkerRun bool // Whether a realtime worker has been started
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.
@@ -67,10 +72,11 @@ type hubLike interface {
// 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 {
sm := &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),
zfsFetchMap: expirymap.New[zfsFetchState](time.Hour), zfsFetchMap: expirymap.New[zfsFetchState](time.Hour),
activeSubscriptions: make(map[string]*subscriptionInfo),
} }
sm.ctx, sm.cancel = context.WithCancel(context.Background()) sm.ctx, sm.cancel = context.WithCancel(context.Background())
return sm return sm
@@ -138,6 +144,7 @@ func (sm *SystemManager) bindEventHooks() {
// onTerminate cancels SystemManager context on app shutdown // onTerminate cancels SystemManager context on app shutdown
func (sm *SystemManager) onTerminate(e *core.TerminateEvent) error { func (sm *SystemManager) onTerminate(e *core.TerminateEvent) error {
sm.cancel() sm.cancel()
sm.stopRealtimeWorker()
return e.Next() return e.Next()
} }

View File

@@ -3,25 +3,27 @@ package systems
import ( import (
"encoding/json" "encoding/json"
"strings" "strings"
"sync"
"time" "time"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/hub/utils"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/subscriptions" "github.com/pocketbase/pocketbase/tools/subscriptions"
) )
type subscriptionInfo struct { type subscriptionInfo struct {
subscription string subscription string
connectedClients uint8 connectedClients int
fetching bool
} }
var ( type realtimeFetch struct {
activeSubscriptions = make(map[string]*subscriptionInfo) systemID string
workerRunning bool subscription string
tickerStopChan chan struct{} info *subscriptionInfo
realtimeMutex sync.Mutex }
)
// onRealtimeConnectRequest handles client connection events for realtime subscriptions. // onRealtimeConnectRequest handles client connection events for realtime subscriptions.
// It cleans up existing subscriptions when a client connects. // It cleans up existing subscriptions when a client connects.
@@ -38,6 +40,19 @@ func (sm *SystemManager) onRealtimeConnectRequest(e *core.RealtimeConnectRequest
// onRealtimeSubscribeRequest handles client subscription events for realtime metrics. // onRealtimeSubscribeRequest handles client subscription events for realtime metrics.
// It tracks new subscriptions and unsubscriptions to manage the realtime worker lifecycle. // It tracks new subscriptions and unsubscriptions to manage the realtime worker lifecycle.
func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeRequestEvent) error { func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeRequestEvent) error {
// Parse with PocketBase's own subscription parser before changing the real
// client. Reject the entire request if any metrics target is inaccessible.
requested := subscriptions.NewDefaultClient()
requested.Subscribe(e.Subscriptions...)
for topic, options := range requested.Subscriptions() {
if !strings.HasPrefix(topic, "rt_metrics") {
continue
}
system, err := sm.GetSystem(options.Query["system"])
if err != nil || !system.HasUser(e.App, e.Auth) {
return e.NotFoundError("", nil)
}
}
oldSubs := e.Client.Subscriptions() oldSubs := e.Client.Subscriptions()
// after e.Next() is the result of the subscribe request // after e.Next() is the result of the subscribe request
err := e.Next() err := e.Next()
@@ -47,14 +62,7 @@ func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeReq
for k, options := range newSubs { for k, options := range newSubs {
if _, ok := oldSubs[k]; !ok { if _, ok := oldSubs[k]; !ok {
if strings.HasPrefix(k, "rt_metrics") { if strings.HasPrefix(k, "rt_metrics") {
systemId := options.Query["system"] sm.addRealtimeSubscription(options.Query["system"], k)
if _, ok := activeSubscriptions[systemId]; !ok {
activeSubscriptions[systemId] = &subscriptionInfo{
subscription: k,
}
}
activeSubscriptions[systemId].connectedClients += 1
sm.onRealtimeSubscriptionAdded()
} }
} }
} }
@@ -68,72 +76,76 @@ func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeReq
return err return err
} }
// onRealtimeSubscriptionAdded initializes or starts the realtime worker when the first subscription is added. // addRealtimeSubscription tracks a subscriber and starts a worker if necessary.
// It ensures only one worker runs at a time. func (sm *SystemManager) addRealtimeSubscription(systemID, subscription string) {
func (sm *SystemManager) onRealtimeSubscriptionAdded() { sm.realtimeMutex.Lock()
realtimeMutex.Lock() defer sm.realtimeMutex.Unlock()
defer realtimeMutex.Unlock()
// Start the worker if it's not already running if sm.activeSubscriptions == nil {
if !workerRunning { sm.activeSubscriptions = make(map[string]*subscriptionInfo)
workerRunning = true }
// Create a new stop channel for this worker instance info, ok := sm.activeSubscriptions[systemID]
tickerStopChan = make(chan struct{}) if !ok {
go sm.startRealtimeWorker() info = &subscriptionInfo{subscription: subscription}
sm.activeSubscriptions[systemID] = info
}
info.connectedClients++
if !sm.realtimeWorkerRun {
sm.realtimeWorkerRun = true
stop := make(chan struct{})
sm.realtimeWorkerStop = stop
go sm.startRealtimeWorker(stop)
} }
} }
// checkSubscriptions stops the realtime worker when there are no active subscriptions. // stopRealtimeWorker stops the current worker generation, if any.
// This prevents unnecessary resource usage when no clients are listening for realtime data. func (sm *SystemManager) stopRealtimeWorker() {
func (sm *SystemManager) checkSubscriptions() { sm.realtimeMutex.Lock()
if !workerRunning || len(activeSubscriptions) > 0 { defer sm.realtimeMutex.Unlock()
sm.stopRealtimeWorkerLocked()
}
func (sm *SystemManager) stopRealtimeWorkerLocked() {
if !sm.realtimeWorkerRun {
return return
} }
close(sm.realtimeWorkerStop)
realtimeMutex.Lock() sm.realtimeWorkerStop = nil
defer realtimeMutex.Unlock() sm.realtimeWorkerRun = false
// Signal the worker to stop
if tickerStopChan != nil {
select {
case tickerStopChan <- struct{}{}:
default:
}
}
// Mark worker as stopped (will be reset when next subscription comes in)
workerRunning = false
} }
// removeRealtimeSubscription removes a realtime subscription and checks if the worker should be stopped. // removeRealtimeSubscription removes a realtime subscription and checks if the worker should be stopped.
// It only processes subscriptions with the "rt_metrics" prefix and triggers cleanup when subscriptions are removed. // It only processes subscriptions with the "rt_metrics" prefix and triggers cleanup when subscriptions are removed.
func (sm *SystemManager) removeRealtimeSubscription(subscription string, options subscriptions.SubscriptionOptions) { func (sm *SystemManager) removeRealtimeSubscription(subscription string, options subscriptions.SubscriptionOptions) {
if strings.HasPrefix(subscription, "rt_metrics") { if strings.HasPrefix(subscription, "rt_metrics") {
systemId := options.Query["system"] systemID := options.Query["system"]
if info, ok := activeSubscriptions[systemId]; ok { sm.realtimeMutex.Lock()
info.connectedClients -= 1 if info, ok := sm.activeSubscriptions[systemID]; ok {
info.connectedClients--
if info.connectedClients <= 0 { if info.connectedClients <= 0 {
delete(activeSubscriptions, systemId) delete(sm.activeSubscriptions, systemID)
} }
} }
sm.checkSubscriptions() if len(sm.activeSubscriptions) == 0 {
sm.stopRealtimeWorkerLocked()
}
sm.realtimeMutex.Unlock()
} }
} }
// startRealtimeWorker runs the main loop for fetching realtime data from agents. // startRealtimeWorker runs the main loop for fetching realtime data from agents.
// It continuously fetches system data and broadcasts it to subscribed clients via WebSocket. // It continuously fetches system data and broadcasts it to subscribed clients via WebSocket.
func (sm *SystemManager) startRealtimeWorker() { func (sm *SystemManager) startRealtimeWorker(stop <-chan struct{}) {
sm.fetchRealtimeDataAndNotify() sm.fetchRealtimeDataAndNotify()
tick := time.Tick(1 * time.Second) ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for { for {
select { select {
case <-tickerStopChan: case <-stop:
return return
case <-tick: case <-ticker.C:
if len(activeSubscriptions) == 0 {
return
}
sm.fetchRealtimeDataAndNotify() sm.fetchRealtimeDataAndNotify()
} }
} }
@@ -141,27 +153,79 @@ func (sm *SystemManager) startRealtimeWorker() {
// fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients. // fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients.
func (sm *SystemManager) fetchRealtimeDataAndNotify() { func (sm *SystemManager) fetchRealtimeDataAndNotify() {
for systemId, info := range activeSubscriptions { for _, fetch := range sm.claimRealtimeFetches() {
system, err := sm.GetSystem(systemId) system, err := sm.GetSystem(fetch.systemID)
if err != nil { if err != nil {
sm.finishRealtimeFetch(fetch)
continue continue
} }
go func() { go func(fetch realtimeFetch) {
defer sm.finishRealtimeFetch(fetch)
data, err := system.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: 1000}) data, err := system.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: 1000})
if err != nil { if err != nil {
return return
} }
bytes, err := json.Marshal(data) bytes, err := json.Marshal(data)
if err == nil { if err == nil {
notify(sm.hub, info.subscription, bytes) notify(sm.hub, system, fetch.subscription, bytes)
} }
}() }(fetch)
}
}
// claimRealtimeFetches takes a stable snapshot and marks each selected system as
// in flight. Slow agents are skipped on later ticks until their fetch completes.
func (sm *SystemManager) claimRealtimeFetches() []realtimeFetch {
sm.realtimeMutex.Lock()
defer sm.realtimeMutex.Unlock()
fetches := make([]realtimeFetch, 0, len(sm.activeSubscriptions))
for systemID, info := range sm.activeSubscriptions {
if info.fetching {
continue
}
info.fetching = true
fetches = append(fetches, realtimeFetch{
systemID: systemID,
subscription: info.subscription,
info: info,
})
}
return fetches
}
func (sm *SystemManager) finishRealtimeFetch(fetch realtimeFetch) {
sm.realtimeMutex.Lock()
defer sm.realtimeMutex.Unlock()
// A subscription may have been removed and recreated while the old request
// was running. Only release the exact entry claimed by this request.
if info := sm.activeSubscriptions[fetch.systemID]; info == fetch.info {
info.fetching = false
} }
} }
// notify broadcasts realtime data to all clients subscribed to a specific subscription. // notify broadcasts realtime data to all clients subscribed to a specific subscription.
// It iterates through all connected clients and sends the data only to those with matching subscriptions. // Custom topics bypass collection rules, so check current access for every
func notify(app core.App, subscription string, data []byte) error { // recipient, including clients whose authentication or membership was revoked.
func notify(app core.App, system *System, subscription string, data []byte) error {
shareAll, _ := utils.GetEnv("SHARE_ALL_SYSTEMS")
members := make(map[string]struct{})
if shareAll != "true" {
// Refresh once per broadcast so membership changes take effect on the
// next update without querying the database for every recipient.
var recordData struct{ Users string }
if err := app.DB().NewQuery("SELECT users FROM systems WHERE id={:id}").
Bind(dbx.Params{"id": system.Id}).One(&recordData); err != nil {
return err
}
var userIDs []string
if err := json.Unmarshal([]byte(recordData.Users), &userIDs); err != nil {
return err
}
for _, id := range userIDs {
members[id] = struct{}{}
}
}
message := subscriptions.Message{ message := subscriptions.Message{
Name: subscription, Name: subscription,
Data: data, Data: data,
@@ -170,6 +234,13 @@ func notify(app core.App, subscription string, data []byte) error {
if !client.HasSubscription(subscription) { if !client.HasSubscription(subscription) {
continue continue
} }
auth, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
if auth == nil {
continue
}
if _, member := members[auth.Id]; shareAll != "true" && !member {
continue
}
client.Send(message) client.Send(message)
} }
return nil return nil

View File

@@ -0,0 +1,229 @@
package systems
import (
"testing"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
pbtests "github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/store"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRealtimeAuthorization(t *testing.T) {
t.Setenv("SHARE_ALL_SYSTEMS", "false")
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "")
app, err := pbtests.NewTestApp(t.TempDir())
require.NoError(t, err)
t.Cleanup(app.Cleanup)
_, err = app.DB().NewQuery(`CREATE TABLE IF NOT EXISTS systems (id TEXT PRIMARY KEY, users TEXT)`).Execute()
require.NoError(t, err)
_, err = app.DB().NewQuery(`INSERT INTO systems (id, users) VALUES ('target', '["member"]')`).Execute()
require.NoError(t, err)
member := core.NewRecord(core.NewAuthCollection("users"))
member.Id = "member"
outsider := core.NewRecord(member.Collection())
outsider.Id = "outsider"
system := &System{Id: "target"}
sm := newRealtimeTestManager()
sm.systems.Set(system.Id, system)
// Keep the lifecycle bookkeeping active without starting an agent worker.
sm.realtimeWorkerRun = true
sm.realtimeWorkerStop = make(chan struct{})
t.Cleanup(sm.stopRealtimeWorker)
topic := `rt_metrics?options={"query":{"system":"target"}}`
for _, tc := range []struct {
name string
auth *core.Record
topic string
share bool
allowed bool
}{
{"guest", nil, topic, false, false},
{"outsider", outsider, topic, false, false},
{"member", member, topic, false, true},
{"missing system", member, `rt_metrics`, false, false},
{"unknown system", member, `rt_metrics?options={"query":{"system":"missing"}}`, false, false},
{"malformed options", member, `rt_metrics?options=invalid`, false, false},
{"prefix variant", outsider, `rt_metrics_extra?options={"query":{"system":"target"}}`, false, false},
{"shared outsider", outsider, topic, true, true},
{"shared guest", nil, topic, true, false},
{"other topic", nil, "systems/*", false, true},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.share {
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "true")
}
client := subscriptions.NewDefaultClient()
client.Subscribe("existing")
e := &core.RealtimeSubscribeRequestEvent{
RequestEvent: &core.RequestEvent{App: app, Auth: tc.auth},
Client: client, Subscriptions: []string{tc.topic},
}
called := false
h := &hook.Hook[*core.RealtimeSubscribeRequestEvent]{}
h.BindFunc(sm.onRealtimeSubscribeRequest)
err := h.Trigger(e, func(e *core.RealtimeSubscribeRequestEvent) error {
called = true
client.Unsubscribe()
client.Subscribe(e.Subscriptions...)
return nil
})
if tc.allowed {
require.NoError(t, err)
assert.True(t, called)
} else {
require.Error(t, err)
assert.False(t, called)
assert.True(t, client.HasSubscription("existing"))
assert.False(t, client.HasSubscription(tc.topic))
}
})
}
t.Run("broadcast checks current access", func(t *testing.T) {
client := subscriptions.NewDefaultClient()
client.Subscribe(topic)
app.SubscriptionsBroker().Register(client)
defer app.SubscriptionsBroker().Unregister(client.Id())
secondClient := subscriptions.NewDefaultClient()
secondClient.Subscribe(topic)
app.SubscriptionsBroker().Register(secondClient)
defer app.SubscriptionsBroker().Unregister(secondClient.Id())
check := func(auth *core.Record, allowed bool) {
t.Helper()
client.Set(apis.RealtimeClientAuthKey, auth)
secondClient.Set(apis.RealtimeClientAuthKey, auth)
done := make(chan struct{})
go func() {
notify(app, system, topic, []byte(`{"cpu":42}`))
close(done)
}()
// Even on failure, drain pending sends and join the broadcaster before
// unregistering clients, which closes their channels.
defer func() {
for {
select {
case <-client.Channel():
case <-secondClient.Channel():
case <-done:
return
}
}
}()
var received [2]int
timer := time.NewTimer(time.Second)
defer timer.Stop()
for {
select {
case msg := <-client.Channel():
received[0]++
assert.Equal(t, topic, msg.Name)
case msg := <-secondClient.Channel():
received[1]++
assert.Equal(t, topic, msg.Name)
case <-done:
want := [2]int{}
if allowed {
want = [2]int{1, 1}
}
assert.Equal(t, want, received)
return
case <-timer.C:
t.Fatal("broadcast did not finish")
}
}
}
check(nil, false)
check(outsider, false)
check(member, true)
_, err := app.DB().NewQuery(`UPDATE systems SET users = '[]'`).Execute()
require.NoError(t, err)
check(member, false)
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "true")
check(outsider, true)
check(nil, false)
})
}
func newRealtimeTestManager() *SystemManager {
return &SystemManager{
systems: store.New(map[string]*System{}),
activeSubscriptions: make(map[string]*subscriptionInfo),
}
}
func TestRealtimeFetchesDoNotOverlapPerSystem(t *testing.T) {
sm := newRealtimeTestManager()
sm.activeSubscriptions["one"] = &subscriptionInfo{subscription: "rt_metrics_one"}
sm.activeSubscriptions["two"] = &subscriptionInfo{subscription: "rt_metrics_two"}
first := sm.claimRealtimeFetches()
require.Len(t, first, 2)
assert.Empty(t, sm.claimRealtimeFetches())
sm.finishRealtimeFetch(first[0])
next := sm.claimRealtimeFetches()
require.Len(t, next, 1)
assert.Equal(t, first[0].systemID, next[0].systemID)
sm.finishRealtimeFetch(first[1])
sm.finishRealtimeFetch(next[0])
}
func TestFinishingOldRealtimeFetchDoesNotReleaseReplacement(t *testing.T) {
sm := newRealtimeTestManager()
oldInfo := &subscriptionInfo{subscription: "old"}
sm.activeSubscriptions["system"] = oldInfo
fetch := sm.claimRealtimeFetches()[0]
newInfo := &subscriptionInfo{subscription: "new", fetching: true}
sm.activeSubscriptions["system"] = newInfo
sm.finishRealtimeFetch(fetch)
assert.True(t, newInfo.fetching)
}
func TestRealtimeSubscriptionLifecycle(t *testing.T) {
sm := newRealtimeTestManager()
options := subscriptions.SubscriptionOptions{Query: map[string]string{"system": "system"}}
sm.addRealtimeSubscription("system", "rt_metrics")
sm.addRealtimeSubscription("system", "rt_metrics")
sm.realtimeMutex.Lock()
firstStop := sm.realtimeWorkerStop
assert.True(t, sm.realtimeWorkerRun)
assert.Equal(t, 2, sm.activeSubscriptions["system"].connectedClients)
sm.realtimeMutex.Unlock()
sm.removeRealtimeSubscription("rt_metrics", options)
sm.realtimeMutex.Lock()
assert.True(t, sm.realtimeWorkerRun)
assert.Equal(t, 1, sm.activeSubscriptions["system"].connectedClients)
sm.realtimeMutex.Unlock()
sm.removeRealtimeSubscription("rt_metrics", options)
sm.realtimeMutex.Lock()
assert.False(t, sm.realtimeWorkerRun)
assert.Empty(t, sm.activeSubscriptions)
sm.realtimeMutex.Unlock()
select {
case <-firstStop:
default:
t.Fatal("worker stop channel was not closed")
}
// A later subscription must get a new stop channel owned by its worker.
sm.addRealtimeSubscription("system", "rt_metrics")
sm.realtimeMutex.Lock()
secondStop := sm.realtimeWorkerStop
assert.NotEqual(t, firstStop, secondStop)
sm.realtimeMutex.Unlock()
sm.stopRealtimeWorker()
}

View File

@@ -3,9 +3,11 @@
package systems_test package systems_test
import ( import (
"encoding/json"
"testing" "testing"
"time" "time"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd" "github.com/henrygd/beszel/internal/entities/systemd"
"github.com/henrygd/beszel/internal/hub/systems" "github.com/henrygd/beszel/internal/hub/systems"
@@ -15,6 +17,42 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestCreateRecordsRejectsNullSystemdService(t *testing.T) {
hub, user := tests.GetHubWithUser(t)
defer hub.Cleanup()
records, err := tests.CreateSystems(hub, 1, user.Id, "paused")
require.NoError(t, err)
sys, err := hub.GetSystemManager().GetSystem(records[0].Id)
require.NoError(t, err)
require.NoError(t, systems.CreateSystemdStatsRecords(hub, []*systemd.Service{
{Name: "existing.service", State: systemd.StatusFailed},
}, records[0].Id))
for _, services := range []string{`[null]`, `[{"name":"new.service"},null]`, `[null,{"name":"new.service"}]`} {
for _, encoding := range []string{"json", "cbor"} {
t.Run(encoding+"/"+services, func(t *testing.T) {
var data system.CombinedData
require.NoError(t, json.Unmarshal([]byte(`{"systemd":`+services+`}`), &data))
if encoding == "cbor" {
encoded, err := cbor.Marshal(data)
require.NoError(t, err)
data = system.CombinedData{}
require.NoError(t, cbor.Unmarshal(encoded, &data))
}
_, err := sys.CreateRecords(&data)
require.ErrorContains(t, err, "null systemd service")
var names []string
require.NoError(t, hub.DB().Select("name").From("systemd_services").
Where(dbx.HashExp{"system": records[0].Id}).Column(&names))
assert.Equal(t, []string{"existing.service"}, names)
count, err := hub.CountRecords("system_stats", dbx.HashExp{"system": records[0].Id})
require.NoError(t, err)
assert.Zero(t, count, "invalid snapshot must roll back system stats")
})
}
}
}
func TestCreateRecordsHandlesSystemdAlertLifecycle(t *testing.T) { func TestCreateRecordsHandlesSystemdAlertLifecycle(t *testing.T) {
hub, user := tests.GetHubWithUser(t) hub, user := tests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()

View File

@@ -75,7 +75,12 @@ export const smartColumns: ColumnDef<SmartAttribute>[] = [
header: "Name", header: "Name",
}, },
{ {
accessorFn: (row) => row.rs || row.rv?.toString(), accessorFn: (row) => {
if (row.n === "DataUnitsWritten" || row.n === "DataUnitsRead") {
return formatDataUnits(Number(row.rv ?? 0))
}
return row.rs || row.rv?.toString()
},
header: "Value", header: "Value",
}, },
{ {
@@ -103,6 +108,12 @@ function formatCapacity(bytes: number): string {
return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}` return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}`
} }
// Function to format NVMe data units
// (1 unit = 1000 * 512 bytes) as a human-readable size
function formatDataUnits(units: number): string {
return formatCapacity(units * 1000 * 512)
}
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated" const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
export const createColumns = ( export const createColumns = (

View File

@@ -193,7 +193,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
header: sortableHeader, header: sortableHeader,
}, },
{ {
accessorFn: ({ info }) => info.g || undefined, accessorFn: ({ info }) => info.g,
id: "gpu", id: "gpu",
name: () => "GPU", name: () => "GPU",
cell: (info) => { cell: (info) => {

View File

@@ -4,7 +4,7 @@ import { basePath } from "@/components/router"
import { toast } from "@/components/ui/use-toast" import { toast } from "@/components/ui/use-toast"
import type { ChartTimes, UserSettings } from "@/types" import type { ChartTimes, UserSettings } from "@/types"
import { $alerts, $allSystemsById, $allSystemsByName, $userSettings } from "./stores" import { $alerts, $allSystemsById, $allSystemsByName, $userSettings } from "./stores"
import { chartTimeData } from "./utils" import { chartTimeData, debounce } from "./utils"
/** PocketBase JS Client */ /** PocketBase JS Client */
export const pb = new PocketBase(basePath) export const pb = new PocketBase(basePath)
@@ -12,7 +12,7 @@ export const pb = new PocketBase(basePath)
export const isAdmin = () => pb.authStore.record?.role === "admin" export const isAdmin = () => pb.authStore.record?.role === "admin"
export const isReadOnlyUser = () => pb.authStore.record?.role === "readonly" export const isReadOnlyUser = () => pb.authStore.record?.role === "readonly"
export const verifyAuth = () => { const verifyAuth = () => {
pb.collection("users") pb.collection("users")
.authRefresh() .authRefresh()
.catch(() => { .catch(() => {
@@ -25,6 +25,22 @@ export const verifyAuth = () => {
}) })
} }
const verifyAuthDebounced = debounce(verifyAuth, 100)
// verify the session whenever any API request returns a 4xx response (e.g. an
// expired JWT). The auth-refresh endpoint is excluded to avoid a loop, since
// it returns 401 itself when the token is no longer valid.
pb.afterSend = (response, data) => {
if (
(response.status === 401 || response.status === 403) &&
pb.authStore.token &&
!response.url.includes("auth-refresh")
) {
verifyAuthDebounced()
}
return data
}
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */ /** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
export function logOut() { export function logOut() {
$allSystemsByName.set({}) $allSystemsByName.set({})

View File

@@ -1,6 +1,6 @@
/** biome-ignore-all lint/suspicious/noAssignInExpressions: it's fine :) */ /** biome-ignore-all lint/suspicious/noAssignInExpressions: it's fine :) */
import type { PreinitializedMapStore } from "nanostores" import type { PreinitializedMapStore } from "nanostores"
import { pb, verifyAuth } from "@/lib/api" import { pb } from "@/lib/api"
import { import {
$allSystemsById, $allSystemsById,
$allSystemsByName, $allSystemsByName,
@@ -167,11 +167,6 @@ export async function subscribe() {
export async function refresh() { export async function refresh() {
try { try {
const records = await fetchSystems() const records = await fetchSystems()
if (!records.length) {
// No systems found, verify authentication
verifyAuth()
return
}
for (const record of records) { for (const record of records) {
add(record) add(record)
} }

View File

@@ -43,7 +43,7 @@ is_glibc() {
set_selinux_context() { set_selinux_context() {
# Check if SELinux is enabled and in enforcing or permissive mode # Check if SELinux is enabled and in enforcing or permissive mode
if command -v getenforce >/dev/null 2>&1; then if command -v getenforce >/dev/null 2>&1; then
SELINUX_MODE=$(getenforce) SELINUX_MODE=$(getenforce) || { warn "Could not query SELinux mode."; return 0; }
if [ "$SELINUX_MODE" != "Disabled" ]; then if [ "$SELINUX_MODE" != "Disabled" ]; then
echo "SELinux is enabled (${SELINUX_MODE} mode). Setting appropriate context..." echo "SELinux is enabled (${SELINUX_MODE} mode). Setting appropriate context..."
@@ -51,7 +51,7 @@ set_selinux_context() {
if command -v semanage >/dev/null 2>&1; then if command -v semanage >/dev/null 2>&1; then
echo "Attempting to set persistent SELinux context..." echo "Attempting to set persistent SELinux context..."
if semanage fcontext -a -t bin_t "$BIN_PATH" >/dev/null 2>&1; then if semanage fcontext -a -t bin_t "$BIN_PATH" >/dev/null 2>&1; then
restorecon -v "$BIN_PATH" >/dev/null 2>&1 restorecon -v "$BIN_PATH" >/dev/null 2>&1 || warn "Failed to restore persistent SELinux context; trying chcon."
else else
echo "Warning: Failed to set persistent context, falling back to temporary context." echo "Warning: Failed to set persistent context, falling back to temporary context."
fi fi
@@ -271,7 +271,7 @@ detect_mips_endianness() {
for bin_to_check in $bins; do for bin_to_check in $bins; do
if [ -f "$bin_to_check" ]; then if [ -f "$bin_to_check" ]; then
# The 6th byte in ELF header: 01 = little, 02 = big # The 6th byte in ELF header: 01 = little, 02 = big
endian=$(hexdump -n 1 -s 5 -e '1/1 "%02x"' "$bin_to_check" 2>/dev/null) endian=$(hexdump -n 1 -s 5 -e '1/1 "%02x"' "$bin_to_check" 2>/dev/null) || continue
if [ "$endian" = "01" ]; then if [ "$endian" = "01" ]; then
echo "mipsle" echo "mipsle"
return return
@@ -286,6 +286,136 @@ detect_mips_endianness() {
echo "mips" echo "mips"
} }
# Expected failures must be handled explicitly; unexpected failures abort installation.
set -eu
fail() {
echo "Error: $*" >&2
exit 1
}
warn() {
echo "Warning: $*" >&2
}
require_value() {
[ "$#" -ge 2 ] && [ -n "$2" ] || fail "Option $1 requires a value."
}
validate_platform() {
case "$(uname -s)" in
Linux)
if is_alpine; then
command -v rc-service >/dev/null && command -v rc-update >/dev/null || fail "OpenRC is required."
elif is_openwrt; then
[ -f /etc/rc.common ] || fail "OpenWrt procd is required."
else
command -v systemctl >/dev/null && [ -d /run/systemd/system ] || fail "This Linux installer requires a running systemd, OpenRC (Alpine), or procd (OpenWrt)."
fi
;;
FreeBSD)
command -v service >/dev/null && command -v sysrc >/dev/null || fail "FreeBSD service and sysrc commands are required."
;;
Darwin) fail "For macOS, use the Homebrew installer: https://github.com/henrygd/beszel/blob/main/supplemental/scripts/install-agent-brew.sh" ;;
*) fail "Unsupported operating system: $(uname -s)" ;;
esac
}
agent_service() {
if is_alpine; then
rc-service beszel-agent "$1"
elif is_openwrt; then
/etc/init.d/beszel-agent "$1"
elif is_freebsd; then
service beszel-agent "$1"
else
systemctl "$1" beszel-agent.service
fi
}
# Match the files preserved by the service setup below. A binary or rc script
# alone is not reusable configuration (FreeBSD stores its environment separately).
agent_configuration_exists() {
if is_alpine || is_openwrt; then
[ -f /etc/init.d/beszel-agent ]
elif is_freebsd; then
[ -f "$AGENT_DIR/env" ]
else
[ -f /etc/systemd/system/beszel-agent.service ]
fi
}
# An orphaned binary can remain after a failed install. It does not imply
# that the service manager knows about the agent yet.
agent_service_registered() {
if is_alpine || is_openwrt; then
[ -f /etc/init.d/beszel-agent ]
elif is_freebsd; then
[ -f /usr/local/etc/rc.d/beszel-agent ]
else
service_load_state=$(systemctl show --property=LoadState --value beszel-agent.service) || return 2
case "$service_load_state" in
not-found) return 1 ;;
"") return 2 ;;
*) return 0 ;;
esac
fi
}
TEMP_DIR=""
STAGED_BINARY=""
INSTALL_STEP="validating installation options"
UPGRADE_PENDING=false
cleanup() {
cleanup_status=$?
trap - 0 HUP INT TERM
if [ "$cleanup_status" -ne 0 ]; then
warn "Installer failed while $INSTALL_STEP (exit $cleanup_status)."
if [ "$UPGRADE_PENDING" = true ]; then
warn "Restoring the previous binary and restarting its service if registered."
if [ -n "$STAGED_BINARY" ]; then
rm -f "$STAGED_BINARY" || warn "Could not remove staged binary."
fi
if STAGED_BINARY=$(mktemp "$BIN_PATH.XXXXXX") && cp -p "$BIN_PATH.bak" "$STAGED_BINARY" && mv -f "$STAGED_BINARY" "$BIN_PATH"; then
# The temporary inode does not inherit the installed binary's SELinux label.
set_selinux_context || warn "Could not restore SELinux context on the previous agent."
if agent_service_registered; then
agent_service restart || warn "Could not restart the previous agent; check the service configuration and logs."
else
service_check_status=$?
[ "$service_check_status" -eq 1 ] || warn "Could not determine whether the previous agent service is registered; check it manually."
fi
else
warn "Could not restore $BIN_PATH.bak. Restore it manually before restarting the service."
fi
fi
fi
if [ -n "$STAGED_BINARY" ]; then
rm -f "$STAGED_BINARY" || warn "Could not remove staged binary."
fi
if [ -n "$TEMP_DIR" ]; then
rm -rf "$TEMP_DIR" || warn "Could not remove temporary directory $TEMP_DIR."
fi
exit "$cleanup_status"
}
trap cleanup 0
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
# A missing crontab is normal. Keep the producer alive so the new job is written.
read_root_crontab() {
crontab -u root -l 2>/dev/null || true
}
prompt_auto_update() {
printf "\nEnable automatic daily updates for beszel-agent? (y/n): "
if ! read -r AUTO_UPDATE; then
AUTO_UPDATE=n
echo "Skipping automatic updates (no input)."
fi
}
# Default values # Default values
PORT=45876 PORT=45876
UNINSTALL=false UNINSTALL=false
@@ -304,7 +434,7 @@ HUB_URL_PROVIDED=false
VERSION="latest" VERSION="latest"
# Check for help flag # Check for help flag
case "$1" in case "${1-}" in
-h | --help) -h | --help)
printf "Beszel Agent installation script\n\n" printf "Beszel Agent installation script\n\n"
printf "Usage: ./install-agent.sh [options]\n\n" printf "Usage: ./install-agent.sh [options]\n\n"
@@ -324,6 +454,9 @@ case "$1" in
;; ;;
esac esac
# Reject unsupported hosts before sudo or any system changes.
validate_platform
# Build sudo args by properly quoting everything # Build sudo args by properly quoting everything
build_sudo_args() { build_sudo_args() {
QUOTED_ARGS="" QUOTED_ARGS=""
@@ -354,26 +487,31 @@ fi
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
-k) -k)
require_value "$@"
shift shift
KEY="$1" KEY="$1"
KEY_PROVIDED=true KEY_PROVIDED=true
;; ;;
-p) -p)
require_value "$@"
shift shift
PORT="$1" PORT="$1"
PORT_PROVIDED=true PORT_PROVIDED=true
;; ;;
-t) -t)
require_value "$@"
shift shift
TOKEN="$1" TOKEN="$1"
TOKEN_PROVIDED=true TOKEN_PROVIDED=true
;; ;;
-url) -url)
require_value "$@"
shift shift
HUB_URL="$1" HUB_URL="$1"
HUB_URL_PROVIDED=true HUB_URL_PROVIDED=true
;; ;;
-v | --version) -v | --version)
require_value "$@"
shift shift
VERSION="$1" VERSION="$1"
;; ;;
@@ -392,7 +530,7 @@ while [ $# -gt 0 ]; do
GITHUB_PROXY_URL="https://gh.beszel.dev" GITHUB_PROXY_URL="https://gh.beszel.dev"
GITHUB_URL="$GITHUB_PROXY_URL" GITHUB_URL="$GITHUB_PROXY_URL"
fi fi
elif [ "$2" != "" ] && ! echo "$2" | grep -q '^-'; then elif [ "${2-}" != "" ] && ! echo "$2" | grep -q '^-'; then
# use custom proxy URL provided as next argument # use custom proxy URL provided as next argument
GITHUB_PROXY_URL="$2" GITHUB_PROXY_URL="$2"
GITHUB_URL="$(ensure_trailing_slash "$2")https://github.com" GITHUB_URL="$(ensure_trailing_slash "$2")https://github.com"
@@ -415,7 +553,7 @@ while [ $# -gt 0 ]; do
else else
echo "Invalid value for --auto-update flag: $AUTO_UPDATE_VALUE. Using default (prompt)." echo "Invalid value for --auto-update flag: $AUTO_UPDATE_VALUE. Using default (prompt)."
fi fi
elif [ "$2" = "true" ] || [ "$2" = "false" ]; then elif [ "${2-}" = "true" ] || [ "${2-}" = "false" ]; then
# Value provided as next argument # Value provided as next argument
AUTO_UPDATE_FLAG="$2" AUTO_UPDATE_FLAG="$2"
shift shift
@@ -443,20 +581,7 @@ else
BIN_PATH="/opt/beszel-agent/beszel-agent" BIN_PATH="/opt/beszel-agent/beszel-agent"
fi fi
# Stop existing service if it exists (for upgrades) INSTALL_STEP="uninstalling the agent"
if [ "$UNINSTALL" != true ] && [ -f "$BIN_PATH" ]; then
echo "Existing installation detected. Stopping service for upgrade..."
if is_alpine; then
rc-service beszel-agent stop 2>/dev/null || true
elif is_openwrt; then
/etc/init.d/beszel-agent stop 2>/dev/null || true
elif is_freebsd; then
service beszel-agent stop 2>/dev/null || true
else
systemctl stop beszel-agent.service 2>/dev/null || true
fi
fi
# Uninstall process # Uninstall process
if [ "$UNINSTALL" = true ]; then if [ "$UNINSTALL" = true ]; then
# Clean up SELinux contexts before removing files # Clean up SELinux contexts before removing files
@@ -464,8 +589,8 @@ if [ "$UNINSTALL" = true ]; then
if is_alpine; then if is_alpine; then
echo "Stopping and disabling the agent service..." echo "Stopping and disabling the agent service..."
rc-service beszel-agent stop rc-service beszel-agent stop || warn "Cleanup command failed: rc-service beszel-agent stop"
rc-update del beszel-agent default rc-update del beszel-agent default || warn "Cleanup command failed: rc-update del beszel-agent default"
echo "Removing the OpenRC service files..." echo "Removing the OpenRC service files..."
rm -f /etc/init.d/beszel-agent rm -f /etc/init.d/beszel-agent
@@ -481,8 +606,8 @@ if [ "$UNINSTALL" = true ]; then
rm -f /var/log/beszel-agent.log /var/log/beszel-agent.err rm -f /var/log/beszel-agent.log /var/log/beszel-agent.err
elif is_openwrt; then elif is_openwrt; then
echo "Stopping and disabling the agent service..." echo "Stopping and disabling the agent service..."
/etc/init.d/beszel-agent stop /etc/init.d/beszel-agent stop || warn "Cleanup command failed: /etc/init.d/beszel-agent stop"
/etc/init.d/beszel-agent disable /etc/init.d/beszel-agent disable || warn "Cleanup command failed: /etc/init.d/beszel-agent disable"
echo "Removing the OpenWRT service files..." echo "Removing the OpenWRT service files..."
rm -f /etc/init.d/beszel-agent rm -f /etc/init.d/beszel-agent
@@ -498,7 +623,7 @@ if [ "$UNINSTALL" = true ]; then
elif is_freebsd; then elif is_freebsd; then
echo "Stopping and disabling the agent service..." echo "Stopping and disabling the agent service..."
service beszel-agent stop service beszel-agent stop || warn "Cleanup command failed: service beszel-agent stop"
sysrc beszel_agent_enable="NO" sysrc beszel_agent_enable="NO"
echo "Removing the FreeBSD service files..." echo "Removing the FreeBSD service files..."
@@ -525,16 +650,16 @@ if [ "$UNINSTALL" = true ]; then
else else
echo "Stopping and disabling the agent service..." echo "Stopping and disabling the agent service..."
systemctl stop beszel-agent.service systemctl stop beszel-agent.service || warn "Cleanup command failed: systemctl stop beszel-agent.service"
systemctl disable beszel-agent.service >/dev/null 2>&1 systemctl disable beszel-agent.service >/dev/null 2>&1 || warn "Cleanup command failed: systemctl disable beszel-agent.service"
echo "Removing the systemd service file..." echo "Removing the systemd service file..."
rm /etc/systemd/system/beszel-agent.service rm -f /etc/systemd/system/beszel-agent.service
# Remove the update timer and service if they exist # Remove the update timer and service if they exist
echo "Removing the daily update service and timer..." echo "Removing the daily update service and timer..."
systemctl stop beszel-agent-update.timer 2>/dev/null systemctl stop beszel-agent-update.timer 2>/dev/null || warn "Cleanup command failed: systemctl stop beszel-agent-update.timer"
systemctl disable beszel-agent-update.timer >/dev/null 2>&1 systemctl disable beszel-agent-update.timer >/dev/null 2>&1 || warn "Cleanup command failed: systemctl disable beszel-agent-update.timer"
rm -f /etc/systemd/system/beszel-agent-update.service rm -f /etc/systemd/system/beszel-agent-update.service
rm -f /etc/systemd/system/beszel-agent-update.timer rm -f /etc/systemd/system/beszel-agent-update.timer
@@ -545,13 +670,15 @@ if [ "$UNINSTALL" = true ]; then
rm -rf "$AGENT_DIR" rm -rf "$AGENT_DIR"
echo "Removing the dedicated user for the agent service..." echo "Removing the dedicated user for the agent service..."
killall beszel-agent 2>/dev/null killall beszel-agent 2>/dev/null || true # Usually already stopped by the service manager.
if is_alpine || is_openwrt; then if id -u beszel >/dev/null 2>&1; then
deluser beszel 2>/dev/null if is_alpine || is_openwrt; then
elif is_freebsd; then deluser beszel || fail "Could not remove the beszel user."
pw user del beszel 2>/dev/null elif is_freebsd; then
else pw user del beszel || fail "Could not remove the beszel user."
userdel beszel 2>/dev/null else
userdel beszel || fail "Could not remove the beszel user."
fi
fi fi
echo "Beszel Agent has been uninstalled successfully!" echo "Beszel Agent has been uninstalled successfully!"
@@ -563,6 +690,7 @@ package_installed() {
command -v "$1" >/dev/null 2>&1 command -v "$1" >/dev/null 2>&1
} }
INSTALL_STEP="installing required packages"
# Check for package manager and install necessary packages if not installed # Check for package manager and install necessary packages if not installed
if package_installed apk; then if package_installed apk; then
if ! package_installed tar || ! package_installed curl || ! package_installed sha256sum; then if ! package_installed tar || ! package_installed curl || ! package_installed sha256sum; then
@@ -596,13 +724,18 @@ else
echo "Warning: Please ensure 'tar' and 'curl' and 'sha256sum (coreutils)' are installed." echo "Warning: Please ensure 'tar' and 'curl' and 'sha256sum (coreutils)' are installed."
fi fi
# If no SSH key is provided, ask for the SSH key interactively (skip if upgrading) for required_command in tar curl; do
command -v "$required_command" >/dev/null || fail "Required command is missing: $required_command"
done
# If no SSH key is provided, prompt unless service setup will reuse configuration.
if [ -z "$KEY" ]; then if [ -z "$KEY" ]; then
if [ -f "$BIN_PATH" ]; then if agent_configuration_exists; then
echo "Upgrading existing installation. Using existing service configuration." echo "Using existing service configuration."
else else
printf "Enter your SSH key: " printf "Enter your SSH key: "
read KEY read -r KEY || fail "No SSH key received. Supply -k for noninteractive installation."
[ -n "$KEY" ] || fail "SSH key must not be empty."
fi fi
fi fi
@@ -623,6 +756,7 @@ else
exit 1 exit 1
fi fi
INSTALL_STEP="configuring the service user"
# Create a dedicated user for the service if it doesn't exist # Create a dedicated user for the service if it doesn't exist
AGENT_USER="beszel" AGENT_USER="beszel"
echo "Configuring the dedicated user for the Beszel Agent service..." echo "Configuring the dedicated user for the Beszel Agent service..."
@@ -678,6 +812,11 @@ elif is_freebsd; then
echo "Adding beszel to wheel group for self-updates" echo "Adding beszel to wheel group for self-updates"
pw group mod wheel -m beszel pw group mod wheel -m beszel
fi fi
# Add the user to the operator group for device access (SMART, /dev/xpt0, /dev/nvme*)
if pw group show operator >/dev/null 2>&1; then
echo "Adding beszel to operator group for device access"
pw group mod operator -m beszel
fi
fi fi
else else
@@ -696,6 +835,7 @@ else
fi fi
fi fi
INSTALL_STEP="creating installation directories"
# Create the directory for the Beszel Agent # Create the directory for the Beszel Agent
if [ ! -d "$AGENT_DIR" ]; then if [ ! -d "$AGENT_DIR" ]; then
@@ -709,6 +849,7 @@ if [ ! -d "$BIN_DIR" ]; then
mkdir -p "$BIN_DIR" mkdir -p "$BIN_DIR"
fi fi
INSTALL_STEP="downloading and verifying the agent"
# Download and install the Beszel Agent # Download and install the Beszel Agent
OS=$(uname -s | sed -e 'y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/') OS=$(uname -s | sed -e 'y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/')
@@ -720,11 +861,12 @@ fi
# Determine version to install # Determine version to install
if [ "$VERSION" = "latest" ]; then if [ "$VERSION" = "latest" ]; then
INSTALL_VERSION=$(curl -s "https://get.beszel.dev/latest-version") INSTALL_VERSION=$(curl -fsS --connect-timeout 10 --max-time 30 "https://get.beszel.dev/latest-version") || INSTALL_VERSION=""
if [ -z "$INSTALL_VERSION" ]; then if [ -z "$INSTALL_VERSION" ]; then
# Fallback to GitHub API # Fallback to GitHub API
API_RELEASE_URL="https://api.github.com/repos/henrygd/beszel/releases/latest" API_RELEASE_URL="https://api.github.com/repos/henrygd/beszel/releases/latest"
INSTALL_VERSION=$(curl -s "$API_RELEASE_URL" | grep -o '"tag_name": "v[^"]*"' | cut -d'"' -f4 | tr -d 'v') RELEASE_JSON=$(curl -fsS --connect-timeout 10 --max-time 30 "$API_RELEASE_URL") || fail "Could not fetch the latest release from GitHub."
INSTALL_VERSION=$(printf '%s\n' "$RELEASE_JSON" | grep -o '"tag_name": "v[^"]*"' | cut -d'"' -f4 | tr -d 'v')
fi fi
if [ -z "$INSTALL_VERSION" ]; then if [ -z "$INSTALL_VERSION" ]; then
echo "Failed to get latest version" echo "Failed to get latest version"
@@ -741,7 +883,8 @@ echo "Downloading beszel-agent v${INSTALL_VERSION}..."
# Download checksums file # Download checksums file
TEMP_DIR=$(mktemp -d) TEMP_DIR=$(mktemp -d)
cd "$TEMP_DIR" || exit 1 cd "$TEMP_DIR" || exit 1
CHECKSUM=$(curl -fsSL "$GITHUB_URL/henrygd/beszel/releases/download/v${INSTALL_VERSION}/beszel_${INSTALL_VERSION}_checksums.txt" | grep "$FILE_NAME" | cut -d' ' -f1) curl -fsSL --connect-timeout 10 --max-time 60 "$GITHUB_URL/henrygd/beszel/releases/download/v${INSTALL_VERSION}/beszel_${INSTALL_VERSION}_checksums.txt" -o checksums.txt || fail "Could not download checksums. Try --mirror if GitHub is unreachable."
CHECKSUM=$(awk -v name="$FILE_NAME" '$2 == name { print $1 }' checksums.txt)
if [ -z "$CHECKSUM" ] || ! echo "$CHECKSUM" | grep -qE "^[a-fA-F0-9]{64}$"; then if [ -z "$CHECKSUM" ] || ! echo "$CHECKSUM" | grep -qE "^[a-fA-F0-9]{64}$"; then
echo "Failed to get checksum or invalid checksum format" echo "Failed to get checksum or invalid checksum format"
echo "Try again with --mirror (or --mirror <url>) if GitHub is not reachable." echo "Try again with --mirror (or --mirror <url>) if GitHub is not reachable."
@@ -763,10 +906,10 @@ if ! tar -tzf "$FILE_NAME" >/dev/null 2>&1; then
exit 1 exit 1
fi fi
if [ "$($CHECK_CMD "$FILE_NAME" | cut -d' ' -f1)" != "$CHECKSUM" ]; then ACTUAL_CHECKSUM=$($CHECK_CMD "$FILE_NAME") || fail "Could not calculate archive checksum."
echo "Checksum verification failed: $($CHECK_CMD "$FILE_NAME" | cut -d' ' -f1) & $CHECKSUM" ACTUAL_CHECKSUM=${ACTUAL_CHECKSUM%% *}
rm -rf "$TEMP_DIR" if [ "$ACTUAL_CHECKSUM" != "$CHECKSUM" ]; then
exit 1 fail "Checksum verification failed: $ACTUAL_CHECKSUM != $CHECKSUM"
fi fi
if ! tar -xzf "$FILE_NAME" beszel-agent; then if ! tar -xzf "$FILE_NAME" beszel-agent; then
@@ -781,20 +924,34 @@ if [ ! -s "$TEMP_DIR/beszel-agent" ]; then
exit 1 exit 1
fi fi
INSTALL_STEP="replacing the agent binary"
# Stage on the destination filesystem so replacement and rollback use atomic renames.
STAGED_BINARY=$(mktemp "$BIN_PATH.XXXXXX") || fail "Could not create a staged binary."
cp beszel-agent "$STAGED_BINARY" || fail "Could not stage the agent binary."
chown "${AGENT_USER}:${AGENT_USER}" "$STAGED_BINARY" || fail "Could not set binary ownership."
chmod 755 "$STAGED_BINARY" || fail "Could not set binary permissions."
if [ -f "$BIN_PATH" ]; then if [ -f "$BIN_PATH" ]; then
echo "Backing up existing binary..." echo "Backing up existing binary..."
cp "$BIN_PATH" "$BIN_PATH.bak" cp -p "$BIN_PATH" "$BIN_PATH.bak" || fail "Could not back up the existing binary."
UPGRADE_PENDING=true
if agent_service_registered; then
agent_service stop || fail "Could not stop the existing agent."
else
service_check_status=$?
[ "$service_check_status" -eq 1 ] || fail "Could not determine whether the existing agent service is registered."
fi
fi fi
mv beszel-agent "$BIN_PATH" mv -f "$STAGED_BINARY" "$BIN_PATH" || fail "Could not install the agent binary."
chown "${AGENT_USER}:${AGENT_USER}" "$BIN_PATH" STAGED_BINARY=""
chmod 755 "$BIN_PATH"
# Set SELinux context if needed # Set SELinux context if needed
set_selinux_context set_selinux_context
# Cleanup # Cleanup
rm -rf "$TEMP_DIR" rm -rf "$TEMP_DIR"
TEMP_DIR=""
# Make sure /etc/machine-id exists and is non-empty for persistent fingerprint # Make sure /etc/machine-id exists and is non-empty for persistent fingerprint
if [ ! -s /etc/machine-id ]; then if [ ! -s /etc/machine-id ]; then
@@ -819,6 +976,7 @@ detect_nvidia_devices() {
echo "$devices" echo "$devices"
} }
INSTALL_STEP="configuring and starting the service"
# Modify service installation part, add Alpine check before systemd service creation # Modify service installation part, add Alpine check before systemd service creation
if is_alpine; then if is_alpine; then
if [ ! -f /etc/init.d/beszel-agent ]; then if [ ! -f /etc/init.d/beszel-agent ]; then
@@ -868,7 +1026,7 @@ EOF
chown "${AGENT_USER}:${AGENT_USER}" /var/log/beszel-agent.log /var/log/beszel-agent.err chown "${AGENT_USER}:${AGENT_USER}" /var/log/beszel-agent.log /var/log/beszel-agent.err
# Start the service # Start the service
rc-service beszel-agent restart rc-service beszel-agent restart || fail "Could not start the agent; check service logs."
# Check if service started successfully # Check if service started successfully
sleep 2 sleep 2
@@ -884,8 +1042,7 @@ EOF
elif [ "$AUTO_UPDATE_FLAG" = "false" ]; then elif [ "$AUTO_UPDATE_FLAG" = "false" ]; then
AUTO_UPDATE="n" AUTO_UPDATE="n"
else else
printf "\nEnable automatic daily updates for beszel-agent? (y/n): " prompt_auto_update
read AUTO_UPDATE
fi fi
case "$AUTO_UPDATE" in case "$AUTO_UPDATE" in
[Yy]*) [Yy]*)
@@ -893,7 +1050,7 @@ EOF
# Create cron job to run beszel-agent update command daily at midnight # Create cron job to run beszel-agent update command daily at midnight
if ! crontab -u root -l 2>/dev/null | grep -q "beszel-agent.*update"; then if ! crontab -u root -l 2>/dev/null | grep -q "beszel-agent.*update"; then
(crontab -u root -l 2>/dev/null; echo "12 0 * * * $BIN_PATH update >/dev/null 2>&1") | crontab -u root - (read_root_crontab; echo "12 0 * * * $BIN_PATH update >/dev/null 2>&1") | crontab -u root -
fi fi
printf "\nDaily updates have been enabled via cron job.\n" printf "\nDaily updates have been enabled via cron job.\n"
@@ -963,7 +1120,7 @@ EOF
fi fi
# Start the service # Start the service
/etc/init.d/beszel-agent restart /etc/init.d/beszel-agent restart || fail "Could not start the agent; check service logs."
# Auto-update service for OpenWRT using a crontab job # Auto-update service for OpenWRT using a crontab job
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
@@ -973,15 +1130,14 @@ EOF
AUTO_UPDATE="n" AUTO_UPDATE="n"
sleep 1 # give time for the service to start sleep 1 # give time for the service to start
else else
printf "\nEnable automatic daily updates for beszel-agent? (y/n): " prompt_auto_update
read AUTO_UPDATE
fi fi
case "$AUTO_UPDATE" in case "$AUTO_UPDATE" in
[Yy]*) [Yy]*)
echo "Setting up daily automatic updates for beszel-agent..." echo "Setting up daily automatic updates for beszel-agent..."
if ! crontab -u root -l 2>/dev/null | grep -q "beszel-agent.*update"; then if ! crontab -u root -l 2>/dev/null | grep -q "beszel-agent.*update"; then
(crontab -u root -l 2>/dev/null; echo "12 0 * * * /etc/init.d/beszel-agent update") | crontab -u root - (read_root_crontab; echo "12 0 * * * /etc/init.d/beszel-agent update") | crontab -u root -
fi fi
/etc/init.d/cron restart /etc/init.d/cron restart
@@ -1072,7 +1228,7 @@ EOF
esac esac
fi fi
service beszel-agent restart service beszel-agent restart || fail "Could not start the agent; check service logs."
# Check if service started successfully # Check if service started successfully
sleep 2 sleep 2
@@ -1088,8 +1244,7 @@ EOF
elif [ "$AUTO_UPDATE_FLAG" = "false" ]; then elif [ "$AUTO_UPDATE_FLAG" = "false" ]; then
AUTO_UPDATE="n" AUTO_UPDATE="n"
else else
printf "\nEnable automatic daily updates for beszel-agent? (y/n): " prompt_auto_update
read AUTO_UPDATE
fi fi
case "$AUTO_UPDATE" in case "$AUTO_UPDATE" in
[Yy]*) [Yy]*)
@@ -1170,7 +1325,7 @@ EOF
printf "\nLoading and starting the agent service...\n" printf "\nLoading and starting the agent service...\n"
systemctl daemon-reload systemctl daemon-reload
systemctl enable beszel-agent.service >/dev/null 2>&1 systemctl enable beszel-agent.service >/dev/null 2>&1
systemctl restart beszel-agent.service systemctl restart beszel-agent.service || fail "Could not start the agent; check service logs."
@@ -1182,8 +1337,7 @@ EOF
AUTO_UPDATE="n" AUTO_UPDATE="n"
sleep 1 # give time for the service to start sleep 1 # give time for the service to start
else else
printf "\nEnable automatic daily updates for beszel-agent? (y/n): " prompt_auto_update
read AUTO_UPDATE
fi fi
case "$AUTO_UPDATE" in case "$AUTO_UPDATE" in
[Yy]*) [Yy]*)
@@ -1229,6 +1383,7 @@ EOF
fi fi
fi fi
UPGRADE_PENDING=false
RUNNING_ADDRESS=$(configured_address) RUNNING_ADDRESS=$(configured_address)
[ -n "$RUNNING_ADDRESS" ] || RUNNING_ADDRESS=$PORT [ -n "$RUNNING_ADDRESS" ] || RUNNING_ADDRESS=$PORT