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:
5
.devcontainer/Dockerfile
Normal file
5
.devcontainer/Dockerfile
Normal 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>
|
20
.devcontainer/devcontainer.json
Normal file
20
.devcontainer/devcontainer.json
Normal 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
128
README.md
Normal 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
32
app.go
Normal 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
31
go.mod
Normal 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
47
go.sum
Normal 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
54
main.go
Normal 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
198
port_detection.go
Normal 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
275
port_forwarder.go
Normal 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
24
run_demo.sh
Executable 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
125
ssh_config.go
Normal 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
393
tui.go
Normal 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()
|
||||||
|
}
|
Reference in New Issue
Block a user