Initial commit: kport - SSH Port Forwarder TUI

- Interactive TUI for SSH port forwarding
- Reads from ~/.ssh/config for host selection
- Automatic port detection on remote hosts
- Manual port forwarding option
- Graceful error handling and connection timeouts
- Built with Bubble Tea framework

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
Ona
2025-09-25 23:57:44 +00:00
commit e39a595956
12 changed files with 1332 additions and 0 deletions

5
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,5 @@
FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04
# use this Dockerfile to install additional tools you might need, e.g.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>

View File

@@ -0,0 +1,20 @@
// The Dev Container format allows you to configure your environment. At the heart of it
// is a Docker image or Dockerfile which controls the tools available in your environment.
//
// See https://aka.ms/devcontainer.json for more information.
{
"name": "Ona",
// Use "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04",
// instead of the build to use a pre-built image.
"build": {
"context": ".",
"dockerfile": "Dockerfile"
}
// Features add additional features to your environment. See https://containers.dev/features
// Beware: features are not supported on all platforms and may have unintended side-effects.
// "features": {
// "ghcr.io/devcontainers/features/docker-in-docker": {
// "moby": false
// }
// }
}

128
README.md Normal file
View File

@@ -0,0 +1,128 @@
# kport - SSH Port Forwarder TUI
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.
## Features
- **SSH Config Integration**: Automatically reads from `~/.ssh/config`
- **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
- **Real-time Port Forwarding**: Creates SSH tunnels similar to VSCode's remote SSH port forwarding
- **Clean TUI Interface**: Built with Bubble Tea for a smooth terminal experience
## Installation
```bash
go build -o kport
```
## Usage
1. **Run the application**:
```bash
./kport
```
2. **Select SSH Host**: Use arrow keys to navigate and press Enter to select an SSH host from your config
3. **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
## Controls
### Host Selection
- `↑/↓` or `j/k`: Navigate through SSH hosts
- `Enter`: Select host and detect ports
- `m`: Manual port forwarding for selected host
- `q`: Quit application
### Port Selection
- `↑/↓` or `j/k`: Navigate through detected ports
- `Enter`: Start port forwarding for selected port
- `m`: Switch to manual port entry
- `Esc`: Go back to host selection
- `q`: Quit application
### Manual Port Entry
- `0-9`: Enter port number
- `Backspace`: Delete last digit
- `Enter`: Start forwarding for entered port
- `Esc`: Go back to previous screen
- `q`: Quit application
### Active Forwarding
- `Esc`: Stop forwarding and return to host selection
- `q`: Quit application
## SSH Configuration
The application reads from your standard SSH config file at `~/.ssh/config`. Example configuration:
```
Host my-server
HostName example.com
User myuser
Port 22
IdentityFile ~/.ssh/id_rsa
Host dev-box
HostName dev.example.com
User developer
Port 2222
```
## Authentication
The application supports:
- SSH key-based authentication (using IdentityFile from config)
- SSH agent authentication (if SSH_AUTH_SOCK is set)
## Requirements
- Go 1.19 or later
- SSH access to remote hosts
- SSH config file at `~/.ssh/config`
## Dependencies
- `github.com/charmbracelet/bubbletea` - TUI framework
- `github.com/charmbracelet/lipgloss` - Terminal styling
- `golang.org/x/crypto/ssh` - SSH client implementation
- `golang.org/x/crypto/ssh/agent` - SSH agent support
## 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
## Expected Behavior
When you select an SSH host:
1. **Connection Success**: Shows detected ports or "No ports detected" with option for manual entry
2. **Connection Failure**: Shows "Could not connect" message with option for manual port forwarding
3. **Timeout**: Connection attempts timeout after 5 seconds to avoid hanging
The application gracefully handles connection failures and allows you to:
- Go back to host selection with `Esc`
- Try manual port forwarding with `m`
- Quit with `q`
## Limitations
- Password authentication is not implemented (use SSH keys or agent)
- Host key verification uses `InsecureIgnoreHostKey` (should be improved for production use)
- Port detection requires `netstat`, `ss`, or `lsof` on the remote host
- Connection failures are expected for non-existent or unreachable hosts
## License
MIT License

