Link unpinned cluster members to their Proxmox address

Under strict TLS a cluster member without its own certificate pin is
dialed by its bare node name, which is what happens to a node that joins
after the others were pinned. Its runtime link was left empty, so the
frontend swapped the bare name for an address guessed from the node's
interfaces and picked a bridge every member shares. A support report
had the newest node's web interface link open 172.16.0.1 instead of
the node's management address.

When neither the member nor the connection has a link override and the
dial URL is a bare label, use the address Proxmox reports for that
member, keeping the dial scheme and port. Dialing is unchanged.
This commit is contained in:
rcourtman
2026-10-24 23:03:18 +01:00
parent c6666e9b94
commit 95bc267bfd
3 changed files with 126 additions and 0 deletions
@@ -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).
@@ -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
@@ -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)
}
})
}
}