feat(dns): auto-derive OAuth subdomain from serverURL hostname (#337)

The speaker firmware constructs the OAuth host by appending "oauth" to
the first label of the configured streaming hostname (aftertouch.lan
→ aftertouchoauth.lan, used by both Spotify and Amazon Music token
refresh). AfterTouch's DNS server previously only hijacked the
hardcoded list of Bose hostnames, so operators self-hosting at a
custom hostname had to add the OAuth alias themselves — and the
amazon-music-oauth.md / spotify-overview.md docs incorrectly
claimed the DNS server handled it automatically.

ofthesun9 (#337) caught this via the worst variant: IP-based
serverURL (192.168.0.30 → 192oauth.168.0.30), which is a malformed
hostname no DNS resolver can answer for. There is no clean DNS
workaround for the IP case — the operator must use a hostname.

Three changes:

- pkg/discovery/dns.go DeriveOAuthHostnames parses the configured
  serverURL, derives <first-label>oauth.<rest> when the host is a
  hostname (not IP), and adds it to the DNSDiscovery hijack list. IP
  serverURLs deliberately yield no derivation — the malformed name
  isn't worth handling and the new health check surfaces the trap.
- New checks_oauth_target health check fires a Warning when serverURL
  is an IP literal, with a concrete example of the malformed name
  (`192oauth.168.0.30`) and a ManualCommand pointing at the switch.
- amazon-music-oauth.md and spotify-overview.md rewritten: drop the
  false "automatic" claim, document the three resolution paths
  (AfterTouch DNS + speaker resolves via it / external LAN DNS /
  per-speaker /etc/hosts), and explicitly flag IP-based --server-url
  as incompatible with OAuth on either provider.

Tests cover the derivation matrix (hostname / IPv4 / IPv6 / single
label / empty / garbage URL), shouldIntercept's new behaviour
(derived host hit, base host not auto-hijacked, case-insensitive),
the health check's four states, and the malformed-host helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-23 00:34:36 +02:00
co-authored by Claude Sonnet 4.6
parent 1421ad5ce1
commit 6861063935
8 changed files with 452 additions and 26 deletions
+12 -2
View File
@@ -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`
+16 -2
View File
@@ -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
+85 -8
View File
@@ -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
}
+130
View File
@@ -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 -13
View File
@@ -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)
+9 -1
View File
@@ -134,6 +134,14 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
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
@@ -499,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)
+111
View File
@@ -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)
}
}
}