Files
Tobias GesellchenandClaude Sonnet 4.6 6861063935 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>
2026-05-23 00:34:36 +02:00

131 lines
3.8 KiB
Go

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)
}
}