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:
rcourtman 2025-11-05 20:03:29 +00:00
parent 13c2005282
commit e21a72578f
10 changed files with 383 additions and 20 deletions

View file

@ -37,6 +37,7 @@ export interface SystemConfig {
backupPollingInterval?: number; // Backup polling interval in seconds (0 = default cadence)
backupPollingEnabled?: boolean; // Enable backup polling of PVE/PBS data
temperatureMonitoringEnabled?: boolean; // Collect CPU/NVMe temperatures via SSH
sshPort?: number; // SSH port for temperature monitoring (default: 22)
allowedOrigins?: string; // CORS allowed origins
backendPort?: number; // Backend API port (default: 7655)
frontendPort?: number; // Frontend UI port (default: 7655)
@ -156,6 +157,7 @@ export const DEFAULT_CONFIG: {
backupPollingEnabled: true,
backupPollingInterval: 0,
temperatureMonitoringEnabled: true,
sshPort: 22,
allowedOrigins: '',
backendPort: 7655,
frontendPort: 7655,

View 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)
}
}

View file

@ -2837,7 +2837,7 @@ func (h *ConfigHandlers) HandleVerifyTemperatureSSH(w http.ResponseWriter, r *ht
homeDir = "/home/pulse"
}
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)
defer cancel()

View file

@ -103,6 +103,7 @@ type Config struct {
GuestMetadataRetryBackoff time.Duration `envconfig:"GUEST_METADATA_RETRY_BACKOFF" default:"30s" json:"guestMetadataRetryBackoff"`
GuestMetadataMaxConcurrent int `envconfig:"GUEST_METADATA_MAX_CONCURRENT" default:"4" json:"guestMetadataMaxConcurrent"`
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
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)
PhysicalDiskPollingMinutes int // How often to poll physical disks (0 = use default)
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
IsCluster bool // True if this is a cluster
@ -438,6 +440,7 @@ type PBSInstance struct {
MonitorPruneJobs bool
MonitorGarbageJobs bool
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
@ -456,6 +459,7 @@ type PMGInstance struct {
MonitorQuarantine bool
MonitorDomainStats bool
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
@ -624,6 +628,12 @@ func Load() (*Config, error) {
if systemSettings.DNSCacheTimeout > 0 {
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
log.Info().
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
if frontendPort := os.Getenv("FRONTEND_PORT"); frontendPort != "" {
if p, err := strconv.Atoi(frontendPort); err == nil {
@ -1259,6 +1283,7 @@ func SaveConfig(cfg *Config) error {
AdaptivePollingMinInterval: int(cfg.AdaptivePollingMinInterval / time.Second),
AdaptivePollingMaxInterval: int(cfg.AdaptivePollingMaxInterval / time.Second),
DNSCacheTimeout: int(cfg.DNSCacheTimeout / time.Second),
SSHPort: cfg.SSHPort,
// APIToken removed - now handled via .env only
}
if err := globalPersistence.SaveSystemSettings(systemSettings); err != nil {

View file

@ -822,6 +822,7 @@ type SystemSettings struct {
AllowedEmbedOrigins string `json:"allowedEmbedOrigins,omitempty"` // Comma-separated list of allowed origins for embedding
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
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
}

View file

@ -3101,7 +3101,7 @@ func New(cfg *config.Config) (*Monitor, error) {
homeDir = "/home/pulse"
}
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
checkContainerizedTempMonitoring()

View file

@ -12,7 +12,7 @@ func TestMonitorTemperatureCollectorToggle(t *testing.T) {
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{
config: cfg,

View file

@ -36,6 +36,7 @@ type temperatureProxy interface {
type TemperatureCollector struct {
sshUser string // SSH user (typically "root" or "pulse-monitor")
sshKeyPath string // Path to SSH private key
sshPort int // SSH port (default 22)
proxyClient temperatureProxy // Optional: unix socket client for proxy
useProxy bool // Whether to use proxy for temperature collection
hostKeys knownhosts.Manager
@ -46,11 +47,21 @@ type TemperatureCollector struct {
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 {
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{
sshUser: sshUser,
sshKeyPath: sshKeyPath,
sshPort: sshPort,
}
homeDir := os.Getenv("HOME")
@ -192,6 +203,7 @@ func (tc *TemperatureCollector) runSSHCommand(ctx context.Context, host, command
"-o", "BatchMode=yes",
"-o", "LogLevel=ERROR", // Suppress host key warnings that break JSON parsing
"-o", "ConnectTimeout=5",
"-p", fmt.Sprintf("%d", tc.sshPort), // Use configured SSH port
}
if tc.hostKeys != nil && tc.hostKeys.Path() != "" {
@ -625,7 +637,7 @@ func (tc *TemperatureCollector) ensureHostKey(ctx context.Context, host string)
if ctx == nil {
ctx = context.Background()
}
return tc.hostKeys.Ensure(ctx, host)
return tc.hostKeys.EnsureWithPort(ctx, host, tc.sshPort)
}
func (tc *TemperatureCollector) isProxyEnabled() bool {

View file

@ -28,14 +28,19 @@ type temperatureService struct {
enabled bool
user string
keyPath string
sshPort int
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{
enabled: enabled,
user: user,
keyPath: keyPath,
sshPort: sshPort,
}
}
@ -55,7 +60,7 @@ func (s *temperatureService) Enable() {
s.enabled = true
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 {
s.mu.Lock()
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
enabled = s.enabled

View file

@ -20,6 +20,9 @@ type Manager interface {
// Ensure guarantees that the host key for the provided host exists in the
// managed known_hosts file.
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() string
}
@ -32,7 +35,7 @@ type manager struct {
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 (
defaultKeyscanTimeout = 5 * time.Second
@ -84,16 +87,26 @@ func NewManager(path string, opts ...Option) (Manager, error) {
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 {
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) == "" {
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()
defer m.mu.Unlock()
if _, ok := m.cache[host]; ok {
if _, ok := m.cache[cacheKey]; ok {
return nil
}
@ -101,30 +114,36 @@ func (m *manager) Ensure(ctx context.Context, host string) error {
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 {
return err
}
if exists {
m.cache[host] = struct{}{}
m.cache[cacheKey] = struct{}{}
return nil
}
keyData, err := m.keyscanFn(ctx, host, m.keyscanTimeout)
keyData, err := m.keyscanFn(ctx, host, port, m.keyscanTimeout)
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 {
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 {
return err
}
m.cache[host] = struct{}{}
m.cache[cacheKey] = struct{}{}
return nil
}
@ -261,16 +280,25 @@ func hostCandidates(part string) []string {
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)
if seconds <= 0 {
seconds = int(defaultKeyscanTimeout / time.Second)
}
if port <= 0 {
port = 22
}
scanCtx, cancel := context.WithTimeout(ctx, timeout)
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()
if err != nil {
return nil, fmt.Errorf("%w (output: %s)", err, strings.TrimSpace(string(output)))