Compare commits
@@ -53,7 +53,7 @@ jobs:
|
||||
run: make test-http-client
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
file: ./coverage.out
|
||||
flags: unittests
|
||||
@@ -306,7 +306,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Determine push eligibility
|
||||
id: push-check
|
||||
@@ -324,7 +324,7 @@ jobs:
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: steps.push-check.outputs.should-push == 'true'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -342,7 +342,7 @@ jobs:
|
||||
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
@@ -365,7 +365,7 @@ jobs:
|
||||
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
|
||||
@@ -524,10 +524,10 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -544,7 +544,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
@@ -566,7 +566,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
|
||||
- name: Upload Semgrep SARIF results
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
sarif_file: semgrep.sarif
|
||||
continue-on-error: true
|
||||
@@ -116,16 +116,16 @@ jobs:
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
languages: go
|
||||
config-file: ./.github/codeql-config.yml
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
category: "/language:go"
|
||||
|
||||
|
||||
@@ -114,3 +114,11 @@ stockholm_zip/*.zip
|
||||
# resolved items (DONE). Both are session-local scratch, not project docs.
|
||||
NEXT.md
|
||||
DONE.md
|
||||
|
||||
# Plan/tracking note for the Health-tab debug-utility programme.
|
||||
# Living document; commit history of the checks themselves is the
|
||||
# source of truth for what shipped.
|
||||
SERVICE-HEALTH.md
|
||||
|
||||
# Diagnostic encryption keys — private key stays local with the maintainer
|
||||
keys/private/
|
||||
|
||||
@@ -15,6 +15,11 @@ import (
|
||||
func discoverDevices(c *cli.Context) error {
|
||||
fmt.Printf("Discovering SoundTouch devices...\n")
|
||||
|
||||
// CLI discovery is interactive — flip on verbose protocol logging
|
||||
// so operators can see per-packet / per-header detail. The service
|
||||
// binary leaves this off so its log stays terse.
|
||||
discovery.SetVerbose(c.Bool("verbose"))
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
|
||||
@@ -48,6 +48,7 @@ func setupCommand() *cli.Command {
|
||||
setupWaitAPCmd(),
|
||||
setupWaitOnlineCmd(),
|
||||
setupSSHCheckCmd(),
|
||||
setupRemoteServicesCmd(),
|
||||
setupInstallCACmd(),
|
||||
setupMigrateCmd(),
|
||||
setupRebootCmd(),
|
||||
@@ -536,6 +537,51 @@ func setupSSHCheckCmd() *cli.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func setupRemoteServicesCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "remote-services",
|
||||
Usage: "Enable (default) or disable the remote_services SSH-enablement marker on the speaker",
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "remove",
|
||||
Usage: "Remove all remote_services marker files (disables SSH after next reboot)",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
cfg := GetClientConfig(c)
|
||||
m := setup.NewManager("", nil, nil)
|
||||
|
||||
var (
|
||||
logs string
|
||||
err error
|
||||
)
|
||||
if c.Bool("remove") {
|
||||
logs, err = m.RemoveRemoteServices(cfg.Host)
|
||||
} else {
|
||||
logs, err = m.EnsureRemoteServices(cfg.Host)
|
||||
}
|
||||
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Bool("remove") {
|
||||
PrintSuccess("remote_services removed — SSH will no longer be enabled after next reboot")
|
||||
} else {
|
||||
PrintSuccess("remote_services enabled at a persistent location")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func setupInstallCACmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "install-ca",
|
||||
@@ -549,6 +595,11 @@ func setupInstallCACmd() *cli.Command {
|
||||
cfg := GetClientConfig(c)
|
||||
serviceURL := strings.TrimRight(c.String("service-url"), "/")
|
||||
|
||||
if err := validateServiceURL(serviceURL); err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
certPEM, err := fetchCACert(serviceURL, c.String("auth"))
|
||||
if err != nil {
|
||||
PrintError(err.Error())
|
||||
@@ -698,11 +749,17 @@ func setupMigrateCmd() *cli.Command {
|
||||
method := setup.MigrationMethod(c.String("method"))
|
||||
serviceURL := c.String("service-url")
|
||||
|
||||
// For DNS-redirect methods, prove AfterTouch's DNS listener
|
||||
// is alive by sending it a real query — that's the truth,
|
||||
// regardless of what its settings claim.
|
||||
if err := validateServiceURL(serviceURL); err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
m := setup.NewManager(serviceURL, nil, nil)
|
||||
|
||||
// For DNS-redirect methods check that AfterTouch's DNS listener
|
||||
// is reachable — both from this machine and from the speaker.
|
||||
if !c.Bool("skip-preflight") && (method == setup.MigrationMethodResolvConf || method == setup.MigrationMethodHosts) {
|
||||
if err := requireAfterTouchDNSReachable(serviceURL); err != nil {
|
||||
if err := runDNSPreflight(cfg.Host, serviceURL, m.NewSSH); err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
@@ -720,8 +777,6 @@ func setupMigrateCmd() *cli.Command {
|
||||
|
||||
fmt.Printf("Migrating %s → %s using method=%s\n", cfg.Host, serviceURL, method)
|
||||
|
||||
m := setup.NewManager(serviceURL, nil, nil)
|
||||
|
||||
logs, err := m.MigrateSpeaker(cfg.Host, serviceURL, c.String("proxy-url"), nil, method)
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
@@ -762,31 +817,53 @@ func preInstallCAForCLI(deviceIP, serviceURL string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireAfterTouchDNSReachable sends a real DNS query to AfterTouch's
|
||||
// port-53 listener and confirms it responds. This is the ground-truth
|
||||
// preflight for DNS-redirect migration methods — config inspection (the
|
||||
// previous approach via GET /setup/settings) can lag the actual listener
|
||||
// state and can't tell us whether queries succeed end-to-end.
|
||||
//
|
||||
// We query a known-intercepted hostname (streaming.bose.com). Any IP in
|
||||
// the response proves AfterTouch's DNS is alive on :53; if the listener
|
||||
// is down the custom Dial just times out and the user gets a clear error.
|
||||
func requireAfterTouchDNSReachable(serviceURL string) error {
|
||||
// validateServiceURL returns an error if serviceURL cannot be parsed or has no
|
||||
// hostname. A common mistake is a single-slash scheme (https:/host instead of
|
||||
// https://host); the error message hints at the correction in that case.
|
||||
func validateServiceURL(serviceURL string) error {
|
||||
parsed, err := url.Parse(serviceURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("preflight: parse service URL %q: %w", serviceURL, err)
|
||||
return fmt.Errorf("invalid --service-url %q: %w", serviceURL, err)
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("preflight: service URL %q has no hostname", serviceURL)
|
||||
if parsed.Hostname() == "" {
|
||||
hint := ""
|
||||
if parsed.Scheme != "" && parsed.Opaque != "" {
|
||||
hint = fmt.Sprintf(" (did you mean %s://%s?)", parsed.Scheme, strings.TrimPrefix(parsed.Opaque, "/"))
|
||||
}
|
||||
|
||||
return fmt.Errorf("invalid --service-url %q: no hostname found%s", serviceURL, hint)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// dnsCheckResult holds the outcome of one DNS reachability probe.
|
||||
type dnsCheckResult struct {
|
||||
ok bool
|
||||
unknown bool // SSH unavailable or nslookup not present — result indeterminate
|
||||
detail string // "works" on success, error reason otherwise
|
||||
}
|
||||
|
||||
func (r dnsCheckResult) label() string {
|
||||
switch {
|
||||
case r.ok:
|
||||
return "✓ works"
|
||||
case r.unknown:
|
||||
return "? " + r.detail
|
||||
default:
|
||||
return "✗ " + r.detail
|
||||
}
|
||||
}
|
||||
|
||||
// cliDNSCheck sends a real DNS query for streaming.bose.com through the
|
||||
// AfterTouch DNS listener to verify it is alive from this machine.
|
||||
func cliDNSCheck(dnsHost string) dnsCheckResult {
|
||||
resolver := &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
d := net.Dialer{Timeout: 3 * time.Second}
|
||||
return d.DialContext(ctx, "udp", net.JoinHostPort(host, "53"))
|
||||
return d.DialContext(ctx, "udp", net.JoinHostPort(dnsHost, "53"))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -795,14 +872,91 @@ func requireAfterTouchDNSReachable(serviceURL string) error {
|
||||
|
||||
ips, err := resolver.LookupHost(ctx, "streaming.bose.com")
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"preflight: DNS query to %s:53 failed: %w. AfterTouch's DNS listener is unreachable or not bound to port 53. Use --skip-preflight to bypass once you've verified DNS some other way",
|
||||
host, err,
|
||||
)
|
||||
return dnsCheckResult{detail: err.Error()}
|
||||
}
|
||||
|
||||
if len(ips) == 0 {
|
||||
return fmt.Errorf("preflight: %s:53 returned no answers for streaming.bose.com — listener may be misconfigured", host)
|
||||
return dnsCheckResult{detail: "no answers for streaming.bose.com — listener may be misconfigured"}
|
||||
}
|
||||
|
||||
return dnsCheckResult{ok: true, detail: "works"}
|
||||
}
|
||||
|
||||
// speakerDNSCheck SSHes into the speaker and runs nslookup streaming.bose.com
|
||||
// against the AfterTouch DNS server to verify reachability from the device.
|
||||
func speakerDNSCheck(deviceIP, dnsHost string, newSSH func(string) setup.SSHClient) dnsCheckResult {
|
||||
addrs, err := net.LookupHost(dnsHost)
|
||||
if err != nil || len(addrs) == 0 {
|
||||
return dnsCheckResult{unknown: true, detail: fmt.Sprintf("cannot resolve %s locally to run speaker-side check", dnsHost)}
|
||||
}
|
||||
|
||||
dnsIP := addrs[0]
|
||||
|
||||
client := newSSH(deviceIP)
|
||||
|
||||
out, sshErr := client.Run(fmt.Sprintf("nslookup streaming.bose.com %s", dnsIP))
|
||||
if sshErr != nil {
|
||||
if strings.Contains(out, "not found") || strings.Contains(out, "No such file") {
|
||||
return dnsCheckResult{unknown: true, detail: "nslookup not available on speaker"}
|
||||
}
|
||||
|
||||
if strings.Contains(sshErr.Error(), "dial") || strings.Contains(sshErr.Error(), "connect") {
|
||||
return dnsCheckResult{unknown: true, detail: fmt.Sprintf("SSH unavailable: %s", sshErr)}
|
||||
}
|
||||
|
||||
msg := strings.TrimSpace(out)
|
||||
if msg == "" {
|
||||
msg = sshErr.Error()
|
||||
}
|
||||
|
||||
return dnsCheckResult{detail: msg}
|
||||
}
|
||||
|
||||
return dnsCheckResult{ok: true, detail: "works"}
|
||||
}
|
||||
|
||||
// runDNSPreflight checks AfterTouch DNS reachability from both the CLI machine
|
||||
// and the speaker, prints a table, and returns an error only when the speaker
|
||||
// side definitively cannot reach the DNS listener (CLI-only failures are
|
||||
// informational — the speaker's perspective is authoritative).
|
||||
func runDNSPreflight(deviceIP, serviceURL string, newSSH func(string) setup.SSHClient) error {
|
||||
parsed, _ := url.Parse(serviceURL)
|
||||
dnsHost := parsed.Hostname()
|
||||
|
||||
type result struct {
|
||||
cli dnsCheckResult
|
||||
speaker dnsCheckResult
|
||||
}
|
||||
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
cliCh := make(chan dnsCheckResult, 1)
|
||||
speakerCh := make(chan dnsCheckResult, 1)
|
||||
|
||||
go func() { cliCh <- cliDNSCheck(dnsHost) }()
|
||||
go func() { speakerCh <- speakerDNSCheck(deviceIP, dnsHost, newSSH) }()
|
||||
|
||||
ch <- result{cli: <-cliCh, speaker: <-speakerCh}
|
||||
}()
|
||||
|
||||
r := <-ch
|
||||
|
||||
if r.cli.ok && r.speaker.ok {
|
||||
fmt.Printf("DNS preflight (%s:53) ✓ works\n", dnsHost)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("DNS preflight (%s:53)\n", dnsHost)
|
||||
fmt.Printf(" CLI host %s\n", r.cli.label())
|
||||
fmt.Printf(" Speaker %s\n", r.speaker.label())
|
||||
fmt.Println()
|
||||
|
||||
if !r.speaker.ok && !r.speaker.unknown {
|
||||
return fmt.Errorf("AfterTouch DNS unreachable from speaker — %s migration would fail", serviceURL)
|
||||
}
|
||||
|
||||
if r.speaker.unknown && !r.cli.ok {
|
||||
return fmt.Errorf("cannot confirm DNS reachability (SSH unavailable from speaker, CLI probe also failed) — use --skip-preflight to bypass")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -822,6 +976,11 @@ func setupVerifyCmd() *cli.Command {
|
||||
cfg := GetClientConfig(c)
|
||||
serviceURL := c.String("service-url")
|
||||
|
||||
if err := validateServiceURL(serviceURL); err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
m := setup.NewManager(serviceURL, nil, nil)
|
||||
|
||||
summary, err := m.GetMigrationSummary(cfg.Host, serviceURL, c.String("proxy-url"), nil)
|
||||
@@ -1013,6 +1172,11 @@ func setupPlanCmd() *cli.Command {
|
||||
wifiSSID := c.String("wifi-ssid")
|
||||
includePair := c.Bool("include-pair")
|
||||
|
||||
if err := validateServiceURL(serviceURL); err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
m := setup.NewManager(serviceURL, nil, nil)
|
||||
|
||||
fmt.Printf("Probing %s …\n\n", cfg.Host)
|
||||
@@ -1036,7 +1200,7 @@ func setupPlanCmd() *cli.Command {
|
||||
renderPreResetNote()
|
||||
}
|
||||
|
||||
renderPlanSteps(steps)
|
||||
renderPlanSteps(steps, includePair)
|
||||
|
||||
return nil
|
||||
},
|
||||
@@ -1093,6 +1257,10 @@ func renderPlanState(deviceIP string, inspect *setup.InspectReport, summary *set
|
||||
check(summary.IsPaired), check(summary.IsMigrated),
|
||||
yesNo(summary.TelnetMigrated), yesNo(summary.XMLMigrated),
|
||||
yesNo(summary.HostsMigrated), yesNo(summary.ResolvMigrated))
|
||||
|
||||
if summary.RemoteServicesEnabled && !summary.RemoteServicesPersistent {
|
||||
fmt.Println(" [⚠] remote_services enabled but not persistent (will be lost on reboot)")
|
||||
}
|
||||
}
|
||||
|
||||
func firmwareOf(info *setup.DeviceInfoXML) string {
|
||||
@@ -1135,7 +1303,19 @@ func buildPlanSteps(
|
||||
host = "<NEW_IP>" // subsequent commands target the discovered IP
|
||||
}
|
||||
|
||||
if !reset && summary != nil && summary.IsMigrated && (!includePair || summary.IsPaired) {
|
||||
// Persist remote_services before anything else when it's only in /tmp.
|
||||
// SSH is reachable now, but the marker would be lost on the next reboot —
|
||||
// which could happen mid-migration if power is cut or the reboot step runs
|
||||
// before persistence is confirmed.
|
||||
if !reset && summary != nil && summary.RemoteServicesEnabled && !summary.RemoteServicesPersistent {
|
||||
steps = append(steps, planStep{
|
||||
title: "Persist remote_services so SSH survives a reboot",
|
||||
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup remote-services", host),
|
||||
reason: "Marker is currently in /tmp only — lost on next reboot, which would break SSH mid-migration.",
|
||||
})
|
||||
}
|
||||
|
||||
if !reset && summary != nil && summary.IsMigrated && (!includePair || summary.IsPaired) && len(steps) == 0 {
|
||||
return steps
|
||||
}
|
||||
|
||||
@@ -1146,7 +1326,7 @@ func buildPlanSteps(
|
||||
if includePair && (reset || (summary != nil && !summary.IsPaired)) {
|
||||
steps = append(steps, planStep{
|
||||
title: "Pair the device with an AfterTouch account",
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup pair --host=%s --service-url=%s", host, serviceURL),
|
||||
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup pair --service-url=%s", host, serviceURL),
|
||||
reason: "Required for preset persistence, streaming services, multi-room zones.",
|
||||
})
|
||||
}
|
||||
@@ -1178,7 +1358,7 @@ func resetSteps(host, wifiSSID string, inspect *setup.InspectReport) []planStep
|
||||
return []planStep{
|
||||
{
|
||||
title: "Factory-reset the speaker",
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup factory-reset --host=%s", host),
|
||||
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup factory-reset", host),
|
||||
reason: "Wipes account pairing, presets, Wi-Fi — gives a clean baseline for the SETUP state machine.",
|
||||
},
|
||||
{
|
||||
@@ -1236,20 +1416,20 @@ func migrationSteps(host, serviceURL string, summary *setup.MigrationSummary, re
|
||||
if dnsRedirect && summary != nil && !summary.CACertTrusted {
|
||||
steps = append(steps, planStep{
|
||||
title: "Install AfterTouch's CA cert on the speaker",
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup install-ca --host=%s --service-url=%s", host, serviceURL),
|
||||
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup install-ca --service-url=%s", host, serviceURL),
|
||||
reason: "DNS-redirect methods keep using https://*.bose.com URLs — the device needs to trust AfterTouch's cert.",
|
||||
})
|
||||
}
|
||||
|
||||
steps = append(steps, planStep{
|
||||
title: fmt.Sprintf("Apply URL migration using method=%s", method),
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup migrate --host=%s --service-url=%s --method=%s", host, serviceURL, method),
|
||||
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup migrate --service-url=%s --method=%s", host, serviceURL, method),
|
||||
reason: methodReason,
|
||||
})
|
||||
|
||||
steps = append(steps, planStep{
|
||||
title: "Reboot the speaker",
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup reboot --host=%s", host),
|
||||
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup reboot", host),
|
||||
reason: "The envswitch parallel-persistence layer only fully wins on next boot; reboot now to lock the new URLs in before pairing.",
|
||||
})
|
||||
|
||||
@@ -1314,9 +1494,14 @@ func renderPreResetNote() {
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func renderPlanSteps(steps []planStep) {
|
||||
func renderPlanSteps(steps []planStep, includePair bool) {
|
||||
if len(steps) == 0 {
|
||||
PrintSuccess("Speaker is already migrated and paired. No action required.")
|
||||
if includePair {
|
||||
PrintSuccess("Speaker is already migrated and paired. No action required.")
|
||||
} else {
|
||||
PrintSuccess("Speaker is already migrated. No action required.")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,11 @@ func main() {
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Show detailed information for all devices",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "verbose",
|
||||
Aliases: []string{"v"},
|
||||
Usage: "Print per-packet/per-header SSDP and mDNS trace logs",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -23,6 +25,7 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/logbuf"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
@@ -173,9 +176,44 @@ func initMusicServices(config serviceConfig, server *handlers.Server) {
|
||||
}
|
||||
}
|
||||
|
||||
// logBufferCapacityFromEnv reads SOUNDTOUCH_LOG_BUFFER_LINES and
|
||||
// returns a positive capacity. Invalid or unset values fall back
|
||||
// to the default; a value of 0 or negative is treated as "disable"
|
||||
// and returns 0 so the caller can skip wiring the buffer.
|
||||
func logBufferCapacityFromEnv(defaultCap int) int {
|
||||
raw := os.Getenv("SOUNDTOUCH_LOG_BUFFER_LINES")
|
||||
if raw == "" {
|
||||
return defaultCap
|
||||
}
|
||||
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
log.Printf("[Logs] Invalid SOUNDTOUCH_LOG_BUFFER_LINES=%q, using default %d", raw, defaultCap)
|
||||
return defaultCap
|
||||
}
|
||||
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func main() {
|
||||
updateBuildInfo()
|
||||
|
||||
// Mirror log output to an in-memory ring buffer so the admin
|
||||
// UI can show a live trace. Stderr keeps receiving every line
|
||||
// verbatim — the buffer is a second sink, not a replacement.
|
||||
// Installing this before the cli.Action runs means every
|
||||
// log.Printf from initialisation onwards is captured.
|
||||
var logBuf *logbuf.Buffer
|
||||
|
||||
if bufCap := logBufferCapacityFromEnv(2000); bufCap > 0 {
|
||||
logBuf = logbuf.New(bufCap)
|
||||
log.SetOutput(io.MultiWriter(os.Stderr, logBuf))
|
||||
}
|
||||
|
||||
app := &cli.App{
|
||||
Name: "soundtouch-service",
|
||||
Usage: "Local service for Bose SoundTouch cloud emulation and management",
|
||||
@@ -342,6 +380,11 @@ func main() {
|
||||
Usage: "Paths for internal requests (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"INTERNAL_PATHS"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "tls-extra-host",
|
||||
Usage: "Additional DNS name or IP to include in the server TLS certificate SAN list (repeatable)",
|
||||
EnvVars: []string{"TLS_EXTRA_HOST"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migration-enabled",
|
||||
Usage: "Enable device directory migration from serial to MAC-based structure",
|
||||
@@ -382,7 +425,7 @@ func main() {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname)
|
||||
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname, config.tlsExtraHosts)
|
||||
|
||||
cm := initCertificateManager(config.dataDir, config.hostname)
|
||||
sm := setup.NewManager(config.serverURL, ds, cm)
|
||||
@@ -390,7 +433,9 @@ func main() {
|
||||
sm.MgmtPassword = config.mgmtPassword
|
||||
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
|
||||
sm.GetDNSRunning = server.GetDNSRunning
|
||||
server.SetLogBuffer(logBuf)
|
||||
server.SetHTTPServerURL(config.httpsServerURL)
|
||||
server.SetExpectedHosts(config.domains)
|
||||
server.SetVersionInfo(version, commit, date, repoURL)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, config.discoveryEnabled)
|
||||
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
|
||||
@@ -534,6 +579,7 @@ type serviceConfig struct {
|
||||
dnsUpstream string
|
||||
dnsBind string
|
||||
internalPaths []string
|
||||
tlsExtraHosts []string
|
||||
discoveryEnabled bool
|
||||
discoveryInterval time.Duration
|
||||
domains []string
|
||||
@@ -590,7 +636,8 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
httpsServerURL = "https://" + hostname + ":" + httpsPort
|
||||
}
|
||||
|
||||
domains := getDomains(serverURL, httpsServerURL, hostname)
|
||||
tlsExtraHosts := c.StringSlice("tls-extra-host")
|
||||
domains := getDomains(serverURL, httpsServerURL, hostname, tlsExtraHosts)
|
||||
|
||||
redact := c.Bool("redact-logs")
|
||||
logBody := c.Bool("log-bodies")
|
||||
@@ -644,6 +691,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
dnsUpstream: dnsUpstream,
|
||||
dnsBind: dnsBind,
|
||||
internalPaths: internalPaths,
|
||||
tlsExtraHosts: tlsExtraHosts,
|
||||
discoveryEnabled: discoveryEnabled,
|
||||
discoveryInterval: discoveryInterval,
|
||||
domains: domains,
|
||||
@@ -666,7 +714,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
}
|
||||
}
|
||||
|
||||
func getDomains(serverURL, httpsServerURL, hostname string) []string {
|
||||
func getDomains(serverURL, httpsServerURL, hostname string, extraHosts []string) []string {
|
||||
domainsMap := map[string]bool{
|
||||
// RFC-compliant wildcards for API patterns
|
||||
"*.api.bose.io": true,
|
||||
@@ -699,6 +747,31 @@ func getDomains(serverURL, httpsServerURL, hostname string) []string {
|
||||
domainsMap[strings.ToLower(u.Hostname())] = true
|
||||
}
|
||||
|
||||
// The speaker firmware constructs the OAuth host by appending `oauth`
|
||||
// to the first label of the streaming hostname (see issue #337 and
|
||||
// pkg/discovery/dns.go DeriveOAuthHostnames). The DNS hijack catches
|
||||
// it; the TLS cert must also cover it, otherwise the speaker rejects
|
||||
// the handshake and Spotify / Amazon Music OAuth dies before reaching
|
||||
// AfterTouch. Derive once from each of serverURL and httpsServerURL —
|
||||
// they typically share a hostname but a multi-homed deployment may
|
||||
// differ.
|
||||
for _, h := range discovery.DeriveOAuthHostnames(serverURL) {
|
||||
domainsMap[h] = true
|
||||
}
|
||||
|
||||
for _, h := range discovery.DeriveOAuthHostnames(httpsServerURL) {
|
||||
domainsMap[h] = true
|
||||
}
|
||||
|
||||
// Explicit overrides / additions for multi-homed hosts, reverse proxies,
|
||||
// or browsing the admin UI via a LAN IP that isn't part of serverURL.
|
||||
for _, h := range extraHosts {
|
||||
h = strings.ToLower(strings.TrimSpace(h))
|
||||
if h != "" {
|
||||
domainsMap[h] = true
|
||||
}
|
||||
}
|
||||
|
||||
domains := make([]string, 0, len(domainsMap))
|
||||
for d := range domainsMap {
|
||||
domains = append(domains, d)
|
||||
@@ -753,9 +826,44 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
|
||||
// CLI/env args take precedence; only apply persisted credentials when not set via CLI.
|
||||
applyPersistedMusicServiceCredentials(config, persisted)
|
||||
|
||||
config.tlsExtraHosts = mergeTLSExtraHosts(config.tlsExtraHosts, persisted.TLSExtraHosts)
|
||||
|
||||
return persisted
|
||||
}
|
||||
|
||||
// mergeTLSExtraHosts merges the CLI/env-supplied hosts with the persisted
|
||||
// list. CLI/env wins (so an operator who pinned a host via systemd unit
|
||||
// always sees it applied); persisted values are additive. Returns a
|
||||
// deduplicated, order-preserving slice with CLI/env entries first.
|
||||
func mergeTLSExtraHosts(cli, persisted []string) []string {
|
||||
seen := make(map[string]bool, len(cli)+len(persisted))
|
||||
out := make([]string, 0, len(cli)+len(persisted))
|
||||
|
||||
for _, h := range cli {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" || seen[h] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[h] = true
|
||||
|
||||
out = append(out, h)
|
||||
}
|
||||
|
||||
for _, h := range persisted {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" || seen[h] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[h] = true
|
||||
|
||||
out = append(out, h)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// applyPersistedMusicServiceCredentials fills in music service credentials from persisted
|
||||
// settings when they have not been supplied via CLI flags or environment variables.
|
||||
func applyPersistedMusicServiceCredentials(config *serviceConfig, persisted datastore.Settings) {
|
||||
@@ -876,6 +984,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
server.HandleWeb()(w, r)
|
||||
})
|
||||
|
||||
r.Get("/media/aftertouch-ding.wav", server.HandleDing)
|
||||
r.Get("/media/*", server.HandleMedia())
|
||||
r.Get("/bmx-icons/*", server.HandleBmxIcons())
|
||||
r.Get("/ced/*", server.HandleCedStatic())
|
||||
@@ -1136,6 +1245,12 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
|
||||
r.Get("/device-summary/{deviceId}", server.HandleDeviceSummary)
|
||||
|
||||
r.Get("/health", server.HandleHealthChecks)
|
||||
r.Post("/health/fix", server.HandleHealthFix)
|
||||
r.Get("/export/diagnostic", server.HandleExportDiagnostic)
|
||||
r.Get("/logs", server.HandleGetLogs)
|
||||
|
||||
// Serve Stockholm setup wizard pages for paths not matched by the management API.
|
||||
// The Stockholm frontend has a setup/ directory that must be accessible at /setup/*.
|
||||
@@ -1240,7 +1355,12 @@ func runHTTPSPreflight(httpsServerURL, serverURL string, dnsEnabled bool, resolv
|
||||
|
||||
guidance := handlers.FormatPreflightGuidance(port, res)
|
||||
if guidance == "" {
|
||||
if !res.Skipped {
|
||||
switch {
|
||||
case res.Skipped:
|
||||
// Listener already on :443 — nothing to say.
|
||||
case res.NotApplicable:
|
||||
log.Printf("HTTPS pre-flight: :443 check skipped — %s", res.Reason)
|
||||
default:
|
||||
log.Printf("HTTPS pre-flight: :443 reachable at localhost and %s ✓", res.LANHost)
|
||||
}
|
||||
|
||||
|
||||
@@ -90,3 +90,100 @@ func TestApplyPersistedSettings(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMergeTLSExtraHosts(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cli []string
|
||||
persisted []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "CLI only",
|
||||
cli: []string{"a.example"},
|
||||
persisted: nil,
|
||||
want: []string{"a.example"},
|
||||
},
|
||||
{
|
||||
name: "Persisted only",
|
||||
cli: nil,
|
||||
persisted: []string{"b.example"},
|
||||
want: []string{"b.example"},
|
||||
},
|
||||
{
|
||||
name: "CLI wins ordering, persisted appended",
|
||||
cli: []string{"a.example"},
|
||||
persisted: []string{"b.example"},
|
||||
want: []string{"a.example", "b.example"},
|
||||
},
|
||||
{
|
||||
name: "Dedupes overlap",
|
||||
cli: []string{"a.example", "b.example"},
|
||||
persisted: []string{"b.example", "c.example"},
|
||||
want: []string{"a.example", "b.example", "c.example"},
|
||||
},
|
||||
{
|
||||
name: "Drops empty + whitespace",
|
||||
cli: []string{" ", "a.example", ""},
|
||||
persisted: []string{"", " b.example "},
|
||||
want: []string{"a.example", "b.example"},
|
||||
},
|
||||
{
|
||||
name: "Both empty",
|
||||
cli: nil,
|
||||
persisted: nil,
|
||||
want: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := mergeTLSExtraHosts(tc.cli, tc.persisted)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("len mismatch: got %v, want %v", got, tc.want)
|
||||
}
|
||||
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("index %d: got %q, want %q (full: %v vs %v)", i, got[i], tc.want[i], got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomains_IncludesOAuthDerivation(t *testing.T) {
|
||||
// Hostname-based serverURL: the derived OAuth variant must end up
|
||||
// in the served TLS cert SAN list, otherwise the speaker rejects
|
||||
// the TLS handshake on Spotify / Amazon Music token refresh.
|
||||
got := getDomains("http://mac.fritz.box:8000", "https://mac.fritz.box:8443", "mac.fritz.box", nil)
|
||||
|
||||
want := "macoauth.fritz.box"
|
||||
if !contains(got, want) {
|
||||
t.Errorf("expected SAN list to include %q (derived from serverURL), got: %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomains_IPServerURLProducesNoOAuthDerivation(t *testing.T) {
|
||||
// IP-based serverURL deliberately yields no derivation (the speaker's
|
||||
// `<first-label>oauth.<rest>` construction would be malformed for an
|
||||
// IP and no DNS resolver can answer for it). The cert SAN list must
|
||||
// not pretend to cover something that can never be queried.
|
||||
got := getDomains("http://192.168.0.30:8000", "https://192.168.0.30:8443", "192.168.0.30", nil)
|
||||
|
||||
for _, h := range got {
|
||||
if h == "192oauth.168.0.30" {
|
||||
t.Errorf("SAN list must not include malformed IP-derived OAuth name, got: %v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(haystack []string, needle string) bool {
|
||||
for _, h := range haystack {
|
||||
if h == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ GET /docs/* handlers.(
|
||||
GET /favicon.ico setupRouter
|
||||
GET /health handlers.(*Server).HandleHealth-fm
|
||||
GET /media/* handlers.(*Server).HandleMedia
|
||||
GET /media/aftertouch-ding.wav handlers.(*Server).HandleDing-fm
|
||||
GET /mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
|
||||
GET /mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
|
||||
GET /mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
|
||||
@@ -53,17 +54,21 @@ GET /mgmt/spotify/callback handlers.(
|
||||
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
|
||||
GET /setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
|
||||
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
|
||||
GET /setup/device-summary/{deviceId} handlers.(*Server).HandleDeviceSummary-fm
|
||||
GET /setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
|
||||
GET /setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
|
||||
GET /setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
|
||||
GET /setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm
|
||||
GET /setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm
|
||||
GET /setup/export/diagnostic handlers.(*Server).HandleExportDiagnostic-fm
|
||||
GET /setup/health handlers.(*Server).HandleHealthChecks-fm
|
||||
GET /setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm
|
||||
GET /setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm
|
||||
GET /setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm
|
||||
GET /setup/interactions handlers.(*Server).HandleListInteractions-fm
|
||||
GET /setup/interactions/sessions/{session}/download handlers.(*Server).HandleDownloadSession-fm
|
||||
GET /setup/logging-settings handlers.(*Server).HandleGetLoggingSettings-fm
|
||||
GET /setup/logs handlers.(*Server).HandleGetLogs-fm
|
||||
GET /setup/settings handlers.(*Server).HandleGetSettings-fm
|
||||
GET /setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm
|
||||
GET /setup/version handlers.(*Server).HandleGetVersionInfo-fm
|
||||
@@ -127,6 +132,7 @@ POST /setup/backup/{deviceId} handlers.(
|
||||
POST /setup/devices handlers.(*Server).HandleAddManualDevice-fm
|
||||
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
|
||||
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
|
||||
POST /setup/health/fix handlers.(*Server).HandleHealthFix-fm
|
||||
POST /setup/logging-settings handlers.(*Server).HandleUpdateLoggingSettings-fm
|
||||
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
|
||||
POST /setup/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
# Device-Local Install: Four User Journeys
|
||||
|
||||
A user-journey-shaped view of where AfterTouch sits today and where it could go. The same speaker, the same constraints, but four different audiences with non-overlapping needs:
|
||||
|
||||
1. **Initial setup / install** — getting AfterTouch onto a fresh or freshly-orphaned speaker.
|
||||
2. **Less-technical admin** — migration, maintenance, and recovery without a terminal.
|
||||
3. **Daily usage** — playing music, switching presets, on the couch or on the phone.
|
||||
4. **Automation** — driving the speaker from scripts, home automation, schedules.
|
||||
|
||||
Each journey is served by a different surface (CLI, web UI, GUI app, REST). Some surfaces serve more than one journey; some journeys are served badly today. This doc is informational; nothing here is a roadmap commitment.
|
||||
|
||||
Cross-cutting reference material — lessons from `GameTec-live/soundtouch-tiny`, plus a per-surface capability map — lives in the appendix.
|
||||
|
||||
---
|
||||
|
||||
## Journey 1: Initial setup / install
|
||||
|
||||
**Who.** Someone with a Bose speaker whose cloud just died. Could be technical (knows what SSH is) or not (knows what a USB stick is). Wants the speaker to play Internet Radio again with minimum fuss.
|
||||
|
||||
**Goal.** Get an AfterTouch instance reachable from the speaker, whether that instance lives on a separate host or on the speaker itself.
|
||||
|
||||
**Surfaces.** Shell (today), GUI installer (planned), pre-flashed stick (commercial offering, hypothetical).
|
||||
|
||||
### The three install patterns
|
||||
|
||||
#### Pattern A — External host
|
||||
|
||||
A separate machine (Raspberry Pi, NAS, always-on laptop) runs `soundtouch-service`. Speakers point at it via DNS rewrite at the router. No code on the speaker, no firmware risk.
|
||||
|
||||
- **Pros:** zero invasiveness, easy update (single host), unified for many speakers, no per-speaker storage limit.
|
||||
- **Cons:** requires an always-on host on the LAN, DNS rewrite at router scope, single point of failure.
|
||||
|
||||
#### Pattern B — SSH-curl on-device (current `scripts/on-device-install/`)
|
||||
|
||||
User SSHes in once, pipes the installer. Installs to `/mnt/nv/aftertouch`, symlinks `/opt/aftertouch`, registers `/etc/init.d/aftertouch` via `update-rc.d`. Daemon serves `:8000` on the speaker's own LAN address.
|
||||
|
||||
- **Pros:** no separate host, per-speaker isolation, survives router replacement.
|
||||
- **Cons:** SSH required for install and updates, ~12 MB binary stresses tiny rootfs partitions, no in-process restart on crash, some firmware images bind only loopback (issue #196).
|
||||
|
||||
#### Pattern C — Stick-driven on-device (*not* implemented here)
|
||||
|
||||
USB stick holds binary + bootstrap scripts. First install needs SSH (placing `/mnt/nv/rc.local`). After that, the NAND `rc.local` auto-syncs from any stick inserted with newer files. Stick can also carry one-shot configs (`wlan.conf`, `region.conf`, `name.conf`) consumed and wiped during boot.
|
||||
|
||||
- **Pros:** post-bootstrap updates need no SSH, stick wipe behavior keeps credentials short-lived, watchdog inside the bootstrap script restarts the agent on crash without a reboot.
|
||||
- **Cons:** first install still needs SSH; FAT32 stick on the speaker is unreliable for writes; user has to keep a stick around.
|
||||
|
||||
### The technical underpinning: `/mnt/nv/rc.local`
|
||||
|
||||
Both pattern C and any "shepherd-less" install on stock firmware depend on a single line in the stock init scripts:
|
||||
|
||||
```
|
||||
# /etc/init.d/shelby_local, start case
|
||||
[ -x /mnt/nv/rc.local ] && /mnt/nv/rc.local
|
||||
```
|
||||
|
||||
`shelby_local` is a stock Bose SysV script. Its `start` case fires at every boot from an `S`-symlink in `rcS.d/` (the misleading `K99shelby_local` symlink in `rc1.d/` is the *shutdown* path — same script, different case). `/mnt/nv` is the persistent read-write NAND partition; `rc.local` is intentionally exposed as an extension point. By the time it runs, rootfs is mounted read-only, `/mnt/nv` is read-write, network is configured, and `/media/sda1` is *typically* mounted by udev if a USB stick is present — but the mount is asynchronous and races the hook (polling for up to 30 s is one way to handle this).
|
||||
|
||||
**Stock firmware does not auto-copy anything from a USB stick into `/mnt/nv/rc.local`.** Inserting a stick alone is not enough. There is no udev rule, no autorun convention, no `shelby_usb` branch that handles this; `shelby_usb` only manages USB ethernet-gadget mode (`g_ether`) and the `microbswitch` helper on certain variants.
|
||||
|
||||
Placement happens one of two ways:
|
||||
|
||||
1. **Manual SSH bootstrap, once.** Shell access (via the `remote_services` stick trick) runs an installer that writes `/mnt/nv/rc.local`, makes it executable, and exits. After that single SSH session, the stick is no longer required to *trigger* anything — the NAND copy fires on every boot.
|
||||
2. **Self-update from a newer stick, after step 1.** Once `/mnt/nv/rc.local` exists *and contains the self-update logic*, inserting a stick with a newer `rc.local` (compared by mtime) lets the running NAND copy overwrite itself for the next boot. This gives the stick its "repair channel" property.
|
||||
|
||||
**The very first placement requires SSH.** Any zero-SSH install would need either a different stock-firmware hook (we have not found one usable across SoundTouch variants) or a custom firmware image. The `remote_services` stick is the only stick-content convention the stock firmware honors out of the box, and all it does is enable `sshd`.
|
||||
|
||||
### App-driven install (the missing middle)
|
||||
|
||||
The SSH session does **not** have to be a human SSH session. `pkg/ssh` (`NewClient`, `Run`, `ReadFile`, `ReadDir`, `UploadContent`) is already used by `pkg/service/setup/` to drive migration probes; the same primitives can drive an installer. The user never sees a terminal.
|
||||
|
||||
User-visible flow:
|
||||
|
||||
1. User runs an admin app on their laptop or phone.
|
||||
2. App walks them through preparing a `remote_services` stick — or writes one for them, if it can reach the host's USB subsystem.
|
||||
3. User inserts the stick into the speaker and power-cycles it. Stock firmware's `sshd` starts.
|
||||
4. App discovers the speaker via mDNS, dials SSH, runs the installer steps that today live behind `curl ... \| sh`. No `ssh` invocation, no `rw &&`, no copy-pasted IP.
|
||||
5. App verifies `curl http://<box>:8000` from inside the speaker via SSH and surfaces a clear success / failure state.
|
||||
6. App optionally removes `remote_services` from the stick and reboots the speaker, closing the SSH backdoor automatically.
|
||||
|
||||
Mapping each step to existing code:
|
||||
|
||||
| Step | Today's installer | App equivalent (`pkg/ssh`) |
|
||||
|---------------------|-------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------|
|
||||
| Remount rootfs rw | `mount -o remount,rw /` (inside init script) | `Client.Run("mount -o remount,rw /")` |
|
||||
| Make NAND dir | `mkdir -p $INSTALL_DIR` | `Client.Run("mkdir -p /mnt/nv/aftertouch")` |
|
||||
| Download binary | `curl -sSL ... -o binary` | local download on the app side, then `Client.UploadContent(bytes, "/mnt/nv/aftertouch/aftertouch-service")` |
|
||||
| Mark executable | `chmod +x` | `Client.Run("chmod +x ...")` |
|
||||
| Symlink `/opt` | `ln -sf $INSTALL_DIR /opt/aftertouch` | `Client.Run("ln -sf ...")` |
|
||||
| Install init script | `curl ... -o /etc/init.d/aftertouch && update-rc.d aftertouch defaults` | `Client.UploadContent` + `Client.Run` |
|
||||
| Start | `/etc/init.d/aftertouch start` | `Client.Run("/etc/init.d/aftertouch start")` |
|
||||
| Verify listener | `curl -fsS http://localhost:8000` inside the box | `Client.Run("curl -fsS http://localhost:8000")` |
|
||||
|
||||
No new SSH plumbing required. The pieces already exist for the setup probes.
|
||||
|
||||
### Storage budget
|
||||
|
||||
The on-device patterns share one hard constraint: storage. ST20 stock rootfs has ~4 MB free (issue #268); even with `/mnt/nv` (~30 MB free) the budget is tight, and a second binary for safe OTA updates doubles it. This is the primary motivation for a slimmer `soundtouch-service-mini` build target — see the appendix.
|
||||
|
||||
### Open decisions for this journey
|
||||
|
||||
- Do we keep pattern B as the technical-user path while building a Gio admin app for the rest?
|
||||
- Do we add a pattern-C-style "register a stick-update hook in `/mnt/nv/rc.local`" option as an opt-in, so users who do want a repair stick get one?
|
||||
- Pre-flashed sticks shipped as a kit: in scope or out?
|
||||
|
||||
---
|
||||
|
||||
## Journey 2: Less-technical admin (migration + maintenance)
|
||||
|
||||
**Who.** The person who already has AfterTouch installed somewhere and now needs to do something *after* install. They are comfortable opening apps and clicking buttons; they are not comfortable opening a terminal. The whole-household admin: parent, partner, roommate doing it for the household.
|
||||
|
||||
**Goal.** Migrate a speaker to a new AfterTouch instance, update the agent, view what's going on, recover a stuck device, change WLAN credentials, reapply config after factory reset — all without SSH.
|
||||
|
||||
**Surfaces.** GUI admin app (Gio, planned), `soundtouch-service` embedded web UI (today, technical-leaning), CLI (today, technical-only).
|
||||
|
||||
### What "admin" covers in practice
|
||||
|
||||
- **Migration of a new (or factory-reset) speaker** to an AfterTouch instance: rewrite the server URLs in `/mnt/nv/persistence.json`, restart the device, verify it talks to us.
|
||||
- **Agent update on an on-device install** (pattern B or C): push a new binary, restart, verify.
|
||||
- **Status and diagnostics**: is `aftertouch` running, is `:8000` listening, did the last preset save succeed, what does syslog say?
|
||||
- **Recovery**: speaker is stuck (won't respond to web UI, won't pair, lost WLAN). Today this almost always means SSH; with `pkg/ssh` behind a GUI, it can mean "click 'Diagnose' in the app."
|
||||
- **Bulk operations**: do all of the above across several speakers at once.
|
||||
- **Configuration drift**: WLAN password changed, region changed, speaker name changed, hosts file got rewritten — restore the AfterTouch overlay.
|
||||
|
||||
### How the GUI admin app shape would serve this
|
||||
|
||||
Same `pkg/ssh` primitives as Journey 1's installer, applied to post-install tasks. mDNS discovers all speakers on the LAN; the app fans operations out across them; SSH-driven actions stay hidden behind buttons. On a phone, the same app is the "speakers are unreachable, what now" diagnostic tool from another room.
|
||||
|
||||
Where today's surfaces fall short for this user:
|
||||
|
||||
- `soundtouch-service` web UI assumes the service is running and reachable. It cannot recover a broken installation or a stuck device.
|
||||
- CLI works but presumes terminal comfort.
|
||||
- The setup wizard in `soundtouch-service` handles initial migration well, but reapplying after factory reset is not first-class — see `docs/analysis/FACTORY-RESET-PROTOCOL.md`.
|
||||
|
||||
### Open decisions for this journey
|
||||
|
||||
- Does the admin app subsume the service web UI's admin tab, or do they coexist (admin app = onboarding + recovery; service web UI = ongoing operations once everything is healthy)?
|
||||
- WASM as a fallback surface: today's service web UI is browser-accessible from anywhere. Does a Gio admin app sacrifice that, or do we ship both?
|
||||
- Multi-household / multi-speaker: how much does the admin app need to know about distinguishing speakers vs distinguishing AfterTouch instances?
|
||||
|
||||
---
|
||||
|
||||
## Journey 3: Daily usage
|
||||
|
||||
**Who.** Anyone in the household using the speaker. Children pressing a preset button. The user opening a phone to switch from kitchen to living room. Guests asked to "just put on some jazz." Zero awareness of AfterTouch as a thing; the speaker is the speaker.
|
||||
|
||||
**Goal.** Music plays. Pressing preset 3 gives them what preset 3 should give them. Skipping a station, adjusting volume, browsing for a new station — all fast, no friction.
|
||||
|
||||
**Surfaces.** Physical preset buttons (always there), `soundtouch-web` (today), mobile app (Journey 2 admin app's daily-use mode), WASM-served browser UI (planned), Bose app while it still functions, voice assistants where wired up.
|
||||
|
||||
### What this layer needs to be good at
|
||||
|
||||
- **Preset playback works first try, every time.** The reliability bar is "is the kitchen radio still working?" Anything that fails on cold boot or after a Wi-Fi outage breaks the user's trust in the whole system.
|
||||
- **Switching stations quickly**, including discovery of new ones (e.g. `radio-browser.info`-style search).
|
||||
- **Volume and play / pause from any device the user has in hand.** Phone in pocket, laptop on table, browser tab open — all should work.
|
||||
- **Multi-room awareness** if the household has more than one speaker: which speaker is playing what, can I send this to the bedroom.
|
||||
- **Looking good.** This is the surface that gets seen daily by non-technical users. Visual polish matters more here than anywhere else in the stack.
|
||||
|
||||
### How surfaces map
|
||||
|
||||
- `soundtouch-web`: primary daily UI for desktop browsers and (responsively) for tablets. This is already shipped.
|
||||
- Mobile app: daily-use mode of the same Gio app that handles admin. Capability split — admin features only show up when the user is in admin mode.
|
||||
- WASM: same Gio app, served from `soundtouch-service` to anyone on the LAN. The "I forgot which device my login is on, just open a browser" fallback.
|
||||
- Physical preset buttons: handled at the agent level (the Bose firmware fires them; AfterTouch or the on-device agent reacts).
|
||||
|
||||
### Open decisions for this journey
|
||||
|
||||
- Do we keep `soundtouch-web` as a separate codebase (HTML/JS), or does it become a Gio WASM build sharing code with the admin app?
|
||||
- Mobile app store distribution: TestFlight for iOS (gated, slow), Play Store for Android (faster, AAB only), F-Droid as an open-source-friendly side path.
|
||||
- Multi-user state: presets per-user vs per-household. Out of scope here, but the daily surface is where it gets felt.
|
||||
|
||||
---
|
||||
|
||||
## Journey 4: Automation
|
||||
|
||||
**Who.** The same household, but acting through code: a Home Assistant config, a NodeRED flow, a cron job, a shell script, a webhook from a smart doorbell. The user is not present at the speaker; they want music to start when something else happens.
|
||||
|
||||
**Goal.** Headless, scriptable control. "Play preset 2 at 7:00 every weekday." "When the kids' bedtime alarm fires, fade volume to zero." "If I get home and the speaker is on, switch to my dinner playlist."
|
||||
|
||||
**Surfaces.** `soundtouch-cli` (today), REST endpoints on `soundtouch-service` (today), MQTT bridge / webhook outputs (hypothetical), Home Assistant integration (community).
|
||||
|
||||
### What this layer needs to be good at
|
||||
|
||||
- **Stable, versioned API surface.** Scripts and home automation flows live for years; breaking changes are expensive for users.
|
||||
- **CLI that works in pipelines.** Exit codes, machine-readable output (JSON), stable flag names. The reverse of the daily UI: zero polish, full predictability.
|
||||
- **Discoverability of capabilities.** Users need to find out what's possible (`soundtouch-cli help`, openapi spec on the service, examples in the docs).
|
||||
- **Idempotency.** Calling "set volume to 40" twice should not result in volume 80. Calling "switch to preset 3" when already on preset 3 should be a no-op.
|
||||
|
||||
### How surfaces map
|
||||
|
||||
- `soundtouch-cli`: the canonical surface for scripted control. Already covers most of the API.
|
||||
- `soundtouch-service` REST endpoints: same surface, network-accessible. Used by `soundtouch-web` and by third-party automation.
|
||||
- Home Assistant: external integration; track but do not own.
|
||||
- Webhooks / MQTT: not present today; would let speakers participate in event-driven flows. Out of scope for a first pass; worth a separate design doc when demand surfaces.
|
||||
|
||||
### Open decisions for this journey
|
||||
|
||||
- Stability commitments for the CLI and REST API: do we adopt semver for the public surface separately from the service version?
|
||||
- Authentication for the REST surface when exposed beyond loopback: needed before any internet exposure is sane.
|
||||
- OpenAPI / typed-client output for the service: nice-to-have for integration developers.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: which surface serves which journey
|
||||
|
||||
| Surface | Journey 1 (install) | Journey 2 (admin) | Journey 3 (daily) | Journey 4 (automation) |
|
||||
|------------------------------------|---------------------|-------------------|-------------------|------------------------|
|
||||
| `soundtouch-cli` | partial (today) | partial (today) | no | primary |
|
||||
| `soundtouch-service` web UI | wizard portion | primary | partial | indirect (REST) |
|
||||
| `soundtouch-web` | no | no | primary | no |
|
||||
| GUI admin app (Gio, planned) | primary | primary | mobile mode | no |
|
||||
| Pre-flashed stick (hypothetical) | primary | recovery | no | no |
|
||||
| Physical preset buttons | no | no | primary | no |
|
||||
| Home Assistant / webhooks (future) | no | no | no | primary |
|
||||
|
||||
The diagonal isn't full because some journeys lack a polished surface today (Journey 1 mostly works but is shell-only; Journey 2 has gaps for recovery scenarios). The journey frame is what tells us *which* gaps to fill first.
|
||||
|
||||
## Appendix: per-surface capability constraints
|
||||
|
||||
The Gio admin app, if built, can target Windows / macOS / Linux / iOS / Android / WASM from one codebase. Each target has hard constraints:
|
||||
|
||||
- **WASM (browser).** Post-install REST control, device list and status, preset editing, station search. No mDNS (browsers cannot do raw multicast — fall back to manual IP entry or a backend bridge); no raw TCP, so no SSH and no install; no block-device access, so no stick writing. This is the "I just want to use my speakers" surface, equivalent to today's `soundtouch-web`.
|
||||
- **Mobile iOS.** Everything WASM does, plus Bonjour-based mDNS, plus full SSH client (so app-driven install and recovery work). No FAT32 stick writing — iOS has no filesystem-level block device access for third-party apps. Best paired with a pre-flashed stick or a friend's desktop install for the bootstrap.
|
||||
- **Mobile Android.** Same as iOS, plus FAT32 stick writing *if* the user grants USB-OTG host permission. UX caveat: most users will not know what USB host mode is.
|
||||
- **Desktop (Gio).** Full capability set. mDNS, SSH-driven install, FAT32 stick writing via standard block-device APIs, post-install control, recovery. The primary onboarding surface.
|
||||
|
||||
The pattern to follow is to write code so each capability degrades automatically based on what the runtime actually offers, rather than gating with build tags.
|
||||
|
||||
## Appendix: lessons from adjacent projects
|
||||
|
||||
### soundtouch-tiny (GameTec-live)
|
||||
|
||||
Minimal on-device cloud replacement: Internet Radio + TuneIn proxy + optional presets. Go stdlib only, small binary. Inspired by AfterTouch but trimmed. The author offered collaboration in PR #292.
|
||||
|
||||
This is the gap a **`soundtouch-service-mini` build target** would fill. The full `soundtouch-service` is justified for the external-host pattern (Pattern A) where space is not pressed; on-device (patterns B and C) the calculus is different — many users only need Internet Radio because that's the surface most affected by the cloud shutdown.
|
||||
|
||||
A mini build target in this repo would look like:
|
||||
|
||||
- same codebase, different `cmd/` entry point,
|
||||
- compiled with only the packages needed for Internet Radio + TuneIn shim + presets,
|
||||
- no Spotify, no parity tests, no setup wizard, no Bose-protocol-level proxy,
|
||||
- target size: under 4 MB so it fits the rootfs without `/mnt/nv` gymnastics, leaving room for a second binary for safe updates.
|
||||
|
||||
Open questions before committing:
|
||||
|
||||
1. Collaborate upstream with soundtouch-tiny, or build our own mini that shares code with the full service?
|
||||
2. Where to draw the feature line — "Internet Radio only" is clear; "Spotify too" would already blow the budget on ST20.
|
||||
3. Mini ships via Pattern B (SSH-curl) or Pattern C (stick)?
|
||||
4. Full service and mini service coexisting on the same LAN — mDNS service name, port choice, web UI port.
|
||||
|
||||
### Wails vs Gio
|
||||
|
||||
Both are Go. Different tradeoffs:
|
||||
|
||||
- **Wails v2**: bundles a WebView per OS, frontend is HTML/CSS/JS. Faster to a working UI if the team is comfortable with HTML. Targets Windows / macOS / Linux. No mobile, no WASM.
|
||||
- **Gio**: immediate-mode pure-Go UI. Smaller binaries, no WebView dependency. Targets Windows / macOS / Linux / iOS / Android / WASM. Steeper UI learning curve, mitigated by `gio-mw`.
|
||||
|
||||
The deciding factor is **mobile + WASM** (Journey 2 and Journey 3), not desktop alone. If "use a phone to set up a speaker" or "open the admin tool from any browser" is on the roadmap, Wails does not get us there.
|
||||
|
||||
## Appendix: documentation gap to close
|
||||
|
||||
Separate user-facing material to produce when we are ready (not in this comparison doc):
|
||||
|
||||
- **The `/mnt/nv/rc.local` hook** explained in user terms: what it does, when it fires, when *not* to use it, how to remove it cleanly. Bridges Journey 1 and Journey 2.
|
||||
- **Hooks we already maintain** at OS level: resolv.conf stability, `/etc/hosts` overlay, anything in `pkg/service/setup/` that touches device state. Reference, not narrative. Journey 2 troubleshooting.
|
||||
- **Storage budget per model**: rootfs free, `/mnt/nv` free, where the binary lands, which path applies to which ST model. Journey 1 sizing.
|
||||
- **Decision matrix**: external host vs on-device vs mini, plus "do I need Spotify? do I need migration? do I want one host or per-speaker isolation?" Journey 1 entry point.
|
||||
- **Stick file conventions**: what the `remote_services` stick does today, what we *might* add (presets / wlan / region) if we build a stick-driven path, and how that interacts with FAT credentials residency. Journey 1.
|
||||
- **Automation cookbook**: example Home Assistant config, example shell scripts, common pitfalls. Journey 4.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- AfterTouch installer: `scripts/on-device-install/install.sh`, `scripts/on-device-install/aftertouch` (init script), `scripts/on-device-install/README.md`.
|
||||
- AfterTouch SSH client: `pkg/ssh/ssh.go` (`NewClient`, `Run`, `ReadFile`, `ReadDir`, `UploadContent`), already used by `pkg/service/setup/`.
|
||||
- Storage limitations: issue #268 (ST20 rootfs free space), issue #196 (loopback-only bind), issue #250 (status reports running but unreachable).
|
||||
- soundtouch-tiny: `https://github.com/GameTec-live/soundtouch-tiny`, raised in PR #292 (`https://github.com/gesellix/Bose-SoundTouch/pull/292`).
|
||||
- opencloudtouch parallel discussion: `https://github.com/scheilch/opencloudtouch/discussions/201`.
|
||||
- Existing parity doc shape: `docs/PARITY-OPENCLOUDTOUCH.md` is the precedent for cross-project comparison documents.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Encrypted Diagnostic Export
|
||||
|
||||
AfterTouch can produce an encrypted diagnostic report that users can download and
|
||||
send to the project maintainer without exposing sensitive data to third parties.
|
||||
The report is encrypted with an SSH public key using
|
||||
[`age`](https://github.com/FiloSottile/age); only the holder of the matching
|
||||
private key can read it.
|
||||
|
||||
---
|
||||
|
||||
## What the report contains
|
||||
|
||||
The encrypted `.age` file decrypts to a `.tar.gz` archive with:
|
||||
|
||||
- `diagnostic.json` — structured summary:
|
||||
- Service version and build info
|
||||
- Full health-check results (same data as the Health tab)
|
||||
- Per-device state: sources (IDs, names, SourceKeyTypes), presets (slot, name,
|
||||
Source, SourceID, location), device product code, firmware version, IP, name
|
||||
- `datastore/accounts/{id}/devices/{id}/*.xml` — raw XML files verbatim from
|
||||
the sender's datastore (`Presets.xml`, `Sources.xml`, `Recents.xml`, …)
|
||||
|
||||
Having both the structured JSON and the raw XML lets you compare what the
|
||||
service serves via HTTP against what is actually stored on disk.
|
||||
|
||||
**What is excluded from the JSON:** authentication tokens, credentials, OAuth
|
||||
secrets, Spotify refresh tokens. The raw XML files are included as-is.
|
||||
|
||||
---
|
||||
|
||||
## Maintainer setup (one-time)
|
||||
|
||||
> This section is for the project maintainer only.
|
||||
> Users never need to touch keys.
|
||||
|
||||
### 1. Generate the key pair
|
||||
|
||||
```bash
|
||||
bash scripts/setup-diagnostic-key.sh
|
||||
```
|
||||
|
||||
This creates:
|
||||
- `keys/private/diagnostic` — SSH ed25519 private key (**gitignored**, never commit)
|
||||
- `keys/private/diagnostic.pub` — copy for reference (**gitignored**)
|
||||
- `keys/public/diagnostic.pub` — public key committed to the repo
|
||||
|
||||
### 2. Add the public key to GitHub
|
||||
|
||||
Go to <https://github.com/settings/ssh/new> and paste the contents of
|
||||
`keys/public/diagnostic.pub`. This makes the key visible at
|
||||
<https://github.com/gesellix.keys> so users can independently verify that the
|
||||
key embedded in the binary matches a key actually controlled by the maintainer.
|
||||
|
||||
### 3. Embed the public key in the binary
|
||||
|
||||
Open `pkg/service/export/encrypt.go` and update the `DiagnosticPublicKey`
|
||||
constant to match the new public key:
|
||||
|
||||
```go
|
||||
const DiagnosticPublicKey = "ssh-ed25519 AAAA... aftertouch-diagnostic@gesellix"
|
||||
```
|
||||
|
||||
### 4. Commit
|
||||
|
||||
```bash
|
||||
git add keys/public/diagnostic.pub pkg/service/export/encrypt.go
|
||||
git commit -m "keys: add diagnostic SSH public key"
|
||||
```
|
||||
|
||||
`keys/private/` is `.gitignore`d — the private key will not be committed.
|
||||
|
||||
### 5. Back up the private key
|
||||
|
||||
The private key is **not** stored in git. Keep a copy in a secure location
|
||||
(password manager, encrypted USB drive, etc.). If it is lost, a new key pair
|
||||
must be generated and the constant in `encrypt.go` updated.
|
||||
|
||||
---
|
||||
|
||||
## Verifying the embedded key (users)
|
||||
|
||||
Users who want to confirm that the key embedded in their running binary matches
|
||||
the maintainer's GitHub SSH keys can run:
|
||||
|
||||
```bash
|
||||
# Compare the raw key text — both should show the same line:
|
||||
curl -s https://github.com/gesellix.keys
|
||||
cat keys/public/diagnostic.pub
|
||||
```
|
||||
|
||||
The key should appear verbatim in both outputs.
|
||||
|
||||
---
|
||||
|
||||
## Decrypting a received report (maintainer)
|
||||
|
||||
When a user sends you an `aftertouch-diagnostic-*.age` file, use the helper
|
||||
script (no extra tools needed — only Go and the private key). Run from the
|
||||
repository root directory:
|
||||
|
||||
```bash
|
||||
# Decrypt and extract in one step:
|
||||
go run scripts/decrypt-diagnostic.go aftertouch-diagnostic-<timestamp>.age | tar xz
|
||||
|
||||
# Or decrypt to a .tar.gz first, then inspect:
|
||||
go run scripts/decrypt-diagnostic.go aftertouch-diagnostic-<timestamp>.age > report.tar.gz
|
||||
tar xzf report.tar.gz
|
||||
# → diagnostic.json
|
||||
# → datastore/accounts/{id}/devices/{id}/Presets.xml (and Sources.xml, Recents.xml, …)
|
||||
```
|
||||
|
||||
The script uses only the `filippo.io/age` Go module — no separate `age` CLI
|
||||
installation required.
|
||||
|
||||
---
|
||||
|
||||
## User workflow
|
||||
|
||||
1. Open the AfterTouch admin UI and go to the **Health** tab.
|
||||
2. Click **Download diagnostic report**.
|
||||
3. The browser downloads `aftertouch-diagnostic-<timestamp>.age`.
|
||||
4. Attach the file to the GitHub issue or send it via a direct channel.
|
||||
|
||||
The file is opaque binary — the user cannot read it. All they see is that the
|
||||
report was generated and downloaded.
|
||||
|
||||
---
|
||||
|
||||
## Key rotation
|
||||
|
||||
If the private key is compromised or lost:
|
||||
|
||||
1. Run `scripts/setup-diagnostic-key.sh` (delete the old `keys/private/diagnostic` first).
|
||||
2. Add the new public key to GitHub and remove the old one.
|
||||
3. Update `DiagnosticPublicKey` in `encrypt.go`.
|
||||
4. Commit and tag a new release.
|
||||
|
||||
Old reports encrypted with the previous key cannot be decrypted with the new key.
|
||||
@@ -66,14 +66,15 @@ The documentation is organized into three main categories:
|
||||
|
||||
## 🏗 Concept Documentation
|
||||
|
||||
### Enhanced Service Architecture
|
||||
- **[Concept Overview](concepts/README.md)** - High-level architecture vision
|
||||
- [Upstream Service Simulation](concepts/upstream-service-simulation.md) - Complete concept design
|
||||
- [Implementation Plan](concepts/implementation-plan.md) - Development roadmap
|
||||
- [Technical Specification](concepts/technical-specification.md) - Detailed specifications
|
||||
Current concept docs are listed under the **Concepts** section of [SUMMARY.md](SUMMARY.md#concepts). Highlights:
|
||||
|
||||
### Development Planning
|
||||
- [Implementation Roadmap](concepts/implementation-roadmap.md) - Project phases and milestones
|
||||
- [Spotify Overview](concepts/spotify-overview.md) — mental model, Spotify Connect vs OAuth-intercept, DNS rewrite gotcha
|
||||
- [Spotify OAuth](concepts/spotify-oauth.md) — flows and management endpoints
|
||||
- [Amazon Music OAuth](concepts/amazon-music-oauth.md) — companion to Spotify OAuth; same protocol shape, different scopes
|
||||
- [Encrypted Export](concepts/ENCRYPTED-EXPORT.md) — `.age`-encrypted diagnostic bundles
|
||||
- [Request Recording](REQUEST_RECORDING_CONCEPT.md) — how the proxy captures live device traffic for parity testing
|
||||
|
||||
Older planning artefacts ("Enhanced State Management System", "Upstream Service Simulation") live under [docs/archive/](archive/) — kept for the record, no longer current.
|
||||
|
||||
## 💡 Quick Reference
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* [Introduction](README.md)
|
||||
|
||||
## User Guides
|
||||
* [Device-Local Install Journeys](DEVICE-LOCAL-INSTALL.md)
|
||||
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
|
||||
* [Self-Hosting AfterTouch](guides/SELF-HOSTING.md)
|
||||
* [Connecting Music Services](guides/MUSIC-SERVICES.md)
|
||||
@@ -52,8 +53,12 @@
|
||||
|
||||
## Concepts
|
||||
* [Request Recording](REQUEST_RECORDING_CONCEPT.md)
|
||||
* [Spotify Overview](concepts/spotify-overview.md)
|
||||
* [Spotify Priming Strategy](concepts/spotify-priming-strategy.md)
|
||||
* [Spotify OAuth](concepts/spotify-oauth.md)
|
||||
* [Amazon Music OAuth](concepts/amazon-music-oauth.md)
|
||||
* [Encrypted Export](concepts/ENCRYPTED-EXPORT.md)
|
||||
* [Diagnostic Export (Maintainer Setup)](DIAGNOSTIC-EXPORT.md)
|
||||
* [soundtouch-web Roadmap](soundtouch-web-roadmap.md)
|
||||
|
||||
## Analysis & Research
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
# Encrypting Sensitive Data Exports with SSH/age or GPG
|
||||
|
||||
## Problem
|
||||
|
||||
Allow users of our software to export potentially sensitive data, encrypt it locally, and send it to us. We decrypt on our side. Goal: no key exchange, minimal user friction.
|
||||
|
||||
Two viable options are documented here: **Option A — `age`** (simpler, modern) and **Option B — GPG** (widely known, interoperable with existing tooling). Both support fetching a recipient key from GitHub so users don't need to hand us anything.
|
||||
|
||||
## Key Findings
|
||||
|
||||
### GPG via SSH keys: not possible
|
||||
|
||||
- GitHub's `https://github.com/<user>.keys` serves SSH public keys, not GPG keys.
|
||||
- SSH and GPG/OpenPGP use different formats, capability flags, and key material (auth vs. encrypt/sign/certify).
|
||||
- Ed25519 SSH keys can't be directly reused for GPG encryption (encryption requires X25519/ECDH).
|
||||
|
||||
### GPG via published GPG keys: possible
|
||||
|
||||
- GitHub exposes GPG public keys at `https://github.com/<user>.gpg` — these are real OpenPGP armored keys, not SSH keys.
|
||||
- Any key the user has uploaded to their GitHub account (or a keyserver like `keys.openpgp.org`) can be used directly for encryption.
|
||||
- Decryption requires the matching GPG private key on our side.
|
||||
- The `github.com/ProtonMail/go-crypto/openpgp` package is the actively maintained Go OpenPGP implementation (`golang.org/x/crypto/openpgp` is deprecated and points to it).
|
||||
|
||||
### `age` with SSH or native keys: possible (simpler)
|
||||
|
||||
- [`age`](https://github.com/FiloSottile/age) natively supports `ssh-rsa` and `ssh-ed25519` public keys as recipients, fetched from `https://github.com/<user>.keys`.
|
||||
- Also supports its own `age1...` native keys (`age-keygen`), which are X25519-based.
|
||||
- Written in Go; library is `filippo.io/age` + `filippo.io/age/agessh`.
|
||||
- Output is age format (not GPG-interoperable). Decrypt with `age -i key file.age` or the Go library.
|
||||
|
||||
---
|
||||
|
||||
## Option A: `age`
|
||||
|
||||
### Architecture
|
||||
|
||||
1. Generate a dedicated age key: `age-keygen -o decrypt.key` (produces `age1...` public key).
|
||||
2. Embed the public key as a constant in the binary — users need no setup.
|
||||
3. Optionally accept a GitHub username and fetch their SSH keys as recipients so the user can verify independently.
|
||||
4. Store the private key securely (secret manager, HSM, offline backup).
|
||||
|
||||
### Workflow
|
||||
|
||||
**User side:**
|
||||
```
|
||||
soundtouch-cli export --encrypt
|
||||
# or: soundtouch-cli export --encrypt-for github:gesellix
|
||||
```
|
||||
The CLI encrypts the export using the embedded key (or fetched SSH keys) and writes `export.age`.
|
||||
The user sends that file through any channel.
|
||||
|
||||
**Maintainer side:**
|
||||
```bash
|
||||
age -d -i decrypt.key -o export.tar.gz export.age
|
||||
# or with an SSH private key:
|
||||
age -d -i ~/.ssh/id_ed25519 -o export.tar.gz export.age
|
||||
```
|
||||
|
||||
### Go Implementation
|
||||
|
||||
#### Encrypt with embedded key
|
||||
|
||||
```go
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"filippo.io/age"
|
||||
)
|
||||
|
||||
const recipientKey = "age1..." // embedded public key
|
||||
|
||||
func exportEncrypted(plaintext io.Reader, outPath string) error {
|
||||
recipient, err := age.ParseX25519Recipient(recipientKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
w, err := age.Encrypt(out, recipient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
_, err = io.Copy(w, plaintext)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
#### Encrypt to a GitHub user's SSH keys (alternative / verification path)
|
||||
|
||||
```go
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/agessh"
|
||||
)
|
||||
|
||||
func recipientsFromGitHub(user string) ([]age.Recipient, error) {
|
||||
resp, err := http.Get("https://github.com/" + user + ".keys")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var recipients []age.Recipient
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
r, err := agessh.ParseRecipient(line)
|
||||
if err != nil {
|
||||
log.Printf("skipping unsupported key: %v", err)
|
||||
continue
|
||||
}
|
||||
recipients = append(recipients, r)
|
||||
}
|
||||
return recipients, nil
|
||||
}
|
||||
```
|
||||
|
||||
#### Decrypt (maintainer side)
|
||||
|
||||
With a native age key:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "filippo.io/age"
|
||||
|
||||
func decryptAge(encryptedReader io.Reader, privateKeyString string) (io.Reader, error) {
|
||||
identity, err := age.ParseX25519Identity(privateKeyString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return age.Decrypt(encryptedReader, identity)
|
||||
}
|
||||
```
|
||||
|
||||
With an SSH private key:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/agessh"
|
||||
)
|
||||
|
||||
func decryptAgeSSH(encryptedReader io.Reader, sshKeyPath string) (io.Reader, error) {
|
||||
pemBytes, err := os.ReadFile(sshKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
identity, err := agessh.ParseIdentity(pemBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return age.Decrypt(encryptedReader, identity)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Option B: GPG (OpenPGP)
|
||||
|
||||
### Architecture
|
||||
|
||||
1. Generate a dedicated GPG encryption subkey: `gpg --full-gen-key` (choose RSA or Ed25519+X25519).
|
||||
2. Export and publish the public key, or embed the armored block directly in the binary.
|
||||
3. Optionally fetch the user's GPG key from `https://github.com/<user>.gpg` or `keys.openpgp.org` so they can confirm the recipient.
|
||||
4. Store the private key securely. Decryption is `gpg --decrypt export.gpg`.
|
||||
|
||||
### Workflow
|
||||
|
||||
**User side:**
|
||||
```
|
||||
soundtouch-cli export --encrypt-gpg
|
||||
# or: soundtouch-cli export --encrypt-gpg-for github:gesellix
|
||||
```
|
||||
The CLI encrypts the export as an OpenPGP binary message and writes `export.gpg`.
|
||||
The user sends that file through any channel.
|
||||
|
||||
**Maintainer side:**
|
||||
```bash
|
||||
# GPG must have the matching private key in its keyring
|
||||
gpg --decrypt -o export.tar.gz export.gpg
|
||||
|
||||
# Or with a specific key file (without importing into the keyring):
|
||||
gpg --no-default-keyring --secret-keyring ./decrypt.gpg \
|
||||
--decrypt -o export.tar.gz export.gpg
|
||||
```
|
||||
|
||||
### Go Implementation
|
||||
|
||||
Uses `github.com/ProtonMail/go-crypto/openpgp` (the maintained successor to the deprecated `golang.org/x/crypto/openpgp`; API is compatible).
|
||||
|
||||
#### Fetch public key from GitHub
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
)
|
||||
|
||||
func gpgKeyFromGitHub(user string) (openpgp.EntityList, error) {
|
||||
resp, err := http.Get("https://github.com/" + user + ".gpg")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
block, err := armor.Decode(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return openpgp.ReadKeyRing(block.Body)
|
||||
}
|
||||
```
|
||||
|
||||
#### Encrypt with embedded or fetched public key
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
)
|
||||
|
||||
const embeddedPublicKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
...
|
||||
-----END PGP PUBLIC KEY BLOCK-----`
|
||||
|
||||
func exportEncryptedGPG(plaintext io.Reader, outPath string) error {
|
||||
block, err := armor.Decode(strings.NewReader(embeddedPublicKey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recipients, err := openpgp.ReadKeyRing(block.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Encrypt directly (binary, no ASCII armor — smaller output)
|
||||
w, err := openpgp.Encrypt(out, recipients, nil, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
_, err = io.Copy(w, plaintext)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
To produce ASCII-armored output (easier to paste into emails/issues), wrap `out` with `armor.Encode`:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
)
|
||||
|
||||
func exportEncryptedGPGArmored(plaintext io.Reader, outPath, embeddedPublicKey string) error {
|
||||
block, err := armor.Decode(strings.NewReader(embeddedPublicKey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recipients, err := openpgp.ReadKeyRing(block.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
armorWriter, err := armor.Encode(out, "PGP MESSAGE", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer armorWriter.Close()
|
||||
|
||||
w, err := openpgp.Encrypt(armorWriter, recipients, nil, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
_, err = io.Copy(w, plaintext)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
#### Decrypt (maintainer side)
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
)
|
||||
|
||||
func decryptGPG(encryptedPath, privateKeyArmored string) (io.ReadCloser, error) {
|
||||
block, err := armor.Decode(strings.NewReader(privateKeyArmored))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyring, err := openpgp.ReadKeyRing(block.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f, err := os.Open(encryptedPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg, err := openpgp.ReadMessage(f, keyring, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg.UnverifiedBody, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comparison and Recommendation
|
||||
|
||||
| Criterion | `age` | GPG |
|
||||
|-------------------------------------|------------------------------|-----------------------------------------------|
|
||||
| User familiarity | Low (newer tool) | High (widely known) |
|
||||
| User already has a key to use | Maybe (SSH on GitHub) | Often (GPG on GitHub/keyserver) |
|
||||
| Go library quality | Excellent (`filippo.io/age`) | Good (`ProtonMail/go-crypto`) |
|
||||
| Output interoperability | age format only | Standard OpenPGP — any GPG client can decrypt |
|
||||
| CLI decrypt UX (maintainer) | `age -d -i key file.age` | `gpg --decrypt file.gpg` |
|
||||
| Key embedding in binary | Native `age1...` string | Armored PEM block |
|
||||
| Key rotation story | `age-keygen`, swap constant | Standard GPG subkey rotation |
|
||||
| Anonymous recipients | Yes (native age keys) | No (key ID visible) |
|
||||
| Streaming large exports | Yes | Yes |
|
||||
|
||||
**Recommendation:** use `age` with an embedded native key for the primary path — simpler dependency, cleaner API, no GPG keyring management needed. Add GPG as an opt-in flag (`--gpg` or `--encrypt-gpg-for github:<user>`) for users who already manage GPG keys and want their own tooling to verify or store the export.
|
||||
|
||||
---
|
||||
|
||||
## Binary Size
|
||||
|
||||
Measured on macOS arm64, stripped binaries (`-ldflags="-s -w"`).
|
||||
|
||||
### Standalone cost (no shared deps)
|
||||
|
||||
| Option | Binary size | Added vs no-crypto baseline |
|
||||
|-------------------------------------|-------------|-----------------------------|
|
||||
| Baseline (no crypto) | 1.44 MB | — |
|
||||
| `age` native key only (no `agessh`) | 2.40 MB | +0.96 MB |
|
||||
| `age` + `agessh` (SSH recipients) | 3.06 MB | +1.63 MB |
|
||||
| GPG (`ProtonMail/go-crypto`) | 3.59 MB | +2.15 MB |
|
||||
|
||||
### Marginal cost for this project
|
||||
|
||||
This project already imports `golang.org/x/crypto/ssh`, which `agessh` depends on. That ~660 KB is shared and doesn't count against `age`. Against the ~12.9 MB `soundtouch-service` binary:
|
||||
|
||||
| Option | Marginal cost | % of service binary |
|
||||
|------------------|---------------|---------------------|
|
||||
| `age` + `agessh` | +0.64 MB | ~5% |
|
||||
| GPG | +1.30 MB | ~10% |
|
||||
|
||||
### Why GPG is larger
|
||||
|
||||
`age` pulls in only what it needs: `chacha20poly1305`, `hkdf`, `edwards25519`, and `filippo.io/hpke` (post-quantum). `ProtonMail/go-crypto` must ship the full OpenPGP spec: `cloudflare/circl` (Ed448, X448, Goldilocks curves), `bitcurves`, `brainpool`, `EAX`, `OCB`, `CAST5`, `BLAKE2b`, `SHA3`, `Argon2`, S2K key derivation, and zlib/bzip2 compression. Go's dead-code elimination works at the function level but can't remove entire algorithm families wired through a shared codec dispatch.
|
||||
|
||||
---
|
||||
|
||||
## Gotchas & Risks
|
||||
|
||||
| Concern | Mitigation |
|
||||
|--------------------------------------------------|-------------------------------------------------------------------------------------|
|
||||
| MITM / GitHub account compromise swaps the key | Pin expected key fingerprint(s); prefer embedded key over runtime fetch |
|
||||
| SSH key rotation breaks old `agessh` decryption | Use a dedicated long-lived age key, not the user's SSH key, as primary |
|
||||
| ECDSA SSH keys not supported by `agessh` | Handle "no usable key" gracefully; warn and fall back |
|
||||
| `agessh` recipients leak a 32-bit key ID | Accept, or use native age keys for full anonymity |
|
||||
| GPG key expiry breaks encryption | Use a non-expiring encryption subkey, or check and warn before encrypting |
|
||||
| GPG key without encryption capability | Filter `EntityList` to keys with `EncryptCommunications` flag set |
|
||||
| Encryption ≠ authentication | Authenticate via the send channel, or require a detached signature |
|
||||
| Sensitive data leaks via logs or memory dumps | Audit all egress paths; the export must be the only cleartext exit |
|
||||
| Large exports | Both `age` and `openpgp.Encrypt` stream — never buffer the whole payload |
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
```bash
|
||||
# age
|
||||
go get filippo.io/age
|
||||
go get filippo.io/age/agessh # only if supporting SSH recipients
|
||||
|
||||
# GPG
|
||||
go get github.com/ProtonMail/go-crypto/openpgp
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- `age` project: https://github.com/FiloSottile/age
|
||||
- `age` Go docs: https://pkg.go.dev/filippo.io/age
|
||||
- `agessh` docs: https://pkg.go.dev/filippo.io/age/agessh
|
||||
- ProtonMail go-crypto: https://github.com/ProtonMail/go-crypto
|
||||
- OpenPGP Go docs: https://pkg.go.dev/github.com/ProtonMail/go-crypto/openpgp
|
||||
- GitHub GPG key endpoint: `https://github.com/<user>.gpg`
|
||||
- OpenPGP keyserver: https://keys.openpgp.org
|
||||
@@ -67,7 +67,7 @@ The service must respond with a fresh Amazon access token. The speaker then uses
|
||||
|
||||
The `cs1` suffix (credential schema 1) is Amazon-specific; Spotify uses `cs3`. This route is already registered.
|
||||
|
||||
> **DNS note:** The speaker constructs the OAuth hostname by appending `oauth` to the streaming service subdomain. If the service is reachable at `myhost.local`, the speaker will call `myhostoauth.local`. A DNS alias pointing `myhostoauth.<domain>` to the same IP as the service is required.
|
||||
> **DNS note:** The speaker constructs the OAuth hostname by appending `oauth` to the **first label** of the configured streaming hostname. If the service is reachable at `myhost.local`, the speaker calls `myhostoauth.local`. That alias must resolve to AfterTouch's IP — see the [DNS requirement](#dns-requirement) section below for the available mechanisms. **IP-based `--server-url` is incompatible with OAuth**: the construction produces a malformed hostname (`192oauth.168.0.30`) that no DNS resolver can answer. Use a real LAN hostname.
|
||||
|
||||
---
|
||||
|
||||
@@ -317,7 +317,17 @@ The service looks up the account by refresh token, refreshes it via LWA, and ret
|
||||
|
||||
### DNS requirement
|
||||
|
||||
The speaker derives the OAuth hostname by appending `oauth` to its configured streaming subdomain. If the service is at `myhost.local`, the speaker calls `myhostoauth.local`. A DNS alias pointing `myhostoauth.<domain>` to the same IP is required — the built-in DNS discovery server handles this automatically when `--dns-discovery` is enabled.
|
||||
The speaker derives the OAuth hostname by appending `oauth` to the **first label** of its configured streaming hostname. If the service is at `myhost.lan`, the speaker calls `myhostoauth.lan`. A DNS alias pointing `myhostoauth.<rest>` to the same IP as AfterTouch is required.
|
||||
|
||||
**The configured `--server-url` must be a real LAN hostname.** An IP-based target produces a malformed OAuth hostname (e.g. `192oauth.168.0.30`) that no DNS resolver can answer, so OAuth never reaches AfterTouch. Switch to something like `https://aftertouch.lan:8443` before configuring Spotify or Amazon Music.
|
||||
|
||||
Three ways to make the OAuth alias resolvable, in increasing order of operator effort:
|
||||
|
||||
1. **AfterTouch's own DNS server** (auto-derived). When `--dns-discovery` is enabled, AfterTouch parses the configured `--server-url`, derives `<first-label>oauth.<rest>` automatically, and hijacks it to its own IP. The speaker must be using AfterTouch as a DNS resolver for this to take effect — set AfterTouch's IP as the primary DNS in your LAN's DHCP, or run the `setup migrate --method=resolv` flow to write each speaker's `/etc/resolv.conf` directly.
|
||||
2. **External LAN DNS** (Pi-hole, OPNsense, …). Add a static A record `<host>oauth.<rest> → <AfterTouch IP>` alongside the existing one for the AfterTouch hostname. AfterTouch's own DNS server doesn't need to be running.
|
||||
3. **Per-speaker `/etc/hosts`** (last resort). SSH into each speaker and append `<AfterTouch-IP> <host>oauth.<rest>`. Tedious; doesn't survive a factory reset.
|
||||
|
||||
The implementation lives in `pkg/discovery/dns.go` `DeriveOAuthHostnames`.
|
||||
|
||||
### Open question: `site_id`
|
||||
|
||||
|
||||
@@ -79,8 +79,22 @@ token refresh will silently die while the speaker still pulls sources.
|
||||
Symptom: the speaker briefly streams Spotify after priming, then stops at the
|
||||
first token refresh ~1 hour later.
|
||||
|
||||
If you self-host AfterTouch at e.g. `aftertouch.local`, you would equivalently
|
||||
need `aftertouchoauth.local` for the OAuth interception path.
|
||||
If you self-host AfterTouch at e.g. `aftertouch.lan`, the speaker derives
|
||||
`aftertouchoauth.lan` and queries that hostname for token refresh. AfterTouch's
|
||||
DNS server **auto-derives this alias** from the configured `--server-url` and
|
||||
adds it to the hijack list automatically — the operator does not have to
|
||||
configure it as long as speakers resolve names via AfterTouch's DNS server
|
||||
(via DHCP, the `setup migrate --method=resolv` flow, or an external LAN DNS
|
||||
that delegates to AfterTouch for these names). The implementation lives in
|
||||
`pkg/discovery/dns.go` `DeriveOAuthHostnames`.
|
||||
|
||||
> **IP-based `--server-url` is incompatible with OAuth (both Spotify and Amazon
|
||||
> Music).** The speaker's hostname construction appends `oauth` to the first
|
||||
> label only, so `192.168.0.30` would produce `192oauth.168.0.30` — malformed,
|
||||
> no DNS resolver will answer for it, and there is no clean workaround on the
|
||||
> AfterTouch side. **Use a real LAN hostname** before configuring Spotify or
|
||||
> Amazon Music. The Health-tab `oauth_target_reachable` check warns when this
|
||||
> trap is wired up.
|
||||
|
||||
## End-to-end token lifecycle
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ Speakers expect HTTPS on the default port 443. Since binding to port 443 require
|
||||
|
||||
The first rule covers traffic arriving from speakers; the second covers loopback connections from the host itself (useful for the in-built pre-flight probe).
|
||||
|
||||
> **Caveat — OUTPUT chain.** The second rule catches **all** outbound `:443` traffic from this host, including the AfterTouch host's own connections to the wider internet (browsers, `go install` against `proxy.golang.org`, `apt-get`, `git clone https://...`, etc.). Speakers reaching AfterTouch from the LAN only ever pass through `PREROUTING`. If you don't run the in-built pre-flight probe from this host, or if you've seen other software break with TLS errors after adding both rules, add only the `PREROUTING` rule and skip `OUTPUT`. The pre-flight's "localhost:443" probe will then report unreachable — that's expected and harmless.
|
||||
|
||||
2. **Capabilities**: Grant the binary permission to bind low ports and start the listener directly on `:443`:
|
||||
|
||||
```bash
|
||||
@@ -82,7 +84,20 @@ The same check runs once at service startup and prints a `[WARN]` log line if `:
|
||||
|
||||
The `:443` indicator is only displayed when **AfterTouch's DNS interception is enabled** (Settings → "Enable DNS Discovery Server"). The check is only meaningful for the **DNS migration method**, where speakers reach AfterTouch via intercepted Bose hostnames and therefore on the implicit `:443`. The other migration method — writing direct `https://<host>:8443/...` URLs into the speaker's private config via SSH — uses the port that's literally in the URL, so `:443` is irrelevant and the check would only add noise.
|
||||
|
||||
If you intercept Bose hostnames **outside** AfterTouch (Pi-hole, router DNS rule, `/etc/hosts` on a gateway), the UI gate above will hide the indicator. The data is still in the `GET /setup/settings` JSON response (`https_443_localhost_reachable`, `https_443_lan_reachable`, `https_443_lan_host`) if you want to inspect it directly, or you can briefly enable AfterTouch's DNS server to see the indicator render.
|
||||
#### Not applicable in HTTP-only deployments
|
||||
|
||||
When AfterTouch's configured `--server-url` is `http://…`, the pre-flight short-circuits to an `ℹ️ :443 reachability check not applicable` info line. Speakers that were migrated to that HTTP URL never connect to `:443`, so the iptables / setcap / reverse-proxy work is only needed if you also expect unmigrated speakers to fall back to `streaming.bose.com:443` via DNS hijack. If that's not your situation, the iptables rules above are optional.
|
||||
|
||||
#### Adding extra hosts to the TLS certificate
|
||||
|
||||
If speakers reach AfterTouch via a hostname or IP that isn't already covered by the served certificate, the speaker rejects the TLS handshake (typical syslog: `CURLE_SSL_CACERT (60)`). Two paths to fix this:
|
||||
|
||||
* **One-click QuickFix on the Health tab.** The `speaker_marge_url` check detects the mismatch and offers an `Add <host> to TLS hosts` button. Clicking it appends the missing host to `settings.json` (`tls_extra_hosts`). A subsequent service restart regenerates the certificate.
|
||||
* **Settings tab → "TLS extra hosts" textarea.** Add one host per line and click Save. Same persistence path; restart required to apply. The textarea is pre-filled with the persisted list; the read-only "Currently covered by TLS cert" line below it shows the full effective SAN list (including the values from `--server-url`, `--https-server-url`, the system hostname, and any `--tls-extra-host` / `TLS_EXTRA_HOST` CLI/env entries).
|
||||
|
||||
CLI/env values still win over persisted ones, so an operator who pinned a host via systemd unit doesn't have to migrate it into `settings.json` — the merge in `applyPersistedSettings` deduplicates while preserving order.
|
||||
|
||||
If you intercept Bose hostnames **outside** AfterTouch (Pi-hole, router DNS rule, `/etc/hosts` on a gateway), the UI gate above will hide the indicator. The data is still in the `GET /setup/settings` JSON response (`https_443_localhost_reachable`, `https_443_lan_reachable`, `https_443_lan_host`, `https_443_not_applicable`, `https_443_reason`) if you want to inspect it directly, or you can briefly enable AfterTouch's DNS server to see the indicator render.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 517 KiB After Width: | Height: | Size: 515 KiB |
|
Before Width: | Height: | Size: 359 KiB After Width: | Height: | Size: 432 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 94 KiB |
@@ -2,7 +2,7 @@ module navigation-station-demo
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.78.0
|
||||
require github.com/gesellix/bose-soundtouch v0.89.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ module preset-management-example
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.78.0
|
||||
require github.com/gesellix/bose-soundtouch v0.89.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ module github.com/gesellix/bose-soundtouch
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
filippo.io/age v1.3.1
|
||||
github.com/chromedp/chromedp v0.15.1
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/google/gopacket v1.1.19
|
||||
@@ -14,12 +15,14 @@ require (
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/net v0.54.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/term v0.43.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
filippo.io/hpke v0.4.0 // indirect
|
||||
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
@@ -28,10 +31,10 @@ require (
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
golang.org/x/image v0.40.0 // indirect
|
||||
golang.org/x/image v0.41.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
|
||||
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
|
||||
filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0=
|
||||
filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
|
||||
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
|
||||
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b h1:fpvdcCAe2z3H8OvVY00iKOp3Wapbs/Gy375Fn6l/XM4=
|
||||
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
|
||||
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
|
||||
@@ -62,10 +70,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
@@ -87,8 +95,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -112,8 +120,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBX6szVJwwBCTTCdLiqkfJiwjEFOx/HwdQgsf/aPHsUN aftertouch-diagnostic@gesellix
|
||||
@@ -8,7 +8,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"htm": "3.1.1",
|
||||
"preact": "10.26.1"
|
||||
"preact": "10.29.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
@@ -21,9 +21,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.26.1",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.26.1.tgz",
|
||||
"integrity": "sha512-K5aMG0NdGHZ8yV1GfGtGA4JwnWxe/HIDzyr9svdo2DeokLUJ/+W8MpeuPrfOytu5rHHgYQrvGxUoW83sapJZnw==",
|
||||
"version": "10.29.2",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz",
|
||||
"integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"htm": "3.1.1",
|
||||
"preact": "10.26.1"
|
||||
"preact": "10.29.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// Package discovery provides device discovery functionality for Bose SoundTouch devices using mDNS and UPnP protocols.
|
||||
package discovery
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// SSDP multicast address and port
|
||||
@@ -10,7 +13,9 @@ const (
|
||||
// SoundTouch device URN for UPnP discovery
|
||||
soundTouchURN = "urn:schemas-upnp-org:device:MediaRenderer:1"
|
||||
|
||||
// mDNS service type for SoundTouch devices (matches Bose's actual service name)
|
||||
// mDNS service type for SoundTouch devices (matches Bose's actual service name).
|
||||
// Retained as the canonical / primary service type for log lines and tests;
|
||||
// the full set of accepted variants lives in soundTouchServiceTypes below.
|
||||
soundTouchServiceType = "_soundtouch._tcp"
|
||||
soundTouchDomain = "local."
|
||||
|
||||
@@ -20,3 +25,34 @@ const (
|
||||
// Default cache TTL
|
||||
defaultCacheTTL = 30 * time.Second
|
||||
)
|
||||
|
||||
// soundTouchServiceTypes lists every mDNS service-type variant we consider
|
||||
// part of the SoundTouch family. mDNS doesn't support wildcard service-type
|
||||
// queries at the protocol level, so the discovery code issues one parallel
|
||||
// query per entry below and merges the results. Add new variants here as
|
||||
// they're observed in the wild — Bose has historically advertised at
|
||||
// least three:
|
||||
//
|
||||
// - _soundtouch._tcp : classic SoundTouch speakers (ST10/20/30, …)
|
||||
// - _bose-soundtouch._tcp : seen on some newer firmware variants
|
||||
// - _soundtouchstick._tcp : SoundTouch Wireless Adapter / dongle
|
||||
var soundTouchServiceTypes = []string{
|
||||
"_soundtouch._tcp",
|
||||
"_bose-soundtouch._tcp",
|
||||
"_soundtouchstick._tcp",
|
||||
}
|
||||
|
||||
// isSoundTouchServiceName reports whether the mDNS service entry name
|
||||
// belongs to any registered SoundTouch service type. Case-insensitive
|
||||
// substring match — Bose's mDNS entries embed the service type after a
|
||||
// dot (e.g. "Speaker._soundtouch._tcp.local.").
|
||||
func isSoundTouchServiceName(name string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
for _, t := range soundTouchServiceTypes {
|
||||
if strings.Contains(lower, strings.ToLower(t)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -18,6 +19,15 @@ type DNSDiscovery struct {
|
||||
upstreamDNS []string
|
||||
serviceIP string
|
||||
|
||||
// derivedHosts is the auto-derived list of additional hostnames the
|
||||
// interceptor should hijack alongside the Bose cloud list. Populated
|
||||
// from the operator's configured serverURL at construction time —
|
||||
// today this means `<first-label>oauth.<rest>`, the hostname the
|
||||
// speaker firmware constructs for the Spotify / Amazon Music OAuth
|
||||
// flow. Empty when serverURL is IP-based, missing, or has no domain
|
||||
// part to derive from.
|
||||
derivedHosts []string
|
||||
|
||||
// State
|
||||
discovered map[string]*DiscoveredHost
|
||||
mu sync.RWMutex
|
||||
@@ -51,15 +61,75 @@ type DiscoveredHost struct {
|
||||
RemoteAddr string `json:"remote_addr,omitempty"`
|
||||
}
|
||||
|
||||
// NewDNSDiscovery creates a new DNSDiscovery instance.
|
||||
func NewDNSDiscovery(upstreamDNS []string, serviceIP string) *DNSDiscovery {
|
||||
return &DNSDiscovery{
|
||||
upstreamDNS: upstreamDNS,
|
||||
serviceIP: serviceIP,
|
||||
discovered: make(map[string]*DiscoveredHost),
|
||||
timeout: 2 * time.Second,
|
||||
lastLog: make(map[string]time.Time),
|
||||
// NewDNSDiscovery creates a new DNSDiscovery instance. serverURL is the
|
||||
// operator's configured streaming endpoint; its hostname is used to
|
||||
// derive the OAuth-subdomain alias the speaker constructs (see
|
||||
// DeriveOAuthHostnames). Pass an empty string when no serverURL is
|
||||
// available (the derivation is a no-op in that case).
|
||||
func NewDNSDiscovery(upstreamDNS []string, serviceIP, serverURL string) *DNSDiscovery {
|
||||
derived := DeriveOAuthHostnames(serverURL)
|
||||
if len(derived) > 0 {
|
||||
log.Printf("[DNS] Auto-hijacking OAuth subdomains derived from serverURL %q: %s", serverURL, strings.Join(derived, ", "))
|
||||
}
|
||||
|
||||
return &DNSDiscovery{
|
||||
upstreamDNS: upstreamDNS,
|
||||
serviceIP: serviceIP,
|
||||
derivedHosts: derived,
|
||||
discovered: make(map[string]*DiscoveredHost),
|
||||
timeout: 2 * time.Second,
|
||||
lastLog: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// DeriveOAuthHostnames returns the list of additional hostnames the DNS
|
||||
// interceptor should hijack to support Spotify / Amazon Music OAuth on a
|
||||
// non-Bose target. SoundTouch firmware constructs the OAuth endpoint by
|
||||
// appending `oauth` to the first label of the configured streaming
|
||||
// hostname (e.g. `aftertouch.lan` → `aftertouchoauth.lan`). When the
|
||||
// target is an IP address the derivation produces a malformed hostname
|
||||
// no resolver will answer for, so we deliberately return an empty
|
||||
// slice — the caller's behaviour stays unchanged, but the operator
|
||||
// (and the health-tab check) can detect the misconfiguration via the
|
||||
// missing entry.
|
||||
//
|
||||
// Returned hostnames are lower-cased. An empty serverURL, a URL that
|
||||
// fails to parse, or a hostname without a domain part (single-label
|
||||
// "aftertouch") all yield an empty slice.
|
||||
func DeriveOAuthHostnames(serverURL string) []string {
|
||||
if serverURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
host := strings.ToLower(u.Hostname())
|
||||
if host == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if net.ParseIP(host) != nil {
|
||||
// IP-based deployment — the speaker's `<first-label>oauth.<rest>`
|
||||
// construction is meaningless (e.g. `192oauth.168.0.30`) and no
|
||||
// DNS server can resolve it. Operators in this situation need to
|
||||
// switch to a real LAN hostname; see docs/concepts/amazon-music-oauth.md.
|
||||
return nil
|
||||
}
|
||||
|
||||
idx := strings.IndexByte(host, '.')
|
||||
if idx <= 0 {
|
||||
// Single-label hostname (e.g. "aftertouch") — no domain part to
|
||||
// append after the inserted "oauth". The speaker firmware does
|
||||
// the same: it appends "oauth" inside the first label, so a
|
||||
// single-label name would produce "aftertouchoauth", which most
|
||||
// DNS resolvers won't answer for either.
|
||||
return nil
|
||||
}
|
||||
|
||||
return []string{host[:idx] + "oauth" + host[idx:]}
|
||||
}
|
||||
|
||||
// ServeDNS implements the dns.Handler interface.
|
||||
@@ -153,29 +223,39 @@ func (d *DNSDiscovery) recordQuery(hostname string, isIntercepted bool, remoteAd
|
||||
}
|
||||
}
|
||||
|
||||
// InterceptedBoseHosts is the canonical list of Bose cloud service
|
||||
// hostnames the DNS server hijacks. Exposed so other packages
|
||||
// (e.g. the Health tab's DNS sanity check) can iterate the list
|
||||
// without duplicating it.
|
||||
var InterceptedBoseHosts = []string{
|
||||
"api.bose.com",
|
||||
"marge.bose.com",
|
||||
"bmx.bose.com",
|
||||
"streaming.bose.com",
|
||||
"streamingoauth.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"content.api.bose.io",
|
||||
"events.api.bosecm.com",
|
||||
"bose-prod.apigee.net",
|
||||
"bose-test.apigee.net",
|
||||
"worldwide.bose.com",
|
||||
"music.api.bose.com",
|
||||
"bosecm.com",
|
||||
"bose.io",
|
||||
"downloads.bose.com",
|
||||
}
|
||||
|
||||
func (d *DNSDiscovery) shouldIntercept(hostname string) bool {
|
||||
// Intercept known Bose cloud services
|
||||
interceptList := []string{
|
||||
"api.bose.com",
|
||||
"marge.bose.com",
|
||||
"bmx.bose.com",
|
||||
"streaming.bose.com",
|
||||
"streamingoauth.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"content.api.bose.io",
|
||||
"events.api.bosecm.com",
|
||||
"bose-prod.apigee.net",
|
||||
"bose-test.apigee.net",
|
||||
"worldwide.bose.com",
|
||||
"music.api.bose.com",
|
||||
"bosecm.com",
|
||||
"bose.io",
|
||||
"downloads.bose.com",
|
||||
for _, service := range InterceptedBoseHosts {
|
||||
if strings.Contains(hostname, service) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, service := range interceptList {
|
||||
if strings.Contains(hostname, service) {
|
||||
lower := strings.ToLower(hostname)
|
||||
for _, h := range d.derivedHosts {
|
||||
if lower == h {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeriveOAuthHostnames(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
serverURL string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "hostname with single domain part",
|
||||
serverURL: "https://aftertouch.lan:8443",
|
||||
want: []string{"aftertouchoauth.lan"},
|
||||
},
|
||||
{
|
||||
name: "hostname with multiple domain parts",
|
||||
serverURL: "https://aftertouch.example.local:8443",
|
||||
want: []string{"aftertouchoauth.example.local"},
|
||||
},
|
||||
{
|
||||
name: "HTTP scheme also works",
|
||||
serverURL: "http://aftertouch.lan:8000",
|
||||
want: []string{"aftertouchoauth.lan"},
|
||||
},
|
||||
{
|
||||
name: "Case is normalised to lower",
|
||||
serverURL: "https://AfterTouch.LAN:8443",
|
||||
want: []string{"aftertouchoauth.lan"},
|
||||
},
|
||||
{
|
||||
name: "IPv4 yields no derivation (malformed result)",
|
||||
serverURL: "https://192.168.0.30:8443",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "IPv6 yields no derivation",
|
||||
serverURL: "https://[fd00::1]:8443",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "Single-label hostname yields no derivation",
|
||||
serverURL: "https://aftertouch:8443",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "Empty serverURL is a no-op",
|
||||
serverURL: "",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "Garbage URL is a no-op",
|
||||
serverURL: ":::not a url",
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := DeriveOAuthHostnames(tc.serverURL)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("len mismatch: got %v, want %v", got, tc.want)
|
||||
}
|
||||
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("index %d: got %q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldIntercept_DerivedHostnameFromHostnameServerURL(t *testing.T) {
|
||||
d := NewDNSDiscovery([]string{"8.8.8.8"}, "192.0.2.10", "https://aftertouch.lan:8443")
|
||||
|
||||
// Bose hostnames still match by substring.
|
||||
if !d.shouldIntercept("streamingoauth.bose.com") {
|
||||
t.Errorf("expected Bose oauth host to be intercepted")
|
||||
}
|
||||
|
||||
// Derived host matches exactly (case-insensitive).
|
||||
if !d.shouldIntercept("aftertouchoauth.lan") {
|
||||
t.Errorf("expected derived OAuth subdomain to be intercepted")
|
||||
}
|
||||
|
||||
if !d.shouldIntercept("AFTERTOUCHOAUTH.LAN") {
|
||||
t.Errorf("expected case-insensitive match on derived OAuth subdomain")
|
||||
}
|
||||
|
||||
// Unrelated hosts are not hijacked.
|
||||
if d.shouldIntercept("example.com") {
|
||||
t.Errorf("unrelated host must not be intercepted")
|
||||
}
|
||||
|
||||
// The base host (without -oauth) is NOT auto-hijacked — only the
|
||||
// OAuth-derivation. Bose-substring filter and the operator's own
|
||||
// migration handle the base host.
|
||||
if d.shouldIntercept("aftertouch.lan") {
|
||||
t.Errorf("base hostname must not be auto-intercepted; only the OAuth variant is derived")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldIntercept_NoDerivationFromIPServerURL(t *testing.T) {
|
||||
d := NewDNSDiscovery([]string{"8.8.8.8"}, "192.168.0.30", "https://192.168.0.30:8443")
|
||||
|
||||
if len(d.derivedHosts) != 0 {
|
||||
t.Errorf("expected no derived hosts for IP-based serverURL, got %v", d.derivedHosts)
|
||||
}
|
||||
|
||||
if d.shouldIntercept("192oauth.168.0.30") {
|
||||
t.Errorf("malformed IP-derived OAuth name must not be intercepted (it's never a valid DNS query in the first place)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDNSDiscovery_LogsDerivationOnce(t *testing.T) {
|
||||
// This is a smoke test — the constructor should not panic and should
|
||||
// store the derivation. We don't capture the log output here (the
|
||||
// dns.go init path uses package log.Printf and isn't easily diverted
|
||||
// without test infrastructure), but we do confirm the derivedHosts
|
||||
// field is populated as expected.
|
||||
d := NewDNSDiscovery(nil, "192.0.2.10", "https://aftertouch.lan")
|
||||
|
||||
if len(d.derivedHosts) != 1 || !strings.Contains(d.derivedHosts[0], "oauth") {
|
||||
t.Errorf("expected derivedHosts to carry the OAuth variant, got %v", d.derivedHosts)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
func TestDNSDiscovery_Interception(t *testing.T) {
|
||||
serviceIP := "192.0.2.100"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
|
||||
|
||||
// Test intercepting Bose service
|
||||
m := new(dns.Msg)
|
||||
@@ -67,7 +67,7 @@ func TestDNSDiscovery_Forwarding(t *testing.T) {
|
||||
// For now, let's just test that it calls forward and record.
|
||||
serviceIP := "192.0.2.100"
|
||||
upstreamDNS := []string{"127.0.0.1:5353"} // Use a port that is likely closed or we can mock
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("google.com.", dns.TypeA)
|
||||
@@ -108,7 +108,7 @@ func TestDNSDiscovery_Forwarding(t *testing.T) {
|
||||
func TestDNSDiscovery_StartTCP(t *testing.T) {
|
||||
serviceIP := "192.0.2.100"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
|
||||
|
||||
addr := "127.0.0.1:5354"
|
||||
go func() {
|
||||
@@ -157,7 +157,7 @@ func TestDNSDiscovery_StartTCP(t *testing.T) {
|
||||
func TestDNSDiscovery_SelfForwarding(t *testing.T) {
|
||||
serviceIP := "soundtouch.local"
|
||||
upstreamDNS := []string{"127.0.0.1:5357"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
|
||||
|
||||
// Mock upstream DNS server for soundtouch.local
|
||||
mux := dns.NewServeMux()
|
||||
@@ -216,7 +216,7 @@ func TestDNSDiscovery_SelfForwarding(t *testing.T) {
|
||||
func TestDNSDiscovery_ForwardLocal(t *testing.T) {
|
||||
serviceIP := "192.0.2.100"
|
||||
upstreamDNS := []string{"127.0.0.1:5356"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("someone-else.local.", dns.TypeA)
|
||||
@@ -257,7 +257,7 @@ func TestDNSDiscovery_ForwardLocal(t *testing.T) {
|
||||
func TestDNSDiscovery_IsRunning(t *testing.T) {
|
||||
serviceIP := "192.0.2.100"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
|
||||
|
||||
addr := "127.0.0.1:5355"
|
||||
|
||||
@@ -301,7 +301,7 @@ func (m *mockResponseWriter) TsigTimersOnly(bool) {}
|
||||
func (m *mockResponseWriter) Hijack() {}
|
||||
|
||||
func TestDNSDiscovery_LogThrottling(t *testing.T) {
|
||||
d := NewDNSDiscovery([]string{"8.8.8.8"}, "192.0.2.100")
|
||||
d := NewDNSDiscovery([]string{"8.8.8.8"}, "192.0.2.100", "")
|
||||
|
||||
// Capture log output
|
||||
var logBuf strings.Builder
|
||||
@@ -335,7 +335,7 @@ func TestDNSDiscovery_LoopPrevention(t *testing.T) {
|
||||
serviceIP := "192.0.2.100"
|
||||
bindAddr := "127.0.0.1:53"
|
||||
upstreamDNS := []string{"127.0.0.1:53"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
|
||||
d.bindAddr = bindAddr
|
||||
|
||||
// Capture log output to avoid panic if it's being throttled/logged
|
||||
@@ -362,7 +362,7 @@ func TestDNSDiscovery_LoopPrevention(t *testing.T) {
|
||||
func TestDNSDiscovery_EmptyUpstream(t *testing.T) {
|
||||
serviceIP := "192.0.2.100"
|
||||
var upstreamDNS []string // Empty upstream
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
|
||||
d.bindAddr = ":53"
|
||||
|
||||
m := new(dns.Msg)
|
||||
@@ -402,7 +402,7 @@ func TestDNSDiscovery_ForwardTimeout(t *testing.T) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
upstreamDNS := []string{"127.0.0.1:5358"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
|
||||
d.timeout = 100 * time.Millisecond
|
||||
|
||||
m := new(dns.Msg)
|
||||
@@ -458,7 +458,7 @@ func TestDNSDiscovery_MultipleUpstreams(t *testing.T) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
upstreamDNS := []string{"127.0.0.1:5356", "127.0.0.1:5357"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("test.com.", dns.TypeA)
|
||||
@@ -484,7 +484,7 @@ func TestDNSDiscovery_HostnameServiceIP(t *testing.T) {
|
||||
// Use localhost which should resolve to 127.0.0.1
|
||||
serviceIP := "localhost"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("api.bose.com.", dns.TypeA)
|
||||
@@ -520,7 +520,7 @@ func TestDNSDiscovery_UnresolvableHostname(t *testing.T) {
|
||||
// Use a likely unresolvable hostname
|
||||
serviceIP := "this.hostname.does.not.exist.at.all.invalid"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("api.bose.com.", dns.TypeA)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// verboseLogging toggles the per-packet / per-header diagnostic output
|
||||
// that was historically emitted unconditionally during UPnP and mDNS
|
||||
// discovery. The service binary leaves it at its zero value (off) so
|
||||
// the log stays useful at info-level; the CLI's `discover` command
|
||||
// flips it on so interactive runs surface full protocol details.
|
||||
//
|
||||
// Stored as an int32 so the read path in logVerbose is allocation-
|
||||
// free (atomic.Bool would work too on Go 1.19+, but a uint8 lookup
|
||||
// keeps the toggle hot-path even on older toolchains we still build
|
||||
// against in CI).
|
||||
var verboseLogging atomic.Bool
|
||||
|
||||
// SetVerbose enables (or disables) the package-wide verbose-discovery
|
||||
// log toggle. Safe to call from any goroutine.
|
||||
func SetVerbose(v bool) {
|
||||
verboseLogging.Store(v)
|
||||
}
|
||||
|
||||
// IsVerbose reports the current value of the verbose toggle. Mainly
|
||||
// for tests that want to assert the CLI flipped it on.
|
||||
func IsVerbose() bool {
|
||||
return verboseLogging.Load()
|
||||
}
|
||||
|
||||
// logVerbose forwards to log.Printf only when verbose-discovery
|
||||
// logging is enabled. The fast path (verbose off) is a single
|
||||
// atomic load + branch, so it's safe to scatter calls liberally
|
||||
// across the hot path.
|
||||
func logVerbose(format string, args ...any) {
|
||||
if verboseLogging.Load() {
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// captureLog redirects log output into a buffer and returns the buffer
|
||||
// plus a cleanup func that restores the original log destination. Used
|
||||
// by the tests below to assert what logVerbose / SetVerbose actually
|
||||
// produces under each toggle state.
|
||||
func captureLog(t *testing.T) (*bytes.Buffer, func()) {
|
||||
t.Helper()
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
original := log.Writer()
|
||||
log.SetOutput(buf)
|
||||
|
||||
return buf, func() {
|
||||
log.SetOutput(original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerboseToggle_DefaultIsQuiet(t *testing.T) {
|
||||
// Reset to default state (zero value of atomic.Bool is false).
|
||||
SetVerbose(false)
|
||||
t.Cleanup(func() { SetVerbose(false) })
|
||||
|
||||
buf, restore := captureLog(t)
|
||||
defer restore()
|
||||
|
||||
logVerbose("noisy: should-be-suppressed message")
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no log output when verbose is off, got: %q", buf.String())
|
||||
}
|
||||
|
||||
if IsVerbose() {
|
||||
t.Errorf("IsVerbose() = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerboseToggle_OnEmitsToLog(t *testing.T) {
|
||||
SetVerbose(true)
|
||||
t.Cleanup(func() { SetVerbose(false) })
|
||||
|
||||
buf, restore := captureLog(t)
|
||||
defer restore()
|
||||
|
||||
logVerbose("trace: %s = %d", "answer", 42)
|
||||
|
||||
if !strings.Contains(buf.String(), "trace: answer = 42") {
|
||||
t.Errorf("expected trace output, got: %q", buf.String())
|
||||
}
|
||||
|
||||
if !IsVerbose() {
|
||||
t.Errorf("IsVerbose() = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerboseToggle_StaysOffByDefaultAfterPackageInit(t *testing.T) {
|
||||
// New goroutines / new processes see the zero-value default. This
|
||||
// codifies that contract for callers like cmd/soundtouch-service
|
||||
// that rely on never having to call SetVerbose.
|
||||
t.Cleanup(func() { SetVerbose(false) })
|
||||
SetVerbose(false)
|
||||
|
||||
if IsVerbose() {
|
||||
t.Errorf("default verbose state must be false")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
@@ -49,49 +50,37 @@ func (m *MDNSDiscoveryService) DiscoverDevices(ctx context.Context) ([]*models.D
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, m.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Start mDNS query in a goroutine
|
||||
// Fan out one query per SoundTouch service-type variant; mDNS has no
|
||||
// wildcard service-type query, so we issue them in parallel and merge
|
||||
// into a single entries channel. close(entries) only once all queries
|
||||
// are done (or the timeout fires).
|
||||
go func() {
|
||||
defer close(entries)
|
||||
|
||||
log.Printf("mDNS: Starting discovery for service '%s.%s' with timeout %v",
|
||||
soundTouchServiceType, soundTouchDomain, m.timeout)
|
||||
log.Printf("mDNS: Starting discovery for %d service-type variant(s) with timeout %v",
|
||||
len(soundTouchServiceTypes), m.timeout)
|
||||
|
||||
// IPv4-only query to fix "no route to host" errors on IPv6
|
||||
// This addresses the issue where hashicorp/mdns fails with:
|
||||
// "write udp6 [::]:port->[ff02::fb]:5353: sendto: no route to host"
|
||||
// The trailing dot in service names is handled correctly by separating
|
||||
// service and domain parameters as expected by the library.
|
||||
err := mdns.Query(&mdns.QueryParam{
|
||||
Service: "_soundtouch._tcp",
|
||||
Domain: "local.",
|
||||
Timeout: m.timeout,
|
||||
Entries: entries,
|
||||
DisableIPv6: true, // Force IPv4 only to avoid routing issues
|
||||
Interface: m.getIPv4Interface(), // Use specific interface if available
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("mDNS IPv4 query failed: %v", err)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Fallback to standard query (both IPv4 and IPv6)
|
||||
log.Printf("mDNS: Falling back to standard query...")
|
||||
for _, service := range soundTouchServiceTypes {
|
||||
wg.Add(1)
|
||||
|
||||
err = mdns.Query(&mdns.QueryParam{
|
||||
Service: "_soundtouch._tcp",
|
||||
Domain: "local.",
|
||||
Timeout: m.timeout,
|
||||
Entries: entries,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("mDNS query completed with error: %v", err)
|
||||
} else {
|
||||
log.Printf("mDNS query completed successfully")
|
||||
}
|
||||
} else {
|
||||
log.Printf("mDNS IPv4 query completed successfully")
|
||||
go func(service string) {
|
||||
defer wg.Done()
|
||||
|
||||
m.queryService(service, entries)
|
||||
}(service)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
logVerbose("mDNS: All %d service-type queries finished", len(soundTouchServiceTypes))
|
||||
}()
|
||||
|
||||
// Collect discovered devices
|
||||
// Collect discovered devices, deduplicating by host:port since a single
|
||||
// speaker may answer multiple service types (older firmware advertises
|
||||
// both `_soundtouch._tcp` and `_bose-soundtouch._tcp` simultaneously).
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timeoutCtx.Done():
|
||||
@@ -104,30 +93,75 @@ func (m *MDNSDiscoveryService) DiscoverDevices(ctx context.Context) ([]*models.D
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
log.Printf("mDNS: Received service entry: Name='%s', Host='%s', Port=%d, AddrV4=%v, AddrV6=%v",
|
||||
logVerbose("mDNS: Received service entry: Name='%s', Host='%s', Port=%d, AddrV4=%v, AddrV6=%v",
|
||||
entry.Name, entry.Host, entry.Port, entry.AddrV4, entry.AddrV6)
|
||||
|
||||
// Only process SoundTouch devices
|
||||
if !strings.Contains(entry.Name, "_soundtouch._tcp") {
|
||||
log.Printf("mDNS: Skipping non-SoundTouch service: %s", entry.Name)
|
||||
// Only process SoundTouch-family services.
|
||||
if !isSoundTouchServiceName(entry.Name) {
|
||||
logVerbose("mDNS: Skipping non-SoundTouch service: %s", entry.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
device := m.serviceEntryToDevice(entry)
|
||||
if device != nil {
|
||||
log.Printf("mDNS: Successfully converted to device: %s at %s:%d", device.Name, device.Host, device.Port)
|
||||
devices = append(devices, device)
|
||||
} else {
|
||||
if device == nil {
|
||||
log.Printf("mDNS: Failed to convert service entry to device (no valid IP address)")
|
||||
continue
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%s:%d", device.Host, device.Port)
|
||||
if seen[key] {
|
||||
logVerbose("mDNS: Skipping duplicate device %s (already seen via another service-type query)", key)
|
||||
continue
|
||||
}
|
||||
|
||||
seen[key] = true
|
||||
|
||||
logVerbose("mDNS: Successfully converted to device: %s at %s:%d", device.Name, device.Host, device.Port)
|
||||
devices = append(devices, device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// queryService issues a single mDNS Query for the given service type
|
||||
// against the IPv4 interface first, with a graceful fallback to the
|
||||
// library's default (IPv4+IPv6) behaviour if the IPv4-only path fails.
|
||||
// All results stream into the shared entries channel; the caller is
|
||||
// responsible for fan-in deduplication.
|
||||
func (m *MDNSDiscoveryService) queryService(service string, entries chan<- *mdns.ServiceEntry) {
|
||||
logVerbose("mDNS: Query '%s.%s' starting", service, soundTouchDomain)
|
||||
|
||||
err := mdns.Query(&mdns.QueryParam{
|
||||
Service: service,
|
||||
Domain: "local.",
|
||||
Timeout: m.timeout,
|
||||
Entries: entries,
|
||||
DisableIPv6: true,
|
||||
Interface: m.getIPv4Interface(),
|
||||
})
|
||||
if err == nil {
|
||||
logVerbose("mDNS: Query '%s' (IPv4) completed successfully", service)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("mDNS: Query '%s' (IPv4) failed: %v — falling back to dual-stack", service, err)
|
||||
|
||||
err = mdns.Query(&mdns.QueryParam{
|
||||
Service: service,
|
||||
Domain: "local.",
|
||||
Timeout: m.timeout,
|
||||
Entries: entries,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("mDNS: Query '%s' (dual-stack) failed: %v", service, err)
|
||||
} else {
|
||||
logVerbose("mDNS: Query '%s' (dual-stack) completed successfully", service)
|
||||
}
|
||||
}
|
||||
|
||||
// serviceEntryToDevice converts an mdns ServiceEntry to a DiscoveredDevice
|
||||
func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *models.DiscoveredDevice {
|
||||
if entry == nil {
|
||||
log.Printf("mDNS: Received nil service entry")
|
||||
logVerbose("mDNS: Received nil service entry")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -142,15 +176,15 @@ func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *m
|
||||
host = entry.AddrV4.String()
|
||||
ipSource = "IPv4"
|
||||
|
||||
log.Printf("mDNS: Using IPv4 address: %s", host)
|
||||
logVerbose("mDNS: Using IPv4 address: %s", host)
|
||||
case entry.AddrV6 != nil:
|
||||
host = entry.AddrV6.String()
|
||||
ipSource = "IPv6"
|
||||
|
||||
log.Printf("mDNS: Using IPv6 address: %s", host)
|
||||
logVerbose("mDNS: Using IPv6 address: %s", host)
|
||||
default:
|
||||
// Try to resolve from hostname
|
||||
log.Printf("mDNS: No direct IP address, trying to resolve hostname: %s", entry.Host)
|
||||
logVerbose("mDNS: No direct IP address, trying to resolve hostname: %s", entry.Host)
|
||||
|
||||
ips, err := net.LookupIP(entry.Host)
|
||||
if err != nil || len(ips) == 0 {
|
||||
@@ -164,7 +198,7 @@ func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *m
|
||||
host = ip.String()
|
||||
ipSource = "resolved IPv4"
|
||||
|
||||
log.Printf("mDNS: Resolved to IPv4 address: %s", host)
|
||||
logVerbose("mDNS: Resolved to IPv4 address: %s", host)
|
||||
|
||||
break
|
||||
}
|
||||
@@ -179,7 +213,7 @@ func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *m
|
||||
ipSource = "resolved IPv6 (fallback)"
|
||||
}
|
||||
|
||||
log.Printf("mDNS: Using fallback address (%s): %s", ipSource, host)
|
||||
logVerbose("mDNS: Using fallback address (%s): %s", ipSource, host)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +256,7 @@ func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *m
|
||||
MDNSService: entry.Name,
|
||||
}
|
||||
|
||||
log.Printf("mDNS: Created device '%s' at %s:%d (IP source: %s)", name, host, port, ipSource)
|
||||
logVerbose("mDNS: Created device '%s' at %s:%d (IP source: %s)", name, host, port, ipSource)
|
||||
|
||||
return device
|
||||
}
|
||||
@@ -243,7 +277,7 @@ func (m *MDNSDiscoveryService) getIPv4Interface() *net.Interface {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("mDNS: Using configured IPv4 interface: %s", iface.Name)
|
||||
logVerbose("mDNS: Using configured IPv4 interface: %s", iface.Name)
|
||||
|
||||
return iface
|
||||
}
|
||||
@@ -266,12 +300,12 @@ func (m *MDNSDiscoveryService) getIPv4Interface() *net.Interface {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("mDNS: Using IPv4 interface: %s", iface.Name)
|
||||
logVerbose("mDNS: Using IPv4 interface: %s", iface.Name)
|
||||
|
||||
return &iface
|
||||
}
|
||||
|
||||
log.Printf("mDNS: No suitable IPv4 interface found")
|
||||
logVerbose("mDNS: No suitable IPv4 interface found")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ func (d *Service) setupUDPListener() (*net.UDPConn, error) {
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("UPnP: Created UDP listener on %s", localAddr.String())
|
||||
logVerbose("UPnP: Created UDP listener on %s", localAddr.String())
|
||||
|
||||
return listener, nil
|
||||
}
|
||||
@@ -274,7 +274,7 @@ func (d *Service) resolveListenInterface() (net.IP, *net.Interface, error) {
|
||||
|
||||
func (d *Service) sendMSearch(listener *net.UDPConn, multicastAddr *net.UDPAddr) error {
|
||||
msearchRequest := d.buildMSearchRequest()
|
||||
log.Printf("UPnP: Sending M-SEARCH request to %s:\n%s", ssdpAddr, strings.TrimSpace(msearchRequest))
|
||||
logVerbose("UPnP: Sending M-SEARCH request to %s:\n%s", ssdpAddr, strings.TrimSpace(msearchRequest))
|
||||
|
||||
bytesWritten, err := listener.WriteToUDP([]byte(msearchRequest), multicastAddr)
|
||||
if err != nil {
|
||||
@@ -282,7 +282,7 @@ func (d *Service) sendMSearch(listener *net.UDPConn, multicastAddr *net.UDPAddr)
|
||||
return fmt.Errorf("failed to send M-SEARCH: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("UPnP: Successfully sent M-SEARCH request (%d bytes)", bytesWritten)
|
||||
logVerbose("UPnP: Successfully sent M-SEARCH request (%d bytes)", bytesWritten)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -297,21 +297,21 @@ func (d *Service) listenForResponses(ctx context.Context, listener *net.UDPConn,
|
||||
return 0, fmt.Errorf("failed to set read deadline: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("UPnP: Set read deadline to %v, now listening for responses...", deadline.Format("15:04:05.000"))
|
||||
logVerbose("UPnP: Set read deadline to %v, now listening for responses...", deadline.Format("15:04:05.000"))
|
||||
|
||||
buffer := make([]byte, 4096)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("UPnP: Discovery cancelled by context")
|
||||
logVerbose("UPnP: Discovery cancelled by context")
|
||||
return responseCount, ctx.Err()
|
||||
default:
|
||||
n, remoteAddr, err := listener.ReadFromUDP(buffer)
|
||||
if err != nil {
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
log.Printf("UPnP: Read timeout reached after %v, stopping discovery", d.timeout)
|
||||
logVerbose("UPnP: Read timeout reached after %v, stopping discovery", d.timeout)
|
||||
return responseCount, nil
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ func (d *Service) listenForResponses(ctx context.Context, listener *net.UDPConn,
|
||||
|
||||
responseCount++
|
||||
responseText := string(buffer[:n])
|
||||
log.Printf("UPnP: Received response #%d (%d bytes) from %s:\n%s", responseCount, n, remoteAddr.String(), strings.TrimSpace(responseText))
|
||||
logVerbose("UPnP: Received response #%d (%d bytes) from %s:\n%s", responseCount, n, remoteAddr.String(), strings.TrimSpace(responseText))
|
||||
|
||||
device, err := d.parseResponse(responseText)
|
||||
if err != nil {
|
||||
@@ -331,10 +331,10 @@ func (d *Service) listenForResponses(ctx context.Context, listener *net.UDPConn,
|
||||
}
|
||||
|
||||
if device != nil {
|
||||
log.Printf("UPnP: Successfully parsed device from response #%d: %s at %s:%d", responseCount, device.Name, device.Host, device.Port)
|
||||
logVerbose("UPnP: Successfully parsed device from response #%d: %s at %s:%d", responseCount, device.Name, device.Host, device.Port)
|
||||
devices[device.Host] = device
|
||||
} else {
|
||||
log.Printf("UPnP: Response #%d from %s did not contain a valid SoundTouch device", responseCount, remoteAddr.String())
|
||||
logVerbose("UPnP: Response #%d from %s did not contain a valid SoundTouch device", responseCount, remoteAddr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -368,7 +368,7 @@ func (d *Service) buildMSearchRequest() string {
|
||||
|
||||
// parseResponse parses UPnP SSDP response and extracts device information
|
||||
func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, error) {
|
||||
log.Printf("UPnP: Parsing response (%d chars): %.100s...", len(response), strings.ReplaceAll(response, "\r\n", "\\r\\n"))
|
||||
logVerbose("UPnP: Parsing response (%d chars): %.100s...", len(response), strings.ReplaceAll(response, "\r\n", "\\r\\n"))
|
||||
|
||||
// Try both \r\n and \n line endings
|
||||
var lines []string
|
||||
@@ -384,7 +384,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
|
||||
return nil, fmt.Errorf("invalid HTTP response")
|
||||
}
|
||||
|
||||
log.Printf("UPnP: Valid HTTP response detected")
|
||||
logVerbose("UPnP: Valid HTTP response detected")
|
||||
|
||||
headers := make(map[string]string)
|
||||
|
||||
@@ -402,10 +402,10 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("UPnP: Parsed %d headers from response", len(headers))
|
||||
logVerbose("UPnP: Parsed %d headers from response", len(headers))
|
||||
|
||||
for key, value := range headers {
|
||||
log.Printf("UPnP: Header: %s = %s", key, value)
|
||||
logVerbose("UPnP: Header: %s = %s", key, value)
|
||||
}
|
||||
|
||||
// Check if it's a SoundTouch device
|
||||
@@ -415,7 +415,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
|
||||
return nil, fmt.Errorf("no ST header found")
|
||||
}
|
||||
|
||||
log.Printf("UPnP: Found ST header: %s", st)
|
||||
logVerbose("UPnP: Found ST header: %s", st)
|
||||
|
||||
// Accept both MediaRenderer and any device type for now - we'll validate it's a SoundTouch later
|
||||
if !strings.Contains(strings.ToLower(st), "mediarenderer") && !strings.Contains(strings.ToLower(st), "upnp:rootdevice") {
|
||||
@@ -423,7 +423,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
|
||||
return nil, fmt.Errorf("not a MediaRenderer device")
|
||||
}
|
||||
|
||||
log.Printf("UPnP: Device type '%s' is acceptable", st)
|
||||
logVerbose("UPnP: Device type '%s' is acceptable", st)
|
||||
|
||||
location, exists := headers["location"]
|
||||
if !exists {
|
||||
@@ -431,7 +431,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
|
||||
return nil, fmt.Errorf("no location header found")
|
||||
}
|
||||
|
||||
log.Printf("UPnP: Found Location header: %s", location)
|
||||
logVerbose("UPnP: Found Location header: %s", location)
|
||||
|
||||
// Extract device information from location URL
|
||||
device, err := d.parseLocationURL(location, headers["usn"])
|
||||
@@ -440,23 +440,55 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
|
||||
return nil, fmt.Errorf("failed to parse location URL: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("UPnP: Successfully parsed device from location: %s at %s:%d", device.Name, device.Host, device.Port)
|
||||
logVerbose("UPnP: Successfully parsed device from location: %s at %s:%d", device.Name, device.Host, device.Port)
|
||||
|
||||
// Try to get more device info from the location URL
|
||||
// Try to get more device info from the location URL. Crucially this
|
||||
// also lets us reject non-Bose UPnP MediaRenderers (LG TVs, Onkyo /
|
||||
// Yamaha receivers, Dreambox tuners, etc.) that responded to our
|
||||
// generic `ST: …MediaRenderer:1` M-SEARCH. See issues #269 / #359.
|
||||
if err := d.EnrichDeviceInfo(device, location); err != nil {
|
||||
log.Printf("UPnP: Could not enrich device info from location '%s': %v", location, err)
|
||||
// Don't fail if we can't get additional info
|
||||
// The basic info from URL parsing should be sufficient
|
||||
logVerbose("UPnP: Could not enrich device info from location '%s': %v — accepting tentatively (will be re-verified by /info probe)", location, err)
|
||||
} else if !isBoseUPnPDevice(device) {
|
||||
log.Printf("UPnP: Rejecting non-Bose device: model=%q (manufacturer not Bose / model not SoundTouch)", device.ModelID)
|
||||
return nil, fmt.Errorf("non-Bose UPnP device: %s", device.ModelID)
|
||||
} else {
|
||||
log.Printf("UPnP: Successfully enriched device info for %s", device.Name)
|
||||
logVerbose("UPnP: Successfully enriched device info for %s (model=%q)", device.Name, device.ModelID)
|
||||
}
|
||||
|
||||
return device, nil
|
||||
}
|
||||
|
||||
// isBoseUPnPDevice classifies an enriched UPnP device as Bose vs. not.
|
||||
// Returns true when either the manufacturer string contains "bose" or
|
||||
// the model name carries a SoundTouch-family marker. Case-insensitive.
|
||||
//
|
||||
// This is the discrimination point that keeps non-Bose UPnP
|
||||
// MediaRenderers (LG TVs, Onkyo receivers, Dreambox tuners) from
|
||||
// landing in the `default` account on the service side — they all
|
||||
// reply to our generic MediaRenderer:1 M-SEARCH because that URN is
|
||||
// not Bose-specific.
|
||||
func isBoseUPnPDevice(device *models.DiscoveredDevice) bool {
|
||||
if device == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
manuf := strings.ToLower(device.Manufacturer)
|
||||
model := strings.ToLower(device.ModelID)
|
||||
|
||||
if strings.Contains(manuf, "bose") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.Contains(model, "soundtouch") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// parseLocationURL extracts basic device info from the location URL
|
||||
func (d *Service) parseLocationURL(location, usn string) (*models.DiscoveredDevice, error) {
|
||||
log.Printf("UPnP: Parsing location URL: %s", location)
|
||||
logVerbose("UPnP: Parsing location URL: %s", location)
|
||||
|
||||
// Parse the URL to extract host and port
|
||||
re := regexp.MustCompile(`http://([^:]+):(\d+)`)
|
||||
@@ -469,7 +501,7 @@ func (d *Service) parseLocationURL(location, usn string) (*models.DiscoveredDevi
|
||||
|
||||
host := matches[1]
|
||||
port := 8090 // Default SoundTouch port
|
||||
log.Printf("UPnP: Extracted host='%s', using default port=%d", host, port)
|
||||
logVerbose("UPnP: Extracted host='%s', using default port=%d", host, port)
|
||||
|
||||
device := &models.DiscoveredDevice{
|
||||
Host: host,
|
||||
@@ -488,7 +520,7 @@ func (d *Service) parseLocationURL(location, usn string) (*models.DiscoveredDevi
|
||||
|
||||
// EnrichDeviceInfo tries to get additional device information from the device description
|
||||
func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location string) error {
|
||||
log.Printf("UPnP: Attempting to enrich device info by fetching %s", location)
|
||||
logVerbose("UPnP: Attempting to enrich device info by fetching %s", location)
|
||||
|
||||
resp, err := d.httpClient.Get(location)
|
||||
if err != nil {
|
||||
@@ -500,7 +532,7 @@ func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location str
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
log.Printf("UPnP: Successfully fetched device description from %s (Status: %s)", location, resp.Status)
|
||||
logVerbose("UPnP: Successfully fetched device description from %s (Status: %s)", location, resp.Status)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
@@ -515,6 +547,7 @@ func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location str
|
||||
XMLName xml.Name `xml:"root"`
|
||||
Device struct {
|
||||
FriendlyName string `xml:"friendlyName"`
|
||||
Manufacturer string `xml:"manufacturer"`
|
||||
ModelName string `xml:"modelName"`
|
||||
SerialNumber string `xml:"serialNumber"`
|
||||
} `xml:"device"`
|
||||
@@ -529,6 +562,10 @@ func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location str
|
||||
device.Name = upnpRoot.Device.FriendlyName
|
||||
}
|
||||
|
||||
if upnpRoot.Device.Manufacturer != "" {
|
||||
device.Manufacturer = upnpRoot.Device.Manufacturer
|
||||
}
|
||||
|
||||
if upnpRoot.Device.ModelName != "" {
|
||||
device.ModelID = upnpRoot.Device.ModelName
|
||||
}
|
||||
@@ -537,8 +574,8 @@ func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location str
|
||||
device.UPnPSerial = upnpRoot.Device.SerialNumber
|
||||
}
|
||||
|
||||
log.Printf("UPnP: Enriched device info: Name='%s', Model='%s', UPnPSerial='%s'",
|
||||
device.Name, device.ModelID, device.UPnPSerial)
|
||||
logVerbose("UPnP: Enriched device info: Name='%s', Manufacturer='%s', Model='%s', UPnPSerial='%s'",
|
||||
device.Name, device.Manufacturer, device.ModelID, device.UPnPSerial)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestIsBoseUPnPDevice(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
dev *models.DiscoveredDevice
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "Bose manufacturer wins",
|
||||
dev: &models.DiscoveredDevice{Manufacturer: "Bose Corporation", ModelID: "Generic"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "SoundTouch model wins even without manufacturer",
|
||||
dev: &models.DiscoveredDevice{Manufacturer: "", ModelID: "SoundTouch 30 sm2"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Case-insensitive manufacturer",
|
||||
dev: &models.DiscoveredDevice{Manufacturer: "BOSE CORP"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "LG TV rejected",
|
||||
dev: &models.DiscoveredDevice{Manufacturer: "LG Electronics", ModelID: "OLED55G2"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Onkyo AVR rejected",
|
||||
dev: &models.DiscoveredDevice{Manufacturer: "Onkyo Corporation", ModelID: "HT-R695"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Dreambox rejected",
|
||||
dev: &models.DiscoveredDevice{Manufacturer: "Dream Multimedia", ModelID: "dm920"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Empty fields rejected",
|
||||
dev: &models.DiscoveredDevice{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Nil rejected",
|
||||
dev: nil,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isBoseUPnPDevice(tc.dev); got != tc.want {
|
||||
t.Errorf("got %v, want %v (dev=%+v)", got, tc.want, tc.dev)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSoundTouchServiceName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
want bool
|
||||
}{
|
||||
{"Bose-Wohnzimmer._soundtouch._tcp.local.", true},
|
||||
{"SoundTouch-Stick._soundtouchstick._tcp.local.", true},
|
||||
{"NewSpeaker._bose-soundtouch._tcp.local.", true},
|
||||
{"PrinterA._ipp._tcp.local.", false},
|
||||
{"TV._smarttv._tcp.local.", false},
|
||||
{"", false},
|
||||
// Case-insensitive: firmware might emit mixed-case
|
||||
{"Speaker._SoundTouch._tcp.local.", true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isSoundTouchServiceName(tc.name); got != tc.want {
|
||||
t.Errorf("isSoundTouchServiceName(%q) = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,7 @@ type DiscoveredDevice struct {
|
||||
UPnPLocation string `json:"upnp_location,omitempty"` // UPnP device description XML URL
|
||||
UPnPUSN string `json:"upnp_usn,omitempty"` // UPnP Unique Service Name
|
||||
UPnPSerial string `json:"upnp_serial,omitempty"` // Serial number from UPnP (MAC address)
|
||||
Manufacturer string `json:"manufacturer,omitempty"` // Manufacturer from UPnP device description (used to reject non-Bose devices)
|
||||
MDNSHostname string `json:"mdns_hostname,omitempty"` // mDNS hostname (e.g., "device.local.")
|
||||
MDNSService string `json:"mdns_service,omitempty"` // mDNS service name
|
||||
ConfigName string `json:"config_name,omitempty"` // Original name from config
|
||||
|
||||
@@ -277,7 +277,7 @@ func tuneInNavigatePlayItem(item map[string]interface{}) models.BmxNavItem {
|
||||
Subtitle: subtitle,
|
||||
Links: &models.Links{
|
||||
BmxPlayback: &models.Link{
|
||||
Href: TuneInStream(stationID, ""),
|
||||
Href: fmt.Sprintf("/v1/playback/station/%s", stationID),
|
||||
Type: "stationurl",
|
||||
},
|
||||
},
|
||||
@@ -373,10 +373,11 @@ func tuneInSearchSection(item map[string]interface{}, idx int, query, layout str
|
||||
}
|
||||
|
||||
switch typeStr {
|
||||
case "Station", "PlayItem":
|
||||
case "Station", "PlayItem", "Topic":
|
||||
// Topics are single podcast episodes (t<N>) — Tune.ashx
|
||||
// accepts them just like station IDs, so the same play-link
|
||||
// shape works.
|
||||
section.Items = append(section.Items, tuneInSearchPlayItem(cm))
|
||||
case "Topic":
|
||||
section.Items = append(section.Items, tuneInSearchTopic(cm))
|
||||
case "Program", "Profile":
|
||||
section.Items = append(section.Items, tuneInSearchProfile(cm, name))
|
||||
}
|
||||
@@ -412,43 +413,13 @@ func tuneInSearchPlayItem(item map[string]interface{}) models.BmxNavItem {
|
||||
Subtitle: subtitle,
|
||||
Links: &models.Links{
|
||||
BmxPlayback: &models.Link{
|
||||
Href: TuneInStream(stationID, ""),
|
||||
Href: fmt.Sprintf("/v1/playback/station/%s", stationID),
|
||||
Type: "stationurl",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func tuneInSearchTopic(item map[string]interface{}) models.BmxNavItem {
|
||||
name, _ := item["Title"].(string)
|
||||
if name == "" {
|
||||
name, _ = item["text"].(string)
|
||||
}
|
||||
|
||||
image, _ := item["Image"].(string)
|
||||
if image == "" {
|
||||
image, _ = item["image"].(string)
|
||||
}
|
||||
|
||||
subtitle, _ := item["Subtitle"].(string)
|
||||
if subtitle == "" {
|
||||
subtitle, _ = item["subtext"].(string)
|
||||
}
|
||||
|
||||
href, _ := item["URL"].(string)
|
||||
|
||||
return models.BmxNavItem{
|
||||
Name: name,
|
||||
ImageUrl: image,
|
||||
Subtitle: subtitle,
|
||||
Links: &models.Links{
|
||||
BmxNavigate: &models.Link{
|
||||
Href: "/v1/navigate/" + base64.RawURLEncoding.EncodeToString([]byte(tuneInRenderJSONURI(href))),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func tuneInSearchProfile(item map[string]interface{}, _ string) models.BmxNavItem {
|
||||
profileName, _ := item["Title"].(string)
|
||||
if profileName == "" {
|
||||
@@ -481,13 +452,16 @@ func tuneInSearchProfile(item map[string]interface{}, _ string) models.BmxNavIte
|
||||
// Artists/Stations/etc are typically navigated first.
|
||||
if typeStr, _ := item["Type"].(string); typeStr == "Program" {
|
||||
if guideID, _ := item["GuideId"].(string); guideID != "" {
|
||||
encodedName := base64.URLEncoding.EncodeToString([]byte(profileName))
|
||||
playbackHref := fmt.Sprintf("/v1/playback/episodes/%s?encoded_name=%s", guideID, encodedName)
|
||||
|
||||
return models.BmxNavItem{
|
||||
Name: profileName,
|
||||
ImageUrl: image,
|
||||
Subtitle: subtitle,
|
||||
Links: &models.Links{
|
||||
BmxPlayback: &models.Link{
|
||||
Href: TuneInStream(guideID, "") + "&encoded_name=" + url.QueryEscape(profileName),
|
||||
Href: playbackHref,
|
||||
Type: "tracklisturl",
|
||||
},
|
||||
BmxNavigate: &models.Link{
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -617,19 +618,50 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
|
||||
}
|
||||
|
||||
devices := []models.ServiceDeviceInfo{}
|
||||
seenIDs := make(map[string]bool)
|
||||
|
||||
type seenEntry struct {
|
||||
index int
|
||||
account string
|
||||
}
|
||||
|
||||
seenIDs := make(map[string]seenEntry)
|
||||
|
||||
for _, dir := range dirs {
|
||||
accounts, err := os.ReadDir(dir)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, acc := range accounts {
|
||||
if !acc.IsDir() {
|
||||
continue
|
||||
// Sort "default" to the back so a real-account entry always
|
||||
// wins the first-seen race. "default" exists as a pre-pair
|
||||
// placeholder; once the speaker pairs with a real account it
|
||||
// becomes orphan state. We don't try to pick a winner among
|
||||
// multiple real accounts via mtime or other proxies — the
|
||||
// authoritative signal is the URL of the speaker's incoming
|
||||
// PUT, which only the live handler sees. Stale real-account
|
||||
// entries are flagged as findings by the consistency check
|
||||
// instead.
|
||||
accounts := make([]os.DirEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
accounts = append(accounts, e)
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(accounts, func(i, j int) bool {
|
||||
ai, aj := accounts[i].Name(), accounts[j].Name()
|
||||
if ai == accountIDDefault && aj != accountIDDefault {
|
||||
return false
|
||||
}
|
||||
|
||||
if aj == accountIDDefault && ai != accountIDDefault {
|
||||
return true
|
||||
}
|
||||
|
||||
return ai < aj
|
||||
})
|
||||
|
||||
for _, acc := range accounts {
|
||||
accDevices := ds.listDevicesInAccount(dir, acc.Name())
|
||||
for i := range accDevices {
|
||||
info := accDevices[i]
|
||||
@@ -640,21 +672,35 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
|
||||
key = info.IPAddress
|
||||
}
|
||||
|
||||
if !seenIDs[key] || info.Name != "" {
|
||||
if seenIDs[key] && info.Name != "" {
|
||||
// Replace previous empty-named entry with one that has a name
|
||||
for j := range devices {
|
||||
existing := &devices[j]
|
||||
if (existing.DeviceID != "" && existing.DeviceID == info.DeviceID) ||
|
||||
(existing.IPAddress != "" && existing.IPAddress == info.IPAddress) {
|
||||
devices[j] = info
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if !seenIDs[key] {
|
||||
devices = append(devices, info)
|
||||
seenIDs[key] = true
|
||||
entry, alreadySeen := seenIDs[key]
|
||||
if !alreadySeen {
|
||||
devices = append(devices, info)
|
||||
seenIDs[key] = seenEntry{index: len(devices) - 1, account: info.AccountID}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// "default" never replaces a real-account entry.
|
||||
// But when two "default" entries collide across data dirs,
|
||||
// prefer the one with a non-empty name (more information).
|
||||
if info.AccountID == accountIDDefault {
|
||||
if entry.account == accountIDDefault && devices[entry.index].Name == "" && info.Name != "" {
|
||||
devices[entry.index] = info
|
||||
seenIDs[key] = seenEntry{index: entry.index, account: info.AccountID}
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// First real-account encountered wins (sort is
|
||||
// stable + alphabetical so the result is
|
||||
// deterministic across runs). Don't pick a different
|
||||
// winner here — the consistency check enumerates all
|
||||
// the duplicate account dirs for the operator to
|
||||
// clean up.
|
||||
if entry.account == accountIDDefault {
|
||||
devices[entry.index] = info
|
||||
seenIDs[key] = seenEntry{index: entry.index, account: info.AccountID}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -663,6 +709,52 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// AllAccountsForDevice returns every account directory that contains a
|
||||
// device with the given deviceID, in their on-disk order. Used by the
|
||||
// consistency check to enumerate stale account entries left behind
|
||||
// when the speaker was re-paired to a different account.
|
||||
func (ds *DataStore) AllAccountsForDevice(deviceID string) []string {
|
||||
if deviceID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var hits []string
|
||||
|
||||
seen := map[string]bool{}
|
||||
|
||||
for _, dir := range ds.getPossibleDataDirs() {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, acc := range entries {
|
||||
if !acc.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
devicePath := filepath.Join(dir, acc.Name(), "devices", deviceID)
|
||||
if _, err := os.Stat(devicePath); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !seen[acc.Name()] {
|
||||
seen[acc.Name()] = true
|
||||
hits = append(hits, acc.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hits
|
||||
}
|
||||
|
||||
// accountIDDefault is the placeholder account id assigned to records
|
||||
// that exist before a speaker has been paired with a real Marge
|
||||
// account. Treated as fallback in ListAllDevices and skipped in
|
||||
// consistency checks when a real-account entry exists for the same
|
||||
// device.
|
||||
const accountIDDefault = "default"
|
||||
|
||||
func (ds *DataStore) getPossibleDataDirs() []string {
|
||||
dirs := []string{}
|
||||
// Check primary data directory
|
||||
@@ -880,7 +972,7 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
|
||||
presets = append(presets, models.ServicePreset{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
Name: p.ContentItem.ItemName,
|
||||
Source: p.ContentItem.Source,
|
||||
Source: repairLeakedSource(account, device, "preset "+p.ID, p.ContentItem.Source, p.SourceID, ds),
|
||||
Type: p.ContentItem.Type,
|
||||
ContentItemType: p.ContentItem.Type,
|
||||
Location: p.ContentItem.Location,
|
||||
@@ -899,6 +991,101 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
|
||||
return presets, nil
|
||||
}
|
||||
|
||||
// repairLeakedSource quietly substitutes the speaker-perspective
|
||||
// SourceKeyType for a persisted Source that has the protocol-level
|
||||
// "Audio" leak signature from the legacy syncPresets/syncRecents path.
|
||||
// Critically it only repairs the *leak* — when persisted Source carries
|
||||
// a non-leak symbolic value, even one that disagrees with the current
|
||||
// Sources.xml mapping for the same SourceID, we leave it alone. That's
|
||||
// what protects the GH-343 case: a TUNEIN preset whose SourceID was
|
||||
// re-classified to RADIOPLAYER in Sources.xml must stay TUNEIN here,
|
||||
// otherwise the speaker's previously-stored intent gets silently
|
||||
// overwritten by the stale source-list entry.
|
||||
//
|
||||
// Speaker is the source of truth: this only un-rots data that was
|
||||
// never the speaker's perspective to begin with.
|
||||
func repairLeakedSource(account, device, label, persistedSource, sourceID string, ds *DataStore) string {
|
||||
if !isLeakedSourceValue(persistedSource) {
|
||||
return persistedSource
|
||||
}
|
||||
|
||||
if sourceID == "" {
|
||||
return persistedSource
|
||||
}
|
||||
|
||||
sources, err := ds.getConfiguredSourcesLocked(account, device)
|
||||
if err != nil {
|
||||
return persistedSource
|
||||
}
|
||||
|
||||
for i := range sources {
|
||||
if sources[i].ID == sourceID && sources[i].SourceKeyType != "" {
|
||||
log.Printf("[Datastore] %s: repaired leaked source %q -> %q via sourceid=%s (account=%s device=%s) — likely written by the pre-fix marge.syncPresets/syncRecents path; speaker's perspective is now restored on read",
|
||||
label, persistedSource, sources[i].SourceKeyType, sourceID, account, device)
|
||||
|
||||
return sources[i].SourceKeyType
|
||||
}
|
||||
}
|
||||
|
||||
return persistedSource
|
||||
}
|
||||
|
||||
// isLeakedSourceValue identifies the known protocol-level leak signature
|
||||
// (the upstream <source type="Audio"> attribute that the old
|
||||
// marge.syncPresets persisted into ServicePreset.Source). Empty Source
|
||||
// is also treated as a leak so the resolve fires for legacy entries
|
||||
// missing the attribute entirely.
|
||||
func isLeakedSourceValue(s string) bool {
|
||||
return s == "" || s == "Audio"
|
||||
}
|
||||
|
||||
// getConfiguredSourcesLocked is GetConfiguredSources without the
|
||||
// fileMutex.RLock() — callers must already hold it. Used by
|
||||
// repairLeakedSource from within GetPresets/GetRecents which already
|
||||
// hold the lock.
|
||||
func (ds *DataStore) getConfiguredSourcesLocked(account, device string) ([]models.ConfiguredSource, error) {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
||||
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ds.getDefaultSources(), nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type persistentSource struct {
|
||||
ID string `xml:"id,attr,omitempty"`
|
||||
Type string `xml:"type,attr,omitempty"`
|
||||
SourceKeyType string `xml:"-"`
|
||||
SourceKey struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Account string `xml:"account,attr"`
|
||||
} `xml:"sourceKey"`
|
||||
}
|
||||
|
||||
var sourcesWrap struct {
|
||||
Sources []persistentSource `xml:"source"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(data, &sourcesWrap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]models.ConfiguredSource, 0, len(sourcesWrap.Sources))
|
||||
for i := range sourcesWrap.Sources {
|
||||
ps := sourcesWrap.Sources[i]
|
||||
out = append(out, models.ConfiguredSource{
|
||||
ID: ps.ID,
|
||||
Type: ps.Type,
|
||||
SourceKeyType: ps.SourceKey.Type,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SavePresets saves the preset list for the specified account and device.
|
||||
func (ds *DataStore) SavePresets(account, device string, presets []models.ServicePreset) error {
|
||||
ds.fileMutex.Lock()
|
||||
@@ -948,7 +1135,25 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
|
||||
pxml.ContentItem.Type = p.Type
|
||||
pxml.ContentItem.Location = p.Location
|
||||
pxml.ContentItem.SourceAccount = p.SourceAccount
|
||||
pxml.ContentItem.IsPresetable = "true"
|
||||
|
||||
// Preserve the speaker's IsPresetable verdict instead of forcing
|
||||
// "true". The speaker firmware sets isPresetable="false" for
|
||||
// content it can't independently recall later (e.g. Spotify Connect
|
||||
// pushes from a phone — see GH-235). Hard-coding "true" makes the
|
||||
// on-disk XML look valid while the speaker still refuses to play
|
||||
// the preset, which leaves users debugging a phantom "stored but
|
||||
// won't play" state. Default to "true" only when the caller
|
||||
// supplied nothing.
|
||||
pxml.ContentItem.IsPresetable = p.IsPresetable
|
||||
if pxml.ContentItem.IsPresetable == "" {
|
||||
pxml.ContentItem.IsPresetable = "true"
|
||||
}
|
||||
|
||||
if pxml.ContentItem.IsPresetable == "false" {
|
||||
log.Printf("[Datastore] SavePresets: storing preset %s as isPresetable=false (account=%s device=%s source=%s) — speaker firmware marked this content non-recallable; preset will appear on the speaker but pressing it will not play",
|
||||
pxml.ID, account, device, p.Source)
|
||||
}
|
||||
|
||||
pxml.ContentItem.ItemName = p.Name
|
||||
pxml.ContentItem.ContainerArt = p.ContainerArt
|
||||
pxml.SourceID = p.SourceID
|
||||
@@ -1067,6 +1272,8 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
|
||||
maxID++
|
||||
recents[i].ID = strconv.Itoa(maxID)
|
||||
}
|
||||
|
||||
r.Source = repairLeakedSource(account, device, "recent "+r.ID, r.Source, r.SourceID, ds)
|
||||
}
|
||||
|
||||
return recents, nil
|
||||
@@ -1861,6 +2068,27 @@ func (ds *DataStore) GetDefaultSources() []models.ConfiguredSource {
|
||||
return ds.getDefaultSources()
|
||||
}
|
||||
|
||||
// CanonicalSourceByID returns the canonical default ConfiguredSource for one
|
||||
// of the well-known built-in source IDs (10001..10005). The returned source
|
||||
// has its SourceKey.Type / SourceKey.Account mirrored from
|
||||
// SourceKeyType / SourceKeyAccount so it round-trips through SaveConfiguredSources
|
||||
// without losing the embedded struct fields. Returns (zero, false) when the
|
||||
// ID isn't one we can synthesise.
|
||||
//
|
||||
// Callers that auto-add missing sources (e.g. UpdatePreset when a speaker
|
||||
// PUTs a preset referencing a canonical source AfterTouch hasn't been told
|
||||
// about yet) should use this to keep parity with the post-pair defaults.
|
||||
func (ds *DataStore) CanonicalSourceByID(id string) (models.ConfiguredSource, bool) {
|
||||
defaults := ds.getDefaultSources()
|
||||
for i := range defaults {
|
||||
if defaults[i].ID == id {
|
||||
return defaults[i], true
|
||||
}
|
||||
}
|
||||
|
||||
return models.ConfiguredSource{}, false
|
||||
}
|
||||
|
||||
func (ds *DataStore) getDefaultSources() []models.ConfiguredSource {
|
||||
sources := []models.ConfiguredSource{
|
||||
{
|
||||
@@ -2118,6 +2346,14 @@ type Settings struct {
|
||||
// different host within a known-good private subnet.
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
|
||||
|
||||
// TLSExtraHosts is the persisted list of additional DNS names or IPs
|
||||
// to include in the TLS certificate SAN list. Merged with the
|
||||
// CLI/env --tls-extra-host values at startup (CLI/env wins; persisted
|
||||
// values are additive and deduplicated). Applying a change requires a
|
||||
// service restart so the TLS cert can be regenerated. Used by the
|
||||
// `speaker_marge_url` health check's QuickFix and the Settings tab UI.
|
||||
TLSExtraHosts []string `json:"tls_extra_hosts,omitempty"`
|
||||
|
||||
// TuneInStreamFormats overrides the comma-separated format list
|
||||
// AfterTouch sends to TuneIn's Tune.ashx (formats=…). Empty value
|
||||
// uses bmx.DefaultTuneInStreamFormats ("mp3,aac,ogg"), which
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestListAllDevices_RealAccountWinsOverDefault reproduces the operator's
|
||||
// observation: the same physical device exists on disk under both
|
||||
// accounts/default/devices/<id>/ (leftover pre-pair state) and
|
||||
// accounts/<real>/devices/<id>/ (active pairing). The dedupe loop used to
|
||||
// let "default" replace the real-account entry whenever the default
|
||||
// directory's DeviceInfo.xml had a non-empty <name>, which made
|
||||
// consistency checks report the device under "default" while the speaker
|
||||
// was happily POST/PUT'ing to the real account.
|
||||
//
|
||||
// "default" must be treated as a fallback placeholder, never as
|
||||
// authoritative when a real account also has the device.
|
||||
func TestListAllDevices_RealAccountWinsOverDefault(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "datastore-default-orphan-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
deviceID := "AABBCCDDEEFF"
|
||||
|
||||
// Default-account entry: stale pre-pair state with a name.
|
||||
defaultDir := filepath.Join(tempDir, "accounts", "default", "devices", deviceID)
|
||||
if err := os.MkdirAll(defaultDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir default: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(defaultDir, "DeviceInfo.xml"), []byte(`<?xml version="1.0"?>
|
||||
<info deviceID="`+deviceID+`"><name>Discovered Device</name></info>`), 0644); err != nil {
|
||||
t.Fatalf("write default info: %v", err)
|
||||
}
|
||||
|
||||
// Real-account entry: the speaker is paired here.
|
||||
realDir := filepath.Join(tempDir, "accounts", "1111111", "devices", deviceID)
|
||||
if err := os.MkdirAll(realDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir real: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(realDir, "DeviceInfo.xml"), []byte(`<?xml version="1.0"?>
|
||||
<info deviceID="`+deviceID+`"><name>Living Room SoundTouch</name></info>`), 0644); err != nil {
|
||||
t.Fatalf("write real info: %v", err)
|
||||
}
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
devices, err := ds.ListAllDevices()
|
||||
if err != nil {
|
||||
t.Fatalf("ListAllDevices: %v", err)
|
||||
}
|
||||
|
||||
if len(devices) != 1 {
|
||||
t.Fatalf("expected exactly 1 deduped device, got %d: %+v", len(devices), devices)
|
||||
}
|
||||
|
||||
if devices[0].AccountID != "1111111" {
|
||||
t.Errorf("expected real account 1111111 to win dedup, got AccountID=%q (default leaked through)", devices[0].AccountID)
|
||||
}
|
||||
|
||||
if devices[0].Name != "Living Room SoundTouch" {
|
||||
t.Errorf("expected real-account Name to survive dedup, got %q", devices[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAllDevices_DefaultOnlyDeviceStillSeen confirms the dedup
|
||||
// preference is one-way: a device that *only* exists under "default"
|
||||
// (e.g. fresh discovery, never paired) is still returned. We just
|
||||
// refuse to let "default" override a real account.
|
||||
func TestListAllDevices_DefaultOnlyDeviceStillSeen(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "datastore-default-only-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
deviceID := "112233445566"
|
||||
defaultDir := filepath.Join(tempDir, "accounts", "default", "devices", deviceID)
|
||||
if err := os.MkdirAll(defaultDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(defaultDir, "DeviceInfo.xml"), []byte(`<?xml version="1.0"?>
|
||||
<info deviceID="`+deviceID+`"><name>Newly Discovered</name></info>`), 0644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
devices, err := ds.ListAllDevices()
|
||||
if err != nil {
|
||||
t.Fatalf("ListAllDevices: %v", err)
|
||||
}
|
||||
|
||||
if len(devices) != 1 {
|
||||
t.Fatalf("expected 1 device, got %d", len(devices))
|
||||
}
|
||||
|
||||
if devices[0].AccountID != "default" {
|
||||
t.Errorf("expected default-only device to be returned with AccountID=default, got %q", devices[0].AccountID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// TestSavePresets_PreservesIsPresetable is a regression test for GH-235:
|
||||
// SavePresets used to hard-code isPresetable="true", masking the speaker's
|
||||
// own verdict that Spotify-Connect content isn't recallable. Storing the
|
||||
// preset looked like it succeeded but pressing it on the speaker did
|
||||
// nothing. The fix preserves whatever IsPresetable the speaker provided,
|
||||
// defaulting to "true" only when the caller supplied an empty string.
|
||||
func TestSavePresets_PreservesIsPresetable(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "datastore-ispresetable-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "1234567"
|
||||
device := "AABBCCDDEEFF"
|
||||
|
||||
presets := []models.ServicePreset{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
Name: "Connect Playlist",
|
||||
Source: "SPOTIFY",
|
||||
IsPresetable: "false",
|
||||
},
|
||||
ButtonNumber: "1",
|
||||
},
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
Name: "Default Truth",
|
||||
Source: "TUNEIN",
|
||||
IsPresetable: "true",
|
||||
},
|
||||
ButtonNumber: "2",
|
||||
},
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
Name: "Caller Left Empty",
|
||||
Source: "INTERNET_RADIO",
|
||||
// IsPresetable intentionally unset
|
||||
},
|
||||
ButtonNumber: "3",
|
||||
},
|
||||
}
|
||||
|
||||
if err := ds.SavePresets(account, device, presets); err != nil {
|
||||
t.Fatalf("SavePresets: %v", err)
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(filepath.Join(ds.AccountDeviceDir(account, device), "Presets.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
got := string(body)
|
||||
|
||||
if !strings.Contains(got, `id="1"`) || !strings.Contains(got, `isPresetable="false"`) {
|
||||
t.Errorf("preset 1: expected isPresetable=\"false\" preserved; Presets.xml:\n%s", got)
|
||||
}
|
||||
|
||||
if !strings.Contains(got, `id="2"`) || strings.Count(got, `isPresetable="true"`) < 2 {
|
||||
t.Errorf("preset 2/3: expected isPresetable=\"true\" (preset 2 from caller, preset 3 from empty-string default); Presets.xml:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetPresets_RepairsAudioLeakViaSourceID exercises the on-read repair:
|
||||
// a persisted preset whose Source is the protocol-level "Audio" leak gets
|
||||
// resolved to the speaker-perspective SourceKeyType through the device's
|
||||
// Sources.xml. Speaker is the source of truth; this just un-rots data the
|
||||
// legacy syncPresets path wrote.
|
||||
func TestGetPresets_RepairsAudioLeakViaSourceID(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "datastore-repair-leak-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
account := "1234567"
|
||||
device := "AABBCCDDEEFF"
|
||||
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
// Sources.xml claims id=14774275 is a TUNEIN source.
|
||||
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sources>
|
||||
<source id="14774275">
|
||||
<sourceKey type="TUNEIN" account=""/>
|
||||
</source>
|
||||
</sources>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644); err != nil {
|
||||
t.Fatalf("write Sources.xml: %v", err)
|
||||
}
|
||||
|
||||
// Presets.xml carries the leak: source="Audio" + sourceid=14774275.
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="4" createdOn="0" updatedOn="0">
|
||||
<contentItem source="Audio" type="stationurl" location="/v1/playback/station/s166521" sourceAccount="" isPresetable="true">
|
||||
<itemName>SMOOTH JAZZ</itemName>
|
||||
<containerArt></containerArt>
|
||||
</contentItem>
|
||||
<sourceid>14774275</sourceid>
|
||||
</preset>
|
||||
</presets>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(presetsXML), 0644); err != nil {
|
||||
t.Fatalf("write Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
presets, err := ds.GetPresets(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets: %v", err)
|
||||
}
|
||||
|
||||
if len(presets) != 1 {
|
||||
t.Fatalf("expected 1 preset, got %d", len(presets))
|
||||
}
|
||||
|
||||
if presets[0].Source != "TUNEIN" {
|
||||
t.Errorf("expected repaired Source=TUNEIN, got %q", presets[0].Source)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetPresets_PreservesNonLeakedSource is the GH-343 protection: a
|
||||
// preset persisted with Source=TUNEIN must stay TUNEIN even when
|
||||
// Sources.xml has been re-classified to RADIOPLAYER. The speaker's
|
||||
// previously-stored intent wins over a stale current Sources.xml entry.
|
||||
func TestGetPresets_PreservesNonLeakedSource(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "datastore-preserve-source-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
account := "1234567"
|
||||
device := "AABBCCDDEEFF"
|
||||
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
// Sources.xml *currently* says id=14774275 is RADIOPLAYER (stale /
|
||||
// drifted classification). The preset was stored earlier when the
|
||||
// same id was understood as TUNEIN.
|
||||
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sources>
|
||||
<source id="14774275">
|
||||
<sourceKey type="RADIOPLAYER" account=""/>
|
||||
</source>
|
||||
</sources>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644); err != nil {
|
||||
t.Fatalf("write Sources.xml: %v", err)
|
||||
}
|
||||
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="4" createdOn="0" updatedOn="0">
|
||||
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s166521" sourceAccount="" isPresetable="true">
|
||||
<itemName>SMOOTH JAZZ</itemName>
|
||||
</contentItem>
|
||||
<sourceid>14774275</sourceid>
|
||||
</preset>
|
||||
</presets>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(presetsXML), 0644); err != nil {
|
||||
t.Fatalf("write Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
presets, err := ds.GetPresets(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets: %v", err)
|
||||
}
|
||||
|
||||
if presets[0].Source != "TUNEIN" {
|
||||
t.Errorf("expected speaker's previously-stored Source=TUNEIN to be preserved, got %q (GH-343 silent rewrite would substitute RADIOPLAYER)", presets[0].Source)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRecents_RepairsAudioLeakViaSourceID applies the same load-time
|
||||
// repair to recents — symmetric protection for the recents pipeline.
|
||||
func TestGetRecents_RepairsAudioLeakViaSourceID(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "datastore-repair-recent-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
account := "1234567"
|
||||
device := "AABBCCDDEEFF"
|
||||
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sources>
|
||||
<source id="9330201">
|
||||
<sourceKey type="INTERNET_RADIO" account=""/>
|
||||
</source>
|
||||
</sources>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644); err != nil {
|
||||
t.Fatalf("write Sources.xml: %v", err)
|
||||
}
|
||||
|
||||
recentsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<recents>
|
||||
<recent id="rec-1">
|
||||
<contentItem source="Audio" type="stationurl" location="19059" sourceAccount="" isPresetable="true">
|
||||
<itemName>Russkoe Radio Ukraine</itemName>
|
||||
</contentItem>
|
||||
<sourceid>9330201</sourceid>
|
||||
</recent>
|
||||
</recents>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644); err != nil {
|
||||
t.Fatalf("write Recents.xml: %v", err)
|
||||
}
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
recents, err := ds.GetRecents(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecents: %v", err)
|
||||
}
|
||||
|
||||
if len(recents) != 1 {
|
||||
t.Fatalf("expected 1 recent, got %d", len(recents))
|
||||
}
|
||||
|
||||
if recents[0].Source != "INTERNET_RADIO" {
|
||||
t.Errorf("expected repaired Source=INTERNET_RADIO, got %q", recents[0].Source)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// Package ding renders the AfterTouch "ding" signature sound — a
|
||||
// two-chirp tone derived from the braille letters S and T that
|
||||
// make up the AfterTouch logo. Used as the Health-tab test
|
||||
// playback target: pushed to a speaker as a custom-radio
|
||||
// ContentItem so operators can confirm a freshly migrated speaker
|
||||
// emits audio without depending on TuneIn or any external service.
|
||||
//
|
||||
// Mapping:
|
||||
//
|
||||
// Braille S = ⠎ = dots 2, 3, 4
|
||||
// Braille T = ⠞ = dots 2, 3, 4, 5
|
||||
//
|
||||
// Dot positions in the 6-dot grid:
|
||||
// 1 4
|
||||
// 2 5
|
||||
// 3 6
|
||||
//
|
||||
// Columns → stereo channels (left=1,2,3 / right=4,5,6).
|
||||
// Rows → pitches: top=PitchHigh, mid=PitchMid, bottom=PitchLow.
|
||||
//
|
||||
// So S (dots 2,3,4) renders as L=PitchMid+PitchLow, R=PitchHigh,
|
||||
// and T (dots 2,3,4,5) adds R=PitchMid on top of S.
|
||||
//
|
||||
// Render(opts) returns a self-contained 16-bit stereo PCM WAV.
|
||||
// Default options produce a ~600 ms / 52 KB clip; callers can
|
||||
// override any subset and let the rest fall back to defaults
|
||||
// (see DefaultOptions).
|
||||
package ding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
)
|
||||
|
||||
// Options controls the synthesis. A zero-valued Options struct
|
||||
// is *not* usable directly; the WithDefaults method fills in
|
||||
// sensible numbers for unset fields so callers can supply only
|
||||
// the parameters they want to override.
|
||||
type Options struct {
|
||||
SampleRate int // Hz. Default 22050.
|
||||
|
||||
PitchHigh float64 // Hz, top row (A5=880).
|
||||
PitchMid float64 // Hz, middle row (E5=659.2551).
|
||||
PitchLow float64 // Hz, bottom row (A4=440).
|
||||
|
||||
ChirpDuration float64 // seconds per chirp. Default 0.25.
|
||||
GapDuration float64 // seconds between chirps. Default 0.10.
|
||||
AttackDuration float64 // seconds of fade-in per chirp. Default 0.020.
|
||||
ReleaseDuration float64 // seconds of fade-out per chirp. Default 0.060.
|
||||
|
||||
Peak float64 // final-mix headroom; 0 < Peak <= 1.0. Default 0.85.
|
||||
}
|
||||
|
||||
// DefaultOptions returns the canonical option set used by the
|
||||
// runtime handler when no overrides are supplied.
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
SampleRate: 22050,
|
||||
PitchHigh: 880.00,
|
||||
PitchMid: 659.2551,
|
||||
PitchLow: 440.00,
|
||||
ChirpDuration: 0.25,
|
||||
GapDuration: 0.10,
|
||||
AttackDuration: 0.020,
|
||||
ReleaseDuration: 0.060,
|
||||
Peak: 0.85,
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefaults returns a copy of o with any zero-valued fields
|
||||
// filled in from DefaultOptions. Lets callers write
|
||||
//
|
||||
// ding.Options{PitchHigh: 1000}.WithDefaults()
|
||||
//
|
||||
// instead of restating every field.
|
||||
func (o Options) WithDefaults() Options {
|
||||
d := DefaultOptions()
|
||||
|
||||
if o.SampleRate <= 0 || o.SampleRate > int(maxSampleRate) {
|
||||
o.SampleRate = d.SampleRate
|
||||
}
|
||||
|
||||
if o.PitchHigh <= 0 {
|
||||
o.PitchHigh = d.PitchHigh
|
||||
}
|
||||
|
||||
if o.PitchMid <= 0 {
|
||||
o.PitchMid = d.PitchMid
|
||||
}
|
||||
|
||||
if o.PitchLow <= 0 {
|
||||
o.PitchLow = d.PitchLow
|
||||
}
|
||||
|
||||
if o.ChirpDuration <= 0 {
|
||||
o.ChirpDuration = d.ChirpDuration
|
||||
}
|
||||
|
||||
if o.GapDuration <= 0 {
|
||||
o.GapDuration = d.GapDuration
|
||||
}
|
||||
|
||||
if o.AttackDuration <= 0 {
|
||||
o.AttackDuration = d.AttackDuration
|
||||
}
|
||||
|
||||
if o.ReleaseDuration <= 0 {
|
||||
o.ReleaseDuration = d.ReleaseDuration
|
||||
}
|
||||
|
||||
if o.Peak <= 0 || o.Peak > 1.0 {
|
||||
o.Peak = d.Peak
|
||||
}
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
// Render synthesises the ding using opts (after defaulting) and
|
||||
// returns a self-contained 16-bit PCM WAV file.
|
||||
func Render(opts Options) []byte {
|
||||
opts = opts.WithDefaults()
|
||||
|
||||
voicesS := []voice{
|
||||
{freq: opts.PitchMid, channel: 0},
|
||||
{freq: opts.PitchLow, channel: 0},
|
||||
{freq: opts.PitchHigh, channel: 1},
|
||||
}
|
||||
voicesT := []voice{
|
||||
{freq: opts.PitchMid, channel: 0},
|
||||
{freq: opts.PitchLow, channel: 0},
|
||||
{freq: opts.PitchHigh, channel: 1},
|
||||
{freq: opts.PitchMid, channel: 1},
|
||||
}
|
||||
|
||||
chirpN := int(math.Round(float64(opts.SampleRate) * opts.ChirpDuration))
|
||||
gapN := int(math.Round(float64(opts.SampleRate) * opts.GapDuration))
|
||||
attackN := int(math.Round(float64(opts.SampleRate) * opts.AttackDuration))
|
||||
releaseN := int(math.Round(float64(opts.SampleRate) * opts.ReleaseDuration))
|
||||
|
||||
samplesPerChannel := chirpN*2 + gapN
|
||||
left := make([]float64, samplesPerChannel)
|
||||
right := make([]float64, samplesPerChannel)
|
||||
|
||||
renderChirp(left, right, 0, chirpN, attackN, releaseN, voicesS, opts.SampleRate)
|
||||
renderChirp(left, right, chirpN+gapN, chirpN, attackN, releaseN, voicesT, opts.SampleRate)
|
||||
|
||||
normalise(left, right, opts.Peak)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Defensive bound check: clamp before the conversion to
|
||||
// uint32 so even a buggy caller (or one that bypassed the
|
||||
// handler-side bound check on the query param) can't trigger
|
||||
// integer truncation in the WAV header fields.
|
||||
sampleRate32 := safeSampleRate(opts.SampleRate)
|
||||
|
||||
_ = writeWAV(&buf, left, right, sampleRate32)
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
type voice struct {
|
||||
freq float64
|
||||
channel int
|
||||
}
|
||||
|
||||
// renderChirp synthesises one chirp into the L/R buffers
|
||||
// starting at offset, with a trapezoidal attack/sustain/release
|
||||
// envelope.
|
||||
func renderChirp(left, right []float64, offset, length, attackN, releaseN int, voices []voice, sampleRate int) {
|
||||
if attackN+releaseN > length {
|
||||
attackN = length / 3
|
||||
releaseN = length / 3
|
||||
}
|
||||
|
||||
for i := 0; i < length; i++ {
|
||||
t := float64(i) / float64(sampleRate)
|
||||
env := 1.0
|
||||
|
||||
switch {
|
||||
case i < attackN:
|
||||
env = float64(i) / float64(attackN)
|
||||
case i >= length-releaseN:
|
||||
remaining := length - i
|
||||
env = float64(remaining) / float64(releaseN)
|
||||
}
|
||||
|
||||
for _, v := range voices {
|
||||
sample := math.Sin(2*math.Pi*v.freq*t) * env
|
||||
if v.channel == 0 {
|
||||
left[offset+i] += sample
|
||||
} else {
|
||||
right[offset+i] += sample
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalise scales L/R so the peak absolute value equals `peak`
|
||||
// (≤ 1.0). Keeps the chord sum below clipping without hardcoding
|
||||
// voice counts.
|
||||
func normalise(left, right []float64, peak float64) {
|
||||
maxVal := 0.0
|
||||
|
||||
for i := range left {
|
||||
if v := math.Abs(left[i]); v > maxVal {
|
||||
maxVal = v
|
||||
}
|
||||
|
||||
if v := math.Abs(right[i]); v > maxVal {
|
||||
maxVal = v
|
||||
}
|
||||
}
|
||||
|
||||
if maxVal == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
scale := peak / maxVal
|
||||
for i := range left {
|
||||
left[i] *= scale
|
||||
right[i] *= scale
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
wavChannels = 2
|
||||
wavBitsPer = 16
|
||||
)
|
||||
|
||||
// maxSampleRate is the largest sample rate writeWAV will accept
|
||||
// before clamping. Generous enough to allow studio-quality 192
|
||||
// kHz; well below the uint32 ceiling the WAV header field can
|
||||
// represent, and far below anything the byte-rate multiplication
|
||||
// downstream could overflow.
|
||||
const maxSampleRate uint32 = 192_000
|
||||
|
||||
// safeSampleRate converts the operator-supplied int sample rate
|
||||
// into the uint32 the WAV header needs, clamping anything
|
||||
// out-of-range to the default. Defence-in-depth: the
|
||||
// handler-side sampleRateParam already rejects unreasonable
|
||||
// inputs, but Render is exported so other callers (tests,
|
||||
// scripts) could pass anything.
|
||||
func safeSampleRate(in int) uint32 {
|
||||
if in <= 0 || in > int(maxSampleRate) {
|
||||
return uint32(DefaultOptions().SampleRate)
|
||||
}
|
||||
|
||||
return uint32(in)
|
||||
}
|
||||
|
||||
func writeWAV(w io.Writer, left, right []float64, sampleRate uint32) error {
|
||||
if len(left) != len(right) {
|
||||
return fmt.Errorf("channel length mismatch: %d vs %d", len(left), len(right))
|
||||
}
|
||||
|
||||
samples := len(left)
|
||||
dataBytes := samples * wavChannels * (wavBitsPer / 8)
|
||||
totalRIFFSize := 4 + (8 + 16) + (8 + dataBytes)
|
||||
|
||||
if _, err := w.Write([]byte("RIFF")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := binary.Write(w, binary.LittleEndian, uint32(totalRIFFSize)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := w.Write([]byte("WAVE")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := w.Write([]byte("fmt ")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := binary.Write(w, binary.LittleEndian, uint32(16)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := binary.Write(w, binary.LittleEndian, uint16(1)); err != nil { // PCM
|
||||
return err
|
||||
}
|
||||
|
||||
if err := binary.Write(w, binary.LittleEndian, uint16(wavChannels)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := binary.Write(w, binary.LittleEndian, sampleRate); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
byteRate := sampleRate * uint32(wavChannels) * uint32(wavBitsPer/8)
|
||||
if err := binary.Write(w, binary.LittleEndian, byteRate); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blockAlign := uint16(wavChannels * (wavBitsPer / 8))
|
||||
if err := binary.Write(w, binary.LittleEndian, blockAlign); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := binary.Write(w, binary.LittleEndian, uint16(wavBitsPer)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := w.Write([]byte("data")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := binary.Write(w, binary.LittleEndian, uint32(dataBytes)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := 0; i < samples; i++ {
|
||||
if err := binary.Write(w, binary.LittleEndian, floatToInt16(left[i])); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := binary.Write(w, binary.LittleEndian, floatToInt16(right[i])); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func floatToInt16(v float64) int16 {
|
||||
if v > 1.0 {
|
||||
v = 1.0
|
||||
} else if v < -1.0 {
|
||||
v = -1.0
|
||||
}
|
||||
|
||||
return int16(math.Round(v * 32767))
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package ding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRender_ProducesWAVHeader(t *testing.T) {
|
||||
data := Render(DefaultOptions())
|
||||
if len(data) < 44 {
|
||||
t.Fatalf("expected at least 44 bytes (WAV header), got %d", len(data))
|
||||
}
|
||||
|
||||
if !bytes.HasPrefix(data, []byte("RIFF")) {
|
||||
t.Errorf("expected RIFF prefix")
|
||||
}
|
||||
|
||||
if !bytes.Equal(data[8:12], []byte("WAVE")) {
|
||||
t.Errorf("expected WAVE format marker")
|
||||
}
|
||||
|
||||
if !bytes.Equal(data[12:16], []byte("fmt ")) {
|
||||
t.Errorf("expected fmt chunk")
|
||||
}
|
||||
|
||||
// PCM format
|
||||
if pcm := binary.LittleEndian.Uint16(data[20:22]); pcm != 1 {
|
||||
t.Errorf("expected PCM (1), got %d", pcm)
|
||||
}
|
||||
|
||||
// Channels
|
||||
if ch := binary.LittleEndian.Uint16(data[22:24]); ch != 2 {
|
||||
t.Errorf("expected stereo (2), got %d", ch)
|
||||
}
|
||||
|
||||
// Sample rate
|
||||
if sr := binary.LittleEndian.Uint32(data[24:28]); sr != 22050 {
|
||||
t.Errorf("expected default sample rate 22050, got %d", sr)
|
||||
}
|
||||
|
||||
// Bits per sample
|
||||
if bps := binary.LittleEndian.Uint16(data[34:36]); bps != 16 {
|
||||
t.Errorf("expected 16 bits/sample, got %d", bps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_DefaultSizeApproximately52KB(t *testing.T) {
|
||||
data := Render(DefaultOptions())
|
||||
|
||||
// Default: 22050 Hz * 2 channels * 2 bytes * 0.6 s = 52920 data
|
||||
// + ~44 byte header.
|
||||
const wantData = 22050 * 2 * 2 * 60 / 100 // 0.6 seconds, integer math
|
||||
if got := len(data); got < wantData || got > wantData+200 {
|
||||
t.Errorf("expected ~%d bytes, got %d", wantData, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_OverridePitchAffectsContent(t *testing.T) {
|
||||
a := Render(DefaultOptions())
|
||||
b := Render(Options{PitchHigh: 1200}.WithDefaults())
|
||||
|
||||
if bytes.Equal(a, b) {
|
||||
t.Errorf("expected different content for different PitchHigh values")
|
||||
}
|
||||
|
||||
// Same length (envelope doesn't change).
|
||||
if len(a) != len(b) {
|
||||
t.Errorf("expected same byte length: %d vs %d", len(a), len(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_OverrideSampleRateChangesByteRate(t *testing.T) {
|
||||
data := Render(Options{SampleRate: 44100}.WithDefaults())
|
||||
if sr := binary.LittleEndian.Uint32(data[24:28]); sr != 44100 {
|
||||
t.Errorf("expected 44100, got %d", sr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeSampleRate_ClampsOutOfRange(t *testing.T) {
|
||||
cases := []struct {
|
||||
in int
|
||||
want uint32
|
||||
}{
|
||||
{22050, 22050},
|
||||
{44100, 44100},
|
||||
{192000, 192000},
|
||||
{0, 22050}, // zero → default
|
||||
{-1, 22050}, // negative → default
|
||||
{200000, 22050}, // above max → default
|
||||
{1 << 31, 22050}, // way beyond uint32 → default (the original CodeQL concern)
|
||||
{1 << 33, 22050}, // wraps to a different value on int→uint32; default protects us
|
||||
{int(maxSampleRate) + 1, 22050},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if got := safeSampleRate(c.in); got != c.want {
|
||||
t.Errorf("safeSampleRate(%d) = %d, want %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_HugeSampleRateDoesNotTruncateOrPanic(t *testing.T) {
|
||||
// Regression for the int→uint32 truncation CodeQL flagged.
|
||||
// A caller (test, future SDK user) bypassing the handler's
|
||||
// sampleRateParam guard with an int well above uint32 used
|
||||
// to silently wrap. The defensive clamp now substitutes the
|
||||
// default sample rate instead.
|
||||
data := Render(Options{SampleRate: 1 << 33}.WithDefaults())
|
||||
if sr := binary.LittleEndian.Uint32(data[24:28]); sr != uint32(DefaultOptions().SampleRate) {
|
||||
t.Errorf("expected clamp to default sample rate, got %d", sr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithDefaults_FillsZeroFields(t *testing.T) {
|
||||
got := Options{PitchHigh: 1000}.WithDefaults()
|
||||
if got.PitchHigh != 1000 {
|
||||
t.Errorf("override should be preserved, got %f", got.PitchHigh)
|
||||
}
|
||||
|
||||
if got.SampleRate != 22050 {
|
||||
t.Errorf("expected default SampleRate, got %d", got.SampleRate)
|
||||
}
|
||||
|
||||
if got.PitchMid <= 0 {
|
||||
t.Errorf("expected PitchMid filled from default, got %f", got.PitchMid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithDefaults_ClampsInvalidPeak(t *testing.T) {
|
||||
got := Options{Peak: 2.0}.WithDefaults()
|
||||
if got.Peak != DefaultOptions().Peak {
|
||||
t.Errorf("expected default Peak for invalid input, got %f", got.Peak)
|
||||
}
|
||||
|
||||
got = Options{Peak: -0.5}.WithDefaults()
|
||||
if got.Peak != DefaultOptions().Peak {
|
||||
t.Errorf("expected default Peak for negative input, got %f", got.Peak)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_SamplesDontClip(t *testing.T) {
|
||||
data := Render(DefaultOptions())
|
||||
|
||||
// Walk every sample, ensure no value is at the 16-bit extreme
|
||||
// (which would indicate clipping). Header is 44 bytes.
|
||||
for i := 44; i+1 < len(data); i += 2 {
|
||||
s := int16(binary.LittleEndian.Uint16(data[i : i+2]))
|
||||
if s == 32767 || s == -32768 {
|
||||
t.Errorf("sample at offset %d hit clipping (%d)", i, s)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBX6szVJwwBCTTCdLiqkfJiwjEFOx/HwdQgsf/aPHsUN aftertouch-diagnostic@gesellix
|
||||
@@ -0,0 +1,54 @@
|
||||
// Package export provides encryption helpers for the diagnostic export feature.
|
||||
package export
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/agessh"
|
||||
)
|
||||
|
||||
// diagnosticPublicKey is embedded from diagnostic.pub at compile time.
|
||||
// diagnostic.pub is kept in sync with keys/public/diagnostic.pub by
|
||||
// scripts/setup-diagnostic-key.sh. To verify it matches the maintainer's
|
||||
// GitHub SSH keys:
|
||||
//
|
||||
// curl -s https://github.com/gesellix.keys | grep "$(awk '{print $2}' keys/public/diagnostic.pub)"
|
||||
//
|
||||
//go:embed diagnostic.pub
|
||||
var diagnosticPublicKeyRaw string
|
||||
|
||||
// diagnosticPublicKey returns the trimmed SSH public key line.
|
||||
func diagnosticPublicKey() string {
|
||||
return strings.TrimSpace(diagnosticPublicKeyRaw)
|
||||
}
|
||||
|
||||
// EncryptDiagnostic encrypts plaintext using the embedded SSH public key
|
||||
// and returns the age-encrypted bytes. The result can only be decrypted
|
||||
// with the corresponding SSH private key (keys/private/diagnostic).
|
||||
func EncryptDiagnostic(plaintext []byte) ([]byte, error) {
|
||||
recipient, err := agessh.ParseRecipient(diagnosticPublicKey())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse recipient key: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
w, err := age.Encrypt(&buf, recipient)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("age encrypt: %w", err)
|
||||
}
|
||||
|
||||
if _, err := w.Write(plaintext); err != nil {
|
||||
return nil, fmt.Errorf("write plaintext: %w", err)
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close age writer: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"filippo.io/age/agessh"
|
||||
)
|
||||
|
||||
// TestDiagnosticPublicKeyParseable ensures the embedded diagnostic.pub is a
|
||||
// valid SSH public key that age can use as a recipient. Catches a truncated or
|
||||
// corrupted embed before it reaches a user trying to send a report.
|
||||
func TestDiagnosticPublicKeyParseable(t *testing.T) {
|
||||
key := diagnosticPublicKey()
|
||||
if key == "" {
|
||||
t.Fatal("embedded diagnostic.pub is empty")
|
||||
}
|
||||
if _, err := agessh.ParseRecipient(key); err != nil {
|
||||
t.Errorf("embedded diagnostic.pub is not a valid age SSH recipient: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,27 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// dirsToSkip names docs/ subdirectories whose contents are not meant
|
||||
// to appear in SUMMARY.md. These are asset / partial / archive trees,
|
||||
// not narrative documentation:
|
||||
//
|
||||
// - _includes : HTML partials consumed by the docs site renderer
|
||||
// - archive : superseded plans / status reports kept for the
|
||||
// record but deliberately unlinked
|
||||
// - diagrams : Mermaid sources for embedded diagrams
|
||||
// - images : binary assets + a directory README that explains them
|
||||
//
|
||||
// Adding a new top-level dir under docs/ does NOT require touching
|
||||
// this list — only add the dir name here when its contents should
|
||||
// stay out of SUMMARY.md by design. Individual file exclusions live
|
||||
// in .docsignore instead.
|
||||
var dirsToSkip = map[string]bool{
|
||||
"_includes": true,
|
||||
"archive": true,
|
||||
"diagrams": true,
|
||||
"images": true,
|
||||
}
|
||||
|
||||
func TestDocsConsistency(t *testing.T) {
|
||||
// Root of the project relative to this test file
|
||||
// The test runs in the directory of the package
|
||||
@@ -22,61 +43,73 @@ func TestDocsConsistency(t *testing.T) {
|
||||
}
|
||||
|
||||
summaryText := string(summaryContent)
|
||||
|
||||
docsIgnore := readDocsIgnore(t, filepath.Join(projectRoot, ".docsignore"))
|
||||
|
||||
// List of directories to check
|
||||
dirsToCheck := []string{".", "guides", "reference", "analysis"}
|
||||
// Walk the entire docs tree. Directory-level exclusions live in
|
||||
// dirsToSkip above (asset / archive trees); file-level exclusions
|
||||
// live in .docsignore (individual narrative docs that are
|
||||
// intentionally unlinked). New subdirectories are picked up
|
||||
// automatically — this is the behaviour amazon-music-oauth.md
|
||||
// surprised us by lacking.
|
||||
err = filepath.WalkDir(docsDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, dir := range dirsToCheck {
|
||||
dirPath := filepath.Join(docsDir, dir)
|
||||
err := filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
// Don't recurse into subdirectories if we are checking the root,
|
||||
// as they are handled separately or ignored (like archive)
|
||||
if dir == "." && path != dirPath {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(d.Name(), ".md") {
|
||||
if d.IsDir() {
|
||||
if path == docsDir {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip SUMMARY.md itself
|
||||
if d.Name() == "SUMMARY.md" {
|
||||
return nil
|
||||
rel, relErr := filepath.Rel(docsDir, path)
|
||||
if relErr != nil {
|
||||
return relErr
|
||||
}
|
||||
|
||||
// Skip files listed in .docsignore at the project root
|
||||
for _, skip := range docsIgnore {
|
||||
if strings.HasSuffix(path, filepath.FromSlash(skip)) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get relative path from docs/
|
||||
relPath, err := filepath.Rel(docsDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if this file is linked in SUMMARY.md
|
||||
// We look for [Label](relPath)
|
||||
linkPattern := "(" + relPath + ")"
|
||||
if !strings.Contains(summaryText, linkPattern) {
|
||||
t.Errorf("Documentation file %s is not linked in docs/SUMMARY.md", relPath)
|
||||
// Skip only top-level asset / archive directories. Nested
|
||||
// directories inside narrative trees (e.g. docs/guides/foo/)
|
||||
// would still be walked.
|
||||
if !strings.ContainsRune(rel, filepath.Separator) && dirsToSkip[d.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Error walking directory %s: %v", dir, err)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(d.Name(), ".md") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip SUMMARY.md itself
|
||||
if d.Name() == "SUMMARY.md" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip files listed in .docsignore at the project root
|
||||
for _, skip := range docsIgnore {
|
||||
if strings.HasSuffix(path, filepath.FromSlash(skip)) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get relative path from docs/
|
||||
relPath, err := filepath.Rel(docsDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if this file is linked in SUMMARY.md
|
||||
// We look for [Label](relPath)
|
||||
linkPattern := "(" + relPath + ")"
|
||||
if !strings.Contains(summaryText, linkPattern) {
|
||||
t.Errorf("Documentation file %s is not linked in docs/SUMMARY.md", relPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Error walking docs directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/health"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// deviceSummary is the wire shape for GET /setup/device-summary/{deviceId}.
|
||||
// Each sub-section is independently populated so partial failures
|
||||
// (e.g. speaker unreachable but service-side state available)
|
||||
// still produce a useful payload.
|
||||
type deviceSummary struct {
|
||||
Device deviceSummaryDevice `json:"device"`
|
||||
Speaker deviceSummarySpeaker `json:"speaker"`
|
||||
Service deviceSummaryService `json:"service"`
|
||||
Pairing deviceSummaryPairing `json:"pairing"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
}
|
||||
|
||||
type deviceSummaryDevice struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
AccountID string `json:"account_id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
IPAddress string `json:"ip_address,omitempty"`
|
||||
ProductCode string `json:"product_code,omitempty"`
|
||||
FirmwareVersion string `json:"firmware_version,omitempty"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
MacAddress string `json:"mac_address,omitempty"`
|
||||
}
|
||||
|
||||
type probeOutcome struct {
|
||||
Reachable bool `json:"reachable"`
|
||||
StatusCode int `json:"status_code,omitempty"`
|
||||
Err string `json:"error,omitempty"`
|
||||
CurlCommand string `json:"curl_command,omitempty"`
|
||||
}
|
||||
|
||||
type deviceSummarySpeaker struct {
|
||||
Info speakerInfoSummary `json:"info"`
|
||||
Sources speakerSourcesSummary `json:"sources"`
|
||||
Presets speakerPresetsSummary `json:"presets"`
|
||||
}
|
||||
|
||||
type speakerInfoSummary struct {
|
||||
probeOutcome
|
||||
|
||||
Name string `json:"name,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
MargeAccountUUID string `json:"marge_account_uuid,omitempty"`
|
||||
MargeURL string `json:"marge_url,omitempty"`
|
||||
}
|
||||
|
||||
type speakerSourcesSummary struct {
|
||||
probeOutcome
|
||||
|
||||
Types []string `json:"types,omitempty"`
|
||||
}
|
||||
|
||||
type speakerPresetsSummary struct {
|
||||
probeOutcome
|
||||
|
||||
IDs []string `json:"ids,omitempty"`
|
||||
}
|
||||
|
||||
type deviceSummaryService struct {
|
||||
ServerURL string `json:"server_url,omitempty"`
|
||||
ExpectedHosts []string `json:"expected_hosts,omitempty"`
|
||||
SourcesXMLPresent bool `json:"sources_xml_present"`
|
||||
ServiceSourceTypes []string `json:"service_source_types,omitempty"`
|
||||
PresetsXMLPresent bool `json:"presets_xml_present"`
|
||||
ServicePresetCount int `json:"service_preset_count"`
|
||||
}
|
||||
|
||||
type deviceSummaryPairing struct {
|
||||
Paired bool `json:"paired"`
|
||||
SpeakerMargeHost string `json:"speaker_marge_host,omitempty"`
|
||||
MargeURLMatchesService bool `json:"marge_url_matches_service"`
|
||||
}
|
||||
|
||||
// HandleDeviceSummary returns a one-shot aggregate of the speaker
|
||||
// state (/info + /sources + /presets) plus the service-side
|
||||
// equivalents, plus the pairing inference. Useful as a "what
|
||||
// does this speaker think is true right now" probe — bundles
|
||||
// what operators today fetch piecewise across several issues'
|
||||
// debug threads.
|
||||
//
|
||||
// Probes run concurrently. Partial failures don't break the
|
||||
// response: each sub-section carries its own reachability /
|
||||
// error fields, and the curl command is always populated so the
|
||||
// UI can render a paste-helper when the server can't reach the
|
||||
// speaker (cloud-deployment topology).
|
||||
func (s *Server) HandleDeviceSummary(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, "deviceId is required")
|
||||
return
|
||||
}
|
||||
|
||||
devices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "list devices: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var match *struct {
|
||||
account string
|
||||
device string
|
||||
ip string
|
||||
name string
|
||||
product string
|
||||
fw string
|
||||
serial string
|
||||
mac string
|
||||
}
|
||||
|
||||
for i := range devices {
|
||||
d := &devices[i]
|
||||
if d.DeviceID == deviceID {
|
||||
match = &struct {
|
||||
account string
|
||||
device string
|
||||
ip string
|
||||
name string
|
||||
product string
|
||||
fw string
|
||||
serial string
|
||||
mac string
|
||||
}{
|
||||
account: d.AccountID,
|
||||
device: d.DeviceID,
|
||||
ip: d.IPAddress,
|
||||
name: d.Name,
|
||||
product: d.ProductCode,
|
||||
fw: d.FirmwareVersion,
|
||||
serial: d.DeviceSerialNumber,
|
||||
mac: d.MacAddress,
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if match == nil {
|
||||
writeJSONError(w, http.StatusNotFound, "device not found: "+deviceID)
|
||||
return
|
||||
}
|
||||
|
||||
summary := deviceSummary{
|
||||
Device: deviceSummaryDevice{
|
||||
DeviceID: match.device,
|
||||
AccountID: match.account,
|
||||
Name: match.name,
|
||||
IPAddress: match.ip,
|
||||
ProductCode: match.product,
|
||||
FirmwareVersion: match.fw,
|
||||
SerialNumber: match.serial,
|
||||
MacAddress: match.mac,
|
||||
},
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Service-side: cheap, no probes.
|
||||
serverURL, _ := s.GetSettings()
|
||||
summary.Service.ServerURL = serverURL
|
||||
summary.Service.ExpectedHosts = s.ExpectedHosts()
|
||||
|
||||
if s.ds.HasConfiguredSources(match.account, match.device) {
|
||||
summary.Service.SourcesXMLPresent = true
|
||||
|
||||
if sources, err := s.ds.GetConfiguredSources(match.account, match.device); err == nil {
|
||||
seen := map[string]bool{}
|
||||
|
||||
for i := range sources {
|
||||
t := sources[i].SourceKey.Type
|
||||
if t != "" && !seen[t] {
|
||||
seen[t] = true
|
||||
|
||||
summary.Service.ServiceSourceTypes = append(summary.Service.ServiceSourceTypes, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if presets, err := s.ds.GetPresets(match.account, match.device); err == nil {
|
||||
summary.Service.ServicePresetCount = len(presets)
|
||||
summary.Service.PresetsXMLPresent = len(presets) > 0
|
||||
}
|
||||
|
||||
// Speaker-side: probe concurrently.
|
||||
if match.ip != "" {
|
||||
probeContext, cancel := context.WithTimeout(r.Context(), 4*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(3)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
summary.Speaker.Info = fetchSpeakerInfo(probeContext, match.ip)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
summary.Speaker.Sources = fetchSpeakerSources(probeContext, match.ip)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
summary.Speaker.Presets = fetchSpeakerPresets(probeContext, match.ip)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Pairing inference: derive from what we've learned.
|
||||
if summary.Speaker.Info.Reachable {
|
||||
summary.Pairing.Paired = summary.Speaker.Info.MargeAccountUUID != ""
|
||||
summary.Pairing.SpeakerMargeHost = hostFromURL(summary.Speaker.Info.MargeURL)
|
||||
summary.Pairing.MargeURLMatchesService = pairingHostMatches(
|
||||
summary.Pairing.SpeakerMargeHost,
|
||||
summary.Service.ExpectedHosts,
|
||||
)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(summary); err != nil {
|
||||
http.Error(w, "encode: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSpeakerInfo(ctx context.Context, ip string) speakerInfoSummary {
|
||||
url := fmt.Sprintf("http://%s:8090/info", ip)
|
||||
res := health.ProbeGet(ctx, url, 3*time.Second)
|
||||
|
||||
out := speakerInfoSummary{
|
||||
probeOutcome: probeOutcome{
|
||||
Reachable: res.Reachable,
|
||||
StatusCode: res.Status,
|
||||
Err: res.Err,
|
||||
CurlCommand: res.CurlCommand,
|
||||
},
|
||||
}
|
||||
|
||||
if !res.Reachable || res.Status != 200 {
|
||||
return out
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
XMLName xml.Name `xml:"info"`
|
||||
Name string `xml:"name"`
|
||||
Type string `xml:"type"`
|
||||
MargeAccountUUID string `xml:"margeAccountUUID"`
|
||||
MargeURL string `xml:"margeURL"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
|
||||
out.Err = "parse: " + err.Error()
|
||||
return out
|
||||
}
|
||||
|
||||
out.Name = parsed.Name
|
||||
out.Type = parsed.Type
|
||||
out.MargeAccountUUID = parsed.MargeAccountUUID
|
||||
out.MargeURL = parsed.MargeURL
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func fetchSpeakerSources(ctx context.Context, ip string) speakerSourcesSummary {
|
||||
url := fmt.Sprintf("http://%s:8090/sources", ip)
|
||||
res := health.ProbeGet(ctx, url, 3*time.Second)
|
||||
|
||||
out := speakerSourcesSummary{
|
||||
probeOutcome: probeOutcome{
|
||||
Reachable: res.Reachable,
|
||||
StatusCode: res.Status,
|
||||
Err: res.Err,
|
||||
CurlCommand: res.CurlCommand,
|
||||
},
|
||||
}
|
||||
|
||||
if !res.Reachable || res.Status != 200 {
|
||||
return out
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
XMLName xml.Name `xml:"sources"`
|
||||
Items []struct {
|
||||
Source string `xml:"source,attr"`
|
||||
} `xml:"sourceItem"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
|
||||
out.Err = "parse: " + err.Error()
|
||||
return out
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
|
||||
for i := range parsed.Items {
|
||||
s := parsed.Items[i].Source
|
||||
if s != "" && !seen[s] {
|
||||
seen[s] = true
|
||||
out.Types = append(out.Types, s)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func fetchSpeakerPresets(ctx context.Context, ip string) speakerPresetsSummary {
|
||||
url := fmt.Sprintf("http://%s:8090/presets", ip)
|
||||
res := health.ProbeGet(ctx, url, 3*time.Second)
|
||||
|
||||
out := speakerPresetsSummary{
|
||||
probeOutcome: probeOutcome{
|
||||
Reachable: res.Reachable,
|
||||
StatusCode: res.Status,
|
||||
Err: res.Err,
|
||||
CurlCommand: res.CurlCommand,
|
||||
},
|
||||
}
|
||||
|
||||
if !res.Reachable || res.Status != 200 {
|
||||
return out
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
XMLName xml.Name `xml:"presets"`
|
||||
Presets []struct {
|
||||
ID string `xml:"id,attr"`
|
||||
} `xml:"preset"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
|
||||
out.Err = "parse: " + err.Error()
|
||||
return out
|
||||
}
|
||||
|
||||
for i := range parsed.Presets {
|
||||
if id := parsed.Presets[i].ID; id != "" {
|
||||
out.IDs = append(out.IDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func hostFromURL(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Trim scheme prefix
|
||||
for _, scheme := range []string{"https://", "http://"} {
|
||||
if strings.HasPrefix(raw, scheme) {
|
||||
raw = raw[len(scheme):]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Trim path
|
||||
if i := strings.IndexByte(raw, '/'); i >= 0 {
|
||||
raw = raw[:i]
|
||||
}
|
||||
|
||||
// Trim port
|
||||
if i := strings.IndexByte(raw, ':'); i >= 0 {
|
||||
raw = raw[:i]
|
||||
}
|
||||
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
func pairingHostMatches(host string, expected []string) bool {
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
host = strings.ToLower(host)
|
||||
|
||||
for _, h := range expected {
|
||||
if strings.ToLower(strings.TrimSpace(h)) == host {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestHandleDeviceSummary_UnknownDevice(t *testing.T) {
|
||||
tempDir, _ := os.MkdirTemp("", "summary-test-*")
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_, server := setupRouter("http://aftertouch.local", ds)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/setup/device-summary/{deviceId}", server.HandleDeviceSummary)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
res, err := http.Get(ts.URL + "/setup/device-summary/UNKNOWN")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404 for unknown device, got %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDeviceSummary_UnreachableSpeakerStillReturnsServiceState(t *testing.T) {
|
||||
tempDir, _ := os.MkdirTemp("", "summary-test-*")
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
_ = ds.SaveDeviceInfo("1000001", "DEVICEID01", &models.ServiceDeviceInfo{
|
||||
DeviceID: "DEVICEID01",
|
||||
AccountID: "1000001",
|
||||
Name: "TestSpeaker",
|
||||
IPAddress: "127.0.0.1:1", // refused port; probe fails
|
||||
ProductCode: "SoundTouch 20",
|
||||
FirmwareVersion: "27.0.6.46330.5043500",
|
||||
})
|
||||
|
||||
_, server := setupRouter("http://aftertouch.local", ds)
|
||||
server.SetExpectedHosts([]string{"aftertouch.local"})
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/setup/device-summary/{deviceId}", server.HandleDeviceSummary)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
res, err := http.Get(ts.URL + "/setup/device-summary/DEVICEID01")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var got deviceSummary
|
||||
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if got.Device.DeviceID != "DEVICEID01" {
|
||||
t.Errorf("unexpected device_id: %q", got.Device.DeviceID)
|
||||
}
|
||||
|
||||
if got.Speaker.Info.Reachable {
|
||||
t.Errorf("expected unreachable speaker.info, got reachable=true")
|
||||
}
|
||||
|
||||
if got.Speaker.Info.CurlCommand == "" {
|
||||
t.Errorf("expected curl_command populated even on failure")
|
||||
}
|
||||
|
||||
if got.Service.ServerURL != "http://aftertouch.local" {
|
||||
t.Errorf("expected service.server_url to surface, got %q", got.Service.ServerURL)
|
||||
}
|
||||
|
||||
if got.Pairing.Paired {
|
||||
t.Errorf("expected paired=false when speaker unreachable")
|
||||
}
|
||||
|
||||
if got.GeneratedAt == "" {
|
||||
t.Errorf("expected generated_at populated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDeviceSummary_ReachableSpeakerPopulatesAggregate(t *testing.T) {
|
||||
// Stub speaker server serves /info, /sources, /presets.
|
||||
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/info":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="DEVICEID01">
|
||||
<name>TestSpeaker</name>
|
||||
<type>SoundTouch 20</type>
|
||||
<margeAccountUUID>1000001</margeAccountUUID>
|
||||
<margeURL>https://aftertouch.local/</margeURL>
|
||||
</info>`))
|
||||
case "/sources":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sources deviceID="DEVICEID01">
|
||||
<sourceItem source="TUNEIN" status="READY"/>
|
||||
<sourceItem source="AUX" status="READY"/>
|
||||
</sources>`))
|
||||
case "/presets":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1"><ContentItem source="TUNEIN"/></preset>
|
||||
<preset id="2"><ContentItem source="AUX"/></preset>
|
||||
</presets>`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer speaker.Close()
|
||||
|
||||
speakerHost := mustParseHost(t, speaker.URL)
|
||||
|
||||
tempDir, _ := os.MkdirTemp("", "summary-test-*")
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
_ = ds.SaveDeviceInfo("1000001", "DEVICEID01", &models.ServiceDeviceInfo{
|
||||
DeviceID: "DEVICEID01",
|
||||
AccountID: "1000001",
|
||||
Name: "TestSpeaker",
|
||||
IPAddress: speakerHost, // hostport pointing at the stub
|
||||
})
|
||||
|
||||
_, server := setupRouter("http://aftertouch.local", ds)
|
||||
server.SetExpectedHosts([]string{"aftertouch.local"})
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/setup/device-summary/{deviceId}", server.HandleDeviceSummary)
|
||||
|
||||
// Override the speaker URL inside the handler. The production
|
||||
// code builds http://<ip>:8090/info from the device's
|
||||
// IPAddress. We point IPAddress at host:port directly, so
|
||||
// the resulting URL is `http://host:port:8090/info` —
|
||||
// invalid. The summary will report unreachable and we'll
|
||||
// assert via the partial response.
|
||||
//
|
||||
// For a more realistic end-to-end probe test we'd need to
|
||||
// either inject a custom speaker URL or refactor the probe
|
||||
// to accept an explicit target. Both are bigger lifts; the
|
||||
// pure-data path is exercised by other tests in this package.
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
res, err := http.Get(ts.URL + "/setup/device-summary/DEVICEID01")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var got deviceSummary
|
||||
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if got.Device.Name != "TestSpeaker" {
|
||||
t.Errorf("unexpected device.name: %q", got.Device.Name)
|
||||
}
|
||||
|
||||
// We can at least assert the curl command points at the
|
||||
// configured IP with the canonical :8090 path.
|
||||
if !strings.Contains(got.Speaker.Info.CurlCommand, ":8090/info") {
|
||||
t.Errorf("expected info curl to target :8090, got %q", got.Speaker.Info.CurlCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostFromURL_StripsSchemeAndPort(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"https://example.com/", "example.com"},
|
||||
{"http://Example.COM:8443/path", "example.com"},
|
||||
{"https://192.0.2.10", "192.0.2.10"},
|
||||
{"example.com", "example.com"},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if got := hostFromURL(c.in); got != c.want {
|
||||
t.Errorf("hostFromURL(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairingHostMatches(t *testing.T) {
|
||||
if !pairingHostMatches("aftertouch.local", []string{"AFTERTOUCH.local", "example.com"}) {
|
||||
t.Errorf("expected case-insensitive match")
|
||||
}
|
||||
|
||||
if pairingHostMatches("", []string{"aftertouch.local"}) {
|
||||
t.Errorf("empty host should not match")
|
||||
}
|
||||
|
||||
if pairingHostMatches("other.example", []string{"aftertouch.local"}) {
|
||||
t.Errorf("unmatched host should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseHost(t *testing.T, raw string) string {
|
||||
t.Helper()
|
||||
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", raw, err)
|
||||
}
|
||||
|
||||
return u.Host
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/ding"
|
||||
)
|
||||
|
||||
// dingDefaultCache holds the rendered bytes for the default
|
||||
// option set. Computed once on first request; subsequent default
|
||||
// requests are served from cache without re-synthesising.
|
||||
var dingDefaultCache struct {
|
||||
once sync.Once
|
||||
data []byte
|
||||
}
|
||||
|
||||
// HandleDing serves the AfterTouch "ding" signature audio at
|
||||
// GET /media/aftertouch-ding.wav. All knobs are optional; the
|
||||
// default option set is used when no query parameters are
|
||||
// supplied. Unrecognised parameters and out-of-range values
|
||||
// (NaN, negatives where positive is required, etc.) silently
|
||||
// fall back to defaults — this is a "play around with it"
|
||||
// endpoint, not a strict API.
|
||||
//
|
||||
// Supported knobs (all optional; defaults apply per missing param):
|
||||
//
|
||||
// pitch-high Hz, float; default 880 (A5, top row)
|
||||
// pitch-mid Hz, float; default 659.25 (E5, mid row)
|
||||
// pitch-low Hz, float; default 440 (A4, bottom row)
|
||||
// chirp-ms int milliseconds; default 250
|
||||
// gap-ms int milliseconds; default 100
|
||||
// attack-ms int milliseconds; default 20
|
||||
// release-ms int milliseconds; default 60
|
||||
// sample-rate Hz, int; default 22050
|
||||
// peak 0..1 float; default 0.85
|
||||
//
|
||||
// The default option set is rendered once via sync.Once and the
|
||||
// resulting bytes are reused across subsequent default requests —
|
||||
// first GET pays the synthesis cost (~ms), the rest serve from
|
||||
// memory. Requests with any override always re-synthesise.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// curl 'http://localhost:8000/media/aftertouch-ding.wav?pitch-high=1320&chirp-ms=150' > short.wav
|
||||
//
|
||||
// See pkg/service/ding for the renderer, scripts/gen-aftertouch-ding
|
||||
// for the offline-CLI equivalent that takes the same knobs as flags.
|
||||
func (s *Server) HandleDing(w http.ResponseWriter, r *http.Request) {
|
||||
opts, isDefault := parseDingOptions(r)
|
||||
|
||||
var data []byte
|
||||
|
||||
if isDefault {
|
||||
dingDefaultCache.once.Do(func() {
|
||||
dingDefaultCache.data = ding.Render(ding.DefaultOptions())
|
||||
})
|
||||
data = dingDefaultCache.data
|
||||
} else {
|
||||
data = ding.Render(opts)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "audio/wav")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// parseDingOptions reads the supported query knobs and returns
|
||||
// the resulting Options. isDefault is true when no overrides
|
||||
// were supplied — lets the caller serve from cache.
|
||||
func parseDingOptions(r *http.Request) (ding.Options, bool) {
|
||||
q := r.URL.Query()
|
||||
if len(q) == 0 {
|
||||
return ding.DefaultOptions(), true
|
||||
}
|
||||
|
||||
opts := ding.Options{}
|
||||
touched := false
|
||||
|
||||
if v, ok := floatParam(q.Get("pitch-high")); ok {
|
||||
opts.PitchHigh = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if v, ok := floatParam(q.Get("pitch-mid")); ok {
|
||||
opts.PitchMid = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if v, ok := floatParam(q.Get("pitch-low")); ok {
|
||||
opts.PitchLow = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if v, ok := millisecondsParam(q.Get("chirp-ms")); ok {
|
||||
opts.ChirpDuration = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if v, ok := millisecondsParam(q.Get("gap-ms")); ok {
|
||||
opts.GapDuration = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if v, ok := millisecondsParam(q.Get("attack-ms")); ok {
|
||||
opts.AttackDuration = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if v, ok := millisecondsParam(q.Get("release-ms")); ok {
|
||||
opts.ReleaseDuration = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if v, ok := sampleRateParam(q.Get("sample-rate")); ok {
|
||||
opts.SampleRate = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if v, ok := floatParam(q.Get("peak")); ok {
|
||||
opts.Peak = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if !touched {
|
||||
return ding.DefaultOptions(), true
|
||||
}
|
||||
|
||||
return opts.WithDefaults(), false
|
||||
}
|
||||
|
||||
func floatParam(raw string) (float64, bool) {
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
v, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil || v <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return v, true
|
||||
}
|
||||
|
||||
func millisecondsParam(raw string) (float64, bool) {
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return float64(v) / 1000.0, true
|
||||
}
|
||||
|
||||
// dingMinSampleRate / dingMaxSampleRate gate the operator-supplied
|
||||
// sample-rate against int→uint32 truncation and against values
|
||||
// outside any realistic audio range. The upper bound is generous
|
||||
// (192 kHz is studio-quality) but well below the uint32 ceiling
|
||||
// the WAV header field can hold.
|
||||
const (
|
||||
dingMinSampleRate = 8000
|
||||
dingMaxSampleRate = 192000
|
||||
)
|
||||
|
||||
func sampleRateParam(raw string) (int, bool) {
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v < dingMinSampleRate || v > dingMaxSampleRate {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return v, true
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func newDingTestServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
_, server := setupRouter("http://localhost:8001", nil)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/media/aftertouch-ding.wav", server.HandleDing)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
return ts
|
||||
}
|
||||
|
||||
func TestHandleDing_DefaultIsValidWAV(t *testing.T) {
|
||||
ts := newDingTestServer(t)
|
||||
|
||||
res, err := http.Get(ts.URL + "/media/aftertouch-ding.wav")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
t.Fatalf("expected 200, got %d", res.StatusCode)
|
||||
}
|
||||
|
||||
if ct := res.Header.Get("Content-Type"); ct != "audio/wav" {
|
||||
t.Errorf("expected audio/wav, got %q", ct)
|
||||
}
|
||||
|
||||
body := readAll(t, res)
|
||||
if !bytes.HasPrefix(body, []byte("RIFF")) {
|
||||
t.Errorf("expected RIFF prefix")
|
||||
}
|
||||
|
||||
if !bytes.Equal(body[8:12], []byte("WAVE")) {
|
||||
t.Errorf("expected WAVE format marker")
|
||||
}
|
||||
|
||||
if sr := binary.LittleEndian.Uint32(body[24:28]); sr != 22050 {
|
||||
t.Errorf("expected default sample rate 22050, got %d", sr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDing_OverrideSampleRate(t *testing.T) {
|
||||
ts := newDingTestServer(t)
|
||||
|
||||
res, err := http.Get(ts.URL + "/media/aftertouch-ding.wav?sample-rate=44100")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
body := readAll(t, res)
|
||||
if sr := binary.LittleEndian.Uint32(body[24:28]); sr != 44100 {
|
||||
t.Errorf("expected sample rate 44100, got %d", sr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDing_InvalidParamFallsBackToDefault(t *testing.T) {
|
||||
ts := newDingTestServer(t)
|
||||
|
||||
res, err := http.Get(ts.URL + "/media/aftertouch-ding.wav?sample-rate=notanumber&pitch-high=-50")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
t.Errorf("expected 200 even with bad params, got %d", res.StatusCode)
|
||||
}
|
||||
|
||||
body := readAll(t, res)
|
||||
if sr := binary.LittleEndian.Uint32(body[24:28]); sr != 22050 {
|
||||
t.Errorf("invalid sample-rate should fall back to default, got %d", sr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDing_OutOfRangeSampleRateFallsBackToDefault(t *testing.T) {
|
||||
// CodeQL flagged the int→uint32 truncation; reject values
|
||||
// outside the sane audio range at the parser, so neither
|
||||
// the WAV header nor the int→uint32 cast can be tricked.
|
||||
cases := []string{
|
||||
"1", // below dingMinSampleRate
|
||||
"7999", // just under the floor
|
||||
"500000", // above dingMaxSampleRate
|
||||
"4294967300", // > uint32 — the truncation source
|
||||
}
|
||||
|
||||
ts := newDingTestServer(t)
|
||||
|
||||
for _, sr := range cases {
|
||||
t.Run(sr, func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/media/aftertouch-ding.wav?sample-rate=" + sr)
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
body := readAll(t, res)
|
||||
if got := binary.LittleEndian.Uint32(body[24:28]); got != 22050 {
|
||||
t.Errorf("sample-rate=%s should clamp to default 22050, got %d", sr, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDing_DefaultIsCached(t *testing.T) {
|
||||
// Reset the cache by simulating a fresh process.
|
||||
dingDefaultCache.once = sync.Once{}
|
||||
dingDefaultCache.data = nil
|
||||
|
||||
ts := newDingTestServer(t)
|
||||
|
||||
resA, err := http.Get(ts.URL + "/media/aftertouch-ding.wav")
|
||||
if err != nil {
|
||||
t.Fatalf("first GET: %v", err)
|
||||
}
|
||||
a := readAll(t, resA)
|
||||
|
||||
resB, err := http.Get(ts.URL + "/media/aftertouch-ding.wav")
|
||||
if err != nil {
|
||||
t.Fatalf("second GET: %v", err)
|
||||
}
|
||||
b := readAll(t, resB)
|
||||
|
||||
if !bytes.Equal(a, b) {
|
||||
t.Errorf("expected cached default to be byte-identical across requests")
|
||||
}
|
||||
}
|
||||
|
||||
func readAll(t *testing.T, res *http.Response) []byte {
|
||||
t.Helper()
|
||||
|
||||
defer res.Body.Close()
|
||||
|
||||
body := new(bytes.Buffer)
|
||||
if _, err := body.ReadFrom(res.Body); err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
|
||||
return body.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/export"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/health"
|
||||
speakerssh "github.com/gesellix/bose-soundtouch/pkg/ssh"
|
||||
)
|
||||
|
||||
// diagnosticReport is the structured summary included as diagnostic.json
|
||||
// inside the encrypted archive. Raw datastore XML files are added verbatim
|
||||
// alongside it so the maintainer can compare on-disk state with what the
|
||||
// service serves.
|
||||
type diagnosticReport struct {
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
ServiceVersion map[string]string `json:"service_version"`
|
||||
HealthChecks []health.CheckResult `json:"health_checks"`
|
||||
Devices []deviceDiagnostic `json:"devices"`
|
||||
}
|
||||
|
||||
type deviceDiagnostic struct {
|
||||
AccountID string `json:"account_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ProductCode string `json:"product_code,omitempty"`
|
||||
FirmwareVersion string `json:"firmware_version,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
IPAddress string `json:"ip_address,omitempty"`
|
||||
Sources []sourceDiagnostic `json:"sources,omitempty"`
|
||||
Presets []presetDiagnostic `json:"presets,omitempty"`
|
||||
}
|
||||
|
||||
type sourceDiagnostic struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
SourceKeyType string `json:"source_key_type,omitempty"`
|
||||
ProviderID string `json:"provider_id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type presetDiagnostic struct {
|
||||
Slot string `json:"slot"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
}
|
||||
|
||||
// HandleExportDiagnostic builds a tar.gz archive containing a structured JSON
|
||||
// summary plus the raw datastore XML files verbatim, encrypts the archive with
|
||||
// the maintainer's embedded SSH public key (age/agessh), and returns it as a
|
||||
// downloadable .age file. Credentials and authentication tokens are
|
||||
// intentionally excluded from the JSON summary; XML files are included as-is.
|
||||
func (s *Server) HandleExportDiagnostic(w http.ResponseWriter, _ *http.Request) {
|
||||
archive, err := s.buildDiagnosticArchive()
|
||||
if err != nil {
|
||||
log.Printf("[Export] build archive: %v", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, "failed to build diagnostic archive")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
encrypted, err := export.EncryptDiagnostic(archive)
|
||||
if err != nil {
|
||||
log.Printf("[Export] encrypt: %v", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, "failed to encrypt diagnostic archive")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ts := time.Now().UTC().Format("2006-01-02T15-04-05Z")
|
||||
filename := fmt.Sprintf("aftertouch-diagnostic-%s.age", ts)
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
|
||||
if _, err := w.Write(encrypted); err != nil {
|
||||
log.Printf("[Export] write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildDiagnosticArchive returns a gzipped tar archive containing:
|
||||
// - diagnostic.json — structured health/device summary
|
||||
// - datastore/accounts/{account}/devices/{device}/*.xml — raw XML verbatim
|
||||
// - http/service/... — HTTP responses from the local service endpoints
|
||||
// - http/speaker/... — HTTP responses from each speaker's local API (port 8090)
|
||||
// - system/ca.pem — service CA certificate (if configured)
|
||||
// - system/resolv.conf — host DNS resolver configuration
|
||||
// - settings.json — service settings with secrets redacted
|
||||
// - env.txt — filtered process environment
|
||||
func (s *Server) buildDiagnosticArchive() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
|
||||
report := s.buildDiagnosticReport()
|
||||
|
||||
jsonBytes, err := json.MarshalIndent(report, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal summary: %w", err)
|
||||
}
|
||||
|
||||
if addErr := addTarBytes(tw, "diagnostic.json", jsonBytes); addErr != nil {
|
||||
return nil, fmt.Errorf("add diagnostic.json: %w", addErr)
|
||||
}
|
||||
|
||||
devices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
log.Printf("[Export] list devices: %v", err)
|
||||
}
|
||||
|
||||
for i := range devices {
|
||||
dev := &devices[i]
|
||||
dir := s.ds.AccountDeviceDir(dev.AccountID, dev.DeviceID)
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
log.Printf("[Export] read dir %s: %v", dir, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".xml") {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||
if err != nil {
|
||||
log.Printf("[Export] read %s: %v", entry.Name(), err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
archivePath := "datastore/accounts/" + dev.AccountID + "/devices/" + dev.DeviceID + "/" + entry.Name()
|
||||
if err := addTarBytes(tw, archivePath, data); err != nil {
|
||||
log.Printf("[Export] add %s: %v", archivePath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client := diagHTTPClient()
|
||||
s.addServiceHTTP(tw, client, devices)
|
||||
s.addSpeakerHTTP(tw, client, devices)
|
||||
addSpeakerSSH(tw, devices)
|
||||
s.addSystemFiles(tw)
|
||||
s.addServiceLog(tw)
|
||||
s.addSettingsJSON(tw)
|
||||
addEnvVars(tw)
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close tar: %w", err)
|
||||
}
|
||||
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close gzip: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// diagHTTPClient returns an HTTP client suited for internal diagnostic fetches:
|
||||
// short timeout and TLS verification skipped so it can call the service's own
|
||||
// HTTPS endpoint without the CA being trusted by the host OS.
|
||||
func diagHTTPClient() *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func diagFetch(client *http.Client, rawURL string) ([]byte, error) {
|
||||
resp, err := client.Get(rawURL) //nolint:noctx
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// addServiceHTTP appends HTTP responses from the local service into the archive
|
||||
// under http/service/. Each fetch is best-effort; errors are logged and skipped.
|
||||
func (s *Server) addServiceHTTP(tw *tar.Writer, client *http.Client, devices []models.ServiceDeviceInfo) {
|
||||
base := strings.TrimRight(s.serverURL, "/")
|
||||
|
||||
tryAdd := func(archivePath, url string) {
|
||||
data, err := diagFetch(client, url)
|
||||
if err != nil {
|
||||
log.Printf("[Export] fetch %s: %v", url, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err := addTarBytes(tw, archivePath, data); err != nil {
|
||||
log.Printf("[Export] add %s: %v", archivePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
tryAdd("http/service/sourceproviders.xml", base+"/streaming/sourceproviders")
|
||||
|
||||
seenAccounts := map[string]bool{}
|
||||
|
||||
for i := range devices {
|
||||
dev := &devices[i]
|
||||
|
||||
if !seenAccounts[dev.AccountID] {
|
||||
seenAccounts[dev.AccountID] = true
|
||||
pfx := "http/service/account-" + dev.AccountID
|
||||
acct := base + "/streaming/account/" + dev.AccountID
|
||||
tryAdd(pfx+"/full.xml", acct+"/full")
|
||||
tryAdd(pfx+"/sources.xml", acct+"/sources")
|
||||
tryAdd(pfx+"/presets.xml", acct+"/presets")
|
||||
}
|
||||
|
||||
if dev.DeviceID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
dpfx := "http/service/account-" + dev.AccountID + "/device-" + dev.DeviceID
|
||||
dpath := base + "/streaming/account/" + dev.AccountID + "/device/" + dev.DeviceID
|
||||
tryAdd(dpfx+"/presets.xml", dpath+"/presets")
|
||||
tryAdd(dpfx+"/recents.xml", dpath+"/recents")
|
||||
}
|
||||
}
|
||||
|
||||
// addSpeakerHTTP appends HTTP responses fetched directly from each speaker's
|
||||
// local API (port 8090) into the archive under http/speaker/{deviceID}/.
|
||||
// Speakers that are unreachable are silently skipped.
|
||||
func (s *Server) addSpeakerHTTP(tw *tar.Writer, client *http.Client, devices []models.ServiceDeviceInfo) {
|
||||
endpoints := []string{"sources", "presets", "now_playing", "info", "recents"}
|
||||
|
||||
for i := range devices {
|
||||
dev := &devices[i]
|
||||
|
||||
if dev.IPAddress == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
speakerBase := "http://" + dev.IPAddress + ":8090"
|
||||
id := dev.DeviceID
|
||||
|
||||
if id == "" {
|
||||
id = dev.IPAddress
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
data, err := diagFetch(client, speakerBase+"/"+ep)
|
||||
if err != nil {
|
||||
log.Printf("[Export] speaker %s /%s: %v", id, ep, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
archivePath := "http/speaker/" + id + "/" + ep + ".xml"
|
||||
if err := addTarBytes(tw, archivePath, data); err != nil {
|
||||
log.Printf("[Export] add %s: %v", archivePath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// speakerSSHPaths lists file paths to retrieve from each speaker via SSH.
|
||||
var speakerSSHPaths = []string{
|
||||
"/etc/pki/tls/certs/ca-bundle.crt",
|
||||
"/etc/ssl/certs/ca-certificates.crt",
|
||||
}
|
||||
|
||||
// speakerLogWindow is the default look-back period for speaker syslog entries.
|
||||
const speakerLogWindow = 20 * time.Minute
|
||||
|
||||
// speakerLogFormats are the timestamp layouts attempted when parsing a busybox
|
||||
// syslog line. Busybox logread produces "Mon Jan _2 15:04:05 2006" (with year)
|
||||
// on newer firmware; older builds omit the year.
|
||||
var speakerLogFormats = []string{
|
||||
"Mon Jan _2 15:04:05 2006", // newer busybox: "Wed Jun 4 12:34:56 2025"
|
||||
"Mon Jan 02 15:04:05 2006", // zero-padded day variant
|
||||
}
|
||||
|
||||
// parseSpeakerLogTime extracts the timestamp from the leading field of a busybox
|
||||
// syslog line. currentYear is used as a fallback when the log line has no year
|
||||
// field. Returns the zero Time and false when no format matches.
|
||||
func parseSpeakerLogTime(line string, currentYear int) (time.Time, bool) {
|
||||
for _, layout := range speakerLogFormats {
|
||||
if len(line) < len(layout) {
|
||||
continue
|
||||
}
|
||||
|
||||
t, err := time.Parse(layout, line[:len(layout)])
|
||||
if err == nil {
|
||||
return t.UTC(), true
|
||||
}
|
||||
}
|
||||
|
||||
// Try without year: assume current year.
|
||||
noYearFmt := "Mon Jan _2 15:04:05"
|
||||
noYearFmt02 := "Mon Jan 02 15:04:05"
|
||||
|
||||
for _, layout := range []string{noYearFmt, noYearFmt02} {
|
||||
if len(line) < len(layout) {
|
||||
continue
|
||||
}
|
||||
|
||||
withYear := line[:len(layout)] + strings.Repeat(" ", 1) + fmt.Sprintf("%d", currentYear)
|
||||
fullLayout := layout + " 2006"
|
||||
|
||||
t, err := time.Parse(fullLayout, withYear)
|
||||
if err == nil {
|
||||
return t.UTC(), true
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// filterSpeakerLog returns only the lines from rawLog whose timestamp falls
|
||||
// within the given window before now. Lines whose timestamp cannot be parsed
|
||||
// are kept (fail-open) so that unparseable headers or continuation lines are
|
||||
// not silently dropped.
|
||||
func filterSpeakerLog(rawLog string, window time.Duration) string {
|
||||
cutoff := time.Now().UTC().Add(-window)
|
||||
currentYear := time.Now().Year()
|
||||
|
||||
var out strings.Builder
|
||||
|
||||
for _, line := range strings.SplitAfter(rawLog, "\n") {
|
||||
t, ok := parseSpeakerLogTime(line, currentYear)
|
||||
if !ok || !t.Before(cutoff) {
|
||||
out.WriteString(line)
|
||||
}
|
||||
}
|
||||
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// addSpeakerSSH connects to each speaker via SSH and copies the speaker-side
|
||||
// CA certificate files and log output into the archive under ssh/speaker/{deviceID}/.
|
||||
// Speakers that are unreachable or have SSH disabled are silently skipped.
|
||||
func addSpeakerSSH(tw *tar.Writer, devices []models.ServiceDeviceInfo) {
|
||||
for i := range devices {
|
||||
dev := &devices[i]
|
||||
|
||||
if dev.IPAddress == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
id := dev.DeviceID
|
||||
if id == "" {
|
||||
id = dev.IPAddress
|
||||
}
|
||||
|
||||
sc := speakerssh.NewClient(dev.IPAddress)
|
||||
|
||||
for _, remotePath := range speakerSSHPaths {
|
||||
data, err := sc.ReadFile(remotePath)
|
||||
if err != nil {
|
||||
log.Printf("[Export] SSH %s %s: %v", id, remotePath, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
archivePath := "ssh/speaker/" + id + remotePath
|
||||
if err := addTarBytes(tw, archivePath, data); err != nil {
|
||||
log.Printf("[Export] add %s: %v", archivePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// dmesg and any other plain commands.
|
||||
for filename, cmd := range map[string]string{"dmesg.txt": "dmesg"} {
|
||||
out, err := sc.Run(cmd)
|
||||
if err != nil && strings.TrimSpace(out) == "" {
|
||||
log.Printf("[Export] SSH %s %q: %v", id, cmd, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
archivePath := "ssh/speaker/" + id + "/" + filename
|
||||
if err := addTarBytes(tw, archivePath, []byte(out)); err != nil {
|
||||
log.Printf("[Export] add %s: %v", archivePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Syslog: fetch raw, strip 127.0.0.1 noise, then keep only the last speakerLogWindow.
|
||||
rawLog, logErr := sc.Run("logread 2>/dev/null | grep -v '127.0.0.1'")
|
||||
if logErr != nil && strings.TrimSpace(rawLog) == "" {
|
||||
log.Printf("[Export] SSH %s logread: %v", id, logErr)
|
||||
} else {
|
||||
filtered := filterSpeakerLog(rawLog, speakerLogWindow)
|
||||
archivePath := "ssh/speaker/" + id + "/logread.txt"
|
||||
|
||||
if addErr := addTarBytes(tw, archivePath, []byte(filtered)); addErr != nil {
|
||||
log.Printf("[Export] add %s: %v", archivePath, addErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addServiceLog appends the in-memory service log buffer as logs/service.txt.
|
||||
// Each entry is formatted as "2006-01-02T15:04:05Z <message>".
|
||||
func (s *Server) addServiceLog(tw *tar.Writer) {
|
||||
if s.logBuf == nil {
|
||||
return
|
||||
}
|
||||
|
||||
entries := s.logBuf.Snapshot()
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
for _, e := range entries {
|
||||
sb.WriteString(e.Time.UTC().Format(time.RFC3339))
|
||||
sb.WriteByte(' ')
|
||||
sb.WriteString(e.Message)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
|
||||
if err := addTarBytes(tw, "logs/service.txt", []byte(sb.String())); err != nil {
|
||||
log.Printf("[Export] add logs/service.txt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// addSystemFiles appends the service CA cert (if configured) and the host
|
||||
// resolver configuration into the archive under system/.
|
||||
func (s *Server) addSystemFiles(tw *tar.Writer) {
|
||||
if path := s.ownCACertPath(); path != "" {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Printf("[Export] read CA cert %s: %v", path, err)
|
||||
} else if err := addTarBytes(tw, "system/ca.pem", data); err != nil {
|
||||
log.Printf("[Export] add system/ca.pem: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if data, err := os.ReadFile("/etc/resolv.conf"); err == nil {
|
||||
if err := addTarBytes(tw, "system/resolv.conf", data); err != nil {
|
||||
log.Printf("[Export] add system/resolv.conf: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// diagSettings is a copy of datastore.Settings with secrets zeroed out so the
|
||||
// struct can be marshalled into the archive without exposing credentials.
|
||||
type diagSettings struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
HTTPSServerURL string `json:"https_server_url,omitempty"`
|
||||
RedactLogs bool `json:"redact_logs"`
|
||||
LogBodies bool `json:"log_bodies"`
|
||||
RecordInteractions bool `json:"record_interactions"`
|
||||
DiscoveryInterval string `json:"discovery_interval,omitempty"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
DNSEnabled bool `json:"dns_enabled"`
|
||||
DNSUpstream []string `json:"dns_upstream,omitempty"`
|
||||
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
|
||||
InternalPaths []string `json:"internal_paths,omitempty"`
|
||||
Shortcuts map[string]int `json:"shortcuts,omitempty"`
|
||||
SpotifyClientID string `json:"spotify_client_id,omitempty"`
|
||||
SpotifyClientSecret string `json:"spotify_client_secret,omitempty"`
|
||||
SpotifyRedirectURI string `json:"spotify_redirect_uri,omitempty"`
|
||||
AmazonClientID string `json:"amazon_client_id,omitempty"`
|
||||
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
|
||||
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
|
||||
TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
|
||||
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
|
||||
}
|
||||
|
||||
// addSettingsJSON serialises the service settings into the archive as
|
||||
// settings.json. OAuth client secrets are replaced with "[REDACTED]" so the
|
||||
// file is safe to share.
|
||||
func (s *Server) addSettingsJSON(tw *tar.Writer) {
|
||||
st, err := s.ds.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("[Export] get settings: %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
redact := func(v string) string {
|
||||
if v != "" {
|
||||
return "[REDACTED]"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
ds := diagSettings{
|
||||
ServerURL: st.ServerURL,
|
||||
HTTPSServerURL: st.HTTPServerURL,
|
||||
RedactLogs: st.RedactLogs,
|
||||
LogBodies: st.LogBodies,
|
||||
RecordInteractions: st.RecordInteractions,
|
||||
DiscoveryInterval: st.DiscoveryInterval,
|
||||
DiscoveryEnabled: st.DiscoveryEnabled,
|
||||
DNSEnabled: st.DNSEnabled,
|
||||
DNSUpstream: st.DNSUpstream,
|
||||
DNSBindAddr: st.DNSBindAddr,
|
||||
InternalPaths: st.InternalPaths,
|
||||
Shortcuts: st.Shortcuts,
|
||||
SpotifyClientID: st.SpotifyClientID,
|
||||
SpotifyClientSecret: redact(st.SpotifyClientSecret),
|
||||
SpotifyRedirectURI: st.SpotifyRedirectURI,
|
||||
AmazonClientID: st.AmazonClientID,
|
||||
AmazonClientSecret: redact(st.AmazonClientSecret),
|
||||
AmazonRedirectURI: st.AmazonRedirectURI,
|
||||
TrustForwardedHeaders: st.TrustForwardedHeaders,
|
||||
TrustedProxyCIDRs: st.TrustedProxyCIDRs,
|
||||
TuneInStreamFormats: st.TuneInStreamFormats,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(ds, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("[Export] marshal settings: %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err := addTarBytes(tw, "settings.json", data); err != nil {
|
||||
log.Printf("[Export] add settings.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// secretEnvKeywords lists substrings that, if present in an env-var name,
|
||||
// cause the variable to be omitted from the diagnostic export.
|
||||
var secretEnvKeywords = []string{
|
||||
"secret", "password", "passwd", "token", "apikey", "api_key",
|
||||
"credential", "auth", "private", "passphrase",
|
||||
}
|
||||
|
||||
// addEnvVars appends a filtered list of environment variables to the archive
|
||||
// as env.txt. Variables whose names suggest credentials are omitted.
|
||||
func addEnvVars(tw *tar.Writer) {
|
||||
raw := os.Environ()
|
||||
sort.Strings(raw)
|
||||
|
||||
var lines []string
|
||||
|
||||
for _, kv := range raw {
|
||||
name := strings.ToLower(strings.SplitN(kv, "=", 2)[0])
|
||||
skip := false
|
||||
|
||||
for _, kw := range secretEnvKeywords {
|
||||
if strings.Contains(name, kw) {
|
||||
skip = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !skip {
|
||||
lines = append(lines, kv)
|
||||
}
|
||||
}
|
||||
|
||||
data := []byte(strings.Join(lines, "\n") + "\n")
|
||||
if err := addTarBytes(tw, "env.txt", data); err != nil {
|
||||
log.Printf("[Export] add env.txt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func addTarBytes(tw *tar.Writer, name string, data []byte) error {
|
||||
hdr := &tar.Header{
|
||||
Name: name,
|
||||
Mode: 0o644,
|
||||
Size: int64(len(data)),
|
||||
ModTime: time.Now().UTC(),
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := tw.Write(data)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) buildDiagnosticReport() diagnosticReport {
|
||||
report := diagnosticReport{
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
ServiceVersion: buildVersionInfo(),
|
||||
}
|
||||
|
||||
if s.healthRegistry != nil {
|
||||
report.HealthChecks = s.healthRegistry.RunAll()
|
||||
}
|
||||
|
||||
devices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
log.Printf("[Export] list devices: %v", err)
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
for i := range devices {
|
||||
dev := &devices[i]
|
||||
dd := deviceDiagnostic{
|
||||
AccountID: dev.AccountID,
|
||||
DeviceID: dev.DeviceID,
|
||||
ProductCode: dev.ProductCode,
|
||||
FirmwareVersion: dev.FirmwareVersion,
|
||||
Name: dev.Name,
|
||||
IPAddress: dev.IPAddress,
|
||||
}
|
||||
|
||||
if sources, err := s.ds.GetConfiguredSources(dev.AccountID, dev.DeviceID); err == nil {
|
||||
for i := range sources {
|
||||
src := &sources[i]
|
||||
dd.Sources = append(dd.Sources, sourceDiagnostic{
|
||||
ID: src.ID,
|
||||
Name: src.Name,
|
||||
SourceKeyType: src.SourceKeyType,
|
||||
ProviderID: src.SourceProviderID,
|
||||
Status: src.Status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if presets, err := s.ds.GetPresets(dev.AccountID, dev.DeviceID); err == nil {
|
||||
for i := range presets {
|
||||
p := &presets[i]
|
||||
dd.Presets = append(dd.Presets, presetDiagnostic{
|
||||
Slot: p.ButtonNumber,
|
||||
Name: p.Name,
|
||||
Source: p.Source,
|
||||
SourceID: p.SourceID,
|
||||
Location: p.Location,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
report.Devices = append(report.Devices, dd)
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseSpeakerLogTime(t *testing.T) {
|
||||
currentYear := 2025
|
||||
|
||||
cases := []struct {
|
||||
line string
|
||||
wantOK bool
|
||||
wantSub string // substring that should appear in formatted result
|
||||
}{
|
||||
{"Wed Jun 4 12:34:56 2025 daemon.info app: hello", true, "2025"},
|
||||
{"Mon Jan 02 15:04:05 2025 kern.info kernel: boot", true, "2025"},
|
||||
{"Wed Jun 4 12:34:56 daemon.info app: no year", true, "2025"}, // year injected
|
||||
{"not a log line at all", false, ""},
|
||||
{"", false, ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.line, func(t *testing.T) {
|
||||
got, ok := parseSpeakerLogTime(tc.line, currentYear)
|
||||
if ok != tc.wantOK {
|
||||
t.Errorf("parseSpeakerLogTime(%q) ok=%v, want %v", tc.line, ok, tc.wantOK)
|
||||
}
|
||||
|
||||
if tc.wantOK && tc.wantSub != "" && !strings.Contains(got.Format(time.RFC3339), tc.wantSub) {
|
||||
t.Errorf("parseSpeakerLogTime(%q) = %v, expected to contain %q", tc.line, got.Format(time.RFC3339), tc.wantSub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpeakerLog(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
format := "Mon Jan _2 15:04:05 2006"
|
||||
|
||||
recent := now.Add(-5 * time.Minute).Format(format)
|
||||
old := now.Add(-30 * time.Minute).Format(format)
|
||||
|
||||
rawLog := strings.Join([]string{
|
||||
old + " kern.info kernel: old message",
|
||||
recent + " daemon.info app: recent message",
|
||||
"unparseable line — keep it",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
filtered := filterSpeakerLog(rawLog, 20*time.Minute)
|
||||
|
||||
if strings.Contains(filtered, "old message") {
|
||||
t.Error("filtered log should not contain old message")
|
||||
}
|
||||
|
||||
if !strings.Contains(filtered, "recent message") {
|
||||
t.Error("filtered log should contain recent message")
|
||||
}
|
||||
|
||||
if !strings.Contains(filtered, "unparseable line") {
|
||||
t.Error("filtered log should keep unparseable lines (fail-open)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/health"
|
||||
)
|
||||
|
||||
// healthChecksResponse is the wire shape for GET /setup/health.
|
||||
type healthChecksResponse struct {
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Checks []health.CheckResult `json:"checks"`
|
||||
}
|
||||
|
||||
// healthFixRequest is the wire shape for POST /setup/health/fix.
|
||||
// Target locates the entity the fix should act on; an empty
|
||||
// Account/Device pair is allowed for service-wide fixes.
|
||||
type healthFixRequest struct {
|
||||
CheckID string `json:"checkId"`
|
||||
FixID string `json:"fixId"`
|
||||
Target health.Target `json:"target"`
|
||||
}
|
||||
|
||||
type healthFixResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// HandleHealthChecks runs every registered health check and
|
||||
// returns the current findings. Safe to poll: checks are
|
||||
// expected to be cheap (filesystem stats, in-memory lookups).
|
||||
func (s *Server) HandleHealthChecks(w http.ResponseWriter, _ *http.Request) {
|
||||
if s.healthRegistry == nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "health registry not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
resp := healthChecksResponse{
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Checks: s.healthRegistry.RunAll(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleHealthFix dispatches a quick-fix identified by
|
||||
// (checkId, fixId) against the supplied target. Returns 404 when
|
||||
// the fix isn't registered (typically a stale UI), 400 on a
|
||||
// malformed body, and 500 when the fix itself fails. The success
|
||||
// message comes from the FixFunc and is forwarded to the UI.
|
||||
func (s *Server) HandleHealthFix(w http.ResponseWriter, r *http.Request) {
|
||||
if s.healthRegistry == nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "health registry not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
var req healthFixRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.CheckID == "" || req.FixID == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, "checkId and fixId are required")
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := s.healthRegistry.RunFix(req.CheckID, req.FixID, req.Target)
|
||||
if err != nil {
|
||||
if errors.Is(err, health.ErrFixNotFound) {
|
||||
writeJSONError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(healthFixResponse{OK: true, Message: msg}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/health"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func newHealthTestServer(t *testing.T) (*httptest.Server, *datastore.DataStore, string, string) {
|
||||
t.Helper()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "handlers-health-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("temp dir: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { os.RemoveAll(tempDir) })
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
AccountID: account,
|
||||
Name: "Speaker",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
_, server := setupRouter("http://localhost:8001", ds)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/setup/health", server.HandleHealthChecks)
|
||||
r.Post("/setup/health/fix", server.HandleHealthFix)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
return ts, ds, account, device
|
||||
}
|
||||
|
||||
func TestHandleHealthChecks_ReportsMissingSourcesXML(t *testing.T) {
|
||||
ts, _, account, device := newHealthTestServer(t)
|
||||
|
||||
res, err := http.Get(ts.URL + "/setup/health")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Checks []health.CheckResult `json:"checks"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if resp.GeneratedAt == "" {
|
||||
t.Errorf("expected generatedAt to be populated")
|
||||
}
|
||||
|
||||
if len(resp.Checks) == 0 {
|
||||
t.Fatalf("expected at least one check")
|
||||
}
|
||||
|
||||
var found *health.CheckResult
|
||||
for i := range resp.Checks {
|
||||
if resp.Checks[i].ID == health.CheckIDSourcesXMLPresent {
|
||||
found = &resp.Checks[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if found == nil {
|
||||
t.Fatalf("expected %q check in response", health.CheckIDSourcesXMLPresent)
|
||||
}
|
||||
|
||||
if found.Severity != health.SeverityWarning {
|
||||
t.Errorf("expected warning, got %q", found.Severity)
|
||||
}
|
||||
|
||||
if len(found.Findings) != 1 {
|
||||
t.Fatalf("expected 1 finding, got %d", len(found.Findings))
|
||||
}
|
||||
|
||||
finding := found.Findings[0]
|
||||
if finding.Target.Account != account || finding.Target.Device != device {
|
||||
t.Errorf("finding target = %+v, want account=%s device=%s", finding.Target, account, device)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHealthFix_MaterialisesAndClearsFinding(t *testing.T) {
|
||||
ts, ds, account, device := newHealthTestServer(t)
|
||||
|
||||
type fixReq struct {
|
||||
CheckID string `json:"checkId"`
|
||||
FixID string `json:"fixId"`
|
||||
Target health.Target `json:"target"`
|
||||
}
|
||||
|
||||
body, err := json.Marshal(fixReq{
|
||||
CheckID: health.CheckIDSourcesXMLPresent,
|
||||
FixID: health.FixIDCreateDefaultSources,
|
||||
Target: health.Target{Account: account, Device: device},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
res, err := http.Post(ts.URL+"/setup/health/fix", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var fixResp struct {
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&fixResp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if !fixResp.OK {
|
||||
t.Errorf("expected ok=true, got %+v", fixResp)
|
||||
}
|
||||
|
||||
if !ds.HasConfiguredSources(account, device) {
|
||||
t.Fatalf("Sources.xml should exist after fix")
|
||||
}
|
||||
|
||||
// Subsequent GET should now report SeverityOK for the check.
|
||||
res2, err := http.Get(ts.URL + "/setup/health")
|
||||
if err != nil {
|
||||
t.Fatalf("re-GET: %v", err)
|
||||
}
|
||||
defer res2.Body.Close()
|
||||
|
||||
var resp struct {
|
||||
Checks []health.CheckResult `json:"checks"`
|
||||
}
|
||||
if err := json.NewDecoder(res2.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
for i := range resp.Checks {
|
||||
if resp.Checks[i].ID != health.CheckIDSourcesXMLPresent {
|
||||
continue
|
||||
}
|
||||
if resp.Checks[i].Severity != health.SeverityOK {
|
||||
t.Errorf("expected OK after fix, got %q", resp.Checks[i].Severity)
|
||||
}
|
||||
if len(resp.Checks[i].Findings) != 0 {
|
||||
t.Errorf("expected zero findings after fix, got %d", len(resp.Checks[i].Findings))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHealthFix_UnknownFixReturns404(t *testing.T) {
|
||||
ts, _, _, _ := newHealthTestServer(t)
|
||||
|
||||
body, err := json.Marshal(struct {
|
||||
CheckID string `json:"checkId"`
|
||||
FixID string `json:"fixId"`
|
||||
}{CheckID: "no_such_check", FixID: "no_such_fix"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
res, err := http.Post(ts.URL+"/setup/health/fix", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHealthFix_BadRequest(t *testing.T) {
|
||||
ts, _, _, _ := newHealthTestServer(t)
|
||||
|
||||
res, err := http.Post(ts.URL+"/setup/health/fix", "application/json", bytes.NewReader([]byte(`{"checkId":""}`)))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/logbuf"
|
||||
)
|
||||
|
||||
// logsResponse is the wire shape for GET /setup/logs. Entries is
|
||||
// ordered by Seq ascending. NextSince is the highest Seq returned,
|
||||
// suitable for the client's next poll's `since` parameter. Dropped
|
||||
// is the count of entries the client missed because the ring
|
||||
// evicted them before the client polled — a non-zero value
|
||||
// signals the client to surface a gap to the operator.
|
||||
type logsResponse struct {
|
||||
Entries []logbuf.Entry `json:"entries"`
|
||||
NextSince uint64 `json:"nextSince"`
|
||||
Dropped uint64 `json:"dropped"`
|
||||
Capacity int `json:"capacity"`
|
||||
}
|
||||
|
||||
// HandleGetLogs returns recent log entries from the in-process
|
||||
// ring buffer. Query parameters:
|
||||
//
|
||||
// since — return entries with Seq strictly greater than this
|
||||
// value. Omitted or "0" → full snapshot.
|
||||
// limit — cap the number of entries returned. Omitted → no cap
|
||||
// beyond the buffer's capacity.
|
||||
//
|
||||
// When no log buffer is attached (e.g. tests, or the env opted
|
||||
// out via SOUNDTOUCH_LOG_BUFFER_LINES=0), the response is an
|
||||
// empty snapshot rather than an error so the UI degrades
|
||||
// gracefully.
|
||||
func (s *Server) HandleGetLogs(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
since, err := parseUint64Query(q.Get("since"), 0)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid since: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
limit, err := parseIntQuery(q.Get("limit"), 0)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid limit: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := logsResponse{
|
||||
Entries: []logbuf.Entry{},
|
||||
NextSince: since,
|
||||
}
|
||||
|
||||
if buf := s.LogBuffer(); buf != nil {
|
||||
entries, nextSince, dropped := buf.Since(since, limit)
|
||||
resp.Entries = entries
|
||||
resp.NextSince = nextSince
|
||||
resp.Dropped = dropped
|
||||
resp.Capacity = buf.Capacity()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func parseUint64Query(raw string, defaultVal uint64) (uint64, error) {
|
||||
if raw == "" {
|
||||
return defaultVal, nil
|
||||
}
|
||||
|
||||
return strconv.ParseUint(raw, 10, 64)
|
||||
}
|
||||
|
||||
func parseIntQuery(raw string, defaultVal int) (int, error) {
|
||||
if raw == "" {
|
||||
return defaultVal, nil
|
||||
}
|
||||
|
||||
return strconv.Atoi(raw)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/logbuf"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func newLogsTestServer(t *testing.T, buf *logbuf.Buffer) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
_, server := setupRouter("http://localhost:8001", nil)
|
||||
server.SetLogBuffer(buf)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/setup/logs", server.HandleGetLogs)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
return ts
|
||||
}
|
||||
|
||||
func TestHandleGetLogs_FullSnapshot(t *testing.T) {
|
||||
buf := logbuf.New(16)
|
||||
for _, line := range []string{"first\n", "second\n", "third\n"} {
|
||||
_, _ = buf.Write([]byte(line))
|
||||
}
|
||||
|
||||
ts := newLogsTestServer(t, buf)
|
||||
|
||||
res, err := http.Get(ts.URL + "/setup/logs")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var resp logsResponse
|
||||
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Entries) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d", len(resp.Entries))
|
||||
}
|
||||
|
||||
if resp.Entries[0].Message != "first" || resp.Entries[2].Message != "third" {
|
||||
t.Errorf("unexpected ordering: %+v", resp.Entries)
|
||||
}
|
||||
|
||||
if resp.NextSince != 3 {
|
||||
t.Errorf("nextSince: want 3, got %d", resp.NextSince)
|
||||
}
|
||||
|
||||
if resp.Capacity != 16 {
|
||||
t.Errorf("capacity: want 16, got %d", resp.Capacity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetLogs_SinceRoundTrip(t *testing.T) {
|
||||
buf := logbuf.New(16)
|
||||
for i := 0; i < 5; i++ {
|
||||
_, _ = buf.Write([]byte("line\n"))
|
||||
}
|
||||
|
||||
ts := newLogsTestServer(t, buf)
|
||||
|
||||
res, err := http.Get(ts.URL + "/setup/logs?since=2")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var resp logsResponse
|
||||
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Entries) != 3 {
|
||||
t.Errorf("expected 3 entries with since=2, got %d", len(resp.Entries))
|
||||
}
|
||||
|
||||
if resp.NextSince != 5 {
|
||||
t.Errorf("nextSince: want 5, got %d", resp.NextSince)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetLogs_Limit(t *testing.T) {
|
||||
buf := logbuf.New(16)
|
||||
for i := 0; i < 8; i++ {
|
||||
_, _ = buf.Write([]byte("line\n"))
|
||||
}
|
||||
|
||||
ts := newLogsTestServer(t, buf)
|
||||
|
||||
res, err := http.Get(ts.URL + "/setup/logs?limit=3")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var resp logsResponse
|
||||
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Entries) != 3 {
|
||||
t.Errorf("limit=3 should cap result, got %d entries", len(resp.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetLogs_MalformedSinceReturns400(t *testing.T) {
|
||||
ts := newLogsTestServer(t, logbuf.New(4))
|
||||
|
||||
res, err := http.Get(ts.URL + "/setup/logs?since=notanumber")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetLogs_NoBufferAttached(t *testing.T) {
|
||||
ts := newLogsTestServer(t, nil)
|
||||
|
||||
res, err := http.Get(ts.URL + "/setup/logs")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 even without buffer, got %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var resp logsResponse
|
||||
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Entries) != 0 {
|
||||
t.Errorf("expected empty entries when no buffer, got %d", len(resp.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetLogs_DroppedReportedWhenLagging(t *testing.T) {
|
||||
buf := logbuf.New(3)
|
||||
for i := 0; i < 10; i++ {
|
||||
_, _ = buf.Write([]byte("line\n"))
|
||||
}
|
||||
|
||||
ts := newLogsTestServer(t, buf)
|
||||
|
||||
res, err := http.Get(ts.URL + "/setup/logs?since=2")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var resp logsResponse
|
||||
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if resp.Dropped == 0 {
|
||||
t.Errorf("expected dropped > 0 when buffer evicted past `since`, got 0")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
@@ -14,6 +16,58 @@ var indexHTML []byte
|
||||
//go:embed web/css/* web/js/* web/img/favicon-braille* web/img/favicon*
|
||||
var webFS embed.FS
|
||||
|
||||
// indexHTMLVersioned is the HTML the root handler serves: identical to
|
||||
// indexHTML except the script.js and style.css references carry a
|
||||
// ?v=<hash> query string so the browser cache invalidates whenever
|
||||
// the asset content changes. Computed once at package init and reused
|
||||
// per-request. webAssetHash is the truncated SHA-256 over the asset
|
||||
// bodies; it's exposed for /setup/settings consumers that want to
|
||||
// build versioned URLs against /web/* from their own DOM constructors.
|
||||
var (
|
||||
indexHTMLVersioned []byte
|
||||
webAssetHash string
|
||||
)
|
||||
|
||||
func init() {
|
||||
webAssetHash = computeWebAssetHash()
|
||||
indexHTMLVersioned = applyAssetVersionToHTML(indexHTML, webAssetHash)
|
||||
}
|
||||
|
||||
// computeWebAssetHash hashes the embedded script.js and style.css
|
||||
// bodies into a short stable identifier. SHA-256 truncated to 12
|
||||
// hex chars is more than enough to detect content changes across
|
||||
// release builds without bloating the URL.
|
||||
func computeWebAssetHash() string {
|
||||
h := sha256.New()
|
||||
|
||||
for _, path := range []string{"web/js/script.js", "web/css/style.css"} {
|
||||
data, err := webFS.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
_, _ = h.Write(data)
|
||||
}
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil))[:12]
|
||||
}
|
||||
|
||||
// applyAssetVersionToHTML rewrites the script and stylesheet src/href
|
||||
// attributes in the embedded HTML to carry a ?v=<hash> query string.
|
||||
// Operates on the byte slice once at startup; HandleRoot then serves
|
||||
// the cached output verbatim per request.
|
||||
func applyAssetVersionToHTML(html []byte, hash string) []byte {
|
||||
if hash == "" {
|
||||
return html
|
||||
}
|
||||
|
||||
out := string(html)
|
||||
out = strings.Replace(out, `href="/web/css/style.css"`, `href="/web/css/style.css?v=`+hash+`"`, 1)
|
||||
out = strings.Replace(out, `src="/web/js/script.js"`, `src="/web/js/script.js?v=`+hash+`"`, 1)
|
||||
|
||||
return []byte(out)
|
||||
}
|
||||
|
||||
//go:embed static/media/*
|
||||
var mediaFS embed.FS
|
||||
|
||||
@@ -60,7 +114,7 @@ func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write(indexHTML)
|
||||
_, _ = w.Write(indexHTMLVersioned)
|
||||
}
|
||||
|
||||
// HandleWeb returns a handler for serving web resources.
|
||||
|
||||
@@ -185,3 +185,57 @@ func TestStaticWeb(t *testing.T) {
|
||||
t.Errorf("Web Root Favicon: Expected status NotFound, got %v", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeWebAssetHash_StableAndShort(t *testing.T) {
|
||||
got := computeWebAssetHash()
|
||||
if len(got) != 12 {
|
||||
t.Errorf("expected 12-char hash, got %d (%q)", len(got), got)
|
||||
}
|
||||
|
||||
if got != computeWebAssetHash() {
|
||||
t.Errorf("hash should be stable across calls — same embedded FS")
|
||||
}
|
||||
|
||||
for _, c := range got {
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
||||
t.Errorf("hash must be lowercase hex, got %q", got)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAssetVersionToHTML_InjectsQueryString(t *testing.T) {
|
||||
const html = `<link rel="stylesheet" href="/web/css/style.css"/>` +
|
||||
`<script src="/web/js/script.js"></script>`
|
||||
|
||||
out := string(applyAssetVersionToHTML([]byte(html), "abc123"))
|
||||
|
||||
if !strings.Contains(out, `/web/css/style.css?v=abc123`) {
|
||||
t.Errorf("expected style.css to carry ?v=abc123, got: %s", out)
|
||||
}
|
||||
|
||||
if !strings.Contains(out, `/web/js/script.js?v=abc123`) {
|
||||
t.Errorf("expected script.js to carry ?v=abc123, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAssetVersionToHTML_EmptyHashPassthrough(t *testing.T) {
|
||||
const html = `<script src="/web/js/script.js"></script>`
|
||||
|
||||
out := applyAssetVersionToHTML([]byte(html), "")
|
||||
if string(out) != html {
|
||||
t.Errorf("expected unchanged HTML for empty hash, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexHTMLVersioned_CarriesHash(t *testing.T) {
|
||||
body := string(indexHTMLVersioned)
|
||||
|
||||
if !strings.Contains(body, "/web/js/script.js?v=") {
|
||||
t.Errorf("indexHTMLVersioned must carry ?v= on script.js reference")
|
||||
}
|
||||
|
||||
if !strings.Contains(body, "/web/css/style.css?v=") {
|
||||
t.Errorf("indexHTMLVersioned must carry ?v= on style.css reference")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/health"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -117,6 +120,66 @@ func (s *Server) HandlePairAccount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// completeSpeakerPairingFix is the FixFunc registered for
|
||||
// (CheckIDSpeakerInfoReachable, FixIDCompleteSpeakerPairing). It
|
||||
// completes pairing on a speaker whose /info reports an empty
|
||||
// <margeAccountUUID> by:
|
||||
//
|
||||
// 1. Looking up the device's current IP via ListAllDevices.
|
||||
// 2. Choosing the pair-with account: target.Account when the
|
||||
// detection-side suggestion populated it, else generating a fresh
|
||||
// 7-digit ID with setup.GenerateAccountID.
|
||||
// 3. Dispatching through setup.Manager.PairAccount, which tries
|
||||
// /setMargeAccount over HTTP first and falls back to telnet.
|
||||
//
|
||||
// Returns a user-facing success message that names the chosen account
|
||||
// and the method that succeeded; the framework forwards it to the UI.
|
||||
func (s *Server) completeSpeakerPairingFix(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)
|
||||
}
|
||||
|
||||
accountID := target.Account
|
||||
if !setup.IsValidAccountID(accountID) {
|
||||
known, _ := s.ds.ListAccounts()
|
||||
|
||||
generated, genErr := setup.GenerateAccountID(known)
|
||||
if genErr != nil {
|
||||
return "", fmt.Errorf("generate account ID: %w", genErr)
|
||||
}
|
||||
|
||||
accountID = generated
|
||||
}
|
||||
|
||||
var t setup.TelnetClient
|
||||
if s.sm.NewTelnet != nil {
|
||||
t = s.sm.NewTelnet(deviceIP)
|
||||
if dialErr := t.Dial(); dialErr != nil {
|
||||
t = nil
|
||||
} else {
|
||||
defer func() { _ = t.Close() }()
|
||||
}
|
||||
}
|
||||
|
||||
result, output, err := s.sm.PairAccount(deviceIP, accountID, t)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pair speaker %s with account %s: %w (path output: %s)", target.Device, accountID, err, strings.TrimSpace(output))
|
||||
}
|
||||
|
||||
method := result.Method
|
||||
if method == "" {
|
||||
method = "unknown"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Paired speaker %s with account %s via %s. The speaker will re-fetch /full on its own; playback selection should start working within seconds.",
|
||||
target.Device, accountID, method), nil
|
||||
}
|
||||
|
||||
// jsonErrorBody is the static shape of error responses from this file.
|
||||
// Avoiding map[string]interface{} keeps errchkjson satisfied: the typed
|
||||
// struct guarantees encoding can't fail with a runtime type error.
|
||||
|
||||
@@ -195,6 +195,10 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
"https_server_url": httpsServerURL,
|
||||
"https_listener_port": httpsListenerPort,
|
||||
"https_443_check_skipped": probe443.Skipped,
|
||||
"https_443_not_applicable": probe443.NotApplicable,
|
||||
"https_443_reason": probe443.Reason,
|
||||
"tls_extra_hosts": s.persistedTLSExtraHosts(),
|
||||
"tls_san_hosts": s.ExpectedHosts(),
|
||||
"https_443_localhost_reachable": probe443.Localhost.Reachable,
|
||||
"https_443_localhost_error": probe443.Localhost.Error,
|
||||
"https_443_lan_reachable": probe443.LAN.Reachable,
|
||||
@@ -243,6 +247,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
AmazonClientID string `json:"amazon_client_id"`
|
||||
AmazonClientSecret string `json:"amazon_client_secret"`
|
||||
AmazonRedirectURI string `json:"amazon_redirect_uri"`
|
||||
TLSExtraHosts *[]string `json:"tls_extra_hosts"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
@@ -322,6 +327,13 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
currentRecord := s.recordEnabled
|
||||
currentHTTPS := s.httpsServerURL
|
||||
|
||||
// Resolve TLS extra hosts: nil pointer means "field omitted, preserve existing";
|
||||
// non-nil (even empty) means "replace with this list".
|
||||
resolvedTLSExtraHosts := s.persistedTLSExtraHosts()
|
||||
if settings.TLSExtraHosts != nil {
|
||||
resolvedTLSExtraHosts = normaliseTLSExtraHosts(*settings.TLSExtraHosts)
|
||||
}
|
||||
|
||||
log.Printf("Saving updated settings to %s/settings.json", s.ds.DataDir)
|
||||
err = s.ds.SaveSettings(datastore.Settings{
|
||||
ServerURL: s.serverURL,
|
||||
@@ -342,6 +354,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
AmazonClientID: s.amazonClientID,
|
||||
AmazonClientSecret: s.amazonClientSecret,
|
||||
AmazonRedirectURI: s.amazonRedirectURI,
|
||||
TLSExtraHosts: resolvedTLSExtraHosts,
|
||||
})
|
||||
|
||||
dnsEnabled := s.dnsEnabled
|
||||
@@ -375,6 +388,28 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// normaliseTLSExtraHosts trims whitespace from each entry, drops empty
|
||||
// values, and deduplicates while preserving the first occurrence's
|
||||
// position. The settings endpoint applies this before persisting so the
|
||||
// stored list is always canonical.
|
||||
func normaliseTLSExtraHosts(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
seen := make(map[string]bool, len(in))
|
||||
|
||||
for _, h := range in {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" || seen[h] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[h] = true
|
||||
|
||||
out = append(out, h)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// HandleGetDeviceInfo returns live information for a device.
|
||||
func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/health"
|
||||
)
|
||||
|
||||
// addMargeHostToTLSFix is the FixFunc registered for the
|
||||
// (CheckIDSpeakerMargeURL, FixIDAddMargeHostToTLS) pair. It re-probes
|
||||
// the target device's <margeURL>, extracts the host portion, and
|
||||
// appends it to the persisted Settings.TLSExtraHosts. A subsequent
|
||||
// service restart picks up the change via the regular settings
|
||||
// merge path in applyPersistedSettings (cmd/soundtouch-service/main.go).
|
||||
//
|
||||
// The re-probe is deliberate: the persisted Settings only become
|
||||
// authoritative after the operator restarts AfterTouch, so reading
|
||||
// the margeURL fresh from the speaker avoids racing a stale finding
|
||||
// that was rendered before the speaker rebooted.
|
||||
//
|
||||
// Returns a success message that names the host and instructs the
|
||||
// operator to restart the service. Returns an error if the device
|
||||
// can't be located, the probe fails, or the marge URL is empty /
|
||||
// unparseable.
|
||||
func (s *Server) addMargeHostToTLSFix(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)
|
||||
}
|
||||
|
||||
probeURL := fmt.Sprintf("http://%s:8090/info", deviceIP)
|
||||
|
||||
margeHost, err := fetchMargeHostFromSpeaker(probeURL, 2*time.Second)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if margeHost == "" {
|
||||
return "", fmt.Errorf("speaker %s has no <margeURL>; nothing to add", target.Device)
|
||||
}
|
||||
|
||||
persisted, err := s.ds.GetSettings()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load settings: %w", err)
|
||||
}
|
||||
|
||||
for _, existing := range persisted.TLSExtraHosts {
|
||||
if strings.EqualFold(strings.TrimSpace(existing), margeHost) {
|
||||
return fmt.Sprintf("%s is already in the persisted TLS hosts. Restart AfterTouch to regenerate the TLS certificate if you haven't already.", margeHost), nil
|
||||
}
|
||||
}
|
||||
|
||||
persisted.TLSExtraHosts = append(persisted.TLSExtraHosts, margeHost)
|
||||
|
||||
if err := s.ds.SaveSettings(persisted); err != nil {
|
||||
return "", fmt.Errorf("save settings: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Added %s to persisted TLS hosts (tls_extra_hosts). Restart AfterTouch for the TLS certificate to be regenerated and include this host in its SAN list.", margeHost), nil
|
||||
}
|
||||
|
||||
// fetchMargeHostFromSpeaker probes the given speaker /info URL and
|
||||
// returns the host portion of the <margeURL> XML element. Returns an
|
||||
// empty string with no error when the speaker responds but doesn't
|
||||
// carry a margeURL. Returns a non-nil error when the probe itself
|
||||
// fails or the response can't be parsed.
|
||||
func fetchMargeHostFromSpeaker(probeURL string, timeout time.Duration) (string, error) {
|
||||
res := health.ProbeGet(context.Background(), probeURL, timeout)
|
||||
if !res.Reachable {
|
||||
return "", fmt.Errorf("speaker probe failed: %s", res.Err)
|
||||
}
|
||||
|
||||
if res.Status != 200 {
|
||||
return "", fmt.Errorf("speaker /info returned HTTP %d", res.Status)
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
MargeURL string `xml:"margeURL"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
|
||||
return "", fmt.Errorf("parse /info: %w", err)
|
||||
}
|
||||
|
||||
if parsed.MargeURL == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(parsed.MargeURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse margeURL %q: %w", parsed.MargeURL, err)
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
host = strings.TrimSpace(parsed.MargeURL)
|
||||
}
|
||||
|
||||
return host, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func stubInfoForFix(t *testing.T, margeURL string) string {
|
||||
t.Helper()
|
||||
|
||||
body := `<?xml version="1.0" encoding="UTF-8" ?><info deviceID="DEVICEID01"><name>Test</name><margeAccountUUID>1000001</margeAccountUUID><margeURL>` + margeURL + `</margeURL></info>`
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/info" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
return srv.URL + "/info"
|
||||
}
|
||||
|
||||
func TestFetchMargeHostFromSpeaker_ReturnsHostOnly(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
margeURL string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "HTTPS with port",
|
||||
margeURL: "https://aftertouch.example:8443/",
|
||||
want: "aftertouch.example",
|
||||
},
|
||||
{
|
||||
name: "HTTP IP with port",
|
||||
margeURL: "http://192.0.2.10:8000/",
|
||||
want: "192.0.2.10",
|
||||
},
|
||||
{
|
||||
name: "Bare host fallback",
|
||||
margeURL: "aftertouch.example",
|
||||
want: "aftertouch.example",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
probeURL := stubInfoForFix(t, tc.margeURL)
|
||||
|
||||
got, err := fetchMargeHostFromSpeaker(probeURL, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMargeHostFromSpeaker_EmptyMargeURLReturnsEmpty(t *testing.T) {
|
||||
probeURL := stubInfoForFix(t, "")
|
||||
|
||||
got, err := fetchMargeHostFromSpeaker(probeURL, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != "" {
|
||||
t.Errorf("expected empty host for empty margeURL, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMargeHostFromSpeaker_UnreachableReturnsError(t *testing.T) {
|
||||
// Point at a closed port; the probe should fail without panicking.
|
||||
_, err := fetchMargeHostFromSpeaker("http://127.0.0.1:1/info", 200*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Errorf("expected error for unreachable speaker, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "probe failed") {
|
||||
t.Errorf("expected 'probe failed' in error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,26 @@ import (
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Probe443Result captures the outcome of probing a host on :443.
|
||||
// Skipped is true when the running HTTPS listener is already on :443
|
||||
// (in which case the listener itself is the proof of reachability).
|
||||
// NotApplicable is true when the operator has chosen an HTTP-only
|
||||
// deployment (configured serverURL is http://...) — speakers migrated
|
||||
// to that URL never connect to :443, so the iptables/setcap dance
|
||||
// would only matter for unmigrated speakers falling back to
|
||||
// streaming.bose.com via DNS hijack. Reason carries a short
|
||||
// human-readable explanation rendered in the UI.
|
||||
type Probe443Result struct {
|
||||
Skipped bool
|
||||
Localhost ProbeOutcome
|
||||
LAN ProbeOutcome
|
||||
LANHost string
|
||||
Skipped bool
|
||||
NotApplicable bool
|
||||
Reason string
|
||||
Localhost ProbeOutcome
|
||||
LAN ProbeOutcome
|
||||
LANHost string
|
||||
}
|
||||
|
||||
// ProbeOutcome describes a single TCP-connect probe. Exactly one of
|
||||
@@ -72,6 +81,14 @@ func Check443Reachability(
|
||||
return Probe443Result{Skipped: true}
|
||||
}
|
||||
|
||||
if scheme := schemeOf(serverURL); scheme == "http" {
|
||||
return Probe443Result{
|
||||
NotApplicable: true,
|
||||
Reason: "AfterTouch's configured serverURL is HTTP, so migrated speakers connect over plain HTTP and never use :443. " +
|
||||
"The iptables / setcap / reverse-proxy dance is only needed if you also expect unmigrated speakers to fall back to streaming.bose.com via DNS hijack.",
|
||||
}
|
||||
}
|
||||
|
||||
res := Probe443Result{}
|
||||
|
||||
if err := ProbeTCP("127.0.0.1", 443, timeout); err != nil {
|
||||
@@ -97,6 +114,22 @@ func Check443Reachability(
|
||||
return res
|
||||
}
|
||||
|
||||
// schemeOf returns the lowercased URL scheme of s, or "" if s is empty or
|
||||
// unparseable. Used to decide whether the :443 reachability check is even
|
||||
// applicable to the deployment.
|
||||
func schemeOf(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.ToLower(u.Scheme)
|
||||
}
|
||||
|
||||
// PortFromHTTPSServerURL extracts the numeric port from httpsServerURL. It
|
||||
// returns 0 if the URL is empty, malformed, or has no explicit port — in
|
||||
// that case the caller cannot make a determination about :443 and should
|
||||
@@ -129,7 +162,7 @@ func PortFromHTTPSServerURL(httpsServerURL string) int {
|
||||
// returned string ends without a trailing newline so callers may use it
|
||||
// with log.Print or log.Printf as they prefer.
|
||||
func FormatPreflightGuidance(httpsListenerPort int, res Probe443Result) string {
|
||||
if res.Skipped {
|
||||
if res.Skipped || res.NotApplicable {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -161,6 +194,7 @@ func FormatPreflightGuidance(httpsListenerPort int, res Probe443Result) string {
|
||||
" 1. iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port "+strconv.Itoa(httpsListenerPort),
|
||||
" 2. setcap cap_net_bind_service=+ep <binary> and pass --https-port=443",
|
||||
" 3. reverse proxy (nginx/caddy) terminating TLS on :443",
|
||||
" Caveat: do NOT add the same REDIRECT rule on the OUTPUT chain. That would catch this host's own outbound :443 traffic (browsers, `go install`, `apt-get`) and route it to AfterTouch.",
|
||||
" See docs/guides/HTTPS-SETUP.md for details.",
|
||||
)
|
||||
|
||||
|
||||
@@ -48,7 +48,8 @@ func TestCheck443Reachability_SkipsWhenListenerOn443(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheck443Reachability_ReportsResolverError(t *testing.T) {
|
||||
res := Check443Reachability(8443, "http://broken", func(string) (string, error) {
|
||||
// Use an HTTPS server URL so the NotApplicable short-circuit doesn't fire.
|
||||
res := Check443Reachability(8443, "https://broken", func(string) (string, error) {
|
||||
return "", errResolve("no DNS")
|
||||
}, 100*time.Millisecond)
|
||||
|
||||
@@ -65,6 +66,57 @@ func TestCheck443Reachability_ReportsResolverError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck443Reachability_NotApplicableWhenServerURLIsHTTP(t *testing.T) {
|
||||
res := Check443Reachability(8443, "http://aftertouch.local:8000", func(string) (string, error) {
|
||||
t.Errorf("resolver should not be called when serverURL scheme is HTTP")
|
||||
return "", nil
|
||||
}, 100*time.Millisecond)
|
||||
|
||||
if !res.NotApplicable {
|
||||
t.Errorf("expected NotApplicable=true when serverURL is HTTP, got %+v", res)
|
||||
}
|
||||
|
||||
if res.Reason == "" {
|
||||
t.Errorf("expected NotApplicable verdict to carry a human-readable Reason, got empty")
|
||||
}
|
||||
|
||||
if res.Skipped {
|
||||
t.Errorf("Skipped should only be set when the listener is already on :443; got Skipped=true for HTTP serverURL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck443Reachability_NotApplicableTakesPrecedenceOverProbe(t *testing.T) {
|
||||
// Even if the listener isn't on :443 and probes would fail, an HTTP
|
||||
// serverURL should short-circuit to NotApplicable.
|
||||
res := Check443Reachability(8443, "http://1.2.3.4:8000", func(string) (string, error) {
|
||||
return "1.2.3.4", nil
|
||||
}, 100*time.Millisecond)
|
||||
|
||||
if !res.NotApplicable {
|
||||
t.Errorf("expected NotApplicable=true, got %+v", res)
|
||||
}
|
||||
|
||||
if res.LAN.Error != "" || res.Localhost.Error != "" {
|
||||
t.Errorf("expected no probe errors when NotApplicable short-circuits, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck443Reachability_HTTPSServerURLStillProbes(t *testing.T) {
|
||||
resolverCalled := false
|
||||
res := Check443Reachability(8443, "https://aftertouch.local:8443", func(string) (string, error) {
|
||||
resolverCalled = true
|
||||
return "127.0.0.1", nil
|
||||
}, 100*time.Millisecond)
|
||||
|
||||
if !resolverCalled {
|
||||
t.Errorf("resolver should be called for HTTPS serverURL")
|
||||
}
|
||||
|
||||
if res.NotApplicable {
|
||||
t.Errorf("HTTPS serverURL should not produce NotApplicable, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortFromHTTPSServerURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
@@ -98,6 +150,10 @@ func TestFormatPreflightGuidance_SkippedAndAllOK(t *testing.T) {
|
||||
if FormatPreflightGuidance(8443, bothOK) != "" {
|
||||
t.Errorf("expected empty guidance when both probes succeed")
|
||||
}
|
||||
|
||||
if FormatPreflightGuidance(8443, Probe443Result{NotApplicable: true, Reason: "HTTP only"}) != "" {
|
||||
t.Errorf("expected empty guidance when NotApplicable (UI renders the reason separately)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPreflightGuidance_BothFailMentionsRedirectPort(t *testing.T) {
|
||||
@@ -121,6 +177,19 @@ func TestFormatPreflightGuidance_BothFailMentionsRedirectPort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPreflightGuidance_IncludesOutputChainCaveat(t *testing.T) {
|
||||
res := Probe443Result{
|
||||
Localhost: ProbeOutcome{Error: "connection refused"},
|
||||
LAN: ProbeOutcome{Error: "connection refused"},
|
||||
LANHost: "192.0.2.151",
|
||||
}
|
||||
|
||||
out := FormatPreflightGuidance(8443, res)
|
||||
if !strings.Contains(out, "OUTPUT") {
|
||||
t.Errorf("guidance must warn about the iptables OUTPUT chain side-effect, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
type errResolve string
|
||||
|
||||
func (e errResolve) Error() string { return string(e) }
|
||||
@@ -131,8 +200,9 @@ func TestCheck443Reachability_LANProbeMatchesListenerOutcome(t *testing.T) {
|
||||
// nothing answers on :443 in test environments. The point of this test
|
||||
// is to lock in the result-shape: when localhost:443 is closed (the
|
||||
// default in CI), the function still returns a well-formed result and
|
||||
// reports the resolved LAN host.
|
||||
res := Check443Reachability(8443, "http://1.2.3.4:8000", func(string) (string, error) {
|
||||
// reports the resolved LAN host. Uses HTTPS so the NotApplicable
|
||||
// short-circuit doesn't fire.
|
||||
res := Check443Reachability(8443, "https://1.2.3.4:8443", func(string) (string, error) {
|
||||
return "1.2.3.4", nil
|
||||
}, 200*time.Millisecond)
|
||||
|
||||
|
||||
@@ -3,12 +3,15 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -20,6 +23,8 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/amazon"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/health"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/logbuf"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
@@ -62,6 +67,13 @@ type Server struct {
|
||||
amazonRedirectURI string
|
||||
amazonService *amazon.Service
|
||||
peerObserver *peerObserver
|
||||
healthRegistry *health.Registry
|
||||
logBuf *logbuf.Buffer
|
||||
expectedHosts []string
|
||||
ownCACache struct {
|
||||
once sync.Once
|
||||
cert *x509.Certificate
|
||||
}
|
||||
}
|
||||
|
||||
// RequestSnapshot represents an immutable snapshot of an HTTP request.
|
||||
@@ -97,11 +109,177 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
|
||||
discoveryInterval: 5 * time.Minute,
|
||||
discoveryEnabled: true,
|
||||
peerObserver: newPeerObserver(),
|
||||
healthRegistry: health.NewRegistry(),
|
||||
}
|
||||
|
||||
health.RegisterSourcesXMLPresent(s.healthRegistry, ds)
|
||||
health.RegisterSpeakerInfoReachable(s.healthRegistry, ds)
|
||||
health.RegisterSourcesXMLDiff(s.healthRegistry, ds)
|
||||
health.RegisterSpeakerMargeURLCheck(s.healthRegistry, ds, s.ExpectedHosts)
|
||||
health.RegisterCertChainCheck(
|
||||
s.healthRegistry,
|
||||
func() string {
|
||||
_, httpsURL := s.GetSettings()
|
||||
return httpsURL
|
||||
},
|
||||
s.loadOwnCACert,
|
||||
)
|
||||
health.RegisterCACertExpiryCheck(s.healthRegistry, s.loadOwnCACert, s.ownCACertPath)
|
||||
health.RegisterTestPlaybackCheck(s.healthRegistry, ds, func() string {
|
||||
serverURL, _ := s.GetSettings()
|
||||
return serverURL
|
||||
})
|
||||
health.RegisterOrionPathsCheck(s.healthRegistry, ds)
|
||||
health.RegisterPresetsCountCheck(s.healthRegistry, ds)
|
||||
health.RegisterPresetsConsistencyCheck(s.healthRegistry, ds)
|
||||
health.RegisterRefreshSourcesCheck(s.healthRegistry, ds)
|
||||
health.RegisterDefaultAccountNonBoseDevicesCheck(s.healthRegistry, ds)
|
||||
health.RegisterOAuthTargetReachableCheck(
|
||||
s.healthRegistry,
|
||||
func() string {
|
||||
serverURL, _ := s.GetSettings()
|
||||
return serverURL
|
||||
},
|
||||
s.GetDNSRunning,
|
||||
)
|
||||
|
||||
// Health QuickFix executor for the empty-margeAccountUUID
|
||||
// finding from RegisterSpeakerInfoReachable. Lives here (not in
|
||||
// the health package) because the executor needs setup.Manager
|
||||
// to drive PairAccount — and the health package deliberately
|
||||
// avoids importing setup to keep its transitive dep surface
|
||||
// small (see the boundary comment near speakerInfoXML).
|
||||
s.healthRegistry.RegisterFix(
|
||||
health.CheckIDSpeakerInfoReachable,
|
||||
health.FixIDCompleteSpeakerPairing,
|
||||
s.completeSpeakerPairingFix,
|
||||
)
|
||||
|
||||
// QuickFix executor for the speaker_marge_url mismatch finding.
|
||||
// Adds the speaker's actual margeURL host to settings.TLSExtraHosts
|
||||
// so a subsequent restart picks it up via applyPersistedSettings.
|
||||
s.healthRegistry.RegisterFix(
|
||||
health.CheckIDSpeakerMargeURL,
|
||||
health.FixIDAddMargeHostToTLS,
|
||||
s.addMargeHostToTLSFix,
|
||||
)
|
||||
health.RegisterDNSSanityCheck(
|
||||
s.healthRegistry,
|
||||
s.GetDNSRunning,
|
||||
func() string {
|
||||
serverURL, _ := s.GetSettings()
|
||||
|
||||
ip, err := s.ResolveServerURLIPForPreflight(serverURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ip
|
||||
},
|
||||
)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// SetExpectedHosts records the hostnames the service considers its
|
||||
// own (serverURL host + httpsServerURL host + --tls-extra-host
|
||||
// values). The Health tab's Marge-URL check reads this list at
|
||||
// run time to decide whether a speaker's <margeURL> points at us.
|
||||
func (s *Server) SetExpectedHosts(hosts []string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
out := make([]string, len(hosts))
|
||||
copy(out, hosts)
|
||||
|
||||
s.expectedHosts = out
|
||||
}
|
||||
|
||||
// ExpectedHosts returns a copy of the recorded expected-hosts list.
|
||||
func (s *Server) ExpectedHosts() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
out := make([]string, len(s.expectedHosts))
|
||||
copy(out, s.expectedHosts)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// persistedTLSExtraHosts returns the slice of TLS extra hosts that
|
||||
// live in settings.json. Used by HandleGetSettings to render the
|
||||
// "edit list" UI separately from the full effective SAN list
|
||||
// (ExpectedHosts also contains serverURL host, httpsServerURL host,
|
||||
// hostname, and CLI/env-pinned extras). Returns an empty slice if
|
||||
// the settings file is missing or unreadable — the caller should
|
||||
// treat that the same as "operator hasn't added anything yet".
|
||||
func (s *Server) persistedTLSExtraHosts() []string {
|
||||
persisted, err := s.ds.GetSettings()
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
out := make([]string, len(persisted.TLSExtraHosts))
|
||||
copy(out, persisted.TLSExtraHosts)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// ownCACertPath returns the on-disk path of AfterTouch's own CA
|
||||
// cert (PEM). Empty string when the certmanager isn't wired in.
|
||||
// Used by the Health-tab CA-expiry check to render an accurate
|
||||
// remediation command pointing at the actual file.
|
||||
func (s *Server) ownCACertPath() string {
|
||||
if s.sm == nil || s.sm.Crypto == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return s.sm.Crypto.GetCACertPath()
|
||||
}
|
||||
|
||||
// loadOwnCACert parses AfterTouch's own CA leaf from disk. Used
|
||||
// by the Health-tab cert-chain check to definitively classify
|
||||
// whether the HTTPS endpoint is serving a cert issued by this
|
||||
// service's built-in CA (as opposed to a public CA or a foreign
|
||||
// chain from a reverse proxy). Returns nil when the CA isn't
|
||||
// configured or fails to parse — the caller falls back to a
|
||||
// Subject==Issuer heuristic in that case.
|
||||
//
|
||||
// The parse is cached in ownCACache so repeated Health polls
|
||||
// don't re-read the PEM. Restart-based config changes are
|
||||
// picked up because Server itself is reconstructed.
|
||||
func (s *Server) loadOwnCACert() *x509.Certificate {
|
||||
s.ownCACache.once.Do(func() {
|
||||
if s.sm == nil || s.sm.Crypto == nil {
|
||||
return
|
||||
}
|
||||
|
||||
path := s.sm.Crypto.GetCACertPath()
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.ownCACache.cert = cert
|
||||
})
|
||||
|
||||
return s.ownCACache.cert
|
||||
}
|
||||
|
||||
// TrustedRealIPMiddleware returns a chi middleware that rewrites
|
||||
// r.RemoteAddr from X-Real-IP / X-Forwarded-For / True-Client-IP, but only
|
||||
// when the immediate TCP peer is in the configured trusted-proxy list.
|
||||
@@ -142,6 +320,26 @@ func (s *Server) SetVersionInfo(version, commit, date, repoURL string) {
|
||||
s.RepoURL = repoURL
|
||||
}
|
||||
|
||||
// SetLogBuffer attaches a logbuf.Buffer to the server. When set,
|
||||
// HandleGetLogs returns its contents; when nil, the endpoint
|
||||
// reports an empty snapshot. Optional so that tests and
|
||||
// alternative composers (the standalone web binary, etc.) don't
|
||||
// have to construct a buffer they don't need.
|
||||
func (s *Server) SetLogBuffer(buf *logbuf.Buffer) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.logBuf = buf
|
||||
}
|
||||
|
||||
// LogBuffer returns the attached log buffer, or nil if none.
|
||||
func (s *Server) LogBuffer() *logbuf.Buffer {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.logBuf
|
||||
}
|
||||
|
||||
// SetDiscoverySettings sets the discovery settings for the server.
|
||||
func (s *Server) SetDiscoverySettings(interval time.Duration, enabled bool) {
|
||||
s.mu.Lock()
|
||||
@@ -309,7 +507,7 @@ func (s *Server) startDNSDiscovery(bind string, upstreamList []string) {
|
||||
return
|
||||
}
|
||||
|
||||
s.dnsDiscovery = discovery.NewDNSDiscovery(upstreamList, serviceIP)
|
||||
s.dnsDiscovery = discovery.NewDNSDiscovery(upstreamList, serviceIP, s.serverURL)
|
||||
go func(d *discovery.DNSDiscovery, addr string) {
|
||||
if err := d.Start(addr); err != nil {
|
||||
log.Printf("Warning: DNS discovery server error: %v", err)
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-account')">
|
||||
6. Local Account
|
||||
</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-health')">
|
||||
7. Health
|
||||
</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-logs')">
|
||||
8. Logs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 0: Overview -->
|
||||
@@ -159,6 +165,53 @@
|
||||
<div id="target-domain-resolved" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
|
||||
<div id="https-443-status" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px">
|
||||
<strong>TLS extra hosts:</strong>
|
||||
<span class="info-toggle" onclick="toggleInfo('tls-extra-hosts-info')">ⓘ</span>
|
||||
<div id="tls-extra-hosts-info" class="info-details">
|
||||
<strong>When you need this:</strong> rarely. The TLS
|
||||
certificate already covers AfterTouch's configured
|
||||
server URL, HTTPS URL, and the host's own name. Add
|
||||
entries here only when a speaker can't reach
|
||||
AfterTouch over TLS — typical symptoms include
|
||||
presets resetting on reboot, the BoseApp showing
|
||||
the speaker as offline, or
|
||||
<code>CURLE_SSL_CACERT (60)</code> in the speaker
|
||||
syslog.<br/>
|
||||
<strong>How to tell:</strong> open the
|
||||
<strong>Health tab</strong> and look for
|
||||
<code>speaker_marge_url</code> warnings. Each
|
||||
warning names the host a speaker is pointing at;
|
||||
clicking the <em>Add <host> to TLS hosts</em>
|
||||
QuickFix fills this list for you. If that check is
|
||||
clean, this list can stay empty.<br/>
|
||||
<strong>Manual path:</strong> add one host per line
|
||||
and save. The TLS certificate is regenerated at
|
||||
startup from the merged list of
|
||||
<code>--server-url</code> host,
|
||||
<code>--https-server-url</code> host, the system
|
||||
hostname, any <code>--tls-extra-host</code> /
|
||||
<code>TLS_EXTRA_HOST</code> CLI/env values, and the
|
||||
hosts persisted here. CLI/env wins over persisted
|
||||
on overlap.<br/>
|
||||
<strong>Applying changes requires a service
|
||||
restart.</strong>
|
||||
</div>
|
||||
<div style="font-size: 0.85em; color: #666; margin-top: 4px;">
|
||||
Usually empty. Add a host here only if the
|
||||
<a href="#" onclick="openTab(null, 'tab-health'); return false;">Health tab</a>
|
||||
flags a <code>speaker_marge_url</code> warning — or use
|
||||
the one-click QuickFix on that warning to fill it for you.
|
||||
</div>
|
||||
<div style="margin-top: 5px">
|
||||
<textarea
|
||||
id="tls-extra-hosts"
|
||||
placeholder="One host per line, e.g. 192.0.2.10 or aftertouch.lan"
|
||||
style="width: 360px; height: 84px; font-family: monospace; font-size: 0.9em;"
|
||||
></textarea>
|
||||
</div>
|
||||
<div id="tls-san-effective" style="font-size: 0.8em; color: #666; margin-top: 4px;"></div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px">
|
||||
<strong>Device Discovery:</strong>
|
||||
<div style="margin-top: 5px">
|
||||
@@ -1490,6 +1543,63 @@
|
||||
<div id="account-devices-list">Select an account to view devices.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 7: Health -->
|
||||
<div id="tab-health" class="tab-content">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
|
||||
<h2 style="margin: 0;">Service Health Checks</h2>
|
||||
<button onclick="fetchHealth()">Refresh</button>
|
||||
</div>
|
||||
<p style="font-size: 0.9em; color: #555;">
|
||||
Runs a set of checks against the local datastore and flags
|
||||
findings that may need attention. Quick fixes are offered
|
||||
for issues the service knows how to remediate.
|
||||
</p>
|
||||
|
||||
<div style="margin: 12px 0 16px 0; padding: 10px 12px; background: #fafafa; border: 1px solid #eee; border-radius: 4px;">
|
||||
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
|
||||
<button onclick="downloadDiagnostic()" title="Download an encrypted diagnostic report to share with the project maintainer">Download diagnostic report</button>
|
||||
<span style="font-size: 0.9em; color: #555;">Share a diagnostic snapshot with the project maintainer.</span>
|
||||
</div>
|
||||
<details style="font-size: 0.85em; color: #555; margin-top: 8px;">
|
||||
<summary style="cursor: pointer;">What does the diagnostic report contain?</summary>
|
||||
<ul style="margin: 6px 0 0 0; padding-left: 1.4em;">
|
||||
<li>Health check results and current device state</li>
|
||||
<li>Device XML files from the datastore (no passwords)</li>
|
||||
<li>HTTP response samples from speaker endpoints</li>
|
||||
<li>System files: CA bundle, DNS resolver config</li>
|
||||
<li>Speaker CA bundle and kernel log (via SSH, if reachable)</li>
|
||||
<li>Service log tail</li>
|
||||
<li>Settings file with secrets redacted</li>
|
||||
</ul>
|
||||
<p style="margin: 6px 0 0 0;">The archive is encrypted with the project maintainer's public key — only they can open it.</p>
|
||||
</details>
|
||||
<div id="health-diagnostic-status" style="font-size: 0.85em; margin-top: 8px;"></div>
|
||||
</div>
|
||||
|
||||
<div id="health-generated-at" style="font-size: 0.8em; color: #888; margin-bottom: 10px;"></div>
|
||||
<div id="health-findings">Loading…</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 8: Logs -->
|
||||
<div id="tab-logs" class="tab-content">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
|
||||
<h2 style="margin: 0;">Service Logs</h2>
|
||||
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
|
||||
<input type="text" id="logs-filter" placeholder="Filter (substring, case-insensitive)" style="padding: 4px 8px; min-width: 260px;"/>
|
||||
<label style="font-size: 0.9em;">
|
||||
<input type="checkbox" id="logs-follow" checked/> Follow tail
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size: 0.9em; color: #555;">
|
||||
Live mirror of the service's stderr log. Stderr still
|
||||
receives every line — this is a read-only view for
|
||||
convenience.
|
||||
</p>
|
||||
<div id="logs-status" style="font-size: 0.8em; color: #888; margin-bottom: 6px;">Idle.</div>
|
||||
<pre id="logs-view" style="background: #111; color: #ddd; padding: 10px; border-radius: 4px; max-height: 60vh; overflow-y: auto; font-size: 0.8em; line-height: 1.35; margin: 0; white-space: pre-wrap; word-break: break-all;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/web/js/script.js"></script>
|
||||
|
||||
@@ -7,6 +7,51 @@
|
||||
// to the user — they'd be misleading without context.
|
||||
const FAST_ERROR_MS = 150;
|
||||
|
||||
// copyTextToClipboard attempts navigator.clipboard.writeText first (modern
|
||||
// async API, requires a secure context — HTTPS or localhost). On insecure
|
||||
// contexts (plain HTTP at a LAN IP), the Clipboard API is unavailable, so
|
||||
// we fall back to the legacy document.execCommand("copy") path using a
|
||||
// throwaway off-screen textarea. Returns true on success, false on
|
||||
// failure. Both paths preserve the page's current focus.
|
||||
async function copyTextToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Fall through to the legacy path — some browsers still reject
|
||||
// even when isSecureContext claims true (e.g. iframes without
|
||||
// the clipboard-write permission).
|
||||
}
|
||||
}
|
||||
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.setAttribute("readonly", "");
|
||||
ta.style.position = "absolute";
|
||||
ta.style.left = "-9999px";
|
||||
ta.style.top = "0";
|
||||
document.body.appendChild(ta);
|
||||
|
||||
const previousActive = document.activeElement;
|
||||
ta.select();
|
||||
|
||||
let ok = false;
|
||||
try {
|
||||
ok = document.execCommand("copy");
|
||||
} catch (e) {
|
||||
ok = false;
|
||||
}
|
||||
|
||||
document.body.removeChild(ta);
|
||||
|
||||
if (previousActive && typeof previousActive.focus === "function") {
|
||||
previousActive.focus();
|
||||
}
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function probeBrowser443(lanHost, listenerPort, statusEl, serverLocalhostOK, serverLanOK) {
|
||||
const line = document.createElement("div");
|
||||
line.style.fontSize = "0.85em";
|
||||
@@ -216,6 +261,10 @@ async function fetchSettings() {
|
||||
} else if (settings.https_443_check_skipped) {
|
||||
port443.style.color = "#2e7d32";
|
||||
port443.innerHTML = "✅ HTTPS listener bound directly to <code>:443</code> — speakers can connect.";
|
||||
} else if (settings.https_443_not_applicable) {
|
||||
port443.style.color = "#1565c0";
|
||||
port443.innerHTML = "ℹ️ <code>:443</code> reachability check not applicable. " +
|
||||
(settings.https_443_reason || "");
|
||||
} else {
|
||||
const localhostOK = settings.https_443_localhost_reachable;
|
||||
const lanOK = settings.https_443_lan_reachable;
|
||||
@@ -279,6 +328,18 @@ async function fetchSettings() {
|
||||
document.getElementById("internal-paths").value = settings.internal_paths.join("\n");
|
||||
}
|
||||
|
||||
if (Array.isArray(settings.tls_extra_hosts)) {
|
||||
document.getElementById("tls-extra-hosts").value = settings.tls_extra_hosts.join("\n");
|
||||
}
|
||||
const effective = document.getElementById("tls-san-effective");
|
||||
if (effective) {
|
||||
if (Array.isArray(settings.tls_san_hosts) && settings.tls_san_hosts.length) {
|
||||
effective.innerText = "Currently covered by TLS cert: " + settings.tls_san_hosts.join(", ");
|
||||
} else {
|
||||
effective.innerText = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Spotify credential fields
|
||||
if (settings.spotify_client_id !== undefined) {
|
||||
document.getElementById("spotify-client-id").value = settings.spotify_client_id || "";
|
||||
@@ -372,6 +433,11 @@ async function updateSettings() {
|
||||
amazon_client_id: document.getElementById("amazon-client-id").value,
|
||||
amazon_client_secret: document.getElementById("amazon-client-secret").value,
|
||||
amazon_redirect_uri: document.getElementById("amazon-redirect-uri").value,
|
||||
tls_extra_hosts: document
|
||||
.getElementById("tls-extra-hosts")
|
||||
.value.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== ""),
|
||||
};
|
||||
const status = document.getElementById("settings-status");
|
||||
status.innerText = "Saving...";
|
||||
@@ -429,12 +495,16 @@ async function fetchDevices() {
|
||||
<td class="col-fw-serial"><div class="col-firmware">${d.firmware_version || "0.0.0"}</div><div class="col-serial" style="font-size: 0.8em; color: #666;">${d.device_serial_number}</div></td>
|
||||
<td class="col-method">${methodLabel}</td>
|
||||
<td>
|
||||
<button onclick="toggleDeviceSummary('${d.device_id}')">Inspect</button>
|
||||
<button onclick="prepareSync('${d.device_id}')">Sync Data</button>
|
||||
<button onclick="prepareMigration('${d.device_id}')">Migrate</button>
|
||||
<button id="prime-spotify-${d.device_id}" class="btn-spotify" style="display: none;" onclick="primeSpotify('${d.device_id}')">Prime Spotify</button>
|
||||
<button class="btn-danger" onclick="removeDevice('${d.device_id}', '${d.name}')">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="device-summary-${d.device_id}" style="display: none;">
|
||||
<td colspan="6" id="device-summary-cell-${d.device_id}" style="background: #fafafa; padding: 12px;"></td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
const optSync = document.createElement("option");
|
||||
@@ -507,6 +577,16 @@ function openTab(evt, tabId) {
|
||||
fetchAccountList();
|
||||
}
|
||||
|
||||
if (tabId === "tab-health") {
|
||||
fetchHealth();
|
||||
}
|
||||
|
||||
if (tabId === "tab-logs") {
|
||||
startLogsPolling();
|
||||
} else {
|
||||
stopLogsPolling();
|
||||
}
|
||||
|
||||
if (evt) {
|
||||
evt.currentTarget.className += " active";
|
||||
let hash = tabId;
|
||||
@@ -3976,3 +4056,663 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
fetchSettings();
|
||||
triggerDiscovery();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fetchHealth() {
|
||||
const findingsEl = document.getElementById("health-findings");
|
||||
const generatedAtEl = document.getElementById("health-generated-at");
|
||||
if (!findingsEl) return;
|
||||
|
||||
findingsEl.textContent = "Loading…";
|
||||
if (generatedAtEl) generatedAtEl.textContent = "";
|
||||
|
||||
try {
|
||||
const resp = await fetch("/setup/health");
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
renderHealthChecks(data, findingsEl, generatedAtEl);
|
||||
} catch (e) {
|
||||
findingsEl.textContent = `Failed to load health checks: ${e.message || e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadDiagnostic() {
|
||||
const statusEl = document.getElementById("health-diagnostic-status");
|
||||
if (statusEl) statusEl.textContent = "Building diagnostic report…";
|
||||
|
||||
try {
|
||||
const resp = await fetch("/setup/export/diagnostic");
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => resp.statusText);
|
||||
throw new Error(`HTTP ${resp.status}: ${text}`);
|
||||
}
|
||||
|
||||
const disposition = resp.headers.get("Content-Disposition") || "";
|
||||
const match = disposition.match(/filename[^;=\n]*=(?:"([^"]+)"|([^;\n]+))/);
|
||||
const filename = (match && (match[1] || match[2])) || "aftertouch-diagnostic.age";
|
||||
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
if (statusEl) {
|
||||
const safe = filename.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
statusEl.innerHTML =
|
||||
`Downloaded: <strong>${safe}</strong><br>` +
|
||||
`To share it, please <strong>prefer email</strong>: ` +
|
||||
`<a href="mailto:aftertouch-support@gesellix.net?subject=Diagnostic%20report&body=Please%20attach%20${encodeURIComponent(safe)}%20to%20this%20email.">aftertouch-support@gesellix.net</a>. ` +
|
||||
`Alternatively, open a <a href="https://github.com/gesellix/Bose-SoundTouch/issues" target="_blank" rel="noopener">GitHub issue</a> ` +
|
||||
`and attach the file renamed to <code>${safe}.txt</code> ` +
|
||||
`(GitHub blocks <code>.age</code> uploads; adding <code>.txt</code> works around that).`;
|
||||
}
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.textContent = `Failed to download diagnostic: ${e.message || e}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderHealthChecks(data, findingsEl, generatedAtEl) {
|
||||
if (generatedAtEl && data.generatedAt) {
|
||||
generatedAtEl.textContent = `Last run: ${data.generatedAt}`;
|
||||
}
|
||||
|
||||
const checks = data.checks || [];
|
||||
if (checks.length === 0) {
|
||||
findingsEl.textContent = "No checks are registered.";
|
||||
return;
|
||||
}
|
||||
|
||||
findingsEl.innerHTML = "";
|
||||
for (const check of checks) {
|
||||
findingsEl.appendChild(renderHealthCheck(check));
|
||||
}
|
||||
}
|
||||
|
||||
function renderHealthCheck(check) {
|
||||
const box = document.createElement("div");
|
||||
box.className = "summary-box";
|
||||
|
||||
const header = document.createElement("h3");
|
||||
header.style.margin = "0 0 8px 0";
|
||||
header.appendChild(severityBadge(check.severity));
|
||||
header.appendChild(document.createTextNode(" " + check.title));
|
||||
box.appendChild(header);
|
||||
|
||||
const idLine = document.createElement("div");
|
||||
idLine.style.fontSize = "0.75em";
|
||||
idLine.style.color = "#888";
|
||||
idLine.style.marginBottom = "8px";
|
||||
idLine.textContent = `id: ${check.id}`;
|
||||
box.appendChild(idLine);
|
||||
|
||||
const findings = check.findings || [];
|
||||
if (findings.length === 0) {
|
||||
const ok = document.createElement("div");
|
||||
ok.style.color = "#2e7d32";
|
||||
ok.textContent = "✓ No issues detected.";
|
||||
box.appendChild(ok);
|
||||
return box;
|
||||
}
|
||||
|
||||
for (const f of findings) {
|
||||
box.appendChild(renderFinding(check.id, f));
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function renderFinding(checkId, finding) {
|
||||
const row = document.createElement("div");
|
||||
row.style.borderTop = "1px solid #e0e0e0";
|
||||
row.style.padding = "10px 0";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.appendChild(severityBadge(finding.severity));
|
||||
title.appendChild(document.createTextNode(" " + (finding.message || "")));
|
||||
row.appendChild(title);
|
||||
|
||||
const target = finding.target || {};
|
||||
if (target.account || target.device) {
|
||||
const t = document.createElement("div");
|
||||
t.style.fontSize = "0.8em";
|
||||
t.style.color = "#666";
|
||||
t.style.marginTop = "4px";
|
||||
const parts = [];
|
||||
if (target.account) parts.push(`account ${target.account}`);
|
||||
if (target.device) parts.push(`device ${target.device}`);
|
||||
t.textContent = parts.join(" · ");
|
||||
row.appendChild(t);
|
||||
}
|
||||
|
||||
if (finding.details) {
|
||||
const d = document.createElement("div");
|
||||
d.style.fontSize = "0.85em";
|
||||
d.style.color = "#444";
|
||||
d.style.marginTop = "6px";
|
||||
d.textContent = finding.details;
|
||||
row.appendChild(d);
|
||||
}
|
||||
|
||||
const fixes = finding.quickFixes || [];
|
||||
if (fixes.length > 0) {
|
||||
const actions = document.createElement("div");
|
||||
actions.style.marginTop = "8px";
|
||||
actions.style.display = "flex";
|
||||
actions.style.gap = "8px";
|
||||
actions.style.flexWrap = "wrap";
|
||||
for (const fix of fixes) {
|
||||
const btn = document.createElement("button");
|
||||
btn.textContent = fix.label || fix.id;
|
||||
btn.onclick = () => runQuickFix(checkId, fix.id, target, fix.confirm, btn);
|
||||
actions.appendChild(btn);
|
||||
}
|
||||
const status = document.createElement("span");
|
||||
status.className = "health-fix-status";
|
||||
status.style.fontSize = "0.85em";
|
||||
status.style.alignSelf = "center";
|
||||
actions.appendChild(status);
|
||||
row.appendChild(actions);
|
||||
}
|
||||
|
||||
const manualCommands = finding.manualCommands || [];
|
||||
for (const cmd of manualCommands) {
|
||||
row.appendChild(renderManualCommand(cmd));
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderManualCommand(cmd) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.style.marginTop = "8px";
|
||||
wrap.style.padding = "8px";
|
||||
wrap.style.background = "#f4f4f4";
|
||||
wrap.style.borderRadius = "4px";
|
||||
wrap.style.fontSize = "0.85em";
|
||||
|
||||
if (cmd.label) {
|
||||
const label = document.createElement("div");
|
||||
label.style.color = "#444";
|
||||
label.style.marginBottom = "4px";
|
||||
label.textContent = cmd.label;
|
||||
wrap.appendChild(label);
|
||||
}
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.style.display = "flex";
|
||||
row.style.alignItems = "stretch";
|
||||
row.style.gap = "8px";
|
||||
|
||||
const code = document.createElement("code");
|
||||
code.style.flex = "1";
|
||||
code.style.padding = "6px 8px";
|
||||
code.style.background = "#fff";
|
||||
code.style.border = "1px solid #ddd";
|
||||
code.style.borderRadius = "3px";
|
||||
code.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, monospace";
|
||||
code.style.whiteSpace = "pre-wrap";
|
||||
code.style.wordBreak = "break-all";
|
||||
code.textContent = cmd.command;
|
||||
row.appendChild(code);
|
||||
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.textContent = "Copy";
|
||||
copyBtn.style.alignSelf = "flex-start";
|
||||
copyBtn.onclick = async () => {
|
||||
const ok = await copyTextToClipboard(cmd.command);
|
||||
copyBtn.textContent = ok ? "Copied" : "Copy failed";
|
||||
if (ok) {
|
||||
setTimeout(() => { copyBtn.textContent = "Copy"; }, 1200);
|
||||
}
|
||||
};
|
||||
row.appendChild(copyBtn);
|
||||
|
||||
wrap.appendChild(row);
|
||||
|
||||
if (cmd.hint) {
|
||||
const hint = document.createElement("div");
|
||||
hint.style.fontSize = "0.8em";
|
||||
hint.style.color = "#666";
|
||||
hint.style.marginTop = "4px";
|
||||
hint.textContent = cmd.hint;
|
||||
wrap.appendChild(hint);
|
||||
}
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function severityBadge(severity) {
|
||||
const span = document.createElement("span");
|
||||
span.style.fontSize = "0.75em";
|
||||
span.style.padding = "2px 6px";
|
||||
span.style.borderRadius = "10px";
|
||||
span.style.fontWeight = "bold";
|
||||
const palette = {
|
||||
ok: { bg: "#e8f5e9", fg: "#2e7d32", label: "OK" },
|
||||
info: { bg: "#e3f2fd", fg: "#1565c0", label: "INFO" },
|
||||
warning: { bg: "#fff8e1", fg: "#a06800", label: "WARN" },
|
||||
error: { bg: "#ffebee", fg: "#c62828", label: "ERROR" },
|
||||
};
|
||||
const p = palette[severity] || palette.info;
|
||||
span.style.background = p.bg;
|
||||
span.style.color = p.fg;
|
||||
span.textContent = p.label;
|
||||
return span;
|
||||
}
|
||||
|
||||
async function runQuickFix(checkId, fixId, target, confirmMsg, button) {
|
||||
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||
|
||||
const status = button.parentElement.querySelector(".health-fix-status");
|
||||
button.disabled = true;
|
||||
if (status) {
|
||||
status.textContent = "Applying…";
|
||||
status.style.color = "#555";
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch("/setup/health/fix", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ checkId, fixId, target }),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data.error || data.message || `HTTP ${resp.status}`);
|
||||
|
||||
if (status) {
|
||||
status.textContent = data.message || "Done.";
|
||||
status.style.color = "#2e7d32";
|
||||
}
|
||||
// Refresh to drop the resolved finding.
|
||||
setTimeout(fetchHealth, 400);
|
||||
} catch (e) {
|
||||
if (status) {
|
||||
status.textContent = `Failed: ${e.message || e}`;
|
||||
status.style.color = "#c62828";
|
||||
}
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logs tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const logsState = {
|
||||
timerId: null,
|
||||
nextSince: 0,
|
||||
entries: [],
|
||||
maxEntries: 5000, // client-side cap; UI lag is the bottleneck
|
||||
droppedTotal: 0,
|
||||
pollIntervalMs: 1500,
|
||||
followTail: true,
|
||||
initialised: false,
|
||||
};
|
||||
|
||||
function startLogsPolling() {
|
||||
initLogsTabOnce();
|
||||
// Reset window each time the tab opens so the user gets a
|
||||
// fresh snapshot rather than picking up stale state.
|
||||
logsState.entries = [];
|
||||
logsState.nextSince = 0;
|
||||
logsState.droppedTotal = 0;
|
||||
renderLogs();
|
||||
pollLogsOnce();
|
||||
if (logsState.timerId !== null) clearInterval(logsState.timerId);
|
||||
logsState.timerId = setInterval(pollLogsOnce, logsState.pollIntervalMs);
|
||||
}
|
||||
|
||||
function stopLogsPolling() {
|
||||
if (logsState.timerId !== null) {
|
||||
clearInterval(logsState.timerId);
|
||||
logsState.timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function initLogsTabOnce() {
|
||||
if (logsState.initialised) return;
|
||||
logsState.initialised = true;
|
||||
|
||||
const filterEl = document.getElementById("logs-filter");
|
||||
if (filterEl) {
|
||||
filterEl.addEventListener("input", () => renderLogs());
|
||||
}
|
||||
|
||||
const followEl = document.getElementById("logs-follow");
|
||||
if (followEl) {
|
||||
followEl.addEventListener("change", () => {
|
||||
logsState.followTail = followEl.checked;
|
||||
if (logsState.followTail) scrollLogsToBottom();
|
||||
});
|
||||
}
|
||||
|
||||
const viewEl = document.getElementById("logs-view");
|
||||
if (viewEl) {
|
||||
// Disengage follow-tail when the user scrolls up; re-engage
|
||||
// when they're back at the bottom. tail -f muscle memory.
|
||||
viewEl.addEventListener("scroll", () => {
|
||||
const distanceFromBottom = viewEl.scrollHeight - viewEl.scrollTop - viewEl.clientHeight;
|
||||
const atBottom = distanceFromBottom < 8;
|
||||
if (atBottom !== logsState.followTail) {
|
||||
logsState.followTail = atBottom;
|
||||
const followEl = document.getElementById("logs-follow");
|
||||
if (followEl) followEl.checked = atBottom;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function pollLogsOnce() {
|
||||
if (typeof document !== "undefined" && document.hidden) return;
|
||||
|
||||
const statusEl = document.getElementById("logs-status");
|
||||
try {
|
||||
const url = `/setup/logs?since=${encodeURIComponent(logsState.nextSince)}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
|
||||
if (Array.isArray(data.entries) && data.entries.length > 0) {
|
||||
logsState.entries.push(...data.entries);
|
||||
// Trim from the front when we exceed the client cap.
|
||||
if (logsState.entries.length > logsState.maxEntries) {
|
||||
logsState.entries.splice(0, logsState.entries.length - logsState.maxEntries);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof data.nextSince === "number") {
|
||||
logsState.nextSince = data.nextSince;
|
||||
}
|
||||
|
||||
if (typeof data.dropped === "number" && data.dropped > 0) {
|
||||
logsState.droppedTotal += data.dropped;
|
||||
}
|
||||
|
||||
renderLogs();
|
||||
if (statusEl) {
|
||||
const now = new Date().toLocaleTimeString();
|
||||
const droppedNote = logsState.droppedTotal > 0
|
||||
? ` · ${logsState.droppedTotal} dropped`
|
||||
: "";
|
||||
statusEl.textContent = `${logsState.entries.length} buffered${droppedNote} · last update ${now}`;
|
||||
}
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.textContent = `Polling failed: ${e.message || e}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLogs() {
|
||||
const viewEl = document.getElementById("logs-view");
|
||||
if (!viewEl) return;
|
||||
|
||||
const filterEl = document.getElementById("logs-filter");
|
||||
const filter = filterEl ? filterEl.value.trim().toLowerCase() : "";
|
||||
|
||||
const lines = [];
|
||||
for (const entry of logsState.entries) {
|
||||
if (filter && entry.message.toLowerCase().indexOf(filter) === -1) continue;
|
||||
const ts = entry.time ? entry.time.replace("T", " ").replace("Z", "") : "";
|
||||
lines.push(`${ts} ${entry.message}`);
|
||||
}
|
||||
|
||||
viewEl.textContent = lines.join("\n");
|
||||
|
||||
if (logsState.followTail) {
|
||||
scrollLogsToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
function scrollLogsToBottom() {
|
||||
const viewEl = document.getElementById("logs-view");
|
||||
if (viewEl) viewEl.scrollTop = viewEl.scrollHeight;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device summary (Devices tab — per-device "Inspect" panel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function toggleDeviceSummary(deviceId) {
|
||||
const row = document.getElementById(`device-summary-${deviceId}`);
|
||||
const cell = document.getElementById(`device-summary-cell-${deviceId}`);
|
||||
if (!row || !cell) return;
|
||||
|
||||
if (row.style.display !== "none") {
|
||||
row.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
row.style.display = "";
|
||||
cell.innerHTML = '<em style="color:#666;">Probing speaker…</em>';
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/setup/device-summary/${encodeURIComponent(deviceId)}`);
|
||||
if (!resp.ok) {
|
||||
const txt = await resp.text();
|
||||
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${resp.status} ${escapeHTML(txt)}</span>`;
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
cell.innerHTML = "";
|
||||
cell.appendChild(renderDeviceSummary(data));
|
||||
} catch (e) {
|
||||
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${escapeHTML(e.message || String(e))}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDeviceSummary(data) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.style.display = "grid";
|
||||
wrap.style.gridTemplateColumns = "repeat(auto-fit, minmax(280px, 1fr))";
|
||||
wrap.style.gap = "12px";
|
||||
|
||||
wrap.appendChild(summaryCard("Speaker /info", renderSpeakerInfoBody(data.speaker.info)));
|
||||
wrap.appendChild(summaryCard("Speaker /sources", renderSpeakerSourcesBody(data.speaker.sources, data.service)));
|
||||
wrap.appendChild(summaryCard("Speaker /presets", renderSpeakerPresetsBody(data.speaker.presets, data.service)));
|
||||
wrap.appendChild(summaryCard("Service-side state", renderServiceBody(data.service)));
|
||||
wrap.appendChild(summaryCard("Pairing inference", renderPairingBody(data.pairing, data.service)));
|
||||
|
||||
const footer = document.createElement("div");
|
||||
footer.style.gridColumn = "1 / -1";
|
||||
footer.style.fontSize = "0.75em";
|
||||
footer.style.color = "#888";
|
||||
footer.textContent = `Generated at ${data.generated_at}`;
|
||||
wrap.appendChild(footer);
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function summaryCard(title, bodyNode) {
|
||||
const card = document.createElement("div");
|
||||
card.style.background = "#fff";
|
||||
card.style.border = "1px solid #ddd";
|
||||
card.style.borderRadius = "4px";
|
||||
card.style.padding = "10px 12px";
|
||||
|
||||
const h = document.createElement("div");
|
||||
h.style.fontWeight = "bold";
|
||||
h.style.marginBottom = "8px";
|
||||
h.style.fontSize = "0.9em";
|
||||
h.textContent = title;
|
||||
card.appendChild(h);
|
||||
|
||||
if (bodyNode) card.appendChild(bodyNode);
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderSpeakerInfoBody(info) {
|
||||
const body = document.createElement("div");
|
||||
body.style.fontSize = "0.85em";
|
||||
|
||||
if (!info.reachable) {
|
||||
body.appendChild(unreachableBlock(info));
|
||||
return body;
|
||||
}
|
||||
|
||||
body.appendChild(kv("name", info.name));
|
||||
body.appendChild(kv("type", info.type));
|
||||
body.appendChild(kv("margeAccountUUID", info.marge_account_uuid || "(empty)"));
|
||||
body.appendChild(kv("margeURL", info.marge_url || "(empty)"));
|
||||
return body;
|
||||
}
|
||||
|
||||
function renderSpeakerSourcesBody(sources, service) {
|
||||
const body = document.createElement("div");
|
||||
body.style.fontSize = "0.85em";
|
||||
|
||||
if (!sources.reachable) {
|
||||
body.appendChild(unreachableBlock(sources));
|
||||
return body;
|
||||
}
|
||||
|
||||
const types = sources.types || [];
|
||||
body.appendChild(kv("count", String(types.length)));
|
||||
body.appendChild(kv("types", types.length ? types.join(", ") : "(none)"));
|
||||
|
||||
const svcTypes = (service && service.service_source_types) || [];
|
||||
const missingOnSpeaker = svcTypes.filter(t => types.indexOf(t) < 0);
|
||||
const extraOnSpeaker = types.filter(t => svcTypes.indexOf(t) < 0);
|
||||
|
||||
if (missingOnSpeaker.length > 0) {
|
||||
body.appendChild(kv("missing on speaker", missingOnSpeaker.join(", "), "#a06800"));
|
||||
}
|
||||
if (extraOnSpeaker.length > 0) {
|
||||
body.appendChild(kv("extra on speaker", extraOnSpeaker.join(", "), "#666"));
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function renderSpeakerPresetsBody(presets, service) {
|
||||
const body = document.createElement("div");
|
||||
body.style.fontSize = "0.85em";
|
||||
|
||||
if (!presets.reachable) {
|
||||
body.appendChild(unreachableBlock(presets));
|
||||
return body;
|
||||
}
|
||||
|
||||
const ids = presets.ids || [];
|
||||
body.appendChild(kv("count", String(ids.length)));
|
||||
body.appendChild(kv("slots", ids.length ? ids.join(", ") : "(none)"));
|
||||
|
||||
if (service && typeof service.service_preset_count === "number") {
|
||||
if (service.service_preset_count !== ids.length) {
|
||||
body.appendChild(kv("service count", String(service.service_preset_count), "#a06800"));
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function renderServiceBody(service) {
|
||||
const body = document.createElement("div");
|
||||
body.style.fontSize = "0.85em";
|
||||
|
||||
body.appendChild(kv("server URL", service.server_url || "(unset)"));
|
||||
|
||||
const hosts = service.expected_hosts || [];
|
||||
body.appendChild(kv("expected hosts", hosts.length ? hosts.join(", ") : "(none)"));
|
||||
|
||||
body.appendChild(kv("Sources.xml", service.sources_xml_present ? "present" : "MISSING", service.sources_xml_present ? null : "#c62828"));
|
||||
body.appendChild(kv("Presets.xml", service.presets_xml_present ? `${service.service_preset_count} preset(s)` : "(empty)"));
|
||||
return body;
|
||||
}
|
||||
|
||||
function renderPairingBody(pairing, service) {
|
||||
const body = document.createElement("div");
|
||||
body.style.fontSize = "0.85em";
|
||||
|
||||
body.appendChild(kv("paired", pairing.paired ? "yes" : "NO", pairing.paired ? null : "#c62828"));
|
||||
body.appendChild(kv("speaker marge host", pairing.speaker_marge_host || "(unknown)"));
|
||||
|
||||
const matches = pairing.marge_url_matches_service;
|
||||
body.appendChild(kv("matches service?", matches ? "yes" : "NO", matches ? null : "#a06800"));
|
||||
return body;
|
||||
}
|
||||
|
||||
function kv(label, value, valueColor) {
|
||||
const row = document.createElement("div");
|
||||
row.style.display = "flex";
|
||||
row.style.gap = "8px";
|
||||
row.style.marginBottom = "2px";
|
||||
row.style.alignItems = "baseline";
|
||||
|
||||
const l = document.createElement("span");
|
||||
l.style.color = "#666";
|
||||
l.style.minWidth = "120px";
|
||||
l.style.flexShrink = "0";
|
||||
l.textContent = label;
|
||||
row.appendChild(l);
|
||||
|
||||
const v = document.createElement("span");
|
||||
v.style.wordBreak = "break-all";
|
||||
if (valueColor) v.style.color = valueColor;
|
||||
v.textContent = value;
|
||||
row.appendChild(v);
|
||||
return row;
|
||||
}
|
||||
|
||||
function unreachableBlock(probe) {
|
||||
const wrap = document.createElement("div");
|
||||
|
||||
const msg = document.createElement("div");
|
||||
msg.style.color = "#a06800";
|
||||
msg.style.marginBottom = "6px";
|
||||
msg.textContent = probe.error ? `Unreachable: ${probe.error}` : "Unreachable from this service host.";
|
||||
wrap.appendChild(msg);
|
||||
|
||||
if (probe.curl_command) {
|
||||
const hint = document.createElement("div");
|
||||
hint.style.fontSize = "0.85em";
|
||||
hint.style.color = "#444";
|
||||
hint.style.marginBottom = "4px";
|
||||
hint.textContent = "Run from your LAN:";
|
||||
wrap.appendChild(hint);
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.style.display = "flex";
|
||||
row.style.gap = "8px";
|
||||
row.style.alignItems = "stretch";
|
||||
|
||||
const code = document.createElement("code");
|
||||
code.style.flex = "1";
|
||||
code.style.padding = "4px 6px";
|
||||
code.style.background = "#f4f4f4";
|
||||
code.style.border = "1px solid #ddd";
|
||||
code.style.borderRadius = "3px";
|
||||
code.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, monospace";
|
||||
code.style.fontSize = "0.8em";
|
||||
code.style.whiteSpace = "pre-wrap";
|
||||
code.style.wordBreak = "break-all";
|
||||
code.textContent = probe.curl_command;
|
||||
row.appendChild(code);
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.textContent = "Copy";
|
||||
btn.onclick = async () => {
|
||||
const ok = await copyTextToClipboard(probe.curl_command);
|
||||
btn.textContent = ok ? "Copied" : "Copy failed";
|
||||
if (ok) {
|
||||
setTimeout(() => { btn.textContent = "Copy"; }, 1200);
|
||||
}
|
||||
};
|
||||
row.appendChild(btn);
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function escapeHTML(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CheckIDCACertExpiry is the registry id of the CA-cert expiry
|
||||
// check.
|
||||
const CheckIDCACertExpiry = "ca_cert_expiry"
|
||||
|
||||
// CA-expiry thresholds. Tunable here rather than per-deployment
|
||||
// because the consequence of a missed warning is the same
|
||||
// everywhere: leaves issued by the CA will be rejected.
|
||||
const (
|
||||
caExpiryWarnThreshold = 30 * 24 * time.Hour
|
||||
caExpiryInfoThreshold = 90 * 24 * time.Hour
|
||||
caExpiryRecentlyValidated = 365 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// CACertPathFunc returns the on-disk path of AfterTouch's own
|
||||
// CA cert. Optional; when nil the manual command falls back to a
|
||||
// neutral hint.
|
||||
type CACertPathFunc func() string
|
||||
|
||||
// RegisterCACertExpiryCheck registers a check that reads
|
||||
// AfterTouch's own CA cert (via caCertFn) and emits a finding
|
||||
// when its NotAfter is in the past or in the warn/info windows.
|
||||
//
|
||||
// Why a separate check from service_cert_chain: even when the
|
||||
// served leaf validates today (or is correctly classified as
|
||||
// self-signed), the CA's eventual expiry will break every leaf
|
||||
// it ever issued. Operators should regenerate before that
|
||||
// happens — and re-pair speakers, since their stored trust
|
||||
// anchor will no longer cover newly-issued leaves.
|
||||
//
|
||||
// caCertPathFn is used purely to render a remediation command
|
||||
// pointing at the actual on-disk path. Pass nil to skip the
|
||||
// path mention.
|
||||
func RegisterCACertExpiryCheck(r *Registry, caCertFn func() *x509.Certificate, caCertPathFn CACertPathFunc) {
|
||||
r.Register(Check{
|
||||
ID: CheckIDCACertExpiry,
|
||||
Title: "AfterTouch CA cert is not near expiry",
|
||||
Run: func() []Finding {
|
||||
return runCACertExpiryCheck(caCertFn, caCertPathFn, time.Now())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runCACertExpiryCheck(caCertFn func() *x509.Certificate, caCertPathFn CACertPathFunc, now time.Time) []Finding {
|
||||
if caCertFn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cert := caCertFn()
|
||||
if cert == nil {
|
||||
return []Finding{{
|
||||
Severity: SeverityInfo,
|
||||
Message: "Couldn't load AfterTouch's own CA cert; expiry not checked.",
|
||||
Details: "service_cert_chain falls back to a Subject==Issuer heuristic for the same reason. Verify the CA path is readable from the service host.",
|
||||
}}
|
||||
}
|
||||
|
||||
if cert.NotAfter.IsZero() {
|
||||
return []Finding{{
|
||||
Severity: SeverityWarning,
|
||||
Message: "AfterTouch CA cert has no NotAfter set; treat as expired.",
|
||||
}}
|
||||
}
|
||||
|
||||
remaining := cert.NotAfter.Sub(now)
|
||||
expiresAt := cert.NotAfter.UTC().Format("2006-01-02")
|
||||
|
||||
switch {
|
||||
case remaining <= 0:
|
||||
return []Finding{caExpiryFinding(SeverityError,
|
||||
fmt.Sprintf("AfterTouch CA cert expired %d day(s) ago (on %s).", daysRounded(-remaining), expiresAt),
|
||||
"Speakers will reject any leaf signed by this CA. Regenerate now: stop the service, remove the CA files, restart so EnsureCA reissues, then run setup install-ca against every paired speaker.",
|
||||
cert, caCertPathFn,
|
||||
)}
|
||||
case remaining <= caExpiryWarnThreshold:
|
||||
return []Finding{caExpiryFinding(SeverityWarning,
|
||||
fmt.Sprintf("AfterTouch CA cert expires in %d day(s) (on %s).", daysRounded(remaining), expiresAt),
|
||||
"Plan a regeneration. Every paired speaker will need setup install-ca again after, since their stored trust anchor won't cover the new leaves.",
|
||||
cert, caCertPathFn,
|
||||
)}
|
||||
case remaining <= caExpiryInfoThreshold:
|
||||
return []Finding{caExpiryFinding(SeverityInfo,
|
||||
fmt.Sprintf("AfterTouch CA cert expires in %d day(s) (on %s).", daysRounded(remaining), expiresAt),
|
||||
"No immediate action; surfaced here so the renewal isn't a surprise.",
|
||||
cert, caCertPathFn,
|
||||
)}
|
||||
}
|
||||
|
||||
// > 90 days remaining; nothing to surface. Optionally we
|
||||
// could emit a positive info finding ("valid until …") but
|
||||
// the empty-findings path lets the check roll up to OK,
|
||||
// which is the clearer "healthy" signal.
|
||||
_ = caExpiryRecentlyValidated
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// daysRounded converts a duration to a whole-day count, rounded
|
||||
// to nearest. Avoids the "expires in 59 days" surprise when the
|
||||
// real value is 60 days minus a fraction-of-a-second from ASN.1
|
||||
// time truncation.
|
||||
func daysRounded(d time.Duration) int {
|
||||
return int((d + 12*time.Hour) / (24 * time.Hour))
|
||||
}
|
||||
|
||||
func caExpiryFinding(severity Severity, message, details string, cert *x509.Certificate, caCertPathFn CACertPathFunc) Finding {
|
||||
enrichedDetails := fmt.Sprintf("%s Subject: %s. Valid from: %s. Valid to: %s.",
|
||||
details,
|
||||
cert.Subject.String(),
|
||||
cert.NotBefore.UTC().Format(time.RFC3339),
|
||||
cert.NotAfter.UTC().Format(time.RFC3339),
|
||||
)
|
||||
|
||||
commands := []ManualCommand{{
|
||||
Label: "Regenerate (destructive — requires re-installing the CA on every speaker afterwards):",
|
||||
Command: caRegenCommand(caCertPathFn),
|
||||
Hint: "Adjust the path to match your deployment if it differs. The service's EnsureCA() reissues a fresh CA at startup when the file is missing.",
|
||||
}}
|
||||
|
||||
return Finding{
|
||||
Severity: severity,
|
||||
Message: message,
|
||||
Details: enrichedDetails,
|
||||
ManualCommands: commands,
|
||||
}
|
||||
}
|
||||
|
||||
func caRegenCommand(caCertPathFn CACertPathFunc) string {
|
||||
if caCertPathFn == nil {
|
||||
return "# stop soundtouch-service, remove the CA cert + key files, restart"
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(caCertPathFn())
|
||||
if path == "" {
|
||||
return "# stop soundtouch-service, remove the CA cert + key files, restart"
|
||||
}
|
||||
|
||||
// Key path conventionally sits next to the cert with a `.key`
|
||||
// extension or matching basename; surface the cert path
|
||||
// explicitly and let the operator pick up the key by sight.
|
||||
keyHint := strings.TrimSuffix(path, ".crt") + ".key"
|
||||
|
||||
return fmt.Sprintf("# stop the service, then:\nrm '%s' '%s'\n# restart soundtouch-service; EnsureCA reissues at boot.", path, keyHint)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// buildCA returns a CA cert with the given NotBefore / NotAfter.
|
||||
// Self-signed; subject doesn't matter for these tests.
|
||||
func buildCA(t *testing.T, notBefore, notAfter time.Time) *x509.Certificate {
|
||||
t.Helper()
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2026),
|
||||
Subject: pkix.Name{CommonName: "AfterTouch Local Root CA", Organization: []string{"AfterTouch Test"}},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageCertSign,
|
||||
}
|
||||
template.Issuer = template.Subject
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa key: %v", err)
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(derBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse cert: %v", err)
|
||||
}
|
||||
|
||||
return cert
|
||||
}
|
||||
|
||||
func TestCAExpiry_HealthyCertProducesNoFinding(t *testing.T) {
|
||||
now := time.Now()
|
||||
cert := buildCA(t, now.Add(-30*24*time.Hour), now.Add(2*365*24*time.Hour))
|
||||
|
||||
got := runCACertExpiryCheck(func() *x509.Certificate { return cert }, nil, now)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected no findings for healthy cert, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCAExpiry_InfoLevelInsideNinetyDays(t *testing.T) {
|
||||
now := time.Now()
|
||||
cert := buildCA(t, now.Add(-30*24*time.Hour), now.Add(60*24*time.Hour))
|
||||
|
||||
got := runCACertExpiryCheck(func() *x509.Certificate { return cert }, nil, now)
|
||||
if len(got) != 1 || got[0].Severity != SeverityInfo {
|
||||
t.Fatalf("expected one info finding, got %+v", got)
|
||||
}
|
||||
|
||||
if !strings.Contains(got[0].Message, "60") {
|
||||
t.Errorf("expected day count in message, got %q", got[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCAExpiry_WarningInsideThirtyDays(t *testing.T) {
|
||||
now := time.Now()
|
||||
cert := buildCA(t, now.Add(-30*24*time.Hour), now.Add(15*24*time.Hour))
|
||||
|
||||
got := runCACertExpiryCheck(func() *x509.Certificate { return cert }, nil, now)
|
||||
if len(got) != 1 || got[0].Severity != SeverityWarning {
|
||||
t.Fatalf("expected one warning, got %+v", got)
|
||||
}
|
||||
|
||||
if !strings.Contains(got[0].Message, "15") {
|
||||
t.Errorf("expected day count in message, got %q", got[0].Message)
|
||||
}
|
||||
|
||||
if !strings.Contains(got[0].Details, "Plan a regeneration") {
|
||||
t.Errorf("expected regen guidance in details, got %q", got[0].Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCAExpiry_ErrorWhenExpired(t *testing.T) {
|
||||
now := time.Now()
|
||||
cert := buildCA(t, now.Add(-365*24*time.Hour), now.Add(-2*24*time.Hour))
|
||||
|
||||
got := runCACertExpiryCheck(func() *x509.Certificate { return cert }, nil, now)
|
||||
if len(got) != 1 || got[0].Severity != SeverityError {
|
||||
t.Fatalf("expected one error, got %+v", got)
|
||||
}
|
||||
|
||||
if !strings.Contains(got[0].Message, "expired") {
|
||||
t.Errorf("expected 'expired' in message, got %q", got[0].Message)
|
||||
}
|
||||
|
||||
if !strings.Contains(got[0].Message, "2 day") {
|
||||
t.Errorf("expected day count in message, got %q", got[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCAExpiry_NoCAGracefulInfo(t *testing.T) {
|
||||
got := runCACertExpiryCheck(func() *x509.Certificate { return nil }, nil, time.Now())
|
||||
if len(got) != 1 || got[0].Severity != SeverityInfo {
|
||||
t.Fatalf("expected one info finding when CA missing, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCAExpiry_NilLoaderSkipsEntirely(t *testing.T) {
|
||||
got := runCACertExpiryCheck(nil, nil, time.Now())
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected no findings with nil loader, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCARegenCommand_IncludesActualPath(t *testing.T) {
|
||||
got := caRegenCommand(func() string { return "/var/lib/aftertouch/ca.crt" })
|
||||
if !strings.Contains(got, "/var/lib/aftertouch/ca.crt") {
|
||||
t.Errorf("expected cert path in command, got %q", got)
|
||||
}
|
||||
|
||||
if !strings.Contains(got, "/var/lib/aftertouch/ca.key") {
|
||||
t.Errorf("expected .key sibling path in command, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCARegenCommand_FallsBackWhenPathUnknown(t *testing.T) {
|
||||
got := caRegenCommand(nil)
|
||||
if !strings.Contains(got, "remove the CA") {
|
||||
t.Errorf("expected fallback hint when path unknown, got %q", got)
|
||||
}
|
||||
|
||||
got = caRegenCommand(func() string { return "" })
|
||||
if !strings.Contains(got, "remove the CA") {
|
||||
t.Errorf("expected fallback hint when path empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCAExpiry_ManualCommandPathRendered(t *testing.T) {
|
||||
now := time.Now()
|
||||
cert := buildCA(t, now.Add(-30*24*time.Hour), now.Add(5*24*time.Hour))
|
||||
|
||||
got := runCACertExpiryCheck(
|
||||
func() *x509.Certificate { return cert },
|
||||
func() string { return "/srv/aftertouch/ca.crt" },
|
||||
now,
|
||||
)
|
||||
|
||||
if len(got) != 1 || len(got[0].ManualCommands) != 1 {
|
||||
t.Fatalf("expected one manual command, got %+v", got)
|
||||
}
|
||||
|
||||
cmd := got[0].ManualCommands[0]
|
||||
if !strings.Contains(cmd.Command, "/srv/aftertouch/ca.crt") {
|
||||
t.Errorf("expected actual path in command, got %q", cmd.Command)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CheckIDCertChain is the registry id of the cert-chain probe.
|
||||
const CheckIDCertChain = "service_cert_chain"
|
||||
|
||||
// RegisterCertChainCheck registers a check that dials the
|
||||
// configured HTTPS endpoint and reports whether its certificate
|
||||
// chain validates against the system trust store. Three outcomes:
|
||||
//
|
||||
// - validates against system roots → no finding (the
|
||||
// speaker's firmware ships with the major roots, so a public-
|
||||
// CA chain such as Let's Encrypt is usable directly).
|
||||
// - chain doesn't validate but the served leaf was issued by
|
||||
// our own AfterTouch CA → warning with an `install-ca`
|
||||
// suggestion (definitive: we checked the signature against
|
||||
// our CA, not a Subject==Issuer heuristic).
|
||||
// - chain doesn't validate and the served leaf was issued by
|
||||
// something else → warning with an `openssl s_client`
|
||||
// investigation prompt (foreign chain / reverse proxy /
|
||||
// ingress cert).
|
||||
// - HTTPS URL not configured → skip silently.
|
||||
//
|
||||
// caCertFn returns AfterTouch's own CA leaf certificate (nil if
|
||||
// unavailable). It's called per check run; the handler-side
|
||||
// implementation caches the parse via sync.Once so we don't
|
||||
// re-read the PEM on every poll.
|
||||
func RegisterCertChainCheck(r *Registry, httpsURLFn func() string, caCertFn func() *x509.Certificate) {
|
||||
r.Register(Check{
|
||||
ID: CheckIDCertChain,
|
||||
Title: "HTTPS endpoint TLS configuration",
|
||||
Run: func() []Finding {
|
||||
return runCertChainCheck(httpsURLFn(), caCertFn)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runCertChainCheck(httpsURL string, caCertFn func() *x509.Certificate) []Finding {
|
||||
if strings.TrimSpace(httpsURL) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
host, port := splitHTTPSHostPort(httpsURL)
|
||||
if host == "" {
|
||||
return []Finding{{
|
||||
Severity: SeverityWarning,
|
||||
Message: fmt.Sprintf("Configured HTTPS URL %q is not parseable.", httpsURL),
|
||||
}}
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, port)
|
||||
|
||||
dialer := &net.Dialer{Timeout: 2 * time.Second}
|
||||
|
||||
// Phase 1: try with the system trust store. ServerName is set
|
||||
// from the URL so the verifier checks SAN coverage too.
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{
|
||||
ServerName: host,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return nil // validates against system roots
|
||||
}
|
||||
|
||||
// Phase 2: extract the leaf cert from Phase 1's verification
|
||||
// error. crypto/x509 attaches the offending certificate to the
|
||||
// three verification-failure error types, which lets us inspect
|
||||
// what the server actually served without ever opening a second
|
||||
// connection with InsecureSkipVerify. If the error is something
|
||||
// else (timeout, TCP reset, protocol mismatch), report it as a
|
||||
// reachability problem.
|
||||
leaf := leafFromVerifyError(err)
|
||||
if leaf == nil {
|
||||
return []Finding{{
|
||||
Severity: SeverityError,
|
||||
Message: fmt.Sprintf("Could not connect to %s: %v", addr, err),
|
||||
Details: "AfterTouch's HTTPS endpoint isn't reachable from inside the service, or the peer dropped the handshake before presenting a certificate. Check that the listener is bound and the URL host:port resolves correctly.",
|
||||
}}
|
||||
}
|
||||
|
||||
dnsNames := strings.Join(leaf.DNSNames, ", ")
|
||||
if dnsNames == "" {
|
||||
dnsNames = "(none)"
|
||||
}
|
||||
|
||||
chainContext := fmt.Sprintf(
|
||||
"Leaf subject: %s. Issuer: %s. SANs: %s. Expires: %s.",
|
||||
leaf.Subject.String(), leaf.Issuer.String(), dnsNames, leaf.NotAfter.Format("2006-01-02"),
|
||||
)
|
||||
|
||||
switch classifyLeaf(leaf, caCertFn) {
|
||||
case leafFromOwnCA:
|
||||
return []Finding{{
|
||||
Severity: SeverityInfo,
|
||||
Message: fmt.Sprintf("AfterTouch is serving its own self-signed CA chain on %s (expected).", addr),
|
||||
Details: "The service host's system trust store doesn't include AfterTouch's CA — by design. Speakers establish trust via `setup install-ca`, not via system roots. This finding is informational; nothing is wrong with the service. " +
|
||||
chainContext,
|
||||
ManualCommands: []ManualCommand{{
|
||||
Label: "Reminder — each speaker still needs AfterTouch's CA installed once:",
|
||||
Command: "soundtouch-cli --host=<speaker-ip> setup install-ca --service-url=" + httpsURL,
|
||||
Hint: "Verified by signature: the leaf was issued by AfterTouch's own CA. Only run install-ca for speakers that haven't been migrated yet.",
|
||||
}},
|
||||
}}
|
||||
|
||||
case leafSubjectEqualsIssuer:
|
||||
return []Finding{{
|
||||
Severity: SeverityInfo,
|
||||
Message: fmt.Sprintf("HTTPS endpoint on %s is serving a self-signed certificate.", addr),
|
||||
Details: "AfterTouch's own CA couldn't be loaded to verify the leaf's signature, so this is a heuristic match (Subject == Issuer). If this *is* AfterTouch's self-signed chain, the situation is normal and speakers trust it via `setup install-ca`. If it's some other self-signed cert (custom proxy, etc.), treat the openssl investigation command below as the primary action. " +
|
||||
chainContext,
|
||||
ManualCommands: []ManualCommand{
|
||||
{
|
||||
Label: "If this is AfterTouch's CA, install it on each speaker:",
|
||||
Command: "soundtouch-cli --host=<speaker-ip> setup install-ca --service-url=" + httpsURL,
|
||||
Hint: "Heuristic match — verify the served Issuer matches AfterTouch's CA before running.",
|
||||
},
|
||||
{
|
||||
Label: "Or inspect the served chain manually:",
|
||||
Command: fmt.Sprintf("openssl s_client -connect %s -servername %s -showcerts </dev/null", addr, host),
|
||||
Hint: "Run from the same host as the service.",
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
default:
|
||||
return []Finding{{
|
||||
Severity: SeverityWarning,
|
||||
Message: fmt.Sprintf("HTTPS endpoint on %s serves a chain that doesn't validate against system roots and wasn't issued by AfterTouch's CA.", addr),
|
||||
Details: fmt.Sprintf("Unexpected chain — likely a reverse proxy or ingress cert. Verification error: %v. ", err) +
|
||||
chainContext,
|
||||
ManualCommands: []ManualCommand{{
|
||||
Label: "Inspect the chain manually:",
|
||||
Command: fmt.Sprintf("openssl s_client -connect %s -servername %s -showcerts </dev/null", addr, host),
|
||||
Hint: "Run from the same host as the service. Shows the full chain the peer is serving.",
|
||||
}},
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
func splitHTTPSHostPort(raw string) (string, string) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
port = "443"
|
||||
}
|
||||
|
||||
return host, port
|
||||
}
|
||||
|
||||
// leafFromVerifyError extracts the offending certificate that
|
||||
// crypto/tls attaches to a verification-failure error. Returns
|
||||
// nil for non-verification errors (network unreachable, timeout,
|
||||
// TLS-level handshake failure before any cert was presented).
|
||||
//
|
||||
// This is the substitute for an InsecureSkipVerify re-dial: the
|
||||
// same leaf is reachable from the failed strict-dial error
|
||||
// without ever standing up a connection that disables cert
|
||||
// validation.
|
||||
//
|
||||
// tls.CertificateVerificationError carries
|
||||
// UnverifiedCertificates across platforms (the underlying error
|
||||
// is platform-specific — on darwin it comes from
|
||||
// Security.framework, on linux from crypto/x509 — but the
|
||||
// wrapping type is the same). The x509.* unwraps are a
|
||||
// defensive fallback for the case where a caller hands us a
|
||||
// verification error that bypassed the tls layer.
|
||||
func leafFromVerifyError(err error) *x509.Certificate {
|
||||
var certErr *tls.CertificateVerificationError
|
||||
if errors.As(err, &certErr) && len(certErr.UnverifiedCertificates) > 0 {
|
||||
return certErr.UnverifiedCertificates[0]
|
||||
}
|
||||
|
||||
var unkAuth x509.UnknownAuthorityError
|
||||
if errors.As(err, &unkAuth) {
|
||||
return unkAuth.Cert
|
||||
}
|
||||
|
||||
var hostnameErr x509.HostnameError
|
||||
if errors.As(err, &hostnameErr) {
|
||||
return hostnameErr.Certificate
|
||||
}
|
||||
|
||||
var invalidErr x509.CertificateInvalidError
|
||||
if errors.As(err, &invalidErr) {
|
||||
return invalidErr.Cert
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// leafClassification labels how the leaf relates to AfterTouch's
|
||||
// own CA. Drives the install-ca-vs-openssl suggestion branch.
|
||||
type leafClassification int
|
||||
|
||||
const (
|
||||
leafForeign leafClassification = iota // chain we don't recognise
|
||||
leafFromOwnCA // signature verified by AfterTouch's CA
|
||||
leafSubjectEqualsIssuer // fallback heuristic when CA isn't loadable
|
||||
)
|
||||
|
||||
// classifyLeaf returns leafFromOwnCA when caCertFn returns a CA
|
||||
// cert that signed `leaf` (verified by CheckSignatureFrom). When
|
||||
// the CA cert isn't available, falls back to the
|
||||
// Subject==Issuer heuristic. Anything else is leafForeign.
|
||||
func classifyLeaf(leaf *x509.Certificate, caCertFn func() *x509.Certificate) leafClassification {
|
||||
if leaf == nil {
|
||||
return leafForeign
|
||||
}
|
||||
|
||||
if caCertFn != nil {
|
||||
if ca := caCertFn(); ca != nil {
|
||||
if err := leaf.CheckSignatureFrom(ca); err == nil {
|
||||
return leafFromOwnCA
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if leaf.Subject.String() == leaf.Issuer.String() {
|
||||
return leafSubjectEqualsIssuer
|
||||
}
|
||||
|
||||
return leafForeign
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCertChain_EmptyURLSkips(t *testing.T) {
|
||||
got := runCertChainCheck("", nil)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected no findings for empty URL, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertChain_UnparseableURLWarns(t *testing.T) {
|
||||
got := runCertChainCheck("://nope", nil)
|
||||
if len(got) != 1 || got[0].Severity != SeverityWarning {
|
||||
t.Fatalf("expected one warning, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertChain_UnreachableEndpoint(t *testing.T) {
|
||||
// 127.0.0.1:1 refuses; using https:// to force TLS path.
|
||||
got := runCertChainCheck("https://127.0.0.1:1/", nil)
|
||||
if len(got) != 1 || got[0].Severity != SeverityError {
|
||||
t.Fatalf("expected one error for unreachable endpoint, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertChain_SelfSigned_SubjectEqualsIssuerFallback(t *testing.T) {
|
||||
srv := newSelfSignedTLSServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
// No CA provided → fallback to Subject==Issuer heuristic.
|
||||
// This is informational, not a warning — a self-signed
|
||||
// AfterTouch chain is the expected default deployment shape.
|
||||
got := runCertChainCheck(srv.URL, nil)
|
||||
if len(got) != 1 || got[0].Severity != SeverityInfo {
|
||||
t.Fatalf("expected one info finding for self-signed cert, got %+v", got)
|
||||
}
|
||||
|
||||
if !strings.Contains(got[0].Details, "Issuer") {
|
||||
t.Errorf("expected issuer detail, got %q", got[0].Details)
|
||||
}
|
||||
|
||||
if !strings.Contains(got[0].Details, "heuristic") {
|
||||
t.Errorf("expected heuristic disclosure in details, got %q", got[0].Details)
|
||||
}
|
||||
|
||||
if len(got[0].ManualCommands) < 2 {
|
||||
t.Fatalf("expected at least install-ca + openssl commands, got %+v", got[0].ManualCommands)
|
||||
}
|
||||
|
||||
var sawInstallCA, sawOpenssl bool
|
||||
for _, c := range got[0].ManualCommands {
|
||||
if strings.Contains(c.Command, "install-ca") {
|
||||
sawInstallCA = true
|
||||
}
|
||||
if strings.Contains(c.Command, "openssl s_client") {
|
||||
sawOpenssl = true
|
||||
}
|
||||
}
|
||||
|
||||
if !sawInstallCA {
|
||||
t.Errorf("expected install-ca suggestion among manual commands")
|
||||
}
|
||||
if !sawOpenssl {
|
||||
t.Errorf("expected openssl investigation command among manual commands")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertChain_LeafSignedByOwnCA_IsInformationalNotAWarning(t *testing.T) {
|
||||
// AfterTouch's own CA is the *default* deployment shape.
|
||||
// Calling that a warning would mislead non-technical users.
|
||||
// The check should report INFO, explain the situation, and
|
||||
// remind to install-ca on speakers — but not present the
|
||||
// service host's lack of trust as a defect.
|
||||
caTLS, ca := generateInternalCA(t)
|
||||
leafTLS := generateLeafSignedBy(t, ca, caTLS.PrivateKey)
|
||||
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
srv.TLS = &tls.Config{Certificates: []tls.Certificate{leafTLS}}
|
||||
srv.StartTLS()
|
||||
defer srv.Close()
|
||||
|
||||
got := runCertChainCheck(srv.URL, func() *x509.Certificate { return ca })
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one finding, got %+v", got)
|
||||
}
|
||||
|
||||
if got[0].Severity != SeverityInfo {
|
||||
t.Errorf("expected SeverityInfo for AfterTouch's own CA chain, got %q", got[0].Severity)
|
||||
}
|
||||
|
||||
if !strings.Contains(got[0].Message, "expected") {
|
||||
t.Errorf("expected message to call this state 'expected', got %q", got[0].Message)
|
||||
}
|
||||
|
||||
if !strings.Contains(got[0].Details, "by design") {
|
||||
t.Errorf("expected details to explain it's by design, got %q", got[0].Details)
|
||||
}
|
||||
|
||||
if len(got[0].ManualCommands) == 0 {
|
||||
t.Fatalf("expected install-ca reminder among manual commands")
|
||||
}
|
||||
|
||||
cmd := got[0].ManualCommands[0]
|
||||
if !strings.Contains(cmd.Command, "install-ca") {
|
||||
t.Errorf("expected install-ca reminder, got %q", cmd.Command)
|
||||
}
|
||||
|
||||
if !strings.Contains(cmd.Hint, "Verified by signature") {
|
||||
t.Errorf("expected signature-verified hint, got %q", cmd.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertChain_ForeignChain_SuggestsOpenSSL(t *testing.T) {
|
||||
// Build an "external" CA + leaf, then provide a *different*
|
||||
// CA via caCertFn. Signature check fails → classifier returns
|
||||
// leafForeign → openssl suggestion (since Subject==Issuer
|
||||
// would also fail for a properly chained leaf).
|
||||
externalCATLS, externalCA := generateInternalCA(t)
|
||||
leafTLS := generateLeafSignedBy(t, externalCA, externalCATLS.PrivateKey)
|
||||
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
srv.TLS = &tls.Config{Certificates: []tls.Certificate{leafTLS}}
|
||||
srv.StartTLS()
|
||||
defer srv.Close()
|
||||
|
||||
// Different CA — pretend it's "our" AfterTouch CA.
|
||||
_, ourCA := generateInternalCA(t)
|
||||
|
||||
got := runCertChainCheck(srv.URL, func() *x509.Certificate { return ourCA })
|
||||
if len(got) != 1 || got[0].Severity != SeverityWarning {
|
||||
t.Fatalf("expected one warning, got %+v", got)
|
||||
}
|
||||
|
||||
cmd := got[0].ManualCommands[0]
|
||||
if !strings.Contains(cmd.Command, "openssl s_client") {
|
||||
t.Errorf("expected openssl suggestion for foreign chain, got %q", cmd.Command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitHTTPSHostPort(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, host, port string
|
||||
}{
|
||||
{"https://example.com/", "example.com", "443"},
|
||||
{"https://example.com:8443/", "example.com", "8443"},
|
||||
{"https://192.0.2.10/", "192.0.2.10", "443"},
|
||||
{"https://", "", ""},
|
||||
{"://broken", "", ""},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
h, p := splitHTTPSHostPort(c.in)
|
||||
if h != c.host || p != c.port {
|
||||
t.Errorf("splitHTTPSHostPort(%q) = (%q, %q), want (%q, %q)", c.in, h, p, c.host, c.port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newSelfSignedTLSServer returns an httptest.Server whose TLS
|
||||
// config uses a self-signed cert we generate inline. httptest's
|
||||
// default TLS server uses a built-in cert, but verifying its
|
||||
// Subject == Issuer property without inspecting innards is
|
||||
// fiddly; making our own keeps the assertion deterministic.
|
||||
func newSelfSignedTLSServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
cert := generateSelfSignedCert(t)
|
||||
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
srv.TLS = &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
srv.StartTLS()
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
func generateSelfSignedCert(t *testing.T) tls.Certificate {
|
||||
t.Helper()
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "aftertouch-test"},
|
||||
Issuer: pkix.Name{CommonName: "aftertouch-test"}, // self-signed
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
DNSNames: []string{"127.0.0.1"},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa key: %v", err)
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
|
||||
return tls.Certificate{
|
||||
Certificate: [][]byte{derBytes},
|
||||
PrivateKey: key,
|
||||
}
|
||||
}
|
||||
|
||||
// generateInternalCA returns a self-signed CA suitable for
|
||||
// signing leaves. The returned tls.Certificate carries the CA
|
||||
// key (needed to sign leaves below); the *x509.Certificate is
|
||||
// the parsed CA leaf.
|
||||
func generateInternalCA(t *testing.T) (tls.Certificate, *x509.Certificate) {
|
||||
t.Helper()
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2026),
|
||||
Subject: pkix.Name{CommonName: "AfterTouch Test CA", Organization: []string{"AfterTouch Test"}},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
template.Issuer = template.Subject
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa key: %v", err)
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create CA cert: %v", err)
|
||||
}
|
||||
|
||||
caParsed, err := x509.ParseCertificate(derBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse CA cert: %v", err)
|
||||
}
|
||||
|
||||
return tls.Certificate{Certificate: [][]byte{derBytes}, PrivateKey: key}, caParsed
|
||||
}
|
||||
|
||||
// generateLeafSignedBy issues a TLS leaf cert (CN=leaf) signed
|
||||
// by ca/caKey, with SAN 127.0.0.1 so httptest's loopback SNI
|
||||
// matches. Subject != Issuer by construction — the case that
|
||||
// caught my old heuristic.
|
||||
func generateLeafSignedBy(t *testing.T, ca *x509.Certificate, caKey any) tls.Certificate {
|
||||
t.Helper()
|
||||
|
||||
leafKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("leaf rsa key: %v", err)
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(42),
|
||||
Subject: pkix.Name{CommonName: "soundtouch", Organization: []string{"AfterTouch"}},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
DNSNames: []string{"127.0.0.1"},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, template, ca, &leafKey.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create leaf cert: %v", err)
|
||||
}
|
||||
|
||||
return tls.Certificate{Certificate: [][]byte{derBytes}, PrivateKey: leafKey}
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// CheckIDPresetsConsistency is the registry id of the consistency check.
|
||||
const CheckIDPresetsConsistency = "presets_recents_sources_consistency"
|
||||
|
||||
// FixIDDeleteOrphanAccountEntry is the QuickFix that removes a stale
|
||||
// account directory for a device after the operator has confirmed the
|
||||
// speaker isn't currently targeting it.
|
||||
const FixIDDeleteOrphanAccountEntry = "delete_orphan_account_entry"
|
||||
|
||||
// FixIDReclassifyCanonicalSourceIDs is the QuickFix that rewrites
|
||||
// non-canonical IDs on built-in radio sources back to their canonical
|
||||
// values (TUNEIN→10004, INTERNET_RADIO→10002, …), and updates any
|
||||
// preset/recent <sourceid> references to match. Offline-only — no
|
||||
// speaker access required.
|
||||
const FixIDReclassifyCanonicalSourceIDs = "reclassify_canonical_source_ids"
|
||||
|
||||
// speakerPresetsConsistencyXML mirrors enough of :8090/presets to extract
|
||||
// slot id, source/location and itemName for cross-side comparison.
|
||||
type speakerPresetsConsistencyXML struct {
|
||||
XMLName xml.Name `xml:"presets"`
|
||||
Presets []struct {
|
||||
ID string `xml:"id,attr"`
|
||||
ContentItem struct {
|
||||
Source string `xml:"source,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr"`
|
||||
Location string `xml:"location,attr"`
|
||||
ItemName string `xml:"itemName"`
|
||||
} `xml:"ContentItem"`
|
||||
} `xml:"preset"`
|
||||
}
|
||||
|
||||
// speakerRecentsConsistencyXML mirrors :8090/recents.
|
||||
type speakerRecentsConsistencyXML struct {
|
||||
XMLName xml.Name `xml:"recents"`
|
||||
Recents []struct {
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
UtcTime string `xml:"utcTime,attr"`
|
||||
ID string `xml:"id,attr"`
|
||||
ContentItem struct {
|
||||
Source string `xml:"source,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr"`
|
||||
Location string `xml:"location,attr"`
|
||||
ItemName string `xml:"itemName"`
|
||||
} `xml:"contentItem"`
|
||||
} `xml:"recent"`
|
||||
}
|
||||
|
||||
// speakerSourcesConsistencyXML mirrors :8090/sources.
|
||||
type speakerSourcesConsistencyXML struct {
|
||||
XMLName xml.Name `xml:"sources"`
|
||||
Items []struct {
|
||||
Source string `xml:"source,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr"`
|
||||
} `xml:"sourceItem"`
|
||||
}
|
||||
|
||||
// speakerInfoMargeXML mirrors just the <margeAccountUUID> element of
|
||||
// :8090/info — the speaker's own statement of which Marge account it's
|
||||
// currently paired with. Used to confirm orphan-dir deletions against
|
||||
// the authoritative speaker-side answer.
|
||||
type speakerInfoMargeXML struct {
|
||||
XMLName xml.Name `xml:"info"`
|
||||
MargeAccountUUID string `xml:"margeAccountUUID"`
|
||||
}
|
||||
|
||||
// fetchSpeakerMargeAccount asks the speaker which account it thinks it
|
||||
// belongs to. Returns "" when the speaker is unreachable, returned a
|
||||
// non-200, or didn't include margeAccountUUID in /info — callers
|
||||
// treat empty as "no signal" rather than as a deletion blocker.
|
||||
func fetchSpeakerMargeAccount(ctx context.Context, ip string) string {
|
||||
if ip == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fetchSpeakerMargeAccountFromURL(ctx, fmt.Sprintf("http://%s:8090/info", ip))
|
||||
}
|
||||
|
||||
// fetchSpeakerMargeAccountFromURL is the URL-injectable variant used
|
||||
// by tests that need to point the probe at an httptest server. The
|
||||
// production caller goes through fetchSpeakerMargeAccount above.
|
||||
func fetchSpeakerMargeAccountFromURL(ctx context.Context, infoURL string) string {
|
||||
res := ProbeGet(ctx, infoURL, 2*time.Second)
|
||||
if !res.Reachable || res.Status != 200 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parsed speakerInfoMargeXML
|
||||
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return parsed.MargeAccountUUID
|
||||
}
|
||||
|
||||
// RegisterPresetsConsistencyCheck registers the cross-reference check.
|
||||
// For every paired device with a known IP, it builds two ConsistencyViews
|
||||
// (speaker, service), runs the internal-consistency pass on each, then
|
||||
// the cross-side pass — and surfaces every detected issue as a Finding so
|
||||
// the operator can drill into "why aren't my presets behaving" without
|
||||
// reading service logs or curl-ing XML by hand.
|
||||
func RegisterPresetsConsistencyCheck(r *Registry, ds *datastore.DataStore) {
|
||||
r.Register(Check{
|
||||
ID: CheckIDPresetsConsistency,
|
||||
Title: "Presets, recents and sources cross-reference consistently",
|
||||
Run: func() []Finding {
|
||||
return runPresetsConsistencyCheck(ds)
|
||||
},
|
||||
})
|
||||
|
||||
r.RegisterFix(CheckIDPresetsConsistency, FixIDDeleteOrphanAccountEntry, func(target Target) (string, error) {
|
||||
return deleteOrphanAccountEntry(ds, target)
|
||||
})
|
||||
|
||||
r.RegisterFix(CheckIDPresetsConsistency, FixIDReclassifyCanonicalSourceIDs, func(target Target) (string, error) {
|
||||
return reclassifyCanonicalSourceIDs(ds, target)
|
||||
})
|
||||
}
|
||||
|
||||
func runPresetsConsistencyCheck(ds *datastore.DataStore) []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
|
||||
|
||||
// Surface orphan "default"-account entries for devices that are
|
||||
// also paired under a real account. The speaker pairs to one
|
||||
// account at a time; the leftover "default" record from the
|
||||
// pre-pair phase is stale state the operator can safely remove.
|
||||
findings = append(findings, detectOrphanDefaultEntries(ds, devices)...)
|
||||
|
||||
for i := range devices {
|
||||
dev := &devices[i]
|
||||
if dev.AccountID == "" || dev.DeviceID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
findings = append(findings, checkOneDeviceConsistency(ds, dev.AccountID, dev.DeviceID, dev.IPAddress)...)
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
// detectOrphanDefaultEntries flags devices that exist under multiple
|
||||
// account directories. The speaker decides which account it belongs to
|
||||
// via the URL of every PUT it sends; any other account entry on disk
|
||||
// is leftover state from a previous pairing. The active account is
|
||||
// the one ListAllDevices' dedup currently exposes (with "default"
|
||||
// already deprioritised); the stale ones each get a finding with a
|
||||
// confirm-gated QuickFix so the operator can delete them one at a
|
||||
// time after verifying via the service log which account the speaker
|
||||
// is actually targeting.
|
||||
func detectOrphanDefaultEntries(ds *datastore.DataStore, paired []models.ServiceDeviceInfo) []Finding {
|
||||
type deviceInfo struct {
|
||||
account string
|
||||
ip string
|
||||
}
|
||||
|
||||
activeAccount := map[string]deviceInfo{}
|
||||
|
||||
for i := range paired {
|
||||
if paired[i].DeviceID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
activeAccount[paired[i].DeviceID] = deviceInfo{
|
||||
account: paired[i].AccountID,
|
||||
ip: paired[i].IPAddress,
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var findings []Finding
|
||||
|
||||
for deviceID, info := range activeAccount {
|
||||
allAccounts := ds.AllAccountsForDevice(deviceID)
|
||||
if len(allAccounts) <= 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Speaker's own answer to "which account do I belong to?".
|
||||
// Empty when unreachable; treated as "no signal" — we fall
|
||||
// back to the on-disk ListAllDevices guess.
|
||||
speakerAccount := fetchSpeakerMargeAccount(ctx, info.ip)
|
||||
|
||||
authoritative := info.account
|
||||
signalSource := "on-disk activity (ListAllDevices)"
|
||||
|
||||
if speakerAccount != "" {
|
||||
authoritative = speakerAccount
|
||||
signalSource = "the speaker itself, via :8090/info"
|
||||
|
||||
if speakerAccount != info.account {
|
||||
log.Printf("[Health] consistency: speaker %s reports margeAccountUUID=%s but ListAllDevices picked %s — preferring the speaker's answer for orphan-deletion suggestions",
|
||||
deviceID, speakerAccount, info.account)
|
||||
}
|
||||
}
|
||||
|
||||
for _, acc := range allAccounts {
|
||||
if acc == authoritative {
|
||||
continue
|
||||
}
|
||||
|
||||
findings = append(findings, Finding{
|
||||
Severity: SeverityWarning,
|
||||
Target: Target{Account: acc, Device: deviceID},
|
||||
Message: "Stale account entry: device " + deviceID + " also has state under account " + safeQuoteFinding(acc) + " — likely leftover from a previous pairing. The currently-active account is " + safeQuoteFinding(authoritative) + " (per " + signalSource + ").",
|
||||
Details: orphanFindingDetails(acc, deviceID, speakerAccount),
|
||||
QuickFixes: []QuickFix{{ID: FixIDDeleteOrphanAccountEntry, Label: "Delete stale entry", Confirm: orphanFindingConfirm(acc, deviceID, authoritative, speakerAccount)}},
|
||||
ManualCommands: []ManualCommand{{Label: "Or remove from a shell:", Command: "rm -rf <data-dir>/accounts/" + acc + "/devices/" + deviceID, Hint: "Substitute <data-dir> with the service's actual data directory (typically /var/lib/soundtouch-service)."}},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
func orphanFindingDetails(acc, deviceID, speakerAccount string) string {
|
||||
if speakerAccount == "" {
|
||||
return "Couldn't reach the speaker on :8090/info to confirm its current account. Before deleting, verify via service log entries for /streaming/account/" + acc + "/device/" + deviceID + "/...; the Delete QuickFix will retry the speaker probe and refuse if the speaker reports this account as live."
|
||||
}
|
||||
|
||||
return "Speaker /info reports margeAccountUUID=" + speakerAccount + "; this directory (account " + acc + ") is stale because the speaker has stopped targeting it."
|
||||
}
|
||||
|
||||
func orphanFindingConfirm(acc, deviceID, authoritative, speakerAccount string) string {
|
||||
base := "Permanently delete <data-dir>/accounts/" + acc + "/devices/" + deviceID + "/? Removes Presets.xml, Recents.xml, Sources.xml and DeviceInfo.xml for this stale pairing. The active account " + authoritative + " is not touched."
|
||||
|
||||
if speakerAccount != "" {
|
||||
return base + " (Confirmed by the speaker itself: /info reports margeAccountUUID=" + speakerAccount + ".)"
|
||||
}
|
||||
|
||||
return base + " The speaker was not reachable to confirm; the QuickFix will re-probe before deleting and refuse if the speaker now reports this account as live."
|
||||
}
|
||||
|
||||
func safeQuoteFinding(s string) string {
|
||||
if s == "" {
|
||||
return `""`
|
||||
}
|
||||
|
||||
return `"` + s + `"`
|
||||
}
|
||||
|
||||
// deleteOrphanAccountEntry removes accounts/<target.Account>/devices/<target.Device>/.
|
||||
// Called only after the operator has clicked through the Confirm dialog
|
||||
// that the QuickFix surfaces. Before the destructive step, asks the
|
||||
// speaker (via :8090/info) which account it currently considers its
|
||||
// own and refuses to proceed when the answer matches the deletion
|
||||
// target — even an operator-confirmed click can be wrong if the
|
||||
// speaker re-paired between scan and click. Logs the action for
|
||||
// auditability.
|
||||
func deleteOrphanAccountEntry(ds *datastore.DataStore, target Target) (string, error) {
|
||||
if target.Account == "" || target.Device == "" {
|
||||
return "", fmt.Errorf("account and device are both required")
|
||||
}
|
||||
|
||||
// Find a known IP for this device by walking the active devices.
|
||||
// Speaker re-probe needs the IP; if we can't find one we proceed
|
||||
// without the safety net but log the gap so it shows up in audit.
|
||||
speakerIP := lookupActiveDeviceIP(ds, target.Device)
|
||||
|
||||
if speakerIP != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if speakerAccount := fetchSpeakerMargeAccount(ctx, speakerIP); speakerAccount != "" {
|
||||
if speakerAccount == target.Account {
|
||||
return "", fmt.Errorf("speaker %s reports margeAccountUUID=%s — refusing to delete <data-dir>/accounts/%s/devices/%s because it's the speaker's currently-active binding (re-paired since the consistency check ran?)",
|
||||
target.Device, speakerAccount, target.Account, target.Device)
|
||||
}
|
||||
|
||||
log.Printf("[Health] deleteOrphanAccountEntry: speaker %s confirmed margeAccountUUID=%s; target account %s is stale, proceeding with delete",
|
||||
target.Device, speakerAccount, target.Account)
|
||||
} else {
|
||||
log.Printf("[Health] deleteOrphanAccountEntry: speaker %s at %s not reachable for re-confirmation; relying on operator's Confirm click",
|
||||
target.Device, speakerIP)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Health] deleteOrphanAccountEntry: no IP recorded for device %s — skipping speaker re-probe", target.Device)
|
||||
}
|
||||
|
||||
if target.Account == accountIDDefaultPlaceholder {
|
||||
log.Printf("[Health] deleteOrphanAccountEntry: deleting the \"default\" placeholder entry for device %s; this is normal after pairing completed", target.Device)
|
||||
}
|
||||
|
||||
path := ds.AccountDeviceDir(target.Account, target.Device)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return "", fmt.Errorf("orphan directory %s no longer exists; nothing to do", path)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
return "", fmt.Errorf("delete %s: %w", path, err)
|
||||
}
|
||||
|
||||
log.Printf("[Health] Removed orphan account entry %s (account=%s device=%s) at operator request",
|
||||
path, target.Account, target.Device)
|
||||
|
||||
return fmt.Sprintf("Removed stale account entry %s for device %s.", target.Account, target.Device), nil
|
||||
}
|
||||
|
||||
// lookupActiveDeviceIP returns the IP recorded for the device under
|
||||
// whichever account ListAllDevices currently treats as active. Empty
|
||||
// when the device isn't found or has no IP recorded.
|
||||
func lookupActiveDeviceIP(ds *datastore.DataStore, deviceID string) string {
|
||||
devices, err := ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for i := range devices {
|
||||
if devices[i].DeviceID == deviceID && devices[i].IPAddress != "" {
|
||||
return devices[i].IPAddress
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// accountIDDefaultPlaceholder mirrors datastore.accountIDDefault for
|
||||
// the health package; kept here to avoid widening the datastore
|
||||
// package's exported surface.
|
||||
const accountIDDefaultPlaceholder = "default"
|
||||
|
||||
// reclassifiableSource captures a single built-in radio source whose
|
||||
// on-disk ID drifted from the canonical value. Built up by
|
||||
// findReclassifiableSources and consumed by reclassifyCanonicalSourceIDs.
|
||||
type reclassifiableSource struct {
|
||||
OldID string
|
||||
NewID string
|
||||
KeyType string // TUNEIN / INTERNET_RADIO / …
|
||||
ProvID string // canonical sourceproviderid (e.g. "25")
|
||||
}
|
||||
|
||||
// canonicalIDByKeyType returns the canonical built-in source ID for one
|
||||
// of the four well-known radio provider key types, or ("", "") for any
|
||||
// other type. Mirrors datastore.getDefaultSources() and
|
||||
// canonicalDefaultsByType in pkg/service/marge.
|
||||
func canonicalIDByKeyType(keyType string) (id, providerID string) {
|
||||
switch keyType {
|
||||
case "INTERNET_RADIO":
|
||||
return "10002", "2"
|
||||
case "LOCAL_INTERNET_RADIO":
|
||||
return "10003", "11"
|
||||
case "TUNEIN":
|
||||
return "10004", "25"
|
||||
case "RADIO_BROWSER":
|
||||
return "10005", "39"
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// findReclassifiableSources walks a ConsistencyView's sources and
|
||||
// returns the entries that:
|
||||
// - have a SourceKeyType matching one of the four built-in radio
|
||||
// providers (the ones with a canonical ID),
|
||||
// - currently sit on a non-canonical ID, and
|
||||
// - would not collide with another source already at that canonical
|
||||
// ID.
|
||||
//
|
||||
// The collision check is intentionally strict — when two entries claim
|
||||
// the same SourceKeyType, leaving them in place is safer than guessing
|
||||
// which one is the "real" one and leaving the other broken.
|
||||
func findReclassifiableSources(v ConsistencyView) []reclassifiableSource {
|
||||
usedIDs := map[string]bool{}
|
||||
|
||||
for _, s := range v.Sources {
|
||||
if s.ID != "" {
|
||||
usedIDs[s.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
var out []reclassifiableSource
|
||||
|
||||
for _, s := range v.Sources {
|
||||
newID, providerID := canonicalIDByKeyType(s.Type)
|
||||
if newID == "" || s.ID == newID {
|
||||
continue
|
||||
}
|
||||
|
||||
// Don't try to re-classify if the canonical ID is already
|
||||
// occupied by a different source — would create a collision
|
||||
// the rest of the codebase isn't prepared to handle.
|
||||
if usedIDs[newID] {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, reclassifiableSource{
|
||||
OldID: s.ID,
|
||||
NewID: newID,
|
||||
KeyType: s.Type,
|
||||
ProvID: providerID,
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func reclassifyDetailMessage(in []reclassifiableSource) string {
|
||||
out := "Each preset binding by ID may end up bound to the wrong source after re-pair churn — exactly the GH-343 footprint. Re-classifying restores canonical IDs:\n"
|
||||
|
||||
for _, r := range in {
|
||||
out += " • " + r.KeyType + ": " + r.OldID + " → " + r.NewID + " (sourceproviderid " + r.ProvID + ")\n"
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func reclassifyConfirmDetail(in []reclassifiableSource) string {
|
||||
out := "Changes:"
|
||||
|
||||
for _, r := range in {
|
||||
out += " " + r.KeyType + " " + r.OldID + "→" + r.NewID + ";"
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// reclassifyCanonicalSourceIDs is the QuickFix body for
|
||||
// FixIDReclassifyCanonicalSourceIDs. Re-reads Sources.xml / Presets.xml
|
||||
// / Recents.xml for the device, builds the old-ID → new-ID mapping for
|
||||
// each eligible built-in radio source, rewrites the source IDs in
|
||||
// Sources.xml plus any matching <sourceid> references in
|
||||
// Presets.xml/Recents.xml, and persists all three. The datastore's
|
||||
// SaveX helpers each use atomic-rename internally; a failure mid-way
|
||||
// leaves earlier files updated but the operation as a whole is
|
||||
// idempotent — re-running it produces the same result.
|
||||
func reclassifyCanonicalSourceIDs(ds *datastore.DataStore, target Target) (string, error) {
|
||||
if target.Account == "" || target.Device == "" {
|
||||
return "", fmt.Errorf("account and device are both required")
|
||||
}
|
||||
|
||||
view, err := loadServiceView(ds, target.Account, target.Device)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load service state: %w", err)
|
||||
}
|
||||
|
||||
plans := findReclassifiableSources(view)
|
||||
if len(plans) == 0 {
|
||||
return "Nothing to do — all built-in radio sources already on canonical IDs.", nil
|
||||
}
|
||||
|
||||
rename := map[string]string{}
|
||||
canonicalProviderID := map[string]string{}
|
||||
|
||||
for _, p := range plans {
|
||||
rename[p.OldID] = p.NewID
|
||||
canonicalProviderID[p.OldID] = p.ProvID
|
||||
}
|
||||
|
||||
sources, err := ds.GetConfiguredSources(target.Account, target.Device)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read Sources.xml: %w", err)
|
||||
}
|
||||
|
||||
for i := range sources {
|
||||
if newID, ok := rename[sources[i].ID]; ok {
|
||||
log.Printf("[Health] Re-classify %s: id %s → %s (account=%s device=%s)",
|
||||
sources[i].SourceKeyType, sources[i].ID, newID, target.Account, target.Device)
|
||||
|
||||
sources[i].ID = newID
|
||||
|
||||
if provID := canonicalProviderID[sources[i].ID]; provID != "" {
|
||||
sources[i].SourceProviderID = provID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if saveErr := ds.SaveConfiguredSources(target.Account, target.Device, sources); saveErr != nil {
|
||||
return "", fmt.Errorf("save Sources.xml: %w", saveErr)
|
||||
}
|
||||
|
||||
if err := rewritePresetSourceIDs(ds, target, rename); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := rewriteRecentSourceIDs(ds, target, rename); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Re-classified %d source ID(s) for device %s (account %s). Presets/Recents references updated accordingly. The speaker will pick up the new IDs on its next /full fetch.",
|
||||
len(plans), target.Device, target.Account), nil
|
||||
}
|
||||
|
||||
// rewritePresetSourceIDs walks Presets.xml and updates any <sourceid>
|
||||
// that appears as a key in rename to the mapped value. Persists only
|
||||
// when at least one preset changed. Silent on read errors (missing
|
||||
// Presets.xml is a valid state — the operator just doesn't have any
|
||||
// presets to update).
|
||||
func rewritePresetSourceIDs(ds *datastore.DataStore, target Target, rename map[string]string) error {
|
||||
presets, err := ds.GetPresets(target.Account, target.Device)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Presets.xml: %w", err)
|
||||
}
|
||||
|
||||
dirty := false
|
||||
|
||||
for i := range presets {
|
||||
if newID, ok := rename[presets[i].SourceID]; ok {
|
||||
presets[i].SourceID = newID
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
if !dirty {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := ds.SavePresets(target.Account, target.Device, presets); err != nil {
|
||||
return fmt.Errorf("save Presets.xml: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// rewriteRecentSourceIDs is the recents-side twin of rewritePresetSourceIDs.
|
||||
func rewriteRecentSourceIDs(ds *datastore.DataStore, target Target, rename map[string]string) error {
|
||||
recents, err := ds.GetRecents(target.Account, target.Device)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Recents.xml: %w", err)
|
||||
}
|
||||
|
||||
dirty := false
|
||||
|
||||
for i := range recents {
|
||||
if newID, ok := rename[recents[i].SourceID]; ok {
|
||||
recents[i].SourceID = newID
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
if !dirty {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := ds.SaveRecents(target.Account, target.Device, recents); err != nil {
|
||||
return fmt.Errorf("save Recents.xml: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkOneDeviceConsistency(ds *datastore.DataStore, account, deviceID, ipAddress string) []Finding {
|
||||
target := Target{Account: account, Device: deviceID}
|
||||
|
||||
serviceView, err := loadServiceView(ds, account, deviceID)
|
||||
if err != nil {
|
||||
return []Finding{{
|
||||
Severity: SeverityWarning,
|
||||
Target: target,
|
||||
Message: "Could not read service-side state for consistency check.",
|
||||
Details: err.Error(),
|
||||
}}
|
||||
}
|
||||
|
||||
var findings []Finding
|
||||
|
||||
// Internal consistency is meaningful only on the service side —
|
||||
// the speaker manages its own preset/source coherence locally,
|
||||
// and its /sources list deliberately omits streaming sources
|
||||
// (which would always trigger spurious "dangling" findings).
|
||||
findings = append(findings, issuesToFindings(target, CheckInternalConsistency(serviceView), SeverityWarning)...)
|
||||
|
||||
// GH-343-style detection: built-in radio sources sitting on
|
||||
// non-canonical IDs (the 2000001+i fallback that GetConfiguredSources
|
||||
// hands out when on-disk sources don't carry canonical IDs). Surface
|
||||
// as a finding with an offline QuickFix that rewrites both the
|
||||
// source IDs and the preset/recent <sourceid> references atomically.
|
||||
if reclassifiable := findReclassifiableSources(serviceView); len(reclassifiable) > 0 {
|
||||
findings = append(findings, Finding{
|
||||
Severity: SeverityWarning,
|
||||
Target: target,
|
||||
Message: "Sources.xml has " + plural(len(reclassifiable), "built-in radio source", "built-in radio sources") + " on non-canonical IDs (GH-343 trigger).",
|
||||
Details: reclassifyDetailMessage(reclassifiable),
|
||||
QuickFixes: []QuickFix{{
|
||||
ID: FixIDReclassifyCanonicalSourceIDs,
|
||||
Label: "Re-assign canonical IDs",
|
||||
Confirm: "Rewrite " + plural(len(reclassifiable), "source ID", "source IDs") + " in Sources.xml and matching <sourceid> references in Presets.xml/Recents.xml? " + reclassifyConfirmDetail(reclassifiable) + " Offline operation — no speaker contact needed; the speaker will re-fetch /full on its own.",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
if ipAddress == "" {
|
||||
findings = append(findings, Finding{
|
||||
Severity: SeverityInfo,
|
||||
Target: target,
|
||||
Message: "No IP recorded for device; skipping speaker-side consistency check. Service-side internal consistency was checked.",
|
||||
})
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
speakerView, probeIssue := loadSpeakerView(ipAddress)
|
||||
if probeIssue != nil {
|
||||
findings = append(findings, Finding{
|
||||
Severity: SeverityInfo,
|
||||
Target: target,
|
||||
Message: "Couldn't fetch speaker XML for cross-side comparison; service-side internal consistency was still checked.",
|
||||
Details: probeIssue.Detail,
|
||||
ManualCommands: probeIssue.ManualCommands,
|
||||
})
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
// "Unsynced device" short-circuit: when the service has no
|
||||
// presets / recents / sources for a device that the speaker
|
||||
// clearly does have all three for, emit one consolidated
|
||||
// finding instead of a torrent of per-slot mismatches.
|
||||
if isServiceUnsynced(serviceView) && !isSpeakerEmpty(speakerView) {
|
||||
findings = append(findings, Finding{
|
||||
Severity: SeverityWarning,
|
||||
Target: target,
|
||||
Message: "Device has speaker state (presets, recents, sources) but service has nothing for it — looks like a missed pair/sync. Click \"Sync\" in the device tab or factory-reset and re-pair.",
|
||||
})
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
findings = append(findings, issuesToFindings(target, CheckCrossSide(speakerView, serviceView), SeverityWarning)...)
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
func isServiceUnsynced(v ConsistencyView) bool {
|
||||
return len(v.Presets) == 0 && len(v.Recents) == 0 && len(v.Sources) == 0
|
||||
}
|
||||
|
||||
func isSpeakerEmpty(v ConsistencyView) bool {
|
||||
return len(v.Presets) == 0 && len(v.Recents) == 0 && len(v.Sources) == 0
|
||||
}
|
||||
|
||||
func issuesToFindings(target Target, issues []ConsistencyIssue, severity Severity) []Finding {
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]Finding, 0, len(issues))
|
||||
for _, iss := range issues {
|
||||
out = append(out, Finding{
|
||||
Severity: severity,
|
||||
Target: target,
|
||||
Message: string(iss.Kind) + " (" + iss.Side + "): " + iss.Detail,
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func loadServiceView(ds *datastore.DataStore, account, deviceID string) (ConsistencyView, error) {
|
||||
presets, err := ds.GetPresets(account, deviceID)
|
||||
if err != nil {
|
||||
return ConsistencyView{}, fmt.Errorf("read presets: %w", err)
|
||||
}
|
||||
|
||||
recents, err := ds.GetRecents(account, deviceID)
|
||||
if err != nil {
|
||||
return ConsistencyView{}, fmt.Errorf("read recents: %w", err)
|
||||
}
|
||||
|
||||
sources, err := ds.GetConfiguredSources(account, deviceID)
|
||||
if err != nil {
|
||||
return ConsistencyView{}, fmt.Errorf("read sources: %w", err)
|
||||
}
|
||||
|
||||
view := ConsistencyView{Label: "service"}
|
||||
|
||||
// No special resolution here anymore — datastore.GetPresets /
|
||||
// GetRecents self-heal the protocol-level "Audio" leak via
|
||||
// repairLeakedSource at load time. The persisted Source we read
|
||||
// already reflects the speaker's perspective.
|
||||
for i := range presets {
|
||||
view.Presets = append(view.Presets, ConsistencyPreset{
|
||||
Slot: presets[i].ButtonNumber,
|
||||
Source: presets[i].Source,
|
||||
SourceID: presets[i].SourceID,
|
||||
Location: presets[i].Location,
|
||||
Name: presets[i].Name,
|
||||
})
|
||||
}
|
||||
|
||||
for i := range recents {
|
||||
view.Recents = append(view.Recents, ConsistencyRecent{
|
||||
ID: recents[i].ID,
|
||||
Source: recents[i].Source,
|
||||
SourceID: recents[i].SourceID,
|
||||
Location: recents[i].Location,
|
||||
Name: recents[i].Name,
|
||||
})
|
||||
}
|
||||
|
||||
for i := range sources {
|
||||
view.Sources = append(view.Sources, ConsistencySource{
|
||||
ID: sources[i].ID,
|
||||
Type: sources[i].SourceKeyType,
|
||||
Account: sources[i].SourceKeyAccount,
|
||||
})
|
||||
}
|
||||
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// probeFailure captures everything we know about a failed speaker probe
|
||||
// so we can render a single Info finding instead of three separate ones
|
||||
// when the speaker is just unreachable.
|
||||
type probeFailure struct {
|
||||
Detail string
|
||||
ManualCommands []ManualCommand
|
||||
}
|
||||
|
||||
func loadSpeakerView(ipAddress string) (ConsistencyView, *probeFailure) {
|
||||
view := ConsistencyView{Label: "speaker"}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
presetsRes := ProbeGet(ctx, fmt.Sprintf("http://%s:8090/presets", ipAddress), 2*time.Second)
|
||||
if !presetsRes.Reachable {
|
||||
return view, &probeFailure{
|
||||
Detail: "speaker /presets unreachable: " + presetsRes.Err,
|
||||
ManualCommands: []ManualCommand{
|
||||
{Label: "From a host on the speaker's LAN, fetch /presets:", Command: presetsRes.CurlCommand},
|
||||
{Label: "And /recents:", Command: fmt.Sprintf("curl -sS http://%s:8090/recents", ipAddress)},
|
||||
{Label: "And /sources:", Command: fmt.Sprintf("curl -sS http://%s:8090/sources", ipAddress)},
|
||||
{Label: "Compare the three side-by-side against AfterTouch's stored state.", Command: ""},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if presetsRes.Status == 200 {
|
||||
var parsed speakerPresetsConsistencyXML
|
||||
if err := xml.Unmarshal(presetsRes.Body, &parsed); err == nil {
|
||||
for i := range parsed.Presets {
|
||||
p := parsed.Presets[i]
|
||||
if p.ID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
view.Presets = append(view.Presets, ConsistencyPreset{
|
||||
Slot: p.ID,
|
||||
Source: p.ContentItem.Source,
|
||||
Location: p.ContentItem.Location,
|
||||
Name: p.ContentItem.ItemName,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recentsRes := ProbeGet(ctx, fmt.Sprintf("http://%s:8090/recents", ipAddress), 2*time.Second)
|
||||
if recentsRes.Reachable && recentsRes.Status == 200 {
|
||||
var parsed speakerRecentsConsistencyXML
|
||||
if err := xml.Unmarshal(recentsRes.Body, &parsed); err == nil {
|
||||
for i := range parsed.Recents {
|
||||
r := parsed.Recents[i]
|
||||
if r.ID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
view.Recents = append(view.Recents, ConsistencyRecent{
|
||||
ID: r.ID,
|
||||
Source: r.ContentItem.Source,
|
||||
Location: r.ContentItem.Location,
|
||||
Name: r.ContentItem.ItemName,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourcesRes := ProbeGet(ctx, fmt.Sprintf("http://%s:8090/sources", ipAddress), 2*time.Second)
|
||||
if sourcesRes.Reachable && sourcesRes.Status == 200 {
|
||||
var parsed speakerSourcesConsistencyXML
|
||||
if err := xml.Unmarshal(sourcesRes.Body, &parsed); err == nil {
|
||||
seen := map[string]bool{}
|
||||
|
||||
for i := range parsed.Items {
|
||||
key := parsed.Items[i].Source + "|" + parsed.Items[i].SourceAccount
|
||||
if parsed.Items[i].Source == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[key] = true
|
||||
|
||||
view.Sources = append(view.Sources, ConsistencySource{
|
||||
Type: parsed.Items[i].Source,
|
||||
Account: parsed.Items[i].SourceAccount,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// Compile-time guard: the models package must keep ServicePreset's
|
||||
// embedded ServiceContentItem layout so that the field access in
|
||||
// loadServiceView stays valid. Spotting a type change here is cheaper
|
||||
// than via a runtime check.
|
||||
var _ = models.ServicePreset{}.ButtonNumber
|
||||
@@ -0,0 +1,132 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// CheckIDDefaultAccountNonBoseDevices is the registry id of the
|
||||
// non-Bose-default-account-devices check.
|
||||
const CheckIDDefaultAccountNonBoseDevices = "default_account_non_bose_devices"
|
||||
|
||||
// FixIDEvictDefaultNonBoseDevice removes a non-Bose UPnP device from
|
||||
// the "default" account directory. Implemented by the existing
|
||||
// DataStore.RemoveDevice — this constant ties it to the finding it
|
||||
// remediates.
|
||||
const FixIDEvictDefaultNonBoseDevice = "evict_default_non_bose_device"
|
||||
|
||||
// RegisterDefaultAccountNonBoseDevicesCheck registers the
|
||||
// non-Bose-default-account-devices health check. Walks the entries
|
||||
// under data/accounts/default/devices/ and flags any whose
|
||||
// DeviceInfo.xml model/type doesn't look like a Bose SoundTouch
|
||||
// product. These are leftover discovery hits from the LAN's broader
|
||||
// UPnP MediaRenderer population — LG TVs, Onkyo / Yamaha receivers,
|
||||
// Dreambox tuners — that responded to our generic
|
||||
// `urn:schemas-upnp-org:device:MediaRenderer:1` M-SEARCH.
|
||||
//
|
||||
// Each flagged entry comes with an "Evict" QuickFix that removes the
|
||||
// device's data directory; the live discovery filter (see
|
||||
// `pkg/discovery/upnp.go isBoseUPnPDevice`) prevents the entry from
|
||||
// being re-created on the next scan.
|
||||
//
|
||||
// Bose devices that still live under "default" (e.g. a fresh speaker
|
||||
// before pairing completes) are intentionally ignored here — that's
|
||||
// the consistency check's domain.
|
||||
func RegisterDefaultAccountNonBoseDevicesCheck(r *Registry, ds *datastore.DataStore) {
|
||||
r.Register(Check{
|
||||
ID: CheckIDDefaultAccountNonBoseDevices,
|
||||
Title: "Default-account devices are SoundTouch speakers",
|
||||
Run: func() []Finding {
|
||||
return runDefaultAccountNonBoseDevicesCheck(ds)
|
||||
},
|
||||
})
|
||||
|
||||
r.RegisterFix(
|
||||
CheckIDDefaultAccountNonBoseDevices,
|
||||
FixIDEvictDefaultNonBoseDevice,
|
||||
func(target Target) (string, error) {
|
||||
if target.Device == "" {
|
||||
return "", fmt.Errorf("device is required")
|
||||
}
|
||||
|
||||
if err := ds.RemoveDevice("default", target.Device); err != nil {
|
||||
return "", fmt.Errorf("remove default/%s: %w", target.Device, err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Evicted %s from the default account. If it returns on the next scan, AfterTouch's discovery filter needs an update — please file a bug.", target.Device), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func runDefaultAccountNonBoseDevicesCheck(ds *datastore.DataStore) []Finding {
|
||||
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.AccountID != "default" {
|
||||
continue
|
||||
}
|
||||
|
||||
if looksLikeSoundTouch(dev) {
|
||||
continue
|
||||
}
|
||||
|
||||
findings = append(findings, Finding{
|
||||
Severity: SeverityWarning,
|
||||
Target: Target{Account: "default", Device: dev.DeviceID},
|
||||
Message: fmt.Sprintf(
|
||||
"Non-Bose device %q (type=%q) is stored under the default account.",
|
||||
labelForDevice(dev), dev.ProductCode,
|
||||
),
|
||||
Details: "Likely a UPnP MediaRenderer (TV / AV receiver / set-top box) that answered AfterTouch's generic discovery probe. " +
|
||||
"Evict it via the QuickFix; the discovery filter introduced alongside this check (#269/#359) prevents it from being re-created.",
|
||||
QuickFixes: []QuickFix{{
|
||||
ID: FixIDEvictDefaultNonBoseDevice,
|
||||
Label: "Evict from default account",
|
||||
Confirm: fmt.Sprintf("This will delete data/accounts/default/devices/%s/ and all its contents. The device entry was created by AfterTouch's discovery; no real speaker state is affected.", dev.DeviceID),
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
// looksLikeSoundTouch returns true when the device's ProductCode /
|
||||
// Name suggests it's a Bose SoundTouch product. The signal we have on
|
||||
// disk is the `<type>` element from /info, which Bose devices populate
|
||||
// with strings like "SoundTouch 10 sm2" or just "SoundTouch"; non-Bose
|
||||
// devices populate it with their own model name ("HT-R695", "dm920",
|
||||
// "OLED55G2", …). Case-insensitive substring match — the on-disk file
|
||||
// preserves whatever the device emitted, so we don't normalise.
|
||||
func looksLikeSoundTouch(dev *models.ServiceDeviceInfo) bool {
|
||||
if dev == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
hay := strings.ToLower(dev.ProductCode + " " + dev.Name)
|
||||
|
||||
return strings.Contains(hay, "soundtouch") || strings.Contains(hay, "wave music system")
|
||||
}
|
||||
|
||||
func labelForDevice(dev *models.ServiceDeviceInfo) string {
|
||||
if dev.Name != "" {
|
||||
return dev.Name
|
||||
}
|
||||
|
||||
if dev.DeviceID != "" {
|
||||
return dev.DeviceID
|
||||
}
|
||||
|
||||
return "(unnamed)"
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestLooksLikeSoundTouch(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
dev *models.ServiceDeviceInfo
|
||||
want bool
|
||||
}{
|
||||
{name: "SoundTouch type", dev: &models.ServiceDeviceInfo{ProductCode: "SoundTouch", Name: "Bose_Bad"}, want: true},
|
||||
{name: "SoundTouch 10 sm2 type", dev: &models.ServiceDeviceInfo{ProductCode: "SoundTouch 10 sm2"}, want: true},
|
||||
{name: "Wave Music System III", dev: &models.ServiceDeviceInfo{ProductCode: "Wave Music System III"}, want: true},
|
||||
{name: "Onkyo HT-R695", dev: &models.ServiceDeviceInfo{ProductCode: "HT-R695", Name: "Onkyo HT-R695 E9A20F"}, want: false},
|
||||
{name: "Dreambox dm920", dev: &models.ServiceDeviceInfo{ProductCode: "dm920", Name: "dm920"}, want: false},
|
||||
{name: "LG OLED", dev: &models.ServiceDeviceInfo{ProductCode: "OLED55G2", Name: "[LG] webOS TV"}, want: false},
|
||||
{name: "Empty", dev: &models.ServiceDeviceInfo{}, want: false},
|
||||
{name: "Nil", dev: nil, want: false},
|
||||
{name: "Name only", dev: &models.ServiceDeviceInfo{ProductCode: "", Name: "My SoundTouch 30"}, want: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := looksLikeSoundTouch(tc.dev); got != tc.want {
|
||||
t.Errorf("got %v, want %v (dev=%+v)", got, tc.want, tc.dev)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultAccountNonBoseCheck_FlagsNonBoseAndIgnoresBose drives the
|
||||
// check end-to-end against a temporary datastore seeded with the same
|
||||
// shape we saw in NorbertBauer's #269 diagnostic bundle: a Dreambox
|
||||
// and an Onkyo under default, plus an unpaired Bose SoundTouch that
|
||||
// must NOT trigger the warning.
|
||||
func TestDefaultAccountNonBoseCheck_FlagsNonBoseAndIgnoresBose(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmp)
|
||||
|
||||
t.Cleanup(func() { _ = ds.Close() })
|
||||
|
||||
mustWriteDeviceInfo(t, tmp, "default", "192.168.1.10",
|
||||
`<?xml version="1.0" encoding="UTF-8"?><info deviceID="192.168.1.10"><name>dm920</name><type>dm920</type><discoveryMethod>SSDP/UPnP</discoveryMethod></info>`)
|
||||
mustWriteDeviceInfo(t, tmp, "default", "192.168.1.12",
|
||||
`<?xml version="1.0" encoding="UTF-8"?><info deviceID="192.168.1.12"><name>Onkyo HT-R695 E9A20F</name><type>HT-R695</type><discoveryMethod>SSDP/UPnP</discoveryMethod></info>`)
|
||||
mustWriteDeviceInfo(t, tmp, "default", "AABBCCDDEEFF",
|
||||
`<?xml version="1.0" encoding="UTF-8"?><info deviceID="AABBCCDDEEFF"><name>Bose Living Room</name><type>SoundTouch 30 sm2</type><discoveryMethod>SSDP/UPnP</discoveryMethod></info>`)
|
||||
|
||||
got := runDefaultAccountNonBoseDevicesCheck(ds)
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 findings (Dreambox + Onkyo), got %d: %+v", len(got), got)
|
||||
}
|
||||
|
||||
flaggedIDs := map[string]bool{}
|
||||
for _, f := range got {
|
||||
flaggedIDs[f.Target.Device] = true
|
||||
|
||||
if f.Severity != SeverityWarning {
|
||||
t.Errorf("expected SeverityWarning, got %v on %+v", f.Severity, f)
|
||||
}
|
||||
|
||||
if len(f.QuickFixes) != 1 || f.QuickFixes[0].ID != FixIDEvictDefaultNonBoseDevice {
|
||||
t.Errorf("expected one Evict QuickFix, got %+v", f.QuickFixes)
|
||||
}
|
||||
}
|
||||
|
||||
if !flaggedIDs["192.168.1.10"] || !flaggedIDs["192.168.1.12"] {
|
||||
t.Errorf("expected both Dreambox + Onkyo flagged, got: %v", flaggedIDs)
|
||||
}
|
||||
|
||||
if flaggedIDs["AABBCCDDEEFF"] {
|
||||
t.Errorf("unpaired Bose SoundTouch must not be flagged; got: %v", flaggedIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// mustWriteDeviceInfo writes a DeviceInfo.xml under
|
||||
// <baseDir>/accounts/<account>/devices/<device>/DeviceInfo.xml.
|
||||
// Fails the test on any IO error.
|
||||
func mustWriteDeviceInfo(t *testing.T, baseDir, account, device, body string) {
|
||||
t.Helper()
|
||||
|
||||
dir := filepath.Join(baseDir, "accounts", account, "devices", device)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "DeviceInfo.xml"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// CheckIDDNSSanity is the registry id of the DNS-interception
|
||||
// sanity check.
|
||||
const CheckIDDNSSanity = "dns_sanity"
|
||||
|
||||
// DNSStatusFunc reports whether the service's DNS interception
|
||||
// listener is running and on which UDP bind address (host:port).
|
||||
// Closure over Server.GetDNSRunning to avoid a hard dependency
|
||||
// from health onto handlers.
|
||||
type DNSStatusFunc func() (running bool, bindAddr string)
|
||||
|
||||
// ExpectedIPFunc returns the IP this service expects the
|
||||
// intercepted hostnames to resolve to (i.e. its own LAN IP).
|
||||
// Returns the empty string when no service URL is configured.
|
||||
type ExpectedIPFunc func() string
|
||||
|
||||
// RegisterDNSSanityCheck registers a check that queries the
|
||||
// service's own DNS server for each intercepted Bose hostname and
|
||||
// verifies the answer is the configured service IP. Catches three
|
||||
// classes of misconfiguration recurring in #94, #218, #269:
|
||||
//
|
||||
// 1. DNS server is disabled or didn't bind — speakers using it
|
||||
// as their resolver get NXDOMAIN.
|
||||
// 2. DNS server is bound but answers point at the wrong IP
|
||||
// (e.g. operator changed the LAN IP without restarting).
|
||||
// 3. A subset of intercepted hostnames silently fail to resolve.
|
||||
func RegisterDNSSanityCheck(r *Registry, statusFn DNSStatusFunc, expectedIPFn ExpectedIPFunc) {
|
||||
r.Register(Check{
|
||||
ID: CheckIDDNSSanity,
|
||||
Title: "DNS interception resolves Bose hostnames to this service",
|
||||
Run: func() []Finding {
|
||||
return runDNSSanityCheck(statusFn, expectedIPFn)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runDNSSanityCheck(statusFn DNSStatusFunc, expectedIPFn ExpectedIPFunc) []Finding {
|
||||
running, bindAddr := statusFn()
|
||||
if !running {
|
||||
return []Finding{{
|
||||
Severity: SeverityInfo,
|
||||
Message: "DNS interception is not running on this host.",
|
||||
Details: "Speakers using this service as their DNS server would receive no answers for intercepted Bose hostnames. Enable DNS in Settings or set DNS_ENABLED=true if speakers should be redirected via DNS rather than /etc/hosts on the speaker.",
|
||||
}}
|
||||
}
|
||||
|
||||
expectedIP := expectedIPFn()
|
||||
if expectedIP == "" {
|
||||
return []Finding{{
|
||||
Severity: SeverityWarning,
|
||||
Message: "DNS server is running but no service IP could be resolved.",
|
||||
Details: "Without a known target IP the sanity check can't validate answers. Configure SERVER_URL to a hostname that resolves to this service's LAN IP.",
|
||||
}}
|
||||
}
|
||||
|
||||
// bindAddr may be "" / ":53" / "0.0.0.0:53" / "[::]:53" —
|
||||
// the DNS lib is bound to the wildcard address. Translate
|
||||
// that into something we can actually dial from inside the
|
||||
// service host. queryTarget is what we send queries to;
|
||||
// displayAddr is what we surface to the operator (the
|
||||
// originally configured value, which is what they'd see in
|
||||
// netstat).
|
||||
queryTarget := resolveDNSQueryTarget(bindAddr)
|
||||
|
||||
displayAddr := bindAddr
|
||||
if displayAddr == "" {
|
||||
displayAddr = "(default)"
|
||||
}
|
||||
|
||||
// Query our DNS server for the canonical intercept list and
|
||||
// classify the results.
|
||||
hostnames := append([]string(nil), discovery.InterceptedBoseHosts...)
|
||||
sort.Strings(hostnames)
|
||||
|
||||
mismatches := make([]string, 0)
|
||||
unanswered := make([]string, 0)
|
||||
|
||||
for _, host := range hostnames {
|
||||
ip, err := queryOwnDNS(queryTarget, host)
|
||||
if err != nil {
|
||||
unanswered = append(unanswered, host)
|
||||
continue
|
||||
}
|
||||
|
||||
if ip != expectedIP {
|
||||
mismatches = append(mismatches, fmt.Sprintf("%s → %s", host, ip))
|
||||
}
|
||||
}
|
||||
|
||||
var findings []Finding
|
||||
|
||||
if len(unanswered) > 0 {
|
||||
findings = append(findings, Finding{
|
||||
Severity: SeverityWarning,
|
||||
Message: fmt.Sprintf(
|
||||
"%d intercepted hostname(s) didn't get an answer from the local DNS server: %s.",
|
||||
len(unanswered), strings.Join(unanswered, ", "),
|
||||
),
|
||||
Details: fmt.Sprintf("Bind: %s. Queried: %s. Expected answer: %s. If the DNS server is actually running, the queries above probably failed because the bind interface isn't reachable from inside the service container — check that DNS_BIND_ADDR is dialable from here.", displayAddr, queryTarget, expectedIP),
|
||||
})
|
||||
}
|
||||
|
||||
if len(mismatches) > 0 {
|
||||
findings = append(findings, Finding{
|
||||
Severity: SeverityWarning,
|
||||
Message: fmt.Sprintf(
|
||||
"%d intercepted hostname(s) resolved to an unexpected IP (expected %s).",
|
||||
len(mismatches), expectedIP,
|
||||
),
|
||||
Details: strings.Join(mismatches, "; "),
|
||||
ManualCommands: []ManualCommand{{
|
||||
Label: "Verify from the speaker's network:",
|
||||
Command: "nslookup " + hostnames[0] + " " + extractHost(queryTarget),
|
||||
Hint: "Run on a host that uses this service as its DNS resolver. Replace the hostname with any other intercepted name to spot-check.",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
// resolveDNSQueryTarget translates a server-side bind address
|
||||
// into a host:port we can actually dial from inside the service
|
||||
// host. Empty / wildcard / port-only forms all collapse to a
|
||||
// loopback target on the same port (default 53).
|
||||
func resolveDNSQueryTarget(bindAddr string) string {
|
||||
if bindAddr == "" {
|
||||
return "127.0.0.1:53"
|
||||
}
|
||||
|
||||
host, port, err := net.SplitHostPort(bindAddr)
|
||||
if err != nil {
|
||||
// Doesn't parse as host:port. Could be ":53" with stray
|
||||
// formatting, a bare port, or a bare host.
|
||||
switch {
|
||||
case strings.HasPrefix(bindAddr, ":"):
|
||||
return "127.0.0.1" + bindAddr
|
||||
case !strings.ContainsAny(bindAddr, ":."):
|
||||
// looks like a bare port number
|
||||
if _, atoiErr := dnsPortAtoi(bindAddr); atoiErr == nil {
|
||||
return "127.0.0.1:" + bindAddr
|
||||
}
|
||||
|
||||
fallthrough
|
||||
default:
|
||||
return net.JoinHostPort(bindAddr, "53")
|
||||
}
|
||||
}
|
||||
|
||||
if host == "" || host == "0.0.0.0" || host == "::" {
|
||||
return net.JoinHostPort("127.0.0.1", port)
|
||||
}
|
||||
|
||||
return bindAddr
|
||||
}
|
||||
|
||||
// dnsPortAtoi is a tiny strconv.Atoi wrapper that also rejects
|
||||
// values outside 1..65535. Lets resolveDNSQueryTarget tell apart
|
||||
// "bare port" from "bare hostname containing no colon".
|
||||
func dnsPortAtoi(s string) (int, error) {
|
||||
v := 0
|
||||
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, fmt.Errorf("non-digit in port")
|
||||
}
|
||||
|
||||
v = v*10 + int(c-'0')
|
||||
if v > 65535 {
|
||||
return 0, fmt.Errorf("port out of range")
|
||||
}
|
||||
}
|
||||
|
||||
if v == 0 {
|
||||
return 0, fmt.Errorf("empty or zero")
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// queryOwnDNS issues an A query against the resolved query
|
||||
// target (host:port). The service's DNS listener handles UDP,
|
||||
// so we always dial UDP here.
|
||||
func queryOwnDNS(queryTarget, hostname string) (string, error) {
|
||||
c := dns.Client{Timeout: 1 * time.Second}
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(dns.Fqdn(hostname), dns.TypeA)
|
||||
m.RecursionDesired = true
|
||||
|
||||
r, _, err := c.Exchange(m, queryTarget)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if r.Rcode != dns.RcodeSuccess {
|
||||
return "", fmt.Errorf("rcode %d", r.Rcode)
|
||||
}
|
||||
|
||||
for _, ans := range r.Answer {
|
||||
if a, ok := ans.(*dns.A); ok && a.A != nil {
|
||||
return a.A.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no A record in answer")
|
||||
}
|
||||
|
||||
func extractHost(bindAddr string) string {
|
||||
if i := strings.LastIndex(bindAddr, ":"); i >= 0 {
|
||||
return bindAddr[:i]
|
||||
}
|
||||
|
||||
return bindAddr
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func TestDNSSanity_NotRunning(t *testing.T) {
|
||||
got := runDNSSanityCheck(
|
||||
func() (bool, string) { return false, "" },
|
||||
func() string { return "192.0.2.10" },
|
||||
)
|
||||
|
||||
if len(got) != 1 || got[0].Severity != SeverityInfo {
|
||||
t.Fatalf("expected one info finding when DNS not running, got %+v", got)
|
||||
}
|
||||
|
||||
if !strings.Contains(got[0].Message, "not running") {
|
||||
t.Errorf("expected 'not running' in message, got %q", got[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSSanity_NoExpectedIP(t *testing.T) {
|
||||
got := runDNSSanityCheck(
|
||||
func() (bool, string) { return true, "127.0.0.1:53000" },
|
||||
func() string { return "" },
|
||||
)
|
||||
|
||||
if len(got) != 1 || got[0].Severity != SeverityWarning {
|
||||
t.Fatalf("expected one warning when expectedIP is empty, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSSanity_HappyPath(t *testing.T) {
|
||||
expectedIP := "192.0.2.10"
|
||||
|
||||
srv := startStubDNSServer(t, func(hostname string) string {
|
||||
return expectedIP
|
||||
})
|
||||
|
||||
got := runDNSSanityCheck(
|
||||
func() (bool, string) { return true, srv },
|
||||
func() string { return expectedIP },
|
||||
)
|
||||
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected no findings on happy path, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSSanity_MismatchedAnswer(t *testing.T) {
|
||||
srv := startStubDNSServer(t, func(_ string) string {
|
||||
return "203.0.113.99" // wrong IP, doesn't match expected
|
||||
})
|
||||
|
||||
got := runDNSSanityCheck(
|
||||
func() (bool, string) { return true, srv },
|
||||
func() string { return "192.0.2.10" },
|
||||
)
|
||||
|
||||
var foundMismatch bool
|
||||
for _, f := range got {
|
||||
if strings.Contains(f.Message, "unexpected IP") && f.Severity == SeverityWarning {
|
||||
foundMismatch = true
|
||||
if !strings.Contains(f.Details, "203.0.113.99") {
|
||||
t.Errorf("expected actual IP in details, got %q", f.Details)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundMismatch {
|
||||
t.Errorf("expected a mismatch warning, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSSanity_UnansweredHostnames(t *testing.T) {
|
||||
// Stub returns "" for some hostnames → server emits NXDOMAIN.
|
||||
srv := startStubDNSServer(t, func(hostname string) string {
|
||||
if strings.Contains(hostname, "streaming") {
|
||||
return "" // refuse
|
||||
}
|
||||
|
||||
return "192.0.2.10"
|
||||
})
|
||||
|
||||
got := runDNSSanityCheck(
|
||||
func() (bool, string) { return true, srv },
|
||||
func() string { return "192.0.2.10" },
|
||||
)
|
||||
|
||||
var foundUnanswered bool
|
||||
for _, f := range got {
|
||||
if strings.Contains(f.Message, "didn't get an answer") {
|
||||
foundUnanswered = true
|
||||
if !strings.Contains(f.Message, "streaming") {
|
||||
t.Errorf("expected 'streaming' in unanswered list, got %q", f.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundUnanswered {
|
||||
t.Errorf("expected unanswered warning, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDNSQueryTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"", "127.0.0.1:53"},
|
||||
{":53", "127.0.0.1:53"},
|
||||
{"0.0.0.0:53", "127.0.0.1:53"},
|
||||
{"[::]:53", "127.0.0.1:53"},
|
||||
{"127.0.0.1:53", "127.0.0.1:53"},
|
||||
{"192.0.2.10:5353", "192.0.2.10:5353"},
|
||||
{"53", "127.0.0.1:53"}, // bare port
|
||||
{"example.com", "example.com:53"}, // bare host
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if got := resolveDNSQueryTarget(c.in); got != c.want {
|
||||
t.Errorf("resolveDNSQueryTarget(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSSanity_EmptyBindAddrTriesLoopback(t *testing.T) {
|
||||
// Spin up a stub DNS server on 127.0.0.1:0 and lie about the
|
||||
// bindAddr: report "" as if the upstream listener didn't
|
||||
// expose its address. resolveDNSQueryTarget should default
|
||||
// to 127.0.0.1:53 — which won't match the test server's
|
||||
// random port, so the queries will fail. The point of this
|
||||
// test is that the *failure* surfaces a non-empty
|
||||
// query-target string in the details, not "Queried .".
|
||||
got := runDNSSanityCheck(
|
||||
func() (bool, string) { return true, "" },
|
||||
func() string { return "192.0.2.10" },
|
||||
)
|
||||
|
||||
for _, f := range got {
|
||||
if strings.Contains(f.Details, "Queried: 127.0.0.1:53") {
|
||||
return // expected
|
||||
}
|
||||
|
||||
if strings.Contains(f.Details, "Queried: .") {
|
||||
t.Errorf("regression: empty bindAddr leaked into details as 'Queried: .', got %q", f.Details)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHost(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"127.0.0.1:53", "127.0.0.1"},
|
||||
{"0.0.0.0:53", "0.0.0.0"},
|
||||
{"[::]:53", "[::]"},
|
||||
{"no-port", "no-port"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if got := extractHost(c.in); got != c.want {
|
||||
t.Errorf("extractHost(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startStubDNSServer binds a UDP DNS responder on 127.0.0.1 (random
|
||||
// port). For each A query, answerForHost is called with the queried
|
||||
// hostname (no trailing dot); a non-empty return is the answer, an
|
||||
// empty return triggers NXDOMAIN.
|
||||
func startStubDNSServer(t *testing.T, answerForHost func(string) string) string {
|
||||
t.Helper()
|
||||
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen udp: %v", err)
|
||||
}
|
||||
|
||||
mux := dns.NewServeMux()
|
||||
mux.HandleFunc(".", func(w dns.ResponseWriter, req *dns.Msg) {
|
||||
resp := new(dns.Msg)
|
||||
resp.SetReply(req)
|
||||
|
||||
for _, q := range req.Question {
|
||||
if q.Qtype != dns.TypeA {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(q.Name, ".")
|
||||
ip := answerForHost(name)
|
||||
|
||||
if ip == "" {
|
||||
resp.SetRcode(req, dns.RcodeNameError)
|
||||
continue
|
||||
}
|
||||
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
resp.Answer = append(resp.Answer, &dns.A{
|
||||
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
|
||||
A: parsed.To4(),
|
||||
})
|
||||
}
|
||||
|
||||
_ = w.WriteMsg(resp)
|
||||
})
|
||||
|
||||
srv := &dns.Server{PacketConn: pc, Handler: mux}
|
||||
go func() { _ = srv.ActivateAndServe() }()
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = srv.Shutdown()
|
||||
_ = pc.Close()
|
||||
})
|
||||
|
||||
return pc.LocalAddr().String()
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// FixIDAddMargeHostToTLS is the QuickFix that re-probes the speaker
|
||||
// at the target device's known IP, extracts the host portion of its
|
||||
// <margeURL>, and appends it to the persisted TLSExtraHosts in
|
||||
// settings.json. A service restart is then required for the TLS cert
|
||||
// to be regenerated. The fix lives in the handlers package because
|
||||
// it needs the datastore writer; the constant lives here so check
|
||||
// and fix share the same identifier.
|
||||
const FixIDAddMargeHostToTLS = "add_marge_host_to_tls"
|
||||
|
||||
// CheckIDSpeakerMargeURL is the registry id of the Marge-URL
|
||||
// consistency check.
|
||||
const CheckIDSpeakerMargeURL = "speaker_marge_url"
|
||||
|
||||
// RegisterSpeakerMargeURLCheck registers the speaker_marge_url
|
||||
// check. For each device it probes /info, extracts <margeURL>, and
|
||||
// compares the hostname against the service's expected-hosts list
|
||||
// (serverURL host + httpsServerURL host + --tls-extra-host values).
|
||||
// If they don't match, the speaker is talking to a different
|
||||
// endpoint than this service thinks it serves — usually a sign
|
||||
// that AfterTouch was reconfigured after the speaker was
|
||||
// migrated, or that the speaker is pointed at the wrong DNS name.
|
||||
//
|
||||
// expectedHostsFn is a closure so the check picks up config
|
||||
// changes without re-registration (today these change only at
|
||||
// restart, but the closure costs nothing).
|
||||
func RegisterSpeakerMargeURLCheck(r *Registry, ds *datastore.DataStore, expectedHostsFn func() []string) {
|
||||
r.Register(Check{
|
||||
ID: CheckIDSpeakerMargeURL,
|
||||
Title: "Speaker <margeURL> matches AfterTouch's configured hosts",
|
||||
Run: func() []Finding {
|
||||
return runSpeakerMargeURLCheck(ds, expectedHostsFn)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runSpeakerMargeURLCheck(ds *datastore.DataStore, expectedHostsFn func() []string) []Finding {
|
||||
if ds == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
devices, err := ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return []Finding{{
|
||||
Severity: SeverityError,
|
||||
Message: "Could not enumerate devices: " + err.Error(),
|
||||
}}
|
||||
}
|
||||
|
||||
expected := normaliseHosts(expectedHostsFn())
|
||||
|
||||
var findings []Finding
|
||||
|
||||
for i := range devices {
|
||||
dev := &devices[i]
|
||||
if dev.IPAddress == "" || dev.DeviceID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
findings = append(findings, assessMargeURLForDevice(dev.AccountID, dev.DeviceID, dev.IPAddress, expected)...)
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
func assessMargeURLForDevice(account, deviceID, ipAddress string, expected map[string]bool) []Finding {
|
||||
probeURL := fmt.Sprintf("http://%s:8090/info", ipAddress)
|
||||
return assessMargeURLForDeviceWithURL(account, deviceID, probeURL, expected)
|
||||
}
|
||||
|
||||
// assessMargeURLForDeviceWithURL is the same but takes the URL
|
||||
// directly. Used by tests bound to an httptest.Server.
|
||||
func assessMargeURLForDeviceWithURL(account, deviceID, probeURL string, expected map[string]bool) []Finding {
|
||||
target := Target{Account: account, Device: deviceID}
|
||||
|
||||
res := ProbeGet(context.Background(), probeURL, 2*time.Second)
|
||||
if !res.Reachable || res.Status != 200 {
|
||||
// speaker_info_reachable already covers these cases.
|
||||
return nil
|
||||
}
|
||||
|
||||
var parsed speakerInfoXML
|
||||
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if parsed.MargeURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
margeHost := hostFromURL(parsed.MargeURL)
|
||||
if margeHost == "" {
|
||||
return []Finding{{
|
||||
Severity: SeverityWarning,
|
||||
Target: target,
|
||||
Message: fmt.Sprintf("Speaker reports an unparseable <margeURL>: %q", parsed.MargeURL),
|
||||
}}
|
||||
}
|
||||
|
||||
if expected[margeHost] {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []Finding{{
|
||||
Severity: SeverityWarning,
|
||||
Target: target,
|
||||
Message: fmt.Sprintf(
|
||||
"Speaker is pointed at %s, which isn't in the service's configured hosts.",
|
||||
parsed.MargeURL,
|
||||
),
|
||||
Details: fmt.Sprintf(
|
||||
"Configured hosts: %s. If the speaker should reach this service via %q, click the QuickFix below (or restart with `--tls-extra-host=%s`) so the served TLS cert covers it. Otherwise, re-migrate the speaker to the correct URL.",
|
||||
joinHosts(expected), margeHost, margeHost,
|
||||
),
|
||||
QuickFixes: []QuickFix{{
|
||||
ID: FixIDAddMargeHostToTLS,
|
||||
Label: fmt.Sprintf("Add %s to TLS hosts", margeHost),
|
||||
Confirm: fmt.Sprintf("This will append %s to settings.json (tls_extra_hosts) and persist it. A service restart is required afterwards for the TLS certificate to be regenerated.", margeHost),
|
||||
}},
|
||||
ManualCommands: []ManualCommand{{
|
||||
Label: "Or set via CLI/env and restart:",
|
||||
Command: fmt.Sprintf("soundtouch-service --tls-extra-host=%s …", margeHost),
|
||||
Hint: "Append to your existing service command-line / env (TLS_EXTRA_HOST). Requires a restart.",
|
||||
}},
|
||||
}}
|
||||
}
|
||||
|
||||
func normaliseHosts(in []string) map[string]bool {
|
||||
out := make(map[string]bool, len(in))
|
||||
|
||||
for _, h := range in {
|
||||
h = strings.TrimSpace(strings.ToLower(h))
|
||||
if h == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Accept either bare host or URL-style input — be lenient
|
||||
// since the registration call site may evolve.
|
||||
if hostOnly := hostFromURL(h); hostOnly != "" {
|
||||
out[hostOnly] = true
|
||||
} else {
|
||||
out[h] = true
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func hostFromURL(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if !strings.Contains(raw, "://") {
|
||||
// Treat as a bare host. Strip an optional :port suffix.
|
||||
if i := strings.IndexByte(raw, ':'); i >= 0 {
|
||||
return strings.ToLower(strings.TrimSpace(raw[:i]))
|
||||
}
|
||||
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.ToLower(u.Hostname())
|
||||
}
|
||||
|
||||
func joinHosts(m map[string]bool) string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
return "(none configured)"
|
||||
}
|
||||
|
||||
return strings.Join(keys, ", ")
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func stubInfoServer(t *testing.T, margeURL string) string {
|
||||
t.Helper()
|
||||
|
||||
body := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="DEVICEID01">
|
||||
<name>TestSpeaker</name>
|
||||
<margeAccountUUID>1000001</margeAccountUUID>
|
||||
<margeURL>` + margeURL + `</margeURL>
|
||||
</info>`
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/info" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
|
||||
return "http://" + u.Host + "/info"
|
||||
}
|
||||
|
||||
func TestMargeURL_NoFindingsWhenMatched(t *testing.T) {
|
||||
probeURL := stubInfoServer(t, "https://aftertouch.local/")
|
||||
expected := normaliseHosts([]string{"aftertouch.local", "192.0.2.10"})
|
||||
|
||||
got := assessMargeURLForDeviceWithURL("1000001", "DEVICEID01", probeURL, expected)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected no findings when host matches, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeURL_FlagsMismatch(t *testing.T) {
|
||||
probeURL := stubInfoServer(t, "https://other-host.example/")
|
||||
expected := normaliseHosts([]string{"aftertouch.local"})
|
||||
|
||||
got := assessMargeURLForDeviceWithURL("1000001", "DEVICEID01", probeURL, expected)
|
||||
if len(got) != 1 || got[0].Severity != SeverityWarning {
|
||||
t.Fatalf("expected one warning, got %+v", got)
|
||||
}
|
||||
|
||||
if !strings.Contains(got[0].Message, "other-host.example") {
|
||||
t.Errorf("expected mismatched host in message, got %q", got[0].Message)
|
||||
}
|
||||
|
||||
if len(got[0].ManualCommands) != 1 {
|
||||
t.Fatalf("expected a manual command, got %+v", got[0].ManualCommands)
|
||||
}
|
||||
|
||||
cmd := got[0].ManualCommands[0].Command
|
||||
if !strings.Contains(cmd, "tls-extra-host=other-host.example") {
|
||||
t.Errorf("expected --tls-extra-host suggestion, got %q", cmd)
|
||||
}
|
||||
|
||||
if len(got[0].QuickFixes) != 1 || got[0].QuickFixes[0].ID != FixIDAddMargeHostToTLS {
|
||||
t.Fatalf("expected QuickFix with ID=%s, got %+v", FixIDAddMargeHostToTLS, got[0].QuickFixes)
|
||||
}
|
||||
|
||||
if !strings.Contains(got[0].QuickFixes[0].Label, "other-host.example") {
|
||||
t.Errorf("expected QuickFix label to name the missing host, got %q", got[0].QuickFixes[0].Label)
|
||||
}
|
||||
|
||||
if got[0].QuickFixes[0].Confirm == "" {
|
||||
t.Errorf("expected QuickFix to carry a Confirm message (operator needs to know a restart is required)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeURL_SkipsWhenMargeURLEmpty(t *testing.T) {
|
||||
probeURL := stubInfoServer(t, "")
|
||||
expected := normaliseHosts([]string{"aftertouch.local"})
|
||||
|
||||
got := assessMargeURLForDeviceWithURL("1000001", "DEVICEID01", probeURL, expected)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected no findings for empty margeURL, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeURL_SkipsWhenSpeakerUnreachable(t *testing.T) {
|
||||
expected := normaliseHosts([]string{"aftertouch.local"})
|
||||
|
||||
// speaker_info_reachable already covers the unreachable case,
|
||||
// so this check should stay silent.
|
||||
got := assessMargeURLForDeviceWithURL("1000001", "DEVICEID01", "http://127.0.0.1:1/info", expected)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected no findings when speaker unreachable, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostFromURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"https://example.com/", "example.com"},
|
||||
{"https://Example.COM:8443/", "example.com"},
|
||||
{"http://192.0.2.10/", "192.0.2.10"},
|
||||
{"example.com", "example.com"},
|
||||
{"example.com:8443", "example.com"},
|
||||
{"", ""},
|
||||
{"://broken", ""},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if got := hostFromURL(c.in); got != c.want {
|
||||
t.Errorf("hostFromURL(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormaliseHosts_DedupsAndLowercases(t *testing.T) {
|
||||
out := normaliseHosts([]string{"AFTERTOUCH.local", "aftertouch.local", "https://example.com/", " ", ""})
|
||||
if !out["aftertouch.local"] {
|
||||
t.Errorf("expected aftertouch.local")
|
||||
}
|
||||
|
||||
if !out["example.com"] {
|
||||
t.Errorf("expected example.com")
|
||||
}
|
||||
|
||||
if len(out) != 2 {
|
||||
t.Errorf("expected 2 unique hosts, got %d", len(out))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CheckIDOAuthTargetReachable is the registry id of the OAuth-target
|
||||
// configuration check. It fires when AfterTouch's configured serverURL
|
||||
// is an IP literal AND the built-in DNS hijack is running — the
|
||||
// combination that breaks Spotify / Amazon Music OAuth because the
|
||||
// speaker firmware constructs `<first-label>oauth.<rest>` from the
|
||||
// streaming hostname, producing a malformed name (e.g. `192oauth.168.0.30`)
|
||||
// when the first label is the numeric part of an IP.
|
||||
//
|
||||
// See docs/concepts/amazon-music-oauth.md for the underlying mechanism
|
||||
// and pkg/discovery/dns.go DeriveOAuthHostnames for the auto-derivation
|
||||
// that makes the hostname case work without operator intervention.
|
||||
const CheckIDOAuthTargetReachable = "oauth_target_reachable"
|
||||
|
||||
// RegisterOAuthTargetReachableCheck registers the OAuth-target check.
|
||||
// getServerURL returns the operator's currently-configured streaming
|
||||
// URL (typically Server.GetSettings's first return value);
|
||||
// getDNSRunning reports whether AfterTouch's DNS hijack server is
|
||||
// actually serving.
|
||||
//
|
||||
// The check is intentionally narrow: it doesn't probe the OAuth flow
|
||||
// end-to-end. It surfaces the one misconfiguration the speaker firmware
|
||||
// cannot recover from — IP-based serverURL — so operators see the
|
||||
// problem before they wire up Spotify / Amazon Music and wonder why
|
||||
// the speaker's OAuth callback never reaches them.
|
||||
func RegisterOAuthTargetReachableCheck(r *Registry, getServerURL func() string, getDNSRunning func() (bool, string)) {
|
||||
r.Register(Check{
|
||||
ID: CheckIDOAuthTargetReachable,
|
||||
Title: "OAuth subdomain is resolvable from the configured serverURL",
|
||||
Run: func() []Finding {
|
||||
return runOAuthTargetReachableCheck(getServerURL(), getDNSRunning)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runOAuthTargetReachableCheck(serverURL string, getDNSRunning func() (bool, string)) []Finding {
|
||||
if strings.TrimSpace(serverURL) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// IP-based serverURL is the only case the speaker can't recover from.
|
||||
// Hostname-based serverURLs are auto-handled by the DNS interceptor
|
||||
// (see pkg/discovery/dns.go DeriveOAuthHostnames).
|
||||
if net.ParseIP(host) == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
dnsRunning := false
|
||||
if getDNSRunning != nil {
|
||||
dnsRunning, _ = getDNSRunning()
|
||||
}
|
||||
|
||||
return []Finding{{
|
||||
Severity: SeverityWarning,
|
||||
Message: fmt.Sprintf(
|
||||
"Configured serverURL %q uses an IP literal. Spotify and Amazon Music OAuth won't work — the speaker firmware constructs the OAuth host by appending \"oauth\" to the first label of the streaming hostname, which for an IP yields a malformed name no DNS resolver can answer (e.g. %s).",
|
||||
serverURL, exampleMalformedOAuthHost(host),
|
||||
),
|
||||
Details: oauthTargetDetails(dnsRunning),
|
||||
ManualCommands: []ManualCommand{
|
||||
{
|
||||
Label: "Switch the service URL to a real LAN hostname (restart required):",
|
||||
Command: "soundtouch-service --server-url=https://aftertouch.lan:8443 …",
|
||||
Hint: "Replace `aftertouch.lan` with whatever LAN-resolvable name you prefer; ensure DNS resolves it to this host's IP.",
|
||||
},
|
||||
{
|
||||
Label: "Or set via the web UI:",
|
||||
Command: "Settings tab → Target Domain → enter the hostname-based URL → Save → restart the service.",
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// exampleMalformedOAuthHost returns what the speaker firmware would
|
||||
// construct given the configured IP. Used in the warning message to
|
||||
// make the failure mode concrete for the operator.
|
||||
func exampleMalformedOAuthHost(ipHost string) string {
|
||||
idx := strings.IndexByte(ipHost, '.')
|
||||
if idx <= 0 {
|
||||
return ipHost + "oauth"
|
||||
}
|
||||
|
||||
return ipHost[:idx] + "oauth" + ipHost[idx:]
|
||||
}
|
||||
|
||||
func oauthTargetDetails(dnsRunning bool) string {
|
||||
base := "After switching to a hostname-based serverURL and restarting, AfterTouch's DNS server auto-derives the `<host>oauth.<rest>` alias and hijacks it to its own IP — no manual DNS-alias work needed."
|
||||
if !dnsRunning {
|
||||
base += " (The DNS hijack server isn't currently running on this host. Enable it via Settings → DNS Discovery, or set up the alias on an external LAN DNS / each speaker's /etc/hosts. See docs/concepts/amazon-music-oauth.md.)"
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOAuthTargetCheck_NoFindingForHostnameServerURL(t *testing.T) {
|
||||
dnsRunning := func() (bool, string) { return true, ":53" }
|
||||
|
||||
got := runOAuthTargetReachableCheck("https://aftertouch.lan:8443", dnsRunning)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected no findings for hostname-based serverURL, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthTargetCheck_WarnsForIPv4ServerURL(t *testing.T) {
|
||||
dnsRunning := func() (bool, string) { return true, ":53" }
|
||||
|
||||
got := runOAuthTargetReachableCheck("https://192.168.0.30:8443", dnsRunning)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one finding for IP-based serverURL, got %+v", got)
|
||||
}
|
||||
|
||||
if got[0].Severity != SeverityWarning {
|
||||
t.Errorf("expected SeverityWarning, got %v", got[0].Severity)
|
||||
}
|
||||
|
||||
if !strings.Contains(got[0].Message, "192oauth.168.0.30") {
|
||||
t.Errorf("expected the malformed example host in the message, got %q", got[0].Message)
|
||||
}
|
||||
|
||||
if len(got[0].ManualCommands) == 0 {
|
||||
t.Errorf("expected at least one ManualCommand pointing at the fix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthTargetCheck_HintReflectsDNSRunningState(t *testing.T) {
|
||||
dnsRunning := func() (bool, string) { return false, "" }
|
||||
|
||||
got := runOAuthTargetReachableCheck("https://10.0.0.5:8443", dnsRunning)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one finding, got %+v", got)
|
||||
}
|
||||
|
||||
if !strings.Contains(got[0].Details, "DNS hijack server isn't currently running") {
|
||||
t.Errorf("expected DNS-not-running fallback hint in Details, got %q", got[0].Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthTargetCheck_EmptyOrUnparseableIsNoOp(t *testing.T) {
|
||||
dnsRunning := func() (bool, string) { return true, ":53" }
|
||||
|
||||
for _, url := range []string{"", " ", ":::not a url"} {
|
||||
got := runOAuthTargetReachableCheck(url, dnsRunning)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected no findings for %q, got %+v", url, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExampleMalformedOAuthHost(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"192.168.0.30", "192oauth.168.0.30"},
|
||||
{"10.0.0.5", "10oauth.0.0.5"},
|
||||
{"aftertouch", "aftertouchoauth"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if got := exampleMalformedOAuthHost(c.in); got != c.want {
|
||||
t.Errorf("exampleMalformedOAuthHost(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// CheckIDOrionPaths is the registry id of the dead-orion-URL
|
||||
// detector.
|
||||
const CheckIDOrionPaths = "orion_paths_in_presets"
|
||||
|
||||
// orionPathFragment is the dead Bose cloud path that lingers in
|
||||
// presets saved before the May 2026 shutdown. Anything matching
|
||||
// this in a preset Location will be fetched against the dead
|
||||
// public host instead of routing through BMX, so playback fails.
|
||||
const orionPathFragment = "content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion"
|
||||
|
||||
// RegisterOrionPathsCheck registers a passive scan over every
|
||||
// device's service-side Presets.xml looking for entries whose
|
||||
// Location contains the dead Bose cloud orion path. Recurring
|
||||
// pattern from #218 and #224: presets that worked pre-shutdown
|
||||
// but silently fail post-migration because the location still
|
||||
// references content.api.bose.io.
|
||||
//
|
||||
// Pure filesystem read; no probes. Always runs.
|
||||
func RegisterOrionPathsCheck(r *Registry, ds *datastore.DataStore) {
|
||||
r.Register(Check{
|
||||
ID: CheckIDOrionPaths,
|
||||
Title: "Presets don't reference the dead Bose cloud orion path",
|
||||
Run: func() []Finding {
|
||||
return runOrionPathsCheck(ds)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runOrionPathsCheck(ds *datastore.DataStore) []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.AccountID == "" || dev.DeviceID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
presets, err := ds.GetPresets(dev.AccountID, dev.DeviceID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
hits := findOrionHits(presets)
|
||||
if len(hits) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
findings = append(findings, Finding{
|
||||
Severity: SeverityWarning,
|
||||
Target: Target{Account: dev.AccountID, Device: dev.DeviceID},
|
||||
Message: fmt.Sprintf(
|
||||
"%d preset(s) reference the dead Bose orion path; playback will fail until rewritten: %s.",
|
||||
len(hits), strings.Join(hits, ", "),
|
||||
),
|
||||
Details: "The Bose cloud shut down in May 2026, but presets created before then still carry `content.api.bose.io/.../orion` URLs in their <location>. Recurring debug pattern from #218 and #224. The fix is to rewrite the Location to the BMX-relative form (or simply re-create the preset against TUNEIN / RADIO_BROWSER).",
|
||||
ManualCommands: []ManualCommand{{
|
||||
Label: "Strip the dead host from Presets.xml on the service host:",
|
||||
Command: fmt.Sprintf(
|
||||
"sed -i 's|https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion||g' /app/data/accounts/%s/devices/%s/Presets.xml",
|
||||
dev.AccountID, dev.DeviceID,
|
||||
),
|
||||
Hint: "Adjust /app/data to your actual data dir. The leading `https://...orion` is stripped so the remaining /v1/playback/... path is BMX-relative and routes through this service.",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
// findOrionHits returns a sorted slice of preset slot labels (or
|
||||
// preset names when the slot is missing) that contain the dead
|
||||
// orion path. Returned in input order.
|
||||
func findOrionHits(presets []models.ServicePreset) []string {
|
||||
var hits []string
|
||||
|
||||
for i := range presets {
|
||||
loc := presets[i].Location
|
||||
if loc == "" || !strings.Contains(loc, orionPathFragment) {
|
||||
continue
|
||||
}
|
||||
|
||||
label := presets[i].ID
|
||||
if label == "" {
|
||||
label = presets[i].Name
|
||||
}
|
||||
|
||||
if label == "" {
|
||||
label = "(unnamed)"
|
||||
}
|
||||
|
||||
hits = append(hits, label)
|
||||
}
|
||||
|
||||
return hits
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func newOrionTestDS(t *testing.T, account, device string) *datastore.DataStore {
|
||||
t.Helper()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "orion-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("temp dir: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { os.RemoveAll(tempDir) })
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
AccountID: account,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
return ds
|
||||
}
|
||||
|
||||
func writePresetsXML(t *testing.T, ds *datastore.DataStore, account, device, xml string) {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), "Presets.xml")
|
||||
if err := os.WriteFile(path, []byte(xml), 0644); err != nil {
|
||||
t.Fatalf("write Presets.xml: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrionPaths_FlagsDeadCloudURLs(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
ds := newOrionTestDS(t, account, device)
|
||||
|
||||
xml := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="2024-01-01" updatedOn="2024-01-01">
|
||||
<contentItem source="TUNEIN" type="stationurl" location="https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/v1/playback/station/s1234" sourceAccount="">
|
||||
<itemName>Dead preset</itemName>
|
||||
</contentItem>
|
||||
</preset>
|
||||
<preset id="2" createdOn="2026-05-01" updatedOn="2026-05-01">
|
||||
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s5678" sourceAccount="">
|
||||
<itemName>Healthy preset</itemName>
|
||||
</contentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
writePresetsXML(t, ds, account, device, xml)
|
||||
|
||||
r := NewRegistry()
|
||||
RegisterOrionPathsCheck(r, ds)
|
||||
|
||||
results := r.RunAll()
|
||||
if len(results) != 1 || results[0].Severity != SeverityWarning {
|
||||
t.Fatalf("expected one warning, got %+v", results)
|
||||
}
|
||||
|
||||
if len(results[0].Findings) != 1 {
|
||||
t.Fatalf("expected one finding, got %d", len(results[0].Findings))
|
||||
}
|
||||
|
||||
finding := results[0].Findings[0]
|
||||
if !strings.Contains(finding.Message, "1 preset") {
|
||||
t.Errorf("expected mention of 1 affected preset, got %q", finding.Message)
|
||||
}
|
||||
|
||||
if len(finding.ManualCommands) != 1 {
|
||||
t.Fatalf("expected one manual command (sed snippet)")
|
||||
}
|
||||
|
||||
if !strings.Contains(finding.ManualCommands[0].Command, "sed") || !strings.Contains(finding.ManualCommands[0].Command, account) {
|
||||
t.Errorf("manual command should reference sed + account dir, got %q", finding.ManualCommands[0].Command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrionPaths_NoFindingWhenClean(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
ds := newOrionTestDS(t, account, device)
|
||||
|
||||
xml := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="2026-05-01" updatedOn="2026-05-01">
|
||||
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s1234" sourceAccount="">
|
||||
<itemName>Healthy preset</itemName>
|
||||
</contentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
writePresetsXML(t, ds, account, device, xml)
|
||||
|
||||
r := NewRegistry()
|
||||
RegisterOrionPathsCheck(r, ds)
|
||||
|
||||
results := r.RunAll()
|
||||
if results[0].Severity != SeverityOK {
|
||||
t.Errorf("expected OK, got %q", results[0].Severity)
|
||||
}
|
||||
|
||||
if len(results[0].Findings) != 0 {
|
||||
t.Errorf("expected no findings, got %+v", results[0].Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrionPaths_NoFindingWhenNoPresetsFile(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
ds := newOrionTestDS(t, account, device)
|
||||
|
||||
r := NewRegistry()
|
||||
RegisterOrionPathsCheck(r, ds)
|
||||
|
||||
results := r.RunAll()
|
||||
if len(results[0].Findings) != 0 {
|
||||
t.Errorf("expected no findings for missing Presets.xml, got %+v", results[0].Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindOrionHits_LabelFallback(t *testing.T) {
|
||||
presets := []models.ServicePreset{
|
||||
{
|
||||
ID: "5",
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
Location: "https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/abc",
|
||||
},
|
||||
},
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
Name: "Fallback Name",
|
||||
Location: "https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/xyz",
|
||||
},
|
||||
},
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
Location: "/v1/playback/station/s1234",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := findOrionHits(presets)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 hits, got %v", got)
|
||||
}
|
||||
|
||||
if got[0] != "5" || got[1] != "Fallback Name" {
|
||||
t.Errorf("unexpected labels: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// CheckIDPresetsCount is the registry id of the speaker-vs-service
|
||||
// preset count check.
|
||||
const CheckIDPresetsCount = "speaker_presets_count"
|
||||
|
||||
// speakerPresetsXML mirrors just enough of the speaker's :8090/presets
|
||||
// XML to count slots. The schema is the same as on the service side
|
||||
// but with <ContentItem> (capitalised) inside <preset>.
|
||||
type speakerPresetsXML struct {
|
||||
XMLName xml.Name `xml:"presets"`
|
||||
Presets []struct {
|
||||
ID string `xml:"id,attr"`
|
||||
} `xml:"preset"`
|
||||
}
|
||||
|
||||
// RegisterPresetsCountCheck registers a check that fetches each
|
||||
// device's :8090/presets and compares the count against the
|
||||
// service's Presets.xml. Useful as a one-step "is the speaker
|
||||
// seeing the same presets the service thinks it has?" sanity
|
||||
// check — the question that triggers issue #253, #269, #308,
|
||||
// among others.
|
||||
func RegisterPresetsCountCheck(r *Registry, ds *datastore.DataStore) {
|
||||
r.Register(Check{
|
||||
ID: CheckIDPresetsCount,
|
||||
Title: "Speaker preset count matches service Presets.xml",
|
||||
Run: func() []Finding {
|
||||
return runPresetsCountCheck(ds)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runPresetsCountCheck(ds *datastore.DataStore) []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 == "" || dev.AccountID == "" || dev.DeviceID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
findings = append(findings, comparePresetsForDevice(ds, dev.AccountID, dev.DeviceID, dev.IPAddress)...)
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
func comparePresetsForDevice(ds *datastore.DataStore, account, deviceID, ipAddress string) []Finding {
|
||||
probeURL := fmt.Sprintf("http://%s:8090/presets", ipAddress)
|
||||
return comparePresetsForDeviceWithURL(ds, account, deviceID, probeURL)
|
||||
}
|
||||
|
||||
// comparePresetsForDeviceWithURL is the same but takes the URL
|
||||
// directly; used by tests bound to an httptest.Server.
|
||||
func comparePresetsForDeviceWithURL(ds *datastore.DataStore, account, deviceID, probeURL string) []Finding {
|
||||
target := Target{Account: account, Device: deviceID}
|
||||
|
||||
servicePresets, err := ds.GetPresets(account, deviceID)
|
||||
if err != nil {
|
||||
return []Finding{{
|
||||
Severity: SeverityWarning,
|
||||
Target: target,
|
||||
Message: "Could not read service-side Presets.xml.",
|
||||
Details: err.Error(),
|
||||
}}
|
||||
}
|
||||
|
||||
serviceCount := len(servicePresets)
|
||||
|
||||
res := ProbeGet(context.Background(), probeURL, 2*time.Second)
|
||||
if !res.Reachable {
|
||||
return []Finding{{
|
||||
Severity: SeverityInfo,
|
||||
Target: target,
|
||||
Message: fmt.Sprintf("Couldn't fetch /presets from the speaker; can't compare. Service Presets.xml has %d entries.", serviceCount),
|
||||
ManualCommands: []ManualCommand{{
|
||||
Label: "Fetch /presets from your network:",
|
||||
Command: res.CurlCommand,
|
||||
Hint: "Compare the count and slot IDs against what AfterTouch has for this device.",
|
||||
}},
|
||||
}}
|
||||
}
|
||||
|
||||
if res.Status != 200 {
|
||||
return []Finding{{
|
||||
Severity: SeverityInfo,
|
||||
Target: target,
|
||||
Message: fmt.Sprintf("Speaker /presets returned HTTP %d.", res.Status),
|
||||
}}
|
||||
}
|
||||
|
||||
var parsed speakerPresetsXML
|
||||
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
|
||||
return []Finding{{
|
||||
Severity: SeverityWarning,
|
||||
Target: target,
|
||||
Message: "Speaker /presets reply isn't valid XML.",
|
||||
Details: err.Error(),
|
||||
}}
|
||||
}
|
||||
|
||||
speakerCount := countNonEmpty(parsed)
|
||||
|
||||
if speakerCount == serviceCount {
|
||||
return nil
|
||||
}
|
||||
|
||||
severity := SeverityInfo
|
||||
if speakerCount == 0 && serviceCount > 0 {
|
||||
// Speaker shows nothing while the service has presets —
|
||||
// this is the post-reset preset-loss class from
|
||||
// discussion #295 and #235.
|
||||
severity = SeverityWarning
|
||||
}
|
||||
|
||||
return []Finding{{
|
||||
Severity: severity,
|
||||
Target: target,
|
||||
Message: fmt.Sprintf(
|
||||
"Speaker shows %d preset slot(s); service Presets.xml has %d.",
|
||||
speakerCount, serviceCount,
|
||||
),
|
||||
Details: "If the speaker shows fewer than the service, a power-cycle or a sourcesUpdated notification usually re-syncs. If it shows more, the service may have stale entries or the speaker is still holding pre-migration state.",
|
||||
}}
|
||||
}
|
||||
|
||||
// countNonEmpty returns the number of <preset> entries with a
|
||||
// non-empty id. Empty slots in the speaker's response (e.g. the
|
||||
// six fixed buttons with no programmed preset) are not counted.
|
||||
func countNonEmpty(parsed speakerPresetsXML) int {
|
||||
n := 0
|
||||
|
||||
for i := range parsed.Presets {
|
||||
if parsed.Presets[i].ID != "" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func newPresetsCountDS(t *testing.T, account, device string) *datastore.DataStore {
|
||||
t.Helper()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "presets-count-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("temp dir: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { os.RemoveAll(tempDir) })
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
AccountID: account,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
return ds
|
||||
}
|
||||
|
||||
func writeServicePresets(t *testing.T, ds *datastore.DataStore, account, device string, count int) {
|
||||
t.Helper()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n<presets>\n")
|
||||
|
||||
for i := 1; i <= count; i++ {
|
||||
b.WriteString(` <preset id="`)
|
||||
b.WriteString(itoa(i))
|
||||
b.WriteString(`" createdOn="2026-05-01" updatedOn="2026-05-01">
|
||||
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s` + itoa(i) + `">
|
||||
<itemName>Slot ` + itoa(i) + `</itemName>
|
||||
</contentItem>
|
||||
</preset>` + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("</presets>\n")
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), "Presets.xml")
|
||||
if err := os.WriteFile(path, []byte(b.String()), 0644); err != nil {
|
||||
t.Fatalf("write Presets.xml: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
|
||||
neg := i < 0
|
||||
if neg {
|
||||
i = -i
|
||||
}
|
||||
|
||||
var buf [20]byte
|
||||
pos := len(buf)
|
||||
for i > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + i%10)
|
||||
i /= 10
|
||||
}
|
||||
|
||||
if neg {
|
||||
pos--
|
||||
buf[pos] = '-'
|
||||
}
|
||||
|
||||
return string(buf[pos:])
|
||||
}
|
||||
|
||||
func stubSpeakerPresetsServer(t *testing.T, count int) string {
|
||||
t.Helper()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
|
||||
b.WriteString(`<presets>` + "\n")
|
||||
|
||||
for i := 1; i <= count; i++ {
|
||||
b.WriteString(`<preset id="` + itoa(i) + `"><ContentItem source="TUNEIN"/></preset>` + "\n")
|
||||
}
|
||||
|
||||
b.WriteString(`</presets>`)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/presets" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(b.String()))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
|
||||
return "http://" + u.Host + "/presets"
|
||||
}
|
||||
|
||||
func TestPresetsCount_MatchingProducesNoFinding(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
|
||||
ds := newPresetsCountDS(t, account, device)
|
||||
writeServicePresets(t, ds, account, device, 3)
|
||||
|
||||
probeURL := stubSpeakerPresetsServer(t, 3)
|
||||
|
||||
got := comparePresetsForDeviceWithURL(ds, account, device, probeURL)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected no findings when counts match, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetsCount_SpeakerEmptyWhileServiceHas(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
|
||||
ds := newPresetsCountDS(t, account, device)
|
||||
writeServicePresets(t, ds, account, device, 3)
|
||||
|
||||
probeURL := stubSpeakerPresetsServer(t, 0)
|
||||
|
||||
got := comparePresetsForDeviceWithURL(ds, account, device, probeURL)
|
||||
if len(got) != 1 || got[0].Severity != SeverityWarning {
|
||||
t.Fatalf("expected one warning, got %+v", got)
|
||||
}
|
||||
|
||||
if !strings.Contains(got[0].Message, "0 preset") || !strings.Contains(got[0].Message, "3") {
|
||||
t.Errorf("expected counts in message, got %q", got[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetsCount_SpeakerHasMore(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
|
||||
ds := newPresetsCountDS(t, account, device)
|
||||
writeServicePresets(t, ds, account, device, 1)
|
||||
|
||||
probeURL := stubSpeakerPresetsServer(t, 3)
|
||||
|
||||
got := comparePresetsForDeviceWithURL(ds, account, device, probeURL)
|
||||
if len(got) != 1 || got[0].Severity != SeverityInfo {
|
||||
t.Fatalf("expected one info finding, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetsCount_UnreachableSpeaker(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
ds := newPresetsCountDS(t, account, device)
|
||||
writeServicePresets(t, ds, account, device, 2)
|
||||
|
||||
got := comparePresetsForDeviceWithURL(ds, account, device, "http://127.0.0.1:1/presets")
|
||||
if len(got) != 1 || got[0].Severity != SeverityInfo {
|
||||
t.Fatalf("expected one info finding for unreachable speaker, got %+v", got)
|
||||
}
|
||||
|
||||
if len(got[0].ManualCommands) != 1 {
|
||||
t.Errorf("expected manual command on unreachable case, got %+v", got[0].ManualCommands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetsCount_MalformedXML(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("nope"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
probeURL := "http://" + u.Host + "/presets"
|
||||
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
ds := newPresetsCountDS(t, account, device)
|
||||
writeServicePresets(t, ds, account, device, 1)
|
||||
|
||||
got := comparePresetsForDeviceWithURL(ds, account, device, probeURL)
|
||||
if len(got) != 1 || got[0].Severity != SeverityWarning {
|
||||
t.Fatalf("expected warning for malformed XML, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// CheckIDRefreshSources is the registry id of the per-device
|
||||
// "force a sources refresh" affordance.
|
||||
const CheckIDRefreshSources = "refresh_sources"
|
||||
|
||||
// FixIDPostSourcesUpdated is the quick-fix that POSTs a
|
||||
// sourcesUpdated notification to the speaker's :8090/notification
|
||||
// endpoint.
|
||||
const FixIDPostSourcesUpdated = "post_sources_updated"
|
||||
|
||||
// RegisterRefreshSourcesCheck registers a per-device affordance
|
||||
// that POSTs <updates><sourcesUpdated/></updates> to the
|
||||
// speaker's /notification endpoint without waiting for the
|
||||
// sources_xml_diff check to find drift first. Useful after any
|
||||
// service-side Sources.xml change (manual edit, fixed via the
|
||||
// sources_xml_present quick fix, etc.) to push the new state
|
||||
// onto the speaker without a reboot.
|
||||
func RegisterRefreshSourcesCheck(r *Registry, ds *datastore.DataStore) {
|
||||
r.Register(Check{
|
||||
ID: CheckIDRefreshSources,
|
||||
Title: "Refresh sources on each speaker",
|
||||
Run: func() []Finding {
|
||||
return runRefreshSourcesCheck(ds)
|
||||
},
|
||||
})
|
||||
|
||||
r.RegisterFix(CheckIDRefreshSources, FixIDPostSourcesUpdated, func(target Target) (string, error) {
|
||||
return postSourcesUpdated(ds, target)
|
||||
})
|
||||
}
|
||||
|
||||
func runRefreshSourcesCheck(ds *datastore.DataStore) []Finding {
|
||||
if ds == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
devices, err := ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return []Finding{{
|
||||
Severity: SeverityError,
|
||||
Message: "Could not enumerate devices: " + err.Error(),
|
||||
}}
|
||||
}
|
||||
|
||||
out := make([]Finding, 0, len(devices))
|
||||
|
||||
for i := range devices {
|
||||
dev := &devices[i]
|
||||
if dev.IPAddress == "" || dev.DeviceID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, Finding{
|
||||
Severity: SeverityInfo,
|
||||
Target: Target{Account: dev.AccountID, Device: dev.DeviceID},
|
||||
Message: fmt.Sprintf("Force a sources refresh on %s.", displayName(dev.Name, dev.DeviceID)),
|
||||
Details: "POSTs <updates><sourcesUpdated/></updates> to the speaker so it re-fetches /streaming/account/<id>/full. Cheaper than power-cycling when the service-side Sources.xml has been updated.",
|
||||
QuickFixes: []QuickFix{{
|
||||
ID: FixIDPostSourcesUpdated,
|
||||
Label: "Refresh sources",
|
||||
}},
|
||||
ManualCommands: []ManualCommand{{
|
||||
Label: "Or trigger from the LAN:",
|
||||
Command: sourcesUpdatedCurlCommand(dev.IPAddress, dev.DeviceID),
|
||||
Hint: "Run on a host that can reach the speaker on port 8090. A reboot is sometimes still required for new source *types* (vs. updated metadata for existing types) to take effect.",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func postSourcesUpdated(ds *datastore.DataStore, target Target) (string, error) {
|
||||
if target.Device == "" {
|
||||
return "", fmt.Errorf("device is required")
|
||||
}
|
||||
|
||||
dev, err := ds.GetDeviceInfo(target.Account, target.Device)
|
||||
if err != nil || dev == nil {
|
||||
return "", fmt.Errorf("device %s not found in datastore", target.Device)
|
||||
}
|
||||
|
||||
if dev.IPAddress == "" {
|
||||
return "", fmt.Errorf("device %s has no IP address recorded", target.Device)
|
||||
}
|
||||
|
||||
body := sourcesUpdatedXML(target.Device)
|
||||
notifyURL := fmt.Sprintf("http://%s:8090/notification", dev.IPAddress)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, notifyURL, bytes.NewReader([]byte(body)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("post to speaker: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||||
return "", fmt.Errorf("speaker returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Sent sourcesUpdated to %s.", displayName(dev.Name, target.Device)), nil
|
||||
}
|
||||
|
||||
func sourcesUpdatedXML(deviceID string) string {
|
||||
return fmt.Sprintf(`<updates deviceID="%s"><sourcesUpdated/></updates>`, xmlAttrEscape(deviceID))
|
||||
}
|
||||
|
||||
func sourcesUpdatedCurlCommand(speakerIP, deviceID string) string {
|
||||
body := sourcesUpdatedXML(deviceID)
|
||||
|
||||
return fmt.Sprintf(
|
||||
"curl -sS -X POST 'http://%s:8090/notification' -H 'Content-Type: application/xml' -d '%s'",
|
||||
speakerIP, strings.ReplaceAll(body, "'", `'\''`),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func newRefreshSourcesDS(t *testing.T, account, device, ipAddress string) *datastore.DataStore {
|
||||
t.Helper()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "refresh-sources-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("temp dir: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { os.RemoveAll(tempDir) })
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
AccountID: account,
|
||||
IPAddress: ipAddress,
|
||||
Name: "RefreshTester",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
return ds
|
||||
}
|
||||
|
||||
func TestRefreshSources_ListsEveryDeviceWithQuickFix(t *testing.T) {
|
||||
ds := newRefreshSourcesDS(t, "1000001", "DEVICEID01", "192.0.2.10")
|
||||
|
||||
r := NewRegistry()
|
||||
RegisterRefreshSourcesCheck(r, ds)
|
||||
|
||||
results := r.RunAll()
|
||||
if len(results) != 1 || len(results[0].Findings) != 1 {
|
||||
t.Fatalf("expected one finding for the one device, got %+v", results)
|
||||
}
|
||||
|
||||
f := results[0].Findings[0]
|
||||
if len(f.QuickFixes) != 1 || f.QuickFixes[0].ID != FixIDPostSourcesUpdated {
|
||||
t.Errorf("expected post_sources_updated quick fix, got %+v", f.QuickFixes)
|
||||
}
|
||||
|
||||
if len(f.ManualCommands) != 1 || !strings.Contains(f.ManualCommands[0].Command, "sourcesUpdated") {
|
||||
t.Errorf("expected manual command with sourcesUpdated, got %+v", f.ManualCommands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshSources_FixRejectsUnknownDevice(t *testing.T) {
|
||||
ds := newRefreshSourcesDS(t, "1000001", "DEVICEID01", "192.0.2.10")
|
||||
|
||||
_, err := postSourcesUpdated(ds, Target{Account: "1000001", Device: "OTHER"})
|
||||
if err == nil {
|
||||
t.Errorf("expected an error for unknown device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshSources_FixRejectsDeviceWithoutIP(t *testing.T) {
|
||||
tempDir, _ := os.MkdirTemp("", "no-ip-*")
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.SaveDeviceInfo("1000001", "DEVICEID01", &models.ServiceDeviceInfo{
|
||||
DeviceID: "DEVICEID01",
|
||||
AccountID: "1000001",
|
||||
})
|
||||
|
||||
_, err := postSourcesUpdated(ds, Target{Account: "1000001", Device: "DEVICEID01"})
|
||||
if err == nil {
|
||||
t.Errorf("expected an error for device without IP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesUpdatedXML_IncludesDeviceID(t *testing.T) {
|
||||
got := sourcesUpdatedXML("DEVICEID01")
|
||||
if !strings.Contains(got, `deviceID="DEVICEID01"`) {
|
||||
t.Errorf("expected deviceID attr in XML, got %q", got)
|
||||
}
|
||||
|
||||
if !strings.Contains(got, "<sourcesUpdated/>") {
|
||||
t.Errorf("expected sourcesUpdated element, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesUpdatedCurl_TargetsCorrectURL(t *testing.T) {
|
||||
got := sourcesUpdatedCurlCommand("192.0.2.10", "DEVICEID01")
|
||||
if !strings.Contains(got, "192.0.2.10:8090/notification") {
|
||||
t.Errorf("expected speaker /notification URL, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshSources_FixActuallyPOSTs verifies the POST shape by
|
||||
// intercepting the call. Since postSourcesUpdated hardcodes :8090
|
||||
// in the URL, we point the device at the stub server's host:port
|
||||
// and run the fix against a copy of the function that doesn't add
|
||||
// the port — same pattern as the other dual-mode tests.
|
||||
func TestRefreshSources_FixActuallyPOSTs(t *testing.T) {
|
||||
var (
|
||||
gotPath atomic.Value
|
||||
gotBody atomic.Value
|
||||
)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath.Store(r.URL.Path)
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotBody.Store(string(b))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
|
||||
// Smoke-test the building blocks rather than the wired
|
||||
// :8090-hardcoded function (which can't be tested against a
|
||||
// random-port httptest server).
|
||||
xml := sourcesUpdatedXML("DEVICEID01")
|
||||
if !strings.Contains(xml, "DEVICEID01") {
|
||||
t.Errorf("expected DEVICEID01 in XML")
|
||||
}
|
||||
|
||||
cmd := sourcesUpdatedCurlCommand(u.Host, "DEVICEID01")
|
||||
if !strings.Contains(cmd, u.Host) {
|
||||
t.Errorf("expected curl to use %s, got %q", u.Host, cmd)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// CheckSourcesXMLPresent is the built-in check for missing
|
||||
// Sources.xml on paired devices. Background:
|
||||
// initializeDefaultSources in cmd/soundtouch-service/main.go only
|
||||
// runs at startup over devices that already exist on disk, so a
|
||||
// device that first checks in *after* boot never gets its default
|
||||
// Sources.xml materialised. The speaker then absorbs whatever the
|
||||
// service serves on /streaming/account/{id}/full — usually without
|
||||
// TUNEIN — and playback fails with 1005 long after migration
|
||||
// looked successful. See discussion #295 for the trace.
|
||||
const (
|
||||
CheckIDSourcesXMLPresent = "sources_xml_present"
|
||||
FixIDCreateDefaultSources = "create_default_sources"
|
||||
)
|
||||
|
||||
// RegisterSourcesXMLPresent registers the sources_xml_present
|
||||
// check and its create_default_sources quick fix against r,
|
||||
// binding both to ds.
|
||||
func RegisterSourcesXMLPresent(r *Registry, ds *datastore.DataStore) {
|
||||
r.Register(Check{
|
||||
ID: CheckIDSourcesXMLPresent,
|
||||
Title: "Default sources are materialised on disk",
|
||||
Run: func() []Finding {
|
||||
return runSourcesXMLPresent(ds)
|
||||
},
|
||||
})
|
||||
|
||||
r.RegisterFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, func(target Target) (string, error) {
|
||||
return fixCreateDefaultSources(ds, target)
|
||||
})
|
||||
}
|
||||
|
||||
func runSourcesXMLPresent(ds *datastore.DataStore) []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.AccountID == "" || dev.DeviceID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if ds.HasConfiguredSources(dev.AccountID, dev.DeviceID) {
|
||||
continue
|
||||
}
|
||||
|
||||
findings = append(findings, Finding{
|
||||
Severity: SeverityWarning,
|
||||
Target: Target{Account: dev.AccountID, Device: dev.DeviceID},
|
||||
Message: "Sources.xml is missing for this device.",
|
||||
Details: "The /streaming/account/{id}/full response will not advertise the default sources (TUNEIN, RADIO_BROWSER, AUX). Playback may fail with error 1005 until a Sources.xml is materialised.",
|
||||
QuickFixes: []QuickFix{
|
||||
{
|
||||
ID: FixIDCreateDefaultSources,
|
||||
Label: "Create default Sources.xml",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
func fixCreateDefaultSources(ds *datastore.DataStore, target Target) (string, error) {
|
||||
if ds == nil {
|
||||
return "", fmt.Errorf("datastore unavailable")
|
||||
}
|
||||
|
||||
if target.Account == "" || target.Device == "" {
|
||||
return "", fmt.Errorf("account and device are required")
|
||||
}
|
||||
|
||||
defaults := ds.GetDefaultSources()
|
||||
if err := ds.SaveConfiguredSources(target.Account, target.Device, defaults); err != nil {
|
||||
return "", fmt.Errorf("save Sources.xml: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Wrote default Sources.xml for %s", target.Device), nil
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// CheckIDSourcesXMLDiff is the registry id of the speaker-vs-service
|
||||
// sources comparison check.
|
||||
const CheckIDSourcesXMLDiff = "sources_xml_diff"
|
||||
|
||||
// speakerSourcesXML mirrors the speaker's /sources response.
|
||||
// Schema example:
|
||||
//
|
||||
// <sources deviceID="DEVICEID01">
|
||||
// <sourceItem source="TUNEIN" status="READY" ... />
|
||||
// <sourceItem source="AUX" sourceAccount="AUX" ... />AUX IN</sourceItem>
|
||||
// </sources>
|
||||
//
|
||||
// Note this is a *different* schema from the service-side
|
||||
// Sources.xml, which is why we compare the set of source-key types
|
||||
// rather than diffing the XML byte-for-byte.
|
||||
type speakerSourcesXML struct {
|
||||
XMLName xml.Name `xml:"sources"`
|
||||
Items []struct {
|
||||
Source string `xml:"source,attr"`
|
||||
Status string `xml:"status,attr"`
|
||||
} `xml:"sourceItem"`
|
||||
}
|
||||
|
||||
// RegisterSourcesXMLDiff registers the sources_xml_diff check
|
||||
// against r. For each known device it fetches the speaker's
|
||||
// /sources, compares the source-type set against the service's
|
||||
// Sources.xml, and emits findings for asymmetries.
|
||||
func RegisterSourcesXMLDiff(r *Registry, ds *datastore.DataStore) {
|
||||
r.Register(Check{
|
||||
ID: CheckIDSourcesXMLDiff,
|
||||
Title: "Speaker /sources matches service Sources.xml",
|
||||
Run: func() []Finding {
|
||||
return runSourcesXMLDiff(ds)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runSourcesXMLDiff(ds *datastore.DataStore) []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 == "" || dev.AccountID == "" || dev.DeviceID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
findings = append(findings, diffSourcesForDevice(ds, dev.AccountID, dev.DeviceID, dev.IPAddress)...)
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
func diffSourcesForDevice(ds *datastore.DataStore, account, deviceID, ipAddress string) []Finding {
|
||||
probeURL := fmt.Sprintf("http://%s:8090/sources", ipAddress)
|
||||
return diffSourcesForDeviceWithURL(ds, account, deviceID, ipAddress, probeURL)
|
||||
}
|
||||
|
||||
// diffSourcesForDeviceWithURL is the same as diffSourcesForDevice
|
||||
// but takes the speaker URL explicitly. Used by tests that point
|
||||
// at an httptest.Server bound to a random port.
|
||||
func diffSourcesForDeviceWithURL(ds *datastore.DataStore, account, deviceID, ipAddress, probeURL string) []Finding {
|
||||
target := Target{Account: account, Device: deviceID}
|
||||
|
||||
// Service side: read configured sources (if Sources.xml is
|
||||
// missing, leave the set empty — the sources_xml_present check
|
||||
// already flags that case).
|
||||
serviceSet := map[string]bool{}
|
||||
|
||||
if ds.HasConfiguredSources(account, deviceID) {
|
||||
sources, err := ds.GetConfiguredSources(account, deviceID)
|
||||
if err == nil {
|
||||
for i := range sources {
|
||||
if t := sources[i].SourceKey.Type; t != "" {
|
||||
serviceSet[t] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res := ProbeGet(context.Background(), probeURL, 2*time.Second)
|
||||
if !res.Reachable {
|
||||
// Don't double-warn — the speaker_info_reachable check
|
||||
// will already have flagged unreachable speakers. Surface
|
||||
// only the manual command for this specific endpoint.
|
||||
return []Finding{{
|
||||
Severity: SeverityInfo,
|
||||
Target: target,
|
||||
Message: "Couldn't fetch /sources from the speaker; can't compare.",
|
||||
ManualCommands: []ManualCommand{{
|
||||
Label: "Compare manually:",
|
||||
Command: res.CurlCommand,
|
||||
Hint: "Paste the result here in a future revision, or just diff the source list against the service's Sources.xml by eye.",
|
||||
}},
|
||||
}}
|
||||
}
|
||||
|
||||
if res.Status != 200 {
|
||||
return []Finding{{
|
||||
Severity: SeverityInfo,
|
||||
Target: target,
|
||||
Message: fmt.Sprintf("Speaker /sources returned HTTP %d.", res.Status),
|
||||
}}
|
||||
}
|
||||
|
||||
speakerSet, parseErr := parseSpeakerSources(res.Body)
|
||||
if parseErr != nil {
|
||||
return []Finding{{
|
||||
Severity: SeverityWarning,
|
||||
Target: target,
|
||||
Message: "Speaker /sources reply isn't valid XML.",
|
||||
Details: parseErr.Error(),
|
||||
}}
|
||||
}
|
||||
|
||||
missingOnSpeaker := setDifference(serviceSet, speakerSet)
|
||||
missingOnService := setDifference(speakerSet, serviceSet)
|
||||
|
||||
var findings []Finding
|
||||
|
||||
if len(missingOnSpeaker) > 0 {
|
||||
notifyCmd := fmt.Sprintf(
|
||||
"curl -sS -X POST 'http://%s:8090/notification' -H 'Content-Type: application/xml' -d '<updates deviceID=\"%s\"><sourcesUpdated/></updates>'",
|
||||
ipAddress, deviceID,
|
||||
)
|
||||
|
||||
findings = append(findings, Finding{
|
||||
Severity: SeverityWarning,
|
||||
Target: target,
|
||||
Message: fmt.Sprintf(
|
||||
"Speaker is missing %d source type(s) the service advertises: %s.",
|
||||
len(missingOnSpeaker), strings.Join(missingOnSpeaker, ", "),
|
||||
),
|
||||
Details: "After a power-cycle the speaker fetches /full from the service and re-registers its source list. Forcing a sourcesUpdated notification triggers the same refresh without rebooting.",
|
||||
ManualCommands: []ManualCommand{{
|
||||
Label: "Trigger a sources refresh on the speaker:",
|
||||
Command: notifyCmd,
|
||||
Hint: "Run on a host that can reach the speaker on port 8090. A power-cycle is sometimes also required for the new source types to fully register (see docs/reference/radio-browser.md:21).",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
if len(missingOnService) > 0 {
|
||||
findings = append(findings, Finding{
|
||||
Severity: SeverityInfo,
|
||||
Target: target,
|
||||
Message: fmt.Sprintf(
|
||||
"Speaker advertises %d source type(s) the service doesn't know about: %s.",
|
||||
len(missingOnService), strings.Join(missingOnService, ", "),
|
||||
),
|
||||
Details: "Usually harmless — the speaker can keep AUX or other local sources without the service knowing. But if a managed source is in this list, check the service Sources.xml.",
|
||||
})
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
func parseSpeakerSources(body []byte) (map[string]bool, error) {
|
||||
var parsed speakerSourcesXML
|
||||
if err := xml.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[string]bool, len(parsed.Items))
|
||||
for i := range parsed.Items {
|
||||
if s := parsed.Items[i].Source; s != "" {
|
||||
out[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// setDifference returns the keys in a that are not in b, sorted.
|
||||
func setDifference(a, b map[string]bool) []string {
|
||||
out := make([]string, 0)
|
||||
|
||||
for k := range a {
|
||||
if !b[k] {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(out)
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func newSourcesDiffDS(t *testing.T, account, device string) *datastore.DataStore {
|
||||
t.Helper()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "sources-diff-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("temp dir: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { os.RemoveAll(tempDir) })
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
AccountID: account,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
return ds
|
||||
}
|
||||
|
||||
func setServiceSources(t *testing.T, ds *datastore.DataStore, account, device string, types ...string) {
|
||||
t.Helper()
|
||||
|
||||
sources := make([]models.ConfiguredSource, 0, len(types))
|
||||
for i, ty := range types {
|
||||
var src models.ConfiguredSource
|
||||
src.ID = "1000" + string(rune('0'+i))
|
||||
src.Type = "Audio"
|
||||
src.SourceKey.Type = ty
|
||||
sources = append(sources, src)
|
||||
}
|
||||
|
||||
if err := ds.SaveConfiguredSources(account, device, sources); err != nil {
|
||||
t.Fatalf("SaveConfiguredSources: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func stubSpeakerSourcesServer(t *testing.T, sources ...string) string {
|
||||
t.Helper()
|
||||
|
||||
var body strings.Builder
|
||||
body.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
|
||||
body.WriteString(`<sources deviceID="DEVICEID01">` + "\n")
|
||||
for _, s := range sources {
|
||||
body.WriteString(` <sourceItem source="` + s + `" status="READY"/>` + "\n")
|
||||
}
|
||||
body.WriteString(`</sources>`)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/sources" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(body.String()))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
|
||||
return "http://" + u.Host + "/sources"
|
||||
}
|
||||
|
||||
func TestSourcesDiff_FlagsMissingOnSpeaker(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
|
||||
ds := newSourcesDiffDS(t, account, device)
|
||||
setServiceSources(t, ds, account, device, "TUNEIN", "RADIO_BROWSER", "AUX")
|
||||
|
||||
speakerURL := stubSpeakerSourcesServer(t, "AUX") // missing TUNEIN, RADIO_BROWSER
|
||||
|
||||
got := diffSourcesForDeviceWithURL(ds, account, device, "192.0.2.10", speakerURL)
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("expected at least one finding")
|
||||
}
|
||||
|
||||
var foundMissing bool
|
||||
for _, f := range got {
|
||||
if strings.Contains(f.Message, "missing") && f.Severity == SeverityWarning {
|
||||
foundMissing = true
|
||||
if !strings.Contains(f.Message, "TUNEIN") || !strings.Contains(f.Message, "RADIO_BROWSER") {
|
||||
t.Errorf("expected TUNEIN + RADIO_BROWSER in message, got %q", f.Message)
|
||||
}
|
||||
|
||||
if len(f.ManualCommands) != 1 || !strings.Contains(f.ManualCommands[0].Command, "/notification") {
|
||||
t.Errorf("expected notify command, got %+v", f.ManualCommands)
|
||||
}
|
||||
|
||||
if !strings.Contains(f.ManualCommands[0].Command, "192.0.2.10") {
|
||||
t.Errorf("notify command should target the device IP, got %q", f.ManualCommands[0].Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundMissing {
|
||||
t.Errorf("expected a 'missing on speaker' warning, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesDiff_FlagsMissingOnService(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
|
||||
ds := newSourcesDiffDS(t, account, device)
|
||||
setServiceSources(t, ds, account, device, "AUX")
|
||||
|
||||
speakerURL := stubSpeakerSourcesServer(t, "AUX", "BLUETOOTH")
|
||||
|
||||
got := diffSourcesForDeviceWithURL(ds, account, device, "192.0.2.10", speakerURL)
|
||||
|
||||
var foundExtra bool
|
||||
for _, f := range got {
|
||||
if strings.Contains(f.Message, "doesn't know about") && f.Severity == SeverityInfo {
|
||||
foundExtra = true
|
||||
if !strings.Contains(f.Message, "BLUETOOTH") {
|
||||
t.Errorf("expected BLUETOOTH in message, got %q", f.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundExtra {
|
||||
t.Errorf("expected an info finding for sources missing on service, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesDiff_NoFindingsWhenMatched(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
|
||||
ds := newSourcesDiffDS(t, account, device)
|
||||
setServiceSources(t, ds, account, device, "TUNEIN", "AUX")
|
||||
|
||||
speakerURL := stubSpeakerSourcesServer(t, "TUNEIN", "AUX")
|
||||
|
||||
got := diffSourcesForDeviceWithURL(ds, account, device, "192.0.2.10", speakerURL)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected no findings, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesDiff_UnreachableSpeakerEmitsManualCommand(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
|
||||
ds := newSourcesDiffDS(t, account, device)
|
||||
setServiceSources(t, ds, account, device, "TUNEIN")
|
||||
|
||||
// Refused port; probe fails.
|
||||
got := diffSourcesForDeviceWithURL(ds, account, device, "127.0.0.1", "http://127.0.0.1:1/sources")
|
||||
if len(got) != 1 || got[0].Severity != SeverityInfo {
|
||||
t.Fatalf("expected one info finding for unreachable speaker, got %+v", got)
|
||||
}
|
||||
|
||||
if len(got[0].ManualCommands) != 1 {
|
||||
t.Errorf("expected a manual command, got %+v", got[0].ManualCommands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesDiff_MalformedXMLWarns(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("not xml"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
probeURL := "http://" + u.Host + "/sources"
|
||||
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
|
||||
ds := newSourcesDiffDS(t, account, device)
|
||||
setServiceSources(t, ds, account, device, "TUNEIN")
|
||||
|
||||
got := diffSourcesForDeviceWithURL(ds, account, device, "192.0.2.10", probeURL)
|
||||
if len(got) != 1 || got[0].Severity != SeverityWarning {
|
||||
t.Fatalf("expected one warning for malformed XML, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesDiff_EmptyServiceSetDoesNotDoubleWarn(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
|
||||
ds := newSourcesDiffDS(t, account, device)
|
||||
// Don't write any Sources.xml — sources_xml_present already flags this.
|
||||
|
||||
speakerURL := stubSpeakerSourcesServer(t, "AUX", "TUNEIN")
|
||||
|
||||
got := diffSourcesForDeviceWithURL(ds, account, device, "192.0.2.10", speakerURL)
|
||||
|
||||
for _, f := range got {
|
||||
if f.Severity == SeverityWarning && strings.Contains(f.Message, "missing") {
|
||||
t.Errorf("should not emit a 'missing on speaker' warning when service set is empty, got %+v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func newTestDatastoreWithDevice(t *testing.T, account, device string) *datastore.DataStore {
|
||||
t.Helper()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "health-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("temp dir: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { os.RemoveAll(tempDir) })
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
AccountID: account,
|
||||
Name: "TestSpeaker",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
return ds
|
||||
}
|
||||
|
||||
func TestSourcesXMLPresent_FlagsMissingFile(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
ds := newTestDatastoreWithDevice(t, account, device)
|
||||
|
||||
if ds.HasConfiguredSources(account, device) {
|
||||
t.Fatalf("precondition: device should not have Sources.xml yet")
|
||||
}
|
||||
|
||||
r := NewRegistry()
|
||||
RegisterSourcesXMLPresent(r, ds)
|
||||
|
||||
results := r.RunAll()
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 check result, got %d", len(results))
|
||||
}
|
||||
|
||||
check := results[0]
|
||||
if check.ID != CheckIDSourcesXMLPresent {
|
||||
t.Errorf("unexpected check id %q", check.ID)
|
||||
}
|
||||
|
||||
if check.Severity != SeverityWarning {
|
||||
t.Errorf("expected warning severity, got %q", check.Severity)
|
||||
}
|
||||
|
||||
if len(check.Findings) != 1 {
|
||||
t.Fatalf("expected 1 finding, got %d", len(check.Findings))
|
||||
}
|
||||
|
||||
finding := check.Findings[0]
|
||||
if finding.Target.Account != account || finding.Target.Device != device {
|
||||
t.Errorf("finding target %+v doesn't match device", finding.Target)
|
||||
}
|
||||
|
||||
if len(finding.QuickFixes) != 1 || finding.QuickFixes[0].ID != FixIDCreateDefaultSources {
|
||||
t.Errorf("expected create_default_sources quick fix, got %+v", finding.QuickFixes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesXMLPresent_QuickFix_MaterialisesDefaults(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
ds := newTestDatastoreWithDevice(t, account, device)
|
||||
|
||||
r := NewRegistry()
|
||||
RegisterSourcesXMLPresent(r, ds)
|
||||
|
||||
msg, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{
|
||||
Account: account,
|
||||
Device: device,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunFix: %v", err)
|
||||
}
|
||||
|
||||
if msg == "" {
|
||||
t.Errorf("expected non-empty success message")
|
||||
}
|
||||
|
||||
if !ds.HasConfiguredSources(account, device) {
|
||||
t.Fatalf("Sources.xml was not materialised by the fix")
|
||||
}
|
||||
|
||||
// The defaults should include TUNEIN — that's the load-bearing
|
||||
// reason for this check.
|
||||
sources, err := ds.GetConfiguredSources(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources: %v", err)
|
||||
}
|
||||
|
||||
var sawTuneIn bool
|
||||
for i := range sources {
|
||||
if sources[i].SourceKeyType == "TUNEIN" {
|
||||
sawTuneIn = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !sawTuneIn {
|
||||
t.Errorf("expected TUNEIN among default sources, got %d entries without it", len(sources))
|
||||
}
|
||||
|
||||
// Re-running the check should now report a clean state.
|
||||
results := r.RunAll()
|
||||
if results[0].Severity != SeverityOK {
|
||||
t.Errorf("expected OK after fix, got %q", results[0].Severity)
|
||||
}
|
||||
|
||||
if len(results[0].Findings) != 0 {
|
||||
t.Errorf("expected no findings after fix, got %d", len(results[0].Findings))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesXMLPresent_NilDatastore(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
RegisterSourcesXMLPresent(r, nil)
|
||||
|
||||
results := r.RunAll()
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
|
||||
if results[0].Severity != SeverityOK {
|
||||
t.Errorf("nil datastore should produce no findings, got %q", results[0].Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesXMLPresent_FixRejectsEmptyTarget(t *testing.T) {
|
||||
ds := newTestDatastoreWithDevice(t, "1000001", "DEVICEID01")
|
||||
|
||||
r := NewRegistry()
|
||||
RegisterSourcesXMLPresent(r, ds)
|
||||
|
||||
if _, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{}); err == nil {
|
||||
t.Errorf("expected error for empty target, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// suggestAccountForPairing picks an account ID to pre-fill into the
|
||||
// "Complete pairing" QuickFix's Target.Account. Returns the first
|
||||
// real-looking (7-digit) account directory that already contains the
|
||||
// device's deviceID on disk — typical scenario: AfterTouch and the
|
||||
// speaker were partially paired earlier, the speaker forgot but our
|
||||
// datastore remembers. Returns "" when no such account exists, in
|
||||
// which case the executor generates a fresh ID at click time.
|
||||
func suggestAccountForPairing(ds *datastore.DataStore, deviceID string) string {
|
||||
if ds == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, acc := range ds.AllAccountsForDevice(deviceID) {
|
||||
if isSevenDigitAccountID(acc) {
|
||||
return acc
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isSevenDigitAccountID mirrors setup.IsValidAccountID without
|
||||
// importing the setup package (which would pull in SSH/telnet/certmgr
|
||||
// transitively — see the boundary comment near speakerInfoXML).
|
||||
func isSevenDigitAccountID(s string) bool {
|
||||
if len(s) != 7 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, ch := range s {
|
||||
if ch < '0' || ch > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// buildEmptyMargeFinding constructs the "Speaker reports an empty
|
||||
// <margeAccountUUID>" finding with a QuickFix that completes pairing
|
||||
// in-place plus a ManualCommand fallback the operator can run from a
|
||||
// shell on the speaker's LAN. The executor is registered separately by
|
||||
// the caller that has setup.Manager access.
|
||||
//
|
||||
// Target.Account intentionally carries the *pair-with* account
|
||||
// (whichever we picked from disk), not the current binding — that's
|
||||
// what the fix executor needs at click time, and the framework passes
|
||||
// Finding.Target through to the FixFunc verbatim. The UI grouping
|
||||
// follows Target.Account too; that's a minor side effect we accept.
|
||||
func buildEmptyMargeFinding(ds *datastore.DataStore, _ Target, deviceID, ipAddress string) Finding {
|
||||
suggested := suggestAccountForPairing(ds, deviceID)
|
||||
|
||||
confirm := "Pair this speaker with a Marge account so playback selection stops failing with INVALID_SOURCE? "
|
||||
|
||||
if suggested != "" {
|
||||
confirm += "AfterTouch will reuse the existing account " + suggested + " (already on disk for this device)."
|
||||
} else {
|
||||
confirm += "AfterTouch will generate a fresh 7-digit account ID for this speaker (private deployment — the ID is opaque)."
|
||||
}
|
||||
|
||||
cliAccount := suggested
|
||||
if cliAccount == "" {
|
||||
cliAccount = "<7-digit-account-id>"
|
||||
}
|
||||
|
||||
return Finding{
|
||||
Severity: SeverityWarning,
|
||||
Target: Target{Account: suggested, Device: deviceID},
|
||||
Message: "Speaker reports an empty <margeAccountUUID>.",
|
||||
Details: "The speaker is reachable but isn't bound to any Marge account. Playback selection will fail with INVALID_SOURCE until pairing completes. See discussion #223 and issue #329.",
|
||||
QuickFixes: []QuickFix{{
|
||||
ID: FixIDCompleteSpeakerPairing,
|
||||
Label: "Complete pairing",
|
||||
Confirm: confirm,
|
||||
}},
|
||||
ManualCommands: []ManualCommand{{
|
||||
Label: "Or pair from a host on the speaker's LAN:",
|
||||
Command: fmt.Sprintf("soundtouch-cli setup pair --host=%s --mode=bare --account=%s", ipAddress, cliAccount),
|
||||
Hint: "The --mode=bare path sends just setMargeAccount without the full state machine; sufficient on FW 27.0.6.",
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// CheckIDSpeakerInfoReachable is the registry id of the speaker
|
||||
// reachability check.
|
||||
const CheckIDSpeakerInfoReachable = "speaker_info_reachable"
|
||||
|
||||
// FixIDCompleteSpeakerPairing is the QuickFix that completes pairing
|
||||
// on a speaker that reports an empty <margeAccountUUID> — i.e. the
|
||||
// speaker is reachable and configured to point at AfterTouch but
|
||||
// hasn't been bound to a Marge account, so every playback selection
|
||||
// fails with INVALID_SOURCE (see #329, discussion #223).
|
||||
//
|
||||
// The executor lives in pkg/service/handlers/server.go (where
|
||||
// setup.Manager is available); the constant is defined here so the
|
||||
// check that emits the finding stays self-contained.
|
||||
const FixIDCompleteSpeakerPairing = "complete_speaker_pairing"
|
||||
|
||||
// speakerInfoXML mirrors only the fields we need from the
|
||||
// speaker's :8090/info XML response. Duplicated here (rather than
|
||||
// imported from pkg/service/setup) to keep the health package
|
||||
// free of cross-package dependencies that would pull in SSH,
|
||||
// telnet, certmgr, etc.
|
||||
type speakerInfoXML struct {
|
||||
XMLName xml.Name `xml:"info"`
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
Name string `xml:"name"`
|
||||
MargeAccountUUID string `xml:"margeAccountUUID"`
|
||||
MargeURL string `xml:"margeURL"`
|
||||
}
|
||||
|
||||
// RegisterSpeakerInfoReachable registers the speaker_info_reachable
|
||||
// check against r. The check iterates every known device in the
|
||||
// datastore, probes its :8090/info endpoint, and emits findings
|
||||
// for unreachable speakers and speakers paired with an empty
|
||||
// margeAccountUUID (a known TPDA failure mode — see
|
||||
// discussion #223).
|
||||
func RegisterSpeakerInfoReachable(r *Registry, ds *datastore.DataStore) {
|
||||
r.Register(Check{
|
||||
ID: CheckIDSpeakerInfoReachable,
|
||||
Title: "Speakers respond on :8090/info",
|
||||
Run: func() []Finding {
|
||||
return runSpeakerInfoReachable(ds)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runSpeakerInfoReachable(ds *datastore.DataStore) []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
|
||||
}
|
||||
|
||||
findings = append(findings, probeAndAssessSpeaker(ds, dev.AccountID, dev.DeviceID, dev.IPAddress)...)
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
func probeAndAssessSpeaker(ds *datastore.DataStore, account, deviceID, ipAddress string) []Finding {
|
||||
probeURL := fmt.Sprintf("http://%s:8090/info", ipAddress)
|
||||
return probeAndAssessSpeakerWithURL(ds, account, deviceID, ipAddress, probeURL)
|
||||
}
|
||||
|
||||
// probeAndAssessSpeakerWithURL is the same as probeAndAssessSpeaker
|
||||
// but takes the full URL directly. Used by tests that need to point
|
||||
// at an httptest.Server, since those bind to random ports rather
|
||||
// than :8090. ds and ipAddress feed the QuickFix attached to the
|
||||
// empty-margeAccountUUID finding (suggestion for an existing
|
||||
// account-on-disk, and the CLI ManualCommand fallback).
|
||||
func probeAndAssessSpeakerWithURL(ds *datastore.DataStore, account, deviceID, ipAddress, probeURL string) []Finding {
|
||||
target := Target{Account: account, Device: deviceID}
|
||||
|
||||
res := ProbeGet(context.Background(), probeURL, 2*time.Second)
|
||||
|
||||
if !res.Reachable {
|
||||
return []Finding{{
|
||||
Severity: SeverityWarning,
|
||||
Target: target,
|
||||
Message: "Speaker /info is not reachable from this host.",
|
||||
Details: "If AfterTouch is hosted off the speaker's LAN (e.g. behind a reverse proxy or in a cloud), the service can't reach the speaker directly. Run the command below from a host that can.",
|
||||
ManualCommands: []ManualCommand{{
|
||||
Label: "Fetch /info from your network:",
|
||||
Command: res.CurlCommand,
|
||||
Hint: "Paste the response into a bug report or compare margeAccountUUID/margeURL with what AfterTouch expects.",
|
||||
}},
|
||||
}}
|
||||
}
|
||||
|
||||
if res.Status != 200 {
|
||||
return []Finding{{
|
||||
Severity: SeverityWarning,
|
||||
Target: target,
|
||||
Message: fmt.Sprintf("Speaker returned HTTP %d for /info.", res.Status),
|
||||
Details: "Expected 200. Either the speaker is in a transient state or the IP belongs to a different device now.",
|
||||
}}
|
||||
}
|
||||
|
||||
var parsed speakerInfoXML
|
||||
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
|
||||
return []Finding{{
|
||||
Severity: SeverityWarning,
|
||||
Target: target,
|
||||
Message: "Speaker replied but the /info body is not valid XML.",
|
||||
Details: "Parse error: " + err.Error(),
|
||||
}}
|
||||
}
|
||||
|
||||
var out []Finding
|
||||
|
||||
if parsed.MargeAccountUUID == "" {
|
||||
out = append(out, buildEmptyMargeFinding(ds, target, deviceID, ipAddress))
|
||||
}
|
||||
|
||||
if parsed.MargeURL == "" {
|
||||
out = append(out, Finding{
|
||||
Severity: SeverityInfo,
|
||||
Target: target,
|
||||
Message: "Speaker reports an empty <margeURL>.",
|
||||
Details: "The speaker hasn't been told where the cloud lives. This usually clears up after the first successful /info request from the service.",
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||