diff --git a/agent/utils/utils.go b/agent/utils/utils.go index b8e8d993..8029b2db 100644 --- a/agent/utils/utils.go +++ b/agent/utils/utils.go @@ -1,3 +1,4 @@ +// Package utils provides utility functions for the agent. package utils import ( diff --git a/internal/hub/api.go b/internal/hub/api.go index e8067ebe..d2d009c0 100644 --- a/internal/hub/api.go +++ b/internal/hub/api.go @@ -14,6 +14,7 @@ import ( "github.com/henrygd/beszel/internal/ghupdate" "github.com/henrygd/beszel/internal/hub/config" "github.com/henrygd/beszel/internal/hub/systems" + "github.com/henrygd/beszel/internal/hub/utils" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" @@ -70,13 +71,13 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) { return e.Next() } // authenticate with trusted header - if autoLogin, _ := GetEnv("AUTO_LOGIN"); autoLogin != "" { + if autoLogin, _ := utils.GetEnv("AUTO_LOGIN"); autoLogin != "" { se.Router.BindFunc(func(e *core.RequestEvent) error { return authorizeRequestWithEmail(e, autoLogin) }) } // authenticate with trusted header - if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" { + if trustedHeader, _ := utils.GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" { se.Router.BindFunc(func(e *core.RequestEvent) error { return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader)) }) @@ -104,7 +105,7 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { apiAuth.GET("/info", h.getInfo) apiAuth.GET("/getkey", h.getInfo) // deprecated - keep for compatibility w/ integrations // check for updates - if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" { + if optIn, _ := utils.GetEnv("CHECK_UPDATES"); optIn == "true" { var updateInfo UpdateInfo apiAuth.GET("/update", updateInfo.getUpdate) } @@ -127,7 +128,7 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { // get systemd service details apiAuth.GET("/systemd/info", h.getSystemdInfo) // /containers routes - if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" { + if enabled, _ := utils.GetEnv("CONTAINER_DETAILS"); enabled != "false" { // get container logs apiAuth.GET("/containers/logs", h.getContainerLogs) // get container info @@ -147,7 +148,7 @@ func (h *Hub) getInfo(e *core.RequestEvent) error { Key: h.pubKey, Version: beszel.Version, } - if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" { + if optIn, _ := utils.GetEnv("CHECK_UPDATES"); optIn == "true" { info.CheckUpdate = true } return e.JSON(http.StatusOK, info) @@ -315,7 +316,7 @@ func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*syst } system, err := h.sm.GetSystem(systemID) - if err != nil || !system.HasUser(e.App, e.Auth.Id) { + if err != nil || !system.HasUser(e.App, e.Auth) { return e.NotFoundError("", nil) } @@ -350,7 +351,7 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error { 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) { + if err != nil || !system.HasUser(e.App, e.Auth) { return e.NotFoundError("", nil) } // verify service exists before fetching details @@ -378,7 +379,7 @@ func (h *Hub) refreshSmartData(e *core.RequestEvent) error { } system, err := h.sm.GetSystem(systemID) - if err != nil || !system.HasUser(e.App, e.Auth.Id) { + if err != nil || !system.HasUser(e.App, e.Auth) { return e.NotFoundError("", nil) } diff --git a/internal/hub/api_test.go b/internal/hub/api_test.go index 7981dfd6..15bb7445 100644 --- a/internal/hub/api_test.go +++ b/internal/hub/api_test.go @@ -344,6 +344,23 @@ func TestApiRoutesAuthentication(t *testing.T) { "Authorization": user2Token, }, }, + { + Name: "GET /containers/info - SHARE_ALL_SYSTEMS allows non-member user", + Method: http.MethodGet, + URL: fmt.Sprintf("/api/beszel/containers/info?system=%s&container=abababababab", system.Id), + ExpectedStatus: 500, + ExpectedContent: []string{"Something went wrong while processing your request."}, + TestAppFactory: testAppFactory, + Headers: map[string]string{ + "Authorization": user2Token, + }, + BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { + t.Setenv("SHARE_ALL_SYSTEMS", "true") + }, + AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) { + t.Setenv("SHARE_ALL_SYSTEMS", "") + }, + }, { Name: "GET /containers/logs - with auth but missing system param should fail", Method: http.MethodGet, diff --git a/internal/hub/collections.go b/internal/hub/collections.go index d8013b17..5b939a44 100644 --- a/internal/hub/collections.go +++ b/internal/hub/collections.go @@ -1,6 +1,9 @@ package hub -import "github.com/pocketbase/pocketbase/core" +import ( + "github.com/henrygd/beszel/internal/hub/utils" + "github.com/pocketbase/pocketbase/core" +) type collectionRules struct { list *string @@ -22,11 +25,11 @@ func setCollectionAuthSettings(app core.App) error { } // disable email auth if DISABLE_PASSWORD_AUTH env var is set - disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH") + disablePasswordAuth, _ := utils.GetEnv("DISABLE_PASSWORD_AUTH") usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true" usersCollection.PasswordAuth.IdentityFields = []string{"email"} // allow oauth user creation if USER_CREATION is set - if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" { + if userCreation, _ := utils.GetEnv("USER_CREATION"); userCreation == "true" { cr := "@request.context = 'oauth2'" usersCollection.CreateRule = &cr } else { @@ -34,7 +37,7 @@ func setCollectionAuthSettings(app core.App) error { } // enable mfaOtp mfa if MFA_OTP env var is set - mfaOtp, _ := GetEnv("MFA_OTP") + mfaOtp, _ := utils.GetEnv("MFA_OTP") usersCollection.OTP.Length = 6 superusersCollection.OTP.Length = 6 usersCollection.OTP.Enabled = mfaOtp == "true" @@ -50,7 +53,7 @@ func setCollectionAuthSettings(app core.App) error { // When SHARE_ALL_SYSTEMS is enabled, any authenticated user can read // system-scoped data. Write rules continue to block readonly users. - shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS") + shareAllSystems, _ := utils.GetEnv("SHARE_ALL_SYSTEMS") authenticatedRule := "@request.auth.id != \"\"" systemsMemberRule := authenticatedRule + " && users.id ?= @request.auth.id" diff --git a/internal/hub/hub.go b/internal/hub/hub.go index badca8c4..13cd7e6a 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -15,6 +15,7 @@ import ( "github.com/henrygd/beszel/internal/hub/config" "github.com/henrygd/beszel/internal/hub/heartbeat" "github.com/henrygd/beszel/internal/hub/systems" + "github.com/henrygd/beszel/internal/hub/utils" "github.com/henrygd/beszel/internal/records" "github.com/henrygd/beszel/internal/users" @@ -44,7 +45,7 @@ func NewHub(app core.App) *Hub { hub.um = users.NewUserManager(hub) hub.rm = records.NewRecordManager(hub) hub.sm = systems.NewSystemManager(hub) - hub.hb = heartbeat.New(app, GetEnv) + hub.hb = heartbeat.New(app, utils.GetEnv) if hub.hb != nil { hub.hbStop = make(chan struct{}) } @@ -52,15 +53,6 @@ func NewHub(app core.App) *Hub { return hub } -// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key. -func GetEnv(key string) (value string, exists bool) { - if value, exists = os.LookupEnv("BESZEL_HUB_" + key); exists { - return value, exists - } - // Fallback to the old unprefixed key - return os.LookupEnv(key) -} - // onAfterBootstrapAndMigrations ensures the provided function runs after the database is set up and migrations are applied. // This is a workaround for behavior in PocketBase where onBootstrap runs before migrations, forcing use of onServe for this purpose. // However, PB's tests.TestApp is already bootstrapped, generally doesn't serve, but does handle migrations. @@ -131,7 +123,7 @@ func (h *Hub) initialize(app core.App) error { // batch requests (for alerts) settings.Batch.Enabled = true // set URL if APP_URL env is set - if appURL, isSet := GetEnv("APP_URL"); isSet { + if appURL, isSet := utils.GetEnv("APP_URL"); isSet { h.appURL = appURL settings.Meta.AppURL = appURL } diff --git a/internal/hub/server_production.go b/internal/hub/server_production.go index 59ca52a1..d2a5b464 100644 --- a/internal/hub/server_production.go +++ b/internal/hub/server_production.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/henrygd/beszel" + "github.com/henrygd/beszel/internal/hub/utils" "github.com/henrygd/beszel/internal/site" "github.com/pocketbase/pocketbase/apis" @@ -32,7 +33,7 @@ func (h *Hub) startServer(se *core.ServeEvent) error { staticPaths := [2]string{"/static/", "/assets/"} serveStatic := apis.Static(site.DistDirFS, false) // get CSP configuration - csp, cspExists := GetEnv("CSP") + csp, cspExists := utils.GetEnv("CSP") // add route se.Router.GET("/{path...}", func(e *core.RequestEvent) error { // serve static assets if path is in staticPaths diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index 28f357a3..947ef257 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -15,6 +15,7 @@ import ( "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/hub/transport" + "github.com/henrygd/beszel/internal/hub/utils" "github.com/henrygd/beszel/internal/hub/ws" "github.com/henrygd/beszel/internal/entities/container" @@ -353,14 +354,21 @@ func (sys *System) getRecord(app core.App) (*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 { +// HasUser checks if the given user is in the system's users list. +// Returns true if SHARE_ALL_SYSTEMS is enabled (any authenticated user can access any system). +func (sys *System) HasUser(app core.App, user *core.Record) bool { + if user == nil { + return false + } + if v, _ := utils.GetEnv("SHARE_ALL_SYSTEMS"); v == "true" { + return true + } record, err := sys.getRecord(app) if err != nil { return false } users := record.GetStringSlice("users") - return slices.Contains(users, userID) + return slices.Contains(users, user.Id) } // setDown marks a system as down in the database. diff --git a/internal/hub/systems/systems_test.go b/internal/hub/systems/systems_test.go index b734edda..1ddb817f 100644 --- a/internal/hub/systems/systems_test.go +++ b/internal/hub/systems/systems_test.go @@ -421,3 +421,51 @@ func testOld(t *testing.T, hub *tests.TestHub) { assert.NoError(t, err) }) } + +func TestHasUser(t *testing.T) { + hub, err := tests.NewTestHub(t.TempDir()) + require.NoError(t, err) + defer hub.Cleanup() + + sm := hub.GetSystemManager() + err = sm.Initialize() + require.NoError(t, err) + + user1, err := tests.CreateUser(hub, "user1@test.com", "password123") + require.NoError(t, err) + user2, err := tests.CreateUser(hub, "user2@test.com", "password123") + require.NoError(t, err) + + record, err := tests.CreateRecord(hub, "systems", map[string]any{ + "name": "has-user-test", + "host": "127.0.0.1", + "port": "33914", + "users": []string{user1.Id}, + }) + require.NoError(t, err) + + sys, err := sm.GetSystemFromStore(record.Id) + require.NoError(t, err) + + t.Run("user in list returns true", func(t *testing.T) { + assert.True(t, sys.HasUser(hub, user1)) + }) + + t.Run("user not in list returns false", func(t *testing.T) { + assert.False(t, sys.HasUser(hub, user2)) + }) + + t.Run("unknown user ID returns false", func(t *testing.T) { + assert.False(t, sys.HasUser(hub, nil)) + }) + + t.Run("SHARE_ALL_SYSTEMS=true grants access to non-member", func(t *testing.T) { + t.Setenv("SHARE_ALL_SYSTEMS", "true") + assert.True(t, sys.HasUser(hub, user2)) + }) + + t.Run("BESZEL_HUB_SHARE_ALL_SYSTEMS=true grants access to non-member", func(t *testing.T) { + t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "true") + assert.True(t, sys.HasUser(hub, user2)) + }) +} diff --git a/internal/hub/utils/utils.go b/internal/hub/utils/utils.go new file mode 100644 index 00000000..43838868 --- /dev/null +++ b/internal/hub/utils/utils.go @@ -0,0 +1,12 @@ +// Package utils provides utility functions for the hub. +package utils + +import "os" + +// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key. +func GetEnv(key string) (value string, exists bool) { + if value, exists = os.LookupEnv("BESZEL_HUB_" + key); exists { + return value, exists + } + return os.LookupEnv(key) +}