fix(api): let live request origin outrank auto-detected public URL

Agent install and update commands advertised http://<LAN-IP>:7655 on
installs where the operator browses Pulse over an https FQDN, because
boot-time detection pre-fills config.PublicURL with an IP guess and
resolvePublicURL treated that guess as configuration. Track
auto-detected values (boot probe or first-request capture) separately:
explicit config from the env var or the settings UI still wins, but an
auto-detected guess now yields to the origin of the admin request that
is asking, so copied commands match how the instance is actually
reached.

Also stop boot detection from clobbering a settings-persisted public
URL on restart, which silently reverted the operator's saved value
whenever PULSE_PUBLIC_URL was unset.

Refs #1692

Contract-Neutral: behavioral fix to advertised-URL derivation: no API shape, field, or endpoint changes; PublicURLAutoDetected is internal (json:-)
This commit is contained in:
rcourtman
2026-08-08 06:33:40 +01:00
parent e597325a4a
commit 8e2858dac0
4 changed files with 104 additions and 17 deletions
+19 -7
View File
@@ -4765,6 +4765,7 @@ func (r *Router) capturePublicURLFromRequest(req *http.Request) {
}
r.config.PublicURL = normalizedCandidate
r.config.PublicURLAutoDetected = true
r.publicURLDetected = true
r.publicURLMu.Unlock()
@@ -10923,7 +10924,12 @@ func (r *Router) resolvePublicURL(req *http.Request) string {
return strings.TrimRight(agentConnectURL, "/")
}
if publicURL := strings.TrimSpace(r.config.PublicURL); publicURL != "" {
// An operator-configured public URL is authoritative. An auto-detected one
// is only a guess (boot-time LAN IP probe or first-request capture), so the
// live request's own origin outranks it: the URL the admin is browsing
// right now is fresher evidence of how this instance is reached (#1692).
publicURL := strings.TrimSpace(r.config.PublicURL)
if publicURL != "" && !r.config.PublicURLAutoDetected {
return strings.TrimRight(publicURL, "/")
}
@@ -10940,12 +10946,18 @@ func (r *Router) resolvePublicURL(req *http.Request) string {
if req != nil {
host = strings.TrimSpace(req.Host)
}
if host == "" {
if r.config.FrontendPort > 0 {
host = fmt.Sprintf("localhost:%d", r.config.FrontendPort)
} else {
host = "localhost:7655"
}
if host != "" {
return fmt.Sprintf("%s://%s", scheme, host)
}
if publicURL != "" {
return strings.TrimRight(publicURL, "/")
}
if r.config.FrontendPort > 0 {
host = fmt.Sprintf("localhost:%d", r.config.FrontendPort)
} else {
host = "localhost:7655"
}
return fmt.Sprintf("%s://%s", scheme, host)
+66
View File
@@ -847,6 +847,72 @@ func TestResolvePublicURL_ConfiguredPublicURL(t *testing.T) {
}
}
func TestResolvePublicURL_AutoDetectedYieldsToRequest(t *testing.T) {
tests := []struct {
name string
publicURL string
autoDetected bool
reqHost string
forwardProto string
want string
}{
{
name: "auto-detected loses to request origin",
publicURL: "http://192.168.1.50:7655",
autoDetected: true,
reqHost: "pulse.example.com",
forwardProto: "https",
want: "https://pulse.example.com",
},
{
name: "auto-detected loses to plain http request origin",
publicURL: "http://192.168.1.50:7655",
autoDetected: true,
reqHost: "pulse.lan:7655",
want: "http://pulse.lan:7655",
},
{
name: "explicit config still beats request origin",
publicURL: "http://192.168.1.50:7655",
autoDetected: false,
reqHost: "pulse.example.com",
forwardProto: "https",
want: "http://192.168.1.50:7655",
},
{
name: "auto-detected survives when request has no host",
publicURL: "http://192.168.1.50:7655",
autoDetected: true,
want: "http://192.168.1.50:7655",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Router{
config: &config.Config{
PublicURL: tt.publicURL,
PublicURLAutoDetected: tt.autoDetected,
},
}
var req *http.Request
if tt.reqHost != "" {
req = httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = tt.reqHost
if tt.forwardProto != "" {
req.Header.Set("X-Forwarded-Proto", tt.forwardProto)
}
}
got := r.resolvePublicURL(req)
if got != tt.want {
t.Errorf("resolvePublicURL() = %q, want %q", got, tt.want)
}
})
}
}
func TestResolvePublicURL_FromRequest(t *testing.T) {
tests := []struct {
name string
+2
View File
@@ -1042,6 +1042,8 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
}
if _, ok := rawRequest["publicURL"]; ok {
h.config.PublicURL = settings.PublicURL
// Operator input, even empty, supersedes any auto-detected guess.
h.config.PublicURLAutoDetected = false
}
// ---- Side effects (discovery, temperature, notifications) ----
+17 -10
View File
@@ -137,14 +137,18 @@ func normalizeEnvAuthPassword(raw string) (string, error) {
// NOTE: The envconfig tags are legacy and not used - configuration is loaded from encrypted JSON files
type Config struct {
// Server settings
BindAddress string
FrontendPort int `envconfig:"FRONTEND_PORT" default:"7655"`
ConfigPath string
DataPath string
AppRoot string `json:"-"` // Root directory of the application (where binary lives)
PublicURL string `envconfig:"PULSE_PUBLIC_URL" default:""` // Full URL to access Pulse (e.g., http://198.51.100.100:7655)
AgentConnectURL string `envconfig:"PULSE_AGENT_CONNECT_URL" default:""` // Dedicated direct connect URL for agents (e.g. http://192.0.2.5:7655)
AgentIngestPort int `envconfig:"PULSE_AGENT_INGEST_PORT" default:"0"` // When >0, additionally serve agent ingest (/api/agents/*) on this dedicated port so it can be network-isolated from the UI/API. 0 = disabled (single-port, default).
BindAddress string
FrontendPort int `envconfig:"FRONTEND_PORT" default:"7655"`
ConfigPath string
DataPath string
AppRoot string `json:"-"` // Root directory of the application (where binary lives)
PublicURL string `envconfig:"PULSE_PUBLIC_URL" default:""` // Full URL to access Pulse (e.g., http://198.51.100.100:7655)
// PublicURLAutoDetected marks PublicURL as a boot-time or request-time
// guess rather than operator configuration. Auto-detected values yield to
// the live request origin when advertising URLs (refs #1692).
PublicURLAutoDetected bool `json:"-"`
AgentConnectURL string `envconfig:"PULSE_AGENT_CONNECT_URL" default:""` // Dedicated direct connect URL for agents (e.g. http://192.0.2.5:7655)
AgentIngestPort int `envconfig:"PULSE_AGENT_INGEST_PORT" default:"0"` // When >0, additionally serve agent ingest (/api/agents/*) on this dedicated port so it can be network-isolated from the UI/API. 0 = disabled (single-port, default).
// Proxmox VE connections
PVEInstances []PVEInstance
@@ -1675,10 +1679,13 @@ func load(initLogging bool) (*Config, error) {
// In hosted mode, fail closed unless explicitly configured.
if os.Getenv("PULSE_HOSTED_MODE") == "true" {
log.Warn().Msg("Hosted mode enabled: public URL not configured; external links (e.g., magic links) will be disabled. Set PULSE_PUBLIC_URL.")
} else {
// Try to auto-detect public URL if not explicitly configured
} else if cfg.PublicURL == "" {
// Try to auto-detect public URL if not explicitly configured.
// A value loaded from system settings is operator configuration
// and must not be clobbered by detection on restart.
if detectedURL := detectPublicURL(cfg.FrontendPort); detectedURL != "" {
cfg.PublicURL = detectedURL
cfg.PublicURLAutoDetected = true
log.Info().Str("url", detectedURL).Msg("Auto-detected public URL for webhook notifications")
}
}