feat: unify and improve Proxmox discovery/scanning architecture

Replaced inconsistent per-product detection logic with a unified probe
architecture using confidence scoring and product-specific matchers.

Key improvements:
- PBS detection now inspects TLS certs, auth headers (401/403), and
  probes PBS-specific endpoints (/api2/json/status, /config/datastore)
  fixing false negatives for self-signed and auth-protected servers
- PMG detection uses header analysis first, then conditional endpoint
  probing, working consistently regardless of port
- Single unified probeProxmoxService() replaces separate checkPort8006()
  and checkServer() code paths, eliminating duplication
- Confidence scoring (0.0-1.0+) with evidence tracking for debugging
- Consolidated hostname resolution and version handling

Technical changes:
- Added ProxmoxProbeResult with structured evidence and scoring
- Added product matchers: applyPVEHeuristics, applyPMGHeuristics,
  applyPBSHeuristics
- Removed legacy methods: checkPort8006, checkServer, isPMGServer,
  detectProductFromEndpoint, and duplicate hostname helpers
- Updated all tests to use new unified probe architecture
- Added probe_test_helpers.go for test access to internal methods

All tests passing. Fixes PBS detection issues and improves consistency
across PVE/PMG/PBS discovery.
This commit is contained in:
rcourtman 2025-10-21 13:09:41 +00:00
parent e0396c1362
commit 7c00055047
3 changed files with 766 additions and 464 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,21 @@
package discovery
import (
"context"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
"context"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/discovery/envdetect"
"github.com/rcourtman/pulse-go-rewrite/pkg/discovery/envdetect"
)
func newTestScanner(client *http.Client) *Scanner {
@ -124,23 +124,26 @@ func TestDetectProductFromEndpoint(t *testing.T) {
}))
defer ts.Close()
scanner := newTestScanner(ts.Client())
scanner := newTestScanner(ts.Client())
address := strings.TrimPrefix(ts.URL, "https://")
if product := scanner.detectProductFromEndpoint(context.Background(), address, "api2/json/statistics/mail"); product != "pmg" {
t.Fatalf("detectProductFromEndpoint returned %q, want %q", product, "pmg")
finding := scanner.ProbeAPIEndpoint(context.Background(), address, "api2/json/statistics/mail")
if finding.ProductGuess != ProductPMG {
t.Fatalf("ProbeAPIEndpoint returned %q, want %q", finding.ProductGuess, ProductPMG)
}
if product := scanner.detectProductFromEndpoint(context.Background(), address, "api2/json/version"); product != "pbs" {
t.Fatalf("detectProductFromEndpoint returned %q, want %q", product, "pbs")
versionFinding := scanner.ProbeAPIEndpoint(context.Background(), address, "api2/json/version")
if versionFinding.ProductGuess != ProductPBS {
t.Fatalf("ProbeAPIEndpoint returned %q, want %q", versionFinding.ProductGuess, ProductPBS)
}
if product := scanner.detectProductFromEndpoint(context.Background(), address, "api2/json/unknown/path"); product != "" {
t.Fatalf("expected empty result for unknown endpoint, got %q", product)
unknownFinding := scanner.ProbeAPIEndpoint(context.Background(), address, "api2/json/unknown/path")
if unknownFinding.ProductGuess != "" || unknownFinding.Status != http.StatusNotFound {
t.Fatalf("expected empty result for unknown endpoint, got %+v", unknownFinding)
}
if len(requestPaths) == 0 {
t.Fatalf("expected detectProductFromEndpoint to perform requests")
t.Fatalf("expected ProbeAPIEndpoint to perform requests")
}
}
@ -148,8 +151,10 @@ func TestIsPMGServer(t *testing.T) {
t.Parallel()
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "statistics/mail") {
w.Header().Set("Proxmox-Product", "Proxmox Mail Gateway")
w.Header().Set("Proxmox-Product", "Proxmox Mail Gateway")
w.Header().Set("WWW-Authenticate", `PMGAuth realm="Proxmox Mail Gateway"`)
if strings.Contains(r.URL.Path, "statistics/mail") ||
strings.Contains(r.URL.Path, "api2/json/version") {
w.WriteHeader(http.StatusOK)
return
}
@ -157,19 +162,39 @@ func TestIsPMGServer(t *testing.T) {
}))
defer ts.Close()
scanner := newTestScanner(ts.Client())
scanner := newTestScanner(ts.Client())
address := strings.TrimPrefix(ts.URL, "https://")
if !scanner.isPMGServer(context.Background(), address) {
t.Fatalf("expected PMG detection to succeed")
host, portStr, err := net.SplitHostPort(ts.Listener.Addr().String())
if err != nil {
t.Fatalf("SplitHostPort: %v", err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("strconv.Atoi: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
probe := scanner.ProbeProxmoxService(ctx, host, port)
if probe == nil || !probe.Positive || probe.PrimaryProduct != ProductPMG {
t.Fatalf("expected PMG detection to succeed, got %+v", probe)
}
tsNoMatch := httptest.NewTLSServer(http.NotFoundHandler())
defer tsNoMatch.Close()
scanner.httpClient = tsNoMatch.Client()
address = strings.TrimPrefix(tsNoMatch.URL, "https://")
if scanner.isPMGServer(context.Background(), address) {
host, portStr, err = net.SplitHostPort(tsNoMatch.Listener.Addr().String())
if err != nil {
t.Fatalf("SplitHostPort: %v", err)
}
port, err = strconv.Atoi(portStr)
if err != nil {
t.Fatalf("strconv.Atoi: %v", err)
}
probe = scanner.ProbeProxmoxService(ctx, host, port)
if probe != nil && probe.Positive {
t.Fatalf("expected PMG detection to fail for endpoints without markers")
}
}
@ -201,26 +226,26 @@ func TestCheckServerRetrievesVersion(t *testing.T) {
t.Fatalf("strconv.Atoi: %v", err)
}
scanner := newTestScanner(ts.Client())
scanner := newTestScanner(ts.Client())
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
server := scanner.checkServer(ctx, host, port, "pbs")
if server == nil {
t.Fatalf("checkServer returned nil")
probe := scanner.ProbeProxmoxService(ctx, host, port)
if probe == nil || !probe.Positive {
t.Fatalf("ProbeProxmoxService returned nil")
}
if server.Type != "pbs" {
t.Fatalf("expected type pbs, got %q", server.Type)
if probe.PrimaryProduct != ProductPBS {
t.Fatalf("expected product pbs, got %q", probe.PrimaryProduct)
}
if server.Version != "2.4.1" {
t.Fatalf("expected version 2.4.1, got %q", server.Version)
if probe.Version != "2.4.1" {
t.Fatalf("expected version 2.4.1, got %q", probe.Version)
}
if server.Release != "1" {
t.Fatalf("expected release 1, got %q", server.Release)
if probe.Release != "1" {
t.Fatalf("expected release 1, got %q", probe.Release)
}
}
@ -249,25 +274,25 @@ func TestCheckServerHandlesUnauthorized(t *testing.T) {
srv := startTLSServerOn(t, "127.0.0.1:9008", unauthorizedHandler)
_ = srv
scanner := newTestScanner(&http.Client{
Timeout: 500 * time.Millisecond,
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
})
scanner := newTestScanner(&http.Client{
Timeout: 500 * time.Millisecond,
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
})
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
server := scanner.checkServer(ctx, "127.0.0.1", 9008, "pve")
if server == nil {
t.Fatalf("expected server discovery despite unauthorized response")
probe := scanner.ProbeProxmoxService(ctx, "127.0.0.1", 9008)
if probe == nil || !probe.Positive {
t.Fatalf("expected server discovery despite unauthorized response: %+v", probe)
}
if server.Type != "pve" {
t.Fatalf("expected type pve, got %q", server.Type)
if probe.PrimaryProduct != ProductPVE {
t.Fatalf("expected product pve, got %q", probe.PrimaryProduct)
}
if server.Version != "Unknown" {
t.Fatalf("expected version Unknown, got %q", server.Version)
if probe.Version != "Unknown" {
t.Fatalf("expected version Unknown, got %q", probe.Version)
}
}
@ -338,10 +363,8 @@ func TestDiscoverServersWithCallback(t *testing.T) {
var callbacks []DiscoveredServer
// Add a manual check for the TCP-only port.
// We call the unexported helper directly inside the package to ensure it does not panic.
if server := scanner.checkServer(ctx, "127.0.0.1", 9009, "pve"); server == nil {
// keep discovery results unaffected but verify we survive the TCP-only host
t.Fatalf("expected checkServer to handle TCP-only host without panic")
if probe := scanner.ProbeProxmoxService(ctx, "127.0.0.1", 9009); probe != nil && probe.Positive {
t.Fatalf("expected ProbeProxmoxService to ignore TCP-only host, got %+v", probe)
}
result, err := scanner.DiscoverServersWithCallback(ctx, subnet, func(server DiscoveredServer, phase string) {

View file

@ -0,0 +1,17 @@
package discovery
import "context"
const (
ProductPVE = productPVE
ProductPMG = productPMG
ProductPBS = productPBS
)
func (s *Scanner) ProbeProxmoxService(ctx context.Context, ip string, port int) *ProxmoxProbeResult {
return s.probeProxmoxService(ctx, ip, port)
}
func (s *Scanner) ProbeAPIEndpoint(ctx context.Context, address, endpoint string) EndpointProbeFinding {
return s.probeAPIEndpoint(ctx, address, endpoint)
}