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
@@ -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
}
+17
View File
@@ -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,
+217
View File
@@ -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
}
+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{