diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 3e9e77830a..744e088700 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -8270,3 +8270,20 @@ are unchanged. This shapes synthetic readings only, not live measurements. a fixed seven-day point/series equivalence and byte-accounting regression, and host generation/tick checks across cache/database/backup/general role changes. The existing `TestBuildFixtureStateIncludesHostAgents` assertion stays intact. + +### Unpinned cluster members link to their Proxmox address (24 October 2026) + +With strict TLS, a cluster member that has no per-member certificate pin is +dialed by its bare node name so the certificate can be validated. Members that +join after the others were pinned land in that state. Their runtime node +`GuestURL` was empty, so the frontend replaced the bare name with an address +guessed from the node's interfaces and picked a bridge every member shares +(a support report opened another node's internal address). When no member or +connection link override exists and the dial URL is a bare label, +`resolveNodeConnectionInfo` now sets the link to the address Proxmox reports +for that member, keeping the dial scheme and port. Operator link overrides, +FQDN and IP dial URLs, and the dial URL itself are unchanged. + +`internal/monitoring/monitor_pve_cluster_refresh_test.go` registers the +unpinned strict-member link, override precedence and the URL builder cases +(custom port, IPv6, FQDN, IP and missing address). diff --git a/internal/monitoring/monitor_polling_node_helpers.go b/internal/monitoring/monitor_polling_node_helpers.go index 058f944699..1e76953aa8 100644 --- a/internal/monitoring/monitor_polling_node_helpers.go +++ b/internal/monitoring/monitor_polling_node_helpers.go @@ -4,6 +4,8 @@ import ( "context" stderrors "errors" "fmt" + "net" + "net/url" "strings" "time" @@ -25,6 +27,8 @@ func resolveNodeConnectionInfo(instanceCfg *config.PVEInstance, discoveryCfg con } if ep.GuestURL != "" { guestURL = ep.GuestURL + } else if guestURL == "" { + guestURL = clusterMemberBrowserURL(ep, connectionHost) } break } @@ -34,6 +38,36 @@ func resolveNodeConnectionInfo(instanceCfg *config.PVEInstance, discoveryCfg con return connectionHost, guestURL } +// clusterMemberBrowserURL gives a cluster member's web interface link the +// address Proxmox reports for that node when Pulse dials it by a bare +// hostname. Strict TLS without a per-member pin dials by name so the +// certificate can be validated, but a browser often cannot resolve that +// label, and the frontend would otherwise guess an address from the node's +// interfaces, which picks bridges every member shares. +func clusterMemberBrowserURL(endpoint config.ClusterEndpoint, connectionHost string) string { + ip := net.ParseIP(strings.TrimSpace(endpoint.EffectiveIP())) + if ip == nil { + return "" + } + parsed, err := url.Parse(connectionHost) + if err != nil || parsed.Hostname() == "" { + return "" + } + host := parsed.Hostname() + if net.ParseIP(host) != nil || strings.Contains(host, ".") { + return "" + } + port := parsed.Port() + if port == "" { + port = "8006" + } + scheme := parsed.Scheme + if scheme == "" { + scheme = "https" + } + return scheme + "://" + net.JoinHostPort(ip.String(), port) +} + func (m *Monitor) determineNodeIDAndStatus(instanceName string, instanceCfg *config.PVEInstance, node proxmox.Node) (string, string) { // Apply grace period for node status to prevent flapping // The configured instance is the canonical provider identity. Normally a diff --git a/internal/monitoring/monitor_pve_cluster_refresh_test.go b/internal/monitoring/monitor_pve_cluster_refresh_test.go index 4693ae025f..f0c6fd06f8 100644 --- a/internal/monitoring/monitor_pve_cluster_refresh_test.go +++ b/internal/monitoring/monitor_pve_cluster_refresh_test.go @@ -472,3 +472,78 @@ func TestBuildClusterEndpointsRetainsDuplicatePinConflicts(t *testing.T) { } } } + +// A member that joined after the others had their certificates pinned is +// dialed by its bare node name under strict TLS. Its web interface link must +// use the address Proxmox reports for it, not leave the frontend to guess one +// from interfaces every member shares. +func TestResolveNodeConnectionInfoUnpinnedStrictMemberLinksToClusterIP(t *testing.T) { + instance := &config.PVEInstance{ + Name: "nexus", + Host: "https://192.168.13.5:8006", + Fingerprint: "AA:BB", + VerifySSL: true, + IsCluster: true, + ClusterEndpoints: []config.ClusterEndpoint{ + {NodeName: "aurora", Host: "https://aurora:8006", IP: "192.168.13.5", Fingerprint: "AA:BB"}, + {NodeName: "venus", Host: "https://venus:8006", IP: "192.168.13.8"}, + }, + } + + host, guestURL := resolveNodeConnectionInfo(instance, config.DiscoveryConfig{}, "venus") + if host != "https://venus:8006" { + t.Fatalf("connection host = %q, want the hostname strict TLS validates", host) + } + if guestURL != "https://192.168.13.8:8006" { + t.Fatalf("guest URL = %q, want the Proxmox-reported member address", guestURL) + } + + host, guestURL = resolveNodeConnectionInfo(instance, config.DiscoveryConfig{}, "aurora") + if host != "https://192.168.13.5:8006" || guestURL != "" { + t.Fatalf("pinned member = (%q, %q), want its IP dial URL and no derived link", host, guestURL) + } +} + +func TestResolveNodeConnectionInfoKeepsOperatorLinks(t *testing.T) { + instance := &config.PVEInstance{ + Name: "nexus", + Host: "https://192.168.13.5:8006", + VerifySSL: true, + IsCluster: true, + ClusterEndpoints: []config.ClusterEndpoint{ + {NodeName: "venus", Host: "https://venus:8006", IP: "192.168.13.8", GuestURL: "https://venus.example.test"}, + }, + } + if _, guestURL := resolveNodeConnectionInfo(instance, config.DiscoveryConfig{}, "venus"); guestURL != "https://venus.example.test" { + t.Fatalf("member link override = %q", guestURL) + } + + instance.ClusterEndpoints[0].GuestURL = "" + instance.GuestURL = "https://cluster.example.test" + if _, guestURL := resolveNodeConnectionInfo(instance, config.DiscoveryConfig{}, "venus"); guestURL != "https://cluster.example.test" { + t.Fatalf("connection link override = %q", guestURL) + } +} + +func TestClusterMemberBrowserURL(t *testing.T) { + cases := []struct { + name string + endpoint config.ClusterEndpoint + host string + want string + }{ + {"bare name uses reported IP", config.ClusterEndpoint{IP: "192.168.13.8"}, "https://venus:8006", "https://192.168.13.8:8006"}, + {"custom port kept", config.ClusterEndpoint{IP: "10.0.0.2"}, "https://venus:9443", "https://10.0.0.2:9443"}, + {"IPv6 bracketed", config.ClusterEndpoint{IP: "fd00::8"}, "https://venus:8006", "https://[fd00::8]:8006"}, + {"FQDN left to the browser", config.ClusterEndpoint{IP: "192.168.13.8"}, "https://venus.lab.test:8006", ""}, + {"IP dial needs nothing", config.ClusterEndpoint{IP: "192.168.13.8"}, "https://192.168.13.8:8006", ""}, + {"no reported address", config.ClusterEndpoint{}, "https://venus:8006", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := clusterMemberBrowserURL(tc.endpoint, tc.host); got != tc.want { + t.Fatalf("clusterMemberBrowserURL = %q, want %q", got, tc.want) + } + }) + } +}