From 9ec67e9b64790476c98c5b9f21cde05704cbe55b Mon Sep 17 00:00:00 2001 From: Ona Date: Fri, 26 Sep 2025 00:32:35 +0000 Subject: [PATCH] Replace Go SSH library with native ssh command Major rewrite to use native ssh command instead of Go SSH library: BREAKING CHANGE: Now requires ssh command in PATH Benefits: - Full SSH feature support including ProxyCommand - Works with SSH containers and jump hosts - Supports all SSH authentication methods - Consistent behavior with terminal SSH - No more custom SSH client implementation Changes: - Port detection now uses 'ssh hostname command' - Port forwarding uses 'ssh -L localport:localhost:remoteport hostname' - Connection testing uses native ssh command - Removed golang.org/x/crypto/ssh dependency - Updated documentation to reflect SSH compatibility This fixes issues with SSH containers that require ProxyCommand and provides full compatibility with user SSH configurations. Co-authored-by: Ona --- README.md | 27 ++++-- main.go | 77 +++++++++++++++- port_detection.go | 115 +++++------------------- port_forwarder.go | 219 ++++++++++------------------------------------ ssh_config.go | 8 +- 5 files changed, 170 insertions(+), 276 deletions(-) diff --git a/README.md b/README.md index f329099..31e6039 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,11 @@ A terminal user interface (TUI) application for SSH port forwarding that reads f - **SSH Config Integration**: Automatically reads from `~/.ssh/config` - **Include Support**: Supports SSH config `Include` directive with glob patterns +- **Full SSH Compatibility**: Uses native `ssh` command - supports ProxyCommand, jump hosts, and all SSH features - **Interactive Host Selection**: Choose from configured SSH hosts using arrow keys - **Automatic Port Detection**: Scans remote host for listening ports using `netstat`, `ss`, or `lsof` - **Manual Port Forwarding**: Option to manually specify remote ports with improved UI -- **Real-time Port Forwarding**: Creates SSH tunnels similar to VSCode's remote SSH port forwarding +- **Real-time Port Forwarding**: Creates SSH tunnels using `ssh -L` command - **Clean TUI Interface**: Built with Bubble Tea for a smooth terminal experience ## Installation @@ -95,9 +96,13 @@ Relative paths in includes are resolved relative to `~/.ssh/` directory, matchin ## Authentication -The application supports: +The application uses the native `ssh` command, so it supports all SSH authentication methods: - SSH key-based authentication (using IdentityFile from config) - SSH agent authentication (if SSH_AUTH_SOCK is set) +- ProxyCommand for jump hosts and SSH containers +- All other SSH configuration options (ControlMaster, etc.) + +This means if you can connect with `ssh hostname`, kport will work too! ## Requirements @@ -115,10 +120,10 @@ The application supports: ## How It Works 1. **Config Parsing**: Reads and parses your SSH config file to extract host information -2. **SSH Connection**: Establishes SSH connection using configured authentication methods -3. **Port Detection**: Runs commands like `netstat -tlnp` on the remote host to find listening ports -4. **Port Forwarding**: Creates local TCP listener that forwards connections through SSH tunnel -5. **Traffic Relay**: Copies data bidirectionally between local and remote connections +2. **SSH Connection**: Uses native `ssh` command with all your configured options +3. **Port Detection**: Runs commands like `netstat -tlnp` on the remote host via SSH to find listening ports +4. **Port Forwarding**: Uses `ssh -L localport:localhost:remoteport hostname` for tunneling +5. **Full Compatibility**: Works with ProxyCommand, jump hosts, SSH containers, and all SSH features ## Expected Behavior @@ -135,11 +140,17 @@ The application gracefully handles connection failures and allows you to: ## Limitations -- Password authentication is not implemented (use SSH keys or agent) -- Host key verification uses `InsecureIgnoreHostKey` (should be improved for production use) +- Requires `ssh` command to be available in PATH - Port detection requires `netstat`, `ss`, or `lsof` on the remote host - Connection failures are expected for non-existent or unreachable hosts +## Advantages of Using Native SSH Command + +- **Full SSH Feature Support**: ProxyCommand, ControlMaster, jump hosts, etc. +- **Consistent Behavior**: Same authentication and connection logic as your terminal +- **SSH Container Support**: Works with containers that require ProxyCommand +- **No Additional Setup**: If `ssh hostname` works, kport works too + ## License MIT License \ No newline at end of file diff --git a/main.go b/main.go index f49d98e..b07eab3 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,9 @@ package main import ( "fmt" "os" + "os/exec" + "os/user" + "strings" ) func main() { @@ -90,9 +93,52 @@ func testConnection(hostName string) { } fmt.Println("") + // Expand shell variables in the host config + expandedHost := *host + expandedHost.User = expandShellVars(host.User) + expandedHost.Identity = expandShellVars(host.Identity) + + if expandedHost.User != host.User { + fmt.Printf("Expanded user: %s -> %s\n", host.User, expandedHost.User) + } + if expandedHost.Identity != host.Identity { + fmt.Printf("Expanded identity: %s -> %s\n", host.Identity, expandedHost.Identity) + } + if expandedHost.User != host.User || expandedHost.Identity != host.Identity { + fmt.Println("") + } + + // Test SSH connection using ssh command (supports all SSH features) + fmt.Println("Testing SSH connection...") + fmt.Printf("Running: ssh -o ConnectTimeout=10 -o BatchMode=yes %s echo 'connection test'\n", expandedHost.Name) + + sshCmd := exec.Command("ssh", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes", expandedHost.Name, "echo", "connection test") + output, err := sshCmd.Output() + if err != nil { + fmt.Printf("❌ SSH connection failed: %v\n", err) + fmt.Println("") + fmt.Println("Common SSH connection issues:") + fmt.Println("- SSH keys not set up or not in SSH agent") + fmt.Println("- Wrong username or hostname") + fmt.Println("- Host key verification failed") + fmt.Println("- SSH server not running or configured differently") + fmt.Println("- ProxyCommand or other SSH config issues") + fmt.Println("") + fmt.Println("Try running the SSH command manually:") + fmt.Printf(" ssh %s\n", expandedHost.Name) + return + } + + if strings.TrimSpace(string(output)) == "connection test" { + fmt.Printf("✅ SSH connection successful!\n") + } else { + fmt.Printf("⚠️ SSH connection partially successful but got unexpected output: %s\n", string(output)) + } + fmt.Println("") + // Test port detection fmt.Println("Testing port detection...") - ports, err := detectRemotePorts(*host) + ports, err := detectRemotePorts(expandedHost) if err != nil { fmt.Printf("❌ Port detection failed: %v\n", err) fmt.Println("") @@ -107,4 +153,33 @@ func testConnection(hostName string) { fmt.Println("") fmt.Println("You can still use manual port forwarding in the TUI even if port detection fails.") +} + +// expandShellVars expands shell variables in SSH config values +func expandShellVars(value string) string { + if value == "" { + return value + } + + // Handle $(whoami) and $USER + if strings.Contains(value, "$(whoami)") || strings.Contains(value, "$USER") { + currentUser, err := user.Current() + if err == nil { + value = strings.ReplaceAll(value, "$(whoami)", currentUser.Username) + value = strings.ReplaceAll(value, "$USER", currentUser.Username) + } + } + + // Handle $HOME and ~/ + if strings.Contains(value, "$HOME") || strings.HasPrefix(value, "~/") { + homeDir, err := os.UserHomeDir() + if err == nil { + value = strings.ReplaceAll(value, "$HOME", homeDir) + if strings.HasPrefix(value, "~/") { + value = strings.Replace(value, "~/", homeDir+"/", 1) + } + } + } + + return value } \ No newline at end of file diff --git a/port_detection.go b/port_detection.go index dd037f1..8fac55d 100644 --- a/port_detection.go +++ b/port_detection.go @@ -2,15 +2,13 @@ package main import ( "fmt" - "net" "os" + "os/exec" "sort" "strconv" "strings" - "time" tea "github.com/charmbracelet/bubbletea" - "golang.org/x/crypto/ssh" ) // PortsDetectedMsg is sent when ports are detected @@ -38,51 +36,8 @@ func DetectPorts(host SSHHost) tea.Cmd { } } -// detectRemotePorts connects to the remote host and detects open ports +// detectRemotePorts connects to the remote host and detects open ports using ssh command func detectRemotePorts(host SSHHost) ([]int, error) { - // Create SSH client configuration - config := &ssh.ClientConfig{ - User: host.User, - Auth: []ssh.AuthMethod{}, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), // In production, use proper host key verification - Timeout: 5 * time.Second, // Shorter timeout - } - - // Add key-based authentication if identity file is specified - if host.Identity != "" { - key, err := loadPrivateKey(host.Identity) - if err == nil { - config.Auth = append(config.Auth, ssh.PublicKeys(key)) - } - } - - // Add SSH agent authentication if available - if agentAuth, err := sshAgentAuth(); err == nil { - config.Auth = append(config.Auth, agentAuth) - } - - // If no auth methods available, add a dummy one to avoid empty auth - if len(config.Auth) == 0 { - config.Auth = append(config.Auth, ssh.PasswordCallback(func() (string, error) { - return "", fmt.Errorf("no authentication methods available") - })) - } - - // Connect to the remote host - addr := net.JoinHostPort(host.Hostname, host.Port) - client, err := ssh.Dial("tcp", addr, config) - if err != nil { - return nil, fmt.Errorf("failed to connect to %s (%s): %w", host.Name, addr, err) - } - defer client.Close() - - // Run netstat command to detect listening ports - session, err := client.NewSession() - if err != nil { - return nil, fmt.Errorf("failed to create SSH session: %w", err) - } - defer session.Close() - // Try different commands to detect listening ports commands := []string{ "netstat -tlnp 2>/dev/null | grep LISTEN | awk '{print $4}' | cut -d: -f2 | sort -n | uniq", @@ -91,23 +46,26 @@ func detectRemotePorts(host SSHHost) ([]int, error) { } var output []byte + var err error + for _, cmd := range commands { - session, err = client.NewSession() - if err != nil { - continue - } + fmt.Fprintf(os.Stderr, "Debug: Running command on %s: %s\n", host.Name, cmd) - output, err = session.Output(cmd) - session.Close() + // Use ssh command directly - this supports all SSH features including ProxyCommand + sshCmd := exec.Command("ssh", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes", host.Name, cmd) + output, err = sshCmd.Output() if err == nil && len(output) > 0 { + fmt.Fprintf(os.Stderr, "Debug: Command succeeded, got output\n") break } + fmt.Fprintf(os.Stderr, "Debug: Command failed: %v\n", err) } if err != nil || len(output) == 0 { + fmt.Fprintf(os.Stderr, "Debug: All port detection commands failed, trying common ports\n") // Fallback: try common ports - return detectCommonPorts(client), nil + return detectCommonPorts(host), nil } // Parse the output to extract port numbers @@ -133,56 +91,29 @@ func detectRemotePorts(host SSHHost) ([]int, error) { return ports, nil } -// detectCommonPorts tries to detect common ports by attempting connections -func detectCommonPorts(client *ssh.Client) []int { +// detectCommonPorts tries to detect common ports by testing connections through SSH +func detectCommonPorts(host SSHHost) []int { commonPorts := []int{80, 443, 3000, 3001, 4000, 5000, 8000, 8080, 8443, 9000} var openPorts []int + fmt.Fprintf(os.Stderr, "Debug: Testing common ports on %s\n", host.Name) + for _, port := range commonPorts { - // Try to create a connection to the port through the SSH tunnel - conn, err := client.Dial("tcp", fmt.Sprintf("localhost:%d", port)) - if err == nil { - conn.Close() + // Test if port is open using SSH to run a quick connection test + cmd := fmt.Sprintf("timeout 1 bash -c '/dev/null && echo 'open' || echo 'closed'", port) + sshCmd := exec.Command("ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", host.Name, cmd) + + output, err := sshCmd.Output() + if err == nil && strings.TrimSpace(string(output)) == "open" { openPorts = append(openPorts, port) + fmt.Fprintf(os.Stderr, "Debug: Port %d is open\n", port) } } return openPorts } -// loadPrivateKey loads a private key from file -func loadPrivateKey(keyPath string) (ssh.Signer, error) { - // Expand tilde to home directory - if strings.HasPrefix(keyPath, "~/") { - homeDir, err := getHomeDir() - if err != nil { - return nil, err - } - keyPath = strings.Replace(keyPath, "~", homeDir, 1) - } - keyBytes, err := readFile(keyPath) - if err != nil { - return nil, fmt.Errorf("failed to read private key file: %w", err) - } - - key, err := ssh.ParsePrivateKey(keyBytes) - if err != nil { - return nil, fmt.Errorf("failed to parse private key: %w", err) - } - - return key, nil -} - -// getHomeDir returns the user's home directory -func getHomeDir() (string, error) { - return os.UserHomeDir() -} - -// readFile reads a file and returns its contents -func readFile(path string) ([]byte, error) { - return os.ReadFile(path) -} // removeDuplicates removes duplicate integers from a slice func removeDuplicates(slice []int) []int { diff --git a/port_forwarder.go b/port_forwarder.go index b6a865c..e0d4c7c 100644 --- a/port_forwarder.go +++ b/port_forwarder.go @@ -2,17 +2,13 @@ package main import ( "fmt" - "io" "net" "os" - "path/filepath" + "os/exec" "strconv" "sync" - "time" tea "github.com/charmbracelet/bubbletea" - "golang.org/x/crypto/ssh" - "golang.org/x/crypto/ssh/agent" ) // ForwardingStartedMsg is sent when port forwarding starts @@ -21,29 +17,29 @@ type ForwardingStartedMsg struct { RemotePort int } -// PortForwarder manages SSH port forwarding +// PortForwarder manages SSH port forwarding using ssh command type PortForwarder struct { - sshClient *ssh.Client + hostName string localPort int remotePort int - listener net.Listener + sshCmd *exec.Cmd stopChan chan struct{} wg sync.WaitGroup isRunning bool mu sync.Mutex } -// NewPortForwarder creates a new port forwarder -func NewPortForwarder(sshClient *ssh.Client, localPort, remotePort int) *PortForwarder { +// NewPortForwarder creates a new port forwarder using ssh command +func NewPortForwarder(hostName string, localPort, remotePort int) *PortForwarder { return &PortForwarder{ - sshClient: sshClient, + hostName: hostName, localPort: localPort, remotePort: remotePort, stopChan: make(chan struct{}), } } -// Start starts the port forwarding +// Start starts the port forwarding using ssh command func (pf *PortForwarder) Start() error { pf.mu.Lock() defer pf.mu.Unlock() @@ -52,18 +48,28 @@ func (pf *PortForwarder) Start() error { return fmt.Errorf("port forwarding already running") } - // Create local listener - listener, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", pf.localPort)) - if err != nil { - return fmt.Errorf("failed to create local listener: %w", err) + // Use ssh command with -L flag for local port forwarding + // Format: ssh -L localport:localhost:remoteport hostname + pf.sshCmd = exec.Command("ssh", + "-L", fmt.Sprintf("%d:localhost:%d", pf.localPort, pf.remotePort), + "-N", // Don't execute remote command, just forward ports + "-o", "ExitOnForwardFailure=yes", // Exit if port forwarding fails + "-o", "ServerAliveInterval=30", // Keep connection alive + "-o", "ServerAliveCountMax=3", + pf.hostName) + + fmt.Fprintf(os.Stderr, "Debug: Starting SSH command: %s\n", pf.sshCmd.String()) + + // Start the SSH command + if err := pf.sshCmd.Start(); err != nil { + return fmt.Errorf("failed to start SSH port forwarding: %w", err) } - pf.listener = listener pf.isRunning = true - // Start accepting connections + // Monitor the SSH process pf.wg.Add(1) - go pf.acceptConnections() + go pf.monitorSSH() return nil } @@ -80,78 +86,34 @@ func (pf *PortForwarder) Stop() { pf.isRunning = false close(pf.stopChan) - if pf.listener != nil { - pf.listener.Close() + // Kill the SSH process + if pf.sshCmd != nil && pf.sshCmd.Process != nil { + fmt.Fprintf(os.Stderr, "Debug: Stopping SSH port forwarding\n") + pf.sshCmd.Process.Kill() } pf.wg.Wait() } -// acceptConnections accepts and handles incoming connections -func (pf *PortForwarder) acceptConnections() { +// monitorSSH monitors the SSH process +func (pf *PortForwarder) monitorSSH() { defer pf.wg.Done() - for { - select { - case <-pf.stopChan: - return - default: - // Set a timeout for Accept to avoid blocking indefinitely - if tcpListener, ok := pf.listener.(*net.TCPListener); ok { - tcpListener.SetDeadline(time.Now().Add(1 * time.Second)) - } - - conn, err := pf.listener.Accept() - if err != nil { - // Check if it's a timeout error and continue - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - continue - } - // If we're stopping, ignore the error - select { - case <-pf.stopChan: - return - default: - continue - } - } - - // Handle the connection in a separate goroutine - pf.wg.Add(1) - go pf.handleConnection(conn) + // Wait for the SSH command to finish or be stopped + select { + case <-pf.stopChan: + // We were asked to stop + return + default: + // Wait for SSH command to finish + if err := pf.sshCmd.Wait(); err != nil { + fmt.Fprintf(os.Stderr, "Debug: SSH command finished with error: %v\n", err) + } else { + fmt.Fprintf(os.Stderr, "Debug: SSH command finished successfully\n") } } } -// handleConnection handles a single connection -func (pf *PortForwarder) handleConnection(localConn net.Conn) { - defer pf.wg.Done() - defer localConn.Close() - - // Create connection to remote host through SSH - remoteConn, err := pf.sshClient.Dial("tcp", fmt.Sprintf("localhost:%d", pf.remotePort)) - if err != nil { - return - } - defer remoteConn.Close() - - // Copy data between connections - var wg sync.WaitGroup - wg.Add(2) - - go func() { - defer wg.Done() - io.Copy(localConn, remoteConn) - }() - - go func() { - defer wg.Done() - io.Copy(remoteConn, localConn) - }() - - wg.Wait() -} - // StartPortForwarding starts port forwarding for a specific port func StartPortForwarding(host SSHHost, remotePort int) tea.Cmd { return func() tea.Msg { @@ -165,18 +127,9 @@ func StartPortForwarding(host SSHHost, remotePort int) tea.Cmd { } fmt.Fprintf(os.Stderr, "Debug: Found available local port: %d\n", localPort) - // Create SSH client - client, err := createSSHClient(host) - if err != nil { - fmt.Fprintf(os.Stderr, "Debug: Failed to create SSH client: %v\n", err) - return ErrorMsg{Error: fmt.Errorf("failed to connect to %s: %w", host.Name, err)} - } - fmt.Fprintf(os.Stderr, "Debug: SSH client created successfully\n") - - // Create and start port forwarder - forwarder := NewPortForwarder(client, localPort, remotePort) + // Create and start port forwarder using ssh command + forwarder := NewPortForwarder(host.Name, localPort, remotePort) if err := forwarder.Start(); err != nil { - client.Close() fmt.Fprintf(os.Stderr, "Debug: Failed to start port forwarder: %v\n", err) return ErrorMsg{Error: fmt.Errorf("failed to start port forwarding: %w", err)} } @@ -213,18 +166,9 @@ func StartManualPortForwarding(host SSHHost, portStr string) tea.Cmd { } fmt.Fprintf(os.Stderr, "Debug: Found available local port: %d\n", localPort) - // Create SSH client - client, err := createSSHClient(host) - if err != nil { - fmt.Fprintf(os.Stderr, "Debug: Failed to create SSH client: %v\n", err) - return ErrorMsg{Error: fmt.Errorf("failed to connect to %s: %w", host.Name, err)} - } - fmt.Fprintf(os.Stderr, "Debug: SSH client created successfully\n") - - // Create and start port forwarder - forwarder := NewPortForwarder(client, localPort, remotePort) + // Create and start port forwarder using ssh command + forwarder := NewPortForwarder(host.Name, localPort, remotePort) if err := forwarder.Start(); err != nil { - client.Close() fmt.Fprintf(os.Stderr, "Debug: Failed to start port forwarder: %v\n", err) return ErrorMsg{Error: fmt.Errorf("failed to start port forwarding: %w", err)} } @@ -237,78 +181,7 @@ func StartManualPortForwarding(host SSHHost, portStr string) tea.Cmd { } } -// createSSHClient creates an SSH client for the given host -func createSSHClient(host SSHHost) (*ssh.Client, error) { - config := &ssh.ClientConfig{ - User: host.User, - Auth: []ssh.AuthMethod{}, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), // In production, use proper host key verification - Timeout: 10 * time.Second, // Longer timeout for better reliability - } - // Add key-based authentication if identity file is specified - if host.Identity != "" { - fmt.Fprintf(os.Stderr, "Debug: Trying identity file: %s\n", host.Identity) - key, err := loadPrivateKey(host.Identity) - if err == nil { - config.Auth = append(config.Auth, ssh.PublicKeys(key)) - fmt.Fprintf(os.Stderr, "Debug: Added key-based auth\n") - } else { - fmt.Fprintf(os.Stderr, "Debug: Failed to load identity file: %v\n", err) - } - } - - // Add SSH agent authentication - if agentAuth, err := sshAgentAuth(); err == nil { - config.Auth = append(config.Auth, agentAuth) - fmt.Fprintf(os.Stderr, "Debug: Added SSH agent auth\n") - } else { - fmt.Fprintf(os.Stderr, "Debug: SSH agent not available: %v\n", err) - } - - // Try to load default SSH keys if no specific identity is set - if host.Identity == "" { - defaultKeys := []string{"id_rsa", "id_ecdsa", "id_ed25519"} - homeDir, err := os.UserHomeDir() - if err == nil { - for _, keyName := range defaultKeys { - keyPath := filepath.Join(homeDir, ".ssh", keyName) - if key, err := loadPrivateKey(keyPath); err == nil { - config.Auth = append(config.Auth, ssh.PublicKeys(key)) - fmt.Fprintf(os.Stderr, "Debug: Added default key: %s\n", keyName) - } - } - } - } - - // If no auth methods available, provide helpful error - if len(config.Auth) == 0 { - return nil, fmt.Errorf("no SSH authentication methods available - please set up SSH keys or SSH agent") - } - - // Connect to the remote host - addr := net.JoinHostPort(host.Hostname, host.Port) - fmt.Fprintf(os.Stderr, "Debug: Connecting to %s\n", addr) - client, err := ssh.Dial("tcp", addr, config) - if err != nil { - return nil, fmt.Errorf("failed to connect to %s (%s): %w", host.Name, addr, err) - } - - fmt.Fprintf(os.Stderr, "Debug: Successfully connected to %s\n", host.Name) - return client, nil -} - -// sshAgentAuth returns SSH agent authentication method -func sshAgentAuth() (ssh.AuthMethod, error) { - // Try to connect to SSH agent - agentConn, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")) - if err != nil { - return nil, err - } - - sshAgent := agent.NewClient(agentConn) - return ssh.PublicKeysCallback(sshAgent.Signers), nil -} // findAvailablePort finds an available local port func findAvailablePort() (int, error) { diff --git a/ssh_config.go b/ssh_config.go index e88bb99..4387d50 100644 --- a/ssh_config.go +++ b/ssh_config.go @@ -175,11 +175,15 @@ func (sc *SSHConfig) GetHosts() []SSHHost { return sc.Hosts } -// GetHostByName returns a specific host by name +// GetHostByName returns a specific host by name with expanded variables func (sc *SSHConfig) GetHostByName(name string) (*SSHHost, error) { for _, host := range sc.Hosts { if host.Name == name { - return &host, nil + // Return a copy with expanded shell variables + expandedHost := host + expandedHost.User = expandShellVars(host.User) + expandedHost.Identity = expandShellVars(host.Identity) + return &expandedHost, nil } } return nil, fmt.Errorf("host '%s' not found", name)