diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index b9c62c6d1..5b3bf8b37 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1376,7 +1376,7 @@ surface and no new `internal/api/` lifecycle handler. instance-scoped under `internal/hostagent/agent.go`; lifecycle-owned registration and update paths must not depend on package-global mutable test seams that can leak between concurrent agent sessions or tests. -5. Preserve canonical /api/auto-register node identity continuity when canonical hosts shift between hostname and IP forms for the same node. +5. Preserve canonical /api/auto-register node identity continuity when canonical hosts shift between hostname and IP forms for the same node, and keep runtime-side Proxmox setup host ordering aligned with the contract: explicit report-IP override, route-aware local IP, resolvable hostname fallback, then heuristic local IP fallback. That same lifecycle-adjacent shared-API boundary now covers relay and command-target hostname resolution too. When lifecycle flows reuse `internal/api/router_routes_ai_relay.go`, `internal/agentexec/server.go`, @@ -2614,13 +2614,17 @@ That canonical /api/auto-register behavior now also includes hostname/IP continu reruns that arrive through a different canonical host form must reuse the same Pulse-managed node record and token instead of forking duplicate fleet entries. That same lifecycle contract also governs the runtime-side Proxmox setup host -selection in `internal/hostagent/proxmox_setup.go`: when the system hostname -resolves to a non-loopback, non-link-local address, the generated Proxmox -registration host must stay on that canonical hostname instead of downgrading -to a route-inferred interface IP. Route-aware IP detection remains the fallback -only when hostname resolution is unusable, so multi-NIC and internal-CA -deployments preserve canonical hostname continuity without losing an IP escape -hatch for non-DNS installs. +selection in `internal/hostagent/proxmox_setup.go`: an explicit report-IP +override remains the highest-priority operator-provided endpoint, and otherwise +the route-aware local IP used to reach Pulse is the preferred registration +`host`. A resolvable system hostname remains an ordered fallback candidate, not +the primary generated host, so Pulse can still select it when the server-side +fingerprint probe proves that hostname is the correct reachable endpoint. The +candidate list plus hostname/IP continuity is the canonical place for DNS +continuity; runtime setup must not prefer a short hostname over the +route-aware interface merely because local resolution succeeds. Heuristic local +IP selection is only a fallback after the explicit override, route-aware IP, +and resolvable-hostname candidate paths. That same Proxmox registration boundary must now also let Pulse choose from the agent's ordered candidate host list instead of blindly persisting the agent's first preference. Unified Agent setup must send canonical `candidateHosts` diff --git a/internal/hostagent/proxmox_setup.go b/internal/hostagent/proxmox_setup.go index e05b60428..a2f5f34df 100644 --- a/internal/hostagent/proxmox_setup.go +++ b/internal/hostagent/proxmox_setup.go @@ -924,26 +924,27 @@ func (p *ProxmoxSetup) candidateHostURLs(ctx context.Context, ptype proxmoxProdu return append(candidates, buildURL(hostname)) } - // Priority 2: Prefer the system hostname when it resolves to a non-loopback, - // non-link-local address. This preserves admin-managed DNS names and matching - // TLS certificates instead of silently downgrading to an inferred IP address. + // Priority 2: Prefer the route-aware local IP used to reach Pulse. This keeps + // generated Proxmox config aligned with the reachable endpoint shown to users, + // while preserving hostname as a fallback candidate below. + if reachableIP := p.getIPThatReachesPulse(); reachableIP != "" { + p.logger.Debug().Str("ip", reachableIP).Msg("Using IP that can reach Pulse server") + candidates = appendUniqueHostCandidate(candidates, seen, buildURL(reachableIP)) + } + + // Priority 3: Keep the system hostname as a fallback when it resolves to a + // non-loopback, non-link-local address. The server receives candidateHosts + // and may still choose it if it is the correct stable endpoint. if hostname := strings.TrimSpace(p.hostname); hostname != "" { if hostnameIP := p.getIPForHostname(); hostnameIP != "" && !isLoopbackOrLinkLocalIP(hostnameIP) { p.logger.Debug(). Str("hostname", hostname). Str("ip", hostnameIP). - Msg("Using resolvable hostname for Proxmox registration") + Msg("Adding resolvable hostname fallback for Proxmox registration") candidates = appendUniqueHostCandidate(candidates, seen, buildURL(hostname)) } } - // Priority 3: Try to determine which local IP is used to connect to Pulse. - // This remains the best fallback when the hostname is not usable. - if reachableIP := p.getIPThatReachesPulse(); reachableIP != "" { - p.logger.Debug().Str("ip", reachableIP).Msg("Using IP that can reach Pulse server") - candidates = appendUniqueHostCandidate(candidates, seen, buildURL(reachableIP)) - } - // Priority 4: Get all IPs and select the best one based on heuristics. hostnameCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() @@ -974,8 +975,8 @@ func (p *ProxmoxSetup) candidateHostURLs(ctx context.Context, ptype proxmoxProdu } // getHostURL constructs the host URL for this Proxmox node. -// Prefers canonical hostname continuity when it resolves to a real local address, -// then falls back to route-aware IP detection and heuristic local IP selection. +// Prefers explicit reportIP, then route-aware IP detection, then hostname and +// heuristic local IP fallbacks. func (p *ProxmoxSetup) getHostURL(ctx context.Context, ptype proxmoxProductType) string { candidates := p.candidateHostURLs(ctx, ptype) if len(candidates) == 0 { diff --git a/internal/hostagent/proxmox_setup_test.go b/internal/hostagent/proxmox_setup_test.go index dd71235c7..06aa6c478 100644 --- a/internal/hostagent/proxmox_setup_test.go +++ b/internal/hostagent/proxmox_setup_test.go @@ -441,7 +441,7 @@ func TestGetHostURL(t *testing.T) { } }) - t.Run("prefers resolvable hostname before inferred route IP", func(t *testing.T) { + t.Run("prefers route-aware IP before resolvable hostname fallback", func(t *testing.T) { p.reportIP = "" p.pulseURL = "https://pulse:7655" mc.lookupIPFn = func(h string) ([]net.IP, error) { @@ -453,9 +453,19 @@ func TestGetHostURL(t *testing.T) { mc.dialTimeoutFn = func(network, address string, timeout time.Duration) (net.Conn, error) { return &mockConn{localAddr: &net.UDPAddr{IP: net.ParseIP("10.1.1.1")}}, nil } - if got := p.getHostURL(context.Background(), "pve"); got != "https://test-host:8006" { + if got := p.getHostURL(context.Background(), "pve"); got != "https://10.1.1.1:8006" { t.Errorf("got %s", got) } + candidates := p.candidateHostURLs(context.Background(), "pve") + if len(candidates) < 2 { + t.Fatalf("candidateHostURLs() = %#v, want route IP then hostname fallback", candidates) + } + if candidates[0] != "https://10.1.1.1:8006" { + t.Fatalf("candidateHostURLs()[0] = %q, want route-aware IP", candidates[0]) + } + if candidates[1] != "https://test-host:8006" { + t.Fatalf("candidateHostURLs()[1] = %q, want hostname fallback", candidates[1]) + } }) t.Run("uses IP that reaches pulse when hostname is not resolvable", func(t *testing.T) {