Compare commits
9 Commits
a332459b92
...
main
Author | SHA1 | Date | |
---|---|---|---|
12928c4736 | |||
66c6ba9307 | |||
12f188de75 | |||
58c10d5a8e | |||
9ec67e9b64 | |||
02322c4a61 | |||
bde1529248 | |||
70307c7cba | |||
94407289db |
20
PUBLISH.md
20
PUBLISH.md
@@ -35,6 +35,26 @@ The repository has been prepared and committed locally. To publish to https://co
|
|||||||
- Remote configured: ✅
|
- Remote configured: ✅
|
||||||
- Files committed: ✅
|
- Files committed: ✅
|
||||||
- Binary properly ignored: ✅
|
- Binary properly ignored: ✅
|
||||||
|
- Screenshot added: ✅
|
||||||
|
- SSH command rewrite completed: ✅
|
||||||
- Ready to push: ✅
|
- 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!
|
The application is fully functional and ready for distribution!
|
51
README.md
51
README.md
@@ -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.
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Interactive terminal interface for SSH port forwarding with host selection and port detection*
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **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
|
- **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
|
- **Clean TUI Interface**: Built with Bubble Tea for a smooth terminal experience
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -25,14 +31,24 @@ go build -o kport
|
|||||||
./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
|
- The app will automatically detect open ports on the remote host
|
||||||
- Select a port to forward using arrow keys and Enter
|
- Select a port to forward using arrow keys and Enter
|
||||||
- Press 'm' for manual port entry
|
- 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
|
## Controls
|
||||||
|
|
||||||
@@ -88,13 +104,20 @@ kport supports the SSH `Include` directive, allowing you to organize your SSH co
|
|||||||
- **Glob patterns**: `Include ~/.ssh/config.d/*`
|
- **Glob patterns**: `Include ~/.ssh/config.d/*`
|
||||||
- **Specific files**: `Include ~/.ssh/work-config`
|
- **Specific files**: `Include ~/.ssh/work-config`
|
||||||
- **Relative paths**: `Include config.d/servers`
|
- **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
|
- **Cycle detection**: Prevents infinite loops from circular includes
|
||||||
|
|
||||||
|
Relative paths in includes are resolved relative to `~/.ssh/` directory, matching OpenSSH behavior.
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
@@ -112,10 +135,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
|
||||||
|
|
||||||
@@ -132,11 +155,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
|
174
main.go
174
main.go
@@ -3,6 +3,10 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/user"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -12,6 +16,18 @@ func main() {
|
|||||||
return
|
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
|
// Initialize the application
|
||||||
app := NewApp()
|
app := NewApp()
|
||||||
if err := app.Run(); err != nil {
|
if err := app.Run(); err != nil {
|
||||||
@@ -51,4 +67,162 @@ func testMode() {
|
|||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println("To run the interactive TUI, use: ./kport")
|
fmt.Println("To run the interactive TUI, use: ./kport")
|
||||||
fmt.Println("Note: TUI requires a proper terminal environment")
|
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.")
|
||||||
}
|
}
|
@@ -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
|
||||||
@@ -28,59 +26,18 @@ func DetectPorts(host SSHHost) tea.Cmd {
|
|||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
ports, err := detectRemotePorts(host)
|
ports, err := detectRemotePorts(host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Instead of returning an error that quits the app,
|
// Log the error for debugging but don't quit the app
|
||||||
// return an empty ports list with a message
|
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{}}
|
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}
|
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) {
|
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",
|
||||||
@@ -89,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
|
||||||
@@ -131,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,16 +2,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"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
|
||||||
@@ -20,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()
|
||||||
@@ -51,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
|
||||||
}
|
}
|
||||||
@@ -79,99 +86,58 @@ 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 {
|
||||||
// Find an available local port
|
fmt.Fprintf(os.Stderr, "Debug: Starting port forwarding for %s:%d\n", host.Name, remotePort)
|
||||||
localPort, err := findAvailablePort()
|
|
||||||
|
// Try to use the same port locally, fallback to random if unavailable
|
||||||
|
localPort, samePort, err := findPreferredLocalPort(remotePort)
|
||||||
if err != nil {
|
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)}
|
return ErrorMsg{Error: fmt.Errorf("failed to find available local port: %w", err)}
|
||||||
}
|
}
|
||||||
|
if samePort {
|
||||||
// Create SSH client
|
fmt.Fprintf(os.Stderr, "Debug: Using same port locally: %d\n", localPort)
|
||||||
client, err := createSSHClient(host)
|
} else {
|
||||||
if err != nil {
|
fmt.Fprintf(os.Stderr, "Debug: Port %d unavailable, using alternative: %d\n", remotePort, localPort)
|
||||||
return ErrorMsg{Error: fmt.Errorf("failed to create SSH client: %w", err)}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and start port forwarder
|
// Create and start port forwarder using ssh command
|
||||||
forwarder := NewPortForwarder(client, localPort, remotePort)
|
forwarder := NewPortForwarder(host.Name, 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)
|
||||||
return ErrorMsg{Error: fmt.Errorf("failed to start port forwarding: %w", 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{
|
return ForwardingStartedMsg{
|
||||||
LocalPort: localPort,
|
LocalPort: localPort,
|
||||||
@@ -183,33 +149,38 @@ func StartPortForwarding(host SSHHost, remotePort int) tea.Cmd {
|
|||||||
// StartManualPortForwarding starts port forwarding for a manually entered port
|
// StartManualPortForwarding starts port forwarding for a manually entered port
|
||||||
func StartManualPortForwarding(host SSHHost, portStr string) tea.Cmd {
|
func StartManualPortForwarding(host SSHHost, portStr string) tea.Cmd {
|
||||||
return func() tea.Msg {
|
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)
|
remotePort, err := strconv.Atoi(portStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Debug: Invalid port number: %s\n", portStr)
|
||||||
return ErrorMsg{Error: fmt.Errorf("invalid port number: %s", portStr)}
|
return ErrorMsg{Error: fmt.Errorf("invalid port number: %s", portStr)}
|
||||||
}
|
}
|
||||||
|
|
||||||
if remotePort <= 0 || remotePort > 65535 {
|
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")}
|
return ErrorMsg{Error: fmt.Errorf("port number must be between 1 and 65535")}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find an available local port
|
// Try to use the same port locally, fallback to random if unavailable
|
||||||
localPort, err := findAvailablePort()
|
localPort, samePort, err := findPreferredLocalPort(remotePort)
|
||||||
if err != nil {
|
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)}
|
return ErrorMsg{Error: fmt.Errorf("failed to find available local port: %w", err)}
|
||||||
}
|
}
|
||||||
|
if samePort {
|
||||||
// Create SSH client
|
fmt.Fprintf(os.Stderr, "Debug: Using same port locally: %d\n", localPort)
|
||||||
client, err := createSSHClient(host)
|
} else {
|
||||||
if err != nil {
|
fmt.Fprintf(os.Stderr, "Debug: Port %d unavailable, using alternative: %d\n", remotePort, localPort)
|
||||||
return ErrorMsg{Error: fmt.Errorf("failed to create SSH client: %w", err)}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and start port forwarder
|
// Create and start port forwarder using ssh command
|
||||||
forwarder := NewPortForwarder(client, localPort, remotePort)
|
forwarder := NewPortForwarder(host.Name, 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)
|
||||||
return ErrorMsg{Error: fmt.Errorf("failed to start port forwarding: %w", 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{
|
return ForwardingStartedMsg{
|
||||||
LocalPort: localPort,
|
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
|
// findPreferredLocalPort tries to use the same port as remote, fallback to random
|
||||||
if agentAuth, err := sshAgentAuth(); err == nil {
|
func findPreferredLocalPort(remotePort int) (localPort int, samePort bool, err error) {
|
||||||
config.Auth = append(config.Auth, agentAuth)
|
// First try to use the same port as the remote port
|
||||||
|
if isPortAvailable(remotePort) {
|
||||||
|
return remotePort, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to the remote host
|
// If same port is not available, find any available port
|
||||||
addr := net.JoinHostPort(host.Hostname, host.Port)
|
availablePort, err := findAvailablePort()
|
||||||
client, err := ssh.Dial("tcp", addr, config)
|
|
||||||
if err != nil {
|
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
|
// isPortAvailable checks if a specific port is available locally
|
||||||
func sshAgentAuth() (ssh.AuthMethod, error) {
|
func isPortAvailable(port int) bool {
|
||||||
// Try to connect to SSH agent
|
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||||
agentConn, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return false
|
||||||
}
|
}
|
||||||
|
listener.Close()
|
||||||
sshAgent := agent.NewClient(agentConn)
|
return true
|
||||||
return ssh.PublicKeysCallback(sshAgent.Signers), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// findAvailablePort finds an available local port
|
// findAvailablePort finds an available local port
|
||||||
|
BIN
screenshots/main.png
Normal file
BIN
screenshots/main.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 MiB |
@@ -77,14 +77,11 @@ func (sc *SSHConfig) loadConfigFromFileRecursive(path string, visited map[string
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Fields(line)
|
key, value, err := parseConfigLine(line)
|
||||||
if len(parts) < 2 {
|
if err != nil {
|
||||||
continue
|
continue // Skip malformed lines
|
||||||
}
|
}
|
||||||
|
|
||||||
key := strings.ToLower(parts[0])
|
|
||||||
value := strings.Join(parts[1:], " ")
|
|
||||||
|
|
||||||
switch key {
|
switch key {
|
||||||
case "include":
|
case "include":
|
||||||
// Handle include directive
|
// 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)
|
return fmt.Errorf("failed to get home directory: %w", err)
|
||||||
}
|
}
|
||||||
pattern = filepath.Join(homeDir, pattern[2:])
|
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
|
// Handle glob patterns
|
||||||
@@ -171,12 +175,41 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
88
tui.go
@@ -16,6 +16,7 @@ const (
|
|||||||
StateConnecting
|
StateConnecting
|
||||||
StateSelectPort
|
StateSelectPort
|
||||||
StateManualPort
|
StateManualPort
|
||||||
|
StateStartingForward
|
||||||
StateForwarding
|
StateForwarding
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -75,6 +76,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m.updatePortSelection(msg)
|
return m.updatePortSelection(msg)
|
||||||
case StateManualPort:
|
case StateManualPort:
|
||||||
return m.updateManualPort(msg)
|
return m.updateManualPort(msg)
|
||||||
|
case StateStartingForward:
|
||||||
|
return m.updateStartingForward(msg)
|
||||||
case StateForwarding:
|
case StateForwarding:
|
||||||
return m.updateForwarding(msg)
|
return m.updateForwarding(msg)
|
||||||
}
|
}
|
||||||
@@ -90,13 +93,32 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case ForwardingStartedMsg:
|
case ForwardingStartedMsg:
|
||||||
m.message = fmt.Sprintf("Port forwarding started: localhost:%d -> %s:%d",
|
if msg.LocalPort == msg.RemotePort {
|
||||||
msg.LocalPort, m.hosts[m.selectedHost].Name, 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
|
m.state = StateForwarding
|
||||||
return m, nil
|
return m, nil
|
||||||
case ErrorMsg:
|
case ErrorMsg:
|
||||||
m.err = msg.Error
|
// Don't quit on errors, just show them and let user continue
|
||||||
return m, tea.Quit
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -163,6 +185,8 @@ func (m *Model) updatePortSelection(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
case "enter", " ":
|
case "enter", " ":
|
||||||
m.selectedPort = m.cursor
|
m.selectedPort = m.cursor
|
||||||
|
m.state = StateStartingForward
|
||||||
|
m.message = "Starting port forwarding..."
|
||||||
// Start port forwarding
|
// Start port forwarding
|
||||||
return m, StartPortForwarding(m.hosts[m.selectedHost], m.ports[m.selectedPort])
|
return m, StartPortForwarding(m.hosts[m.selectedHost], m.ports[m.selectedPort])
|
||||||
case "m":
|
case "m":
|
||||||
@@ -188,6 +212,8 @@ func (m *Model) updateManualPort(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
case "enter":
|
case "enter":
|
||||||
if m.manualPort != "" {
|
if m.manualPort != "" {
|
||||||
|
m.state = StateStartingForward
|
||||||
|
m.message = "Starting port forwarding..."
|
||||||
// Parse and start manual port forwarding
|
// Parse and start manual port forwarding
|
||||||
return m, StartManualPortForwarding(m.hosts[m.selectedHost], m.manualPort)
|
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
|
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
|
// updateForwarding handles forwarding state
|
||||||
func (m *Model) updateForwarding(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *Model) updateForwarding(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
@@ -256,6 +296,8 @@ func (m *Model) View() string {
|
|||||||
s.WriteString(m.renderPortSelection())
|
s.WriteString(m.renderPortSelection())
|
||||||
case StateManualPort:
|
case StateManualPort:
|
||||||
s.WriteString(m.renderManualPort())
|
s.WriteString(m.renderManualPort())
|
||||||
|
case StateStartingForward:
|
||||||
|
s.WriteString(m.renderStartingForward())
|
||||||
case StateForwarding:
|
case StateForwarding:
|
||||||
s.WriteString(m.renderForwarding())
|
s.WriteString(m.renderForwarding())
|
||||||
}
|
}
|
||||||
@@ -408,6 +450,23 @@ func (m *Model) renderManualPort() string {
|
|||||||
return s.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
|
// renderForwarding renders the forwarding status view
|
||||||
func (m *Model) renderForwarding() string {
|
func (m *Model) renderForwarding() string {
|
||||||
var s strings.Builder
|
var s strings.Builder
|
||||||
@@ -420,6 +479,27 @@ func (m *Model) renderForwarding() string {
|
|||||||
s.WriteString("\n\n")
|
s.WriteString("\n\n")
|
||||||
s.WriteString(m.message)
|
s.WriteString(m.message)
|
||||||
s.WriteString("\n\n")
|
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("Controls:\n")
|
||||||
s.WriteString(" Esc: Stop forwarding and return q: Quit\n")
|
s.WriteString(" Esc: Stop forwarding and return q: Quit\n")
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user