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:
parent
e0396c1362
commit
7c00055047
3 changed files with 766 additions and 464 deletions
|
|
@ -146,6 +146,49 @@ type ScanProgress struct {
|
||||||
Percentage float64 `json:"percentage"`
|
Percentage float64 `json:"percentage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EndpointProbeFinding struct {
|
||||||
|
Endpoint string
|
||||||
|
Status int
|
||||||
|
Headers http.Header
|
||||||
|
ProductGuess string
|
||||||
|
Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxmoxProbeResult struct {
|
||||||
|
IP string
|
||||||
|
Port int
|
||||||
|
Reachable bool
|
||||||
|
TLSState *tls.ConnectionState
|
||||||
|
TLSHandshakeError error
|
||||||
|
|
||||||
|
Version string
|
||||||
|
Release string
|
||||||
|
VersionStatus int
|
||||||
|
VersionError error
|
||||||
|
Headers http.Header
|
||||||
|
|
||||||
|
EndpointFindings map[string]EndpointProbeFinding
|
||||||
|
|
||||||
|
ProductScores map[string]float64
|
||||||
|
ProductEvidence map[string][]string
|
||||||
|
PrimaryProduct string
|
||||||
|
PrimaryScore float64
|
||||||
|
|
||||||
|
Positive bool
|
||||||
|
PositiveReasons []string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
productPVE = "pve"
|
||||||
|
productPMG = "pmg"
|
||||||
|
productPBS = "pbs"
|
||||||
|
|
||||||
|
productPositiveThreshold = 0.7
|
||||||
|
)
|
||||||
|
|
||||||
|
var proxmoxProbePorts = [...]int{8006, 8007}
|
||||||
|
|
||||||
// DiscoverServers scans the network for Proxmox VE and PBS servers
|
// DiscoverServers scans the network for Proxmox VE and PBS servers
|
||||||
func (s *Scanner) DiscoverServers(ctx context.Context, subnet string) (*DiscoveryResult, error) {
|
func (s *Scanner) DiscoverServers(ctx context.Context, subnet string) (*DiscoveryResult, error) {
|
||||||
return s.DiscoverServersWithCallbacks(ctx, subnet, nil, nil)
|
return s.DiscoverServersWithCallbacks(ctx, subnet, nil, nil)
|
||||||
|
|
@ -290,6 +333,104 @@ type phaseError struct {
|
||||||
Message string
|
Message string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) discoverAtPort(ctx context.Context, ip string, port int) *DiscoveredServer {
|
||||||
|
probe := s.probeProxmoxService(ctx, ip, port)
|
||||||
|
if probe == nil || !probe.Positive {
|
||||||
|
if probe != nil && probe.Err != nil && !errors.Is(probe.Err, context.Canceled) {
|
||||||
|
log.Debug().
|
||||||
|
Str("ip", ip).
|
||||||
|
Int("port", port).
|
||||||
|
Float64("confidence", probe.PrimaryScore).
|
||||||
|
Err(probe.Err).
|
||||||
|
Msg("Probe completed without identification")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.buildServerFromProbe(ctx, probe)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) buildServerFromProbe(ctx context.Context, probe *ProxmoxProbeResult) *DiscoveredServer {
|
||||||
|
product := strings.TrimSpace(probe.PrimaryProduct)
|
||||||
|
if product == "" {
|
||||||
|
log.Debug().
|
||||||
|
Str("ip", probe.IP).
|
||||||
|
Int("port", probe.Port).
|
||||||
|
Float64("confidence", probe.PrimaryScore).
|
||||||
|
Msg("Probe identified Proxmox server but product type is ambiguous")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
version := strings.TrimSpace(probe.Version)
|
||||||
|
if version == "" {
|
||||||
|
version = "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
server := &DiscoveredServer{
|
||||||
|
IP: probe.IP,
|
||||||
|
Port: probe.Port,
|
||||||
|
Type: product,
|
||||||
|
Version: version,
|
||||||
|
Release: probe.Release,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.populateServerHostname(ctx, server)
|
||||||
|
|
||||||
|
log.Info().
|
||||||
|
Str("ip", server.IP).
|
||||||
|
Int("port", server.Port).
|
||||||
|
Str("type", server.Type).
|
||||||
|
Str("version", server.Version).
|
||||||
|
Float64("confidence", probe.PrimaryScore).
|
||||||
|
Msg("Discovered Proxmox server")
|
||||||
|
|
||||||
|
if len(probe.PositiveReasons) > 0 {
|
||||||
|
log.Debug().
|
||||||
|
Str("ip", server.IP).
|
||||||
|
Int("port", server.Port).
|
||||||
|
Str("type", server.Type).
|
||||||
|
Float64("confidence", probe.PrimaryScore).
|
||||||
|
Strs("evidence", probe.PositiveReasons).
|
||||||
|
Msg("Probe evidence")
|
||||||
|
}
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) populateServerHostname(ctx context.Context, server *DiscoveredServer) {
|
||||||
|
if server == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.policy.EnableReverseDNS {
|
||||||
|
names, err := net.DefaultResolver.LookupAddr(ctx, server.IP)
|
||||||
|
if err == nil && len(names) > 0 {
|
||||||
|
hostname := strings.TrimSuffix(names[0], ".")
|
||||||
|
if hostname != "" {
|
||||||
|
server.Hostname = hostname
|
||||||
|
log.Debug().
|
||||||
|
Str("ip", server.IP).
|
||||||
|
Int("port", server.Port).
|
||||||
|
Str("hostname", hostname).
|
||||||
|
Msg("Resolved hostname via reverse DNS")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch server.Type {
|
||||||
|
case productPVE, productPBS:
|
||||||
|
if hostname := s.fetchNodeHostname(ctx, server.IP, server.Port); hostname != "" {
|
||||||
|
server.Hostname = hostname
|
||||||
|
log.Debug().
|
||||||
|
Str("ip", server.IP).
|
||||||
|
Int("port", server.Port).
|
||||||
|
Str("hostname", hostname).
|
||||||
|
Msg("Resolved hostname via API nodes endpoint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// scanWorker scans IPs from the channel
|
// scanWorker scans IPs from the channel
|
||||||
// NOTE: This function is kept for backward compatibility but is not actively used.
|
// NOTE: This function is kept for backward compatibility but is not actively used.
|
||||||
// New code should use scanWorkerWithProgress which includes progress tracking.
|
// New code should use scanWorkerWithProgress which includes progress tracking.
|
||||||
|
|
@ -301,12 +442,10 @@ func (s *Scanner) scanWorker(ctx context.Context, wg *sync.WaitGroup, phase stri
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
if server := s.checkPort8006(ctx, ip); server != nil {
|
for _, port := range proxmoxProbePorts {
|
||||||
|
if server := s.discoverAtPort(ctx, ip, port); server != nil {
|
||||||
resultChan <- discoveredResult{Phase: phase, Server: server}
|
resultChan <- discoveredResult{Phase: phase, Server: server}
|
||||||
}
|
}
|
||||||
|
|
||||||
if server := s.checkServer(ctx, ip, 8007, "pbs"); server != nil {
|
|
||||||
resultChan <- discoveredResult{Phase: phase, Server: server}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -321,12 +460,10 @@ func (s *Scanner) scanWorkerWithProgress(ctx context.Context, wg *sync.WaitGroup
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
if server := s.checkPort8006(ctx, ip); server != nil {
|
for _, port := range proxmoxProbePorts {
|
||||||
|
if server := s.discoverAtPort(ctx, ip, port); server != nil {
|
||||||
resultChan <- discoveredResult{Phase: phase, Server: server}
|
resultChan <- discoveredResult{Phase: phase, Server: server}
|
||||||
}
|
}
|
||||||
|
|
||||||
if server := s.checkServer(ctx, ip, 8007, "pbs"); server != nil {
|
|
||||||
resultChan <- discoveredResult{Phase: phase, Server: server}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signal that this IP has been processed
|
// Signal that this IP has been processed
|
||||||
|
|
@ -741,173 +878,357 @@ func max(a, b int) int {
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkPort8006 checks if port 8006 is running PMG or PVE
|
func newProxmoxProbeResult(ip string, port int) *ProxmoxProbeResult {
|
||||||
func (s *Scanner) checkPort8006(ctx context.Context, ip string) *DiscoveredServer {
|
return &ProxmoxProbeResult{
|
||||||
address := net.JoinHostPort(ip, "8006")
|
IP: ip,
|
||||||
|
Port: port,
|
||||||
|
ProductScores: map[string]float64{},
|
||||||
|
ProductEvidence: map[string][]string{},
|
||||||
|
EndpointFindings: map[string]EndpointProbeFinding{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// First attempt a TLS handshake with proper timeout so we can inspect certificate metadata.
|
func (r *ProxmoxProbeResult) addConfidence(product, reason string, score float64, positive bool) {
|
||||||
var tlsState *tls.ConnectionState
|
if score <= 0 {
|
||||||
timeout := s.policy.DialTimeout
|
return
|
||||||
if timeout <= 0 {
|
|
||||||
timeout = time.Second
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use context with timeout for TLS dial to prevent hangs
|
if product != "" {
|
||||||
tlsCtx, cancel := context.WithTimeout(ctx, timeout)
|
r.ProductScores[product] += score
|
||||||
defer cancel()
|
if reason != "" {
|
||||||
|
r.ProductEvidence[product] = append(r.ProductEvidence[product], reason)
|
||||||
dialer := &net.Dialer{Timeout: timeout}
|
|
||||||
tlsConn, tlsErr := tls.DialWithDialer(dialer, "tcp", address, &tls.Config{InsecureSkipVerify: true})
|
|
||||||
if tlsErr != nil {
|
|
||||||
// If TLS fails completely, try a context-aware TCP dial
|
|
||||||
conn, err := dialer.DialContext(tlsCtx, "tcp", address)
|
|
||||||
if err != nil {
|
|
||||||
return nil // Port not open or unreachable
|
|
||||||
}
|
}
|
||||||
conn.Close()
|
}
|
||||||
// Port is open but TLS failed - continue to HTTP check
|
|
||||||
|
if positive {
|
||||||
|
if reason == "" {
|
||||||
|
reason = "positive identification"
|
||||||
|
}
|
||||||
|
if product != "" {
|
||||||
|
r.PositiveReasons = append(r.PositiveReasons, fmt.Sprintf("%s: %s", product, reason))
|
||||||
} else {
|
} else {
|
||||||
state := tlsConn.ConnectionState()
|
r.PositiveReasons = append(r.PositiveReasons, reason)
|
||||||
tlsState = &state
|
|
||||||
tlsConn.Close()
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Track whether we got positive identification
|
func (r *ProxmoxProbeResult) recordEndpoint(f EndpointProbeFinding) {
|
||||||
positiveIdentification := false
|
if f.Endpoint == "" {
|
||||||
serverType := "pve" // Default assumption
|
return
|
||||||
version := "Unknown"
|
}
|
||||||
var release string
|
if r.EndpointFindings == nil {
|
||||||
|
r.EndpointFindings = map[string]EndpointProbeFinding{}
|
||||||
|
}
|
||||||
|
r.EndpointFindings[f.Endpoint] = f
|
||||||
|
}
|
||||||
|
|
||||||
// Infer from certificate if available
|
func (r *ProxmoxProbeResult) endpointFinding(endpoint string) (EndpointProbeFinding, bool) {
|
||||||
if tlsState != nil {
|
if r.EndpointFindings == nil {
|
||||||
if guess := inferTypeFromCertificate(*tlsState); guess != "" {
|
return EndpointProbeFinding{}, false
|
||||||
serverType = guess
|
}
|
||||||
positiveIdentification = true // Certificate indicates Proxmox
|
f, ok := r.EndpointFindings[endpoint]
|
||||||
|
return f, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProxmoxProbeResult) finalize() {
|
||||||
|
var (
|
||||||
|
bestProduct string
|
||||||
|
bestScore float64
|
||||||
|
)
|
||||||
|
for product, score := range r.ProductScores {
|
||||||
|
if score > bestScore {
|
||||||
|
bestProduct = product
|
||||||
|
bestScore = score
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get version or authentication headers
|
r.PrimaryProduct = bestProduct
|
||||||
versionURL := fmt.Sprintf("https://%s/api2/json/version", address)
|
r.PrimaryScore = bestScore
|
||||||
if req, err := http.NewRequestWithContext(ctx, "GET", versionURL, nil); err == nil {
|
r.Positive = len(r.PositiveReasons) > 0
|
||||||
if resp, err := s.httpClient.Do(req); err == nil {
|
if r.Positive {
|
||||||
defer resp.Body.Close()
|
r.Err = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch resp.StatusCode {
|
func (s *Scanner) probeProxmoxService(ctx context.Context, ip string, port int) *ProxmoxProbeResult {
|
||||||
|
address := net.JoinHostPort(ip, strconv.Itoa(port))
|
||||||
|
result := newProxmoxProbeResult(ip, port)
|
||||||
|
|
||||||
|
tlsState, reachable, tlsErr := s.performTLSProbe(ctx, address)
|
||||||
|
result.TLSState = tlsState
|
||||||
|
result.Reachable = reachable
|
||||||
|
result.TLSHandshakeError = tlsErr
|
||||||
|
|
||||||
|
if !reachable {
|
||||||
|
result.Err = tlsErr
|
||||||
|
result.finalize()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
versionFinding, version, release := s.probeVersionEndpoint(ctx, address)
|
||||||
|
result.recordEndpoint(versionFinding)
|
||||||
|
result.VersionStatus = versionFinding.Status
|
||||||
|
result.VersionError = versionFinding.Error
|
||||||
|
result.Version = version
|
||||||
|
result.Release = release
|
||||||
|
result.Headers = cloneHeader(versionFinding.Headers)
|
||||||
|
|
||||||
|
s.applyProductMatchers(ctx, address, result)
|
||||||
|
|
||||||
|
if strings.TrimSpace(result.Version) == "" {
|
||||||
|
result.Version = "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Positive {
|
||||||
|
if tlsErr != nil {
|
||||||
|
result.Err = tlsErr
|
||||||
|
} else if result.VersionError != nil {
|
||||||
|
result.Err = result.VersionError
|
||||||
|
} else {
|
||||||
|
result.Err = errors.New("no positive identification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.finalize()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) applyProductMatchers(ctx context.Context, address string, result *ProxmoxProbeResult) {
|
||||||
|
applySharedHeuristics(result)
|
||||||
|
applyPVEHeuristics(result)
|
||||||
|
s.applyPMGHeuristics(ctx, address, result)
|
||||||
|
s.applyPBSHeuristics(ctx, address, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySharedHeuristics(result *ProxmoxProbeResult) {
|
||||||
|
switch result.Port {
|
||||||
|
case 8006:
|
||||||
|
result.addConfidence(productPVE, "port 8006 reachable", 0.1, false)
|
||||||
|
result.addConfidence(productPMG, "port 8006 reachable", 0.1, false)
|
||||||
|
case 8007:
|
||||||
|
result.addConfidence(productPBS, "port 8007 reachable", 0.2, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.TLSState != nil {
|
||||||
|
if product := inferTypeFromCertificate(*result.TLSState); product != "" {
|
||||||
|
result.addConfidence(product, "TLS certificate metadata", 0.6, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
versionFinding, ok := result.endpointFinding("api2/json/version")
|
||||||
|
if !ok || versionFinding.Error != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch versionFinding.Status {
|
||||||
case http.StatusOK:
|
case http.StatusOK:
|
||||||
var versionResp struct {
|
if result.Version != "" {
|
||||||
Data struct {
|
result.addConfidence("", "version endpoint JSON responded", 0.35, true)
|
||||||
Version string `json:"version"`
|
} else {
|
||||||
Release string `json:"release,omitempty"`
|
result.addConfidence("", "version endpoint responded", 0.25, true)
|
||||||
} `json:"data"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&versionResp); err == nil && versionResp.Data.Version != "" {
|
if versionFinding.ProductGuess != "" {
|
||||||
version = versionResp.Data.Version
|
result.addConfidence(versionFinding.ProductGuess, "version endpoint headers", 0.45, true)
|
||||||
release = versionResp.Data.Release
|
|
||||||
positiveIdentification = true // Got valid version data
|
|
||||||
|
|
||||||
if guess := inferTypeFromMetadata(
|
|
||||||
versionResp.Data.Version,
|
|
||||||
versionResp.Data.Release,
|
|
||||||
resp.Header.Get("Server"),
|
|
||||||
resp.Header.Get("Proxmox-Product"),
|
|
||||||
resp.Header.Get("WWW-Authenticate"),
|
|
||||||
strings.Join(resp.Header.Values("Set-Cookie"), " "),
|
|
||||||
); guess != "" {
|
|
||||||
serverType = guess
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debug().
|
if guess := inferTypeFromMetadata(result.Version, result.Release); guess != "" {
|
||||||
Str("ip", ip).
|
result.addConfidence(guess, "version payload metadata", 0.2, true)
|
||||||
Int("port", 8006).
|
}
|
||||||
Str("version", version).
|
|
||||||
Msg("Got server version without auth")
|
if versionFinding.ProductGuess == "" {
|
||||||
|
for _, product := range defaultProductsForPort(result.Port) {
|
||||||
|
result.addConfidence(product, "version endpoint success without explicit product", 0.2, true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case http.StatusUnauthorized, http.StatusForbidden:
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
// Check for Proxmox-specific auth headers
|
if versionFinding.ProductGuess != "" {
|
||||||
|
result.addConfidence(versionFinding.ProductGuess, "auth headers indicated product", 0.4, true)
|
||||||
|
} else {
|
||||||
|
for _, product := range defaultProductsForPort(result.Port) {
|
||||||
|
reason := fmt.Sprintf("version endpoint on %s port requires authentication", product)
|
||||||
|
result.addConfidence(product, reason, 0.25, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPVEHeuristics(result *ProxmoxProbeResult) {
|
||||||
|
if result.Port != 8006 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lowerVersion := strings.ToLower(result.Version)
|
||||||
|
lowerRelease := strings.ToLower(result.Release)
|
||||||
|
|
||||||
|
if strings.Contains(lowerVersion, "pve") || strings.Contains(lowerRelease, "pve") {
|
||||||
|
result.addConfidence(productPVE, "version metadata references pve", 0.4, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Headers != nil {
|
||||||
|
if server := strings.ToLower(result.Headers.Get("Server")); strings.Contains(server, "pve") {
|
||||||
|
result.addConfidence(productPVE, "server header references pve", 0.35, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ProductScores[productPMG] >= productPositiveThreshold {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.VersionStatus == http.StatusOK && result.Version != "" && result.ProductScores[productPVE] < 0.5 {
|
||||||
|
result.addConfidence(productPVE, "version endpoint success on port 8006", 0.25, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) applyPMGHeuristics(ctx context.Context, address string, result *ProxmoxProbeResult) {
|
||||||
|
versionFinding, _ := result.endpointFinding("api2/json/version")
|
||||||
|
hasPMGSignal := false
|
||||||
|
|
||||||
|
if versionFinding.ProductGuess == productPMG {
|
||||||
|
hasPMGSignal = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Headers != nil {
|
||||||
if guess := inferTypeFromMetadata(
|
if guess := inferTypeFromMetadata(
|
||||||
resp.Header.Get("WWW-Authenticate"),
|
result.Headers.Get("Proxmox-Product"),
|
||||||
resp.Header.Get("Server"),
|
result.Headers.Get("Server"),
|
||||||
resp.Header.Get("Proxmox-Product"),
|
result.Headers.Get("WWW-Authenticate"),
|
||||||
); guess != "" {
|
strings.Join(result.Headers.Values("Set-Cookie"), " "),
|
||||||
serverType = guess
|
); guess == productPMG {
|
||||||
positiveIdentification = true // Proxmox auth headers present
|
result.addConfidence(productPMG, "version headers reference pmg", 0.45, true)
|
||||||
}
|
hasPMGSignal = true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: probe PMG-specific endpoints if we still think this is a PVE server.
|
if result.Port != 8006 && !hasPMGSignal {
|
||||||
if serverType != "pmg" && s.isPMGServer(ctx, address) {
|
return
|
||||||
serverType = "pmg"
|
|
||||||
positiveIdentification = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only report server if we got positive identification
|
if result.ProductScores[productPMG] >= productPositiveThreshold {
|
||||||
// (not just an open port)
|
return
|
||||||
if !positiveIdentification {
|
}
|
||||||
log.Debug().
|
|
||||||
Str("ip", ip).
|
pmgEndpoints := []struct {
|
||||||
Int("port", 8006).
|
Path string
|
||||||
Msg("Port 8006 open but no Proxmox identification found")
|
Weight float64
|
||||||
|
}{
|
||||||
|
{"api2/json/statistics/mail", 0.4},
|
||||||
|
{"api2/json/mail/queue", 0.35},
|
||||||
|
{"api2/json/mail/quarantine", 0.35},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, endpoint := range pmgEndpoints {
|
||||||
|
if _, ok := result.EndpointFindings[endpoint.Path]; !ok {
|
||||||
|
finding := s.probeAPIEndpoint(ctx, address, endpoint.Path)
|
||||||
|
result.recordEndpoint(finding)
|
||||||
|
}
|
||||||
|
|
||||||
|
finding, ok := result.endpointFinding(endpoint.Path)
|
||||||
|
if !ok || finding.Error != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if finding.ProductGuess == productPMG {
|
||||||
|
result.addConfidence(productPMG, fmt.Sprintf("endpoint %s headers", endpoint.Path), endpoint.Weight, true)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if finding.Status != http.StatusNotFound && finding.Status != 0 {
|
||||||
|
result.addConfidence(productPMG, fmt.Sprintf("endpoint %s responded", endpoint.Path), endpoint.Weight-0.05, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) applyPBSHeuristics(ctx context.Context, address string, result *ProxmoxProbeResult) {
|
||||||
|
if result.Port != 8007 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
versionFinding, _ := result.endpointFinding("api2/json/version")
|
||||||
|
|
||||||
|
if result.Headers != nil {
|
||||||
|
if guess := inferTypeFromMetadata(
|
||||||
|
result.Headers.Get("Proxmox-Product"),
|
||||||
|
result.Headers.Get("Server"),
|
||||||
|
result.Headers.Get("WWW-Authenticate"),
|
||||||
|
strings.Join(result.Headers.Values("Set-Cookie"), " "),
|
||||||
|
); guess == productPBS {
|
||||||
|
result.addConfidence(productPBS, "version headers reference pbs", 0.4, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch versionFinding.Status {
|
||||||
|
case http.StatusOK:
|
||||||
|
if result.Version != "" {
|
||||||
|
result.addConfidence(productPBS, "version endpoint returned JSON", 0.45, true)
|
||||||
|
} else {
|
||||||
|
result.addConfidence(productPBS, "version endpoint responded", 0.35, true)
|
||||||
|
}
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
if versionFinding.ProductGuess == productPBS {
|
||||||
|
result.addConfidence(productPBS, "auth headers indicated PBS", 0.45, true)
|
||||||
|
} else {
|
||||||
|
result.addConfidence(productPBS, "version endpoint on PBS port requires auth", 0.35, true)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if result.Reachable && result.ProductScores[productPBS] < 0.25 {
|
||||||
|
result.addConfidence(productPBS, "port 8007 reachable", 0.25, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ProductScores[productPBS] >= productPositiveThreshold {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pbsEndpoints := []struct {
|
||||||
|
Path string
|
||||||
|
Weight float64
|
||||||
|
SuccessNote string
|
||||||
|
}{
|
||||||
|
{"api2/json/status", 0.45, "status endpoint responded"},
|
||||||
|
{"api2/json/config/datastore", 0.35, "datastore endpoint reachable"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, endpoint := range pbsEndpoints {
|
||||||
|
if _, ok := result.EndpointFindings[endpoint.Path]; !ok {
|
||||||
|
finding := s.probeAPIEndpoint(ctx, address, endpoint.Path)
|
||||||
|
result.recordEndpoint(finding)
|
||||||
|
}
|
||||||
|
|
||||||
|
finding, ok := result.endpointFinding(endpoint.Path)
|
||||||
|
if !ok || finding.Error != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if finding.ProductGuess == productPBS {
|
||||||
|
result.addConfidence(productPBS, fmt.Sprintf("endpoint %s headers", endpoint.Path), endpoint.Weight, true)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if finding.Status != http.StatusNotFound && finding.Status != 0 {
|
||||||
|
reason := endpoint.SuccessNote
|
||||||
|
if endpoint.Path == "api2/json/config/datastore" && finding.Status == http.StatusUnauthorized {
|
||||||
|
reason = "datastore endpoint requires auth"
|
||||||
|
}
|
||||||
|
result.addConfidence(productPBS, reason, endpoint.Weight-0.05, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultProductsForPort(port int) []string {
|
||||||
|
switch port {
|
||||||
|
case 8006:
|
||||||
|
return []string{productPVE, productPMG}
|
||||||
|
case 8007:
|
||||||
|
return []string{productPBS}
|
||||||
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info().
|
|
||||||
Str("ip", ip).
|
|
||||||
Int("port", 8006).
|
|
||||||
Str("type", serverType).
|
|
||||||
Str("version", version).
|
|
||||||
Msg("Discovered Proxmox server")
|
|
||||||
|
|
||||||
server := &DiscoveredServer{
|
|
||||||
IP: ip,
|
|
||||||
Port: 8006,
|
|
||||||
Type: serverType,
|
|
||||||
Version: version,
|
|
||||||
Release: release,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to resolve hostname via reverse DNS
|
|
||||||
if s.policy.EnableReverseDNS {
|
|
||||||
names, err := net.DefaultResolver.LookupAddr(ctx, ip)
|
|
||||||
if err == nil && len(names) > 0 {
|
|
||||||
hostname := strings.TrimSuffix(names[0], ".")
|
|
||||||
server.Hostname = hostname
|
|
||||||
log.Debug().Str("ip", ip).Str("hostname", hostname).Msg("Resolved hostname via DNS")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// isPMGServer checks if a server is PMG by checking for PMG-specific endpoints
|
func (s *Scanner) fetchNodeHostname(ctx context.Context, ip string, port int) string {
|
||||||
func (s *Scanner) isPMGServer(ctx context.Context, address string) bool {
|
address := net.JoinHostPort(ip, strconv.Itoa(port))
|
||||||
endpoints := []string{
|
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://%s/api2/json/nodes", address), nil)
|
||||||
"api2/json/statistics/mail",
|
|
||||||
"api2/json/mail/queue",
|
|
||||||
"api2/json/mail/quarantine",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, endpoint := range endpoints {
|
|
||||||
product := s.detectProductFromEndpoint(ctx, address, endpoint)
|
|
||||||
if product == "pmg" {
|
|
||||||
log.Debug().
|
|
||||||
Str("address", address).
|
|
||||||
Str("endpoint", endpoint).
|
|
||||||
Msg("PMG-specific endpoint confirmed")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectProductFromEndpoint inspects an HTTP endpoint and tries to infer the product type.
|
|
||||||
func (s *Scanner) detectProductFromEndpoint(ctx context.Context, address, endpoint string) string {
|
|
||||||
url := fmt.Sprintf("https://%s/%s", address, endpoint)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
@ -918,22 +1239,133 @@ func (s *Scanner) detectProductFromEndpoint(ctx context.Context, address, endpoi
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
headerProduct := inferTypeFromMetadata(
|
var nodesResp struct {
|
||||||
|
Data []struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&nodesResp); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(nodesResp.Data) > 0 {
|
||||||
|
return nodesResp.Data[0].Node
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) performTLSProbe(ctx context.Context, address string) (*tls.ConnectionState, bool, error) {
|
||||||
|
timeout := s.policy.DialTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
dialer := &net.Dialer{Timeout: timeout}
|
||||||
|
tlsConn, err := tls.DialWithDialer(dialer, "tcp", address, &tls.Config{InsecureSkipVerify: true})
|
||||||
|
if err == nil {
|
||||||
|
state := tlsConn.ConnectionState()
|
||||||
|
tlsConn.Close()
|
||||||
|
return &state, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dialCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
conn, tcpErr := dialer.DialContext(dialCtx, "tcp", address)
|
||||||
|
if tcpErr != nil {
|
||||||
|
return nil, false, tcpErr
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) probeVersionEndpoint(ctx context.Context, address string) (EndpointProbeFinding, string, string) {
|
||||||
|
const endpoint = "api2/json/version"
|
||||||
|
|
||||||
|
finding := EndpointProbeFinding{Endpoint: endpoint}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://%s/%s", address, endpoint), nil)
|
||||||
|
if err != nil {
|
||||||
|
finding.Error = err
|
||||||
|
return finding, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
finding.Error = err
|
||||||
|
return finding, "", ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
finding.Status = resp.StatusCode
|
||||||
|
finding.Headers = cloneHeader(resp.Header)
|
||||||
|
finding.ProductGuess = inferTypeFromMetadata(
|
||||||
resp.Header.Get("Server"),
|
resp.Header.Get("Server"),
|
||||||
resp.Header.Get("Proxmox-Product"),
|
resp.Header.Get("Proxmox-Product"),
|
||||||
resp.Header.Get("WWW-Authenticate"),
|
resp.Header.Get("WWW-Authenticate"),
|
||||||
strings.Join(resp.Header.Values("Set-Cookie"), " "),
|
strings.Join(resp.Header.Values("Set-Cookie"), " "),
|
||||||
)
|
)
|
||||||
if headerProduct != "" {
|
|
||||||
return headerProduct
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return finding, "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the endpoint responded (not 404) and the path is PMG-specific, treat it as PMG.
|
var payload struct {
|
||||||
if resp.StatusCode != http.StatusNotFound && strings.Contains(endpoint, "mail") {
|
Data struct {
|
||||||
return "pmg"
|
Version string `json:"version"`
|
||||||
|
Release string `json:"release,omitempty"`
|
||||||
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
finding.Error = err
|
||||||
|
return finding, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return finding, payload.Data.Version, payload.Data.Release
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) probeAPIEndpoint(ctx context.Context, address, endpoint string) EndpointProbeFinding {
|
||||||
|
finding := EndpointProbeFinding{Endpoint: endpoint}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://%s/%s", address, endpoint), nil)
|
||||||
|
if err != nil {
|
||||||
|
finding.Error = err
|
||||||
|
return finding
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
finding.Error = err
|
||||||
|
return finding
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
finding.Status = resp.StatusCode
|
||||||
|
finding.Headers = cloneHeader(resp.Header)
|
||||||
|
finding.ProductGuess = inferTypeFromMetadata(
|
||||||
|
resp.Header.Get("Server"),
|
||||||
|
resp.Header.Get("Proxmox-Product"),
|
||||||
|
resp.Header.Get("WWW-Authenticate"),
|
||||||
|
strings.Join(resp.Header.Values("Set-Cookie"), " "),
|
||||||
|
)
|
||||||
|
|
||||||
|
return finding
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneHeader(h http.Header) http.Header {
|
||||||
|
if h == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c := make(http.Header, len(h))
|
||||||
|
for k, values := range h {
|
||||||
|
cp := make([]string, len(values))
|
||||||
|
copy(cp, values)
|
||||||
|
c[k] = cp
|
||||||
|
}
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// inferTypeFromCertificate tries to determine the product based on TLS certificate metadata.
|
// inferTypeFromCertificate tries to determine the product based on TLS certificate metadata.
|
||||||
|
|
@ -950,7 +1382,6 @@ func inferTypeFromCertificate(state tls.ConnectionState) string {
|
||||||
return inferTypeFromMetadata(parts...)
|
return inferTypeFromMetadata(parts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// inferTypeFromMetadata inspects textual metadata and returns a best-effort product type.
|
|
||||||
func inferTypeFromMetadata(parts ...string) string {
|
func inferTypeFromMetadata(parts ...string) string {
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
|
|
||||||
|
|
@ -995,175 +1426,6 @@ func inferTypeFromMetadata(parts ...string) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkServer checks if a server is running at the given IP and port
|
|
||||||
func (s *Scanner) checkServer(ctx context.Context, ip string, port int, serverType string) *DiscoveredServer {
|
|
||||||
// First check if port is open with context-aware dial
|
|
||||||
address := net.JoinHostPort(ip, strconv.Itoa(port))
|
|
||||||
timeout := s.policy.DialTimeout
|
|
||||||
if timeout <= 0 {
|
|
||||||
timeout = time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
dialCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
dialer := &net.Dialer{Timeout: timeout}
|
|
||||||
conn, err := dialer.DialContext(dialCtx, "tcp", address)
|
|
||||||
if err != nil {
|
|
||||||
return nil // Port not open
|
|
||||||
}
|
|
||||||
conn.Close()
|
|
||||||
|
|
||||||
// Port is open - verify it's actually a Proxmox server
|
|
||||||
positiveIdentification := false
|
|
||||||
version := "Unknown"
|
|
||||||
var release string
|
|
||||||
|
|
||||||
// Try to get version or authentication headers
|
|
||||||
url := fmt.Sprintf("https://%s/api2/json/version", address)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
||||||
if err == nil {
|
|
||||||
resp, err := s.httpClient.Do(req)
|
|
||||||
if err == nil {
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
switch resp.StatusCode {
|
|
||||||
case http.StatusOK:
|
|
||||||
var versionResp struct {
|
|
||||||
Data struct {
|
|
||||||
Version string `json:"version"`
|
|
||||||
Release string `json:"release,omitempty"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&versionResp); err == nil && versionResp.Data.Version != "" {
|
|
||||||
version = versionResp.Data.Version
|
|
||||||
release = versionResp.Data.Release
|
|
||||||
positiveIdentification = true
|
|
||||||
|
|
||||||
log.Debug().
|
|
||||||
Str("ip", ip).
|
|
||||||
Int("port", port).
|
|
||||||
Str("version", version).
|
|
||||||
Msg("Got server version without auth")
|
|
||||||
}
|
|
||||||
case http.StatusUnauthorized, http.StatusForbidden:
|
|
||||||
// Check for Proxmox-specific auth headers
|
|
||||||
if inferTypeFromMetadata(
|
|
||||||
resp.Header.Get("WWW-Authenticate"),
|
|
||||||
resp.Header.Get("Server"),
|
|
||||||
resp.Header.Get("Proxmox-Product"),
|
|
||||||
) != "" {
|
|
||||||
positiveIdentification = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only report server if we got positive identification
|
|
||||||
if !positiveIdentification {
|
|
||||||
log.Debug().
|
|
||||||
Str("ip", ip).
|
|
||||||
Int("port", port).
|
|
||||||
Str("expected_type", serverType).
|
|
||||||
Msg("Port open but no Proxmox identification found")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info().
|
|
||||||
Str("ip", ip).
|
|
||||||
Int("port", port).
|
|
||||||
Str("type", serverType).
|
|
||||||
Str("version", version).
|
|
||||||
Msg("Discovered Proxmox server")
|
|
||||||
|
|
||||||
server := &DiscoveredServer{
|
|
||||||
IP: ip,
|
|
||||||
Port: port,
|
|
||||||
Type: serverType,
|
|
||||||
Version: version,
|
|
||||||
Release: release,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to resolve hostname via reverse DNS
|
|
||||||
if s.policy.EnableReverseDNS {
|
|
||||||
names, err := net.DefaultResolver.LookupAddr(ctx, ip)
|
|
||||||
if err == nil && len(names) > 0 {
|
|
||||||
hostname := strings.TrimSuffix(names[0], ".")
|
|
||||||
server.Hostname = hostname
|
|
||||||
log.Debug().Str("ip", ip).Str("hostname", hostname).Msg("Resolved hostname via DNS")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return server
|
|
||||||
}
|
|
||||||
|
|
||||||
// getProxmoxHostname tries to get the hostname of a Proxmox VE server
|
|
||||||
func (s *Scanner) getProxmoxHostname(ctx context.Context, ip string, port int) string {
|
|
||||||
address := net.JoinHostPort(ip, strconv.Itoa(port))
|
|
||||||
url := fmt.Sprintf("https://%s/api2/json/nodes", address)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := s.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var nodesResp struct {
|
|
||||||
Data []struct {
|
|
||||||
Node string `json:"node"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&nodesResp); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(nodesResp.Data) > 0 {
|
|
||||||
return nodesResp.Data[0].Node
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// getPBSHostname tries to get the hostname of a PBS server
|
|
||||||
func (s *Scanner) getPBSHostname(ctx context.Context, ip string, port int) string {
|
|
||||||
address := net.JoinHostPort(ip, strconv.Itoa(port))
|
|
||||||
url := fmt.Sprintf("https://%s/api2/json/nodes", address)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := s.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var nodesResp struct {
|
|
||||||
Data []struct {
|
|
||||||
Node string `json:"node"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&nodesResp); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(nodesResp.Data) > 0 {
|
|
||||||
return nodesResp.Data[0].Node
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateIPs generates all IPs in a subnet
|
// generateIPs generates all IPs in a subnet
|
||||||
func (s *Scanner) generateIPs(ipNet *net.IPNet) []string {
|
func (s *Scanner) generateIPs(ipNet *net.IPNet) []string {
|
||||||
baseIP := ipNet.IP.Mask(ipNet.Mask).To4()
|
baseIP := ipNet.IP.Mask(ipNet.Mask).To4()
|
||||||
|
|
|
||||||
|
|
@ -127,20 +127,23 @@ func TestDetectProductFromEndpoint(t *testing.T) {
|
||||||
scanner := newTestScanner(ts.Client())
|
scanner := newTestScanner(ts.Client())
|
||||||
|
|
||||||
address := strings.TrimPrefix(ts.URL, "https://")
|
address := strings.TrimPrefix(ts.URL, "https://")
|
||||||
if product := scanner.detectProductFromEndpoint(context.Background(), address, "api2/json/statistics/mail"); product != "pmg" {
|
finding := scanner.ProbeAPIEndpoint(context.Background(), address, "api2/json/statistics/mail")
|
||||||
t.Fatalf("detectProductFromEndpoint returned %q, want %q", product, "pmg")
|
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" {
|
versionFinding := scanner.ProbeAPIEndpoint(context.Background(), address, "api2/json/version")
|
||||||
t.Fatalf("detectProductFromEndpoint returned %q, want %q", product, "pbs")
|
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 != "" {
|
unknownFinding := scanner.ProbeAPIEndpoint(context.Background(), address, "api2/json/unknown/path")
|
||||||
t.Fatalf("expected empty result for unknown endpoint, got %q", product)
|
if unknownFinding.ProductGuess != "" || unknownFinding.Status != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected empty result for unknown endpoint, got %+v", unknownFinding)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(requestPaths) == 0 {
|
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()
|
t.Parallel()
|
||||||
|
|
||||||
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -159,17 +164,37 @@ func TestIsPMGServer(t *testing.T) {
|
||||||
|
|
||||||
scanner := newTestScanner(ts.Client())
|
scanner := newTestScanner(ts.Client())
|
||||||
|
|
||||||
address := strings.TrimPrefix(ts.URL, "https://")
|
host, portStr, err := net.SplitHostPort(ts.Listener.Addr().String())
|
||||||
if !scanner.isPMGServer(context.Background(), address) {
|
if err != nil {
|
||||||
t.Fatalf("expected PMG detection to succeed")
|
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())
|
tsNoMatch := httptest.NewTLSServer(http.NotFoundHandler())
|
||||||
defer tsNoMatch.Close()
|
defer tsNoMatch.Close()
|
||||||
|
|
||||||
scanner.httpClient = tsNoMatch.Client()
|
scanner.httpClient = tsNoMatch.Client()
|
||||||
address = strings.TrimPrefix(tsNoMatch.URL, "https://")
|
host, portStr, err = net.SplitHostPort(tsNoMatch.Listener.Addr().String())
|
||||||
if scanner.isPMGServer(context.Background(), address) {
|
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")
|
t.Fatalf("expected PMG detection to fail for endpoints without markers")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -206,21 +231,21 @@ func TestCheckServerRetrievesVersion(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
server := scanner.checkServer(ctx, host, port, "pbs")
|
probe := scanner.ProbeProxmoxService(ctx, host, port)
|
||||||
if server == nil {
|
if probe == nil || !probe.Positive {
|
||||||
t.Fatalf("checkServer returned nil")
|
t.Fatalf("ProbeProxmoxService returned nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
if server.Type != "pbs" {
|
if probe.PrimaryProduct != ProductPBS {
|
||||||
t.Fatalf("expected type pbs, got %q", server.Type)
|
t.Fatalf("expected product pbs, got %q", probe.PrimaryProduct)
|
||||||
}
|
}
|
||||||
|
|
||||||
if server.Version != "2.4.1" {
|
if probe.Version != "2.4.1" {
|
||||||
t.Fatalf("expected version 2.4.1, got %q", server.Version)
|
t.Fatalf("expected version 2.4.1, got %q", probe.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
if server.Release != "1" {
|
if probe.Release != "1" {
|
||||||
t.Fatalf("expected release 1, got %q", server.Release)
|
t.Fatalf("expected release 1, got %q", probe.Release)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,17 +282,17 @@ func TestCheckServerHandlesUnauthorized(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
server := scanner.checkServer(ctx, "127.0.0.1", 9008, "pve")
|
probe := scanner.ProbeProxmoxService(ctx, "127.0.0.1", 9008)
|
||||||
if server == nil {
|
if probe == nil || !probe.Positive {
|
||||||
t.Fatalf("expected server discovery despite unauthorized response")
|
t.Fatalf("expected server discovery despite unauthorized response: %+v", probe)
|
||||||
}
|
}
|
||||||
|
|
||||||
if server.Type != "pve" {
|
if probe.PrimaryProduct != ProductPVE {
|
||||||
t.Fatalf("expected type pve, got %q", server.Type)
|
t.Fatalf("expected product pve, got %q", probe.PrimaryProduct)
|
||||||
}
|
}
|
||||||
|
|
||||||
if server.Version != "Unknown" {
|
if probe.Version != "Unknown" {
|
||||||
t.Fatalf("expected version Unknown, got %q", server.Version)
|
t.Fatalf("expected version Unknown, got %q", probe.Version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -338,10 +363,8 @@ func TestDiscoverServersWithCallback(t *testing.T) {
|
||||||
var callbacks []DiscoveredServer
|
var callbacks []DiscoveredServer
|
||||||
|
|
||||||
// Add a manual check for the TCP-only port.
|
// 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 probe := scanner.ProbeProxmoxService(ctx, "127.0.0.1", 9009); probe != nil && probe.Positive {
|
||||||
if server := scanner.checkServer(ctx, "127.0.0.1", 9009, "pve"); server == nil {
|
t.Fatalf("expected ProbeProxmoxService to ignore TCP-only host, got %+v", probe)
|
||||||
// keep discovery results unaffected but verify we survive the TCP-only host
|
|
||||||
t.Fatalf("expected checkServer to handle TCP-only host without panic")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := scanner.DiscoverServersWithCallback(ctx, subnet, func(server DiscoveredServer, phase string) {
|
result, err := scanner.DiscoverServersWithCallback(ctx, subnet, func(server DiscoveredServer, phase string) {
|
||||||
|
|
|
||||||
17
pkg/discovery/probe_test_helpers.go
Normal file
17
pkg/discovery/probe_test_helpers.go
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue