From e21a72578fa41615455d4f30904d2ae6f271b563 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 5 Nov 2025 20:03:29 +0000 Subject: [PATCH] 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. --- frontend-modern/src/types/config.ts | 2 + .../alerts/per_metric_delay_example_test.go | 290 ++++++++++++++++++ internal/api/config_handlers.go | 2 +- internal/config/config.go | 25 ++ internal/config/persistence.go | 1 + internal/monitoring/monitor.go | 2 +- .../monitor_temperature_toggle_test.go | 2 +- internal/monitoring/temperature.go | 16 +- internal/monitoring/temperature_service.go | 11 +- internal/ssh/knownhosts/manager.go | 52 +++- 10 files changed, 383 insertions(+), 20 deletions(-) create mode 100644 internal/alerts/per_metric_delay_example_test.go diff --git a/frontend-modern/src/types/config.ts b/frontend-modern/src/types/config.ts index 2247a77..ada80e1 100644 --- a/frontend-modern/src/types/config.ts +++ b/frontend-modern/src/types/config.ts @@ -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, diff --git a/internal/alerts/per_metric_delay_example_test.go b/internal/alerts/per_metric_delay_example_test.go new file mode 100644 index 0000000..026411a --- /dev/null +++ b/internal/alerts/per_metric_delay_example_test.go @@ -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) + } +} diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go index 0bfe590..8b373fd 100644 --- a/internal/api/config_handlers.go +++ b/internal/api/config_handlers.go @@ -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() diff --git a/internal/config/config.go b/internal/config/config.go index bbace64..5495e1f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 { diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 3b64345..e228e97 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -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 } diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 21d7098..36a74c8 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -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() diff --git a/internal/monitoring/monitor_temperature_toggle_test.go b/internal/monitoring/monitor_temperature_toggle_test.go index 32da533..7e13bb8 100644 --- a/internal/monitoring/monitor_temperature_toggle_test.go +++ b/internal/monitoring/monitor_temperature_toggle_test.go @@ -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, diff --git a/internal/monitoring/temperature.go b/internal/monitoring/temperature.go index b0043d2..191648a 100644 --- a/internal/monitoring/temperature.go +++ b/internal/monitoring/temperature.go @@ -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 { diff --git a/internal/monitoring/temperature_service.go b/internal/monitoring/temperature_service.go index d549bfb..73ce6ce 100644 --- a/internal/monitoring/temperature_service.go +++ b/internal/monitoring/temperature_service.go @@ -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 diff --git a/internal/ssh/knownhosts/manager.go b/internal/ssh/knownhosts/manager.go index eb076f8..6a4cfb4 100644 --- a/internal/ssh/knownhosts/manager.go +++ b/internal/ssh/knownhosts/manager.go @@ -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)))