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 <no-reply@ona.com>
This commit is contained in:
27
README.md
27
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`
|
- **SSH Config Integration**: Automatically reads from `~/.ssh/config`
|
||||||
- **Include Support**: Supports SSH config `Include` directive with glob patterns
|
- **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
|
- **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`
|
- **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
|
- **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
|
- **Clean TUI Interface**: Built with Bubble Tea for a smooth terminal experience
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -95,9 +96,13 @@ Relative paths in includes are resolved relative to `~/.ssh/` directory, matchin
|
|||||||
|
|
||||||
## Authentication
|
## 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 key-based authentication (using IdentityFile from config)
|
||||||
- SSH agent authentication (if SSH_AUTH_SOCK is set)
|
- 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
|
## Requirements
|
||||||
|
|
||||||
@@ -115,10 +120,10 @@ The application supports:
|
|||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
1. **Config Parsing**: Reads and parses your SSH config file to extract host information
|
1. **Config Parsing**: Reads and parses your SSH config file to extract host information
|
||||||
2. **SSH Connection**: Establishes SSH connection using configured authentication methods
|
2. **SSH Connection**: Uses native `ssh` command with all your configured options
|
||||||
3. **Port Detection**: Runs commands like `netstat -tlnp` on the remote host to find listening ports
|
3. **Port Detection**: Runs commands like `netstat -tlnp` on the remote host via SSH to find listening ports
|
||||||
4. **Port Forwarding**: Creates local TCP listener that forwards connections through SSH tunnel
|
4. **Port Forwarding**: Uses `ssh -L localport:localhost:remoteport hostname` for tunneling
|
||||||
5. **Traffic Relay**: Copies data bidirectionally between local and remote connections
|
5. **Full Compatibility**: Works with ProxyCommand, jump hosts, SSH containers, and all SSH features
|
||||||
|
|
||||||
## Expected Behavior
|
## Expected Behavior
|
||||||
|
|
||||||
@@ -135,11 +140,17 @@ The application gracefully handles connection failures and allows you to:
|
|||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- Password authentication is not implemented (use SSH keys or agent)
|
- Requires `ssh` command to be available in PATH
|
||||||
- Host key verification uses `InsecureIgnoreHostKey` (should be improved for production use)
|
|
||||||
- Port detection requires `netstat`, `ss`, or `lsof` on the remote host
|
- Port detection requires `netstat`, `ss`, or `lsof` on the remote host
|
||||||
- Connection failures are expected for non-existent or unreachable hosts
|
- 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
|
## License
|
||||||
|
|
||||||
MIT License
|
MIT License
|
77
main.go
77
main.go
@@ -3,6 +3,9 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/user"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -90,9 +93,52 @@ func testConnection(hostName string) {
|
|||||||
}
|
}
|
||||||
fmt.Println("")
|
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
|
// Test port detection
|
||||||
fmt.Println("Testing port detection...")
|
fmt.Println("Testing port detection...")
|
||||||
ports, err := detectRemotePorts(*host)
|
ports, err := detectRemotePorts(expandedHost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("❌ Port detection failed: %v\n", err)
|
fmt.Printf("❌ Port detection failed: %v\n", err)
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
@@ -108,3 +154,32 @@ func testConnection(hostName string) {
|
|||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println("You can still use manual port forwarding in the TUI even if port detection fails.")
|
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
|
||||||
|
}
|
@@ -2,15 +2,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// PortsDetectedMsg is sent when ports are detected
|
// 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) {
|
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
|
// Try different commands to detect listening ports
|
||||||
commands := []string{
|
commands := []string{
|
||||||
"netstat -tlnp 2>/dev/null | grep LISTEN | awk '{print $4}' | cut -d: -f2 | sort -n | uniq",
|
"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 output []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
for _, cmd := range commands {
|
for _, cmd := range commands {
|
||||||
session, err = client.NewSession()
|
fmt.Fprintf(os.Stderr, "Debug: Running command on %s: %s\n", host.Name, cmd)
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
output, err = session.Output(cmd)
|
// Use ssh command directly - this supports all SSH features including ProxyCommand
|
||||||
session.Close()
|
sshCmd := exec.Command("ssh", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes", host.Name, cmd)
|
||||||
|
|
||||||
|
output, err = sshCmd.Output()
|
||||||
if err == nil && len(output) > 0 {
|
if err == nil && len(output) > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Debug: Command succeeded, got output\n")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "Debug: Command failed: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil || len(output) == 0 {
|
if err != nil || len(output) == 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Debug: All port detection commands failed, trying common ports\n")
|
||||||
// Fallback: try common ports
|
// Fallback: try common ports
|
||||||
return detectCommonPorts(client), nil
|
return detectCommonPorts(host), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the output to extract port numbers
|
// Parse the output to extract port numbers
|
||||||
@@ -133,56 +91,29 @@ func detectRemotePorts(host SSHHost) ([]int, error) {
|
|||||||
return ports, nil
|
return ports, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectCommonPorts tries to detect common ports by attempting connections
|
// detectCommonPorts tries to detect common ports by testing connections through SSH
|
||||||
func detectCommonPorts(client *ssh.Client) []int {
|
func detectCommonPorts(host SSHHost) []int {
|
||||||
commonPorts := []int{80, 443, 3000, 3001, 4000, 5000, 8000, 8080, 8443, 9000}
|
commonPorts := []int{80, 443, 3000, 3001, 4000, 5000, 8000, 8080, 8443, 9000}
|
||||||
var openPorts []int
|
var openPorts []int
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "Debug: Testing common ports on %s\n", host.Name)
|
||||||
|
|
||||||
for _, port := range commonPorts {
|
for _, port := range commonPorts {
|
||||||
// Try to create a connection to the port through the SSH tunnel
|
// Test if port is open using SSH to run a quick connection test
|
||||||
conn, err := client.Dial("tcp", fmt.Sprintf("localhost:%d", port))
|
cmd := fmt.Sprintf("timeout 1 bash -c '</dev/tcp/localhost/%d' 2>/dev/null && echo 'open' || echo 'closed'", port)
|
||||||
if err == nil {
|
sshCmd := exec.Command("ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", host.Name, cmd)
|
||||||
conn.Close()
|
|
||||||
|
output, err := sshCmd.Output()
|
||||||
|
if err == nil && strings.TrimSpace(string(output)) == "open" {
|
||||||
openPorts = append(openPorts, port)
|
openPorts = append(openPorts, port)
|
||||||
|
fmt.Fprintf(os.Stderr, "Debug: Port %d is open\n", port)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return openPorts
|
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
|
// removeDuplicates removes duplicate integers from a slice
|
||||||
func removeDuplicates(slice []int) []int {
|
func removeDuplicates(slice []int) []int {
|
||||||
|
@@ -2,17 +2,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
"golang.org/x/crypto/ssh/agent"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ForwardingStartedMsg is sent when port forwarding starts
|
// ForwardingStartedMsg is sent when port forwarding starts
|
||||||
@@ -21,29 +17,29 @@ type ForwardingStartedMsg struct {
|
|||||||
RemotePort int
|
RemotePort int
|
||||||
}
|
}
|
||||||
|
|
||||||
// PortForwarder manages SSH port forwarding
|
// PortForwarder manages SSH port forwarding using ssh command
|
||||||
type PortForwarder struct {
|
type PortForwarder struct {
|
||||||
sshClient *ssh.Client
|
hostName string
|
||||||
localPort int
|
localPort int
|
||||||
remotePort int
|
remotePort int
|
||||||
listener net.Listener
|
sshCmd *exec.Cmd
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
isRunning bool
|
isRunning bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPortForwarder creates a new port forwarder
|
// NewPortForwarder creates a new port forwarder using ssh command
|
||||||
func NewPortForwarder(sshClient *ssh.Client, localPort, remotePort int) *PortForwarder {
|
func NewPortForwarder(hostName string, localPort, remotePort int) *PortForwarder {
|
||||||
return &PortForwarder{
|
return &PortForwarder{
|
||||||
sshClient: sshClient,
|
hostName: hostName,
|
||||||
localPort: localPort,
|
localPort: localPort,
|
||||||
remotePort: remotePort,
|
remotePort: remotePort,
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start starts the port forwarding
|
// Start starts the port forwarding using ssh command
|
||||||
func (pf *PortForwarder) Start() error {
|
func (pf *PortForwarder) Start() error {
|
||||||
pf.mu.Lock()
|
pf.mu.Lock()
|
||||||
defer pf.mu.Unlock()
|
defer pf.mu.Unlock()
|
||||||
@@ -52,18 +48,28 @@ func (pf *PortForwarder) Start() error {
|
|||||||
return fmt.Errorf("port forwarding already running")
|
return fmt.Errorf("port forwarding already running")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create local listener
|
// Use ssh command with -L flag for local port forwarding
|
||||||
listener, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", pf.localPort))
|
// Format: ssh -L localport:localhost:remoteport hostname
|
||||||
if err != nil {
|
pf.sshCmd = exec.Command("ssh",
|
||||||
return fmt.Errorf("failed to create local listener: %w", err)
|
"-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
|
pf.isRunning = true
|
||||||
|
|
||||||
// Start accepting connections
|
// Monitor the SSH process
|
||||||
pf.wg.Add(1)
|
pf.wg.Add(1)
|
||||||
go pf.acceptConnections()
|
go pf.monitorSSH()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -80,78 +86,34 @@ func (pf *PortForwarder) Stop() {
|
|||||||
pf.isRunning = false
|
pf.isRunning = false
|
||||||
close(pf.stopChan)
|
close(pf.stopChan)
|
||||||
|
|
||||||
if pf.listener != nil {
|
// Kill the SSH process
|
||||||
pf.listener.Close()
|
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()
|
pf.wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
// acceptConnections accepts and handles incoming connections
|
// monitorSSH monitors the SSH process
|
||||||
func (pf *PortForwarder) acceptConnections() {
|
func (pf *PortForwarder) monitorSSH() {
|
||||||
defer pf.wg.Done()
|
defer pf.wg.Done()
|
||||||
|
|
||||||
for {
|
// Wait for the SSH command to finish or be stopped
|
||||||
select {
|
select {
|
||||||
case <-pf.stopChan:
|
case <-pf.stopChan:
|
||||||
return
|
// We were asked to stop
|
||||||
default:
|
return
|
||||||
// Set a timeout for Accept to avoid blocking indefinitely
|
default:
|
||||||
if tcpListener, ok := pf.listener.(*net.TCPListener); ok {
|
// Wait for SSH command to finish
|
||||||
tcpListener.SetDeadline(time.Now().Add(1 * time.Second))
|
if err := pf.sshCmd.Wait(); err != nil {
|
||||||
}
|
fmt.Fprintf(os.Stderr, "Debug: SSH command finished with error: %v\n", err)
|
||||||
|
} else {
|
||||||
conn, err := pf.listener.Accept()
|
fmt.Fprintf(os.Stderr, "Debug: SSH command finished successfully\n")
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// StartPortForwarding starts port forwarding for a specific port
|
||||||
func StartPortForwarding(host SSHHost, remotePort int) tea.Cmd {
|
func StartPortForwarding(host SSHHost, remotePort int) tea.Cmd {
|
||||||
return func() tea.Msg {
|
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)
|
fmt.Fprintf(os.Stderr, "Debug: Found available local port: %d\n", localPort)
|
||||||
|
|
||||||
// Create SSH client
|
// Create and start port forwarder using ssh command
|
||||||
client, err := createSSHClient(host)
|
forwarder := NewPortForwarder(host.Name, localPort, remotePort)
|
||||||
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)
|
|
||||||
if err := forwarder.Start(); err != nil {
|
if err := forwarder.Start(); err != nil {
|
||||||
client.Close()
|
|
||||||
fmt.Fprintf(os.Stderr, "Debug: Failed to start port forwarder: %v\n", err)
|
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)}
|
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)
|
fmt.Fprintf(os.Stderr, "Debug: Found available local port: %d\n", localPort)
|
||||||
|
|
||||||
// Create SSH client
|
// Create and start port forwarder using ssh command
|
||||||
client, err := createSSHClient(host)
|
forwarder := NewPortForwarder(host.Name, localPort, remotePort)
|
||||||
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)
|
|
||||||
if err := forwarder.Start(); err != nil {
|
if err := forwarder.Start(); err != nil {
|
||||||
client.Close()
|
|
||||||
fmt.Fprintf(os.Stderr, "Debug: Failed to start port forwarder: %v\n", err)
|
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)}
|
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
|
// findAvailablePort finds an available local port
|
||||||
func findAvailablePort() (int, error) {
|
func findAvailablePort() (int, error) {
|
||||||
|
@@ -175,11 +175,15 @@ func (sc *SSHConfig) GetHosts() []SSHHost {
|
|||||||
return sc.Hosts
|
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) {
|
func (sc *SSHConfig) GetHostByName(name string) (*SSHHost, error) {
|
||||||
for _, host := range sc.Hosts {
|
for _, host := range sc.Hosts {
|
||||||
if host.Name == name {
|
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)
|
return nil, fmt.Errorf("host '%s' not found", name)
|
||||||
|
Reference in New Issue
Block a user