mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
Implements comprehensive security hardening for pulse-sensor-proxy: - Privilege drop from root to unprivileged user (UID 995) - Hash-chained tamper-evident audit logging with remote forwarding - Per-UID rate limiting (0.2 QPS, burst 2) with concurrency caps - Enhanced command validation with 10+ attack pattern tests - Fuzz testing (7M+ executions, 0 crashes) - SSH hardening, AppArmor/seccomp profiles, operational runbooks All 27 Phase 1 tasks complete. Ready for production deployment.
533 lines
15 KiB
Go
533 lines
15 KiB
Go
package monitoring
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/tempproxy"
|
|
)
|
|
|
|
type stubProxyResponse struct {
|
|
output string
|
|
err error
|
|
}
|
|
|
|
type stubTemperatureProxy struct {
|
|
mu sync.Mutex
|
|
available bool
|
|
responses []stubProxyResponse
|
|
responseFunc func(call int) stubProxyResponse
|
|
callCount int
|
|
}
|
|
|
|
func (s *stubTemperatureProxy) IsAvailable() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.available
|
|
}
|
|
|
|
func (s *stubTemperatureProxy) GetTemperature(host string) (string, error) {
|
|
s.mu.Lock()
|
|
call := s.callCount
|
|
s.callCount++
|
|
|
|
resp := stubProxyResponse{}
|
|
switch {
|
|
case call < len(s.responses):
|
|
resp = s.responses[call]
|
|
case s.responseFunc != nil:
|
|
resp = s.responseFunc(call)
|
|
case len(s.responses) > 0:
|
|
resp = s.responses[len(s.responses)-1]
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
return resp.output, resp.err
|
|
}
|
|
|
|
func (s *stubTemperatureProxy) setAvailable(v bool) {
|
|
s.mu.Lock()
|
|
s.available = v
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func TestParseSensorsJSON_NoTemperatureData(t *testing.T) {
|
|
collector := &TemperatureCollector{}
|
|
|
|
// Test with a chip that doesn't match any known CPU or NVMe patterns
|
|
jsonStr := `{
|
|
"unknown-sensor-0": {
|
|
"Adapter": "Unknown interface",
|
|
"temp1": {
|
|
"temp1_label": "temp1"
|
|
}
|
|
}
|
|
}`
|
|
|
|
temp, err := collector.parseSensorsJSON(jsonStr)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error parsing sensors output: %v", err)
|
|
}
|
|
if temp == nil {
|
|
t.Fatalf("expected temperature struct, got nil")
|
|
}
|
|
if temp.Available {
|
|
t.Fatalf("expected temperature to be unavailable when no CPU or NVMe chips are detected")
|
|
}
|
|
if temp.HasCPU {
|
|
t.Fatalf("expected HasCPU to be false when no CPU chip detected")
|
|
}
|
|
if temp.HasNVMe {
|
|
t.Fatalf("expected HasNVMe to be false when no NVMe chip detected")
|
|
}
|
|
}
|
|
|
|
func TestParseSensorsJSON_WithCpuAndNvmeData(t *testing.T) {
|
|
collector := &TemperatureCollector{}
|
|
|
|
jsonStr := `{
|
|
"coretemp-isa-0000": {
|
|
"Package id 0": {"temp1_input": 45.5},
|
|
"Core 0": {"temp2_input": 43.0},
|
|
"Core 1": {"temp3_input": 44.2}
|
|
},
|
|
"nvme-pci-0400": {
|
|
"Composite": {"temp1_input": 38.75}
|
|
}
|
|
}`
|
|
|
|
temp, err := collector.parseSensorsJSON(jsonStr)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error parsing sensors output: %v", err)
|
|
}
|
|
if temp == nil {
|
|
t.Fatalf("expected temperature struct, got nil")
|
|
}
|
|
if !temp.Available {
|
|
t.Fatalf("expected temperature to be available when readings are present")
|
|
}
|
|
if temp.CPUPackage != 45.5 {
|
|
t.Fatalf("expected cpu package temperature 45.5, got %.2f", temp.CPUPackage)
|
|
}
|
|
if temp.CPUMax <= 0 {
|
|
t.Fatalf("expected cpu max temperature to be greater than zero, got %.2f", temp.CPUMax)
|
|
}
|
|
if len(temp.Cores) != 2 {
|
|
t.Fatalf("expected two core temperatures, got %d", len(temp.Cores))
|
|
}
|
|
if len(temp.NVMe) != 1 {
|
|
t.Fatalf("expected one NVMe temperature, got %d", len(temp.NVMe))
|
|
}
|
|
if temp.NVMe[0].Temp != 38.75 {
|
|
t.Fatalf("expected NVMe temperature 38.75, got %.2f", temp.NVMe[0].Temp)
|
|
}
|
|
if !temp.HasCPU {
|
|
t.Fatalf("expected HasCPU to be true when CPU data present")
|
|
}
|
|
if !temp.HasNVMe {
|
|
t.Fatalf("expected HasNVMe to be true when NVMe data present")
|
|
}
|
|
}
|
|
|
|
func TestParseSensorsJSON_RPiWrapper(t *testing.T) {
|
|
collector := &TemperatureCollector{}
|
|
|
|
jsonStr := `{"rpitemp-virtual":{"temp1":{"temp1_input":47.5}}}`
|
|
|
|
temp, err := collector.parseSensorsJSON(jsonStr)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error parsing wrapper output: %v", err)
|
|
}
|
|
if temp == nil {
|
|
t.Fatalf("expected temperature struct, got nil")
|
|
}
|
|
if !temp.HasCPU {
|
|
t.Fatalf("expected HasCPU to be true for wrapper output")
|
|
}
|
|
if temp.CPUPackage != 47.5 {
|
|
t.Fatalf("expected cpu package temperature 47.5, got %.2f", temp.CPUPackage)
|
|
}
|
|
if !temp.Available {
|
|
t.Fatalf("expected temperature to be available for wrapper output")
|
|
}
|
|
}
|
|
|
|
func TestShouldDisableProxy(t *testing.T) {
|
|
collector := &TemperatureCollector{}
|
|
|
|
if !collector.shouldDisableProxy(fmt.Errorf("plain")) {
|
|
t.Fatalf("expected plain errors to disable proxy")
|
|
}
|
|
|
|
transportErr := &tempproxy.ProxyError{Type: tempproxy.ErrorTypeTransport}
|
|
if !collector.shouldDisableProxy(transportErr) {
|
|
t.Fatalf("expected transport errors to disable proxy")
|
|
}
|
|
|
|
sensorErr := &tempproxy.ProxyError{Type: tempproxy.ErrorTypeSensor}
|
|
if collector.shouldDisableProxy(sensorErr) {
|
|
t.Fatalf("sensor errors should not disable proxy")
|
|
}
|
|
}
|
|
|
|
// TestParseSensorsJSON_NVMeOnly tests that NVMe-only systems don't show "No CPU sensor"
|
|
func TestParseSensorsJSON_NVMeOnly(t *testing.T) {
|
|
collector := &TemperatureCollector{}
|
|
|
|
jsonStr := `{
|
|
"nvme-pci-0400": {
|
|
"Composite": {"temp1_input": 42.5}
|
|
},
|
|
"nvme-pci-0500": {
|
|
"Composite": {"temp1_input": 38.0}
|
|
}
|
|
}`
|
|
|
|
temp, err := collector.parseSensorsJSON(jsonStr)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error parsing sensors output: %v", err)
|
|
}
|
|
if temp == nil {
|
|
t.Fatalf("expected temperature struct, got nil")
|
|
}
|
|
// available should be true (any temperature data exists)
|
|
if !temp.Available {
|
|
t.Fatalf("expected temperature to be available when NVMe readings are present")
|
|
}
|
|
// hasCPU should be false (no CPU temperature data)
|
|
if temp.HasCPU {
|
|
t.Fatalf("expected HasCPU to be false when only NVMe data present")
|
|
}
|
|
// hasNVMe should be true
|
|
if !temp.HasNVMe {
|
|
t.Fatalf("expected HasNVMe to be true when NVMe data present")
|
|
}
|
|
// Verify NVMe data was parsed correctly
|
|
if len(temp.NVMe) != 2 {
|
|
t.Fatalf("expected two NVMe temperatures, got %d", len(temp.NVMe))
|
|
}
|
|
// Check that both expected temperatures are present (order may vary)
|
|
foundTemps := make(map[float64]bool)
|
|
for _, nvme := range temp.NVMe {
|
|
foundTemps[nvme.Temp] = true
|
|
}
|
|
if !foundTemps[42.5] {
|
|
t.Fatalf("expected to find NVMe temperature 42.5")
|
|
}
|
|
if !foundTemps[38.0] {
|
|
t.Fatalf("expected to find NVMe temperature 38.0")
|
|
}
|
|
}
|
|
|
|
// TestParseSensorsJSON_ZeroTemperature tests that HasCPU is true even when sensor reports 0°C
|
|
func TestParseSensorsJSON_ZeroTemperature(t *testing.T) {
|
|
collector := &TemperatureCollector{}
|
|
|
|
jsonStr := `{
|
|
"coretemp-isa-0000": {
|
|
"Package id 0": {"temp1_input": 0.0},
|
|
"Core 0": {"temp2_input": 0.0}
|
|
}
|
|
}`
|
|
|
|
temp, err := collector.parseSensorsJSON(jsonStr)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error parsing sensors output: %v", err)
|
|
}
|
|
if temp == nil {
|
|
t.Fatalf("expected temperature struct, got nil")
|
|
}
|
|
// hasCPU should be true because coretemp chip was detected, even though values are 0
|
|
if !temp.HasCPU {
|
|
t.Fatalf("expected HasCPU to be true when CPU chip is detected (even with 0°C readings)")
|
|
}
|
|
// available should be true because we have a CPU sensor
|
|
if !temp.Available {
|
|
t.Fatalf("expected temperature to be available when CPU chip is detected")
|
|
}
|
|
// Values should be accepted (not filtered out)
|
|
if temp.CPUPackage != 0.0 {
|
|
t.Fatalf("expected CPUPackage to be 0.0, got %.2f", temp.CPUPackage)
|
|
}
|
|
if len(temp.Cores) != 1 {
|
|
t.Fatalf("expected one core temperature, got %d", len(temp.Cores))
|
|
}
|
|
if temp.Cores[0].Temp != 0.0 {
|
|
t.Fatalf("expected core temperature to be 0.0, got %.2f", temp.Cores[0].Temp)
|
|
}
|
|
}
|
|
|
|
func TestParseRPiTemperature(t *testing.T) {
|
|
collector := &TemperatureCollector{}
|
|
temp, err := collector.parseRPiTemperature("48720\n")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error parsing RPi thermal zone output: %v", err)
|
|
}
|
|
if !temp.Available {
|
|
t.Fatalf("expected temperature to be marked available")
|
|
}
|
|
if !temp.HasCPU {
|
|
t.Fatalf("expected HasCPU to be true for RPi thermal zone output")
|
|
}
|
|
expected := 48.72
|
|
if diff := temp.CPUPackage - expected; diff > 1e-6 || diff < -1e-6 {
|
|
t.Fatalf("expected cpu package temperature %.2f, got %.2f", expected, temp.CPUPackage)
|
|
}
|
|
if temp.CPUMax != temp.CPUPackage {
|
|
t.Fatalf("expected cpu max to match package temperature %.2f, got %.2f", temp.CPUPackage, temp.CPUMax)
|
|
}
|
|
if temp.LastUpdate.IsZero() {
|
|
t.Fatalf("expected LastUpdate to be set")
|
|
}
|
|
if elapsed := time.Since(temp.LastUpdate); elapsed > 2*time.Second {
|
|
t.Fatalf("expected LastUpdate to be recent, got %s", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestParseSensorsJSON_PiPartialSensors(t *testing.T) {
|
|
collector := &TemperatureCollector{}
|
|
|
|
jsonStr := `{
|
|
"cpu_thermal-virtual-0": {
|
|
"Adapter": "Virtual device",
|
|
"temp1": {"temp1_input": 51.625}
|
|
}
|
|
}`
|
|
|
|
temp, err := collector.parseSensorsJSON(jsonStr)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error parsing Pi sensors output: %v", err)
|
|
}
|
|
if !temp.Available {
|
|
t.Fatalf("expected temperature to be available when cpu_thermal sensor present")
|
|
}
|
|
if !temp.HasCPU {
|
|
t.Fatalf("expected HasCPU to be true when cpu_thermal sensor present")
|
|
}
|
|
if temp.CPUPackage != 51.625 {
|
|
t.Fatalf("expected cpu package temperature 51.625, got %.3f", temp.CPUPackage)
|
|
}
|
|
if temp.CPUMax != 51.625 {
|
|
t.Fatalf("expected cpu max temperature 51.625, got %.3f", temp.CPUMax)
|
|
}
|
|
if len(temp.Cores) != 0 {
|
|
t.Fatalf("expected no per-core temperatures, got %d entries", len(temp.Cores))
|
|
}
|
|
}
|
|
|
|
func TestParseSensorsJSON_CoretempAndRPiFallback(t *testing.T) {
|
|
collector := &TemperatureCollector{}
|
|
|
|
jsonStr := `{
|
|
"coretemp-isa-0000": {
|
|
"Package id 0": {"temp1_input": 65.0},
|
|
"Core 0": {"temp2_input": 63.0},
|
|
"Core 1": {"temp3_input": 62.5}
|
|
},
|
|
"cpu_thermal-virtual-0": {
|
|
"temp1": {"temp1_input": 50.0}
|
|
}
|
|
}`
|
|
|
|
temp, err := collector.parseSensorsJSON(jsonStr)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error parsing mixed sensors output: %v", err)
|
|
}
|
|
if temp.CPUPackage != 65.0 {
|
|
t.Fatalf("expected cpu package temperature 65.0 from coretemp, got %.2f", temp.CPUPackage)
|
|
}
|
|
if temp.CPUMax < 63.0 {
|
|
t.Fatalf("expected cpu max to reflect hottest core (>=63.0), got %.2f", temp.CPUMax)
|
|
}
|
|
if !temp.HasCPU {
|
|
t.Fatalf("expected HasCPU to be true when CPU sensors present")
|
|
}
|
|
if !temp.Available {
|
|
t.Fatalf("expected temperature to be available when CPU sensors present")
|
|
}
|
|
}
|
|
|
|
func TestTemperatureCollector_DisablesProxyAfterFailures(t *testing.T) {
|
|
stub := &stubTemperatureProxy{
|
|
responses: []stubProxyResponse{
|
|
{err: &tempproxy.ProxyError{Type: tempproxy.ErrorTypeTransport, Message: "transport failure 1"}},
|
|
{err: &tempproxy.ProxyError{Type: tempproxy.ErrorTypeTransport, Message: "transport failure 2"}},
|
|
{err: &tempproxy.ProxyError{Type: tempproxy.ErrorTypeTransport, Message: "transport failure 3"}},
|
|
},
|
|
}
|
|
stub.setAvailable(true)
|
|
|
|
collector := &TemperatureCollector{
|
|
proxyClient: stub,
|
|
useProxy: true,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
for i := 0; i < proxyFailureThreshold; i++ {
|
|
temp, err := collector.CollectTemperature(ctx, "https://node.example", "node")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error on proxy failure %d: %v", i+1, err)
|
|
}
|
|
if temp.Available {
|
|
t.Fatalf("expected temperature to be unavailable after proxy failure %d", i+1)
|
|
}
|
|
}
|
|
|
|
if collector.useProxy {
|
|
t.Fatalf("expected proxy to be disabled after %d failures", proxyFailureThreshold)
|
|
}
|
|
if collector.proxyFailures != 0 {
|
|
t.Fatalf("expected proxy failure counter to reset after disable, got %d", collector.proxyFailures)
|
|
}
|
|
if collector.proxyCooldownUntil.IsZero() {
|
|
t.Fatalf("expected proxy cooldown to be scheduled after disable")
|
|
}
|
|
if time.Until(collector.proxyCooldownUntil) <= 0 {
|
|
t.Fatalf("expected proxy cooldown to be in the future, got %s", collector.proxyCooldownUntil)
|
|
}
|
|
}
|
|
|
|
func TestTemperatureCollector_ProxyReenablesAfterCooldown(t *testing.T) {
|
|
stub := &stubTemperatureProxy{}
|
|
stub.setAvailable(true)
|
|
|
|
collector := &TemperatureCollector{
|
|
proxyClient: stub,
|
|
useProxy: false,
|
|
proxyCooldownUntil: time.Now().Add(-time.Minute),
|
|
}
|
|
|
|
if !collector.isProxyEnabled() {
|
|
t.Fatalf("expected proxy to re-enable when available after cooldown")
|
|
}
|
|
if !collector.useProxy {
|
|
t.Fatalf("expected useProxy to be true after proxy restored")
|
|
}
|
|
if !collector.proxyCooldownUntil.IsZero() {
|
|
t.Fatalf("expected cooldown to reset after proxy restoration, got %s", collector.proxyCooldownUntil)
|
|
}
|
|
if collector.proxyFailures != 0 {
|
|
t.Fatalf("expected proxy failure counter to reset after restoration, got %d", collector.proxyFailures)
|
|
}
|
|
}
|
|
|
|
func TestTemperatureCollector_ProxyCooldownExtendsWhenUnavailable(t *testing.T) {
|
|
stub := &stubTemperatureProxy{}
|
|
stub.setAvailable(false)
|
|
|
|
collector := &TemperatureCollector{
|
|
proxyClient: stub,
|
|
useProxy: false,
|
|
proxyCooldownUntil: time.Now().Add(-time.Minute),
|
|
}
|
|
|
|
before := time.Now()
|
|
if collector.isProxyEnabled() {
|
|
t.Fatalf("expected proxy to remain disabled while unavailable")
|
|
}
|
|
if collector.useProxy {
|
|
t.Fatalf("expected useProxy to remain false while proxy unavailable")
|
|
}
|
|
if !collector.proxyCooldownUntil.After(before) {
|
|
t.Fatalf("expected cooldown to be pushed into the future, got %s", collector.proxyCooldownUntil)
|
|
}
|
|
}
|
|
|
|
func TestTemperatureCollector_SuccessResetsFailureCount(t *testing.T) {
|
|
successJSON := `{"coretemp-isa-0000":{"Package id 0":{"temp1_input": 45.0}}}`
|
|
stub := &stubTemperatureProxy{
|
|
responses: []stubProxyResponse{
|
|
{err: &tempproxy.ProxyError{Type: tempproxy.ErrorTypeTransport, Message: "transient failure"}},
|
|
{output: successJSON},
|
|
},
|
|
}
|
|
stub.setAvailable(true)
|
|
|
|
collector := &TemperatureCollector{
|
|
proxyClient: stub,
|
|
useProxy: true,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
if temp, err := collector.CollectTemperature(ctx, "https://node.example", "node"); err != nil {
|
|
t.Fatalf("unexpected error during proxy failure: %v", err)
|
|
} else if temp.Available {
|
|
t.Fatalf("expected unavailable temperature on proxy failure")
|
|
}
|
|
if collector.proxyFailures != 1 {
|
|
t.Fatalf("expected proxy failure counter to increment to 1, got %d", collector.proxyFailures)
|
|
}
|
|
|
|
temp, err := collector.CollectTemperature(ctx, "https://node.example", "node")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error on proxy success: %v", err)
|
|
}
|
|
if temp == nil || !temp.Available {
|
|
t.Fatalf("expected valid temperature after proxy success")
|
|
}
|
|
if collector.proxyFailures != 0 {
|
|
t.Fatalf("expected proxy failure counter reset after success, got %d", collector.proxyFailures)
|
|
}
|
|
if !collector.useProxy {
|
|
t.Fatalf("expected proxy to remain enabled after success")
|
|
}
|
|
}
|
|
|
|
func TestTemperatureCollector_ConcurrentCollectTemperature(t *testing.T) {
|
|
successJSON := `{"coretemp-isa-0000":{"Package id 0":{"temp1_input": 55.0}}}`
|
|
var callCounter int32
|
|
stub := &stubTemperatureProxy{
|
|
responseFunc: func(int) stubProxyResponse {
|
|
n := atomic.AddInt32(&callCounter, 1)
|
|
if n%2 == 1 {
|
|
return stubProxyResponse{
|
|
err: &tempproxy.ProxyError{Type: tempproxy.ErrorTypeTransport, Message: "transient transport error"},
|
|
}
|
|
}
|
|
return stubProxyResponse{output: successJSON}
|
|
},
|
|
}
|
|
stub.setAvailable(true)
|
|
|
|
collector := &TemperatureCollector{
|
|
proxyClient: stub,
|
|
useProxy: true,
|
|
}
|
|
|
|
const goroutines = 16
|
|
const iterations = 32
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(goroutines)
|
|
|
|
ctx := context.Background()
|
|
for i := 0; i < goroutines; i++ {
|
|
go func() {
|
|
defer wg.Done()
|
|
for j := 0; j < iterations; j++ {
|
|
temp, err := collector.CollectTemperature(ctx, "https://node.example", "node")
|
|
if err != nil {
|
|
t.Errorf("collect temperature returned error: %v", err)
|
|
return
|
|
}
|
|
if temp == nil {
|
|
t.Errorf("expected non-nil temperature result")
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
if !collector.useProxy {
|
|
t.Fatalf("expected proxy to remain enabled during concurrent collection")
|
|
}
|
|
if collector.proxyFailures >= proxyFailureThreshold {
|
|
t.Fatalf("expected proxy failures to stay below disable threshold, got %d", collector.proxyFailures)
|
|
}
|
|
}
|