feat(health): add speaker_ca_bundle integrity check

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 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-27 01:25:56 +02:00
co-authored by Claude Sonnet 4.6
parent 172e14dc26
commit 118e3fc4a0
5 changed files with 350 additions and 5 deletions
+47
View File
@@ -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
+6 -5
View File
@@ -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{