fix(tls): also cover derived OAuth subdomain in served cert SAN list

#337's first commit added the OAuth-derivation to the DNS interceptor
but missed the served TLS certificate. With a serverURL of
`http://mac.fritz.box:8000` the cert SAN list covered `mac.fritz.box`
but not `macoauth.fritz.box`, so the speaker would resolve the OAuth
host correctly (via the new DNS hijack) and then immediately fail the
TLS handshake — Spotify / Amazon Music token refresh dies before
reaching AfterTouch.

getDomains now calls discovery.DeriveOAuthHostnames(serverURL) and
discovery.DeriveOAuthHostnames(httpsServerURL), feeding the derived
names into the SAN map alongside the existing entries. IP-based
serverURLs continue to produce no derivation (the OAuth construction
is unrecoverable for them — see the existing oauth_target_reachable
health check).

Tests in cmd/soundtouch-service/main_test.go lock in:
  - Hostname serverURL → derived OAuth variant present in SAN list.
  - IP serverURL → no malformed `192oauth.…` entry leaks in.

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 90a8bb25cf
commit 93248659a5
2 changed files with 52 additions and 0 deletions
+16
View File
@@ -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 {
+36
View File
@@ -151,3 +151,39 @@ func TestMergeTLSExtraHosts(t *testing.T) {
})
}
}
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
}