Compare commits
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -747,6 +747,22 @@ func getDomains(serverURL, httpsServerURL, hostname string, extraHosts []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 {
|
||||
@@ -810,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) {
|
||||
@@ -954,6 +1005,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Get("/v1/navigate", server.HandleTuneInNavigate)
|
||||
r.Get("/v1/navigate/*", server.HandleTuneInNavigate)
|
||||
r.Get("/v1/search", server.HandleTuneInSearch)
|
||||
r.Get("/v1/search/next", server.HandleTuneInSearchNext)
|
||||
r.Post("/v1/favorite/{stationID}", server.HandleTuneInFavorite)
|
||||
r.Delete("/v1/favorite/{stationID}", server.HandleTuneInDeleteFavorite)
|
||||
})
|
||||
@@ -1304,7 +1356,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
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ GET /bmx/tunein/v1/playback/episode/{podcastID} handlers.(
|
||||
GET /bmx/tunein/v1/playback/episodes/{podcastID} handlers.(*Server).HandleTuneInPodcastInfo-fm
|
||||
GET /bmx/tunein/v1/playback/station/{stationID} handlers.(*Server).HandleTuneInPlayback-fm
|
||||
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
|
||||
GET /bmx/tunein/v1/search/next handlers.(*Server).HandleTuneInSearchNext-fm
|
||||
GET /ced/* handlers.(*Server).HandleCedStatic
|
||||
GET /core02/svc-bmx-adapter-orion/prod/orion/station handlers.(*Server).HandleOrionPlayback-fm
|
||||
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -53,8 +53,10 @@
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -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: 147 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 518 KiB After Width: | Height: | Size: 515 KiB |
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 432 KiB |
|
Before Width: | Height: | Size: 97 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.91.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.91.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ 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
|
||||
)
|
||||
|
||||
@@ -31,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
|
||||
)
|
||||
|
||||
@@ -70,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=
|
||||
@@ -95,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=
|
||||
@@ -120,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=
|
||||
|
||||
@@ -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.
|
||||
@@ -183,6 +253,13 @@ func (d *DNSDiscovery) shouldIntercept(hostname string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
lower := strings.ToLower(hostname)
|
||||
for _, h := range d.derivedHosts {
|
||||
if lower == h {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,6 +36,7 @@ type Links struct {
|
||||
BmxSearch *Link `json:"bmx_search,omitempty" xml:"-"`
|
||||
BmxPlayback *Link `json:"bmx_playback,omitempty" xml:"-"`
|
||||
BmxPreset *Link `json:"bmx_preset,omitempty" xml:"-"`
|
||||
BmxNext *Link `json:"bmx_next,omitempty" xml:"-"`
|
||||
}
|
||||
|
||||
// BmxNavItem represents a single item in a TuneIn browse or search result.
|
||||
|
||||
@@ -361,6 +361,24 @@ func tuneInSearchSection(item map[string]interface{}, idx int, query, layout str
|
||||
}
|
||||
}
|
||||
|
||||
// Pivots.More.Url is the "load more" cursor from the TuneIn profiles API.
|
||||
// It is only present when there are more results beyond the first page.
|
||||
if pivots, ok := item["Pivots"].(map[string]interface{}); ok {
|
||||
if more, ok := pivots["More"].(map[string]interface{}); ok {
|
||||
if containerURL, _ := more["Url"].(string); strings.Contains(containerURL, "itemToken") {
|
||||
if u, err := url.Parse(containerURL); err == nil && allowedTuneInHosts[u.Hostname()] {
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(containerURL))
|
||||
|
||||
if section.Links == nil {
|
||||
section.Links = &models.Links{}
|
||||
}
|
||||
|
||||
section.Links.BmxNext = &models.Link{Href: "/v1/search/next?cursor=" + encoded}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, child := range children {
|
||||
cm, ok := child.(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -386,6 +404,58 @@ func tuneInSearchSection(item map[string]interface{}, idx int, query, layout str
|
||||
return section
|
||||
}
|
||||
|
||||
// TuneInSearchNext fetches the remaining results for a section using the opaque
|
||||
// cursor produced by TuneInSearch. The cursor URL returns a flat Items[] list
|
||||
// (not nested containers), so we parse items directly rather than via
|
||||
// tuneInSearchSection. TuneIn typically returns all remaining results in one
|
||||
// shot; Paging is empty and no further cursor is generated.
|
||||
func TuneInSearchNext(encodedCursor string) (*models.BmxNavResponse, error) {
|
||||
cursorBytes, err := base64.RawURLEncoding.DecodeString(encodedCursor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid cursor: %w", err)
|
||||
}
|
||||
|
||||
cursorURL := string(cursorBytes)
|
||||
|
||||
u, err := url.Parse(cursorURL)
|
||||
if err != nil || !allowedTuneInHosts[u.Hostname()] {
|
||||
return nil, fmt.Errorf("cursor URL not allowed")
|
||||
}
|
||||
|
||||
data, err := fetchJSON(cursorURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawItems, ok := data["Items"].([]interface{})
|
||||
if !ok {
|
||||
rawItems, _ = data["body"].([]interface{})
|
||||
}
|
||||
|
||||
navItems := make([]models.BmxNavItem, 0, len(rawItems))
|
||||
for _, raw := range rawItems {
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
typeStr, _ := m["Type"].(string)
|
||||
switch typeStr {
|
||||
case "Station", "PlayItem", "Topic":
|
||||
navItems = append(navItems, tuneInSearchPlayItem(m))
|
||||
case "Program", "Profile":
|
||||
navItems = append(navItems, tuneInSearchProfile(m, ""))
|
||||
}
|
||||
}
|
||||
|
||||
return &models.BmxNavResponse{
|
||||
Layout: "classic",
|
||||
BmxSections: []models.BmxNavSection{
|
||||
{Items: navItems, Layout: "grid"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func tuneInSearchPlayItem(item map[string]interface{}) models.BmxNavItem {
|
||||
name, _ := item["Title"].(string)
|
||||
if name == "" {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetPresets_CapitalCContentItem checks that Presets.xml files written by
|
||||
// older AfterTouch versions (or verbatim speaker XML) using <ContentItem>
|
||||
// (capital C) are parsed correctly. encoding/xml is case-sensitive, so without
|
||||
// the normalisation in GetPresets the source and location fields would be empty
|
||||
// and /full would silently skip all presets for the device (i218 diagnostic).
|
||||
func TestGetPresets_CapitalCContentItem(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "datastore-capital-c-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
account := "7961999"
|
||||
device := "304511B46CBC"
|
||||
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
// Verbatim format from the i218 diagnostic: capital-C ContentItem, no
|
||||
// <sourceid> child element, only the first preset has createdOn/updatedOn.
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="1778969808" updatedOn="1778969808">
|
||||
<ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="http://192.168.1.11/OPB.json" sourceAccount="" isPresetable="true">
|
||||
<itemName>Internet Radio</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="2">
|
||||
<ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="http://192.168.1.11/AllClassicalPortland.json" sourceAccount="" isPresetable="true">
|
||||
<itemName>Internet Radio</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="3">
|
||||
<ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="http://192.168.1.11/AncientFM.json" sourceAccount="" isPresetable="true">
|
||||
<itemName>Internet Radio</itemName>
|
||||
</ContentItem>
|
||||
</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) != 3 {
|
||||
t.Fatalf("expected 3 presets, got %d (capital-C ContentItem not parsed)", len(presets))
|
||||
}
|
||||
|
||||
for i, p := range presets {
|
||||
if p.Source != "LOCAL_INTERNET_RADIO" {
|
||||
t.Errorf("preset %d: expected Source=LOCAL_INTERNET_RADIO, got %q", i+1, p.Source)
|
||||
}
|
||||
if p.Location == "" {
|
||||
t.Errorf("preset %d: Location is empty — ContentItem attributes not parsed", i+1)
|
||||
}
|
||||
if p.Name != "Internet Radio" {
|
||||
t.Errorf("preset %d: expected Name=Internet Radio, got %q", i+1, p.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if presets[0].CreatedOn != "1778969808" {
|
||||
t.Errorf("preset 1: expected CreatedOn=1778969808, got %q", presets[0].CreatedOn)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetPresets_CapitalCRewritesFile checks that after GetPresets detects the
|
||||
// legacy <ContentItem> format it rewrites Presets.xml in canonical lowercase
|
||||
// form, so subsequent reads are clean without needing the compat shim.
|
||||
func TestGetPresets_CapitalCRewritesFile(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "datastore-capital-c-rewrite-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
account := "7961999"
|
||||
device := "304511B46CBC"
|
||||
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
presetsPath := filepath.Join(deviceDir, "Presets.xml")
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="1778969808" updatedOn="1778969808">
|
||||
<ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="http://192.168.1.11/OPB.json" sourceAccount="" isPresetable="true">
|
||||
<itemName>Internet Radio</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
if err := os.WriteFile(presetsPath, []byte(presetsXML), 0644); err != nil {
|
||||
t.Fatalf("write Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
if _, err := ds.GetPresets(account, device); err != nil {
|
||||
t.Fatalf("GetPresets: %v", err)
|
||||
}
|
||||
|
||||
rewritten, err := os.ReadFile(presetsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read rewritten Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(string(rewritten), "<ContentItem") {
|
||||
t.Errorf("Presets.xml still contains <ContentItem> after auto-rewrite:\n%s", string(rewritten))
|
||||
}
|
||||
|
||||
if !strings.Contains(string(rewritten), "<contentItem") {
|
||||
t.Errorf("Presets.xml missing canonical <contentItem> after auto-rewrite:\n%s", string(rewritten))
|
||||
}
|
||||
}
|
||||
@@ -928,6 +928,26 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
|
||||
|
||||
// GetPresets retrieves all presets for the specified account and device.
|
||||
func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, error) {
|
||||
presets, needsRewrite, err := ds.readPresetsLocked(account, device)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if needsRewrite {
|
||||
log.Printf("[Datastore] Presets.xml for device %s used legacy <ContentItem> format; rewriting in canonical form", device)
|
||||
|
||||
if werr := ds.SavePresets(account, device, presets); werr != nil {
|
||||
log.Printf("[Datastore] failed to rewrite normalised Presets.xml for device %s: %v", device, werr)
|
||||
}
|
||||
}
|
||||
|
||||
return presets, nil
|
||||
}
|
||||
|
||||
// readPresetsLocked is the locked read half of GetPresets. It returns the
|
||||
// parsed presets and a flag indicating whether the on-disk file used the
|
||||
// legacy <ContentItem> (capital C) format that needs rewriting.
|
||||
func (ds *DataStore) readPresetsLocked(account, device string) ([]models.ServicePreset, bool, error) {
|
||||
ds.fileMutex.RLock()
|
||||
defer ds.fileMutex.RUnlock()
|
||||
|
||||
@@ -936,10 +956,10 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []models.ServicePreset{}, nil
|
||||
return []models.ServicePreset{}, false, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
var presetsWrap struct {
|
||||
@@ -960,8 +980,16 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
|
||||
} `xml:"preset"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(data, &presetsWrap); err != nil {
|
||||
return nil, fmt.Errorf("malformed presets XML at %s: %w", path, err)
|
||||
// encoding/xml is case-sensitive. Older AfterTouch versions (and raw
|
||||
// speaker XML) used <ContentItem> (capital C); normalise to lowercase
|
||||
// before unmarshaling so legacy files are parsed correctly.
|
||||
normalized := bytes.ReplaceAll(data, []byte("<ContentItem"), []byte("<contentItem"))
|
||||
normalized = bytes.ReplaceAll(normalized, []byte("</ContentItem>"), []byte("</contentItem>"))
|
||||
|
||||
needsRewrite := !bytes.Equal(normalized, data)
|
||||
|
||||
if err := xml.Unmarshal(normalized, &presetsWrap); err != nil {
|
||||
return nil, false, fmt.Errorf("malformed presets XML at %s: %w", path, err)
|
||||
}
|
||||
|
||||
presets := []models.ServicePreset{}
|
||||
@@ -988,7 +1016,7 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
|
||||
})
|
||||
}
|
||||
|
||||
return presets, nil
|
||||
return presets, needsRewrite, nil
|
||||
}
|
||||
|
||||
// repairLeakedSource quietly substitutes the speaker-perspective
|
||||
@@ -2346,6 +2374,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -290,6 +290,33 @@ func (s *Server) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInSearchNext returns the next page of TuneIn search results using
|
||||
// an opaque cursor produced by HandleTuneInSearch.
|
||||
func (s *Server) HandleTuneInSearchNext(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
log.Printf("[BMX] Authorization missing (gate temporarily disabled, see handlers_bmx.go); path=%q ua=%q",
|
||||
r.URL.Path, r.UserAgent())
|
||||
}
|
||||
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
if cursor == "" {
|
||||
http.Error(w, "cursor parameter required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := bmx.TuneInSearchNext(cursor)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(resp); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInFavorite handles POST /bmx/tunein/v1/favorite/{stationID}.
|
||||
func (s *Server) HandleTuneInFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -133,6 +133,15 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
|
||||
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
|
||||
@@ -145,6 +154,15 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
|
||||
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,
|
||||
@@ -188,6 +206,25 @@ func (s *Server) ExpectedHosts() []string {
|
||||
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
|
||||
@@ -470,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)
|
||||
|
||||
@@ -165,11 +165,58 @@
|
||||
<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">
|
||||
<label style="display: block; margin-bottom: 5px">
|
||||
<input type="checkbox" id="discovery-enabled"/> Enable Automated Discovery
|
||||
<input type="checkbox" id="discovery-enabled"/> Enable Periodic Discovery
|
||||
</label>
|
||||
<div style="margin-left: 20px">
|
||||
<label for="discovery-interval">Discovery Interval:</label>
|
||||
@@ -1501,18 +1548,36 @@
|
||||
<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>
|
||||
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||
<button onclick="fetchHealth()">Refresh</button>
|
||||
<button onclick="downloadDiagnostic()" title="Download an encrypted diagnostic report to share with the project maintainer">Download diagnostic report</button>
|
||||
</div>
|
||||
<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-diagnostic-status" style="font-size: 0.85em; margin-bottom: 8px;"></div>
|
||||
<div id="health-findings">Loading…</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 || "";
|
||||
@@ -321,6 +382,7 @@ async function fetchSettings() {
|
||||
|
||||
fetchLoggingSettings();
|
||||
fetchSpotifyStatus();
|
||||
return settings;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch settings", error);
|
||||
}
|
||||
@@ -372,6 +434,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...";
|
||||
@@ -1599,8 +1666,10 @@ function formatXML(xml) {
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
fetchSettings();
|
||||
triggerDiscovery();
|
||||
const cfg = await fetchSettings();
|
||||
if (cfg?.discovery_enabled !== false) {
|
||||
triggerDiscovery();
|
||||
}
|
||||
fetchVersion();
|
||||
await fetchDevices();
|
||||
|
||||
@@ -3985,10 +4054,12 @@ async function applyCustomPlan() {
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
fetchDevices();
|
||||
fetchSettings();
|
||||
triggerDiscovery();
|
||||
const cfg = await fetchSettings();
|
||||
if (cfg?.discovery_enabled !== false) {
|
||||
triggerDiscovery();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -4038,7 +4109,16 @@ async function downloadDiagnostic() {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
if (statusEl) statusEl.textContent = `Downloaded: ${filename}`;
|
||||
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}`;
|
||||
}
|
||||
@@ -4191,13 +4271,10 @@ function renderManualCommand(cmd) {
|
||||
copyBtn.textContent = "Copy";
|
||||
copyBtn.style.alignSelf = "flex-start";
|
||||
copyBtn.onclick = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(cmd.command);
|
||||
const orig = copyBtn.textContent;
|
||||
copyBtn.textContent = "Copied";
|
||||
setTimeout(() => { copyBtn.textContent = orig; }, 1200);
|
||||
} catch (e) {
|
||||
copyBtn.textContent = "Copy failed";
|
||||
const ok = await copyTextToClipboard(cmd.command);
|
||||
copyBtn.textContent = ok ? "Copied" : "Copy failed";
|
||||
if (ok) {
|
||||
setTimeout(() => { copyBtn.textContent = "Copy"; }, 1200);
|
||||
}
|
||||
};
|
||||
row.appendChild(copyBtn);
|
||||
@@ -4623,13 +4700,10 @@ function unreachableBlock(probe) {
|
||||
const btn = document.createElement("button");
|
||||
btn.textContent = "Copy";
|
||||
btn.onclick = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(probe.curl_command);
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = "Copied";
|
||||
setTimeout(() => { btn.textContent = orig; }, 1200);
|
||||
} catch (e) {
|
||||
btn.textContent = "Copy failed";
|
||||
const ok = await copyTextToClipboard(probe.curl_command);
|
||||
btn.textContent = ok ? "Copied" : "Copy failed";
|
||||
if (ok) {
|
||||
setTimeout(() => { btn.textContent = "Copy"; }, 1200);
|
||||
}
|
||||
};
|
||||
row.appendChild(btn);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,15 @@ import (
|
||||
"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"
|
||||
@@ -112,11 +121,16 @@ func assessMargeURLForDeviceWithURL(account, deviceID, probeURL string, expected
|
||||
parsed.MargeURL,
|
||||
),
|
||||
Details: fmt.Sprintf(
|
||||
"Configured hosts: %s. If the speaker should reach this service via %q, restart with `--tls-extra-host=%s` so the served TLS cert covers it. Otherwise, re-migrate the speaker to the correct URL.",
|
||||
"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: "Add the speaker's expected hostname to AfterTouch's TLS cert:",
|
||||
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.",
|
||||
}},
|
||||
|
||||
@@ -64,6 +64,18 @@ func TestMargeURL_FlagsMismatch(t *testing.T) {
|
||||
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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,50 @@ func TestIsTelnetMigrated_EmptyVerifiedConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsXMLMigrated_MalformedServiceURL guards against the false-positive that
|
||||
// occurs when --service-url is a malformed URL with an empty hostname (e.g.
|
||||
// "https:/host" instead of "https://host"). url.Parse succeeds but Hostname()
|
||||
// returns "", and strings.Contains(anything, "") is always true in Go.
|
||||
func TestIsXMLMigrated_MalformedServiceURL(t *testing.T) {
|
||||
m := &Manager{ServerURL: "https:/soundtouch.fritz.box"} // single slash — malformed
|
||||
|
||||
summary := &MigrationSummary{
|
||||
ParsedCurrentConfig: &PrivateCfg{
|
||||
MargeServerUrl: "https://streaming.bose.com",
|
||||
},
|
||||
}
|
||||
|
||||
if m.isXMLMigrated(summary) {
|
||||
t.Error("isXMLMigrated = true, want false when ServerURL has no resolvable hostname")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckIsMigrated_MalformedServiceURL ensures a malformed --service-url
|
||||
// does not produce IsMigrated=true on an unmigrated speaker.
|
||||
func TestCheckIsMigrated_MalformedServiceURL(t *testing.T) {
|
||||
m := &Manager{
|
||||
ServerURL: "https:/soundtouch.fritz.box", // single slash — malformed
|
||||
NewSSH: func(string) SSHClient {
|
||||
return &mockSSH{runFunc: func(string) (string, error) { return "", errors.New("unused") }}
|
||||
},
|
||||
}
|
||||
|
||||
summary := &MigrationSummary{
|
||||
SSHSuccess: true,
|
||||
TelnetVerifiedConfig: "", // empty — not migrated via telnet either
|
||||
ParsedCurrentConfig: &PrivateCfg{
|
||||
MargeServerUrl: "https://streaming.bose.com",
|
||||
},
|
||||
CurrentResolvConf: "nameserver 8.8.8.8\n",
|
||||
}
|
||||
|
||||
m.checkIsMigrated(summary, "192.0.2.1")
|
||||
|
||||
if summary.IsMigrated {
|
||||
t.Error("IsMigrated = true, want false when service URL is malformed and speaker still points at streaming.bose.com")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckIsMigrated_TelnetOnlyMigratedDevice covers the gap that motivated
|
||||
// this iteration: SSH is unreachable, but the speaker has been pointed at
|
||||
// our service via telnet (e.g. a firmware that refuses USB unlock). The
|
||||
|
||||
@@ -543,6 +543,9 @@ func (m *Manager) isXMLMigrated(summary *MigrationSummary) bool {
|
||||
}
|
||||
|
||||
targetHost := parsedTarget.Hostname()
|
||||
if targetHost == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.Contains(summary.ParsedCurrentConfig.MargeServerUrl, targetHost) ||
|
||||
strings.Contains(summary.ParsedCurrentConfig.StatsServerUrl, targetHost) ||
|
||||
@@ -595,6 +598,10 @@ func (m *Manager) isResolvConfMigrated(client SSHClient, summary *MigrationSumma
|
||||
}
|
||||
|
||||
targetHost := parsedTarget.Hostname()
|
||||
if targetHost == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.Contains(summary.CurrentResolvConf, targetHost) && summary.CACertTrusted {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -638,6 +638,27 @@ func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInSearchNext returns the next page of TuneIn search results using an opaque cursor.
|
||||
func (app *WebApp) HandleTuneInSearchNext(w http.ResponseWriter, r *http.Request) {
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
if cursor == "" {
|
||||
app.sendError(w, "cursor parameter required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := bmxpkg.TuneInSearchNext(cursor)
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInNavigate handles TuneIn browse/navigate requests, proxying directly to the bmx package.
|
||||
// Supported path suffixes (relative to /api/tunein/navigate):
|
||||
// - (empty) → top-level browse
|
||||
@@ -1010,13 +1031,17 @@ func (app *WebApp) HandleDevicePlay(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
contentItem := &models.ContentItem{
|
||||
Source: req.Source,
|
||||
Type: req.Type,
|
||||
Location: req.Location,
|
||||
SourceAccount: req.SourceAccount,
|
||||
ItemName: req.ItemName,
|
||||
ContainerArt: req.ContainerArt,
|
||||
IsPresetable: req.IsPresetable,
|
||||
Source: req.Source,
|
||||
Type: req.Type,
|
||||
Location: req.Location,
|
||||
ItemName: req.ItemName,
|
||||
ContainerArt: req.ContainerArt,
|
||||
IsPresetable: req.IsPresetable,
|
||||
}
|
||||
// Only pass SourceAccount when it's a real credential, not the placeholder
|
||||
// value that speakers echo back (source name == source account, e.g. "TUNEIN").
|
||||
if req.SourceAccount != "" && req.SourceAccount != req.Source {
|
||||
contentItem.SourceAccount = req.SourceAccount
|
||||
}
|
||||
|
||||
if err := device.Client.SelectContentItem(contentItem); err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ package soundtouchweb
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -622,3 +623,84 @@ func BenchmarkSendError(b *testing.B) {
|
||||
app.sendError(w, "Test error", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleDevicePlay_SourceAccountFiltering verifies that a SourceAccount
|
||||
// equal to Source (the placeholder speakers echo back, e.g. "TUNEIN") is
|
||||
// stripped before the ContentItem XML is sent to the speaker, while a real
|
||||
// credential (SourceAccount != Source) is preserved.
|
||||
func TestHandleDevicePlay_SourceAccountFiltering(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
sourceAccount string
|
||||
wantSourceAccount string // empty means the XML attr must be absent
|
||||
}{
|
||||
{
|
||||
name: "placeholder echoed back — stripped",
|
||||
source: "TUNEIN",
|
||||
sourceAccount: "TUNEIN",
|
||||
wantSourceAccount: "",
|
||||
},
|
||||
{
|
||||
name: "real credential — preserved",
|
||||
source: "TUNEIN",
|
||||
sourceAccount: "real-account-id",
|
||||
wantSourceAccount: "real-account-id",
|
||||
},
|
||||
{
|
||||
name: "empty account — stays empty",
|
||||
source: "TUNEIN",
|
||||
sourceAccount: "",
|
||||
wantSourceAccount: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var capturedBody string
|
||||
|
||||
// Fake speaker that captures the /select POST body.
|
||||
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/select" {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
capturedBody = string(b)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer speaker.Close()
|
||||
|
||||
speakerClient := client.NewClient(&client.Config{Host: speaker.URL})
|
||||
|
||||
app := NewWebApp()
|
||||
deviceInfo := &models.DeviceInfo{Name: "Test Speaker"}
|
||||
conn := webtypes.NewDeviceConnection(speakerClient, deviceInfo)
|
||||
conn.SetStatus(&webtypes.DeviceStatus{IsConnected: true, LastActivity: time.Now()})
|
||||
app.AddDevice("play-device", conn)
|
||||
|
||||
body := strings.NewReader(`{
|
||||
"source":"` + tt.source + `",
|
||||
"type":"stationurl",
|
||||
"location":"/v1/playback/station/s6634",
|
||||
"sourceAccount":"` + tt.sourceAccount + `",
|
||||
"itemName":"Venice Classic Radio"
|
||||
}`)
|
||||
req := httptest.NewRequest("POST", "/api/device-play/play-device", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withChiParams(req, map[string]string{"id": "play-device"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleDevicePlay(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// SourceAccount XML attribute is always emitted (no omitempty on the struct
|
||||
// tag), so check its value rather than its presence/absence.
|
||||
want := `sourceAccount="` + tt.wantSourceAccount + `"`
|
||||
if !strings.Contains(capturedBody, want) {
|
||||
t.Errorf("XML should contain %q, got: %s", want, capturedBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov
|
||||
|
||||
// TuneIn browse, search, and playback
|
||||
r.Get("/api/tunein/search", app.HandleTuneInSearch)
|
||||
r.Get("/api/tunein/search/next", app.HandleTuneInSearchNext)
|
||||
r.Get("/api/tunein/navigate", app.HandleTuneInNavigate)
|
||||
r.Get("/api/tunein/navigate/*", app.HandleTuneInNavigate)
|
||||
r.Post("/api/tunein/play/{id}", app.HandlePlayTuneIn)
|
||||
|
||||
@@ -581,6 +581,8 @@ img { display: block; max-width: 100%; }
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.tunein-play-btn:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
|
||||
.tunein-section-name { font-size: .85rem; font-weight: 600; color: var(--text-dim); padding: 8px 0 2px; margin: 0; }
|
||||
.tunein-load-more { margin: 4px 0 12px; }
|
||||
|
||||
/* ── Device picker overlay ───────────────────────────────────────────────── */
|
||||
.overlay {
|
||||
|
||||
@@ -30,6 +30,7 @@ export const api = {
|
||||
}),
|
||||
tuneInBrowse: (path) => req(path ? `/api/tunein/navigate/${path}` : '/api/tunein/navigate'),
|
||||
tuneInSearch: (q) => req(`/api/tunein/search?q=${encodeURIComponent(q)}`),
|
||||
tuneInSearchNext: (cursor) => req(`/api/tunein/search/next?cursor=${encodeURIComponent(cursor)}`),
|
||||
control: (id, action, presetId) => req(`/api/control/${id}/${action}?id=${presetId}`),
|
||||
selectSource: (id, source, account) => req(`/api/control/${id}/source?name=${encodeURIComponent(source)}&account=${encodeURIComponent(account || '')}`),
|
||||
tuneInPlay: (deviceId, item) => req(`/api/tunein/play/${deviceId}`, {
|
||||
|
||||
@@ -5,9 +5,10 @@ import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
// BmxNavResponse has shape { bmx_sections: [{ name, items: [{ name, imageUrl, subtitle, _links }] }] }
|
||||
// BmxNavResponse has shape { bmx_sections: [{ name, items: [{ name, imageUrl, subtitle, _links }], _links }] }
|
||||
// _links.bmx_navigate.href = "/v1/navigate/{encodedPath}" — strip prefix for API call
|
||||
// _links.bmx_playback.href = station/track URL, type = "stationurl"|"tracklisturl"
|
||||
// _links.bmx_next.href = "/v1/search/next?cursor={base64}" — load-more cursor
|
||||
|
||||
function navPath(item) {
|
||||
const href = item._links?.bmx_navigate?.href;
|
||||
@@ -19,15 +20,23 @@ function playbackInfo(item) {
|
||||
return link ? { location: link.href, type: link.type || 'stationurl' } : null;
|
||||
}
|
||||
|
||||
function flattenSections(data) {
|
||||
function sectionCursor(section) {
|
||||
const href = section._links?.bmx_next?.href;
|
||||
if (!href) return null;
|
||||
return new URLSearchParams(href.split('?')[1] || '').get('cursor');
|
||||
}
|
||||
|
||||
function toSections(data) {
|
||||
if (!data?.bmx_sections) return [];
|
||||
return data.bmx_sections.flatMap(section =>
|
||||
(section.items || []).map(item => ({ ...item, _sectionName: section.name }))
|
||||
);
|
||||
return data.bmx_sections.map(s => ({
|
||||
name: s.name,
|
||||
items: s.items || [],
|
||||
nextCursor: sectionCursor(s),
|
||||
}));
|
||||
}
|
||||
|
||||
export function TuneInBrowser({ devices }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [sections, setSections] = useState([]);
|
||||
const [navStack, setNavStack] = useState([{ label: 'TuneIn', path: null }]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -39,7 +48,7 @@ export function TuneInBrowser({ devices }) {
|
||||
setLoading(true);
|
||||
const resp = await api.tuneInBrowse(path);
|
||||
setLoading(false);
|
||||
if (resp.success) setItems(flattenSections(resp.data));
|
||||
if (resp.success) setSections(toSections(resp.data));
|
||||
}
|
||||
|
||||
async function search(q) {
|
||||
@@ -49,10 +58,25 @@ export function TuneInBrowser({ devices }) {
|
||||
setLoading(false);
|
||||
if (resp.success) {
|
||||
setNavStack([{ label: 'TuneIn', path: null }, { label: `"${q}"`, path: null }]);
|
||||
setItems(flattenSections(resp.data));
|
||||
setSections(toSections(resp.data));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore(section) {
|
||||
setLoading(true);
|
||||
const resp = await api.tuneInSearchNext(section.nextCursor);
|
||||
setLoading(false);
|
||||
if (!resp.success) return;
|
||||
const next = toSections(resp.data);
|
||||
const newItems = next.flatMap(s => s.items);
|
||||
const newCursor = next[0]?.nextCursor || null;
|
||||
setSections(prev => prev.map(s =>
|
||||
s.name === section.name
|
||||
? { ...s, items: [...s.items, ...newItems], nextCursor: newCursor }
|
||||
: s
|
||||
));
|
||||
}
|
||||
|
||||
function navigate(item) {
|
||||
const path = navPath(item);
|
||||
const play = playbackInfo(item);
|
||||
@@ -111,32 +135,42 @@ export function TuneInBrowser({ devices }) {
|
||||
|
||||
${loading ? html`<div class="loading-bar"></div>` : null}
|
||||
|
||||
<ul class="tunein-list">
|
||||
${items.map((item, i) => {
|
||||
const isNav = !!navPath(item);
|
||||
const play = playbackInfo(item);
|
||||
return html`
|
||||
<li key=${item._links?.self?.href || i} class="tunein-item" onClick=${() => navigate(item)}>
|
||||
${item.imageUrl ? html`<img class="tunein-thumb" src=${item.imageUrl} alt="" />` : null}
|
||||
<div class="tunein-item-info">
|
||||
<span class="tunein-item-name">${item.name}</span>
|
||||
${item.subtitle ? html`<span class="tunein-item-desc">${item.subtitle}</span>` : null}
|
||||
</div>
|
||||
${play ? html`
|
||||
<button
|
||||
class="tunein-play-btn"
|
||||
title="Play"
|
||||
onClick=${(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingPlay({ ...play, name: item.name, image: item.imageUrl });
|
||||
}}
|
||||
>▶</button>
|
||||
` : null}
|
||||
${isNav ? html`<span class="tunein-item-arrow">›</span>` : null}
|
||||
</li>
|
||||
`;
|
||||
})}
|
||||
</ul>
|
||||
${sections.map(section => html`
|
||||
<div>
|
||||
${section.name ? html`<h4 class="tunein-section-name">${section.name}</h4>` : null}
|
||||
<ul class="tunein-list">
|
||||
${section.items.map((item, i) => {
|
||||
const isNav = !!navPath(item);
|
||||
const play = playbackInfo(item);
|
||||
return html`
|
||||
<li key=${item._links?.self?.href || i} class="tunein-item" onClick=${() => navigate(item)}>
|
||||
${item.imageUrl ? html`<img class="tunein-thumb" src=${item.imageUrl} alt="" />` : null}
|
||||
<div class="tunein-item-info">
|
||||
<span class="tunein-item-name">${item.name}</span>
|
||||
${item.subtitle ? html`<span class="tunein-item-desc">${item.subtitle}</span>` : null}
|
||||
</div>
|
||||
${play ? html`
|
||||
<button
|
||||
class="tunein-play-btn"
|
||||
title="Play"
|
||||
onClick=${(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingPlay({ ...play, name: item.name, image: item.imageUrl });
|
||||
}}
|
||||
>▶</button>
|
||||
` : null}
|
||||
${isNav ? html`<span class="tunein-item-arrow">›</span>` : null}
|
||||
</li>
|
||||
`;
|
||||
})}
|
||||
</ul>
|
||||
${section.nextCursor ? html`
|
||||
<button class="btn-secondary tunein-load-more" onClick=${() => loadMore(section)}>
|
||||
Load more
|
||||
</button>
|
||||
` : null}
|
||||
</div>
|
||||
`)}
|
||||
|
||||
${pendingPlay ? html`
|
||||
<div class="overlay" onClick=${() => setPendingPlay(null)}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
VERSION=${VERSION:-0.80.1}
|
||||
VERSION=${VERSION:-0.91.0}
|
||||
GH_REPO=${GH_REPO:-gesellix/Bose-SoundTouch}
|
||||
BINARY_URL=${BINARY_URL:-https://github.com/$GH_REPO/releases/download/v$VERSION/soundtouch-service-v$VERSION-linux-armv7}
|
||||
INIT_SCRIPT_URL=${INIT_SCRIPT_URL:-https://raw.githubusercontent.com/$GH_REPO/v$VERSION/scripts/on-device-install/aftertouch}
|
||||
|
||||
@@ -28,7 +28,7 @@ You can override defaults:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.80.1 \
|
||||
VERSION=v0.91.0 \
|
||||
HOSTNAME_FQDN=soundtouch.local \
|
||||
HTTP_PORT=80 \
|
||||
HTTPS_PORT=443 \
|
||||
|
||||
@@ -10,7 +10,7 @@ set -euo pipefail
|
||||
# Examples (override defaults via env vars):
|
||||
#
|
||||
# sudo \
|
||||
# VERSION=v0.80.0 \
|
||||
# VERSION=v0.91.0 \
|
||||
# HOSTNAME_FQDN=soundtouch.local \
|
||||
# HTTP_PORT=80 \
|
||||
# HTTPS_PORT=443 \
|
||||
@@ -18,7 +18,7 @@ set -euo pipefail
|
||||
# bash install.sh
|
||||
#
|
||||
# Or with a version argument to perform an update:
|
||||
# sudo bash install.sh v0.80.1
|
||||
# sudo bash install.sh v0.91.0
|
||||
#
|
||||
# Notes:
|
||||
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
|
||||
@@ -28,7 +28,7 @@ set -euo pipefail
|
||||
# - Safe to re-run; it will update binary/config/unit and restart the service.
|
||||
# ==============================================================================
|
||||
|
||||
VERSION="${1:-${VERSION:-v0.80.1}}"
|
||||
VERSION="${1:-${VERSION:-v0.91.0}}"
|
||||
# Normalize version prefix
|
||||
if [[ ! "$VERSION" =~ ^v ]]; then
|
||||
VERSION="v${VERSION}"
|
||||
@@ -117,7 +117,7 @@ detect_arch_asset() {
|
||||
download_url_for() {
|
||||
local asset="$1"
|
||||
# Release asset pattern used by you earlier:
|
||||
# soundtouch-service-v0.80.1-linux-armv7
|
||||
# soundtouch-service-v0.91.0-linux-armv7
|
||||
echo "https://github.com/gesellix/Bose-SoundTouch/releases/download/${VERSION}/soundtouch-service-${VERSION}-${asset}"
|
||||
}
|
||||
|
||||
|
||||