Add configurable SSH port for temperature monitoring
Related to #595 This change adds support for custom SSH ports when collecting temperature data from Proxmox nodes, resolving issues for users who run SSH on non-standard ports. **Why SSH is still needed:** Temperature monitoring requires reading /sys/class/hwmon sensors on Proxmox nodes, which is not exposed via the Proxmox API. Even when using API tokens for authentication, Pulse needs SSH access to collect temperature data. **Changes:** - Add `sshPort` configuration to SystemSettings (system.json) - Add `SSHPort` field to Config with environment variable support (SSH_PORT) - Add per-node SSH port override capability for PVE, PBS, and PMG instances - Update TemperatureCollector to accept and use custom SSH port - Update SSH known_hosts manager to support non-standard ports - Add NewTemperatureCollectorWithPort() constructor with port parameter - Maintain backward compatibility with NewTemperatureCollector() (uses port 22) - Update frontend TypeScript types for SSH port configuration **Configuration methods:** 1. Environment variable: SSH_PORT=2222 2. system.json: {"sshPort": 2222} 3. Per-node override in nodes.enc (future UI support) **Default behavior:** - Defaults to port 22 if not configured - Maintains full backward compatibility - No changes required for existing deployments The implementation includes proper ssh-keyscan port handling and known_hosts management for non-standard ports using [host]:port notation per SSH standards.
This commit is contained in:
parent
13c2005282
commit
e21a72578f
10 changed files with 383 additions and 20 deletions
|
|
@ -37,6 +37,7 @@ export interface SystemConfig {
|
||||||
backupPollingInterval?: number; // Backup polling interval in seconds (0 = default cadence)
|
backupPollingInterval?: number; // Backup polling interval in seconds (0 = default cadence)
|
||||||
backupPollingEnabled?: boolean; // Enable backup polling of PVE/PBS data
|
backupPollingEnabled?: boolean; // Enable backup polling of PVE/PBS data
|
||||||
temperatureMonitoringEnabled?: boolean; // Collect CPU/NVMe temperatures via SSH
|
temperatureMonitoringEnabled?: boolean; // Collect CPU/NVMe temperatures via SSH
|
||||||
|
sshPort?: number; // SSH port for temperature monitoring (default: 22)
|
||||||
allowedOrigins?: string; // CORS allowed origins
|
allowedOrigins?: string; // CORS allowed origins
|
||||||
backendPort?: number; // Backend API port (default: 7655)
|
backendPort?: number; // Backend API port (default: 7655)
|
||||||
frontendPort?: number; // Frontend UI port (default: 7655)
|
frontendPort?: number; // Frontend UI port (default: 7655)
|
||||||
|
|
@ -156,6 +157,7 @@ export const DEFAULT_CONFIG: {
|
||||||
backupPollingEnabled: true,
|
backupPollingEnabled: true,
|
||||||
backupPollingInterval: 0,
|
backupPollingInterval: 0,
|
||||||
temperatureMonitoringEnabled: true,
|
temperatureMonitoringEnabled: true,
|
||||||
|
sshPort: 22,
|
||||||
allowedOrigins: '',
|
allowedOrigins: '',
|
||||||
backendPort: 7655,
|
backendPort: 7655,
|
||||||
frontendPort: 7655,
|
frontendPort: 7655,
|
||||||
|
|
|
||||||
290
internal/alerts/per_metric_delay_example_test.go
Normal file
290
internal/alerts/per_metric_delay_example_test.go
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPerMetricDelayConfiguration demonstrates the per-metric delay feature
|
||||||
|
// This test shows how to configure different alert delays for different metrics
|
||||||
|
func TestPerMetricDelayConfiguration(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config AlertConfig
|
||||||
|
resourceType string
|
||||||
|
metricType string
|
||||||
|
expectedDelay int
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "CPU alert with longer delay than memory",
|
||||||
|
config: AlertConfig{
|
||||||
|
TimeThresholds: map[string]int{
|
||||||
|
"guest": 60, // 60 second default for guests
|
||||||
|
},
|
||||||
|
MetricTimeThresholds: map[string]map[string]int{
|
||||||
|
"guest": {
|
||||||
|
"cpu": 300, // 5 minutes for CPU (transient spikes)
|
||||||
|
"memory": 30, // 30 seconds for memory (persistent issues)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resourceType: "guest",
|
||||||
|
metricType: "cpu",
|
||||||
|
expectedDelay: 300,
|
||||||
|
description: "CPU alerts should wait 5 minutes due to transient spikes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Memory alert with shorter delay",
|
||||||
|
config: AlertConfig{
|
||||||
|
TimeThresholds: map[string]int{
|
||||||
|
"guest": 60,
|
||||||
|
},
|
||||||
|
MetricTimeThresholds: map[string]map[string]int{
|
||||||
|
"guest": {
|
||||||
|
"cpu": 300,
|
||||||
|
"memory": 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resourceType: "guest",
|
||||||
|
metricType: "memory",
|
||||||
|
expectedDelay: 30,
|
||||||
|
description: "Memory alerts should trigger quickly as they're persistent",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Disk alert falls back to resource type default",
|
||||||
|
config: AlertConfig{
|
||||||
|
TimeThresholds: map[string]int{
|
||||||
|
"guest": 60,
|
||||||
|
},
|
||||||
|
MetricTimeThresholds: map[string]map[string]int{
|
||||||
|
"guest": {
|
||||||
|
"cpu": 300,
|
||||||
|
"memory": 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resourceType: "guest",
|
||||||
|
metricType: "disk",
|
||||||
|
expectedDelay: 60,
|
||||||
|
description: "Disk has no specific override, uses guest default (60s)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Global metric delay when no type-specific base exists",
|
||||||
|
config: AlertConfig{
|
||||||
|
TimeThresholds: map[string]int{
|
||||||
|
"guest": 60,
|
||||||
|
// Note: no "storage" entry, so storage uses global metric delays
|
||||||
|
},
|
||||||
|
MetricTimeThresholds: map[string]map[string]int{
|
||||||
|
"all": {
|
||||||
|
"usage": 120, // 2 minutes for storage usage alerts globally
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resourceType: "storage",
|
||||||
|
metricType: "usage",
|
||||||
|
expectedDelay: 120,
|
||||||
|
description: "Storage usage uses global metric override (120s) when no type-specific base delay exists",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Specific override beats global override",
|
||||||
|
config: AlertConfig{
|
||||||
|
TimeThresholds: map[string]int{
|
||||||
|
"guest": 60,
|
||||||
|
},
|
||||||
|
MetricTimeThresholds: map[string]map[string]int{
|
||||||
|
"all": {
|
||||||
|
"cpu": 180, // 3 minutes globally
|
||||||
|
},
|
||||||
|
"guest": {
|
||||||
|
"cpu": 300, // 5 minutes for guests specifically
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resourceType: "guest",
|
||||||
|
metricType: "cpu",
|
||||||
|
expectedDelay: 300,
|
||||||
|
description: "Guest-specific CPU delay (300s) overrides global CPU delay (180s)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Docker container with restart count alert",
|
||||||
|
config: AlertConfig{
|
||||||
|
TimeThresholds: map[string]int{
|
||||||
|
"guest": 60,
|
||||||
|
},
|
||||||
|
MetricTimeThresholds: map[string]map[string]int{
|
||||||
|
"docker": {
|
||||||
|
"restartcount": 10, // Quick notification for container restarts
|
||||||
|
"cpu": 120, // Longer for CPU
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resourceType: "docker",
|
||||||
|
metricType: "restartcount",
|
||||||
|
expectedDelay: 10,
|
||||||
|
description: "Restart count alerts trigger quickly (10s) for immediate attention",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Create a manager with the test config
|
||||||
|
manager := &Manager{
|
||||||
|
config: tt.config,
|
||||||
|
pendingAlerts: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the time threshold for this resource/metric combination
|
||||||
|
delay := manager.getTimeThreshold("test-resource-id", tt.resourceType, tt.metricType)
|
||||||
|
|
||||||
|
if delay != tt.expectedDelay {
|
||||||
|
t.Errorf("Expected delay %d seconds, got %d seconds\n%s",
|
||||||
|
tt.expectedDelay, delay, tt.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ %s: %d seconds", tt.description, delay)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPerMetricDelayJSON demonstrates JSON serialization/deserialization
|
||||||
|
func TestPerMetricDelayJSON(t *testing.T) {
|
||||||
|
config := AlertConfig{
|
||||||
|
TimeThresholds: map[string]int{
|
||||||
|
"guest": 60,
|
||||||
|
"node": 30,
|
||||||
|
},
|
||||||
|
MetricTimeThresholds: map[string]map[string]int{
|
||||||
|
"guest": {
|
||||||
|
"cpu": 300,
|
||||||
|
"memory": 30,
|
||||||
|
"disk": 90,
|
||||||
|
},
|
||||||
|
"node": {
|
||||||
|
"cpu": 120,
|
||||||
|
"temperature": 180,
|
||||||
|
},
|
||||||
|
"all": {
|
||||||
|
"networkout": 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize to JSON
|
||||||
|
data, err := json.MarshalIndent(config, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to marshal config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Configuration JSON:\n%s", string(data))
|
||||||
|
|
||||||
|
// Deserialize back
|
||||||
|
var decoded AlertConfig
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the data round-trips correctly
|
||||||
|
if decoded.TimeThresholds["guest"] != 60 {
|
||||||
|
t.Errorf("TimeThresholds not preserved")
|
||||||
|
}
|
||||||
|
if decoded.MetricTimeThresholds["guest"]["cpu"] != 300 {
|
||||||
|
t.Errorf("MetricTimeThresholds not preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ JSON serialization/deserialization works correctly")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPerMetricDelayUseCases documents common use cases
|
||||||
|
func TestPerMetricDelayUseCases(t *testing.T) {
|
||||||
|
t.Log("=== Per-Metric Delay Configuration Use Cases ===\n")
|
||||||
|
|
||||||
|
useCases := []struct {
|
||||||
|
scenario string
|
||||||
|
config string
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "Production VMs: Different delays for different metrics",
|
||||||
|
config: `{
|
||||||
|
"timeThresholds": {
|
||||||
|
"guest": 60
|
||||||
|
},
|
||||||
|
"metricTimeThresholds": {
|
||||||
|
"guest": {
|
||||||
|
"cpu": 300,
|
||||||
|
"memory": 60,
|
||||||
|
"disk": 120,
|
||||||
|
"diskread": 180,
|
||||||
|
"diskwrite": 180
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
reason: `
|
||||||
|
- CPU: 5 minutes (transient spikes are normal)
|
||||||
|
- Memory: 1 minute (persistent issues need attention)
|
||||||
|
- Disk: 2 minutes (filling up gradually)
|
||||||
|
- I/O: 3 minutes (workload-dependent, can spike)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "Proxmox Nodes: Temperature needs patience",
|
||||||
|
config: `{
|
||||||
|
"timeThresholds": {
|
||||||
|
"node": 30
|
||||||
|
},
|
||||||
|
"metricTimeThresholds": {
|
||||||
|
"node": {
|
||||||
|
"cpu": 120,
|
||||||
|
"memory": 60,
|
||||||
|
"temperature": 300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
reason: `
|
||||||
|
- CPU: 2 minutes (load balancing can spike briefly)
|
||||||
|
- Memory: 1 minute (host memory issues are serious)
|
||||||
|
- Temperature: 5 minutes (fans need time to respond)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "Docker Containers: Fast alerts for restart issues",
|
||||||
|
config: `{
|
||||||
|
"timeThresholds": {
|
||||||
|
"guest": 60
|
||||||
|
},
|
||||||
|
"metricTimeThresholds": {
|
||||||
|
"docker": {
|
||||||
|
"restartcount": 10,
|
||||||
|
"cpu": 120,
|
||||||
|
"memory": 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
reason: `
|
||||||
|
- Restart count: 10 seconds (immediate notification needed)
|
||||||
|
- CPU: 2 minutes (containers can spike during startup)
|
||||||
|
- Memory: 1 minute (OOM is a critical issue)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "Global overrides for specific metrics across all types",
|
||||||
|
config: `{
|
||||||
|
"metricTimeThresholds": {
|
||||||
|
"all": {
|
||||||
|
"networkout": 300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
reason: `
|
||||||
|
- Network Out: 5 minutes globally (backups/migrations cause spikes)
|
||||||
|
- Applies to VMs, containers, nodes, hosts unless overridden`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, uc := range useCases {
|
||||||
|
t.Logf("\n📋 Scenario: %s\n", uc.scenario)
|
||||||
|
t.Logf("Configuration:\n%s\n", uc.config)
|
||||||
|
t.Logf("Reasoning:%s\n", uc.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2837,7 +2837,7 @@ func (h *ConfigHandlers) HandleVerifyTemperatureSSH(w http.ResponseWriter, r *ht
|
||||||
homeDir = "/home/pulse"
|
homeDir = "/home/pulse"
|
||||||
}
|
}
|
||||||
sshKeyPath := filepath.Join(homeDir, ".ssh/id_ed25519_sensors")
|
sshKeyPath := filepath.Join(homeDir, ".ssh/id_ed25519_sensors")
|
||||||
tempCollector := monitoring.NewTemperatureCollector("root", sshKeyPath)
|
tempCollector := monitoring.NewTemperatureCollectorWithPort("root", sshKeyPath, h.config.SSHPort)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ type Config struct {
|
||||||
GuestMetadataRetryBackoff time.Duration `envconfig:"GUEST_METADATA_RETRY_BACKOFF" default:"30s" json:"guestMetadataRetryBackoff"`
|
GuestMetadataRetryBackoff time.Duration `envconfig:"GUEST_METADATA_RETRY_BACKOFF" default:"30s" json:"guestMetadataRetryBackoff"`
|
||||||
GuestMetadataMaxConcurrent int `envconfig:"GUEST_METADATA_MAX_CONCURRENT" default:"4" json:"guestMetadataMaxConcurrent"`
|
GuestMetadataMaxConcurrent int `envconfig:"GUEST_METADATA_MAX_CONCURRENT" default:"4" json:"guestMetadataMaxConcurrent"`
|
||||||
DNSCacheTimeout time.Duration `envconfig:"DNS_CACHE_TIMEOUT" default:"5m" json:"dnsCacheTimeout"`
|
DNSCacheTimeout time.Duration `envconfig:"DNS_CACHE_TIMEOUT" default:"5m" json:"dnsCacheTimeout"`
|
||||||
|
SSHPort int `envconfig:"SSH_PORT" default:"22" json:"sshPort"` // Default SSH port for temperature monitoring
|
||||||
|
|
||||||
// Logging settings
|
// Logging settings
|
||||||
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
|
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
|
||||||
|
|
@ -403,6 +404,7 @@ type PVEInstance struct {
|
||||||
MonitorPhysicalDisks *bool // Monitor physical disks (nil = enabled by default, can be explicitly disabled)
|
MonitorPhysicalDisks *bool // Monitor physical disks (nil = enabled by default, can be explicitly disabled)
|
||||||
PhysicalDiskPollingMinutes int // How often to poll physical disks (0 = use default)
|
PhysicalDiskPollingMinutes int // How often to poll physical disks (0 = use default)
|
||||||
TemperatureMonitoringEnabled *bool // Monitor temperature via SSH (nil = use global setting, true/false = override)
|
TemperatureMonitoringEnabled *bool // Monitor temperature via SSH (nil = use global setting, true/false = override)
|
||||||
|
SSHPort int // SSH port for temperature monitoring (0 = use global default)
|
||||||
|
|
||||||
// Cluster support
|
// Cluster support
|
||||||
IsCluster bool // True if this is a cluster
|
IsCluster bool // True if this is a cluster
|
||||||
|
|
@ -438,6 +440,7 @@ type PBSInstance struct {
|
||||||
MonitorPruneJobs bool
|
MonitorPruneJobs bool
|
||||||
MonitorGarbageJobs bool
|
MonitorGarbageJobs bool
|
||||||
TemperatureMonitoringEnabled *bool // Monitor temperature via SSH (nil = use global setting, true/false = override)
|
TemperatureMonitoringEnabled *bool // Monitor temperature via SSH (nil = use global setting, true/false = override)
|
||||||
|
SSHPort int // SSH port for temperature monitoring (0 = use global default)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PMGInstance represents a Proxmox Mail Gateway connection
|
// PMGInstance represents a Proxmox Mail Gateway connection
|
||||||
|
|
@ -456,6 +459,7 @@ type PMGInstance struct {
|
||||||
MonitorQuarantine bool
|
MonitorQuarantine bool
|
||||||
MonitorDomainStats bool
|
MonitorDomainStats bool
|
||||||
TemperatureMonitoringEnabled *bool // Monitor temperature via SSH (nil = use global setting, true/false = override)
|
TemperatureMonitoringEnabled *bool // Monitor temperature via SSH (nil = use global setting, true/false = override)
|
||||||
|
SSHPort int // SSH port for temperature monitoring (0 = use global default)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global persistence instance for saving
|
// Global persistence instance for saving
|
||||||
|
|
@ -624,6 +628,12 @@ func Load() (*Config, error) {
|
||||||
if systemSettings.DNSCacheTimeout > 0 {
|
if systemSettings.DNSCacheTimeout > 0 {
|
||||||
cfg.DNSCacheTimeout = time.Duration(systemSettings.DNSCacheTimeout) * time.Second
|
cfg.DNSCacheTimeout = time.Duration(systemSettings.DNSCacheTimeout) * time.Second
|
||||||
}
|
}
|
||||||
|
// Load SSH port
|
||||||
|
if systemSettings.SSHPort > 0 {
|
||||||
|
cfg.SSHPort = systemSettings.SSHPort
|
||||||
|
} else {
|
||||||
|
cfg.SSHPort = 22 // Default SSH port
|
||||||
|
}
|
||||||
// APIToken no longer loaded from system.json - only from .env
|
// APIToken no longer loaded from system.json - only from .env
|
||||||
log.Info().
|
log.Info().
|
||||||
Str("updateChannel", cfg.UpdateChannel).
|
Str("updateChannel", cfg.UpdateChannel).
|
||||||
|
|
@ -840,6 +850,20 @@ func Load() (*Config, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sshPort := utils.GetenvTrim("SSH_PORT"); sshPort != "" {
|
||||||
|
if port, err := strconv.Atoi(sshPort); err == nil {
|
||||||
|
if port <= 0 || port > 65535 {
|
||||||
|
log.Warn().Str("value", sshPort).Msg("Ignoring invalid SSH_PORT from environment (must be 1-65535)")
|
||||||
|
} else {
|
||||||
|
cfg.SSHPort = port
|
||||||
|
cfg.EnvOverrides["SSH_PORT"] = true
|
||||||
|
log.Info().Int("port", port).Msg("SSH port overridden by environment")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Warn().Str("value", sshPort).Msg("Invalid SSH_PORT value, expected integer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Support both FRONTEND_PORT (preferred) and PORT (legacy) env vars
|
// Support both FRONTEND_PORT (preferred) and PORT (legacy) env vars
|
||||||
if frontendPort := os.Getenv("FRONTEND_PORT"); frontendPort != "" {
|
if frontendPort := os.Getenv("FRONTEND_PORT"); frontendPort != "" {
|
||||||
if p, err := strconv.Atoi(frontendPort); err == nil {
|
if p, err := strconv.Atoi(frontendPort); err == nil {
|
||||||
|
|
@ -1259,6 +1283,7 @@ func SaveConfig(cfg *Config) error {
|
||||||
AdaptivePollingMinInterval: int(cfg.AdaptivePollingMinInterval / time.Second),
|
AdaptivePollingMinInterval: int(cfg.AdaptivePollingMinInterval / time.Second),
|
||||||
AdaptivePollingMaxInterval: int(cfg.AdaptivePollingMaxInterval / time.Second),
|
AdaptivePollingMaxInterval: int(cfg.AdaptivePollingMaxInterval / time.Second),
|
||||||
DNSCacheTimeout: int(cfg.DNSCacheTimeout / time.Second),
|
DNSCacheTimeout: int(cfg.DNSCacheTimeout / time.Second),
|
||||||
|
SSHPort: cfg.SSHPort,
|
||||||
// APIToken removed - now handled via .env only
|
// APIToken removed - now handled via .env only
|
||||||
}
|
}
|
||||||
if err := globalPersistence.SaveSystemSettings(systemSettings); err != nil {
|
if err := globalPersistence.SaveSystemSettings(systemSettings); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -822,6 +822,7 @@ type SystemSettings struct {
|
||||||
AllowedEmbedOrigins string `json:"allowedEmbedOrigins,omitempty"` // Comma-separated list of allowed origins for embedding
|
AllowedEmbedOrigins string `json:"allowedEmbedOrigins,omitempty"` // Comma-separated list of allowed origins for embedding
|
||||||
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
|
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
|
||||||
DNSCacheTimeout int `json:"dnsCacheTimeout,omitempty"` // DNS cache timeout in seconds (0 = default 5 minutes)
|
DNSCacheTimeout int `json:"dnsCacheTimeout,omitempty"` // DNS cache timeout in seconds (0 = default 5 minutes)
|
||||||
|
SSHPort int `json:"sshPort,omitempty"` // Default SSH port for temperature monitoring (0 = use 22)
|
||||||
// APIToken removed - now handled via .env file only
|
// APIToken removed - now handled via .env file only
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3101,7 +3101,7 @@ func New(cfg *config.Config) (*Monitor, error) {
|
||||||
homeDir = "/home/pulse"
|
homeDir = "/home/pulse"
|
||||||
}
|
}
|
||||||
sshKeyPath := filepath.Join(homeDir, ".ssh/id_ed25519_sensors")
|
sshKeyPath := filepath.Join(homeDir, ".ssh/id_ed25519_sensors")
|
||||||
tempCollector := NewTemperatureCollector("root", sshKeyPath)
|
tempCollector := NewTemperatureCollectorWithPort("root", sshKeyPath, cfg.SSHPort)
|
||||||
|
|
||||||
// Security warning if running in container with SSH temperature monitoring
|
// Security warning if running in container with SSH temperature monitoring
|
||||||
checkContainerizedTempMonitoring()
|
checkContainerizedTempMonitoring()
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ func TestMonitorTemperatureCollectorToggle(t *testing.T) {
|
||||||
|
|
||||||
cfg := &config.Config{TemperatureMonitoringEnabled: false}
|
cfg := &config.Config{TemperatureMonitoringEnabled: false}
|
||||||
|
|
||||||
service := newTemperatureService(false, "root", filepath.Join(t.TempDir(), "id_ed25519_sensors"))
|
service := newTemperatureService(false, "root", filepath.Join(t.TempDir(), "id_ed25519_sensors"), 22)
|
||||||
|
|
||||||
m := &Monitor{
|
m := &Monitor{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ type temperatureProxy interface {
|
||||||
type TemperatureCollector struct {
|
type TemperatureCollector struct {
|
||||||
sshUser string // SSH user (typically "root" or "pulse-monitor")
|
sshUser string // SSH user (typically "root" or "pulse-monitor")
|
||||||
sshKeyPath string // Path to SSH private key
|
sshKeyPath string // Path to SSH private key
|
||||||
|
sshPort int // SSH port (default 22)
|
||||||
proxyClient temperatureProxy // Optional: unix socket client for proxy
|
proxyClient temperatureProxy // Optional: unix socket client for proxy
|
||||||
useProxy bool // Whether to use proxy for temperature collection
|
useProxy bool // Whether to use proxy for temperature collection
|
||||||
hostKeys knownhosts.Manager
|
hostKeys knownhosts.Manager
|
||||||
|
|
@ -46,11 +47,21 @@ type TemperatureCollector struct {
|
||||||
legacySSHDisabled atomic.Bool
|
legacySSHDisabled atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTemperatureCollector creates a new temperature collector
|
// NewTemperatureCollector creates a new temperature collector with default SSH port (22)
|
||||||
func NewTemperatureCollector(sshUser, sshKeyPath string) *TemperatureCollector {
|
func NewTemperatureCollector(sshUser, sshKeyPath string) *TemperatureCollector {
|
||||||
|
return NewTemperatureCollectorWithPort(sshUser, sshKeyPath, 22)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTemperatureCollectorWithPort creates a new temperature collector with custom SSH port
|
||||||
|
func NewTemperatureCollectorWithPort(sshUser, sshKeyPath string, sshPort int) *TemperatureCollector {
|
||||||
|
if sshPort <= 0 {
|
||||||
|
sshPort = 22 // Default to standard SSH port
|
||||||
|
}
|
||||||
|
|
||||||
tc := &TemperatureCollector{
|
tc := &TemperatureCollector{
|
||||||
sshUser: sshUser,
|
sshUser: sshUser,
|
||||||
sshKeyPath: sshKeyPath,
|
sshKeyPath: sshKeyPath,
|
||||||
|
sshPort: sshPort,
|
||||||
}
|
}
|
||||||
|
|
||||||
homeDir := os.Getenv("HOME")
|
homeDir := os.Getenv("HOME")
|
||||||
|
|
@ -192,6 +203,7 @@ func (tc *TemperatureCollector) runSSHCommand(ctx context.Context, host, command
|
||||||
"-o", "BatchMode=yes",
|
"-o", "BatchMode=yes",
|
||||||
"-o", "LogLevel=ERROR", // Suppress host key warnings that break JSON parsing
|
"-o", "LogLevel=ERROR", // Suppress host key warnings that break JSON parsing
|
||||||
"-o", "ConnectTimeout=5",
|
"-o", "ConnectTimeout=5",
|
||||||
|
"-p", fmt.Sprintf("%d", tc.sshPort), // Use configured SSH port
|
||||||
}
|
}
|
||||||
|
|
||||||
if tc.hostKeys != nil && tc.hostKeys.Path() != "" {
|
if tc.hostKeys != nil && tc.hostKeys.Path() != "" {
|
||||||
|
|
@ -625,7 +637,7 @@ func (tc *TemperatureCollector) ensureHostKey(ctx context.Context, host string)
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
return tc.hostKeys.Ensure(ctx, host)
|
return tc.hostKeys.EnsureWithPort(ctx, host, tc.sshPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tc *TemperatureCollector) isProxyEnabled() bool {
|
func (tc *TemperatureCollector) isProxyEnabled() bool {
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,19 @@ type temperatureService struct {
|
||||||
enabled bool
|
enabled bool
|
||||||
user string
|
user string
|
||||||
keyPath string
|
keyPath string
|
||||||
|
sshPort int
|
||||||
collector *TemperatureCollector
|
collector *TemperatureCollector
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTemperatureService(enabled bool, user, keyPath string) TemperatureService {
|
func newTemperatureService(enabled bool, user, keyPath string, sshPort int) TemperatureService {
|
||||||
|
if sshPort <= 0 {
|
||||||
|
sshPort = 22
|
||||||
|
}
|
||||||
return &temperatureService{
|
return &temperatureService{
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
user: user,
|
user: user,
|
||||||
keyPath: keyPath,
|
keyPath: keyPath,
|
||||||
|
sshPort: sshPort,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,7 +60,7 @@ func (s *temperatureService) Enable() {
|
||||||
|
|
||||||
s.enabled = true
|
s.enabled = true
|
||||||
if s.collector == nil && s.user != "" && s.keyPath != "" {
|
if s.collector == nil && s.user != "" && s.keyPath != "" {
|
||||||
s.collector = NewTemperatureCollector(s.user, s.keyPath)
|
s.collector = NewTemperatureCollectorWithPort(s.user, s.keyPath, s.sshPort)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,7 +85,7 @@ func (s *temperatureService) Collect(ctx context.Context, host, nodeName string)
|
||||||
if collector == nil {
|
if collector == nil {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.enabled && s.collector == nil && s.user != "" && s.keyPath != "" {
|
if s.enabled && s.collector == nil && s.user != "" && s.keyPath != "" {
|
||||||
s.collector = NewTemperatureCollector(s.user, s.keyPath)
|
s.collector = NewTemperatureCollectorWithPort(s.user, s.keyPath, s.sshPort)
|
||||||
}
|
}
|
||||||
collector = s.collector
|
collector = s.collector
|
||||||
enabled = s.enabled
|
enabled = s.enabled
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ type Manager interface {
|
||||||
// Ensure guarantees that the host key for the provided host exists in the
|
// Ensure guarantees that the host key for the provided host exists in the
|
||||||
// managed known_hosts file.
|
// managed known_hosts file.
|
||||||
Ensure(ctx context.Context, host string) error
|
Ensure(ctx context.Context, host string) error
|
||||||
|
// EnsureWithPort guarantees that the host key for the provided host:port exists
|
||||||
|
// in the managed known_hosts file.
|
||||||
|
EnsureWithPort(ctx context.Context, host string, port int) error
|
||||||
// Path returns the absolute path to the managed known_hosts file.
|
// Path returns the absolute path to the managed known_hosts file.
|
||||||
Path() string
|
Path() string
|
||||||
}
|
}
|
||||||
|
|
@ -32,7 +35,7 @@ type manager struct {
|
||||||
keyscanTimeout time.Duration
|
keyscanTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
type keyscanFunc func(ctx context.Context, host string, timeout time.Duration) ([]byte, error)
|
type keyscanFunc func(ctx context.Context, host string, port int, timeout time.Duration) ([]byte, error)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultKeyscanTimeout = 5 * time.Second
|
defaultKeyscanTimeout = 5 * time.Second
|
||||||
|
|
@ -84,16 +87,26 @@ func NewManager(path string, opts ...Option) (Manager, error) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure implements Manager.Ensure.
|
// Ensure implements Manager.Ensure (uses default port 22).
|
||||||
func (m *manager) Ensure(ctx context.Context, host string) error {
|
func (m *manager) Ensure(ctx context.Context, host string) error {
|
||||||
|
return m.EnsureWithPort(ctx, host, 22)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureWithPort implements Manager.EnsureWithPort.
|
||||||
|
func (m *manager) EnsureWithPort(ctx context.Context, host string, port int) error {
|
||||||
if strings.TrimSpace(host) == "" {
|
if strings.TrimSpace(host) == "" {
|
||||||
return fmt.Errorf("knownhosts: missing host")
|
return fmt.Errorf("knownhosts: missing host")
|
||||||
}
|
}
|
||||||
|
if port <= 0 {
|
||||||
|
port = 22 // Default to standard SSH port
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := fmt.Sprintf("%s:%d", host, port)
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
if _, ok := m.cache[host]; ok {
|
if _, ok := m.cache[cacheKey]; ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,30 +114,36 @@ func (m *manager) Ensure(ctx context.Context, host string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
exists, err := hostKeyExists(m.path, host)
|
// For non-standard ports, check for [host]:port format
|
||||||
|
hostSpec := host
|
||||||
|
if port != 22 {
|
||||||
|
hostSpec = fmt.Sprintf("[%s]:%d", host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := hostKeyExists(m.path, hostSpec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if exists {
|
if exists {
|
||||||
m.cache[host] = struct{}{}
|
m.cache[cacheKey] = struct{}{}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
keyData, err := m.keyscanFn(ctx, host, m.keyscanTimeout)
|
keyData, err := m.keyscanFn(ctx, host, port, m.keyscanTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("knownhosts: ssh-keyscan failed for %s: %w", host, err)
|
return fmt.Errorf("knownhosts: ssh-keyscan failed for %s:%d: %w", host, port, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
entries := sanitizeKeyscanOutput(host, keyData)
|
entries := sanitizeKeyscanOutput(hostSpec, keyData)
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
return fmt.Errorf("%w for %s", ErrNoHostKeys, host)
|
return fmt.Errorf("%w for %s:%d", ErrNoHostKeys, host, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := appendHostKey(m.path, entries); err != nil {
|
if err := appendHostKey(m.path, entries); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
m.cache[host] = struct{}{}
|
m.cache[cacheKey] = struct{}{}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -261,16 +280,25 @@ func hostCandidates(part string) []string {
|
||||||
return candidates
|
return candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultKeyscan(ctx context.Context, host string, timeout time.Duration) ([]byte, error) {
|
func defaultKeyscan(ctx context.Context, host string, port int, timeout time.Duration) ([]byte, error) {
|
||||||
seconds := int(timeout.Round(time.Second) / time.Second)
|
seconds := int(timeout.Round(time.Second) / time.Second)
|
||||||
if seconds <= 0 {
|
if seconds <= 0 {
|
||||||
seconds = int(defaultKeyscanTimeout / time.Second)
|
seconds = int(defaultKeyscanTimeout / time.Second)
|
||||||
}
|
}
|
||||||
|
if port <= 0 {
|
||||||
|
port = 22
|
||||||
|
}
|
||||||
|
|
||||||
scanCtx, cancel := context.WithTimeout(ctx, timeout)
|
scanCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd := exec.CommandContext(scanCtx, "ssh-keyscan", "-T", strconv.Itoa(seconds), host)
|
args := []string{"-T", strconv.Itoa(seconds)}
|
||||||
|
if port != 22 {
|
||||||
|
args = append(args, "-p", strconv.Itoa(port))
|
||||||
|
}
|
||||||
|
args = append(args, host)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(scanCtx, "ssh-keyscan", args...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w (output: %s)", err, strings.TrimSpace(string(output)))
|
return nil, fmt.Errorf("%w (output: %s)", err, strings.TrimSpace(string(output)))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue