mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
172e14dc26
commit
118e3fc4a0
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user