32
app.go Normal file
View File

@@ -0,0 +1,32 @@
package main
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
)
// App represents the main application
type App struct {
model *Model
}
// NewApp creates a new application instance
func NewApp() *App {
return &App{
model: NewModel(),
}
}
// Run starts the application
func (a *App) Run() error {
// Create the Bubble Tea program
p := tea.NewProgram(a.model, tea.WithAltScreen())
// Run the program
if _, err := p.Run(); err != nil {
return fmt.Errorf("failed to run TUI application: %w", err)
}
return nil
}

31
go.mod Normal file
View File

@@ -0,0 +1,31 @@
module kport
go 1.24.0
toolchain go1.24.7
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
golang.org/x/crypto v0.42.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
)

47
go.sum Normal file
View File

@@ -0,0 +1,47 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=

54
main.go Normal file
View File

@@ -0,0 +1,54 @@
package main
import (
"fmt"
"os"
)
func main() {
// Check for test mode
if len(os.Args) > 1 && os.Args[1] == "--test" {
testMode()
return
}
// Initialize the application
app := NewApp()
if err := app.Run(); err != nil {
fmt.Printf("Error running application: %v\n", err)
os.Exit(1)
}
}
// testMode runs a simple test without TUI
func testMode() {
fmt.Println("kport - SSH Port Forwarder - Test Mode")
fmt.Println("======================================")
// Test SSH config loading
config := NewSSHConfig()
if err := config.LoadConfig(); err != nil {
fmt.Printf("❌ Failed to load SSH config: %v\n", err)
return
}
hosts := config.GetHosts()
fmt.Printf("✅ Successfully loaded SSH config with %d hosts:\n\n", len(hosts))
for i, host := range hosts {
fmt.Printf("%d. %s\n", i+1, host.Name)
fmt.Printf(" Host: %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()
}
fmt.Println("📝 Note: The example hosts above are not real servers.")
fmt.Println(" Replace them in ~/.ssh/config with your actual SSH hosts.")
fmt.Println("")
fmt.Println("To run the interactive TUI, use: ./kport")
fmt.Println("Note: TUI requires a proper terminal environment")
}

198
port_detection.go Normal file
View File

@@ -0,0 +1,198 @@
package main
import (
"fmt"
"net"
"os"
"sort"
"strconv"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"golang.org/x/crypto/ssh"
)
// PortsDetectedMsg is sent when ports are detected
type PortsDetectedMsg struct {
Ports []int
}
// ErrorMsg is sent when an error occurs
type ErrorMsg struct {
Error error
}
// DetectPorts detects open ports on the remote host
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
return PortsDetectedMsg{Ports: []int{}}
}
return PortsDetectedMsg{Ports: ports}
}
}
// detectRemotePorts connects to the remote host and detects open ports
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",
"ss -tlnp 2>/dev/null | grep LISTEN | awk '{print $4}' | cut -d: -f2 | sort -n | uniq",
"lsof -i -P -n 2>/dev/null | grep LISTEN | awk '{print $9}' | cut -d: -f2 | sort -n | uniq",
}
var output []byte
for _, cmd := range commands {
session, err = client.NewSession()
if err != nil {
continue
}
output, err = session.Output(cmd)
session.Close()
if err == nil && len(output) > 0 {
break
}
}
if err != nil || len(output) == 0 {
// Fallback: try common ports
return detectCommonPorts(client), nil
}
// Parse the output to extract port numbers
ports := make([]int, 0)
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
port, err := strconv.Atoi(line)
if err == nil && port > 0 && port < 65536 {
ports = append(ports, port)
}
}
// Remove duplicates and sort
ports = removeDuplicates(ports)
sort.Ints(ports)
return ports, nil
}
// detectCommonPorts tries to detect common ports by attempting connections
func detectCommonPorts(client *ssh.Client) []int {
commonPorts := []int{80, 443, 3000, 3001, 4000, 5000, 8000, 8080, 8443, 9000}
var openPorts []int
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()
openPorts = append(openPorts, 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 {
keys := make(map[int]bool)
result := make([]int, 0)
for _, item := range slice {
if !keys[item] {
keys[item] = true
result = append(result, item)
}
}
return result
}

275
port_forwarder.go Normal file
View File

@@ -0,0 +1,275 @@
package main
import (
"fmt"
"io"
"net"
"os"
"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
type ForwardingStartedMsg struct {
LocalPort int
RemotePort int
}
// PortForwarder manages SSH port forwarding
type PortForwarder struct {
sshClient *ssh.Client
localPort int
remotePort int
listener net.Listener
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 {
return &PortForwarder{
sshClient: sshClient,
localPort: localPort,
remotePort: remotePort,
stopChan: make(chan struct{}),
}
}
// Start starts the port forwarding
func (pf *PortForwarder) Start() error {
pf.mu.Lock()
defer pf.mu.Unlock()
if pf.isRunning {
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)
}
pf.listener = listener
pf.isRunning = true
// Start accepting connections
pf.wg.Add(1)
go pf.acceptConnections()
return nil
}
// Stop stops the port forwarding
func (pf *PortForwarder) Stop() {
pf.mu.Lock()
defer pf.mu.Unlock()
if !pf.isRunning {
return
}
pf.isRunning = false
close(pf.stopChan)
if pf.listener != nil {
pf.listener.Close()
}
pf.wg.Wait()
}
// acceptConnections accepts and handles incoming connections
func (pf *PortForwarder) acceptConnections() {
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)
}
}
}
// 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()
if err != nil {
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)}
}
// Create and start port forwarder
forwarder := NewPortForwarder(client, localPort, remotePort)
if err := forwarder.Start(); err != nil {
client.Close()
return ErrorMsg{Error: fmt.Errorf("failed to start port forwarding: %w", err)}
}
return ForwardingStartedMsg{
LocalPort: localPort,
RemotePort: remotePort,
}
}
}
// StartManualPortForwarding starts port forwarding for a manually entered port
func StartManualPortForwarding(host SSHHost, portStr string) tea.Cmd {
return func() tea.Msg {
remotePort, err := strconv.Atoi(portStr)
if err != nil {
return ErrorMsg{Error: fmt.Errorf("invalid port number: %s", portStr)}
}
if remotePort <= 0 || remotePort > 65535 {
return ErrorMsg{Error: fmt.Errorf("port number must be between 1 and 65535")}
}
// Find an available local port
localPort, err := findAvailablePort()
if err != nil {
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)}
}
// Create and start port forwarder
forwarder := NewPortForwarder(client, localPort, remotePort)
if err := forwarder.Start(); err != nil {
client.Close()
return ErrorMsg{Error: fmt.Errorf("failed to start port forwarding: %w", err)}
}
return ForwardingStartedMsg{
LocalPort: localPort,
RemotePort: remotePort,
}
}
}
// 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)
}
// 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: %w", host.Name, err)
}
return client, nil
}
// sshAgentAuth returns SSH agent authentication method
func sshAgentAuth() (ssh.AuthMethod, error) {
// Try to connect to SSH agent
agentConn, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK"))
if err != nil {
return nil, err
}
sshAgent := agent.NewClient(agentConn)
return ssh.PublicKeysCallback(sshAgent.Signers), nil
}
// findAvailablePort finds an available local port
func findAvailablePort() (int, error) {
listener, err := net.Listen("tcp", ":0")
if err != nil {
return 0, err
}
defer listener.Close()
addr := listener.Addr().(*net.TCPAddr)
return addr.Port, nil
}

