Compare commits

...

9 Commits

Author SHA1 Message Date
Ona
12928c4736 Update README with smart port mapping feature
- Document the new smart port mapping functionality
- Add --test-port command example
- Fix duplicate step numbering in usage section
- Explain same-port preference and fallback behavior

Co-authored-by: Ona <no-reply@ona.com>
2025-09-26 21:30:54 +00:00
Ona
66c6ba9307 Improve port mapping to prefer same local port
Enhanced port forwarding logic to be more intuitive:

- Try to map remote port to same local port when possible
- Fallback to random available port if same port unavailable
- Clear user feedback showing port mapping (same vs different)
- Enhanced forwarding view with access URLs and instructions
- Added --test-port command to test port mapping logic

Examples:
- Remote port 3000 -> localhost:3000 (if available)
- Remote port 80 -> localhost:random (if 80 unavailable)
- Shows 'same port' or 'port X was unavailable' messages

This makes port forwarding much more intuitive - users can
access localhost:3000 when forwarding remote port 3000.

Co-authored-by: Ona <no-reply@ona.com>
2025-09-26 21:30:14 +00:00
Ona
12f188de75 Update publishing status
Document current commits ready to push and authentication issue.

Co-authored-by: Ona <no-reply@ona.com>
2025-09-26 00:43:16 +00:00
Ona
58c10d5a8e Add screenshot to README
- Add main.png screenshot showing the TUI interface
- Display screenshot prominently at the top of README
- Shows host selection and interactive features

Co-authored-by: Ona <no-reply@ona.com>
2025-09-26 00:40:23 +00:00
Ona
9ec67e9b64 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>
2025-09-26 00:32:35 +00:00
Ona
02322c4a61 Remove debug log file
Co-authored-by: Ona <no-reply@ona.com>
2025-09-26 00:24:08 +00:00
Ona
bde1529248 Fix port detection and manual forwarding issues
Major improvements to error handling and debugging:

- Fix program quitting on manual port forwarding errors
- Add comprehensive debug logging for SSH connections
- Improve error handling to show messages instead of quitting
- Add StateStartingForward for better user feedback
- Enhanced SSH client creation with default key loading
- Add --test-connect mode for debugging specific hosts
- Better timeout handling and connection diagnostics

The application now gracefully handles connection failures and
provides helpful error messages instead of crashing.

Co-authored-by: Ona <no-reply@ona.com>
2025-09-26 00:24:04 +00:00
Ona
70307c7cba Document quoted include support in README
- Add examples of quoted include paths
- Explain relative path resolution behavior
- Mention compatibility with tools like Gitpod

Co-authored-by: Ona <no-reply@ona.com>
2025-09-26 00:16:21 +00:00
Ona
94407289db Add support for quoted values in SSH config
- Support both double quotes and single quotes in config values
- Handle quoted Include directives (e.g., Include "gitpod/config")
- Properly resolve relative paths in includes to ~/.ssh/ directory
- Maintain compatibility with unquoted values
- Add parseConfigLine function for proper quote handling

This fixes compatibility with Gitpod and other tools that generate
SSH configs with quoted include paths.

Co-authored-by: Ona <no-reply@ona.com>
2025-09-26 00:16:10 +00:00
8 changed files with 473 additions and 249 deletions

View File

@@ -35,6 +35,26 @@ The repository has been prepared and committed locally. To publish to https://co
- Remote configured: ✅
- Files committed: ✅
- Binary properly ignored: ✅
- Screenshot added: ✅
- SSH command rewrite completed: ✅
- Ready to push: ✅
## Current Commits Ready to Push:
```
58c10d5 Add screenshot to README
9ec67e9 Replace Go SSH library with native ssh command
02322c4 Remove debug log file
bde1529 Fix port detection and manual forwarding issues
70307c7 Document quoted include support in README
a332459 Update README with new features
5ebe20a Improve manual port input UI styling
df3c9fe Add support for SSH config includes
6e1ee6d Add .gitignore file
e39a595 Initial commit: kport - SSH Port Forwarder TUI
```
## Push Issue:
Authentication required - push manually from local machine with credentials.
The application is fully functional and ready for distribution!

View File

