From 118e3fc4a0cbc25ecf2aec32b93f7272e789550e Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Wed, 27 May 2026 01:15:08 +0200 Subject: [PATCH] feat(health): add speaker_ca_bundle integrity check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two per-device checks run against each speaker's CA bundle via a single SSH probe round-trip: (1) Every PEM block from ca-bundle.crt.original (the factory backup written by TrustCACertFromBytes on first CA injection) must be present in the live ca-bundle.crt. A missing block means the original trust store was truncated, which would break external HTTPS (Spotify, Amazon, firmware updates). (2) The AfterTouch CA sentinel (# AfterTouch) must be present in the live bundle. Without it the speaker rejects AfterTouch's TLS cert and migration is effectively inactive. Both findings carry a QuickFix: - FixIDRestoreAndInjectCA: cp .original → live bundle over SSH, then TrustCACert to re-inject the AfterTouch CA. - FixIDInjectCACert: TrustCACert only (original certs intact). Graceful degradation: - SSH unavailable → SeverityInfo, no fix offered. - .original absent (device never had install-ca run) → SeverityWarning, suggest install-ca; check (2) still runs. Infrastructure changes: - ssh_probe.go: add ca-bundle.crt.original to probeFilePaths (free in the existing single-round-trip batch). - setup.go: export ProbeCABundles and RestoreCABundleFromOriginal so the handlers package can use them without exposing speakerProbe. - Fix executors live in handlers (need setup.Manager) per the established boundary used by completeSpeakerPairingFix. Co-Authored-By: Claude Sonnet 4.6 --- pkg/service/handlers/handlers_ca_bundle.go | 63 ++++++ pkg/service/handlers/server.go | 17 ++ pkg/service/health/checks_ca_bundle.go | 217 +++++++++++++++++++++ pkg/service/setup/setup.go | 47 +++++ pkg/service/setup/ssh_probe.go | 11 +- 5 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 pkg/service/handlers/handlers_ca_bundle.go create mode 100644 pkg/service/health/checks_ca_bundle.go diff --git a/pkg/service/handlers/handlers_ca_bundle.go b/pkg/service/handlers/handlers_ca_bundle.go new file mode 100644 index 0000000..e3f3cea --- /dev/null +++ b/pkg/service/handlers/handlers_ca_bundle.go @@ -0,0 +1,63 @@ +package handlers + +import ( + "fmt" + + "github.com/gesellix/bose-soundtouch/pkg/service/health" +) + +// restoreAndInjectCAFix is the FixFunc registered for +// (CheckIDSpeakerCABundle, FixIDRestoreAndInjectCA). It handles the case +// where original factory CA certificates have gone missing from the live +// bundle by: +// +// 1. Copying .original back over the live ca-bundle.crt via SSH. +// 2. Re-injecting the AfterTouch CA via TrustCACert so the speaker +// continues to trust AfterTouch's TLS certificate. +func (s *Server) restoreAndInjectCAFix(target health.Target) (string, error) { + if target.Device == "" { + return "", fmt.Errorf("device is required") + } + + deviceIP, err := s.resolveDeviceIDToIP(target.Device) + if err != nil { + return "", fmt.Errorf("locate device %s: %w", target.Device, err) + } + + if err := s.sm.RestoreCABundleFromOriginal(deviceIP); err != nil { + return "", fmt.Errorf("restore CA bundle on device %s: %w", target.Device, err) + } + + if _, err := s.sm.TrustCACert(deviceIP); err != nil { + return "", fmt.Errorf("re-inject AfterTouch CA on device %s after restore: %w", target.Device, err) + } + + return fmt.Sprintf( + "Device %s: original CA bundle restored from factory backup and AfterTouch CA re-injected.", + target.Device, + ), nil +} + +// injectCACertFix is the FixFunc registered for +// (CheckIDSpeakerCABundle, FixIDInjectCACert). It handles the case where +// the AfterTouch CA is absent from the live bundle (e.g. removed manually +// or not yet injected). +func (s *Server) injectCACertFix(target health.Target) (string, error) { + if target.Device == "" { + return "", fmt.Errorf("device is required") + } + + deviceIP, err := s.resolveDeviceIDToIP(target.Device) + if err != nil { + return "", fmt.Errorf("locate device %s: %w", target.Device, err) + } + + if _, err := s.sm.TrustCACert(deviceIP); err != nil { + return "", fmt.Errorf("inject AfterTouch CA on device %s: %w", target.Device, err) + } + + return fmt.Sprintf( + "Device %s: AfterTouch CA certificate installed in speaker's bundle.", + target.Device, + ), nil +} diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index b2205bf..905c687 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -135,6 +135,9 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red health.RegisterRefreshSourcesCheck(s.healthRegistry, ds) health.RegisterStaleInternetRadioCheck(s.healthRegistry, ds) health.RegisterDefaultAccountNonBoseDevicesCheck(s.healthRegistry, ds) + health.RegisterSpeakerCABundleCheck(s.healthRegistry, ds, func(deviceIP string) (string, string, bool) { + return s.sm.ProbeCABundles(deviceIP) + }) health.RegisterServerURLReachableCheck(s.healthRegistry, func() string { serverURL, _ := s.GetSettings() return serverURL @@ -168,6 +171,20 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red health.FixIDAddMargeHostToTLS, s.addMargeHostToTLSFix, ) + + // QuickFix executors for the speaker_ca_bundle integrity check. + // Fix executors live here (not in the health package) because they + // need setup.Manager — the same boundary as completeSpeakerPairingFix. + s.healthRegistry.RegisterFix( + health.CheckIDSpeakerCABundle, + health.FixIDRestoreAndInjectCA, + s.restoreAndInjectCAFix, + ) + s.healthRegistry.RegisterFix( + health.CheckIDSpeakerCABundle, + health.FixIDInjectCACert, + s.injectCACertFix, + ) health.RegisterDNSSanityCheck( s.healthRegistry, s.GetDNSRunning, diff --git a/pkg/service/health/checks_ca_bundle.go b/pkg/service/health/checks_ca_bundle.go new file mode 100644 index 0000000..21936b9 --- /dev/null +++ b/pkg/service/health/checks_ca_bundle.go @@ -0,0 +1,217 @@ +package health + +import ( + "encoding/pem" + "fmt" + "strings" + + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +// CheckIDSpeakerCABundle is the registry ID of the speaker CA bundle +// integrity check. +const CheckIDSpeakerCABundle = "speaker_ca_bundle" + +// FixIDRestoreAndInjectCA is the registry ID of the fix that restores +// the .original factory backup and re-injects the AfterTouch CA. +// Used when check (1) — original certs still present — fails. +const FixIDRestoreAndInjectCA = "restore_and_inject_ca" + +// FixIDInjectCACert is the registry ID of the fix that injects the +// AfterTouch CA into the speaker's bundle. +// Used when check (2) — AfterTouch CA present — fails. +const FixIDInjectCACert = "inject_ca_cert" + +// caLabel is the AfterTouch sentinel written by TrustCACertFromBytes. +// Keep in sync with setup.CALabel in pkg/service/setup/setup.go. +// The health package deliberately avoids importing setup to keep its +// transitive dependency surface small. +const caLabel = "# AfterTouch" + +// RegisterSpeakerCABundleCheck registers a per-device health check that +// verifies the integrity of the CA bundle on each speaker. probeFn must +// return the live bundle content, the .original backup content, and +// whether SSH succeeded. An empty bundle string means the file was +// absent on the device. +func RegisterSpeakerCABundleCheck( + r *Registry, + ds *datastore.DataStore, + probeFn func(deviceIP string) (current, original string, sshOK bool), +) { + r.Register(Check{ + ID: CheckIDSpeakerCABundle, + Title: "Speaker CA bundle integrity", + Run: func() []Finding { + return runSpeakerCABundleCheck(ds, probeFn) + }, + }) +} + +func runSpeakerCABundleCheck( + ds *datastore.DataStore, + probeFn func(deviceIP string) (current, original string, sshOK bool), +) []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 + } + + current, original, sshOK := probeFn(dev.IPAddress) + + target := Target{Account: dev.AccountID, Device: dev.DeviceID} + name := displayName(dev.Name, dev.DeviceID) + + if !sshOK { + findings = append(findings, Finding{ + Severity: SeverityInfo, + Target: target, + Message: fmt.Sprintf("Device %s: SSH unavailable — CA bundle integrity cannot be verified", name), + Details: "SSH access is required to inspect the on-device CA bundle. Enable SSH via USB stick or the remote_services panel, run the check again, then disable it.", + }) + + continue + } + + // Check (1): every PEM block from .original must be present in current. + if original == "" { + findings = append(findings, Finding{ + Severity: SeverityWarning, + Target: target, + Message: fmt.Sprintf("Device %s: original CA bundle backup not found on speaker", name), + Details: "The .original backup is created by `setup install-ca` on first injection. Run `soundtouch-cli setup install-ca` to inject the AfterTouch CA and establish the backup in one step.", + }) + // Fall through to check (2) — we still know whether the AfterTouch CA + // is in the current bundle even without the backup. + } else { + missing := missingPEMBlocks(original, stripCALabel(current)) + if len(missing) > 0 { + findings = append(findings, Finding{ + Severity: SeverityError, + Target: target, + Message: fmt.Sprintf( + "Device %s: %d original CA certificate(s) missing from current bundle", + name, len(missing), + ), + Details: "The live CA bundle has fewer certificates than the factory backup. " + + "External HTTPS connections (Spotify, Amazon Music, firmware updates) may fail. " + + "Use the fix to restore the factory bundle and re-inject the AfterTouch CA.", + QuickFixes: []QuickFix{{ + ID: FixIDRestoreAndInjectCA, + Label: "Restore original bundle and re-inject AfterTouch CA", + Confirm: fmt.Sprintf( + "This will overwrite the live CA bundle on speaker %s with its factory backup, "+ + "then re-inject the AfterTouch CA. SSH access is required.", + name, + ), + }}, + }) + } + } + + // Check (2): AfterTouch CA must be present in the current bundle. + if !strings.Contains(current, caLabel) { + findings = append(findings, Finding{ + Severity: SeverityError, + Target: target, + Message: fmt.Sprintf("Device %s: AfterTouch CA certificate not found in speaker's bundle", name), + Details: "Without the AfterTouch CA, the speaker will reject AfterTouch's TLS certificate " + + "and migration is effectively inactive. Use the fix to install the CA.", + QuickFixes: []QuickFix{{ + ID: FixIDInjectCACert, + Label: "Install AfterTouch CA on speaker", + Confirm: fmt.Sprintf( + "This will inject the AfterTouch CA certificate into the CA bundle on speaker %s. SSH access is required.", + name, + ), + }}, + }) + } + } + + return findings +} + +// stripCALabel removes AfterTouch-labelled blocks from bundle so that +// the containment check compares only the original factory entries. +// This mirrors the sentinel-scan in setup.stripAfterTouchEntries without +// importing that package. +func stripCALabel(bundle string) string { + var out strings.Builder + + inBlock := false + + for _, line := range strings.Split(bundle, "\n") { + if line == caLabel { + inBlock = !inBlock + continue + } + + if !inBlock { + out.WriteString(line) + out.WriteByte('\n') + } + } + + return out.String() +} + +// missingPEMBlocks returns the number of PEM blocks present in want that +// are absent from have. Comparison is by raw DER bytes so header +// differences (e.g. different Proc-Type lines) are ignored. +func missingPEMBlocks(want, have string) [][]byte { + haveSet := pemDERSet(have) + + var missing [][]byte + + rest := []byte(want) + + for { + var block *pem.Block + + block, rest = pem.Decode(rest) + if block == nil { + break + } + + if _, ok := haveSet[string(block.Bytes)]; !ok { + missing = append(missing, block.Bytes) + } + } + + return missing +} + +// pemDERSet decodes all PEM blocks from s and returns a set of their +// DER (raw Bytes) values for O(1) membership tests. +func pemDERSet(s string) map[string]struct{} { + set := make(map[string]struct{}) + rest := []byte(s) + + for { + var block *pem.Block + + block, rest = pem.Decode(rest) + if block == nil { + break + } + + set[string(block.Bytes)] = struct{}{} + } + + return set +} diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index 3a01245..c56e295 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -1198,6 +1198,53 @@ func (m *Manager) EnsureRemoteServices(deviceIP string) (string, error) { return logs, fmt.Errorf("failed to enable remote services in any of the locations: %v", locations) } +// ProbeCABundles returns the live CA bundle and the .original factory backup +// from the speaker at deviceIP via a single SSH round-trip. sshOK is false +// when SSH is unreachable or authentication failed; in that case both bundle +// strings are empty. Either bundle string may be empty if the corresponding +// file does not exist on the device (e.g. .original is absent on speakers +// that have never had the AfterTouch CA injected). +// +// Used by the health check to verify bundle integrity without exposing the +// unexported speakerProbe type. +func (m *Manager) ProbeCABundles(deviceIP string) (current, original string, sshOK bool) { + probe := m.probeSpeakerSSH(deviceIP) + + return probe.Files["/etc/pki/tls/certs/ca-bundle.crt"], + probe.Files["/etc/pki/tls/certs/ca-bundle.crt.original"], + probe.SSHOK +} + +// RestoreCABundleFromOriginal copies the .original factory backup over the +// live ca-bundle.crt on the speaker. It is called by the health-check fix +// executor when check (1) — "original certs are contained in current bundle" +// — fails, before re-injecting the AfterTouch CA via TrustCACert. +// +// The operation requires SSH access and a read-write root filesystem; both +// are attempted via the same remount path as TrustCACertFromBytes. +func (m *Manager) RestoreCABundleFromOriginal(deviceIP string) error { + if m.NewSSH == nil { + return errors.New("RestoreCABundleFromOriginal: no SSH factory configured") + } + + bundlePath := "/etc/pki/tls/certs/ca-bundle.crt" + client := m.NewSSH(deviceIP) + + if _, err := client.Run("(rw || mount -o remount,rw /)"); err != nil { + return fmt.Errorf("remount rw: %w", err) + } + + if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", bundlePath)); err != nil { + return fmt.Errorf("original backup %s.original not found on speaker — run `setup install-ca` first", bundlePath) + } + + if _, err := client.Run(fmt.Sprintf("cp %s.original %s", bundlePath, bundlePath)); err != nil { + return fmt.Errorf("restore from original: %w", err) + } + + return nil +} + // TrustCACert injects the local CA certificate into the device's shared // trust store. The cert is read from disk via Manager.Crypto — used by // the in-process migration flow where the CLI and the certmanager share diff --git a/pkg/service/setup/ssh_probe.go b/pkg/service/setup/ssh_probe.go index bcee08d..d4e71d8 100644 --- a/pkg/service/setup/ssh_probe.go +++ b/pkg/service/setup/ssh_probe.go @@ -35,11 +35,12 @@ type speakerProbe struct { // with the consumers in GetMigrationSummary. var ( probeFilePaths = []string{ - SoundTouchSdkPrivateCfgPath, // current XML config - SoundTouchSdkPrivateCfgPath + ".original", // backup XML config - "/etc/resolv.conf", // DNS resolver - "/etc/hosts", // hostname overrides - "/etc/pki/tls/certs/ca-bundle.crt", // CA trust store + SoundTouchSdkPrivateCfgPath, // current XML config + SoundTouchSdkPrivateCfgPath + ".original", // backup XML config + "/etc/resolv.conf", // DNS resolver + "/etc/hosts", // hostname overrides + "/etc/pki/tls/certs/ca-bundle.crt", // CA trust store + "/etc/pki/tls/certs/ca-bundle.crt.original", // factory backup written by TrustCACertFromBytes } probeExistsPaths = []string{