mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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>
173 lines
5.0 KiB
Go
173 lines
5.0 KiB
Go
package setup
|
||
|
||
import (
|
||
"bufio"
|
||
"encoding/base64"
|
||
"strings"
|
||
)
|
||
|
||
// speakerProbe is the result of a single batched SSH round-trip that
|
||
// gathers everything GetMigrationSummary needs in one go. Without it,
|
||
// the summary makes ~8 sequential SSH dials; pkg/ssh opens a fresh
|
||
// TCP+SSH handshake on every Run(), and SoundTouch firmware accepts
|
||
// only legacy crypto so each handshake is ~500 ms–1 s. Batching collapses
|
||
// that to one handshake.
|
||
type speakerProbe struct {
|
||
// SSHOK reports whether the batched probe completed successfully.
|
||
// false implies SSH is unreachable, auth failed, or the script
|
||
// errored — in all cases GetMigrationSummary falls back to its
|
||
// non-SSH paths (telnet preflight, HTTPS probe, etc).
|
||
SSHOK bool
|
||
|
||
// Files maps absolute device paths to their decoded contents.
|
||
// Missing keys mean the file did not exist or could not be read.
|
||
Files map[string]string
|
||
|
||
// Exists is the set of probe paths that exist on the device (for
|
||
// directories or non-text files we only need a yes/no signal).
|
||
Exists map[string]bool
|
||
|
||
// Err carries the underlying SSH error if SSHOK is false.
|
||
Err error
|
||
}
|
||
|
||
// Probe paths used by the batched script. Keep this list in lockstep
|
||
// 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
|
||
"/etc/pki/tls/certs/ca-bundle.crt.original", // factory backup written by TrustCACertFromBytes
|
||
}
|
||
|
||
probeExistsPaths = []string{
|
||
"/etc/remote_services", // SSH-enablement marker (persistent)
|
||
"/mnt/nv/remote_services", // SSH-enablement marker (persistent, NV)
|
||
"/tmp/remote_services", // SSH-enablement marker (volatile)
|
||
"/mnt/nv/aftertouch.resolv.conf",
|
||
}
|
||
)
|
||
|
||
// probeSpeakerSSH runs one shell script over a single SSH connection
|
||
// and parses the result into a speakerProbe. The script emits framed
|
||
// blocks per file (base64-encoded so newlines/binary don't break the
|
||
// parser) and EXISTS lines per probe path.
|
||
func (m *Manager) probeSpeakerSSH(deviceIP string) *speakerProbe {
|
||
probe := &speakerProbe{
|
||
Files: make(map[string]string),
|
||
Exists: make(map[string]bool),
|
||
}
|
||
|
||
if m.NewSSH == nil {
|
||
return probe
|
||
}
|
||
|
||
script := buildSpeakerProbeScript(probeFilePaths, probeExistsPaths)
|
||
|
||
client := m.NewSSH(deviceIP)
|
||
|
||
output, err := client.Run(script)
|
||
if err != nil {
|
||
probe.Err = err
|
||
return probe
|
||
}
|
||
|
||
parseSpeakerProbe(probe, output)
|
||
|
||
return probe
|
||
}
|
||
|
||
// buildSpeakerProbeScript composes the POSIX-sh script that does all the
|
||
// probes in one execution. Kept separate so tests can verify the script
|
||
// shape without having to mock an SSH transport.
|
||
func buildSpeakerProbeScript(filePaths, existsPaths []string) string {
|
||
var b strings.Builder
|
||
|
||
b.WriteString("echo '@SSH_OK@'\n")
|
||
|
||
for _, p := range filePaths {
|
||
b.WriteString("if [ -f '")
|
||
b.WriteString(p)
|
||
b.WriteString("' ]; then\n")
|
||
b.WriteString(" echo '@FILE@")
|
||
b.WriteString(p)
|
||
b.WriteString("@'\n")
|
||
b.WriteString(" base64 < '")
|
||
b.WriteString(p)
|
||
b.WriteString("' 2>/dev/null | tr -d '\\n'\n")
|
||
b.WriteString(" echo\n")
|
||
b.WriteString(" echo '@END@'\n")
|
||
b.WriteString("fi\n")
|
||
}
|
||
|
||
for _, p := range existsPaths {
|
||
b.WriteString("if [ -e '")
|
||
b.WriteString(p)
|
||
b.WriteString("' ]; then echo '@EXISTS@")
|
||
b.WriteString(p)
|
||
b.WriteString("@'; fi\n")
|
||
}
|
||
|
||
return b.String()
|
||
}
|
||
|
||
// parseSpeakerProbe parses the script's stdout into the probe struct.
|
||
// The format is line-oriented:
|
||
//
|
||
// @SSH_OK@ — sentinel: script ran to completion
|
||
// @FILE@<path>@ — start-of-file marker
|
||
// <base64 contents> — exactly one line of base64 (no newlines)
|
||
// @END@ — end-of-file marker
|
||
// @EXISTS@<path>@ — path-exists assertion
|
||
//
|
||
// We tolerate any other lines as stray output and skip them.
|
||
func parseSpeakerProbe(probe *speakerProbe, output string) {
|
||
scanner := bufio.NewScanner(strings.NewReader(output))
|
||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||
|
||
var (
|
||
inFile bool
|
||
currentPath string
|
||
b64 strings.Builder
|
||
)
|
||
|
||
for scanner.Scan() {
|
||
line := scanner.Text()
|
||
|
||
switch {
|
||
case line == "@SSH_OK@":
|
||
probe.SSHOK = true
|
||
|
||
case strings.HasPrefix(line, "@FILE@") && strings.HasSuffix(line, "@"):
|
||
currentPath = strings.TrimSuffix(strings.TrimPrefix(line, "@FILE@"), "@")
|
||
inFile = true
|
||
|
||
b64.Reset()
|
||
|
||
case line == "@END@":
|
||
if inFile && currentPath != "" {
|
||
if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64.String())); err == nil {
|
||
probe.Files[currentPath] = string(decoded)
|
||
}
|
||
}
|
||
|
||
inFile = false
|
||
currentPath = ""
|
||
|
||
b64.Reset()
|
||
|
||
case strings.HasPrefix(line, "@EXISTS@") && strings.HasSuffix(line, "@"):
|
||
path := strings.TrimSuffix(strings.TrimPrefix(line, "@EXISTS@"), "@")
|
||
probe.Exists[path] = true
|
||
|
||
default:
|
||
if inFile {
|
||
b64.WriteString(line)
|
||
}
|
||
}
|
||
}
|
||
}
|