diff --git a/docs/concepts/amazon-music-oauth.md b/docs/concepts/amazon-music-oauth.md index 1770072..9b7a60c 100644 --- a/docs/concepts/amazon-music-oauth.md +++ b/docs/concepts/amazon-music-oauth.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.` 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.` 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.` 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 `oauth.` 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 `oauth.` 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 ` oauth.`. Tedious; doesn't survive a factory reset. + +The implementation lives in `pkg/discovery/dns.go` `DeriveOAuthHostnames`. ### Open question: `site_id` diff --git a/docs/concepts/spotify-overview.md b/docs/concepts/spotify-overview.md index 3230fef..1f3b264 100644 --- a/docs/concepts/spotify-overview.md +++ b/docs/concepts/spotify-overview.md @@ -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 diff --git a/pkg/discovery/dns.go b/pkg/discovery/dns.go index a23ab68..ee4a99a 100644 --- a/pkg/discovery/dns.go +++ b/pkg/discovery/dns.go @@ -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 `oauth.`, 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 `oauth.` + // 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 } diff --git a/pkg/discovery/dns_oauth_test.go b/pkg/discovery/dns_oauth_test.go new file mode 100644 index 0000000..640e223 --- /dev/null +++ b/pkg/discovery/dns_oauth_test.go @@ -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) + } +} diff --git a/pkg/discovery/dns_test.go b/pkg/discovery/dns_test.go index e625d4b..fbd278b 100644 --- a/pkg/discovery/dns_test.go +++ b/pkg/discovery/dns_test.go @@ -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) diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index ea3fc2b..fbf814f 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -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) diff --git a/pkg/service/health/checks_oauth_target.go b/pkg/service/health/checks_oauth_target.go new file mode 100644 index 0000000..3124e22 --- /dev/null +++ b/pkg/service/health/checks_oauth_target.go @@ -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 `oauth.` 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 `oauth.` 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 +} diff --git a/pkg/service/health/checks_oauth_target_test.go b/pkg/service/health/checks_oauth_target_test.go new file mode 100644 index 0000000..8c99acb --- /dev/null +++ b/pkg/service/health/checks_oauth_target_test.go @@ -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) + } + } +}