24
run_demo.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
echo "kport - SSH Port Forwarder Demo"
echo "==============================="
echo ""
echo "This application requires a proper terminal to run the TUI interface."
echo ""
echo "In a real terminal, you would run:"
echo " ./kport"
echo ""
echo "Expected behavior when selecting hosts:"
echo "• test-server & dev-server: Will show 'Could not connect' (they're fake hosts)"
echo "• localhost-test: May work if SSH server is running locally"
echo ""
echo "The application will:"
echo "1. Read your SSH config from ~/.ssh/config"
echo "2. Show an interactive list of SSH hosts"
echo "3. Let you select a host with arrow keys"
echo "4. Show 'Connecting...' message"
echo "5. Either detect ports or show connection error"
echo "6. Allow manual port forwarding with 'm' key"
echo ""
echo "Testing SSH config parsing:"
./kport --test

125
ssh_config.go Normal file
View File

@@ -0,0 +1,125 @@
package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
// SSHHost represents an SSH host configuration
type SSHHost struct {
Name string
Hostname string
User string
Port string
Identity string
}
// SSHConfig handles parsing SSH configuration
type SSHConfig struct {
Hosts []SSHHost
}
// NewSSHConfig creates a new SSH config parser
func NewSSHConfig() *SSHConfig {
return &SSHConfig{
Hosts: make([]SSHHost, 0),
}
}
// LoadConfig loads SSH configuration from the default location
func (sc *SSHConfig) LoadConfig() error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
configPath := filepath.Join(homeDir, ".ssh", "config")
return sc.LoadConfigFromFile(configPath)
}
// LoadConfigFromFile loads SSH configuration from a specific file
func (sc *SSHConfig) LoadConfigFromFile(path string) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open SSH config file: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
var currentHost *SSHHost
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
key := strings.ToLower(parts[0])
value := strings.Join(parts[1:], " ")
switch key {
case "host":
// Save previous host if exists
if currentHost != nil {
sc.Hosts = append(sc.Hosts, *currentHost)
}
// Start new host
currentHost = &SSHHost{
Name: value,
Port: "22", // default port
}
case "hostname":
if currentHost != nil {
currentHost.Hostname = value
}
case "user":
if currentHost != nil {
currentHost.User = value
}
case "port":
if currentHost != nil {
currentHost.Port = value
}
case "identityfile":
if currentHost != nil {
currentHost.Identity = value
}
}
}
// Add the last host
if currentHost != nil {
sc.Hosts = append(sc.Hosts, *currentHost)
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("error reading SSH config file: %w", err)
}
return nil
}
// GetHosts returns all configured SSH hosts
func (sc *SSHConfig) GetHosts() []SSHHost {
return sc.Hosts
}
// GetHostByName returns a specific host by name
func (sc *SSHConfig) GetHostByName(name string) (*SSHHost, error) {
for _, host := range sc.Hosts {
if host.Name == name {
return &host, nil
}
}
return nil, fmt.Errorf("host '%s' not found", name)
}

