Files
Tobias GesellchenandClaude Sonnet 4.6 118e3fc4a0 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>
2026-05-27 01:25:56 +02:00

64 lines
2.0 KiB
Go

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
}