feat(settings): derive the HTTPS URL from the Target Domain, show + override in UI (#355)

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) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-07-04 17:45:41 +02:00
co-authored by Claude Opus 4.8
parent 433a779998
commit b1b3472297
7 changed files with 208 additions and 36 deletions
+22 -10
View File
@@ -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,
+34 -25
View File
@@ -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,
+36
View File
@@ -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
+31
View File
@@ -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")
+48 -1
View File
@@ -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).
+21
View File
@@ -183,6 +183,27 @@
<div id="target-domain-resolved" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
<div id="https-443-status" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
</div>
<div style="margin-bottom: 20px">
<label>HTTPS URL:</label>
<code id="https-url-effective"></code>
<span id="https-url-effective-note" style="font-size: 0.8em; color: #666"></span>
<div style="margin-top: 6px">
<span class="info-toggle" onclick="toggleInfo('https-url-override-info')">Advanced: override HTTPS URL ⓘ</span>
<div id="https-url-override-info" class="info-details">
<p style="font-size: 0.85em; color: #666; margin: 4px 0">
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.
</p>
<input type="text" id="https-url-override" placeholder="https://host:8443 (empty = derive)" style="width: 300px"/>
</div>
</div>
</div>
<div style="margin-bottom: 20px">
<label for="default-landing">Landing page (<code>/</code>):</label>
<select id="default-landing" style="margin-left: 4px">
+16
View File
@@ -250,6 +250,20 @@ async function fetchSettings() {
if (settings.server_url) {
document.getElementById("target-domain").value = settings.server_url;
}
const httpsEff = document.getElementById("https-url-effective");
if (httpsEff) {
httpsEff.textContent = settings.https_server_url || "—";
}
const httpsOverrideInput = document.getElementById("https-url-override");
if (httpsOverrideInput) {
httpsOverrideInput.value = settings.https_server_url_override || "";
}
const httpsEffNote = document.getElementById("https-url-effective-note");
if (httpsEffNote) {
httpsEffNote.textContent = settings.https_server_url_override
? "(override)"
: "(derived from Target Domain)";
}
const resolved = document.getElementById("target-domain-resolved");
if (resolved) {
if (settings.server_url_resolved_ip) {
@@ -461,8 +475,10 @@ async function updateLoggingSettings() {
}
async function updateSettings() {
const httpsOverrideEl = document.getElementById("https-url-override");
const settings = {
server_url: document.getElementById("target-domain").value,
https_server_url_override: httpsOverrideEl ? httpsOverrideEl.value.trim() : "",
default_landing: document.getElementById("default-landing").value,
discovery_interval: document.getElementById("discovery-interval").value,
discovery_enabled: document.getElementById("discovery-enabled").checked,