mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2025-09-07 21:30:39 +02:00
feat: add port forwarding support
This commit is contained in:
parent
21c5d41977
commit
2deec405f7
89
README.md
89
README.md
@ -28,7 +28,8 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
|
|||||||
### 🎯 **Core Features**
|
### 🎯 **Core Features**
|
||||||
- **🎨 Beautiful TUI Interface** - Navigate your SSH hosts with an elegant, interactive terminal UI
|
- **🎨 Beautiful TUI Interface** - Navigate your SSH hosts with an elegant, interactive terminal UI
|
||||||
- **⚡ Quick Connect** - Connect to any host instantly
|
- **⚡ Quick Connect** - Connect to any host instantly
|
||||||
- **📝 Easy Management** - Add, edit, and manage SSH configurations seamlessly
|
- **🔄 Port Forwarding** - Easy setup for Local, Remote, and Dynamic (SOCKS) forwarding
|
||||||
|
- **📝Easy Management** - Add, edit, and manage SSH configurations seamlessly
|
||||||
- **🏷️ Tag Support** - Organize your hosts with custom tags for better categorization
|
- **🏷️ Tag Support** - Organize your hosts with custom tags for better categorization
|
||||||
- **🔍 Smart Search** - Find hosts quickly with built-in filtering and search
|
- **🔍 Smart Search** - Find hosts quickly with built-in filtering and search
|
||||||
- **🔒 Secure** - Works directly with your existing `~/.ssh/config` file
|
- **🔒 Secure** - Works directly with your existing `~/.ssh/config` file
|
||||||
@ -40,6 +41,7 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
|
|||||||
- **Add new SSH hosts** with interactive forms
|
- **Add new SSH hosts** with interactive forms
|
||||||
- **Edit existing configurations** in-place
|
- **Edit existing configurations** in-place
|
||||||
- **Delete hosts** with confirmation prompts
|
- **Delete hosts** with confirmation prompts
|
||||||
|
- **Port forwarding setup** with intuitive interface for Local (-L), Remote (-R), and Dynamic (-D) forwarding
|
||||||
- **Backup configurations** automatically before changes
|
- **Backup configurations** automatically before changes
|
||||||
- **Validate settings** to prevent configuration errors
|
- **Validate settings** to prevent configuration errors
|
||||||
- **ProxyJump support** for secure connection tunneling through bastion hosts
|
- **ProxyJump support** for secure connection tunneling through bastion hosts
|
||||||
@ -102,6 +104,7 @@ sshm
|
|||||||
- `a` - Add new host
|
- `a` - Add new host
|
||||||
- `e` - Edit selected host
|
- `e` - Edit selected host
|
||||||
- `d` - Delete selected host
|
- `d` - Delete selected host
|
||||||
|
- `f` - Port forwarding setup
|
||||||
- `q` - Quit
|
- `q` - Quit
|
||||||
- `/` - Search/filter hosts
|
- `/` - Search/filter hosts
|
||||||
|
|
||||||
@ -122,6 +125,90 @@ The interactive forms will guide you through configuration:
|
|||||||
- **SSH Options** - Additional SSH options in `-o` format (e.g., `-o Compression=yes -o ServerAliveInterval=60`)
|
- **SSH Options** - Additional SSH options in `-o` format (e.g., `-o Compression=yes -o ServerAliveInterval=60`)
|
||||||
- **Tags** - Comma-separated tags for organization
|
- **Tags** - Comma-separated tags for organization
|
||||||
|
|
||||||
|
### Port Forwarding
|
||||||
|
|
||||||
|
SSHM provides an intuitive interface for setting up SSH port forwarding. Press `f` while selecting a host to open the port forwarding setup:
|
||||||
|
|
||||||
|
**Forward Types:**
|
||||||
|
- **Local (-L)** - Forward a local port to a remote host/port through the SSH connection
|
||||||
|
- Example: Access a remote database on `localhost:5432` via local port `15432`
|
||||||
|
- Use case: `ssh -L 15432:localhost:5432 server` → Database accessible on `localhost:15432`
|
||||||
|
|
||||||
|
- **Remote (-R)** - Forward a remote port back to a local host/port
|
||||||
|
- Example: Expose local web server on remote host's port `8080`
|
||||||
|
- Use case: `ssh -R 8080:localhost:3000 server` → Local app accessible from remote host's port 8080
|
||||||
|
- ⚠️ **Requirements for external access:**
|
||||||
|
- **SSH Server Config**: Add `GatewayPorts yes` to `/etc/ssh/sshd_config` and restart SSH service
|
||||||
|
- **Firewall**: Open the remote port in the server's firewall (`ufw allow 8080` or equivalent)
|
||||||
|
- **Port Availability**: Ensure the remote port is not already in use
|
||||||
|
- **Bind Address**: Use `0.0.0.0` for external access, `127.0.0.1` for local-only
|
||||||
|
|
||||||
|
- **Dynamic (-D)** - Create a SOCKS proxy for secure browsing
|
||||||
|
- Example: Route web traffic through the SSH connection
|
||||||
|
- Use case: `ssh -D 1080 server` → Configure browser to use `localhost:1080` as SOCKS proxy
|
||||||
|
- ⚠️ **Configuration requirements:**
|
||||||
|
- **Browser Setup**: Configure SOCKS v5 proxy in browser settings
|
||||||
|
- **DNS**: Enable "Proxy DNS when using SOCKS v5" for full privacy
|
||||||
|
- **Applications**: Only SOCKS-aware applications will use the proxy
|
||||||
|
- **Bind Address**: Use `127.0.0.1` for security (local access only)
|
||||||
|
|
||||||
|
**Port Forwarding Interface:**
|
||||||
|
- Choose forward type with ←/→ arrow keys
|
||||||
|
- Configure ports and addresses with guided forms
|
||||||
|
- Optional bind address configuration (defaults to 127.0.0.1)
|
||||||
|
- Real-time validation of port numbers and addresses
|
||||||
|
- Connect automatically with configured forwarding options
|
||||||
|
|
||||||
|
**Troubleshooting Port Forwarding:**
|
||||||
|
|
||||||
|
*Remote Forwarding Issues:*
|
||||||
|
```bash
|
||||||
|
# Error: "remote port forwarding failed for listen port X"
|
||||||
|
# Solutions:
|
||||||
|
1. Check if port is already in use: ssh server "netstat -tln | grep :X"
|
||||||
|
2. Use a different port that's available
|
||||||
|
3. Enable GatewayPorts in SSH config for external access
|
||||||
|
```
|
||||||
|
|
||||||
|
*SSH Server Configuration for Remote Forwarding:*
|
||||||
|
```bash
|
||||||
|
# Edit SSH daemon config on the server:
|
||||||
|
sudo nano /etc/ssh/sshd_config
|
||||||
|
|
||||||
|
# Add or uncomment:
|
||||||
|
GatewayPorts yes
|
||||||
|
|
||||||
|
# Restart SSH service:
|
||||||
|
sudo systemctl restart sshd # Ubuntu/Debian/CentOS 7+
|
||||||
|
# OR
|
||||||
|
sudo service ssh restart # Older systems
|
||||||
|
```
|
||||||
|
|
||||||
|
*Firewall Configuration:*
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian (UFW):
|
||||||
|
sudo ufw allow [port_number]
|
||||||
|
|
||||||
|
# CentOS/RHEL/Rocky (firewalld):
|
||||||
|
sudo firewall-cmd --add-port=[port_number]/tcp --permanent
|
||||||
|
sudo firewall-cmd --reload
|
||||||
|
|
||||||
|
# Check if port is accessible:
|
||||||
|
telnet [server_ip] [port_number]
|
||||||
|
```
|
||||||
|
|
||||||
|
*Dynamic Forwarding (SOCKS) Browser Setup:*
|
||||||
|
```
|
||||||
|
Firefox: about:preferences → Network Settings
|
||||||
|
- Manual proxy configuration
|
||||||
|
- SOCKS Host: localhost, Port: [your_port]
|
||||||
|
- SOCKS v5: ✓
|
||||||
|
- Proxy DNS when using SOCKS v5: ✓
|
||||||
|
|
||||||
|
Chrome: Launch with proxy
|
||||||
|
chrome --proxy-server="socks5://localhost:[your_port]"
|
||||||
|
```
|
||||||
|
|
||||||
### CLI Usage
|
### CLI Usage
|
||||||
|
|
||||||
SSHM provides both command-line operations and an interactive TUI interface:
|
SSHM provides both command-line operations and an interactive TUI interface:
|
||||||
|
@ -35,8 +35,31 @@ const (
|
|||||||
ViewList ViewMode = iota
|
ViewList ViewMode = iota
|
||||||
ViewAdd
|
ViewAdd
|
||||||
ViewEdit
|
ViewEdit
|
||||||
|
ViewPortForward
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PortForwardType defines the type of port forwarding
|
||||||
|
type PortForwardType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
LocalForward PortForwardType = iota
|
||||||
|
RemoteForward
|
||||||
|
DynamicForward
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p PortForwardType) String() string {
|
||||||
|
switch p {
|
||||||
|
case LocalForward:
|
||||||
|
return "Local (-L)"
|
||||||
|
case RemoteForward:
|
||||||
|
return "Remote (-R)"
|
||||||
|
case DynamicForward:
|
||||||
|
return "Dynamic (-D)"
|
||||||
|
default:
|
||||||
|
return "Local (-L)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Model represents the state of the user interface
|
// Model represents the state of the user interface
|
||||||
type Model struct {
|
type Model struct {
|
||||||
table table.Model
|
table table.Model
|
||||||
@ -51,9 +74,10 @@ type Model struct {
|
|||||||
configFile string // Path to the SSH config file
|
configFile string // Path to the SSH config file
|
||||||
|
|
||||||
// View management
|
// View management
|
||||||
viewMode ViewMode
|
viewMode ViewMode
|
||||||
addForm *addFormModel
|
addForm *addFormModel
|
||||||
editForm *editFormModel
|
editForm *editFormModel
|
||||||
|
portForwardForm *portForwardModel
|
||||||
|
|
||||||
// Terminal size and styles
|
// Terminal size and styles
|
||||||
width int
|
width int
|
||||||
|
490
internal/ui/port_forward_form.go
Normal file
490
internal/ui/port_forward_form.go
Normal file
@ -0,0 +1,490 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Input field indices for port forward form
|
||||||
|
const (
|
||||||
|
pfTypeInput = iota
|
||||||
|
pfLocalPortInput
|
||||||
|
pfRemoteHostInput
|
||||||
|
pfRemotePortInput
|
||||||
|
pfBindAddressInput
|
||||||
|
)
|
||||||
|
|
||||||
|
type portForwardModel struct {
|
||||||
|
inputs []textinput.Model
|
||||||
|
focused int
|
||||||
|
forwardType PortForwardType
|
||||||
|
hostName string
|
||||||
|
err string
|
||||||
|
styles Styles
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
configFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
// portForwardSubmitMsg is sent when the port forward form is submitted
|
||||||
|
type portForwardSubmitMsg struct {
|
||||||
|
err error
|
||||||
|
sshArgs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// portForwardCancelMsg is sent when the port forward form is cancelled
|
||||||
|
type portForwardCancelMsg struct{}
|
||||||
|
|
||||||
|
// NewPortForwardForm creates a new port forward form model
|
||||||
|
func NewPortForwardForm(hostName string, styles Styles, width, height int, configFile string) *portForwardModel {
|
||||||
|
inputs := make([]textinput.Model, 5)
|
||||||
|
|
||||||
|
// Forward type input (display only, controlled by arrow keys)
|
||||||
|
inputs[pfTypeInput] = textinput.New()
|
||||||
|
inputs[pfTypeInput].Placeholder = "Use ←/→ to change forward type"
|
||||||
|
inputs[pfTypeInput].Focus()
|
||||||
|
inputs[pfTypeInput].Width = 40
|
||||||
|
inputs[pfTypeInput].SetValue("Local (-L)")
|
||||||
|
|
||||||
|
// Local port input
|
||||||
|
inputs[pfLocalPortInput] = textinput.New()
|
||||||
|
inputs[pfLocalPortInput].Placeholder = "8080"
|
||||||
|
inputs[pfLocalPortInput].CharLimit = 5
|
||||||
|
inputs[pfLocalPortInput].Width = 20
|
||||||
|
|
||||||
|
// Remote host input
|
||||||
|
inputs[pfRemoteHostInput] = textinput.New()
|
||||||
|
inputs[pfRemoteHostInput].Placeholder = "localhost"
|
||||||
|
inputs[pfRemoteHostInput].CharLimit = 100
|
||||||
|
inputs[pfRemoteHostInput].Width = 30
|
||||||
|
inputs[pfRemoteHostInput].SetValue("localhost")
|
||||||
|
|
||||||
|
// Remote port input
|
||||||
|
inputs[pfRemotePortInput] = textinput.New()
|
||||||
|
inputs[pfRemotePortInput].Placeholder = "80"
|
||||||
|
inputs[pfRemotePortInput].CharLimit = 5
|
||||||
|
inputs[pfRemotePortInput].Width = 20
|
||||||
|
|
||||||
|
// Bind address input (optional)
|
||||||
|
inputs[pfBindAddressInput] = textinput.New()
|
||||||
|
inputs[pfBindAddressInput].Placeholder = "127.0.0.1 (optional)"
|
||||||
|
inputs[pfBindAddressInput].CharLimit = 50
|
||||||
|
inputs[pfBindAddressInput].Width = 30
|
||||||
|
|
||||||
|
pf := &portForwardModel{
|
||||||
|
inputs: inputs,
|
||||||
|
focused: 0,
|
||||||
|
forwardType: LocalForward,
|
||||||
|
hostName: hostName,
|
||||||
|
styles: styles,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
configFile: configFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize input visibility
|
||||||
|
pf.updateInputVisibility()
|
||||||
|
|
||||||
|
return pf
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *portForwardModel) Init() tea.Cmd {
|
||||||
|
return textinput.Blink
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *portForwardModel) Update(msg tea.Msg) (*portForwardModel, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "ctrl+c":
|
||||||
|
return m, func() tea.Msg { return portForwardCancelMsg{} }
|
||||||
|
|
||||||
|
case "enter":
|
||||||
|
nextField := m.getNextValidField(m.focused)
|
||||||
|
if nextField != -1 {
|
||||||
|
// Move to next valid input
|
||||||
|
m.inputs[m.focused].Blur()
|
||||||
|
m.focused = nextField
|
||||||
|
m.inputs[m.focused].Focus()
|
||||||
|
return m, textinput.Blink
|
||||||
|
} else {
|
||||||
|
// Submit form
|
||||||
|
return m, m.submitForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
case "shift+tab", "up":
|
||||||
|
prevField := m.getPrevValidField(m.focused)
|
||||||
|
if prevField != -1 {
|
||||||
|
m.inputs[m.focused].Blur()
|
||||||
|
m.focused = prevField
|
||||||
|
m.inputs[m.focused].Focus()
|
||||||
|
return m, textinput.Blink
|
||||||
|
}
|
||||||
|
|
||||||
|
case "tab", "down":
|
||||||
|
nextField := m.getNextValidField(m.focused)
|
||||||
|
if nextField != -1 {
|
||||||
|
m.inputs[m.focused].Blur()
|
||||||
|
m.focused = nextField
|
||||||
|
m.inputs[m.focused].Focus()
|
||||||
|
return m, textinput.Blink
|
||||||
|
}
|
||||||
|
|
||||||
|
case "left", "right":
|
||||||
|
if m.focused == pfTypeInput {
|
||||||
|
// Change forward type
|
||||||
|
if msg.String() == "left" {
|
||||||
|
if m.forwardType > 0 {
|
||||||
|
m.forwardType--
|
||||||
|
} else {
|
||||||
|
m.forwardType = DynamicForward
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if m.forwardType < DynamicForward {
|
||||||
|
m.forwardType++
|
||||||
|
} else {
|
||||||
|
m.forwardType = LocalForward
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.inputs[pfTypeInput].SetValue(m.forwardType.String())
|
||||||
|
m.updateInputVisibility()
|
||||||
|
|
||||||
|
// Ensure focused field is valid for the new type
|
||||||
|
validFields := m.getValidFields()
|
||||||
|
validFocus := false
|
||||||
|
for _, field := range validFields {
|
||||||
|
if field == m.focused {
|
||||||
|
validFocus = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !validFocus && len(validFields) > 0 {
|
||||||
|
m.inputs[m.focused].Blur()
|
||||||
|
m.focused = validFields[0]
|
||||||
|
m.inputs[m.focused].Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the focused input
|
||||||
|
m.inputs[m.focused], cmd = m.inputs[m.focused].Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *portForwardModel) updateInputVisibility() {
|
||||||
|
// Reset all inputs visibility
|
||||||
|
for i := range m.inputs {
|
||||||
|
if i != pfTypeInput {
|
||||||
|
m.inputs[i].Placeholder = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch m.forwardType {
|
||||||
|
case LocalForward:
|
||||||
|
m.inputs[pfLocalPortInput].Placeholder = "Local port (e.g., 8080)"
|
||||||
|
m.inputs[pfRemoteHostInput].Placeholder = "Remote host (e.g., localhost)"
|
||||||
|
m.inputs[pfRemotePortInput].Placeholder = "Remote port (e.g., 80)"
|
||||||
|
m.inputs[pfBindAddressInput].Placeholder = "Bind address (optional, default: 127.0.0.1)"
|
||||||
|
case RemoteForward:
|
||||||
|
m.inputs[pfLocalPortInput].Placeholder = "Remote port (e.g., 8080)"
|
||||||
|
m.inputs[pfRemoteHostInput].Placeholder = "Local host (e.g., localhost)"
|
||||||
|
m.inputs[pfRemotePortInput].Placeholder = "Local port (e.g., 80)"
|
||||||
|
m.inputs[pfBindAddressInput].Placeholder = "Bind address (optional)"
|
||||||
|
case DynamicForward:
|
||||||
|
m.inputs[pfLocalPortInput].Placeholder = "SOCKS port (e.g., 1080)"
|
||||||
|
m.inputs[pfRemoteHostInput].Placeholder = ""
|
||||||
|
m.inputs[pfRemotePortInput].Placeholder = ""
|
||||||
|
m.inputs[pfBindAddressInput].Placeholder = "Bind address (optional, default: 127.0.0.1)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *portForwardModel) View() string {
|
||||||
|
var sections []string
|
||||||
|
|
||||||
|
// Title
|
||||||
|
title := m.styles.Header.Render("🔗 Port Forwarding Setup")
|
||||||
|
sections = append(sections, title)
|
||||||
|
|
||||||
|
// Host info
|
||||||
|
hostInfo := fmt.Sprintf("Host: %s", m.hostName)
|
||||||
|
sections = append(sections, m.styles.HelpText.Render(hostInfo))
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
if m.err != "" {
|
||||||
|
sections = append(sections, m.styles.Error.Render("Error: "+m.err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
var fields []string
|
||||||
|
|
||||||
|
// Forward type
|
||||||
|
typeLabel := "Forward Type:"
|
||||||
|
if m.focused == pfTypeInput {
|
||||||
|
typeLabel = m.styles.FocusedLabel.Render(typeLabel)
|
||||||
|
} else {
|
||||||
|
typeLabel = m.styles.Label.Render(typeLabel)
|
||||||
|
}
|
||||||
|
fields = append(fields, typeLabel)
|
||||||
|
fields = append(fields, m.inputs[pfTypeInput].View())
|
||||||
|
fields = append(fields, m.styles.HelpText.Render("Use ←/→ to change type"))
|
||||||
|
|
||||||
|
switch m.forwardType {
|
||||||
|
case LocalForward:
|
||||||
|
fields = append(fields, "")
|
||||||
|
fields = append(fields, m.styles.HelpText.Render("Local forwarding: ssh -L [bind_address:]local_port:remote_host:remote_port"))
|
||||||
|
fields = append(fields, "")
|
||||||
|
|
||||||
|
// Local port
|
||||||
|
localPortLabel := "Local Port:"
|
||||||
|
if m.focused == pfLocalPortInput {
|
||||||
|
localPortLabel = m.styles.FocusedLabel.Render(localPortLabel)
|
||||||
|
} else {
|
||||||
|
localPortLabel = m.styles.Label.Render(localPortLabel)
|
||||||
|
}
|
||||||
|
fields = append(fields, localPortLabel)
|
||||||
|
fields = append(fields, m.inputs[pfLocalPortInput].View())
|
||||||
|
|
||||||
|
// Remote host
|
||||||
|
remoteHostLabel := "Remote Host:"
|
||||||
|
if m.focused == pfRemoteHostInput {
|
||||||
|
remoteHostLabel = m.styles.FocusedLabel.Render(remoteHostLabel)
|
||||||
|
} else {
|
||||||
|
remoteHostLabel = m.styles.Label.Render(remoteHostLabel)
|
||||||
|
}
|
||||||
|
fields = append(fields, remoteHostLabel)
|
||||||
|
fields = append(fields, m.inputs[pfRemoteHostInput].View())
|
||||||
|
|
||||||
|
// Remote port
|
||||||
|
remotePortLabel := "Remote Port:"
|
||||||
|
if m.focused == pfRemotePortInput {
|
||||||
|
remotePortLabel = m.styles.FocusedLabel.Render(remotePortLabel)
|
||||||
|
} else {
|
||||||
|
remotePortLabel = m.styles.Label.Render(remotePortLabel)
|
||||||
|
}
|
||||||
|
fields = append(fields, remotePortLabel)
|
||||||
|
fields = append(fields, m.inputs[pfRemotePortInput].View())
|
||||||
|
|
||||||
|
case RemoteForward:
|
||||||
|
fields = append(fields, "")
|
||||||
|
fields = append(fields, m.styles.HelpText.Render("Remote forwarding: ssh -R [bind_address:]remote_port:local_host:local_port"))
|
||||||
|
fields = append(fields, "")
|
||||||
|
|
||||||
|
// Remote port
|
||||||
|
remotePortLabel := "Remote Port:"
|
||||||
|
if m.focused == pfLocalPortInput {
|
||||||
|
remotePortLabel = m.styles.FocusedLabel.Render(remotePortLabel)
|
||||||
|
} else {
|
||||||
|
remotePortLabel = m.styles.Label.Render(remotePortLabel)
|
||||||
|
}
|
||||||
|
fields = append(fields, remotePortLabel)
|
||||||
|
fields = append(fields, m.inputs[pfLocalPortInput].View())
|
||||||
|
|
||||||
|
// Local host
|
||||||
|
localHostLabel := "Local Host:"
|
||||||
|
if m.focused == pfRemoteHostInput {
|
||||||
|
localHostLabel = m.styles.FocusedLabel.Render(localHostLabel)
|
||||||
|
} else {
|
||||||
|
localHostLabel = m.styles.Label.Render(localHostLabel)
|
||||||
|
}
|
||||||
|
fields = append(fields, localHostLabel)
|
||||||
|
fields = append(fields, m.inputs[pfRemoteHostInput].View())
|
||||||
|
|
||||||
|
// Local port
|
||||||
|
localPortLabel := "Local Port:"
|
||||||
|
if m.focused == pfRemotePortInput {
|
||||||
|
localPortLabel = m.styles.FocusedLabel.Render(localPortLabel)
|
||||||
|
} else {
|
||||||
|
localPortLabel = m.styles.Label.Render(localPortLabel)
|
||||||
|
}
|
||||||
|
fields = append(fields, localPortLabel)
|
||||||
|
fields = append(fields, m.inputs[pfRemotePortInput].View())
|
||||||
|
|
||||||
|
case DynamicForward:
|
||||||
|
fields = append(fields, "")
|
||||||
|
fields = append(fields, m.styles.HelpText.Render("Dynamic forwarding (SOCKS proxy): ssh -D [bind_address:]port"))
|
||||||
|
fields = append(fields, "")
|
||||||
|
|
||||||
|
// SOCKS port
|
||||||
|
socksPortLabel := "SOCKS Port:"
|
||||||
|
if m.focused == pfLocalPortInput {
|
||||||
|
socksPortLabel = m.styles.FocusedLabel.Render(socksPortLabel)
|
||||||
|
} else {
|
||||||
|
socksPortLabel = m.styles.Label.Render(socksPortLabel)
|
||||||
|
}
|
||||||
|
fields = append(fields, socksPortLabel)
|
||||||
|
fields = append(fields, m.inputs[pfLocalPortInput].View())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind address (for all types)
|
||||||
|
fields = append(fields, "")
|
||||||
|
bindLabel := "Bind Address (optional):"
|
||||||
|
if m.focused == pfBindAddressInput {
|
||||||
|
bindLabel = m.styles.FocusedLabel.Render(bindLabel)
|
||||||
|
} else {
|
||||||
|
bindLabel = m.styles.Label.Render(bindLabel)
|
||||||
|
}
|
||||||
|
fields = append(fields, bindLabel)
|
||||||
|
fields = append(fields, m.inputs[pfBindAddressInput].View())
|
||||||
|
|
||||||
|
// Join form fields
|
||||||
|
formContent := lipgloss.JoinVertical(lipgloss.Left, fields...)
|
||||||
|
sections = append(sections, formContent)
|
||||||
|
|
||||||
|
// Help text
|
||||||
|
helpText := " Tab/↓: next field • Shift+Tab/↑: previous field • Enter: connect • Esc: cancel"
|
||||||
|
sections = append(sections, m.styles.HelpText.Render(helpText))
|
||||||
|
|
||||||
|
// Join all sections
|
||||||
|
content := lipgloss.JoinVertical(lipgloss.Left, sections...)
|
||||||
|
|
||||||
|
// Center the form
|
||||||
|
return lipgloss.Place(
|
||||||
|
m.width,
|
||||||
|
m.height,
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
m.styles.FormContainer.Render(content),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *portForwardModel) submitForm() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
// Validate inputs
|
||||||
|
localPort := strings.TrimSpace(m.inputs[pfLocalPortInput].Value())
|
||||||
|
if localPort == "" {
|
||||||
|
return portForwardSubmitMsg{err: fmt.Errorf("port is required"), sshArgs: nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate port number
|
||||||
|
if _, err := strconv.Atoi(localPort); err != nil {
|
||||||
|
return portForwardSubmitMsg{err: fmt.Errorf("invalid port number"), sshArgs: nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build SSH command with port forwarding
|
||||||
|
var sshArgs []string
|
||||||
|
|
||||||
|
// Add config file if specified
|
||||||
|
if m.configFile != "" {
|
||||||
|
sshArgs = append(sshArgs, "-F", m.configFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add forwarding arguments
|
||||||
|
bindAddress := strings.TrimSpace(m.inputs[pfBindAddressInput].Value())
|
||||||
|
|
||||||
|
switch m.forwardType {
|
||||||
|
case LocalForward:
|
||||||
|
remoteHost := strings.TrimSpace(m.inputs[pfRemoteHostInput].Value())
|
||||||
|
remotePort := strings.TrimSpace(m.inputs[pfRemotePortInput].Value())
|
||||||
|
|
||||||
|
if remoteHost == "" {
|
||||||
|
remoteHost = "localhost"
|
||||||
|
}
|
||||||
|
if remotePort == "" {
|
||||||
|
return portForwardSubmitMsg{err: fmt.Errorf("remote port is required for local forwarding"), sshArgs: nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate remote port
|
||||||
|
if _, err := strconv.Atoi(remotePort); err != nil {
|
||||||
|
return portForwardSubmitMsg{err: fmt.Errorf("invalid remote port number"), sshArgs: nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build -L argument
|
||||||
|
var forwardArg string
|
||||||
|
if bindAddress != "" {
|
||||||
|
forwardArg = fmt.Sprintf("%s:%s:%s:%s", bindAddress, localPort, remoteHost, remotePort)
|
||||||
|
} else {
|
||||||
|
forwardArg = fmt.Sprintf("%s:%s:%s", localPort, remoteHost, remotePort)
|
||||||
|
}
|
||||||
|
sshArgs = append(sshArgs, "-L", forwardArg)
|
||||||
|
|
||||||
|
case RemoteForward:
|
||||||
|
localHost := strings.TrimSpace(m.inputs[pfRemoteHostInput].Value())
|
||||||
|
localPortStr := strings.TrimSpace(m.inputs[pfRemotePortInput].Value())
|
||||||
|
|
||||||
|
if localHost == "" {
|
||||||
|
localHost = "localhost"
|
||||||
|
}
|
||||||
|
if localPortStr == "" {
|
||||||
|
return portForwardSubmitMsg{err: fmt.Errorf("local port is required for remote forwarding"), sshArgs: nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate local port
|
||||||
|
if _, err := strconv.Atoi(localPortStr); err != nil {
|
||||||
|
return portForwardSubmitMsg{err: fmt.Errorf("invalid local port number"), sshArgs: nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build -R argument (note: localPort is actually the remote port in this context)
|
||||||
|
var forwardArg string
|
||||||
|
if bindAddress != "" {
|
||||||
|
forwardArg = fmt.Sprintf("%s:%s:%s:%s", bindAddress, localPort, localHost, localPortStr)
|
||||||
|
} else {
|
||||||
|
forwardArg = fmt.Sprintf("%s:%s:%s", localPort, localHost, localPortStr)
|
||||||
|
}
|
||||||
|
sshArgs = append(sshArgs, "-R", forwardArg)
|
||||||
|
|
||||||
|
case DynamicForward:
|
||||||
|
// Build -D argument
|
||||||
|
var forwardArg string
|
||||||
|
if bindAddress != "" {
|
||||||
|
forwardArg = fmt.Sprintf("%s:%s", bindAddress, localPort)
|
||||||
|
} else {
|
||||||
|
forwardArg = localPort
|
||||||
|
}
|
||||||
|
sshArgs = append(sshArgs, "-D", forwardArg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add hostname
|
||||||
|
sshArgs = append(sshArgs, m.hostName)
|
||||||
|
|
||||||
|
// Return success with the SSH command to execute
|
||||||
|
return portForwardSubmitMsg{err: nil, sshArgs: sshArgs}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getValidFields returns the list of valid field indices for the current forward type
|
||||||
|
func (m *portForwardModel) getValidFields() []int {
|
||||||
|
switch m.forwardType {
|
||||||
|
case LocalForward:
|
||||||
|
return []int{pfTypeInput, pfLocalPortInput, pfRemoteHostInput, pfRemotePortInput, pfBindAddressInput}
|
||||||
|
case RemoteForward:
|
||||||
|
return []int{pfTypeInput, pfLocalPortInput, pfRemoteHostInput, pfRemotePortInput, pfBindAddressInput}
|
||||||
|
case DynamicForward:
|
||||||
|
return []int{pfTypeInput, pfLocalPortInput, pfBindAddressInput}
|
||||||
|
default:
|
||||||
|
return []int{pfTypeInput, pfLocalPortInput, pfRemoteHostInput, pfRemotePortInput, pfBindAddressInput}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNextValidField returns the next valid field index, or -1 if none
|
||||||
|
func (m *portForwardModel) getNextValidField(currentField int) int {
|
||||||
|
validFields := m.getValidFields()
|
||||||
|
|
||||||
|
for i, field := range validFields {
|
||||||
|
if field == currentField && i < len(validFields)-1 {
|
||||||
|
return validFields[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPrevValidField returns the previous valid field index, or -1 if none
|
||||||
|
func (m *portForwardModel) getPrevValidField(currentField int) int {
|
||||||
|
validFields := m.getValidFields()
|
||||||
|
|
||||||
|
for i, field := range validFields {
|
||||||
|
if field == currentField && i > 0 {
|
||||||
|
return validFields[i-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
@ -36,9 +36,12 @@ type Styles struct {
|
|||||||
Error lipgloss.Style
|
Error lipgloss.Style
|
||||||
|
|
||||||
// Form styles (for add/edit forms)
|
// Form styles (for add/edit forms)
|
||||||
FormTitle lipgloss.Style
|
FormTitle lipgloss.Style
|
||||||
FormField lipgloss.Style
|
FormField lipgloss.Style
|
||||||
FormHelp lipgloss.Style
|
FormHelp lipgloss.Style
|
||||||
|
FormContainer lipgloss.Style
|
||||||
|
Label lipgloss.Style
|
||||||
|
FocusedLabel lipgloss.Style
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStyles creates a new Styles struct with the given terminal width
|
// NewStyles creates a new Styles struct with the given terminal width
|
||||||
@ -105,6 +108,18 @@ func NewStyles(width int) Styles {
|
|||||||
|
|
||||||
FormHelp: lipgloss.NewStyle().
|
FormHelp: lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("#626262")),
|
Foreground(lipgloss.Color("#626262")),
|
||||||
|
|
||||||
|
FormContainer: lipgloss.NewStyle().
|
||||||
|
BorderStyle(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color(PrimaryColor)).
|
||||||
|
Padding(1, 2),
|
||||||
|
|
||||||
|
Label: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(SecondaryColor)),
|
||||||
|
|
||||||
|
FocusedLabel: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(PrimaryColor)).
|
||||||
|
Bold(true),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -46,6 +46,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.editForm.height = m.height
|
m.editForm.height = m.height
|
||||||
m.editForm.styles = m.styles
|
m.editForm.styles = m.styles
|
||||||
}
|
}
|
||||||
|
if m.portForwardForm != nil {
|
||||||
|
m.portForwardForm.width = m.width
|
||||||
|
m.portForwardForm.height = m.height
|
||||||
|
m.portForwardForm.styles = m.styles
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case addFormSubmitMsg:
|
case addFormSubmitMsg:
|
||||||
@ -136,6 +141,45 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.table.Focus()
|
m.table.Focus()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case portForwardSubmitMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
// Show error in form
|
||||||
|
if m.portForwardForm != nil {
|
||||||
|
m.portForwardForm.err = msg.err.Error()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
} else {
|
||||||
|
// Success: execute SSH command with port forwarding
|
||||||
|
if len(msg.sshArgs) > 0 {
|
||||||
|
sshCmd := exec.Command("ssh", msg.sshArgs...)
|
||||||
|
|
||||||
|
// Record the connection in history
|
||||||
|
if m.historyManager != nil && m.portForwardForm != nil {
|
||||||
|
err := m.historyManager.RecordConnection(m.portForwardForm.hostName)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: Could not record connection history: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, tea.ExecProcess(sshCmd, func(err error) tea.Msg {
|
||||||
|
return tea.Quit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no SSH args, just return to list view
|
||||||
|
m.viewMode = ViewList
|
||||||
|
m.portForwardForm = nil
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case portForwardCancelMsg:
|
||||||
|
// Cancel: return to list view
|
||||||
|
m.viewMode = ViewList
|
||||||
|
m.portForwardForm = nil
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
// Handle view-specific key presses
|
// Handle view-specific key presses
|
||||||
switch m.viewMode {
|
switch m.viewMode {
|
||||||
@ -153,6 +197,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.editForm = newForm
|
m.editForm = newForm
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
case ViewPortForward:
|
||||||
|
if m.portForwardForm != nil {
|
||||||
|
var newForm *portForwardModel
|
||||||
|
newForm, cmd = m.portForwardForm.Update(msg)
|
||||||
|
m.portForwardForm = newForm
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
case ViewList:
|
case ViewList:
|
||||||
// Handle list view keys
|
// Handle list view keys
|
||||||
return m.handleListViewKeys(msg)
|
return m.handleListViewKeys(msg)
|
||||||
@ -324,6 +375,17 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "f":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Port forwarding for the selected host
|
||||||
|
selected := m.table.SelectedRow()
|
||||||
|
if len(selected) > 0 {
|
||||||
|
hostName := selected[0] // The hostname is in the first column
|
||||||
|
m.portForwardForm = NewPortForwardForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||||
|
m.viewMode = ViewPortForward
|
||||||
|
return m, textinput.Blink
|
||||||
|
}
|
||||||
|
}
|
||||||
case "s":
|
case "s":
|
||||||
if !m.searchMode && !m.deleteMode {
|
if !m.searchMode && !m.deleteMode {
|
||||||
// Cycle through sort modes (only 2 modes now)
|
// Cycle through sort modes (only 2 modes now)
|
||||||
|
@ -23,6 +23,10 @@ func (m Model) View() string {
|
|||||||
if m.editForm != nil {
|
if m.editForm != nil {
|
||||||
return m.editForm.View()
|
return m.editForm.View()
|
||||||
}
|
}
|
||||||
|
case ViewPortForward:
|
||||||
|
if m.portForwardForm != nil {
|
||||||
|
return m.portForwardForm.View()
|
||||||
|
}
|
||||||
case ViewList:
|
case ViewList:
|
||||||
return m.renderListView()
|
return m.renderListView()
|
||||||
}
|
}
|
||||||
@ -62,7 +66,7 @@ func (m Model) renderListView() string {
|
|||||||
// Add the help text
|
// Add the help text
|
||||||
var helpText string
|
var helpText string
|
||||||
if !m.searchMode {
|
if !m.searchMode {
|
||||||
helpText = " Use ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • Tab to switch\n Sort: (s)witch • (r)ecent • (n)ame • q/ESC to quit"
|
helpText = " Use ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • (f)orward • / to search • Tab to switch\n Sort: (s)witch • (r)ecent • (n)ame • q/ESC to quit"
|
||||||
} else {
|
} else {
|
||||||
helpText = " Type to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit"
|
helpText = " Type to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit"
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user