diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index ae7c5e4..26c0b7d 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -150,6 +150,28 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red health.RegisterSpeakerCABundleCheck(s.healthRegistry, ds, func(deviceIP string) (string, string, bool) { return s.sm.ProbeCABundles(deviceIP) }) + health.RegisterSpeakerClockCheck(s.healthRegistry, ds, func(ip string) (int64, int64, bool) { + cfg := client.DefaultConfig() + cfg.Host = ip + cfg.Timeout = 5 * time.Second + + c := client.NewClient(cfg) + ct, err := c.GetClockTime() + + if err != nil || ct == nil || ct.GetUTC() == 0 { + return 0, 0, false + } + + return ct.GetUTC(), ct.GetUTCSyncTime(), true + }, func(ip string) error { + cfg := client.DefaultConfig() + cfg.Host = ip + cfg.Timeout = 5 * time.Second + + c := client.NewClient(cfg) + + return c.SetClockTime(models.NewClockTimeRequest(time.Now())) + }) health.RegisterServerURLReachableCheck(s.healthRegistry, func() string { serverURL, _ := s.GetSettings() return serverURL diff --git a/pkg/service/health/checks_speaker_clock.go b/pkg/service/health/checks_speaker_clock.go new file mode 100644 index 0000000..5e40c9b --- /dev/null +++ b/pkg/service/health/checks_speaker_clock.go @@ -0,0 +1,275 @@ +package health + +import ( + "fmt" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +// CheckIDSpeakerClock is the registry ID of the speaker clock skew check. +const CheckIDSpeakerClock = "speaker_clock" + +// clockPlausibilityMin is the lower bound of the plausibility window (year 2000). +// Matches the lower bound used by ClockTimeRequest.Validate in pkg/models/clocktime.go. +const clockPlausibilityMin int64 = 946684800 + +// clockPlausibilityMax is the upper bound of the plausibility window (year 2100). +// Matches the upper bound used by ClockTimeRequest.Validate in pkg/models/clocktime.go. +const clockPlausibilityMax int64 = 4102444800 + +// clockStaleNTPThreshold is how long after the last NTP sync before we flag it +// as stale. +const clockStaleNTPThreshold = time.Hour + +// setClockQuickFix is the QuickFix descriptor for the set_clock action. +// Attached to Warning and Error findings only (Info is harmless sub-5-minute drift). +var setClockQuickFix = QuickFix{ + ID: "set_clock", + Label: "Set clock from AfterTouch", + Confirm: "Sets the speaker's clock to this server's current time. " + + "If the speaker's NTP sync is failing, the clock may drift again or reset on reboot " + + "— restoring time sync is the durable fix.", +} + +// RegisterSpeakerClockCheck registers a per-device health check that +// compares each speaker's UTC epoch to the service's UTC epoch and surfaces +// findings when the skew is large enough to cause TLS certificate failures. +// +// clockFn must return the speaker's clock as epoch seconds (utc), its last +// NTP sync epoch (sync), and ok=false when /clockTime could not be read or +// had no usable UTC time. +// +// setFn sets the clock on the speaker at the given IP. It is called by the +// set_clock QuickFix. Passing nil disables the fix registration. +func RegisterSpeakerClockCheck( + r *Registry, + ds *datastore.DataStore, + clockFn func(deviceIP string) (utc, sync int64, ok bool), + setFn func(deviceIP string) error, +) { + r.Register(Check{ + ID: CheckIDSpeakerClock, + Title: "Speaker clock accuracy", + Run: func() []Finding { + return runSpeakerClockCheck(ds, clockFn, time.Now()) + }, + }) + + if setFn == nil { + return + } + + r.RegisterFix(CheckIDSpeakerClock, "set_clock", func(target Target) (string, error) { + devices, err := ds.ListAllDevices() + if err != nil { + return "", fmt.Errorf("could not enumerate devices: %w", err) + } + + var ( + ip string + devName string + ) + + for i := range devices { + if devices[i].DeviceID == target.Device { + ip = devices[i].IPAddress + devName = displayName(devices[i].Name, devices[i].DeviceID) + + break + } + } + + if ip == "" { + return "", fmt.Errorf("device %q not found or has no IP address", target.Device) + } + + if err := setFn(ip); err != nil { + return "", fmt.Errorf("set clock on %s: %w", devName, err) + } + + now := time.Now().UTC().Format(time.RFC3339) + + return fmt.Sprintf( + "Set clock on %s to %s. "+ + "If NTP is still failing the clock may drift again or reset on reboot "+ + "— restoring time sync is the durable fix.", + devName, now, + ), nil + }) +} + +func runSpeakerClockCheck( + ds *datastore.DataStore, + clockFn func(deviceIP string) (utc, sync int64, ok bool), + now time.Time, +) []Finding { + if ds == nil { + return nil + } + + devices, err := ds.ListAllDevices() + if err != nil { + return []Finding{{ + Severity: SeverityError, + Message: "Could not enumerate devices: " + err.Error(), + }} + } + + var findings []Finding + + for i := range devices { + dev := &devices[i] + if dev.IPAddress == "" { + continue + } + + utc, sync, ok := clockFn(dev.IPAddress) + if !ok { + // Reachability is speaker_info_reachable's job; do not duplicate. + continue + } + + target := Target{Account: dev.AccountID, Device: dev.DeviceID} + name := displayName(dev.Name, dev.DeviceID) + + skew := now.Unix() - utc + + abs := skew + if abs < 0 { + abs = -abs + } + + // Build common details lines for enrichment. + speakerTimeStr := time.Unix(utc, 0).UTC().Format(time.RFC3339) + serviceTimeStr := now.UTC().Format(time.RFC3339) + + var skewSign string + if skew >= 0 { + skewSign = fmt.Sprintf("+%ds", skew) + } else { + skewSign = fmt.Sprintf("%ds", skew) + } + + details := fmt.Sprintf( + "Speaker UTC: %s. Service UTC: %s. Signed skew: %s.", + speakerTimeStr, serviceTimeStr, skewSign, + ) + + // NTP staleness note. + ntpNote := "" + if sync == 0 { + ntpNote = " Speaker has never successfully synced with an NTP server — NTP is likely failing." + } else if now.Unix()-sync > int64(clockStaleNTPThreshold.Seconds()) { + lastSync := time.Unix(sync, 0).UTC().Format(time.RFC3339) + ntpNote = fmt.Sprintf(" Speaker's last NTP sync was %s (more than 1 hour ago) — NTP is likely failing.", lastSync) + } + + if ntpNote != "" { + details += ntpNote + } + + remediation := " Remediation: ensure the speaker can reach an NTP server" + + " (UDP/123 outbound to a reachable time server). AfterTouch can also" + + " push the current time via /clockTime (soundtouch-cli set-clock-time) if needed." + details += remediation + + // Determine tier. The plausibility check is applied regardless of abs + // magnitude: a speaker claiming year 2000 or year 2101 is an Error even + // if the arithmetic skew happens to be small. + outsidePlausibility := utc < clockPlausibilityMin || utc > clockPlausibilityMax + + switch { + case outsidePlausibility || abs >= int64(24*time.Hour/time.Second): + var msg string + if outsidePlausibility { + msg = fmt.Sprintf( + "Device %s: speaker clock is implausible (speaker reads %s, service is %s)."+ + " HTTPS/TLS will fail: certificates appear not-yet-valid or expired,"+ + " so TuneIn/BMX content and any HTTPS source will break until time sync is restored.", + name, speakerTimeStr, serviceTimeStr, + ) + } else { + msg = fmt.Sprintf( + "Device %s: speaker clock is far off (speaker reads %s, service is %s)."+ + " HTTPS/TLS will fail: certificates appear not-yet-valid or expired,"+ + " so TuneIn/BMX content and any HTTPS source will break until time sync is restored.", + name, speakerTimeStr, serviceTimeStr, + ) + } + + findings = append(findings, Finding{ + Severity: SeverityError, + Target: target, + Message: msg, + Details: details, + QuickFixes: []QuickFix{setClockQuickFix}, + }) + + case abs >= int64(5*time.Minute/time.Second): + findings = append(findings, Finding{ + Severity: SeverityWarning, + Target: target, + Message: fmt.Sprintf( + "Device %s: clock is off by %s; may cause intermittent HTTPS/TLS failures.", + name, formatDuration(abs), + ), + Details: details, + QuickFixes: []QuickFix{setClockQuickFix}, + }) + + case abs >= 60: + findings = append(findings, Finding{ + Severity: SeverityInfo, + Target: target, + Message: fmt.Sprintf( + "Device %s: minor clock drift of %s; NTP sync may be loose.", + name, formatDuration(abs), + ), + Details: details, + }) + + default: + // abs < 60s: healthy, no finding. + } + } + + return findings +} + +// formatDuration renders a duration (given as unsigned seconds) as a +// human-readable string (e.g. "10m30s", "2h5m", "33d"). +func formatDuration(secs int64) string { + days := secs / 86400 + rem := secs % 86400 + hours := rem / 3600 + rem %= 3600 + minutes := rem / 60 + seconds := rem % 60 + + if days > 0 { + if hours > 0 { + return fmt.Sprintf("%dd%dh", days, hours) + } + + return fmt.Sprintf("%dd", days) + } + + if hours > 0 { + if minutes > 0 { + return fmt.Sprintf("%dh%dm", hours, minutes) + } + + return fmt.Sprintf("%dh", hours) + } + + if minutes > 0 { + if seconds > 0 { + return fmt.Sprintf("%dm%ds", minutes, seconds) + } + + return fmt.Sprintf("%dm", minutes) + } + + return fmt.Sprintf("%ds", seconds) +} diff --git a/pkg/service/health/checks_speaker_clock_test.go b/pkg/service/health/checks_speaker_clock_test.go new file mode 100644 index 0000000..28b0522 --- /dev/null +++ b/pkg/service/health/checks_speaker_clock_test.go @@ -0,0 +1,398 @@ +package health + +import ( + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +// newSpeakerClockDatastore creates a temporary datastore populated with a +// single device. ip may be "" to test the empty-IP skip behaviour. +func newSpeakerClockDatastore(t *testing.T, accountID, deviceID, ip string) *datastore.DataStore { + t.Helper() + + tempDir, err := os.MkdirTemp("", "speaker-clock-test-*") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + t.Cleanup(func() { os.RemoveAll(tempDir) }) + + ds := datastore.NewDataStore(tempDir) + if err := ds.SaveDeviceInfo(accountID, deviceID, &models.ServiceDeviceInfo{ + DeviceID: deviceID, + AccountID: accountID, + Name: "Speaker-" + deviceID, + IPAddress: ip, + }); err != nil { + t.Fatalf("SaveDeviceInfo: %v", err) + } + + return ds +} + +func TestSpeakerClock_InSync(t *testing.T) { + now := time.Unix(1748908800, 0) // fixed reference: 2025-06-03 00:00:00 UTC + ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10") + + clockFn := func(string) (int64, int64, bool) { + return now.Unix() - 5, now.Unix() - 30, true // 5s skew, recent NTP sync + } + + got := runSpeakerClockCheck(ds, clockFn, now) + if len(got) != 0 { + t.Errorf("expected no findings for in-sync clock, got %+v", got) + } +} + +func TestSpeakerClock_InfoTier_90sSkew(t *testing.T) { + now := time.Unix(1748908800, 0) + ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10") + + clockFn := func(string) (int64, int64, bool) { + return now.Unix() - 90, now.Unix() - 30, true + } + + got := runSpeakerClockCheck(ds, clockFn, now) + if len(got) != 1 { + t.Fatalf("expected one finding for 90s skew, got %+v", got) + } + if got[0].Severity != SeverityInfo { + t.Errorf("expected SeverityInfo, got %s", got[0].Severity) + } +} + +func TestSpeakerClock_WarningTier_10mSkew(t *testing.T) { + now := time.Unix(1748908800, 0) + ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10") + + clockFn := func(string) (int64, int64, bool) { + return now.Unix() - 600, now.Unix() - 30, true // 10 minutes + } + + got := runSpeakerClockCheck(ds, clockFn, now) + if len(got) != 1 { + t.Fatalf("expected one finding for 10m skew, got %+v", got) + } + if got[0].Severity != SeverityWarning { + t.Errorf("expected SeverityWarning, got %s", got[0].Severity) + } + if !strings.Contains(got[0].Message, "10m") { + t.Errorf("expected duration in message, got %q", got[0].Message) + } +} + +func TestSpeakerClock_ErrorTier_33daysSkew(t *testing.T) { + // The #345 case: speaker clock ~33 days behind service time. + now := time.Unix(1748908800, 0) + skewSecs := int64(33 * 24 * 60 * 60) + speakerUTC := now.Unix() - skewSecs + + ds := newSpeakerClockDatastore(t, "1000001", "606405FE97AE", "192.0.2.20") + + clockFn := func(string) (int64, int64, bool) { + return speakerUTC, 0, true // sync==0 means never NTP-synced + } + + got := runSpeakerClockCheck(ds, clockFn, now) + if len(got) != 1 { + t.Fatalf("expected one finding for 33d skew, got %+v", got) + } + if got[0].Severity != SeverityError { + t.Errorf("expected SeverityError, got %s", got[0].Severity) + } + + // Message must name both the speaker time and the service time. + speakerTimeStr := time.Unix(speakerUTC, 0).UTC().Format(time.RFC3339) + serviceTimeStr := now.UTC().Format(time.RFC3339) + + if !strings.Contains(got[0].Message, speakerTimeStr) { + t.Errorf("expected speaker time %q in message, got %q", speakerTimeStr, got[0].Message) + } + if !strings.Contains(got[0].Message, serviceTimeStr) { + t.Errorf("expected service time %q in message, got %q", serviceTimeStr, got[0].Message) + } + if !strings.Contains(strings.ToLower(got[0].Message), "tls") { + t.Errorf("expected TLS mention in message, got %q", got[0].Message) + } +} + +func TestSpeakerClock_ErrorTier_PlausibilityWindow(t *testing.T) { + // Speaker epoch is just inside year 2000 boundary (but within a minute of it), + // which is outside the plausibility window. + now := time.Unix(1748908800, 0) + speakerUTC := int64(946684800 + 60) // year 2000 + 60s: inside window but barely + + ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10") + + clockFn := func(string) (int64, int64, bool) { + return speakerUTC, 0, true + } + + got := runSpeakerClockCheck(ds, clockFn, now) + if len(got) != 1 { + t.Fatalf("expected one error finding for year-2000 epoch, got %+v", got) + } + if got[0].Severity != SeverityError { + t.Errorf("expected SeverityError (>24h skew), got %s", got[0].Severity) + } +} + +func TestSpeakerClock_ErrorTier_BeforePlausibilityMin(t *testing.T) { + // Epoch before year 2000 (e.g. Unix 0 = 1970). + now := time.Unix(1748908800, 0) + + ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10") + + clockFn := func(string) (int64, int64, bool) { + return 0, 0, true // epoch 0 = 1970, outside plausibility window + } + + got := runSpeakerClockCheck(ds, clockFn, now) + if len(got) != 1 { + t.Fatalf("expected one error finding for epoch 0, got %+v", got) + } + if got[0].Severity != SeverityError { + t.Errorf("expected SeverityError for implausible epoch, got %s", got[0].Severity) + } + if !strings.Contains(got[0].Message, "implausible") { + t.Errorf("expected 'implausible' in message, got %q", got[0].Message) + } +} + +func TestSpeakerClock_OkFalse_Skipped(t *testing.T) { + now := time.Unix(1748908800, 0) + ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10") + + clockFn := func(string) (int64, int64, bool) { + return 0, 0, false // unreachable + } + + got := runSpeakerClockCheck(ds, clockFn, now) + if len(got) != 0 { + t.Errorf("expected no findings when clockFn returns ok=false, got %+v", got) + } +} + +func TestSpeakerClock_EmptyIP_Skipped(t *testing.T) { + now := time.Unix(1748908800, 0) + ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "") // empty IP + + called := false + clockFn := func(string) (int64, int64, bool) { + called = true + return now.Unix(), now.Unix() - 30, true + } + + got := runSpeakerClockCheck(ds, clockFn, now) + if len(got) != 0 { + t.Errorf("expected no findings for device with empty IP, got %+v", got) + } + if called { + t.Error("clockFn should not be called for a device with empty IP") + } +} + +func TestSpeakerClock_StaleNTPSync_DetailsNote(t *testing.T) { + now := time.Unix(1748908800, 0) + // Skew of 90s (Info tier) but NTP sync was 2 hours ago. + lastSync := now.Unix() - int64(2*time.Hour/time.Second) + + ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10") + + clockFn := func(string) (int64, int64, bool) { + return now.Unix() - 90, lastSync, true + } + + got := runSpeakerClockCheck(ds, clockFn, now) + if len(got) != 1 { + t.Fatalf("expected one finding, got %+v", got) + } + if !strings.Contains(got[0].Details, "NTP is likely failing") { + t.Errorf("expected NTP staleness note in Details, got %q", got[0].Details) + } +} + +func TestSpeakerClock_ZeroSync_DetailsNote(t *testing.T) { + now := time.Unix(1748908800, 0) + // Skew of 90s (Info tier) with zero sync (never synced). + ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10") + + clockFn := func(string) (int64, int64, bool) { + return now.Unix() - 90, 0, true // sync == 0 + } + + got := runSpeakerClockCheck(ds, clockFn, now) + if len(got) != 1 { + t.Fatalf("expected one finding, got %+v", got) + } + if !strings.Contains(got[0].Details, "never") { + t.Errorf("expected 'never' in NTP staleness note in Details, got %q", got[0].Details) + } + if !strings.Contains(got[0].Details, "NTP is likely failing") { + t.Errorf("expected 'NTP is likely failing' in Details, got %q", got[0].Details) + } +} + +// --- QuickFix descriptor tests --- + +func TestSpeakerClock_ErrorFinding_CarriesSetClockQuickFix(t *testing.T) { + now := time.Unix(1748908800, 0) + skewSecs := int64(33 * 24 * 60 * 60) + + ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10") + + clockFn := func(string) (int64, int64, bool) { + return now.Unix() - skewSecs, 0, true + } + + got := runSpeakerClockCheck(ds, clockFn, now) + if len(got) != 1 { + t.Fatalf("expected one finding, got %+v", got) + } + if got[0].Severity != SeverityError { + t.Fatalf("expected SeverityError, got %s", got[0].Severity) + } + + hasSetClock := false + for _, qf := range got[0].QuickFixes { + if qf.ID == "set_clock" { + hasSetClock = true + } + } + if !hasSetClock { + t.Errorf("expected set_clock QuickFix on Error finding, got %+v", got[0].QuickFixes) + } +} + +func TestSpeakerClock_WarningFinding_CarriesSetClockQuickFix(t *testing.T) { + now := time.Unix(1748908800, 0) + + ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10") + + clockFn := func(string) (int64, int64, bool) { + return now.Unix() - 600, now.Unix() - 30, true // 10 minutes skew + } + + got := runSpeakerClockCheck(ds, clockFn, now) + if len(got) != 1 { + t.Fatalf("expected one finding, got %+v", got) + } + if got[0].Severity != SeverityWarning { + t.Fatalf("expected SeverityWarning, got %s", got[0].Severity) + } + + hasSetClock := false + for _, qf := range got[0].QuickFixes { + if qf.ID == "set_clock" { + hasSetClock = true + } + } + if !hasSetClock { + t.Errorf("expected set_clock QuickFix on Warning finding, got %+v", got[0].QuickFixes) + } +} + +func TestSpeakerClock_InfoFinding_NoSetClockQuickFix(t *testing.T) { + now := time.Unix(1748908800, 0) + + ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10") + + clockFn := func(string) (int64, int64, bool) { + return now.Unix() - 90, now.Unix() - 30, true // 90s skew = Info tier + } + + got := runSpeakerClockCheck(ds, clockFn, now) + if len(got) != 1 { + t.Fatalf("expected one finding, got %+v", got) + } + if got[0].Severity != SeverityInfo { + t.Fatalf("expected SeverityInfo, got %s", got[0].Severity) + } + + for _, qf := range got[0].QuickFixes { + if qf.ID == "set_clock" { + t.Errorf("Info finding must not carry set_clock QuickFix, but got one") + } + } +} + +// --- RunFix dispatch tests --- + +// newSpeakerClockRegistry creates a Registry with RegisterSpeakerClockCheck +// wired up using the provided setFn stub. +func newSpeakerClockRegistry(t *testing.T, ds *datastore.DataStore, setFn func(string) error) *Registry { + t.Helper() + + r := NewRegistry() + clockFn := func(string) (int64, int64, bool) { + return time.Now().Unix(), time.Now().Unix() - 30, true + } + RegisterSpeakerClockCheck(r, ds, clockFn, setFn) + + return r +} + +func TestSpeakerClock_RunFix_Success(t *testing.T) { + ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10") + + var calledIP string + setFn := func(ip string) error { + calledIP = ip + return nil + } + + r := newSpeakerClockRegistry(t, ds, setFn) + + msg, _, err := r.RunFix(CheckIDSpeakerClock, "set_clock", Target{Device: "DEVICE01"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if calledIP != "192.0.2.10" { + t.Errorf("setFn called with IP %q, want 192.0.2.10", calledIP) + } + if !strings.Contains(msg, "Set clock on") { + t.Errorf("expected 'Set clock on' in message, got %q", msg) + } + if !strings.Contains(msg, "restoring time sync is the durable fix") { + t.Errorf("expected NTP band-aid note in message, got %q", msg) + } +} + +func TestSpeakerClock_RunFix_SetFnError_Propagates(t *testing.T) { + ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10") + + setFn := func(string) error { + return errors.New("connection refused") + } + + r := newSpeakerClockRegistry(t, ds, setFn) + + _, _, err := r.RunFix(CheckIDSpeakerClock, "set_clock", Target{Device: "DEVICE01"}) + if err == nil { + t.Fatal("expected error from failing setFn, got nil") + } + if !strings.Contains(err.Error(), "connection refused") { + t.Errorf("expected original error in message, got %q", err.Error()) + } +} + +func TestSpeakerClock_RunFix_UnknownDevice_ReturnsError(t *testing.T) { + ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10") + + setFn := func(string) error { return nil } + + r := newSpeakerClockRegistry(t, ds, setFn) + + _, _, err := r.RunFix(CheckIDSpeakerClock, "set_clock", Target{Device: "NOSUCHDEVICE"}) + if err == nil { + t.Fatal("expected error for unknown device, got nil") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("expected 'not found' in error, got %q", err.Error()) + } +}