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: ✅
|
||||
- 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!
|
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.
|
||||
|
||||

|
||||
|
||||
*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
174
main.go
@@ -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.")
|
||||
}
|
@@ -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 {
|
||||
|
@@ -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
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
|
||||
}
|
||||
|
||||
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
88
tui.go
@@ -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")
|
||||
|
||||
|
Reference in New Issue
Block a user