mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
1421ad5ce1
commit
6861063935
@@ -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
|
||||
|
||||
|
||||
+85
-8
@@ -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
-13
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user