mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-21 08:57:48 +02:00
Compare commits
2 Commits
997adc19bb
...
install-ag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dae8181f47 | ||
|
|
b8fb5d2367 |
@@ -2,8 +2,6 @@
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**PLEASE ONLY USE SECURITY ADVISORIES FOR REAL HIGH SEVERITY VULNERABILITIES.**
|
||||
If you find a vulnerability in the latest version, please [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new).
|
||||
|
||||
If you find a vulnerability in the latest version, and it is not high severity, open an issue instead of an advisory.
|
||||
|
||||
I am overwhelmed with advisories, often erroneous, which are clearly found and written by AI. I don't have the capacity to review all of them.
|
||||
If it's low severity (use best judgement) you may open an issue instead of an advisory.
|
||||
|
||||
@@ -25,9 +25,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Keep the connection alive long enough for a slow collection cycle to
|
||||
// finish before the hub considers the agent disconnected.
|
||||
wsDeadline = 120 * time.Second
|
||||
wsDeadline = 70 * time.Second
|
||||
)
|
||||
|
||||
type caCertFileError struct {
|
||||
|
||||
@@ -700,11 +700,3 @@ func TestGetToken(t *testing.T) {
|
||||
assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content")
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebSocketDeadlineCoversSlowCollection(t *testing.T) {
|
||||
const minimumDeadline = 120 * time.Second
|
||||
|
||||
if wsDeadline < minimumDeadline {
|
||||
t.Fatalf("WebSocket deadline %s is shorter than the slow-collection window of %s", wsDeadline, minimumDeadline)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,11 +43,6 @@ COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
|
||||
# Copy smartmontools binaries and config files
|
||||
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
|
||||
|
||||
# Install ZFS userspace utilities (zpool, zfs) for pool/dataset monitoring
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
zfsutils-linux \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Ensure data persistence across container recreations
|
||||
VOLUME ["/var/lib/beszel-agent"]
|
||||
|
||||
|
||||
@@ -65,32 +65,6 @@ RUN set -eux; \
|
||||
cp -v "$interp" "/out/rootfs$interp"; \
|
||||
fi
|
||||
|
||||
# --------------------------
|
||||
# ZFS utilities builder stage
|
||||
# --------------------------
|
||||
FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS zfsutils-builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
zfsutils-linux \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy the zpool/zfs binaries and their required runtime libraries
|
||||
RUN set -eux; \
|
||||
mkdir -p /out/rootfs/lib /out/rootfs/lib64 /out/rootfs/usr/lib; \
|
||||
for bin in /usr/sbin/zpool /usr/sbin/zfs; do \
|
||||
mkdir -p "/out/rootfs$(dirname "$bin")"; \
|
||||
cp -v "$bin" "/out/rootfs$bin"; \
|
||||
ldd "$bin" \
|
||||
| awk '{print $3}' \
|
||||
| grep '^/' \
|
||||
| xargs -r -I '{}' sh -c 'mkdir -p "/out/rootfs$(dirname "{}")"; cp -v "{}" "/out/rootfs{}"'; \
|
||||
interp="$(ldd "$bin" | awk "/ld-linux/ {print \$1}")"; \
|
||||
if [ -n "$interp" ] && [ -e "$interp" ]; then \
|
||||
mkdir -p "/out/rootfs$(dirname "$interp")"; \
|
||||
cp -v "$interp" "/out/rootfs$interp"; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# --------------------------
|
||||
# Final image: lightweight multi-arch NVIDIA agent (slim)
|
||||
# --------------------------
|
||||
@@ -104,9 +78,6 @@ COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
|
||||
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
|
||||
COPY --from=smartmontools-builder /out/rootfs/ /
|
||||
|
||||
# Copy ZFS utilities (zpool, zfs) binaries and required runtime libraries
|
||||
COPY --from=zfsutils-builder /out/rootfs/ /
|
||||
|
||||
# nvidia-smi is intentionally not bundled.
|
||||
# Mount the host binary instead, for example:
|
||||
# - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro
|
||||
|
||||
@@ -272,15 +272,7 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
||||
|
||||
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
||||
systemRecord.Set("status", up)
|
||||
// Distinguish an idle GPU from a system without GPU data (#2312)
|
||||
info := struct {
|
||||
system.Info
|
||||
GpuPct *float64 `json:"g,omitempty"`
|
||||
}{Info: data.Info}
|
||||
if len(data.Stats.GPUData) > 0 {
|
||||
info.GpuPct = &data.Info.GpuPct
|
||||
}
|
||||
systemRecord.Set("info", info)
|
||||
systemRecord.Set("info", data.Info)
|
||||
if err := txApp.SaveNoValidate(systemRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
//go:build testing
|
||||
|
||||
package systems
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateRecordsGPUUtilization(t *testing.T) {
|
||||
sys, app := newTestSystemWithHub(t)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
gpu bool
|
||||
usage float64
|
||||
}{
|
||||
{"no GPU", false, 0},
|
||||
{"active GPU", true, 42.5},
|
||||
{"idle GPU", true, 0},
|
||||
{"GPU removed", false, 0},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data := &system.CombinedData{Info: system.Info{GpuPct: tc.usage, Cpu: 12.5}}
|
||||
if tc.gpu {
|
||||
data.Stats.GPUData = map[string]system.GPUData{"0": {Name: "GPU", Usage: tc.usage}}
|
||||
}
|
||||
_, err := sys.createRecords(data)
|
||||
require.NoError(t, err)
|
||||
record, err := app.FindRecordById("systems", sys.Id)
|
||||
require.NoError(t, err)
|
||||
var info map[string]any
|
||||
require.NoError(t, record.UnmarshalJSONField("info", &info))
|
||||
assert.Equal(t, 12.5, info["cpu"])
|
||||
if tc.gpu {
|
||||
assert.Equal(t, tc.usage, info["g"])
|
||||
} else {
|
||||
assert.NotContains(t, info, "g")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/hub/ws"
|
||||
@@ -43,17 +42,13 @@ var errSystemExists = errors.New("system exists")
|
||||
// SystemManager manages a collection of monitored systems and their connections.
|
||||
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
|
||||
type SystemManager struct {
|
||||
hub hubLike // Hub interface for database and alert operations
|
||||
systems *store.Store[string, *System] // Thread-safe store of active systems
|
||||
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
|
||||
smartFetchMap *expirymap.ExpiryMap[smartFetchState] // Stores last SMART fetch time/result; TTL is only for cleanup
|
||||
zfsFetchMap *expirymap.ExpiryMap[zfsFetchState] // Stores last ZFS fetch time/result; TTL is only for cleanup
|
||||
realtimeMutex sync.Mutex // Protects all realtime worker and subscription state
|
||||
activeSubscriptions map[string]*subscriptionInfo // Realtime subscriptions keyed by system ID
|
||||
realtimeWorkerStop chan struct{} // Stops the current realtime worker generation
|
||||
realtimeWorkerRun bool // Whether a realtime worker has been started
|
||||
ctx context.Context // Cancelled when the app terminates
|
||||
cancel context.CancelFunc // Cancels ctx and all child system contexts
|
||||
hub hubLike // Hub interface for database and alert operations
|
||||
systems *store.Store[string, *System] // Thread-safe store of active systems
|
||||
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
|
||||
smartFetchMap *expirymap.ExpiryMap[smartFetchState] // Stores last SMART fetch time/result; TTL is only for cleanup
|
||||
zfsFetchMap *expirymap.ExpiryMap[zfsFetchState] // Stores last ZFS fetch time/result; TTL is only for cleanup
|
||||
ctx context.Context // Cancelled when the app terminates
|
||||
cancel context.CancelFunc // Cancels ctx and all child system contexts
|
||||
}
|
||||
|
||||
// hubLike defines the interface requirements for the hub dependency.
|
||||
@@ -72,11 +67,10 @@ type hubLike interface {
|
||||
// The hub must implement the hubLike interface to provide database and alert functionality.
|
||||
func NewSystemManager(hub hubLike) *SystemManager {
|
||||
sm := &SystemManager{
|
||||
systems: store.New(map[string]*System{}),
|
||||
hub: hub,
|
||||
smartFetchMap: expirymap.New[smartFetchState](time.Hour),
|
||||
zfsFetchMap: expirymap.New[zfsFetchState](time.Hour),
|
||||
activeSubscriptions: make(map[string]*subscriptionInfo),
|
||||
systems: store.New(map[string]*System{}),
|
||||
hub: hub,
|
||||
smartFetchMap: expirymap.New[smartFetchState](time.Hour),
|
||||
zfsFetchMap: expirymap.New[zfsFetchState](time.Hour),
|
||||
}
|
||||
sm.ctx, sm.cancel = context.WithCancel(context.Background())
|
||||
return sm
|
||||
@@ -144,7 +138,6 @@ func (sm *SystemManager) bindEventHooks() {
|
||||
// onTerminate cancels SystemManager context on app shutdown
|
||||
func (sm *SystemManager) onTerminate(e *core.TerminateEvent) error {
|
||||
sm.cancel()
|
||||
sm.stopRealtimeWorker()
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,27 +3,25 @@ package systems
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/hub/utils"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
)
|
||||
|
||||
type subscriptionInfo struct {
|
||||
subscription string
|
||||
connectedClients int
|
||||
fetching bool
|
||||
connectedClients uint8
|
||||
}
|
||||
|
||||
type realtimeFetch struct {
|
||||
systemID string
|
||||
subscription string
|
||||
info *subscriptionInfo
|
||||
}
|
||||
var (
|
||||
activeSubscriptions = make(map[string]*subscriptionInfo)
|
||||
workerRunning bool
|
||||
tickerStopChan chan struct{}
|
||||
realtimeMutex sync.Mutex
|
||||
)
|
||||
|
||||
// onRealtimeConnectRequest handles client connection events for realtime subscriptions.
|
||||
// It cleans up existing subscriptions when a client connects.
|
||||
@@ -40,19 +38,6 @@ func (sm *SystemManager) onRealtimeConnectRequest(e *core.RealtimeConnectRequest
|
||||
// onRealtimeSubscribeRequest handles client subscription events for realtime metrics.
|
||||
// It tracks new subscriptions and unsubscriptions to manage the realtime worker lifecycle.
|
||||
func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeRequestEvent) error {
|
||||
// Parse with PocketBase's own subscription parser before changing the real
|
||||
// client. Reject the entire request if any metrics target is inaccessible.
|
||||
requested := subscriptions.NewDefaultClient()
|
||||
requested.Subscribe(e.Subscriptions...)
|
||||
for topic, options := range requested.Subscriptions() {
|
||||
if !strings.HasPrefix(topic, "rt_metrics") {
|
||||
continue
|
||||
}
|
||||
system, err := sm.GetSystem(options.Query["system"])
|
||||
if err != nil || !system.HasUser(e.App, e.Auth) {
|
||||
return e.NotFoundError("", nil)
|
||||
}
|
||||
}
|
||||
oldSubs := e.Client.Subscriptions()
|
||||
// after e.Next() is the result of the subscribe request
|
||||
err := e.Next()
|
||||
@@ -62,7 +47,14 @@ func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeReq
|
||||
for k, options := range newSubs {
|
||||
if _, ok := oldSubs[k]; !ok {
|
||||
if strings.HasPrefix(k, "rt_metrics") {
|
||||
sm.addRealtimeSubscription(options.Query["system"], k)
|
||||
systemId := options.Query["system"]
|
||||
if _, ok := activeSubscriptions[systemId]; !ok {
|
||||
activeSubscriptions[systemId] = &subscriptionInfo{
|
||||
subscription: k,
|
||||
}
|
||||
}
|
||||
activeSubscriptions[systemId].connectedClients += 1
|
||||
sm.onRealtimeSubscriptionAdded()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,76 +68,72 @@ func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeReq
|
||||
return err
|
||||
}
|
||||
|
||||
// addRealtimeSubscription tracks a subscriber and starts a worker if necessary.
|
||||
func (sm *SystemManager) addRealtimeSubscription(systemID, subscription string) {
|
||||
sm.realtimeMutex.Lock()
|
||||
defer sm.realtimeMutex.Unlock()
|
||||
// onRealtimeSubscriptionAdded initializes or starts the realtime worker when the first subscription is added.
|
||||
// It ensures only one worker runs at a time.
|
||||
func (sm *SystemManager) onRealtimeSubscriptionAdded() {
|
||||
realtimeMutex.Lock()
|
||||
defer realtimeMutex.Unlock()
|
||||
|
||||
if sm.activeSubscriptions == nil {
|
||||
sm.activeSubscriptions = make(map[string]*subscriptionInfo)
|
||||
}
|
||||
info, ok := sm.activeSubscriptions[systemID]
|
||||
if !ok {
|
||||
info = &subscriptionInfo{subscription: subscription}
|
||||
sm.activeSubscriptions[systemID] = info
|
||||
}
|
||||
info.connectedClients++
|
||||
|
||||
if !sm.realtimeWorkerRun {
|
||||
sm.realtimeWorkerRun = true
|
||||
stop := make(chan struct{})
|
||||
sm.realtimeWorkerStop = stop
|
||||
go sm.startRealtimeWorker(stop)
|
||||
// Start the worker if it's not already running
|
||||
if !workerRunning {
|
||||
workerRunning = true
|
||||
// Create a new stop channel for this worker instance
|
||||
tickerStopChan = make(chan struct{})
|
||||
go sm.startRealtimeWorker()
|
||||
}
|
||||
}
|
||||
|
||||
// stopRealtimeWorker stops the current worker generation, if any.
|
||||
func (sm *SystemManager) stopRealtimeWorker() {
|
||||
sm.realtimeMutex.Lock()
|
||||
defer sm.realtimeMutex.Unlock()
|
||||
sm.stopRealtimeWorkerLocked()
|
||||
}
|
||||
|
||||
func (sm *SystemManager) stopRealtimeWorkerLocked() {
|
||||
if !sm.realtimeWorkerRun {
|
||||
// checkSubscriptions stops the realtime worker when there are no active subscriptions.
|
||||
// This prevents unnecessary resource usage when no clients are listening for realtime data.
|
||||
func (sm *SystemManager) checkSubscriptions() {
|
||||
if !workerRunning || len(activeSubscriptions) > 0 {
|
||||
return
|
||||
}
|
||||
close(sm.realtimeWorkerStop)
|
||||
sm.realtimeWorkerStop = nil
|
||||
sm.realtimeWorkerRun = false
|
||||
|
||||
realtimeMutex.Lock()
|
||||
defer realtimeMutex.Unlock()
|
||||
|
||||
// Signal the worker to stop
|
||||
if tickerStopChan != nil {
|
||||
select {
|
||||
case tickerStopChan <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Mark worker as stopped (will be reset when next subscription comes in)
|
||||
workerRunning = false
|
||||
}
|
||||
|
||||
// removeRealtimeSubscription removes a realtime subscription and checks if the worker should be stopped.
|
||||
// It only processes subscriptions with the "rt_metrics" prefix and triggers cleanup when subscriptions are removed.
|
||||
func (sm *SystemManager) removeRealtimeSubscription(subscription string, options subscriptions.SubscriptionOptions) {
|
||||
if strings.HasPrefix(subscription, "rt_metrics") {
|
||||
systemID := options.Query["system"]
|
||||
sm.realtimeMutex.Lock()
|
||||
if info, ok := sm.activeSubscriptions[systemID]; ok {
|
||||
info.connectedClients--
|
||||
systemId := options.Query["system"]
|
||||
if info, ok := activeSubscriptions[systemId]; ok {
|
||||
info.connectedClients -= 1
|
||||
if info.connectedClients <= 0 {
|
||||
delete(sm.activeSubscriptions, systemID)
|
||||
delete(activeSubscriptions, systemId)
|
||||
}
|
||||
}
|
||||
if len(sm.activeSubscriptions) == 0 {
|
||||
sm.stopRealtimeWorkerLocked()
|
||||
}
|
||||
sm.realtimeMutex.Unlock()
|
||||
sm.checkSubscriptions()
|
||||
}
|
||||
}
|
||||
|
||||
// startRealtimeWorker runs the main loop for fetching realtime data from agents.
|
||||
// It continuously fetches system data and broadcasts it to subscribed clients via WebSocket.
|
||||
func (sm *SystemManager) startRealtimeWorker(stop <-chan struct{}) {
|
||||
func (sm *SystemManager) startRealtimeWorker() {
|
||||
sm.fetchRealtimeDataAndNotify()
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
tick := time.Tick(1 * time.Second)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
case <-tickerStopChan:
|
||||
return
|
||||
case <-ticker.C:
|
||||
case <-tick:
|
||||
if len(activeSubscriptions) == 0 {
|
||||
return
|
||||
}
|
||||
sm.fetchRealtimeDataAndNotify()
|
||||
}
|
||||
}
|
||||
@@ -153,79 +141,27 @@ func (sm *SystemManager) startRealtimeWorker(stop <-chan struct{}) {
|
||||
|
||||
// fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients.
|
||||
func (sm *SystemManager) fetchRealtimeDataAndNotify() {
|
||||
for _, fetch := range sm.claimRealtimeFetches() {
|
||||
system, err := sm.GetSystem(fetch.systemID)
|
||||
for systemId, info := range activeSubscriptions {
|
||||
system, err := sm.GetSystem(systemId)
|
||||
if err != nil {
|
||||
sm.finishRealtimeFetch(fetch)
|
||||
continue
|
||||
}
|
||||
go func(fetch realtimeFetch) {
|
||||
defer sm.finishRealtimeFetch(fetch)
|
||||
go func() {
|
||||
data, err := system.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: 1000})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
bytes, err := json.Marshal(data)
|
||||
if err == nil {
|
||||
notify(sm.hub, system, fetch.subscription, bytes)
|
||||
notify(sm.hub, info.subscription, bytes)
|
||||
}
|
||||
}(fetch)
|
||||
}
|
||||
}
|
||||
|
||||
// claimRealtimeFetches takes a stable snapshot and marks each selected system as
|
||||
// in flight. Slow agents are skipped on later ticks until their fetch completes.
|
||||
func (sm *SystemManager) claimRealtimeFetches() []realtimeFetch {
|
||||
sm.realtimeMutex.Lock()
|
||||
defer sm.realtimeMutex.Unlock()
|
||||
|
||||
fetches := make([]realtimeFetch, 0, len(sm.activeSubscriptions))
|
||||
for systemID, info := range sm.activeSubscriptions {
|
||||
if info.fetching {
|
||||
continue
|
||||
}
|
||||
info.fetching = true
|
||||
fetches = append(fetches, realtimeFetch{
|
||||
systemID: systemID,
|
||||
subscription: info.subscription,
|
||||
info: info,
|
||||
})
|
||||
}
|
||||
return fetches
|
||||
}
|
||||
|
||||
func (sm *SystemManager) finishRealtimeFetch(fetch realtimeFetch) {
|
||||
sm.realtimeMutex.Lock()
|
||||
defer sm.realtimeMutex.Unlock()
|
||||
// A subscription may have been removed and recreated while the old request
|
||||
// was running. Only release the exact entry claimed by this request.
|
||||
if info := sm.activeSubscriptions[fetch.systemID]; info == fetch.info {
|
||||
info.fetching = false
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// notify broadcasts realtime data to all clients subscribed to a specific subscription.
|
||||
// Custom topics bypass collection rules, so check current access for every
|
||||
// recipient, including clients whose authentication or membership was revoked.
|
||||
func notify(app core.App, system *System, subscription string, data []byte) error {
|
||||
shareAll, _ := utils.GetEnv("SHARE_ALL_SYSTEMS")
|
||||
members := make(map[string]struct{})
|
||||
if shareAll != "true" {
|
||||
// Refresh once per broadcast so membership changes take effect on the
|
||||
// next update without querying the database for every recipient.
|
||||
var recordData struct{ Users string }
|
||||
if err := app.DB().NewQuery("SELECT users FROM systems WHERE id={:id}").
|
||||
Bind(dbx.Params{"id": system.Id}).One(&recordData); err != nil {
|
||||
return err
|
||||
}
|
||||
var userIDs []string
|
||||
if err := json.Unmarshal([]byte(recordData.Users), &userIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range userIDs {
|
||||
members[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
// It iterates through all connected clients and sends the data only to those with matching subscriptions.
|
||||
func notify(app core.App, subscription string, data []byte) error {
|
||||
message := subscriptions.Message{
|
||||
Name: subscription,
|
||||
Data: data,
|
||||
@@ -234,13 +170,6 @@ func notify(app core.App, system *System, subscription string, data []byte) erro
|
||||
if !client.HasSubscription(subscription) {
|
||||
continue
|
||||
}
|
||||
auth, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
|
||||
if auth == nil {
|
||||
continue
|
||||
}
|
||||
if _, member := members[auth.Id]; shareAll != "true" && !member {
|
||||
continue
|
||||
}
|
||||
client.Send(message)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
package systems
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
pbtests "github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRealtimeAuthorization(t *testing.T) {
|
||||
t.Setenv("SHARE_ALL_SYSTEMS", "false")
|
||||
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "")
|
||||
app, err := pbtests.NewTestApp(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(app.Cleanup)
|
||||
_, err = app.DB().NewQuery(`CREATE TABLE IF NOT EXISTS systems (id TEXT PRIMARY KEY, users TEXT)`).Execute()
|
||||
require.NoError(t, err)
|
||||
_, err = app.DB().NewQuery(`INSERT INTO systems (id, users) VALUES ('target', '["member"]')`).Execute()
|
||||
require.NoError(t, err)
|
||||
member := core.NewRecord(core.NewAuthCollection("users"))
|
||||
member.Id = "member"
|
||||
outsider := core.NewRecord(member.Collection())
|
||||
outsider.Id = "outsider"
|
||||
system := &System{Id: "target"}
|
||||
sm := newRealtimeTestManager()
|
||||
sm.systems.Set(system.Id, system)
|
||||
// Keep the lifecycle bookkeeping active without starting an agent worker.
|
||||
sm.realtimeWorkerRun = true
|
||||
sm.realtimeWorkerStop = make(chan struct{})
|
||||
t.Cleanup(sm.stopRealtimeWorker)
|
||||
topic := `rt_metrics?options={"query":{"system":"target"}}`
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
auth *core.Record
|
||||
topic string
|
||||
share bool
|
||||
allowed bool
|
||||
}{
|
||||
{"guest", nil, topic, false, false},
|
||||
{"outsider", outsider, topic, false, false},
|
||||
{"member", member, topic, false, true},
|
||||
{"missing system", member, `rt_metrics`, false, false},
|
||||
{"unknown system", member, `rt_metrics?options={"query":{"system":"missing"}}`, false, false},
|
||||
{"malformed options", member, `rt_metrics?options=invalid`, false, false},
|
||||
{"prefix variant", outsider, `rt_metrics_extra?options={"query":{"system":"target"}}`, false, false},
|
||||
{"shared outsider", outsider, topic, true, true},
|
||||
{"shared guest", nil, topic, true, false},
|
||||
{"other topic", nil, "systems/*", false, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.share {
|
||||
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "true")
|
||||
}
|
||||
client := subscriptions.NewDefaultClient()
|
||||
client.Subscribe("existing")
|
||||
e := &core.RealtimeSubscribeRequestEvent{
|
||||
RequestEvent: &core.RequestEvent{App: app, Auth: tc.auth},
|
||||
Client: client, Subscriptions: []string{tc.topic},
|
||||
}
|
||||
called := false
|
||||
h := &hook.Hook[*core.RealtimeSubscribeRequestEvent]{}
|
||||
h.BindFunc(sm.onRealtimeSubscribeRequest)
|
||||
err := h.Trigger(e, func(e *core.RealtimeSubscribeRequestEvent) error {
|
||||
called = true
|
||||
client.Unsubscribe()
|
||||
client.Subscribe(e.Subscriptions...)
|
||||
return nil
|
||||
})
|
||||
if tc.allowed {
|
||||
require.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
assert.False(t, called)
|
||||
assert.True(t, client.HasSubscription("existing"))
|
||||
assert.False(t, client.HasSubscription(tc.topic))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("broadcast checks current access", func(t *testing.T) {
|
||||
client := subscriptions.NewDefaultClient()
|
||||
client.Subscribe(topic)
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
defer app.SubscriptionsBroker().Unregister(client.Id())
|
||||
secondClient := subscriptions.NewDefaultClient()
|
||||
secondClient.Subscribe(topic)
|
||||
app.SubscriptionsBroker().Register(secondClient)
|
||||
defer app.SubscriptionsBroker().Unregister(secondClient.Id())
|
||||
check := func(auth *core.Record, allowed bool) {
|
||||
t.Helper()
|
||||
client.Set(apis.RealtimeClientAuthKey, auth)
|
||||
secondClient.Set(apis.RealtimeClientAuthKey, auth)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
notify(app, system, topic, []byte(`{"cpu":42}`))
|
||||
close(done)
|
||||
}()
|
||||
// Even on failure, drain pending sends and join the broadcaster before
|
||||
// unregistering clients, which closes their channels.
|
||||
defer func() {
|
||||
for {
|
||||
select {
|
||||
case <-client.Channel():
|
||||
case <-secondClient.Channel():
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
var received [2]int
|
||||
timer := time.NewTimer(time.Second)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case msg := <-client.Channel():
|
||||
received[0]++
|
||||
assert.Equal(t, topic, msg.Name)
|
||||
case msg := <-secondClient.Channel():
|
||||
received[1]++
|
||||
assert.Equal(t, topic, msg.Name)
|
||||
case <-done:
|
||||
want := [2]int{}
|
||||
if allowed {
|
||||
want = [2]int{1, 1}
|
||||
}
|
||||
assert.Equal(t, want, received)
|
||||
return
|
||||
case <-timer.C:
|
||||
t.Fatal("broadcast did not finish")
|
||||
}
|
||||
}
|
||||
}
|
||||
check(nil, false)
|
||||
check(outsider, false)
|
||||
check(member, true)
|
||||
_, err := app.DB().NewQuery(`UPDATE systems SET users = '[]'`).Execute()
|
||||
require.NoError(t, err)
|
||||
check(member, false)
|
||||
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "true")
|
||||
check(outsider, true)
|
||||
check(nil, false)
|
||||
})
|
||||
}
|
||||
|
||||
func newRealtimeTestManager() *SystemManager {
|
||||
return &SystemManager{
|
||||
systems: store.New(map[string]*System{}),
|
||||
activeSubscriptions: make(map[string]*subscriptionInfo),
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeFetchesDoNotOverlapPerSystem(t *testing.T) {
|
||||
sm := newRealtimeTestManager()
|
||||
sm.activeSubscriptions["one"] = &subscriptionInfo{subscription: "rt_metrics_one"}
|
||||
sm.activeSubscriptions["two"] = &subscriptionInfo{subscription: "rt_metrics_two"}
|
||||
|
||||
first := sm.claimRealtimeFetches()
|
||||
require.Len(t, first, 2)
|
||||
assert.Empty(t, sm.claimRealtimeFetches())
|
||||
|
||||
sm.finishRealtimeFetch(first[0])
|
||||
next := sm.claimRealtimeFetches()
|
||||
require.Len(t, next, 1)
|
||||
assert.Equal(t, first[0].systemID, next[0].systemID)
|
||||
|
||||
sm.finishRealtimeFetch(first[1])
|
||||
sm.finishRealtimeFetch(next[0])
|
||||
}
|
||||
|
||||
func TestFinishingOldRealtimeFetchDoesNotReleaseReplacement(t *testing.T) {
|
||||
sm := newRealtimeTestManager()
|
||||
oldInfo := &subscriptionInfo{subscription: "old"}
|
||||
sm.activeSubscriptions["system"] = oldInfo
|
||||
|
||||
fetch := sm.claimRealtimeFetches()[0]
|
||||
newInfo := &subscriptionInfo{subscription: "new", fetching: true}
|
||||
sm.activeSubscriptions["system"] = newInfo
|
||||
|
||||
sm.finishRealtimeFetch(fetch)
|
||||
assert.True(t, newInfo.fetching)
|
||||
}
|
||||
|
||||
func TestRealtimeSubscriptionLifecycle(t *testing.T) {
|
||||
sm := newRealtimeTestManager()
|
||||
options := subscriptions.SubscriptionOptions{Query: map[string]string{"system": "system"}}
|
||||
|
||||
sm.addRealtimeSubscription("system", "rt_metrics")
|
||||
sm.addRealtimeSubscription("system", "rt_metrics")
|
||||
|
||||
sm.realtimeMutex.Lock()
|
||||
firstStop := sm.realtimeWorkerStop
|
||||
assert.True(t, sm.realtimeWorkerRun)
|
||||
assert.Equal(t, 2, sm.activeSubscriptions["system"].connectedClients)
|
||||
sm.realtimeMutex.Unlock()
|
||||
|
||||
sm.removeRealtimeSubscription("rt_metrics", options)
|
||||
sm.realtimeMutex.Lock()
|
||||
assert.True(t, sm.realtimeWorkerRun)
|
||||
assert.Equal(t, 1, sm.activeSubscriptions["system"].connectedClients)
|
||||
sm.realtimeMutex.Unlock()
|
||||
|
||||
sm.removeRealtimeSubscription("rt_metrics", options)
|
||||
sm.realtimeMutex.Lock()
|
||||
assert.False(t, sm.realtimeWorkerRun)
|
||||
assert.Empty(t, sm.activeSubscriptions)
|
||||
sm.realtimeMutex.Unlock()
|
||||
select {
|
||||
case <-firstStop:
|
||||
default:
|
||||
t.Fatal("worker stop channel was not closed")
|
||||
}
|
||||
|
||||
// A later subscription must get a new stop channel owned by its worker.
|
||||
sm.addRealtimeSubscription("system", "rt_metrics")
|
||||
sm.realtimeMutex.Lock()
|
||||
secondStop := sm.realtimeWorkerStop
|
||||
assert.NotEqual(t, firstStop, secondStop)
|
||||
sm.realtimeMutex.Unlock()
|
||||
sm.stopRealtimeWorker()
|
||||
}
|
||||
@@ -75,12 +75,7 @@ export const smartColumns: ColumnDef<SmartAttribute>[] = [
|
||||
header: "Name",
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => {
|
||||
if (row.n === "DataUnitsWritten" || row.n === "DataUnitsRead") {
|
||||
return formatDataUnits(Number(row.rv ?? 0))
|
||||
}
|
||||
return row.rs || row.rv?.toString()
|
||||
},
|
||||
accessorFn: (row) => row.rs || row.rv?.toString(),
|
||||
header: "Value",
|
||||
},
|
||||
{
|
||||
@@ -108,12 +103,6 @@ function formatCapacity(bytes: number): string {
|
||||
return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}`
|
||||
}
|
||||
|
||||
// Function to format NVMe data units
|
||||
// (1 unit = 1000 * 512 bytes) as a human-readable size
|
||||
function formatDataUnits(units: number): string {
|
||||
return formatCapacity(units * 1000 * 512)
|
||||
}
|
||||
|
||||
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
|
||||
|
||||
export const createColumns = (
|
||||
|
||||
@@ -193,7 +193,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
||||
header: sortableHeader,
|
||||
},
|
||||
{
|
||||
accessorFn: ({ info }) => info.g,
|
||||
accessorFn: ({ info }) => info.g || undefined,
|
||||
id: "gpu",
|
||||
name: () => "GPU",
|
||||
cell: (info) => {
|
||||
|
||||
@@ -812,11 +812,6 @@ elif is_freebsd; then
|
||||
echo "Adding beszel to wheel group for self-updates"
|
||||
pw group mod wheel -m beszel
|
||||
fi
|
||||
# Add the user to the operator group for device access (SMART, /dev/xpt0, /dev/nvme*)
|
||||
if pw group show operator >/dev/null 2>&1; then
|
||||
echo "Adding beszel to operator group for device access"
|
||||
pw group mod operator -m beszel
|
||||
fi
|
||||
fi
|
||||
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user