Merge pull request #44 from fgbm/main

Fix: connectivity check for hosts using ProxyJump or ProxyCommand
This commit is contained in:
Guillaume Archambault
2026-02-22 12:24:42 +01:00
committed by GitHub
3 changed files with 88 additions and 28 deletions

View File

@@ -2,12 +2,15 @@ package connectivity
import (
"context"
"fmt"
"net"
"github.com/Gu1llaum-3/sshm/internal/config"
"os/exec"
"strings"
"sync"
"time"
"github.com/Gu1llaum-3/sshm/internal/config"
"golang.org/x/crypto/ssh"
)
@@ -45,16 +48,18 @@ type HostPingResult struct {
// PingManager manages SSH connectivity checks for multiple hosts
type PingManager struct {
results map[string]*HostPingResult
mutex sync.RWMutex
timeout time.Duration
results map[string]*HostPingResult
mutex sync.RWMutex
timeout time.Duration
configFile string
}
// NewPingManager creates a new ping manager with the specified timeout
func NewPingManager(timeout time.Duration) *PingManager {
func NewPingManager(timeout time.Duration, configFile string) *PingManager {
return &PingManager{
results: make(map[string]*HostPingResult),
timeout: timeout,
results: make(map[string]*HostPingResult),
timeout: timeout,
configFile: configFile,
}
}
@@ -98,6 +103,14 @@ func (pm *PingManager) PingHost(ctx context.Context, host config.SSHHost) *HostP
// Mark as connecting
pm.updateStatus(host.Name, StatusConnecting, nil, 0)
// If the host uses a ProxyJump or ProxyCommand, we need to use the external SSH command
// because implementing jump host support with pure Go ssh library requires
// handling authentication for the jump host, which is complex and requires
// access to the user's SSH agent or keys.
if host.ProxyJump != "" || host.ProxyCommand != "" {
return pm.pingWithExternalCommand(ctx, host, start)
}
// Determine the actual hostname and port
hostname := host.Hostname
if hostname == "" {
@@ -159,6 +172,53 @@ func (pm *PingManager) PingHost(ctx context.Context, host config.SSHHost) *HostP
}
}
// pingWithExternalCommand pings a host using the external SSH command
func (pm *PingManager) pingWithExternalCommand(ctx context.Context, host config.SSHHost, start time.Time) *HostPingResult {
// Construct the SSH command
// ssh -q -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=5 host exit
args := []string{"-q", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no"}
// Set timeout matching the manager's timeout
// Convert duration to seconds (rounding up to ensure we don't timeout too early in the command)
timeoutSec := int(pm.timeout.Seconds())
if timeoutSec < 1 {
timeoutSec = 1
}
args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", timeoutSec))
// If we have a specific config file, use it
if pm.configFile != "" {
args = append(args, "-F", pm.configFile)
}
// Add the host name and the command to run (exit)
args = append(args, host.Name, "exit")
// Create command with context for timeout cancellation
// Note: We used pm.timeout for the ssh command option, but we also respect the context deadline
cmd := exec.CommandContext(ctx, "ssh", args...)
// Run the command
err := cmd.Run()
duration := time.Since(start)
var status PingStatus
if err != nil {
// SSH returns non-zero exit code on connection failure
status = StatusOffline
} else {
status = StatusOnline
}
pm.updateStatus(host.Name, status, err, duration)
return &HostPingResult{
HostName: host.Name,
Status: status,
Error: err,
Duration: duration,
}
}
// PingAllHosts pings all hosts concurrently and returns a channel of results
func (pm *PingManager) PingAllHosts(ctx context.Context, hosts []config.SSHHost) <-chan *HostPingResult {
resultChan := make(chan *HostPingResult, len(hosts))

View File

@@ -9,7 +9,7 @@ import (
)
func TestNewPingManager(t *testing.T) {
pm := NewPingManager(5 * time.Second)
pm := NewPingManager(5*time.Second, "")
if pm == nil {
t.Error("NewPingManager() returned nil")
}
@@ -19,16 +19,16 @@ func TestNewPingManager(t *testing.T) {
}
func TestPingManager_PingHost(t *testing.T) {
pm := NewPingManager(1 * time.Second)
pm := NewPingManager(1*time.Second, "")
ctx := context.Background()
// Test ping method exists and doesn't panic
host := config.SSHHost{Name: "test", Hostname: "127.0.0.1", Port: "22"}
result := pm.PingHost(ctx, host)
if result == nil {
t.Error("Expected ping result to be returned")
}
// Test with invalid host
invalidHost := config.SSHHost{Name: "invalid", Hostname: "invalid.host.12345", Port: "22"}
result = pm.PingHost(ctx, invalidHost)
@@ -38,14 +38,14 @@ func TestPingManager_PingHost(t *testing.T) {
}
func TestPingManager_GetStatus(t *testing.T) {
pm := NewPingManager(1 * time.Second)
pm := NewPingManager(1*time.Second, "")
// Test unknown host
status := pm.GetStatus("unknown.host")
if status != StatusUnknown {
t.Errorf("Expected StatusUnknown for unknown host, got %v", status)
}
// Test after ping
ctx := context.Background()
host := config.SSHHost{Name: "test", Hostname: "127.0.0.1", Port: "22"}
@@ -57,21 +57,21 @@ func TestPingManager_GetStatus(t *testing.T) {
}
func TestPingManager_PingMultipleHosts(t *testing.T) {
pm := NewPingManager(1 * time.Second)
pm := NewPingManager(1*time.Second, "")
hosts := []config.SSHHost{
{Name: "localhost", Hostname: "127.0.0.1", Port: "22"},
{Name: "invalid", Hostname: "invalid.host.12345", Port: "22"},
}
ctx := context.Background()
// Ping each host individually
for _, host := range hosts {
result := pm.PingHost(ctx, host)
if result == nil {
t.Errorf("Expected ping result for host %s", host.Name)
}
// Check that status was set
status := pm.GetStatus(host.Name)
if status == StatusUnknown {
@@ -81,19 +81,19 @@ func TestPingManager_PingMultipleHosts(t *testing.T) {
}
func TestPingManager_GetResult(t *testing.T) {
pm := NewPingManager(1 * time.Second)
pm := NewPingManager(1*time.Second, "")
ctx := context.Background()
// Test getting result for unknown host
result, exists := pm.GetResult("unknown")
if exists || result != nil {
t.Error("Expected no result for unknown host")
}
// Test after ping
host := config.SSHHost{Name: "test", Hostname: "127.0.0.1", Port: "22"}
pm.PingHost(ctx, host)
result, exists = pm.GetResult("test")
if !exists || result == nil {
t.Error("Expected result to exist after ping")
@@ -114,7 +114,7 @@ func TestPingStatus_String(t *testing.T) {
{StatusOffline, "offline"},
{PingStatus(999), "unknown"}, // Invalid status
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
if got := tt.status.String(); got != tt.expected {
@@ -126,19 +126,19 @@ func TestPingStatus_String(t *testing.T) {
func TestPingHost_Basic(t *testing.T) {
// Test that the ping functionality exists
pm := NewPingManager(1 * time.Second)
pm := NewPingManager(1*time.Second, "")
ctx := context.Background()
host := config.SSHHost{Name: "test", Hostname: "127.0.0.1", Port: "22"}
// Just ensure the function doesn't panic
result := pm.PingHost(ctx, host)
if result == nil {
t.Error("Expected ping result to be returned")
}
// Test that status is set
status := pm.GetStatus("test")
if status == StatusUnknown {
t.Error("Expected status to be set after ping attempt")
}
}
}

View File

@@ -38,7 +38,7 @@ func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, curren
styles := NewStyles(80) // Default width
// Initialize ping manager with 5 second timeout
pingManager := connectivity.NewPingManager(5 * time.Second)
pingManager := connectivity.NewPingManager(5*time.Second, configFile)
// Create the model with default sorting by name
m := Model{