From 98687be2f220f3b2f4ea748edc1bb0d08305e7de Mon Sep 17 00:00:00 2001 From: henrygd Date: Tue, 8 Sep 2026 10:08:33 -0400 Subject: [PATCH] fix(hub): reject null systemd service entries to prevent hub panic --- internal/hub/systems/system.go | 5 +++ internal/hub/systems/systemd_records_test.go | 38 ++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index e5650526..5cb3f30b 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -330,6 +330,11 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s valueStrings := make([]string, 0, len(data)) for i, service := range data { + // Agent payloads can contain null entries. Reject the snapshot before + // executing any queries so existing service records remain intact. + if service == nil { + return fmt.Errorf("null systemd service at index %d", i) + } suffix := fmt.Sprintf("%d", i) valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix)) params["id"+suffix] = makeStableHashId(systemId, service.Name) diff --git a/internal/hub/systems/systemd_records_test.go b/internal/hub/systems/systemd_records_test.go index 91af6f48..e6d03410 100644 --- a/internal/hub/systems/systemd_records_test.go +++ b/internal/hub/systems/systemd_records_test.go @@ -3,9 +3,11 @@ package systems_test import ( + "encoding/json" "testing" "time" + "github.com/fxamacker/cbor/v2" "github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/systemd" "github.com/henrygd/beszel/internal/hub/systems" @@ -15,6 +17,42 @@ import ( "github.com/stretchr/testify/require" ) +func TestCreateRecordsRejectsNullSystemdService(t *testing.T) { + hub, user := tests.GetHubWithUser(t) + defer hub.Cleanup() + records, err := tests.CreateSystems(hub, 1, user.Id, "paused") + require.NoError(t, err) + sys, err := hub.GetSystemManager().GetSystem(records[0].Id) + require.NoError(t, err) + require.NoError(t, systems.CreateSystemdStatsRecords(hub, []*systemd.Service{ + {Name: "existing.service", State: systemd.StatusFailed}, + }, records[0].Id)) + + for _, services := range []string{`[null]`, `[{"name":"new.service"},null]`, `[null,{"name":"new.service"}]`} { + for _, encoding := range []string{"json", "cbor"} { + t.Run(encoding+"/"+services, func(t *testing.T) { + var data system.CombinedData + require.NoError(t, json.Unmarshal([]byte(`{"systemd":`+services+`}`), &data)) + if encoding == "cbor" { + encoded, err := cbor.Marshal(data) + require.NoError(t, err) + data = system.CombinedData{} + require.NoError(t, cbor.Unmarshal(encoded, &data)) + } + _, err := sys.CreateRecords(&data) + require.ErrorContains(t, err, "null systemd service") + var names []string + require.NoError(t, hub.DB().Select("name").From("systemd_services"). + Where(dbx.HashExp{"system": records[0].Id}).Column(&names)) + assert.Equal(t, []string{"existing.service"}, names) + count, err := hub.CountRecords("system_stats", dbx.HashExp{"system": records[0].Id}) + require.NoError(t, err) + assert.Zero(t, count, "invalid snapshot must roll back system stats") + }) + } + } +} + func TestCreateRecordsHandlesSystemdAlertLifecycle(t *testing.T) { hub, user := tests.GetHubWithUser(t) defer hub.Cleanup()