From 9f8faee011fbd66d199d244c248c403b60a9b4bd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 17:06:38 +0000 Subject: [PATCH 1/2] Cap history capacity to prevent OOM from misconfigured window/interval HistoryCapacity() was the unchecked ratio of history_window_minutes to poll_interval_seconds. Since NewRingBuffer allocates its capacity eagerly for each of the 7 scalar history series, an extreme ratio (e.g. a typo like history_window_minutes: 525600 with poll_interval_seconds: 0.05) could allocate several GiB at startup and OOM a Pi. Validate() now rejects any configuration whose implied history capacity exceeds 1,000,000 points per series, failing fast with a descriptive error instead of letting the allocator die. Closes #65 --- internal/config/config.go | 12 ++++++++++++ internal/config/config_test.go | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index e6b7359..98c8ec6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -182,6 +182,15 @@ func (c Config) HistoryCapacity() int { return capacity } +// maxHistoryCapacity bounds HistoryCapacity(). NewRingBuffer allocates its +// capacity eagerly for each of the 7 scalar history series, so an +// unreasonably large window/interval ratio (e.g. a config typo like +// history_window_minutes: 525600 with poll_interval_seconds: 0.05) would +// otherwise allocate on the order of gigabytes at startup and OOM a Pi. One +// million points per series (~7M points, well under 1 GiB total) is far more +// than any real dashboard use case needs. +const maxHistoryCapacity = 1_000_000 + // validLogLevels are the log levels newLogger understands; any other value // silently falls back to info, so we reject it here instead. var validLogLevels = map[string]bool{ @@ -208,6 +217,9 @@ func (c Config) Validate() error { if c.HistoryWindowMinutes <= 0 { return fmt.Errorf("history_window_minutes must be > 0 (got %v)", c.HistoryWindowMinutes) } + if cap := c.HistoryCapacity(); cap > maxHistoryCapacity { + return fmt.Errorf("history_window_minutes (%v) / poll_interval_seconds (%v) implies %d history points per series, exceeding the %d limit; increase poll_interval_seconds or reduce history_window_minutes", c.HistoryWindowMinutes, c.PollIntervalSeconds, cap, maxHistoryCapacity) + } if c.HistoryPersistEnabled && c.DataDir == "" { return fmt.Errorf("data_dir must not be empty when history_persist_enabled is true") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 335757a..2e32568 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -232,6 +232,10 @@ func TestValidate_RejectsBadValues(t *testing.T) { {"disk warn above crit", func(c *Config) { c.Thresholds.DiskWarnPercent = 99 }}, {"swap warn above crit", func(c *Config) { c.Thresholds.SwapWarnPercent = 99 }}, {"memory warn above crit", func(c *Config) { c.Thresholds.MemoryWarnPercent = 99 }}, + {"history capacity exceeds sanity cap", func(c *Config) { + c.HistoryWindowMinutes = 525600 + c.PollIntervalSeconds = 0.05 + }}, {"negative alerts for_seconds", func(c *Config) { c.Alerts.ForSeconds = -1 }}, {"negative notify max retries", func(c *Config) { c.Alerts.NotifyMaxRetries = -1 }}, {"negative notify backoff", func(c *Config) { c.Alerts.NotifyRetryBackoffSeconds = -1 }}, @@ -255,6 +259,23 @@ func TestValidate_RejectsBadValues(t *testing.T) { } } +func TestValidate_HistoryCapacityBoundary(t *testing.T) { + cfg := Default() + // With poll_interval_seconds: 60, HistoryCapacity() equals + // history_window_minutes exactly, giving clean integer boundaries. + cfg.PollIntervalSeconds = 60 + + cfg.HistoryWindowMinutes = maxHistoryCapacity // exactly at the cap + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() rejected history capacity exactly at the cap: %v", err) + } + + cfg.HistoryWindowMinutes = maxHistoryCapacity + 1 // one point over + if err := cfg.Validate(); err == nil { + t.Fatal("Validate() accepted history capacity one point over the cap") + } +} + func TestValidate_AcceptsValidEdgeCases(t *testing.T) { cfg := Default() // Warn equal to crit and a zero stale threshold are both allowed. From a3e2f8c19ba6dcaaa4dcd9a95fad57ff430379f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 17:26:24 +0000 Subject: [PATCH 2/2] Fix platform-dependent bypass of the history capacity cap Validate() called HistoryCapacity(), which converts the window/interval ratio to int before the cap check. Converting an out-of-range float64 to int is implementation-specific in Go and differs by architecture: amd64 wraps an overflowing value to a negative int64, while arm64 saturates to math.MaxInt64. Since HistoryCapacity() also clamps negative results up to 1, an extreme ratio (e.g. history_window_minutes: 1e300) would wrap to a negative number on amd64 and pass validation with a useless 1-point buffer, while correctly failing on arm64 (the Pi's actual architecture) - the exact class of typo this cap exists to catch. Comparing the ratio in float64 before any int conversion closes the gap on all platforms. --- internal/config/config.go | 10 ++++++++-- internal/config/config_test.go | 9 +++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 98c8ec6..9ad5d45 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -217,8 +217,14 @@ func (c Config) Validate() error { if c.HistoryWindowMinutes <= 0 { return fmt.Errorf("history_window_minutes must be > 0 (got %v)", c.HistoryWindowMinutes) } - if cap := c.HistoryCapacity(); cap > maxHistoryCapacity { - return fmt.Errorf("history_window_minutes (%v) / poll_interval_seconds (%v) implies %d history points per series, exceeding the %d limit; increase poll_interval_seconds or reduce history_window_minutes", c.HistoryWindowMinutes, c.PollIntervalSeconds, cap, maxHistoryCapacity) + // Compare the ratio in float64 before any int conversion: converting an + // out-of-range float64 to int is implementation-specific in Go and + // differs by architecture (e.g. amd64 wraps to math.MinInt64, arm64 + // saturates to math.MaxInt64), so calling HistoryCapacity() first and + // comparing the resulting int could let an extreme ratio slip past this + // check on some platforms but not others. + if ratio := c.HistoryWindowMinutes * 60 / c.PollIntervalSeconds; ratio > maxHistoryCapacity { + return fmt.Errorf("history_window_minutes (%v) / poll_interval_seconds (%v) implies %v history points per series, exceeding the %d limit; increase poll_interval_seconds or reduce history_window_minutes", c.HistoryWindowMinutes, c.PollIntervalSeconds, ratio, maxHistoryCapacity) } if c.HistoryPersistEnabled && c.DataDir == "" { return fmt.Errorf("data_dir must not be empty when history_persist_enabled is true") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2e32568..05e9fa2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -236,6 +236,15 @@ func TestValidate_RejectsBadValues(t *testing.T) { c.HistoryWindowMinutes = 525600 c.PollIntervalSeconds = 0.05 }}, + {"history capacity ratio overflows int64", func(c *Config) { + // Regression test: converting an out-of-range float64 to int is + // implementation-specific in Go and differs by architecture + // (amd64 wraps to a negative number, arm64 saturates to + // math.MaxInt64), so the cap must be checked on the float64 + // ratio, not on HistoryCapacity()'s post-conversion int. + c.HistoryWindowMinutes = 1e300 + c.PollIntervalSeconds = 1 + }}, {"negative alerts for_seconds", func(c *Config) { c.Alerts.ForSeconds = -1 }}, {"negative notify max retries", func(c *Config) { c.Alerts.NotifyMaxRetries = -1 }}, {"negative notify backoff", func(c *Config) { c.Alerts.NotifyRetryBackoffSeconds = -1 }},