mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2025-09-07 21:30:39 +02:00
Compare commits
2 Commits
21c5d41977
...
2ade315ddc
Author | SHA1 | Date | |
---|---|---|---|
2ade315ddc | |||
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**
|
||||
- **🎨 Beautiful TUI Interface** - Navigate your SSH hosts with an elegant, interactive terminal UI
|
||||
- **⚡ 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
|
||||
- **🔍 Smart Search** - Find hosts quickly with built-in filtering and search
|
||||
- **🔒 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
|
||||
- **Edit existing configurations** in-place
|
||||
- **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
|
||||
- **Validate settings** to prevent configuration errors
|
||||
- **ProxyJump support** for secure connection tunneling through bastion hosts
|
||||
@ -102,6 +104,7 @@ sshm
|
||||
- `a` - Add new host
|
||||
- `e` - Edit selected host
|
||||
- `d` - Delete selected host
|
||||
- `f` - Port forwarding setup
|
||||
- `q` - Quit
|
||||
- `/` - 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`)
|
||||
- **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
|
||||
|
||||
SSHM provides both command-line operations and an interactive TUI interface:
|
||||
|
@ -74,10 +74,10 @@ downloadBinary() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find the extracted binary
|
||||
EXTRACTED_BINARY=$(find . -name "sshm-${OS}-${ARCH}" -type f)
|
||||
if [ -z "$EXTRACTED_BINARY" ]; then
|
||||
printf "${RED}Could not find extracted binary${NC}\n"
|
||||
# Check if the expected binary exists (no find needed)
|
||||
EXTRACTED_BINARY="./sshm-${OS}-${ARCH}"
|
||||
if [ ! -f "$EXTRACTED_BINARY" ]; then
|
||||
printf "${RED}Could not find extracted binary: $EXTRACTED_BINARY${NC}\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@ -88,17 +88,37 @@ downloadBinary() {
|
||||
install() {
|
||||
printf "${YELLOW}Installing SSHM...${NC}\n"
|
||||
|
||||
# Backup old version if it exists to prevent interference during installation
|
||||
OLD_BACKUP=""
|
||||
if [ -f "$EXECUTABLE_PATH" ]; then
|
||||
OLD_BACKUP="$EXECUTABLE_PATH.backup.$$"
|
||||
runAsRoot mv "$EXECUTABLE_PATH" "$OLD_BACKUP"
|
||||
fi
|
||||
|
||||
chmod +x "sshm-tmp"
|
||||
if [ $? -ne 0 ]; then
|
||||
printf "${RED}Failed to set permissions${NC}\n"
|
||||
# Restore backup if installation fails
|
||||
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
|
||||
runAsRoot mv "$OLD_BACKUP" "$EXECUTABLE_PATH"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
runAsRoot mv "sshm-tmp" "$EXECUTABLE_PATH"
|
||||
if [ $? -ne 0 ]; then
|
||||
printf "${RED}Failed to install binary${NC}\n"
|
||||
# Restore backup if installation fails
|
||||
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
|
||||
runAsRoot mv "$OLD_BACKUP" "$EXECUTABLE_PATH"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up backup if installation succeeded
|
||||
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
|
||||
runAsRoot rm -f "$OLD_BACKUP"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
@ -161,7 +181,8 @@ main() {
|
||||
# Show version
|
||||
printf "${YELLOW}Verifying installation...${NC}\n"
|
||||
if command -v sshm >/dev/null 2>&1; then
|
||||
sshm --version
|
||||
# Use the full path to ensure we're using the newly installed version
|
||||
"$EXECUTABLE_PATH" --version 2>/dev/null || echo "Version check failed, but installation completed"
|
||||
else
|
||||
printf "${RED}Warning: 'sshm' command not found in PATH. You may need to restart your terminal or add $INSTALL_DIR to your PATH.${NC}\n"
|
||||
fi
|
||||
|
@ -35,8 +35,31 @@ const (
|
||||
ViewList ViewMode = iota
|
||||
ViewAdd
|
||||
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
|
||||
type Model struct {
|
||||
table table.Model
|
||||
@ -51,9 +74,10 @@ type Model struct {
|
||||
configFile string // Path to the SSH config file
|
||||
|
||||
// View management
|
||||
viewMode ViewMode
|
||||
addForm *addFormModel
|
||||
editForm *editFormModel
|
||||
viewMode ViewMode
|
||||
addForm *addFormModel
|
||||
editForm *editFormModel
|
||||
portForwardForm *portForwardModel
|
||||
|
||||
// Terminal size and styles
|
||||
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
|
||||
|
||||
// Form styles (for add/edit forms)
|
||||
FormTitle lipgloss.Style
|
||||
FormField lipgloss.Style
|
||||
FormHelp lipgloss.Style
|
||||
FormTitle lipgloss.Style
|
||||
FormField 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
|
||||
@ -105,6 +108,18 @@ func NewStyles(width int) Styles {
|
||||
|
||||
FormHelp: lipgloss.NewStyle().
|
||||
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.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
|
||||
|
||||
case addFormSubmitMsg:
|
||||
@ -136,6 +141,45 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.table.Focus()
|
||||
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:
|
||||
// Handle view-specific key presses
|
||||
switch m.viewMode {
|
||||
@ -153,6 +197,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.editForm = newForm
|
||||
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:
|
||||
// Handle list view keys
|
||||
return m.handleListViewKeys(msg)
|
||||
@ -324,6 +375,17 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
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":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Cycle through sort modes (only 2 modes now)
|
||||
|
@ -23,6 +23,10 @@ func (m Model) View() string {
|
||||
if m.editForm != nil {
|
||||
return m.editForm.View()
|
||||
}
|
||||
case ViewPortForward:
|
||||
if m.portForwardForm != nil {
|
||||
return m.portForwardForm.View()
|
||||
}
|
||||
case ViewList:
|
||||
return m.renderListView()
|
||||
}
|
||||
@ -62,7 +66,7 @@ func (m Model) renderListView() string {
|
||||
// Add the help text
|
||||
var helpText string
|
||||
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 {
|
||||
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