393
tui.go Normal file
View File

@@ -0,0 +1,393 @@
package main
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// AppState represents the current state of the application
type AppState int
const (
StateSelectHost AppState = iota
StateConnecting
StateSelectPort
StateManualPort
StateForwarding
)
// Model represents the TUI model
type Model struct {
state AppState
sshConfig *SSHConfig
hosts []SSHHost
selectedHost int
ports []int
selectedPort int
cursor int
manualPort string
forwarder *PortForwarder
message string
err error
}
// NewModel creates a new TUI model
func NewModel() *Model {
return &Model{
state: StateSelectHost,
sshConfig: NewSSHConfig(),
cursor: 0,
}
}
// Init initializes the model
func (m *Model) Init() tea.Cmd {
// Load SSH config
if err := m.sshConfig.LoadConfig(); err != nil {
m.err = err
// Don't quit immediately, let user see the error
return nil
}
m.hosts = m.sshConfig.GetHosts()
// Check if we have any hosts
if len(m.hosts) == 0 {
m.err = fmt.Errorf("no SSH hosts found in config file")
return nil
}
return nil
}
// Update handles messages and updates the model
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch m.state {
case StateSelectHost:
return m.updateHostSelection(msg)
case StateConnecting:
return m.updateConnecting(msg)
case StateSelectPort:
return m.updatePortSelection(msg)
case StateManualPort:
return m.updateManualPort(msg)
case StateForwarding:
return m.updateForwarding(msg)
}
case PortsDetectedMsg:
m.ports = msg.Ports
m.state = StateSelectPort
m.cursor = 0
// Set a message about the connection attempt
if len(msg.Ports) == 0 {
m.message = fmt.Sprintf("Could not connect to %s or no ports detected", m.hosts[m.selectedHost].Name)
} else {
m.message = ""
}
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)
m.state = StateForwarding
return m, nil
case ErrorMsg:
m.err = msg.Error
return m, tea.Quit
}
return m, nil
}
// updateHostSelection handles host selection state
func (m *Model) updateHostSelection(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.hosts)-1 {
m.cursor++
}
case "enter", " ":
m.selectedHost = m.cursor
m.state = StateConnecting
m.message = fmt.Sprintf("Connecting to %s...", m.hosts[m.selectedHost].Name)
// Detect ports on selected host
return m, DetectPorts(m.hosts[m.selectedHost])
case "m":
// Manual port forwarding
m.selectedHost = m.cursor
m.state = StateManualPort
m.manualPort = ""
return m, nil
}
return m, nil
}
// updateConnecting handles connecting state
func (m *Model) updateConnecting(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateSelectHost
m.cursor = m.selectedHost
m.message = ""
return m, nil
}
return m, nil
}
// updatePortSelection handles port selection state
func (m *Model) updatePortSelection(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateSelectHost
m.cursor = m.selectedHost
return m, nil
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.ports)-1 {
m.cursor++
}
case "enter", " ":
m.selectedPort = m.cursor
// Start port forwarding
return m, StartPortForwarding(m.hosts[m.selectedHost], m.ports[m.selectedPort])
case "m":
// Manual port forwarding
m.state = StateManualPort
m.manualPort = ""
return m, nil
}
return m, nil
}
// updateManualPort handles manual port input state
func (m *Model) updateManualPort(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
if len(m.ports) > 0 {
m.state = StateSelectPort
} else {
m.state = StateSelectHost
}
return m, nil
case "enter":
if m.manualPort != "" {
// Parse and start manual port forwarding
return m, StartManualPortForwarding(m.hosts[m.selectedHost], m.manualPort)
}
case "backspace":
if len(m.manualPort) > 0 {
m.manualPort = m.manualPort[:len(m.manualPort)-1]
}
default:
// Add character to manual port
if len(msg.String()) == 1 && msg.String() >= "0" && msg.String() <= "9" {
m.manualPort += msg.String()
}
}
return m, nil
}
// updateForwarding handles forwarding state
func (m *Model) updateForwarding(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
if m.forwarder != nil {
m.forwarder.Stop()
}
return m, tea.Quit
case "esc":
if m.forwarder != nil {
m.forwarder.Stop()
}
m.state = StateSelectHost
m.cursor = 0
m.message = ""
return m, nil
}
return m, nil
}
// View renders the TUI
func (m *Model) View() string {
if m.err != nil {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF5F87")).
Bold(true)
return fmt.Sprintf("%s\n\n%s\n\nPress q to quit.",
errorStyle.Render("❌ Error"), m.err.Error())
}
var s strings.Builder
// Header
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4")).
Padding(0, 1)
s.WriteString(headerStyle.Render("kport - SSH Port Forwarder"))
s.WriteString("\n\n")
switch m.state {
case StateSelectHost:
s.WriteString(m.renderHostSelection())
case StateConnecting:
s.WriteString(m.renderConnecting())
case StateSelectPort:
s.WriteString(m.renderPortSelection())
case StateManualPort:
s.WriteString(m.renderManualPort())
case StateForwarding:
s.WriteString(m.renderForwarding())
}
return s.String()
}
// renderHostSelection renders the host selection view
func (m *Model) renderHostSelection() string {
var s strings.Builder
s.WriteString("Select an SSH host:\n\n")
for i, host := range m.hosts {
cursor := " "
if m.cursor == i {
cursor = ">"
}
hostInfo := fmt.Sprintf("%s@%s", host.User, host.Hostname)
if host.User == "" {
hostInfo = host.Hostname
}
style := lipgloss.NewStyle()
if m.cursor == i {
style = style.Foreground(lipgloss.Color("#FF75B7"))
}
s.WriteString(fmt.Sprintf("%s %s (%s)\n", cursor,
style.Render(host.Name), hostInfo))
}
s.WriteString("\n")
s.WriteString("Controls:\n")
s.WriteString(" ↑/↓: Navigate Enter: Select m: Manual port q: Quit\n")
return s.String()
}
// renderConnecting renders the connecting view
func (m *Model) renderConnecting() string {
var s strings.Builder
connectingStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00BFFF")).
Bold(true)
s.WriteString(connectingStyle.Render("🔄 " + m.message))
s.WriteString("\n\n")
s.WriteString("Please wait while connecting to the remote host...\n\n")
s.WriteString("Controls:\n")
s.WriteString(" Esc: Cancel and go back q: Quit\n")
return s.String()
}
// renderPortSelection renders the port selection view
func (m *Model) renderPortSelection() string {
var s strings.Builder
host := m.hosts[m.selectedHost]
s.WriteString(fmt.Sprintf("Detected ports on %s:\n\n", host.Name))
if len(m.ports) == 0 {
if m.message != "" {
warningStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500"))
s.WriteString(warningStyle.Render("⚠️ " + m.message))
s.WriteString("\n\n")
}
s.WriteString("No open ports detected.\n\n")
s.WriteString("Press 'm' for manual port forwarding or Esc to go back.\n")
return s.String()
}
for i, port := range m.ports {
cursor := " "
if m.cursor == i {
cursor = ">"
}
style := lipgloss.NewStyle()
if m.cursor == i {
style = style.Foreground(lipgloss.Color("#FF75B7"))
}
s.WriteString(fmt.Sprintf("%s %s\n", cursor, style.Render(fmt.Sprintf("Port %d", port))))
}
s.WriteString("\n")
s.WriteString("Controls:\n")
s.WriteString(" ↑/↓: Navigate Enter: Forward m: Manual port Esc: Back q: Quit\n")
return s.String()
}
// renderManualPort renders the manual port input view
func (m *Model) renderManualPort() string {
var s strings.Builder
host := m.hosts[m.selectedHost]
s.WriteString(fmt.Sprintf("Manual port forwarding for %s:\n\n", host.Name))
s.WriteString("Enter remote port number: ")
inputStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
Padding(0, 1)
s.WriteString(inputStyle.Render(m.manualPort))
s.WriteString("\n\n")
s.WriteString("Controls:\n")
s.WriteString(" Enter: Start forwarding Esc: Back q: Quit\n")
return s.String()
}
// renderForwarding renders the forwarding status view
func (m *Model) renderForwarding() string {
var s strings.Builder
successStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575")).
Bold(true)
s.WriteString(successStyle.Render("✓ Port Forwarding Active"))
s.WriteString("\n\n")
s.WriteString(m.message)
s.WriteString("\n\n")
s.WriteString("Controls:\n")
s.WriteString(" Esc: Stop forwarding and return q: Quit\n")
return s.String()
}