Files
Tobias GesellchenandClaude Opus 4.8 b1b3472297 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>
2026-07-04 17:45:41 +02:00

259 lines
8.4 KiB
Go

package handlers
import (
"net"
"strings"
"testing"
"time"
)
func TestProbeTCP_OpenPortSucceeds(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to start listener: %v", err)
}
defer ln.Close()
port := ln.Addr().(*net.TCPAddr).Port
if err := ProbeTCP("127.0.0.1", port, 500*time.Millisecond); err != nil {
t.Errorf("expected probe of open port to succeed, got: %v", err)
}
}
func TestProbeTCP_ClosedPortFails(t *testing.T) {
// Bind, capture port, close — leaves the port verifiably unbound.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to start listener: %v", err)
}
port := ln.Addr().(*net.TCPAddr).Port
_ = ln.Close()
if err := ProbeTCP("127.0.0.1", port, 500*time.Millisecond); err == nil {
t.Errorf("expected probe of closed port to fail, got nil")
}
}
func TestCheck443Reachability_SkipsWhenListenerOn443(t *testing.T) {
res := Check443Reachability(443, "http://example.test:8000", func(string) (string, error) {
t.Errorf("resolver should not be called when listener is on :443")
return "", nil
}, 100*time.Millisecond)
if !res.Skipped {
t.Errorf("expected Skipped=true when httpsListenerPort=443, got %+v", res)
}
}
func TestCheck443Reachability_ReportsResolverError(t *testing.T) {
// Use an HTTPS server URL so the NotApplicable short-circuit doesn't fire.
res := Check443Reachability(8443, "https://broken", func(string) (string, error) {
return "", errResolve("no DNS")
}, 100*time.Millisecond)
if res.Skipped {
t.Errorf("expected Skipped=false, got true")
}
if res.LAN.Reachable {
t.Errorf("expected LAN.Reachable=false, got true")
}
if !strings.Contains(res.LAN.Error, "cannot resolve LAN target") {
t.Errorf("expected LAN.Error to wrap resolver failure, got %q", res.LAN.Error)
}
}
func TestCheck443Reachability_NotApplicableWhenServerURLIsHTTP(t *testing.T) {
res := Check443Reachability(8443, "http://aftertouch.local:8000", func(string) (string, error) {
t.Errorf("resolver should not be called when serverURL scheme is HTTP")
return "", nil
}, 100*time.Millisecond)
if !res.NotApplicable {
t.Errorf("expected NotApplicable=true when serverURL is HTTP, got %+v", res)
}
if res.Reason == "" {
t.Errorf("expected NotApplicable verdict to carry a human-readable Reason, got empty")
}
if res.Skipped {
t.Errorf("Skipped should only be set when the listener is already on :443; got Skipped=true for HTTP serverURL")
}
}
func TestCheck443Reachability_NotApplicableTakesPrecedenceOverProbe(t *testing.T) {
// Even if the listener isn't on :443 and probes would fail, an HTTP
// serverURL should short-circuit to NotApplicable.
res := Check443Reachability(8443, "http://1.2.3.4:8000", func(string) (string, error) {
return "1.2.3.4", nil
}, 100*time.Millisecond)
if !res.NotApplicable {
t.Errorf("expected NotApplicable=true, got %+v", res)
}
if res.LAN.Error != "" || res.Localhost.Error != "" {
t.Errorf("expected no probe errors when NotApplicable short-circuits, got %+v", res)
}
}
func TestCheck443Reachability_HTTPSServerURLStillProbes(t *testing.T) {
resolverCalled := false
res := Check443Reachability(8443, "https://aftertouch.local:8443", func(string) (string, error) {
resolverCalled = true
return "127.0.0.1", nil
}, 100*time.Millisecond)
if !resolverCalled {
t.Errorf("resolver should be called for HTTPS serverURL")
}
if res.NotApplicable {
t.Errorf("HTTPS serverURL should not produce NotApplicable, got %+v", res)
}
}
func TestPortFromHTTPSServerURL(t *testing.T) {
cases := []struct {
in string
want int
}{
{"", 0},
{"https://example.test:8443", 8443},
{"https://example.test:443", 443},
{"https://example.test", 0},
{":::not a url", 0},
}
for _, tc := range cases {
got := PortFromHTTPSServerURL(tc.in)
if got != tc.want {
t.Errorf("PortFromHTTPSServerURL(%q) = %d, want %d", tc.in, got, tc.want)
}
}
}
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")
}
bothOK := Probe443Result{
Localhost: ProbeOutcome{Reachable: true},
LAN: ProbeOutcome{Reachable: true},
LANHost: "10.0.0.1",
}
if FormatPreflightGuidance(8443, bothOK) != "" {
t.Errorf("expected empty guidance when both probes succeed")
}
if FormatPreflightGuidance(8443, Probe443Result{NotApplicable: true, Reason: "HTTP only"}) != "" {
t.Errorf("expected empty guidance when NotApplicable (UI renders the reason separately)")
}
}
func TestFormatPreflightGuidance_BothFailMentionsRedirectPort(t *testing.T) {
res := Probe443Result{
Localhost: ProbeOutcome{Error: "connection refused"},
LAN: ProbeOutcome{Error: "connection refused"},
LANHost: "192.0.2.151",
}
out := FormatPreflightGuidance(8443, res)
if !strings.Contains(out, "--to-port 8443") {
t.Errorf("guidance must reference configured listener port for iptables, got: %s", out)
}
if !strings.Contains(out, "192.0.2.151:443") {
t.Errorf("guidance must mention probed LAN host, got: %s", out)
}
if !strings.Contains(out, "[WARN]") {
t.Errorf("guidance must be marked as a warning, got: %s", out)
}
}
func TestFormatPreflightGuidance_IncludesOutputChainCaveat(t *testing.T) {
res := Probe443Result{
Localhost: ProbeOutcome{Error: "connection refused"},
LAN: ProbeOutcome{Error: "connection refused"},
LANHost: "192.0.2.151",
}
out := FormatPreflightGuidance(8443, res)
if !strings.Contains(out, "OUTPUT") {
t.Errorf("guidance must warn about the iptables OUTPUT chain side-effect, got: %s", out)
}
}
type errResolve string
func (e errResolve) Error() string { return string(e) }
func TestCheck443Reachability_LANProbeMatchesListenerOutcome(t *testing.T) {
// Spin up a listener on a random port and use that port via resolver
// trickery: we point the LAN host at 127.0.0.1 and rely on the fact that
// nothing answers on :443 in test environments. The point of this test
// is to lock in the result-shape: when localhost:443 is closed (the
// default in CI), the function still returns a well-formed result and
// reports the resolved LAN host. Uses HTTPS so the NotApplicable
// short-circuit doesn't fire.
res := Check443Reachability(8443, "https://1.2.3.4:8443", func(string) (string, error) {
return "1.2.3.4", nil
}, 200*time.Millisecond)
if res.Skipped {
t.Fatalf("expected Skipped=false, got true")
}
if res.LANHost != "1.2.3.4" {
t.Errorf("expected LANHost=1.2.3.4, got %q", res.LANHost)
}
// In any sane CI environment nothing is listening on :443, so both
// probes should report errors. We don't assert the exact error string
// (varies by OS) but we do assert it's populated.
if res.LAN.Reachable {
t.Errorf("did not expect LAN:443 to be reachable in test env")
}
if res.LAN.Error == "" {
t.Errorf("expected LAN.Error to be populated when unreachable")
}
}