@@ -2,14 +2,20 @@
A terminal user interface (TUI) application for SSH port forwarding that reads from your local SSH config, allows you to select SSH connections, detects running ports on remote hosts, and forwards them to localhost.
![kport TUI Interface](screenshots/main.png)
*Interactive terminal interface for SSH port forwarding with host selection and port detection*
## Features
- **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
- **Smart Port Mapping**: Tries to use same port locally (e.g., remote:3000 → localhost:3000)
- **Real-time Port Forwarding**: Creates SSH tunnels using `ssh -L` command
- **Clean TUI Interface**: Built with Bubble Tea for a smooth terminal experience
## Installation
@@ -25,14 +31,24 @@ go build -o kport
./kport
```
2. **Select SSH Host**: Use arrow keys to navigate and press Enter to select an SSH host from your config
2. **Test port mapping** (optional):
```bash
./kport --test-port 3000
# Shows: ✅ Port 3000 available locally - using same port
# Mapping: localhost:3000 -> remote:3000
```
3. **Choose Port**:
3. **Select SSH Host**: Use arrow keys to navigate and press Enter to select an SSH host from your config
4. **Choose Port**:
- The app will automatically detect open ports on the remote host
- Select a port to forward using arrow keys and Enter
- Press 'm' for manual port entry
4. **Port Forwarding**: Once started, the app will show the local port that forwards to your remote port
5. **Port Forwarding**:
- kport tries to use the same port locally (e.g., remote:3000 → localhost:3000)
- If unavailable, it uses a random available port
- Clear feedback shows the actual mapping and access URLs
## Controls
@@ -88,13 +104,20 @@ kport supports the SSH `Include` directive, allowing you to organize your SSH co
- **Glob patterns**: `Include ~/.ssh/config.d/*`
- **Specific files**: `Include ~/.ssh/work-config`
- **Relative paths**: `Include config.d/servers`
- **Quoted paths**: `Include "gitpod/config"` or `Include 'path with spaces/config'`
- **Cycle detection**: Prevents infinite loops from circular includes
Relative paths in includes are resolved relative to `~/.ssh/` directory, matching OpenSSH behavior.
## 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
@@ -112,10 +135,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
@@ -132,11 +155,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

174
main.go
View File

@@ -3,6 +3,10 @@ package main
import (
"fmt"
"os"
"os/exec"
"os/user"
"strconv"
"strings"
)
func main() {
@@ -12,6 +16,18 @@ func main() {
return
}
// Check for connection test mode
if len(os.Args) > 2 && os.Args[1] == "--test-connect" {
testConnection(os.Args[2])
return
}
// Check for port mapping test mode
if len(os.Args) > 2 && os.Args[1] == "--test-port" {
testPortMapping(os.Args[2])
return
}
// Initialize the application
app := NewApp()
if err := app.Run(); err != nil {
@@ -51,4 +67,162 @@ func testMode() {
fmt.Println("")
fmt.Println("To run the interactive TUI, use: ./kport")
fmt.Println("Note: TUI requires a proper terminal environment")
fmt.Println("")
fmt.Println("To test connection to a specific host: ./kport --test-connect <hostname>")
fmt.Println("To test port mapping logic: ./kport --test-port <port>")
}
// testConnection tests connecting to a specific host
func testConnection(hostName string) {
fmt.Printf("Testing connection to host: %s\n", hostName)
fmt.Println("=====================================")
// Load SSH config
config := NewSSHConfig()
if err := config.LoadConfig(); err != nil {
fmt.Printf("❌ Failed to load SSH config: %v\n", err)
return
}
// Find the host
host, err := config.GetHostByName(hostName)
if err != nil {
fmt.Printf("❌ Host not found: %v\n", err)
return
}
fmt.Printf("Found host configuration:\n")
fmt.Printf(" Name: %s\n", host.Name)
fmt.Printf(" Hostname: %s\n", host.Hostname)
fmt.Printf(" User: %s\n", host.User)
fmt.Printf(" Port: %s\n", host.Port)
if host.Identity != "" {
fmt.Printf(" Identity: %s\n", host.Identity)
}
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(expandedHost)
if err != nil {
fmt.Printf("❌ Port detection failed: %v\n", err)
fmt.Println("")
fmt.Println("This is expected if:")
fmt.Println("- The host is not reachable")
fmt.Println("- SSH keys are not set up")
fmt.Println("- SSH agent is not running")
fmt.Println("- The host doesn't exist")
} else {
fmt.Printf("✅ Port detection successful! Found %d ports: %v\n", len(ports), ports)
}
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
}
// testPortMapping tests the port mapping logic
func testPortMapping(portStr string) {
fmt.Printf("Testing port mapping for port: %s\n", portStr)
fmt.Println("=====================================")
remotePort, err := strconv.Atoi(portStr)
if err != nil {
fmt.Printf("❌ Invalid port number: %s\n", portStr)
return
}
if remotePort <= 0 || remotePort > 65535 {
fmt.Printf("❌ Port number must be between 1 and 65535\n")
return
}
// Test the port mapping logic
localPort, samePort, err := findPreferredLocalPort(remotePort)
if err != nil {
fmt.Printf("❌ Failed to find available port: %v\n", err)
return
}
if samePort {
fmt.Printf("✅ Port %d is available locally - using same port\n", localPort)
fmt.Printf(" Mapping: localhost:%d -> remote:%d\n", localPort, remotePort)
} else {
fmt.Printf("⚠️ Port %d is unavailable locally - using alternative port %d\n", remotePort, localPort)
fmt.Printf(" Mapping: localhost:%d -> remote:%d\n", localPort, remotePort)
}
fmt.Println("")
fmt.Println("This is how kport will map the ports when forwarding.")
}

View File

@@ -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
@@ -28,59 +26,18 @@ func DetectPorts(host SSHHost) tea.Cmd {
return func() tea.Msg {
ports, err := detectRemotePorts(host)
if err != nil {
// Instead of returning an error that quits the app,
// return an empty ports list with a message
// Log the error for debugging but don't quit the app
fmt.Fprintf(os.Stderr, "Debug: Port detection failed for %s: %v\n", host.Name, err)
// Return empty ports list so user can still use manual port forwarding
return PortsDetectedMsg{Ports: []int{}}
}
fmt.Fprintf(os.Stderr, "Debug: Detected %d ports on %s: %v\n", len(ports), host.Name, ports)
return PortsDetectedMsg{Ports: ports}
}
}
// 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",
@@ -89,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
@@ -131,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/tcp/localhost/%d' 2>/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 {

View File

@@ -2,16 +2,13 @@ package main
import (
"fmt"
"io"
"net"
"os"
"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
@@ -20,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()
@@ -51,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
}
@@ -79,99 +86,58 @@ 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 {
// Find an available local port
localPort, err := findAvailablePort()
fmt.Fprintf(os.Stderr, "Debug: Starting port forwarding for %s:%d\n", host.Name, remotePort)
// Try to use the same port locally, fallback to random if unavailable
localPort, samePort, err := findPreferredLocalPort(remotePort)
if err != nil {
fmt.Fprintf(os.Stderr, "Debug: Failed to find available port: %v\n", err)
return ErrorMsg{Error: fmt.Errorf("failed to find available local port: %w", err)}
}
// Create SSH client
client, err := createSSHClient(host)
if err != nil {
return ErrorMsg{Error: fmt.Errorf("failed to create SSH client: %w", err)}
if samePort {
fmt.Fprintf(os.Stderr, "Debug: Using same port locally: %d\n", localPort)
} else {
fmt.Fprintf(os.Stderr, "Debug: Port %d unavailable, using alternative: %d\n", remotePort, localPort)
}
// 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)}
}
fmt.Fprintf(os.Stderr, "Debug: Port forwarder started successfully\n")
return ForwardingStartedMsg{
LocalPort: localPort,
@@ -183,33 +149,38 @@ func StartPortForwarding(host SSHHost, remotePort int) tea.Cmd {
// StartManualPortForwarding starts port forwarding for a manually entered port
func StartManualPortForwarding(host SSHHost, portStr string) tea.Cmd {
return func() tea.Msg {
fmt.Fprintf(os.Stderr, "Debug: Manual port forwarding requested for %s:%s\n", host.Name, portStr)
remotePort, err := strconv.Atoi(portStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Debug: Invalid port number: %s\n", portStr)
return ErrorMsg{Error: fmt.Errorf("invalid port number: %s", portStr)}
}
if remotePort <= 0 || remotePort > 65535 {
fmt.Fprintf(os.Stderr, "Debug: Port number out of range: %d\n", remotePort)
return ErrorMsg{Error: fmt.Errorf("port number must be between 1 and 65535")}
}
// Find an available local port
localPort, err := findAvailablePort()
// Try to use the same port locally, fallback to random if unavailable
localPort, samePort, err := findPreferredLocalPort(remotePort)
if err != nil {
fmt.Fprintf(os.Stderr, "Debug: Failed to find available port: %v\n", err)
return ErrorMsg{Error: fmt.Errorf("failed to find available local port: %w", err)}
}
// Create SSH client
client, err := createSSHClient(host)
if err != nil {
return ErrorMsg{Error: fmt.Errorf("failed to create SSH client: %w", err)}
if samePort {
fmt.Fprintf(os.Stderr, "Debug: Using same port locally: %d\n", localPort)
} else {
fmt.Fprintf(os.Stderr, "Debug: Port %d unavailable, using alternative: %d\n", remotePort, localPort)
}
// 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)}
}
fmt.Fprintf(os.Stderr, "Debug: Port forwarder started successfully\n")
return ForwardingStartedMsg{
LocalPort: localPort,
@@ -218,48 +189,32 @@ 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: 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 agentAuth, err := sshAgentAuth(); err == nil {
config.Auth = append(config.Auth, agentAuth)
// findPreferredLocalPort tries to use the same port as remote, fallback to random
func findPreferredLocalPort(remotePort int) (localPort int, samePort bool, err error) {
// First try to use the same port as the remote port
if isPortAvailable(remotePort) {
return remotePort, true, nil
}
// Connect to the remote host
addr := net.JoinHostPort(host.Hostname, host.Port)
client, err := ssh.Dial("tcp", addr, config)
// If same port is not available, find any available port
availablePort, err := findAvailablePort()
if err != nil {
return nil, fmt.Errorf("failed to connect to %s: %w", host.Name, err)
return 0, false, err
}
return client, nil
return availablePort, false, 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"))
// isPortAvailable checks if a specific port is available locally
func isPortAvailable(port int) bool {
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
return nil, err
return false
}
sshAgent := agent.NewClient(agentConn)
return ssh.PublicKeysCallback(sshAgent.Signers), nil
listener.Close()
return true
}
// findAvailablePort finds an available local port

BIN
screenshots/main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -77,14 +77,11 @@ func (sc *SSHConfig) loadConfigFromFileRecursive(path string, visited map[string
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
key, value, err := parseConfigLine(line)
if err != nil {
continue // Skip malformed lines
}
key := strings.ToLower(parts[0])
value := strings.Join(parts[1:], " ")
switch key {
case "include":
// Handle include directive
@@ -142,6 +139,13 @@ func (sc *SSHConfig) processInclude(pattern string, visited map[string]bool) err
return fmt.Errorf("failed to get home directory: %w", err)
}
pattern = filepath.Join(homeDir, pattern[2:])
} else if !filepath.IsAbs(pattern) {
// Relative paths are relative to ~/.ssh/
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
pattern = filepath.Join(homeDir, ".ssh", pattern)
}
// Handle glob patterns
@@ -171,12 +175,41 @@ 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)
}
// parseConfigLine parses a SSH config line, handling quoted values
func parseConfigLine(line string) (key, value string, err error) {
// Find the first whitespace to separate key from value
parts := strings.SplitN(strings.TrimSpace(line), " ", 2)
if len(parts) < 2 {
return "", "", fmt.Errorf("invalid config line")
}
key = strings.ToLower(strings.TrimSpace(parts[0]))
valueStr := strings.TrimSpace(parts[1])
// Handle quoted values
if len(valueStr) >= 2 &&
((valueStr[0] == '"' && valueStr[len(valueStr)-1] == '"') ||
(valueStr[0] == '\'' && valueStr[len(valueStr)-1] == '\'')) {
// Remove quotes
value = valueStr[1 : len(valueStr)-1]
} else {
// Handle unquoted values (may contain multiple words)
value = valueStr
}
return key, value, nil
}

88
tui.go
View File

@@ -16,6 +16,7 @@ const (
StateConnecting
StateSelectPort
StateManualPort
StateStartingForward
StateForwarding
)
@@ -75,6 +76,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updatePortSelection(msg)
case StateManualPort:
return m.updateManualPort(msg)
case StateStartingForward:
return m.updateStartingForward(msg)
case StateForwarding:
return m.updateForwarding(msg)
}
@@ -90,13 +93,32 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case ForwardingStartedMsg:
m.message = fmt.Sprintf("Port forwarding started: localhost:%d -> %s:%d",
msg.LocalPort, m.hosts[m.selectedHost].Name, msg.RemotePort)
if msg.LocalPort == msg.RemotePort {
m.message = fmt.Sprintf("Port forwarding started: localhost:%d -> %s:%d (same port)",
msg.LocalPort, m.hosts[m.selectedHost].Name, msg.RemotePort)
} else {
m.message = fmt.Sprintf("Port forwarding started: localhost:%d -> %s:%d (port %d was unavailable)",
msg.LocalPort, m.hosts[m.selectedHost].Name, msg.RemotePort, msg.RemotePort)
}
m.state = StateForwarding
return m, nil
case ErrorMsg:
m.err = msg.Error
return m, tea.Quit
// Don't quit on errors, just show them and let user continue
m.message = fmt.Sprintf("Error: %v", msg.Error)
// Go back to appropriate state depending on current state
switch m.state {
case StateConnecting:
m.state = StateSelectPort
m.ports = []int{} // Show empty ports list
case StateStartingForward:
// Go back to port selection or manual port depending on where we came from
if len(m.ports) > 0 {
m.state = StateSelectPort
} else {
m.state = StateManualPort
}
}
return m, nil
}
return m, nil
}
@@ -163,6 +185,8 @@ func (m *Model) updatePortSelection(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
case "enter", " ":
m.selectedPort = m.cursor
m.state = StateStartingForward
m.message = "Starting port forwarding..."
// Start port forwarding
return m, StartPortForwarding(m.hosts[m.selectedHost], m.ports[m.selectedPort])
case "m":
@@ -188,6 +212,8 @@ func (m *Model) updateManualPort(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
case "enter":
if m.manualPort != "" {
m.state = StateStartingForward
m.message = "Starting port forwarding..."
// Parse and start manual port forwarding
return m, StartManualPortForwarding(m.hosts[m.selectedHost], m.manualPort)
}
@@ -204,6 +230,20 @@ func (m *Model) updateManualPort(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
// updateStartingForward handles the starting forward state
func (m *Model) updateStartingForward(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
// Cancel the forwarding attempt
m.state = StateSelectPort
m.message = ""
return m, nil
}
return m, nil
}
// updateForwarding handles forwarding state
func (m *Model) updateForwarding(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
@@ -256,6 +296,8 @@ func (m *Model) View() string {
s.WriteString(m.renderPortSelection())
case StateManualPort:
s.WriteString(m.renderManualPort())
case StateStartingForward:
s.WriteString(m.renderStartingForward())
case StateForwarding:
s.WriteString(m.renderForwarding())
}
@@ -408,6 +450,23 @@ func (m *Model) renderManualPort() string {
return s.String()
}
// renderStartingForward renders the starting forward view
func (m *Model) renderStartingForward() string {
var s strings.Builder
startingStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00BFFF")).
Bold(true)
s.WriteString(startingStyle.Render("🚀 " + m.message))
s.WriteString("\n\n")
s.WriteString("Setting up SSH tunnel and port forwarding...\n\n")
s.WriteString("Controls:\n")
s.WriteString(" Esc: Cancel q: Quit\n")
return s.String()
}
// renderForwarding renders the forwarding status view
func (m *Model) renderForwarding() string {
var s strings.Builder
@@ -420,6 +479,27 @@ func (m *Model) renderForwarding() string {
s.WriteString("\n\n")
s.WriteString(m.message)
s.WriteString("\n\n")
// Add helpful access information
accessStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Bold(true)
s.WriteString(accessStyle.Render("Access your service:"))
s.WriteString("\n")
// Extract local port from message for display
if strings.Contains(m.message, "localhost:") {
parts := strings.Split(m.message, "localhost:")
if len(parts) > 1 {
portPart := strings.Split(parts[1], " ")[0]
s.WriteString(fmt.Sprintf(" • http://localhost:%s\n", portPart))
s.WriteString(fmt.Sprintf(" • https://localhost:%s\n", portPart))
s.WriteString(fmt.Sprintf(" • Or connect to localhost:%s with any client\n", portPart))
}
}
s.WriteString("\n")
s.WriteString("Controls:\n")
s.WriteString(" Esc: Stop forwarding and return q: Quit\n")