mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-12 05:45:27 +00:00
Frontend: - Enhanced cluster vs standalone node visual distinction in Settings - Added glassmorphic style to all toast notifications for consistency - Fixed test connection in edit modal to use stored encrypted credentials - Added batch credential modal for bulk node operations - Added network discovery modal with auto-subnet detection - Improved notification system with dual toast/notification support - Added event bus for component communication Backend: - Fixed duplicate toast notifications during auto-registration - Fixed PBS auto-registration token extraction from JSON output - Added network discovery service with background scanning - Improved cluster detection with actual cluster name from API - Added helper function to reduce code duplication in cluster detection - Fixed host URL normalization in auto-registration - Enhanced PBS client token authentication parsing Bug Fixes: - Fixed stacking toast notifications creating visual bugs - Fixed PBS authentication failures after auto-registration - Fixed network discovery not finding Proxmox servers - Fixed test connection for existing nodes with encrypted tokens - Removed duplicate WebSocket broadcasts for auto-registration events
399 lines
No EOL
9.6 KiB
Go
399 lines
No EOL
9.6 KiB
Go
package discovery
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// DiscoveredServer represents a discovered Proxmox/PBS server
|
|
type DiscoveredServer struct {
|
|
IP string `json:"ip"`
|
|
Port int `json:"port"`
|
|
Type string `json:"type"` // "pve" or "pbs"
|
|
Version string `json:"version"`
|
|
Hostname string `json:"hostname,omitempty"`
|
|
Release string `json:"release,omitempty"`
|
|
}
|
|
|
|
// DiscoveryResult contains all discovered servers
|
|
type DiscoveryResult struct {
|
|
Servers []DiscoveredServer `json:"servers"`
|
|
Errors []string `json:"errors,omitempty"`
|
|
}
|
|
|
|
// Scanner handles network scanning for Proxmox/PBS servers
|
|
type Scanner struct {
|
|
timeout time.Duration
|
|
concurrent int
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewScanner creates a new network scanner
|
|
func NewScanner() *Scanner {
|
|
return &Scanner{
|
|
timeout: 1 * time.Second, // Reduced timeout for faster scanning
|
|
concurrent: 50, // Increased concurrent workers for faster scanning
|
|
httpClient: &http.Client{
|
|
Timeout: 2 * time.Second, // Reduced HTTP timeout
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
MaxIdleConns: 100,
|
|
MaxConnsPerHost: 10,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// DiscoverServers scans the network for Proxmox VE and PBS servers
|
|
func (s *Scanner) DiscoverServers(ctx context.Context, subnet string) (*DiscoveryResult, error) {
|
|
log.Info().Str("subnet", subnet).Msg("Starting network discovery")
|
|
|
|
// Parse subnet
|
|
var ipNet *net.IPNet
|
|
|
|
if subnet == "" || subnet == "auto" {
|
|
// Auto-detect local subnet
|
|
ipNet = s.getLocalSubnet()
|
|
if ipNet == nil {
|
|
return nil, fmt.Errorf("failed to auto-detect local subnet")
|
|
}
|
|
log.Info().Str("detected", ipNet.String()).Msg("Auto-detected local subnet")
|
|
} else {
|
|
// Parse provided subnet
|
|
_, parsedNet, err := net.ParseCIDR(subnet)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid subnet: %w", err)
|
|
}
|
|
ipNet = parsedNet
|
|
}
|
|
|
|
// Check subnet size - limit to /24 or smaller for safety
|
|
ones, bits := ipNet.Mask.Size()
|
|
if ones < 24 && bits == 32 { // IPv4 with more than 256 addresses
|
|
log.Warn().Str("subnet", ipNet.String()).Msg("Subnet too large, limiting to /24")
|
|
// Convert to /24
|
|
ipNet.Mask = net.CIDRMask(24, 32)
|
|
}
|
|
|
|
// Generate list of IPs to scan
|
|
ips := s.generateIPs(ipNet)
|
|
log.Info().Int("count", len(ips)).Msg("IPs to scan")
|
|
|
|
// Create channels for work distribution
|
|
ipChan := make(chan string, len(ips))
|
|
resultChan := make(chan *DiscoveredServer, len(ips))
|
|
errorChan := make(chan string, len(ips))
|
|
|
|
// Start workers
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < s.concurrent; i++ {
|
|
wg.Add(1)
|
|
go s.scanWorker(ctx, &wg, ipChan, resultChan, errorChan)
|
|
}
|
|
|
|
// Send IPs to scan
|
|
for _, ip := range ips {
|
|
ipChan <- ip
|
|
}
|
|
close(ipChan)
|
|
|
|
// Wait for workers to finish
|
|
go func() {
|
|
wg.Wait()
|
|
close(resultChan)
|
|
close(errorChan)
|
|
}()
|
|
|
|
// Collect results
|
|
result := &DiscoveryResult{
|
|
Servers: []DiscoveredServer{},
|
|
Errors: []string{},
|
|
}
|
|
|
|
done := false
|
|
for !done {
|
|
select {
|
|
case server, ok := <-resultChan:
|
|
if !ok {
|
|
done = true
|
|
break
|
|
}
|
|
if server != nil {
|
|
result.Servers = append(result.Servers, *server)
|
|
}
|
|
case errMsg, ok := <-errorChan:
|
|
if ok && errMsg != "" {
|
|
result.Errors = append(result.Errors, errMsg)
|
|
}
|
|
case <-ctx.Done():
|
|
return result, ctx.Err()
|
|
}
|
|
}
|
|
|
|
log.Info().
|
|
Int("found", len(result.Servers)).
|
|
Int("errors", len(result.Errors)).
|
|
Msg("Discovery completed")
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// scanWorker scans IPs from the channel
|
|
func (s *Scanner) scanWorker(ctx context.Context, wg *sync.WaitGroup, ipChan <-chan string, resultChan chan<- *DiscoveredServer, errorChan chan<- string) {
|
|
defer wg.Done()
|
|
|
|
for ip := range ipChan {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
// Check Proxmox VE (port 8006)
|
|
if server := s.checkServer(ctx, ip, 8006, "pve"); server != nil {
|
|
resultChan <- server
|
|
}
|
|
|
|
// Check PBS (port 8007)
|
|
if server := s.checkServer(ctx, ip, 8007, "pbs"); server != nil {
|
|
resultChan <- server
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
address := fmt.Sprintf("%s:%d", ip, port)
|
|
conn, err := net.DialTimeout("tcp", address, s.timeout)
|
|
if err != nil {
|
|
return nil // Port not open
|
|
}
|
|
conn.Close()
|
|
|
|
// Port is open - this is likely a Proxmox/PBS server
|
|
// Since most installations require auth for version endpoint,
|
|
// we'll return it as a discovered server based on the port alone
|
|
|
|
log.Info().
|
|
Str("ip", ip).
|
|
Int("port", port).
|
|
Str("type", serverType).
|
|
Msg("Found potential server (port open)")
|
|
|
|
server := &DiscoveredServer{
|
|
IP: ip,
|
|
Port: port,
|
|
Type: serverType,
|
|
Version: "Unknown", // Will be determined after auth
|
|
}
|
|
|
|
// Try to get version without auth (some installations allow it)
|
|
url := fmt.Sprintf("https://%s:%d/api2/json/version", ip, port)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err == nil {
|
|
resp, err := s.httpClient.Do(req)
|
|
if err == nil {
|
|
defer resp.Body.Close()
|
|
|
|
// Only try to parse if we got a successful response
|
|
if resp.StatusCode == 200 {
|
|
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 != "" {
|
|
server.Version = versionResp.Data.Version
|
|
server.Release = versionResp.Data.Release
|
|
|
|
log.Info().
|
|
Str("ip", ip).
|
|
Int("port", port).
|
|
Str("version", server.Version).
|
|
Msg("Got server version without auth")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to resolve hostname via reverse DNS
|
|
names, err := net.LookupAddr(ip)
|
|
if err == nil && len(names) > 0 {
|
|
// Use the first hostname, remove trailing dot if present
|
|
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 {
|
|
url := fmt.Sprintf("https://%s:%d/api2/json/nodes", ip, port)
|
|
|
|
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 {
|
|
url := fmt.Sprintf("https://%s:%d/api2/json/nodes", ip, port)
|
|
|
|
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
|
|
func (s *Scanner) generateIPs(ipNet *net.IPNet) []string {
|
|
var ips []string
|
|
|
|
// Get the starting IP
|
|
ip := ipNet.IP.Mask(ipNet.Mask)
|
|
|
|
// Calculate the number of hosts
|
|
ones, bits := ipNet.Mask.Size()
|
|
hostBits := bits - ones
|
|
numHosts := 1 << hostBits
|
|
|
|
// Skip network and broadcast addresses for common subnets
|
|
start := 1
|
|
end := numHosts - 1
|
|
if numHosts > 256 {
|
|
// For larger subnets, scan everything
|
|
start = 0
|
|
end = numHosts
|
|
}
|
|
|
|
// Limit to maximum 1024 IPs to avoid scanning huge networks
|
|
if end-start > 1024 {
|
|
end = start + 1024
|
|
log.Warn().Int("limited_to", 1024).Msg("Limiting scan to first 1024 IPs")
|
|
}
|
|
|
|
for i := start; i < end; i++ {
|
|
// Calculate IP
|
|
currIP := make(net.IP, len(ip))
|
|
copy(currIP, ip)
|
|
|
|
// Add offset to IP address
|
|
offset := i
|
|
for j := len(currIP) - 1; j >= 0 && offset > 0; j-- {
|
|
currIP[j] += byte(offset & 0xFF)
|
|
offset >>= 8
|
|
}
|
|
|
|
// Skip common non-server IPs
|
|
lastOctet := currIP[len(currIP)-1]
|
|
if lastOctet == 0 || lastOctet == 255 {
|
|
continue // Skip network and broadcast
|
|
}
|
|
|
|
ips = append(ips, currIP.String())
|
|
}
|
|
|
|
return ips
|
|
}
|
|
|
|
// getLocalSubnet attempts to detect the local subnet
|
|
func (s *Scanner) getLocalSubnet() *net.IPNet {
|
|
interfaces, err := net.Interfaces()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
for _, iface := range interfaces {
|
|
// Skip loopback and down interfaces
|
|
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
|
|
continue
|
|
}
|
|
|
|
addrs, err := iface.Addrs()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, addr := range addrs {
|
|
if ipNet, ok := addr.(*net.IPNet); ok && ipNet.IP.To4() != nil {
|
|
// Found an IPv4 address
|
|
if !ipNet.IP.IsLoopback() && !ipNet.IP.IsLinkLocalUnicast() {
|
|
// Convert to /24 subnet for auto-detection
|
|
// This ensures we scan a reasonable range
|
|
ip := ipNet.IP.To4()
|
|
if ip != nil {
|
|
// Create a /24 subnet from the IP
|
|
ip[3] = 0 // Set last octet to 0
|
|
return &net.IPNet{
|
|
IP: ip,
|
|
Mask: net.CIDRMask(24, 32),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Default to common subnet if detection fails
|
|
_, defaultNet, _ := net.ParseCIDR("192.168.1.0/24")
|
|
return defaultNet
|
|
} |