Compare commits

..
Author SHA1 Message Date
José M. Requena PlensandGitHub 4bf70700f2 feat(hub): add TRUSTED_PROXY_IPS allowlist for TRUSTED_AUTH_HEADER (#2327)
With TRUSTED_AUTH_HEADER set, the hub authenticates a request from the
header alone, whichever address it comes from. That is right when every
request passes through the reverse proxy, and not when the hub can also
be reached directly: anyone who can reach it sets the header themselves.

TRUSTED_PROXY_IPS takes a comma-separated list of IPs or CIDR ranges.
When set, the header is only honored on requests whose peer address is
in the list; other requests fall through to the normal authentication.
When unset, nothing changes.

The check uses the connection's RemoteAddr, not a forwarded header, so
the list names the proxy itself. IPv4-mapped IPv6 entries are treated as
IPv4. Entries that do not parse are skipped with a warning on the
console; a list with no valid entry trusts nobody, so a typo narrows the
allowlist instead of widening it.
2026-09-17 13:48:46 -04:00
henrygd 18f7a4bbc0 agent: revert #2275 warning on certain SMART attributes (#2296, #2308, #2347) 2026-09-17 11:48:01 -04:00
f0f1f7985c feat: persist view preferences and language to user settings (#1831)
Co-authored-by: ChangkeunJ <reiot92@gmail.com>
2026-09-16 20:29:36 -04:00
50f6fc075d Merge commit from fork
* fix: make first-user bootstrap atomic

* add tests

---------

Co-authored-by: henrygd <hank@henrygd.me>
2026-09-16 20:13:32 -04:00
henrygd a0bf338796 hub: raise max batch requests and lower max batch body size 2026-09-16 19:39:07 -04:00
982101743e fix(zfs): skip zpool list when /dev/zfs unavailable in Linux (#2325)
Co-authored-by: henrygd <hank@henrygd.me>
2026-09-16 13:28:47 -04:00
Petr RajtslegrandGitHub 6a7b2772d9 fix(site): switch theme live when system preference changes (#2328)
Listen for prefers-color-scheme changes in ThemeProvider and expose resolvedTheme so the login border color follows the active theme without a hard refresh.
2026-09-13 12:40:21 -04:00
David JangandGitHub 086091a0fe fix(site): discard pending history when switching to live charts (#2333) 2026-09-13 12:32:04 -04:00
26 changed files with 946 additions and 132 deletions
-3
View File
@@ -931,9 +931,6 @@ func (sm *SmartManager) parseSmartForSata(output []byte, deviceType string) (boo
if parsed, ok := smart.ParseSmartRawValueString(attr.Raw.String); ok {
rawValue = parsed
}
if smartData.SmartStatus == "PASSED" && rawValue > 0 && (attr.ID == 5 || attr.ID == 197 || attr.ID == 198) {
smartData.SmartStatus = "WARNING"
}
smartAttr := &smart.SmartAttribute{
ID: attr.ID,
Name: attr.Name,
-22
View File
@@ -7,7 +7,6 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"testing"
"github.com/henrygd/beszel/internal/entities/smart"
@@ -90,27 +89,6 @@ func TestParseSmartForSata(t *testing.T) {
}
}
func TestParseSmartForSataWarnsForCriticalAttributes(t *testing.T) {
for _, attrID := range []int{5, 197, 198} {
t.Run("attribute "+strconv.Itoa(attrID), func(t *testing.T) {
jsonPayload := []byte(fmt.Sprintf(`{
"smartctl": {"exit_status": 0},
"device": {"name": "/dev/sda", "type": "sat"},
"model_name": "Example",
"serial_number": "WARNING%d",
"smart_status": {"passed": true},
"temperature": {"current": 30},
"ata_smart_attributes": {"table": [{"id": %d, "raw": {"value": 1, "string": "1"}}]}
}`, attrID, attrID))
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, _ := sm.parseSmartForSata(jsonPayload, "")
require.True(t, hasData)
assert.Equal(t, "WARNING", sm.SmartDataMap[fmt.Sprintf("WARNING%d", attrID)].SmartStatus)
})
}
}
func TestParseSmartForSataPreservesFailedAndUnknownStatus(t *testing.T) {
for _, test := range []struct {
name string
+1 -1
View File
@@ -80,7 +80,7 @@ func newZfsBackend() *poolBackend {
return &poolBackend{
name: "zfs",
poolStatsFn: optionalPoolSource(zfs.PoolStats),
datasetsFn: zfs.Datasets,
datasetsFn: optionalPoolSource(zfs.Datasets),
kernelStatsFn: optionalPoolSource(zfs.PoolKernelStats),
poolStatusesFn: optionalPoolSource(zfs.PoolStatuses),
}
+15
View File
@@ -323,6 +323,21 @@ func TestDatasetUsageRefreshOnErrorKeepsPrevious(t *testing.T) {
assert.Len(t, usage, 1, "previous usage should be retained on error")
}
func TestDatasetUsageClearsAbsentBackend(t *testing.T) {
b := newZfsBackend()
b.datasetUsage = map[string]zfsDatasetUsage{"/tank": {used: 1, avail: 1}}
b.datasetsFn = optionalPoolSource(func() ([]zfs.Dataset, error) {
return nil, zfs.ErrNoZfs
})
datasets, err := b.datasets()
require.NoError(t, err, "an absent backend must not produce an error to log")
assert.Empty(t, datasets)
b.refreshDatasetUsage()
assert.Empty(t, b.datasetUsage)
assert.False(t, b.lastUsageRefresh.IsZero())
}
func TestGetDetailForceRefresh(t *testing.T) {
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
poolCalls := 0
+6
View File
@@ -70,6 +70,9 @@ type Dataset struct {
// PoolStats returns capacity and health for all pools on the system using
// `zpool list`. Frequent health and I/O sampling uses PoolKernelStats instead.
func PoolStats() ([]PoolStat, error) {
if err := checkZfsDevice(); err != nil {
return nil, err
}
out, err := commandOutput("zpool", "list", "-Hp", "-o", "name,size,alloc,free,health")
if err != nil {
var exitErr *exec.ExitError
@@ -84,6 +87,9 @@ func PoolStats() ([]PoolStat, error) {
// Datasets returns all datasets on the system with usage and mountpoint
// information using `zfs list` (recursive by default).
func Datasets() ([]Dataset, error) {
if err := checkZfsDevice(); err != nil {
return nil, err
}
out, err := commandOutput("zfs", "list", "-Hp", "-o", "name,used,avail,mountpoint")
if err != nil {
return nil, fmt.Errorf("zfs list: %w", err)
+17 -1
View File
@@ -13,7 +13,10 @@ import (
"strings"
)
var procZfsPath = "/proc/spl/kstat/zfs"
var (
procZfsPath = "/proc/spl/kstat/zfs"
devZfsPath = "/dev/zfs"
)
func ARCSize() (uint64, error) {
file, err := os.Open(filepath.Join(procZfsPath, "arcstats"))
@@ -40,6 +43,19 @@ func ARCSize() (uint64, error) {
return 0, fmt.Errorf("size field not found in arcstats")
}
// checkZfsDevice lets containers without /dev/zfs fail fast instead of
// waiting for ZFS utility commands to time out.
func checkZfsDevice() error {
_, err := os.Stat(devZfsPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ErrNoZfs
}
return err
}
return nil
}
// PoolKernelStats reads pool state and cumulative I/O counters directly from
// procfs. These kstats are the same interfaces used by node_exporter's Linux
// ZFS collector and avoid keeping a `zpool iostat` subprocess alive.
+63
View File
@@ -88,3 +88,66 @@ func TestReadObjsetIORequiresAllCounters(t *testing.T) {
_, _, err := readObjsetIO(path)
require.Error(t, err)
}
func TestCollectorsSkipCommandsWhenDevZfsMissing(t *testing.T) {
root := t.TempDir()
oldDevZfsPath := devZfsPath
devZfsPath = filepath.Join(root, "missing")
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
oldCommandOutput := commandOutput
commandOutput = func(name string, args ...string) ([]byte, error) {
t.Fatalf("unexpected %s call with %v", name, args)
return nil, nil
}
t.Cleanup(func() { commandOutput = oldCommandOutput })
_, err := PoolStats()
assert.ErrorIs(t, err, ErrNoZfs)
_, err = Datasets()
assert.ErrorIs(t, err, ErrNoZfs)
}
func TestDatasetsDelegatesWhenDevZfsPresent(t *testing.T) {
oldDevZfsPath := devZfsPath
devZfsPath = filepath.Join(t.TempDir(), "zfs")
require.NoError(t, os.WriteFile(devZfsPath, nil, 0o644))
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
oldCommandOutput := commandOutput
commandOutput = func(name string, args ...string) ([]byte, error) {
assert.Equal(t, "zfs", name)
assert.Equal(t, []string{"list", "-Hp", "-o", "name,used,avail,mountpoint"}, args)
return []byte("tank\t50\t50\t/tank\n"), nil
}
t.Cleanup(func() { commandOutput = oldCommandOutput })
datasets, err := Datasets()
require.NoError(t, err)
assert.Equal(t, []Dataset{{Name: "tank", Used: 50, Avail: 50, Mountpoint: "/tank"}}, datasets)
}
func TestPoolStatsDelegatesToZpoolWhenDevZfsPresent(t *testing.T) {
root := t.TempDir()
devFile := filepath.Join(root, "zfs")
require.NoError(t, os.WriteFile(devFile, []byte(""), 0o644))
oldDevZfsPath := devZfsPath
devZfsPath = devFile
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
oldCommandOutput := commandOutput
called := false
commandOutput = func(name string, args ...string) ([]byte, error) {
called = true
assert.Equal(t, "zpool", name)
assert.Equal(t, []string{"list", "-Hp", "-o", "name,size,alloc,free,health"}, args)
return []byte("tank\t100\t50\t50\tONLINE\n"), nil
}
t.Cleanup(func() { commandOutput = oldCommandOutput })
pools, err := PoolStats()
require.NoError(t, err)
assert.True(t, called)
assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 50, Free: 50, Health: "ONLINE"}}, pools)
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package zfs
// The /dev/zfs probe is Linux-specific. Other platforms detect availability
// through the ZFS utilities themselves.
func checkZfsDevice() error {
return nil
}
+33
View File
@@ -0,0 +1,33 @@
//go:build testing && !linux
package zfs
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCollectorsUseUtilitiesOnNonLinux(t *testing.T) {
oldCommandOutput := commandOutput
commandOutput = func(name string, args ...string) ([]byte, error) {
switch name {
case "zpool":
return []byte("tank\t100\t50\t50\tONLINE\n"), nil
case "zfs":
return []byte("tank\t50\t50\t/tank\n"), nil
default:
t.Fatalf("unexpected command %s", name)
return nil, nil
}
}
t.Cleanup(func() { commandOutput = oldCommandOutput })
pools, err := PoolStats()
require.NoError(t, err)
assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 50, Free: 50, Health: "ONLINE"}}, pools)
datasets, err := Datasets()
require.NoError(t, err)
assert.Equal(t, []Dataset{{Name: "tank", Used: 50, Avail: 50, Mountpoint: "/tank"}}, datasets)
}
+6 -6
View File
@@ -19,10 +19,10 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.12.1
golang.org/x/crypto v0.57.0
golang.org/x/crypto v0.56.0
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa
golang.org/x/net v0.59.0
golang.org/x/sys v0.48.0
golang.org/x/net v0.58.0
golang.org/x/sys v0.47.0
gopkg.in/yaml.v3 v3.0.1
howett.net/plist v1.0.1
)
@@ -60,9 +60,9 @@ require (
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/image v0.45.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/term v0.46.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.41.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
+14 -14
View File
@@ -136,34 +136,34 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk=
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
+73
View File
@@ -2,7 +2,11 @@ package hub
import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
"net/netip"
"regexp"
"strings"
"time"
@@ -78,12 +82,81 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
}
// authenticate with trusted header
if trustedHeader, _ := utils.GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
// only honor the header from these peers, if set
trustedProxies, restricted := parseTrustedProxies()
se.Router.BindFunc(func(e *core.RequestEvent) error {
if restricted && !isTrustedProxy(trustedProxies, e.Request.RemoteAddr) {
return e.Next()
}
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
})
}
}
// parseTrustedProxies reads TRUSTED_PROXY_IPS (comma-separated IPs or CIDRs).
// restricted is false when the variable is unset or empty, meaning the trusted
// header is accepted from any peer. Invalid entries are skipped with a warning,
// so a typo narrows the allowlist rather than widening it.
func parseTrustedProxies() (prefixes []netip.Prefix, restricted bool) {
value, _ := utils.GetEnv("TRUSTED_PROXY_IPS")
if value == "" {
return nil, false
}
for entry := range strings.SplitSeq(value, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if prefix, err := parseProxyPrefix(entry); err == nil {
prefixes = append(prefixes, prefix)
} else {
slog.Warn("Ignoring invalid TRUSTED_PROXY_IPS entry", "entry", entry)
}
}
return prefixes, true
}
// parseProxyPrefix parses an IP or CIDR into a masked prefix. IPv4-mapped IPv6
// entries are converted to IPv4 so they match IPv4 peers.
func parseProxyPrefix(entry string) (netip.Prefix, error) {
prefix, err := netip.ParsePrefix(entry)
if err != nil {
addr, err := netip.ParseAddr(entry)
if err != nil {
return netip.Prefix{}, err
}
addr = addr.Unmap()
return netip.PrefixFrom(addr, addr.BitLen()), nil
}
if prefix.Addr().Is4In6() {
if prefix.Bits() < 96 {
return netip.Prefix{}, fmt.Errorf("%s covers more than the IPv4-mapped range", entry)
}
prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96)
}
return prefix.Masked(), nil
}
// isTrustedProxy reports whether the peer address of a request (host:port) is
// within one of the prefixes.
func isTrustedProxy(prefixes []netip.Prefix, remoteAddr string) bool {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
addr, err := netip.ParseAddr(host)
if err != nil {
return false
}
addr = addr.Unmap().WithZone("")
for _, prefix := range prefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
// registerApiRoutes registers custom API routes
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// auth protected routes
+211
View File
@@ -6,12 +6,16 @@ import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"sort"
"testing"
"time"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/henrygd/beszel/internal/migrations"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
pbTests "github.com/pocketbase/pocketbase/tests"
"github.com/stretchr/testify/require"
@@ -26,6 +30,59 @@ func jsonReader(v any) io.Reader {
return bytes.NewReader(data)
}
type gatedReader struct {
data []byte
started chan struct{}
release chan struct{}
offset int
}
func (r *gatedReader) Read(p []byte) (int, error) {
if r.offset == 0 {
close(r.started)
<-r.release
}
if r.offset >= len(r.data) {
return 0, io.EOF
}
n := copy(p, r.data[r.offset:])
r.offset += n
return n, nil
}
func firstUserTestMux(t *testing.T) (*beszelTests.TestHub, http.Handler) {
t.Helper()
hub, err := beszelTests.NewTestHub(t.TempDir())
require.NoError(t, err)
_ = hub.StartHub()
router, err := apis.NewRouter(hub.TestApp)
require.NoError(t, err)
serveEvent := &core.ServeEvent{App: hub.TestApp, Router: router}
var handler http.Handler
err = hub.TestApp.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
var buildErr error
handler, buildErr = e.Router.BuildMux()
return buildErr
})
require.NoError(t, err)
require.NotNil(t, handler)
return hub, handler
}
func postFirstUser(handler http.Handler, email string) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]string{
"email": email,
"password": "password123",
})
req := httptest.NewRequest(http.MethodPost, "/api/beszel/create-user", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
return recorder
}
func TestApiRoutesAuthentication(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
@@ -789,6 +846,87 @@ func TestFirstUserCreation(t *testing.T) {
})
}
func TestFirstUserBootstrapAtomicity(t *testing.T) {
t.Run("concurrent complete requests produce exactly one winner", func(t *testing.T) {
hub, handler := firstUserTestMux(t)
defer hub.Cleanup()
start := make(chan struct{})
statuses := make(chan int, 2)
for _, email := range []string{"first@example.com", "second@example.com"} {
go func(email string) {
<-start
statuses <- postFirstUser(handler, email).Code
}(email)
}
close(start)
got := []int{<-statuses, <-statuses}
sort.Ints(got)
require.Equal(t, []int{http.StatusOK, http.StatusForbidden}, got)
users, err := hub.FindAllRecords("users")
require.NoError(t, err)
require.Len(t, users, 1)
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.Len(t, superusers, 1)
require.NotEqual(t, migrations.TempAdminEmail, superusers[0].Email())
})
t.Run("partial body cannot retain stale bootstrap authorization", func(t *testing.T) {
hub, handler := firstUserTestMux(t)
defer hub.Cleanup()
body, err := json.Marshal(map[string]string{
"email": "parked@example.com",
"password": "password123",
})
require.NoError(t, err)
gated := &gatedReader{
data: body,
started: make(chan struct{}),
release: make(chan struct{}),
}
parkedRequest := httptest.NewRequest(http.MethodPost, "/api/beszel/create-user", gated)
parkedRequest.Header.Set("Content-Type", "application/json")
parkedRecorder := httptest.NewRecorder()
parkedDone := make(chan struct{})
go func() {
handler.ServeHTTP(parkedRecorder, parkedRequest)
close(parkedDone)
}()
select {
case <-gated.started:
case <-time.After(2 * time.Second):
t.Fatal("parked request did not begin reading its body")
}
operatorRecorder := postFirstUser(handler, "operator@example.com")
require.Equal(t, http.StatusOK, operatorRecorder.Code)
lateRecorder := postFirstUser(handler, "late@example.com")
require.Equal(t, http.StatusForbidden, lateRecorder.Code)
close(gated.release)
select {
case <-parkedDone:
case <-time.After(2 * time.Second):
t.Fatal("parked request did not finish")
}
require.Equal(t, http.StatusForbidden, parkedRecorder.Code)
users, err := hub.FindAllRecords("users")
require.NoError(t, err)
require.Len(t, users, 1)
require.Equal(t, "operator@example.com", users[0].Email())
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.Len(t, superusers, 1)
require.Equal(t, "operator@example.com", superusers[0].Email())
})
}
func TestCreateUserEndpointAvailability(t *testing.T) {
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
@@ -969,6 +1107,79 @@ func TestTrustedHeaderMiddleware(t *testing.T) {
}
}
func TestTrustedHeaderProxyAllowlist(t *testing.T) {
var hubs []*beszelTests.TestHub
defer func() {
for _, hub := range hubs {
hub.Cleanup()
}
}()
testAppFactory := func(t testing.TB) *pbTests.TestApp {
hub, _ := beszelTests.NewTestHub(t.TempDir())
hubs = append(hubs, hub)
hub.StartHub()
return hub.TestApp
}
// httptest requests arrive from 192.0.2.1:1234
testCases := []struct {
name string
proxies string
expectedStatus int
expectedContent []string
}{
{
name: "peer inside an allowed range",
proxies: "10.0.0.0/8, 192.0.2.0/24",
expectedStatus: 200,
expectedContent: []string{"\"key\":", "\"v\":"},
},
{
name: "peer is the listed address",
proxies: "192.0.2.1",
expectedStatus: 200,
expectedContent: []string{"\"key\":", "\"v\":"},
},
{
name: "peer outside the allowlist",
proxies: "10.0.0.0/8",
expectedStatus: 401,
expectedContent: []string{"requires valid"},
},
{
name: "allowlist with no valid entry",
proxies: "proxy.internal",
expectedStatus: 401,
expectedContent: []string{"requires valid"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("TRUSTED_AUTH_HEADER", "X-Beszel-Trusted")
t.Setenv("TRUSTED_PROXY_IPS", tc.proxies)
scenario := beszelTests.ApiScenario{
Name: "GET /getkey - with trusted header",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
Headers: map[string]string{
"X-Beszel-Trusted": "user@test.com",
},
ExpectedStatus: tc.expectedStatus,
ExpectedContent: tc.expectedContent,
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.CreateUser(app, "user@test.com", "password123")
},
}
scenario.Test(t)
})
}
}
func TestUpdateEndpoint(t *testing.T) {
t.Setenv("CHECK_UPDATES", "true")
+2
View File
@@ -122,6 +122,8 @@ func (h *Hub) initialize(app core.App) error {
settings := app.Settings()
// batch requests (for alerts)
settings.Batch.Enabled = true
settings.Batch.MaxRequests = 100
settings.Batch.MaxBodySize = 1 << 20 // 1 MiB
// set URL if APP_URL env is set
if appURL, isSet := utils.GetEnv("APP_URL"); isSet {
h.appURL = appURL
+127
View File
@@ -0,0 +1,127 @@
//go:build testing
package hub
import (
"net/netip"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseTrustedProxies(t *testing.T) {
testCases := []struct {
name string
value string
prefixes []string
restricted bool
}{
{
name: "empty",
value: "",
restricted: false,
},
{
name: "blank",
value: " , ",
prefixes: nil,
restricted: true,
},
{
name: "single addresses become host prefixes",
value: "10.0.0.5, 2001:db8::1",
prefixes: []string{"10.0.0.5/32", "2001:db8::1/128"},
restricted: true,
},
{
name: "cidrs are masked",
value: "172.16.5.9/12,fd00::1/64",
prefixes: []string{"172.16.0.0/12", "fd00::/64"},
restricted: true,
},
{
name: "ipv4-mapped entries become ipv4",
value: "::ffff:10.0.0.5, ::ffff:10.0.0.0/104",
prefixes: []string{"10.0.0.5/32", "10.0.0.0/8"},
restricted: true,
},
{
name: "invalid entries are skipped, valid ones kept",
value: "proxy.internal, 10.0.0.0/8, 300.1.1.1, ::ffff:0.0.0.0/64",
prefixes: []string{"10.0.0.0/8"},
restricted: true,
},
{
name: "only invalid entries trust nobody",
value: "proxy.internal",
prefixes: nil,
restricted: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("TRUSTED_PROXY_IPS", tc.value)
prefixes, restricted := parseTrustedProxies()
assert.Equal(t, tc.restricted, restricted)
var got []string
for _, p := range prefixes {
got = append(got, p.String())
}
assert.Equal(t, tc.prefixes, got)
})
}
t.Run("unset", func(t *testing.T) {
t.Setenv("TRUSTED_PROXY_IPS", "")
os.Unsetenv("TRUSTED_PROXY_IPS")
prefixes, restricted := parseTrustedProxies()
assert.False(t, restricted)
assert.Nil(t, prefixes)
})
t.Run("prefixed env var takes precedence", func(t *testing.T) {
t.Setenv("TRUSTED_PROXY_IPS", "10.0.0.0/8")
t.Setenv("BESZEL_HUB_TRUSTED_PROXY_IPS", "192.168.0.0/16")
prefixes, restricted := parseTrustedProxies()
assert.True(t, restricted)
require.Len(t, prefixes, 1)
assert.Equal(t, "192.168.0.0/16", prefixes[0].String())
})
}
func TestIsTrustedProxy(t *testing.T) {
prefixes := []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("2001:db8::/32"),
netip.MustParsePrefix("fe80::/10"),
}
testCases := []struct {
name string
remoteAddr string
trusted bool
}{
{"ipv4 in prefix", "10.20.30.40:51234", true},
{"ipv4 outside prefix", "11.0.0.1:51234", false},
{"ipv6 in prefix", "[2001:db8:1::2]:443", true},
{"ipv6 outside prefix", "[2001:db9::1]:443", false},
{"ipv4-mapped ipv6 matches ipv4 prefix", "[::ffff:10.1.2.3]:80", true},
{"zone is ignored", "[fe80::1%eth0]:80", true},
{"no port", "10.1.2.3", true},
{"empty", "", false},
{"garbage", "not-an-address:80", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.trusted, isTrustedProxy(prefixes, tc.remoteAddr))
})
}
t.Run("empty allowlist trusts nobody", func(t *testing.T) {
assert.False(t, isTrustedProxy(nil, "10.0.0.1:1"))
})
}
+2 -2
View File
@@ -15,7 +15,7 @@ export default function () {
const page = useStore($router)
const [isFirstRun, setFirstRun] = useState(false)
const [authMethods, setAuthMethods] = useState<AuthMethodsList>()
const { theme } = useTheme()
const { resolvedTheme } = useTheme()
useEffect(() => {
document.title = t`Login` + " / Beszel"
@@ -54,7 +54,7 @@ export default function () {
<div
className="grid gap-5 w-full px-4 mx-auto"
// @ts-expect-error
style={{ maxWidth: "21.5em", "--border": theme == "light" ? "hsl(30, 8%, 70%)" : "hsl(220, 3%, 25%)" }}
style={{ maxWidth: "21.5em", "--border": resolvedTheme == "light" ? "hsl(30, 8%, 70%)" : "hsl(220, 3%, 25%)" }}
>
<div className="absolute top-3 right-3">
<ModeToggle />
@@ -63,7 +63,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
<Label className="block" htmlFor="lang">
<Trans>Preferred Language</Trans>
</Label>
<Select value={i18n.locale} onValueChange={(lang: string) => dynamicActivate(lang)}>
<Select name="lang" value={i18n.locale} onValueChange={(lang: string) => dynamicActivate(lang)}>
<SelectTrigger id="lang">
<SelectValue />
</SelectTrigger>
@@ -14,7 +14,7 @@ import { lazy, useEffect } from "react"
import { $router } from "@/components/router.tsx"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
import { toast } from "@/components/ui/use-toast.ts"
import { pb } from "@/lib/api"
import { saveUserSettings } from "@/lib/api"
import { $userSettings } from "@/lib/stores.ts"
import type { UserSettings } from "@/types"
import { Separator } from "../../ui/separator"
@@ -36,24 +36,13 @@ const HeartbeatSettings = lazy(heartbeatSettingsImport)
export async function saveSettings(newSettings: Partial<UserSettings>) {
try {
// get fresh copy of settings
const req = await pb.collection("user_settings").getFirstListItem("", {
fields: "id,settings",
})
// update user settings
const updatedSettings = await pb.collection("user_settings").update(req.id, {
settings: {
...req.settings,
...newSettings,
},
})
$userSettings.set(updatedSettings.settings)
await saveUserSettings(newSettings)
toast({
title: t`Settings saved`,
description: t`Your user settings have been updated.`,
})
} catch (e) {
// console.error('update settings', e)
console.error("save settings", e)
toast({
title: t`Failed to save settings`,
description: t`Check logs for more details.`,
@@ -1,9 +1,9 @@
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { subscribeKeys } from "nanostores"
import { useEffect, useMemo, useRef, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useContainerChartConfigs } from "@/components/charts/hooks"
import { pb } from "@/lib/api"
import { pb, queueUserSettings } from "@/lib/api"
import { SystemStatus } from "@/lib/enums"
import {
$allSystemsById,
@@ -15,7 +15,7 @@ import {
$systems,
$userSettings,
} from "@/lib/stores"
import { chartTimeData, listen, parseSemVer, useBrowserStorage } from "@/lib/utils"
import { chartTimeData, listen, parseSemVer } from "@/lib/utils"
import type {
ChartData,
ContainerStatsRecord,
@@ -35,8 +35,42 @@ export function useSystemData(id: string) {
const systems = useStore($systems)
const chartTime = useStore($chartTime)
const maxValues = useStore($maxValues)
const [grid, setGrid] = useBrowserStorage("grid", true)
const [displayMode, setDisplayMode] = useBrowserStorage<"default" | "tabs">("displayMode", "default")
const [grid, _setGrid] = useState<boolean>(
() => $userSettings.get().grid ?? JSON.parse(localStorage.getItem("besz-grid") ?? "null") ?? true
)
const [displayMode, _setDisplayMode] = useState<"default" | "tabs">(
() =>
$userSettings.get().displayMode ??
(JSON.parse(localStorage.getItem("besz-displayMode") || "null") as "default" | "tabs" | null) ??
"default"
)
const applied = useRef(new Set<string>())
useEffect(() => {
return subscribeKeys($userSettings, ["grid", "displayMode"], (vals) => {
if (!applied.current.has("grid") && vals.grid !== undefined) {
applied.current.add("grid")
_setGrid(vals.grid)
}
if (!applied.current.has("displayMode") && vals.displayMode !== undefined) {
applied.current.add("displayMode")
_setDisplayMode(vals.displayMode)
}
})
}, [])
const setGrid = useCallback((v: boolean) => {
_setGrid(v)
localStorage.setItem("besz-grid", JSON.stringify(v))
$userSettings.setKey("grid", v)
queueUserSettings({ grid: v })
}, [])
const setDisplayMode = useCallback((v: "default" | "tabs") => {
_setDisplayMode(v)
localStorage.setItem("besz-displayMode", JSON.stringify(v))
$userSettings.setKey("displayMode", v)
queueUserSettings({ displayMode: v })
}, [])
const [activeTab, setActiveTabRaw] = useState("core")
const [mountedTabs, setMountedTabs] = useState(() => new Set<string>(["core"]))
const tabsRef = useRef<string[]>(["core", "disk"])
@@ -171,6 +205,7 @@ export function useSystemData(id: string) {
// get stats when system "changes." (Not just system to system,
// also when new info comes in via systemManager realtime connection, indicating an update)
useEffect(() => {
const requestId = ++statsRequestId.current
if (!system.id || !chartTime || chartTime === "1m") {
return
}
@@ -179,7 +214,6 @@ export function useSystemData(id: string) {
const { expectedInterval } = chartTimeData[chartTime]
const ss_cache_key = `${systemId}_${chartTime}_system_stats`
const cs_cache_key = `${systemId}_${chartTime}_container_stats`
const requestId = ++statsRequestId.current
const cachedSystemStats = cache.get(ss_cache_key) as SystemStatsRecord[] | undefined
const cachedContainerData = cache.get(cs_cache_key) as ChartData["containerData"] | undefined
@@ -203,7 +237,7 @@ export function useSystemData(id: string) {
getStats<SystemStatsRecord>("system_stats", systemId, chartTime),
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime),
]).then(([systemStats, containerStats]) => {
// If another request has been made since this one, ignore the results
// Ignore responses for a previous system or chart time
if (requestId !== statsRequestId.current) {
return
}
@@ -1,5 +1,6 @@
import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { subscribeKeys } from "nanostores"
import { getPagePath } from "@nanostores/router"
import {
type ColumnDef,
@@ -26,7 +27,7 @@ import {
Settings2Icon,
XIcon,
} from "lucide-react"
import { memo, useEffect, useMemo, useRef, useState } from "react"
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
@@ -42,8 +43,9 @@ import {
import { Input } from "@/components/ui/input"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { SystemStatus } from "@/lib/enums"
import { $downSystems, $pausedSystems, $systems, $upSystems } from "@/lib/stores"
import { cn, runOnce, useBrowserStorage } from "@/lib/utils"
import { queueUserSettings } from "@/lib/api"
import { $downSystems, $pausedSystems, $systems, $upSystems, $userSettings } from "@/lib/stores"
import { cn, runOnce } from "@/lib/utils"
import type { SystemRecord } from "@/types"
import AlertButton from "../alerts/alert-button"
import { $router, Link } from "../router"
@@ -62,14 +64,83 @@ export default function SystemsTable() {
const pausedSystems = $pausedSystems.get()
const { i18n, t } = useLingui()
const [filter, setFilter] = useState<string>("")
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [sorting, setSorting] = useBrowserStorage<SortingState>(
"sortMode",
[{ id: "system", desc: false }],
sessionStorage
const [statusFilter, setStatusFilter] = useState<StatusFilter>(
() =>
$userSettings.get().statusFilter ??
(JSON.parse(localStorage.getItem("besz-statusFilter") || "null") as StatusFilter | null) ??
"all"
)
const [sorting, setSorting] = useState<SortingState>(
() =>
$userSettings.get().sortMode ??
JSON.parse(sessionStorage.getItem("besz-sortMode") || "null") ?? [{ id: "system", desc: false }]
)
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useBrowserStorage<VisibilityState>("cols", {})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
() => $userSettings.get().cols ?? JSON.parse(localStorage.getItem("besz-cols") || "{}")
)
// Apply settings from server once they load (handles incognito / new devices)
const applied = useRef(new Set<string>())
useEffect(() => {
return subscribeKeys($userSettings, ["cols", "statusFilter", "viewMode", "sortMode"], (vals) => {
if (!applied.current.has("cols") && vals.cols !== undefined) {
applied.current.add("cols")
setColumnVisibility(vals.cols)
}
if (!applied.current.has("statusFilter") && vals.statusFilter !== undefined) {
applied.current.add("statusFilter")
setStatusFilter(vals.statusFilter)
}
if (!applied.current.has("viewMode") && vals.viewMode !== undefined) {
applied.current.add("viewMode")
setViewMode(vals.viewMode)
}
if (!applied.current.has("sortMode") && vals.sortMode !== undefined) {
applied.current.add("sortMode")
setSorting(vals.sortMode)
}
})
}, [])
const handleColumnVisibilityChange = useCallback(
(updater: VisibilityState | ((prev: VisibilityState) => VisibilityState)) => {
setColumnVisibility((prev) => {
const next = typeof updater === "function" ? updater(prev) : updater
localStorage.setItem("besz-cols", JSON.stringify(next))
$userSettings.setKey("cols", next)
queueUserSettings({ cols: next })
return next
})
},
[]
)
const handleStatusFilterChange = useCallback((value: string) => {
const next = value as StatusFilter
setStatusFilter(next)
localStorage.setItem("besz-statusFilter", JSON.stringify(next))
$userSettings.setKey("statusFilter", next)
queueUserSettings({ statusFilter: next })
}, [])
const handleViewModeChange = useCallback((view: string) => {
const next = view as ViewMode
setViewMode(next)
localStorage.setItem("besz-viewMode", JSON.stringify(next))
$userSettings.setKey("viewMode", next)
queueUserSettings({ viewMode: next })
}, [])
const handleSortingChange = useCallback((updater: SortingState | ((prev: SortingState) => SortingState)) => {
setSorting((prev) => {
const next = typeof updater === "function" ? updater(prev) : updater
sessionStorage.setItem("besz-sortMode", JSON.stringify(next))
$userSettings.setKey("sortMode", next)
queueUserSettings({ sortMode: next })
return next
})
}, [])
const locale = i18n.locale
@@ -87,10 +158,12 @@ export default function SystemsTable() {
return Object.values(pausedSystems) ?? []
}, [data, statusFilter])
const [viewMode, setViewMode] = useBrowserStorage<ViewMode>(
"viewMode",
// show grid view on mobile if there are less than 200 systems (looks better but table is more efficient)
window.innerWidth < 1024 && filteredData.length < 200 ? "grid" : "table"
const [viewMode, setViewMode] = useState<ViewMode>(
() =>
$userSettings.get().viewMode ??
(JSON.parse(localStorage.getItem("besz-viewMode") || "null") as ViewMode | null) ??
// show grid view on mobile if there are less than 200 systems (looks better but table is more efficient)
(window.innerWidth < 1024 && filteredData.length < 200 ? "grid" : "table")
)
useEffect(() => {
@@ -105,11 +178,11 @@ export default function SystemsTable() {
data: filteredData,
columns: columnDefs,
getCoreRowModel: getCoreRowModel(),
onSortingChange: setSorting,
onSortingChange: handleSortingChange,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onColumnVisibilityChange: handleColumnVisibilityChange,
state: {
sorting,
columnFilters,
@@ -181,11 +254,7 @@ export default function SystemsTable() {
<Trans>Layout</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
className="px-1 pb-1"
value={viewMode}
onValueChange={(view) => setViewMode(view as ViewMode)}
>
<DropdownMenuRadioGroup className="px-1 pb-1" value={viewMode} onValueChange={handleViewModeChange}>
<DropdownMenuRadioItem value="table" onSelect={(e) => e.preventDefault()} className="gap-2">
<LayoutListIcon className="size-4" />
<Trans>Table</Trans>
@@ -206,7 +275,7 @@ export default function SystemsTable() {
<DropdownMenuRadioGroup
className="px-1 pb-1"
value={statusFilter}
onValueChange={(value) => setStatusFilter(value as StatusFilter)}
onValueChange={handleStatusFilterChange}
>
<DropdownMenuRadioItem value="all" onSelect={(e) => e.preventDefault()}>
<Trans>All Systems</Trans>
@@ -245,7 +314,9 @@ export default function SystemsTable() {
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setSorting([{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc }])
handleSortingChange([
{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc },
])
}}
key={column.id}
>
+17 -10
View File
@@ -1,6 +1,7 @@
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light" | "system"
type ResolvedTheme = "dark" | "light"
type ThemeProviderProps = {
children: React.ReactNode
@@ -10,11 +11,13 @@ type ThemeProviderProps = {
type ThemeProviderState = {
theme: Theme
resolvedTheme: ResolvedTheme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
resolvedTheme: "light",
setTheme: () => null,
}
@@ -27,24 +30,28 @@ export function ThemeProvider({
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(() => (localStorage.getItem(storageKey) as Theme) || defaultTheme)
const [systemDark, setSystemDark] = useState(() => window.matchMedia("(prefers-color-scheme: dark)").matches)
useEffect(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)")
const onChange = (event: MediaQueryListEvent) => setSystemDark(event.matches)
media.addEventListener("change", onChange)
return () => media.removeEventListener("change", onChange)
}, [])
const resolvedTheme = theme === "system" ? (systemDark ? "dark" : "light") : theme
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
root.classList.add(resolvedTheme)
}, [resolvedTheme])
const value = {
theme,
resolvedTheme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
+36
View File
@@ -2,6 +2,7 @@ import { t } from "@lingui/core/macro"
import PocketBase from "pocketbase"
import { basePath } from "@/components/router"
import { toast } from "@/components/ui/use-toast"
import { dynamicActivate, getLocale } from "@/lib/i18n"
import type { ChartTimes, UserSettings } from "@/types"
import { $alerts, $allSystemsById, $allSystemsByName, $userSettings } from "./stores"
import { chartTimeData, debounce } from "./utils"
@@ -52,11 +53,45 @@ export function logOut() {
pb.realtime.unsubscribe()
}
/** Save a partial update to user settings in database immediately */
export async function saveUserSettings(newSettings: Partial<UserSettings>) {
// get fresh copy of settings so concurrent changes aren't overwritten
const req = await pb.collection("user_settings").getFirstListItem("", { fields: "id,settings" })
const updatedSettings = await pb.collection("user_settings").update(req.id, {
settings: {
...req.settings,
...newSettings,
},
})
$userSettings.set(updatedSettings.settings)
}
// keys queued by queueUserSettings, flushed together in a single request so that
// two debounced saves for different keys can't race each other's read-modify-write
// and silently drop one of the changes
let queuedSettings: Partial<UserSettings> = {}
const flushQueuedSettings = debounce(() => {
const toSave = queuedSettings
queuedSettings = {}
if (Object.keys(toSave).length === 0) {
return
}
saveUserSettings(toSave).catch(console.error)
}, 1000)
/** Queue a partial user settings update, merging with any other pending keys and saving them together after a debounce window */
export function queueUserSettings(newSettings: Partial<UserSettings>) {
queuedSettings = { ...queuedSettings, ...newSettings }
flushQueuedSettings()
}
/** Fetch or create user settings in database */
export async function updateUserSettings() {
try {
const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" })
$userSettings.set(req.settings)
dynamicActivate(req.settings.lang || getLocale())
return
} catch (e) {
console.error("get settings", e)
@@ -65,6 +100,7 @@ export async function updateUserSettings() {
try {
const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record?.id })
$userSettings.set(createdSettings.settings)
dynamicActivate(createdSettings.settings.lang || getLocale())
} catch (e) {
console.error("create settings", e)
}
+1
View File
@@ -121,6 +121,7 @@ const Layout = () => {
const I18nApp = () => {
useEffect(() => {
// Activate a locale so I18nProvider can mount App and load the account settings.
dynamicActivate(getLocale())
}, [])
+7
View File
@@ -370,6 +370,13 @@ export interface UserSettings {
colorCrit?: number
hourFormat?: HourFormat
layoutWidth?: number
lang?: string
cols?: Record<string, boolean>
statusFilter?: "all" | "up" | "down" | "paused" | "pending"
viewMode?: "table" | "grid"
sortMode?: Array<{ id: string; desc: boolean }>
grid?: boolean
displayMode?: "default" | "tabs"
}
type ChartDataContainer = {
+51 -29
View File
@@ -2,6 +2,7 @@
package users
import (
"errors"
"log"
"net/http"
@@ -15,6 +16,8 @@ type UserManager struct {
app core.App
}
var errBootstrapUnavailable = errors.New("bootstrap unavailable")
func NewUserManager(app core.App) *UserManager {
return &UserManager{
app: app,
@@ -59,17 +62,7 @@ func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
// Custom API endpoint to create the first user.
// Mimics previous default behavior in PocketBase < 0.23.0 allowing user to be created through the Beszel UI.
func (um *UserManager) CreateFirstUser(e *core.RequestEvent) error {
// check that there are no users
totalUsers, err := um.app.CountRecords("users")
if err != nil || totalUsers > 0 {
return e.JSON(http.StatusForbidden, map[string]string{"err": "Forbidden"})
}
// check that there is only one superuser and the email matches the email of the superuser we set up in initial-settings.go
adminUsers, err := um.app.FindAllRecords(core.CollectionNameSuperusers)
if err != nil || len(adminUsers) != 1 || adminUsers[0].GetString("email") != migrations.TempAdminEmail {
return e.JSON(http.StatusForbidden, map[string]string{"err": "Forbidden"})
}
// create first user using supplied email and password in request body
// Consume the complete body before evaluating the one-time bootstrap state.
data := struct {
Email string `json:"email"`
Password string `json:"password"`
@@ -81,26 +74,55 @@ func (um *UserManager) CreateFirstUser(e *core.RequestEvent) error {
return e.JSON(http.StatusBadRequest, map[string]string{"err": "Bad request"})
}
collection, _ := um.app.FindCollectionByNameOrId("users")
user := core.NewRecord(collection)
user.SetEmail(data.Email)
user.SetPassword(data.Password)
user.Set("role", "admin")
user.Set("verified", true)
if err := um.app.Save(user); err != nil {
return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()})
err := um.app.RunInTransaction(func(txApp core.App) error {
totalUsers, err := txApp.CountRecords("users")
if err != nil {
return err
}
if totalUsers > 0 {
return errBootstrapUnavailable
}
adminUsers, err := txApp.FindAllRecords(core.CollectionNameSuperusers)
if err != nil {
return err
}
if len(adminUsers) != 1 || adminUsers[0].GetString("email") != migrations.TempAdminEmail {
return errBootstrapUnavailable
}
collection, err := txApp.FindCollectionByNameOrId("users")
if err != nil {
return err
}
user := core.NewRecord(collection)
user.SetEmail(data.Email)
user.SetPassword(data.Password)
user.Set("role", "admin")
user.Set("verified", true)
if err := txApp.Save(user); err != nil {
return err
}
collection, err = txApp.FindCollectionByNameOrId(core.CollectionNameSuperusers)
if err != nil {
return err
}
adminUser := core.NewRecord(collection)
adminUser.SetEmail(data.Email)
adminUser.SetPassword(data.Password)
if err := txApp.Save(adminUser); err != nil {
return err
}
return txApp.Delete(adminUsers[0])
})
if errors.Is(err, errBootstrapUnavailable) {
return e.JSON(http.StatusForbidden, map[string]string{"err": "Forbidden"})
}
// create superuser using the email of the first user
collection, _ = um.app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
adminUser := core.NewRecord(collection)
adminUser.SetEmail(data.Email)
adminUser.SetPassword(data.Password)
if err := um.app.Save(adminUser); err != nil {
return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()})
}
// delete the intial superuser
if err := um.app.Delete(adminUsers[0]); err != nil {
if err != nil {
return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()})
}
return e.JSON(http.StatusOK, map[string]string{"msg": "User created"})
}
+117
View File
@@ -0,0 +1,117 @@
//go:build testing
package users_test
import (
"errors"
"io"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/henrygd/beszel/internal/migrations"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/henrygd/beszel/internal/users"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/stretchr/testify/require"
)
type blockedBody struct {
io.Reader
entered chan struct{}
resume chan struct{}
}
func (b *blockedBody) Read(p []byte) (int, error) {
if b.entered != nil {
close(b.entered)
b.entered = nil
<-b.resume
}
return b.Reader.Read(p)
}
func TestCreateFirstUserAtomic(t *testing.T) {
for _, scenario := range []string{"parked body", "concurrent requests", "rollback"} {
t.Run(scenario, func(t *testing.T) {
h, err := beszelTests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer h.Cleanup()
h.StartHub()
um := users.NewUserManager(h.App)
invoke := func(body io.Reader) int {
req := httptest.NewRequest("POST", "/api/beszel/create-user", body)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
if err := um.CreateFirstUser(&core.RequestEvent{App: h.App, Event: router.Event{Request: req, Response: res}}); err != nil {
t.Error(err)
}
return res.Code
}
body := func(email string) io.Reader {
return strings.NewReader(`{"email":"` + email + `","password":"password12345"}`)
}
await := func(results <-chan int) int {
select {
case status := <-results:
return status
case <-time.After(10 * time.Second):
t.Fatal("request did not finish")
return 0
}
}
switch scenario {
case "parked body":
entered, resume := make(chan struct{}), make(chan struct{})
defer func() {
select {
case <-resume:
default:
close(resume)
}
}()
result := make(chan int, 1)
go func() { result <- invoke(&blockedBody{body("attacker@example.com"), entered, resume}) }()
select {
case <-entered:
case <-time.After(10 * time.Second):
t.Fatal("request did not reach body parsing")
}
require.Equal(t, 200, invoke(body("operator@example.com")))
close(resume)
require.Equal(t, 403, await(result))
case "concurrent requests":
start := make(chan struct{})
results := make(chan int, 2)
for _, email := range []string{"one@example.com", "two@example.com"} {
go func() { <-start; results <- invoke(body(email)) }()
}
close(start)
require.ElementsMatch(t, []int{200, 403}, []int{await(results), await(results)})
case "rollback":
hook := h.OnRecordCreate(core.CollectionNameSuperusers).BindFunc(func(e *core.RecordEvent) error {
return errors.New("injected superuser creation failure")
})
require.Equal(t, 500, invoke(body("operator@example.com")))
count, err := h.CountRecords("users")
require.NoError(t, err)
require.Zero(t, count)
admins, err := h.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.Len(t, admins, 1)
require.Equal(t, migrations.TempAdminEmail, admins[0].Email())
h.OnRecordCreate(core.CollectionNameSuperusers).Unbind(hook)
require.Equal(t, 200, invoke(body("operator@example.com")))
}
count, err := h.CountRecords("users")
require.NoError(t, err)
require.EqualValues(t, 1, count)
admins, err := h.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.Len(t, admins, 1)
require.NotEqual(t, migrations.TempAdminEmail, admins[0].Email())
})
}
}