Compare commits

..

11 Commits

Author SHA1 Message Date
henrygd
0fff699bf6 hub: return error if accessing /api/beszel/universal-token with a superuser account (#1870) 2026-04-01 22:16:47 -04:00
henrygd
ba10da1b9f hub: add additional validation checks for custom api routes
- Validate the user is assigned to system in authenticated routes where
the user passes in system ID. This protects against a somewhat
impractical scenario where an authenticated user cracks a random 15
character alphanumeric ID of a system that doesn't belong to them via
web API.
- Validate that systemd service exists in database before requesting
service details from agent. This protects against authenticated users
getting unit properties of services that aren't explicitly monitored.
- Refactor responses in authenticated routes to prevent enumeration of
other users' random 15 char system IDs.
2026-04-01 16:30:45 -04:00
henrygd
7f4f14b505 fix(agent,windows): raise timeout on first sensor collection to allow LHM to start 2026-03-31 16:10:59 -04:00
henrygd
2fda4ff264 agent: update LibreHardwareMonitorLib to 0.9.6 2026-03-31 15:55:02 -04:00
henrygd
20b0b40ec8 ui: no centered dialog headings and a few other tweaks 2026-03-31 15:40:52 -04:00
Malith Rukshan
d548a012b4 fix(ui): revert CardTitle to text-2xl to fix tailwind-merge class override (#1860) 2026-03-31 14:55:23 -04:00
henrygd
ce5d1217dd fix(hub): cancel pending down status alert if system paused before alert sent 2026-03-31 14:08:44 -04:00
henrygd
cef09d7cb1 fix(agent): fix windows root disk detection if exe not running on root disk (#1863) 2026-03-31 12:58:42 -04:00
henrygd
f6440acb43 fix(ui): hide noop add system btn and smart actions for readonly users 2026-03-30 19:45:12 -04:00
henrygd
5463a38f0f refactor(hub): move api user role checks to middlewares 2026-03-30 19:35:02 -04:00
Sven van Ginkel
80135fdad3 fix(agent): exclude nested virtual fs when mounting host root to /extra-filesystems in Docker (#1859) 2026-03-30 13:48:54 -04:00
22 changed files with 528 additions and 106 deletions

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ type SensorConfig struct {
isBlacklist bool
hasWildcards bool
skipCollection bool
firstRun bool
}
func (a *Agent) newSensorConfig() *SensorConfig {
@@ -52,6 +53,7 @@ func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal
context: context.Background(),
primarySensor: primarySensor,
skipCollection: skipCollection,
firstRun: true,
sensors: make(map[string]struct{}),
}
@@ -167,6 +169,14 @@ func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureS
err error
}
// Use a longer timeout on the first run to allow for initialization
// (e.g. Windows LHM subprocess startup)
timeout := temperatureFetchTimeout
if a.sensorConfig.firstRun {
a.sensorConfig.firstRun = false
timeout = 10 * time.Second
}
resultCh := make(chan result, 1)
go func() {
temps, err := a.getTempsWithPanicRecovery(getTemps)
@@ -176,7 +186,7 @@ func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureS
select {
case res := <-resultCh:
return res.temps, res.err
case <-time.After(temperatureFetchTimeout):
case <-time.After(timeout):
return nil, errTemperatureFetchTimeout
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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