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)
|
||||
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,
|
||||
|
|
|
|||
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"
|
||||
}
|
||||
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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
Loading…
Reference in a new issue