Prefer route-aware Proxmox host URLs

This commit is contained in:
rcourtman 2026-07-07 10:37:10 +01:00
parent 3dd2ecd198
commit 022f170dff
3 changed files with 38 additions and 23 deletions

View file

@ -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`

View file

@ -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 {

View file

@ -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) {