From b1b3472297a7348739be4762438e37a674319e97 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 4 Jul 2026 17:22:33 +0200 Subject: [PATCH] feat(settings): derive the HTTPS URL from the Target Domain, show + override in UI (#355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTTPS URL AfterTouch advertises (and points speakers at for the DNS-redirect, OAuth, install-ca and cert-trust flows) was a separate, internally-tracked value: sourced only from --https-server-url / HTTPS_SERVER_URL / the settings file, defaulting to the machine hostname, and never shown or editable in the web UI. So it could silently diverge from the Target Domain (e.g. a different host, or a port-less value that fell back to 443 while the listener was on 8443 — the root of #355), with no way to see or fix it in the UI. Make it derive + show + override: - DeriveHTTPSURL resolves the effective HTTPS URL: an explicit override wins; otherwise it follows the Target Domain (same host, https, on the configured HTTPS port); an already-https Target Domain is honoured verbatim (its port is not second-guessed); empty falls back to the hostname default. So changing the Target Domain updates the HTTPS URL automatically for the common single-host case. - The persisted https_server_url is now the *override* (empty = derive). Existing installs carry their old value here, so it is preserved as an override — no silent change on upgrade; clearing it opts into derive. - The server keeps httpsServerURL as the effective value, so all consumers (cert SANs, migration, export, health) are unchanged; it is recomputed whenever the Target Domain or override changes. - Settings API returns https_server_url (effective) plus https_server_url_override; the Settings page shows the effective URL with a derived/override note and an "advanced" override field. Verified live on a clean data dir: derive from an http Target Domain, auto-follow when the Target Domain changes, explicit override, an https Target Domain kept verbatim, and override persistence across restart. Unit tests cover DeriveHTTPSURL including the already-https cases. refs #355 Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/soundtouch-service/main.go | 32 +++++++++----- pkg/service/handlers/handlers_setup.go | 59 +++++++++++++++----------- pkg/service/handlers/preflight.go | 36 ++++++++++++++++ pkg/service/handlers/preflight_test.go | 31 ++++++++++++++ pkg/service/handlers/server.go | 49 ++++++++++++++++++++- pkg/service/handlers/web/index.html | 21 +++++++++ pkg/service/handlers/web/js/script.js | 16 +++++++ 7 files changed, 208 insertions(+), 36 deletions(-) diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 663f86a..79209f1 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -518,8 +518,8 @@ func main() { server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record) sm.GetDNSRunning = server.GetDNSRunning server.SetLogBuffer(logBuf) - server.SetHTTPServerURL(config.httpsServerURL) server.SetHTTPSListenAddr(config.httpsAddr) + server.SetHTTPSSettings(config.httpsOverride, config.httpsPort, config.httpsDefaultURL) server.SetExpectedHosts(config.domains) server.SetVersionInfo(version, commit, date, repoURL) server.SetDiscoverySettings(config.discoveryInterval, config.discoveryEnabled) @@ -683,7 +683,10 @@ type serviceConfig struct { dataDir string hostname string serverURL string - httpsServerURL string + httpsServerURL string // effective (derived or overridden) + httpsOverride string // explicit override; "" = derive from serverURL + httpsPort string + httpsDefaultURL string // hostname-based fallback httpsAddr string redact bool logBody bool @@ -756,10 +759,13 @@ func loadConfig(c *cli.Context) serviceConfig { httpsAddr = ":" + httpsPort } - httpsServerURL := c.String("https-server-url") - if httpsServerURL == "" { - httpsServerURL = "https://" + hostname + ":" + httpsPort - } + // The HTTPS URL is an override (from the flag/env); when empty it is + // derived from serverURL + https port so one setting (Target Domain) + // drives both. httpsDefaultURL is the hostname-based fallback used + // before a Target Domain is configured. + httpsOverride := c.String("https-server-url") + httpsDefaultURL := "https://" + hostname + ":" + httpsPort + httpsServerURL := handlers.DeriveHTTPSURL(serverURL, httpsOverride, httpsPort, httpsDefaultURL) tlsExtraHosts := c.StringSlice("tls-extra-host") domains := getDomains(serverURL, httpsServerURL, hostname, tlsExtraHosts) @@ -817,6 +823,9 @@ func loadConfig(c *cli.Context) serviceConfig { hostname: hostname, serverURL: serverURL, httpsServerURL: httpsServerURL, + httpsOverride: httpsOverride, + httpsPort: httpsPort, + httpsDefaultURL: httpsDefaultURL, httpsAddr: httpsAddr, redact: redact, logBody: logBody, @@ -954,9 +963,12 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data config.serverURL = handlers.NormalizeServerURL(persisted.ServerURL) } - if persisted.HTTPServerURL != "" { - config.httpsServerURL = persisted.HTTPServerURL - } + // persisted.HTTPServerURL is the HTTPS override (empty = derive). + // Existing installs carry their old effective value here, so it is + // preserved as an override; recompute the effective URL either way, + // since serverURL may have come from the persisted settings above. + config.httpsOverride = persisted.HTTPServerURL + config.httpsServerURL = handlers.DeriveHTTPSURL(config.serverURL, config.httpsOverride, config.httpsPort, config.httpsDefaultURL) config.discoveryEnabled = persisted.DiscoveryEnabled if persisted.DiscoveryInterval != "" { @@ -1076,7 +1088,7 @@ func applyPersistedMusicServiceCredentials(config *serviceConfig, persisted data func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings { settings := datastore.Settings{ ServerURL: config.serverURL, - HTTPServerURL: config.httpsServerURL, + HTTPServerURL: config.httpsOverride, RedactLogs: config.redact, LogBodies: config.logBody, RecordInteractions: config.record, diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index 19ff7ac..515e52e 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -162,6 +162,7 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { s.mu.RLock() serverURL, httpsServerURL := s.serverURL, s.httpsServerURL + httpsOverride := s.httpsOverride discoveryInterval := s.discoveryInterval.String() discoveryEnabled := s.discoveryEnabled dnsEnabled := s.dnsEnabled @@ -230,6 +231,7 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { "server_url_resolved_ip": serverURLResolvedIP, "server_url_resolve_error": serverURLResolveError, "https_server_url": httpsServerURL, + "https_server_url_override": httpsOverride, "https_listener_port": httpsListenerPort, "https_443_check_skipped": probe443.Skipped, "https_443_not_applicable": probe443.NotApplicable, @@ -278,28 +280,29 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { // HandleUpdateSettings updates the service settings. func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { var settings struct { - ServerURL string `json:"server_url"` - DiscoveryInterval string `json:"discovery_interval"` - DiscoveryEnabled bool `json:"discovery_enabled"` - DNSEnabled bool `json:"dns_enabled"` - DNSUpstream string `json:"dns_upstream"` - DNSBindAddr string `json:"dns_bind_addr"` - InternalPaths []string `json:"internal_paths"` - Shortcuts map[string]int `json:"shortcuts"` - SpotifyClientID string `json:"spotify_client_id"` - SpotifyClientSecret string `json:"spotify_client_secret"` - SpotifyRedirectURI string `json:"spotify_redirect_uri"` - AmazonClientID string `json:"amazon_client_id"` - AmazonClientSecret string `json:"amazon_client_secret"` - AmazonRedirectURI string `json:"amazon_redirect_uri"` - TTSProvider string `json:"tts_provider"` - TTSGoogleAPIKey string `json:"tts_google_api_key"` - TTSAppKey string `json:"tts_app_key"` - TTSLanguage string `json:"tts_language"` - TTSVoice string `json:"tts_voice"` - TTSVolume int `json:"tts_volume"` - TLSExtraHosts *[]string `json:"tls_extra_hosts"` - DefaultLanding string `json:"default_landing"` + ServerURL string `json:"server_url"` + HTTPSServerURLOverride *string `json:"https_server_url_override"` + DiscoveryInterval string `json:"discovery_interval"` + DiscoveryEnabled bool `json:"discovery_enabled"` + DNSEnabled bool `json:"dns_enabled"` + DNSUpstream string `json:"dns_upstream"` + DNSBindAddr string `json:"dns_bind_addr"` + InternalPaths []string `json:"internal_paths"` + Shortcuts map[string]int `json:"shortcuts"` + SpotifyClientID string `json:"spotify_client_id"` + SpotifyClientSecret string `json:"spotify_client_secret"` + SpotifyRedirectURI string `json:"spotify_redirect_uri"` + AmazonClientID string `json:"amazon_client_id"` + AmazonClientSecret string `json:"amazon_client_secret"` + AmazonRedirectURI string `json:"amazon_redirect_uri"` + TTSProvider string `json:"tts_provider"` + TTSGoogleAPIKey string `json:"tts_google_api_key"` + TTSAppKey string `json:"tts_app_key"` + TTSLanguage string `json:"tts_language"` + TTSVoice string `json:"tts_voice"` + TTSVolume int `json:"tts_volume"` + TLSExtraHosts *[]string `json:"tls_extra_hosts"` + DefaultLanding string `json:"default_landing"` } if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -343,6 +346,9 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { s.mu.Lock() s.serverURL = settings.ServerURL + // nil override = "field omitted, preserve"; recompute regardless, since + // the Target Domain (which the derived URL follows) may have changed. + s.applyHTTPSOverrideLocked(settings.HTTPSServerURLOverride) s.discoveryEnabled = settings.DiscoveryEnabled if settings.DiscoveryInterval != "" { @@ -397,7 +403,9 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { currentRedact := s.redactLogs currentLogBody := s.logBodies currentRecord := s.recordEnabled - currentHTTPS := s.httpsServerURL + // Persist the override (empty = derive), not the effective URL, so the + // HTTPS URL keeps following the Target Domain across restarts. + currentHTTPS := s.httpsOverride // Resolve TLS extra hosts: nil pointer means "field omitted, preserve existing"; // non-nil (even empty) means "replace with this list". @@ -1008,14 +1016,15 @@ func (s *Server) HandleUpdateLoggingSettings(w http.ResponseWriter, r *http.Requ // Persist to datastore // Access fields directly since we already hold the lock - serverURL, httpsServerURL := s.serverURL, s.httpsServerURL + // Persist the HTTPS override (empty = derive), not the effective URL. + serverURL, httpsOverride := s.serverURL, s.httpsOverride discoveryInterval := s.discoveryInterval.String() discoveryEnabled := s.discoveryEnabled log.Printf("Saving updated proxy settings to %s/settings.json", s.ds.DataDir) err := s.ds.SaveSettings(datastore.Settings{ ServerURL: serverURL, - HTTPServerURL: httpsServerURL, + HTTPServerURL: httpsOverride, RedactLogs: s.redactLogs, LogBodies: s.logBodies, RecordInteractions: s.recordEnabled, diff --git a/pkg/service/handlers/preflight.go b/pkg/service/handlers/preflight.go index ac50e9b..f350537 100644 --- a/pkg/service/handlers/preflight.go +++ b/pkg/service/handlers/preflight.go @@ -130,6 +130,42 @@ func schemeOf(s string) string { return strings.ToLower(u.Scheme) } +// DeriveHTTPSURL resolves the effective HTTPS URL AfterTouch advertises. +// +// The rules, in order: +// - a non-empty override wins verbatim (set via --https-server-url / +// HTTPS_SERVER_URL or the "advanced" field in the web UI; needed for +// reverse-proxy setups where the public HTTPS endpoint differs). +// - if the Target Domain is itself an https:// URL, use it as-is: the +// operator has already named an HTTPS endpoint (host and, if given, +// port), so we must not second-guess its port. +// - otherwise derive from the http:// serverURL: same host, https +// scheme, on httpsPort. This keeps the common single-host case to one +// setting — change the Target Domain and the HTTPS URL follows. +// - if serverURL has no usable host (e.g. not configured yet), fall +// back to the startup default (hostname-based). +func DeriveHTTPSURL(serverURL, override, httpsPort, fallback string) string { + if strings.TrimSpace(override) != "" { + return override + } + + if u, err := url.Parse(serverURL); err == nil && u.Hostname() != "" { + // Target Domain already points at HTTPS: honour it verbatim + // (scheme + host + whatever port the operator specified, or none). + if strings.EqualFold(u.Scheme, "https") { + return "https://" + u.Host + } + + if httpsPort != "" { + return "https://" + net.JoinHostPort(u.Hostname(), httpsPort) + } + + return "https://" + u.Hostname() + } + + return fallback +} + // PortFromHTTPSServerURL extracts the numeric port from httpsServerURL. It // returns 0 if the URL is empty, malformed, or has no explicit port — in // that case the caller cannot make a determination about :443 and should diff --git a/pkg/service/handlers/preflight_test.go b/pkg/service/handlers/preflight_test.go index 77b102e..a9258ec 100644 --- a/pkg/service/handlers/preflight_test.go +++ b/pkg/service/handlers/preflight_test.go @@ -137,6 +137,37 @@ func TestPortFromHTTPSServerURL(t *testing.T) { } } +func TestDeriveHTTPSURL(t *testing.T) { + const fallback = "https://myhost:8443" + + cases := []struct { + name string + serverURL, override, httpsPort string + want string + }{ + {"override wins verbatim", "http://192.0.2.10:8000", "https://proxy.example:443", "8443", "https://proxy.example:443"}, + {"override wins over https serverURL", "https://192.0.2.10:9000", "https://proxy.example", "8443", "https://proxy.example"}, + {"http derives to https on https port", "http://192.0.2.10:8000", "", "8443", "https://192.0.2.10:8443"}, + {"http host without port still derives on https port", "http://192.0.2.10", "", "8443", "https://192.0.2.10:8443"}, + // The Target Domain is already HTTPS: honour it verbatim, do not + // re-impose the configured https port (issue #355 follow-up). + {"https serverURL with port kept as-is", "https://192.0.2.10:8443", "", "8443", "https://192.0.2.10:8443"}, + {"https serverURL with custom port kept as-is", "https://192.0.2.10:9000", "", "8443", "https://192.0.2.10:9000"}, + {"https serverURL without port kept as-is", "https://192.0.2.10", "", "8443", "https://192.0.2.10"}, + {"empty serverURL falls back", "", "", "8443", fallback}, + {"unparseable serverURL falls back", "://nope", "", "8443", fallback}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := DeriveHTTPSURL(tc.serverURL, tc.override, tc.httpsPort, fallback) + if got != tc.want { + t.Errorf("DeriveHTTPSURL(%q, %q, %q) = %q, want %q", tc.serverURL, tc.override, tc.httpsPort, got, tc.want) + } + }) + } +} + func TestFormatPreflightGuidance_SkippedAndAllOK(t *testing.T) { if FormatPreflightGuidance(443, Probe443Result{Skipped: true}) != "" { t.Errorf("expected empty guidance when skipped") diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index a42e5e8..0616950 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -40,7 +40,10 @@ type Server struct { sm *setup.Manager mu sync.RWMutex serverURL string - httpsServerURL string + httpsServerURL string // effective (derived or overridden) HTTPS URL + httpsOverride string // explicit HTTPS URL override; "" means derive from serverURL + httpsPort string // configured HTTPS port, used when deriving + httpsDefaultURL string // startup hostname-based fallback when serverURL has no host httpsListenAddr string discovering bool redactLogs bool @@ -796,6 +799,10 @@ func (s *Server) GetDiscoverySettings() (time.Duration, bool) { } // SetHTTPServerURL sets the external HTTPS URL of the service. +// +// Deprecated: prefer SetHTTPSSettings, which tracks the override vs the +// derived value so the effective URL follows the Target Domain. Kept for +// callers that set the effective URL directly. func (s *Server) SetHTTPServerURL(url string) { s.mu.Lock() defer s.mu.Unlock() @@ -803,6 +810,46 @@ func (s *Server) SetHTTPServerURL(url string) { s.httpsServerURL = url } +// SetHTTPSSettings records the HTTPS URL override (empty = derive from +// the Target Domain), the configured HTTPS port, and the startup +// hostname-based fallback, then recomputes the effective HTTPS URL. +func (s *Server) SetHTTPSSettings(override, httpsPort, defaultURL string) { + s.mu.Lock() + defer s.mu.Unlock() + + s.httpsOverride = strings.TrimSpace(override) + s.httpsPort = httpsPort + s.httpsDefaultURL = defaultURL + s.recomputeHTTPSURLLocked() +} + +// recomputeHTTPSURLLocked refreshes the effective HTTPS URL from the +// current serverURL + override + port. Callers must hold s.mu. +func (s *Server) recomputeHTTPSURLLocked() { + s.httpsServerURL = DeriveHTTPSURL(s.serverURL, s.httpsOverride, s.httpsPort, s.httpsDefaultURL) +} + +// applyHTTPSOverrideLocked sets the HTTPS override from an optional +// request value (nil = preserve the current one, non-nil replaces it, +// empty re-enables deriving) and recomputes the effective URL. Callers +// must hold s.mu. +func (s *Server) applyHTTPSOverrideLocked(override *string) { + if override != nil { + s.httpsOverride = strings.TrimSpace(*override) + } + + s.recomputeHTTPSURLLocked() +} + +// HTTPSOverride returns the explicit HTTPS URL override, or "" when the +// effective URL is derived from the Target Domain. +func (s *Server) HTTPSOverride() string { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.httpsOverride +} + // SetHTTPSListenAddr records the address the HTTPS listener is bound // to (e.g. ":8443"). The cert-chain health check uses its port to // detect an advertised-URL/listener port mismatch (issue #355). diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index 7c55af7..9a5f7c5 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -183,6 +183,27 @@
+
+ + + +
+ Advanced: override HTTPS URL ⓘ +
+

+ AfterTouch also serves an HTTPS endpoint (used for the + DNS-based redirect, music-service login, and certificate + trust). By default it follows the Target Domain above + (same host, on the HTTPS port), so you normally don't + set anything here. Provide an override only when a + reverse proxy serves HTTPS on a different host or port. + Leave it empty to go back to deriving it automatically. + Takes effect after saving. +

+ +
+
+