mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2025-09-09 14:20:39 +02:00
Compare commits
No commits in common. "2ade315ddca7cb1eb64a2bcc44852cb8f38d7b8c" and "21c5d41977eff3dc2c0d3e8cd7587075cb1ea475" have entirely different histories.
2ade315ddc
...
21c5d41977
87
README.md
87
README.md
@ -28,7 +28,6 @@ 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
|
||||||
- **🔄 Port Forwarding** - Easy setup for Local, Remote, and Dynamic (SOCKS) forwarding
|
|
||||||
- **📝 Easy Management** - Add, edit, and manage SSH configurations seamlessly
|
- **📝 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
|
||||||
@ -41,7 +40,6 @@ 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
|
||||||
@ -104,7 +102,6 @@ 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
|
||||||
|
|
||||||
@ -125,90 +122,6 @@ 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:
|
||||||
|
@ -74,10 +74,10 @@ downloadBinary() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if the expected binary exists (no find needed)
|
# Find the extracted binary
|
||||||
EXTRACTED_BINARY="./sshm-${OS}-${ARCH}"
|
EXTRACTED_BINARY=$(find . -name "sshm-${OS}-${ARCH}" -type f)
|
||||||
if [ ! -f "$EXTRACTED_BINARY" ]; then
|
if [ -z "$EXTRACTED_BINARY" ]; then
|
||||||
printf "${RED}Could not find extracted binary: $EXTRACTED_BINARY${NC}\n"
|
printf "${RED}Could not find extracted binary${NC}\n"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@ -88,37 +88,17 @@ downloadBinary() {
|
|||||||
install() {
|
install() {
|
||||||
printf "${YELLOW}Installing SSHM...${NC}\n"
|
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"
|
chmod +x "sshm-tmp"
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
printf "${RED}Failed to set permissions${NC}\n"
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
runAsRoot mv "sshm-tmp" "$EXECUTABLE_PATH"
|
runAsRoot mv "sshm-tmp" "$EXECUTABLE_PATH"
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
printf "${RED}Failed to install binary${NC}\n"
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Clean up backup if installation succeeded
|
|
||||||
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
|
|
||||||
runAsRoot rm -f "$OLD_BACKUP"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
@ -181,8 +161,7 @@ main() {
|
|||||||
# Show version
|
# Show version
|
||||||
printf "${YELLOW}Verifying installation...${NC}\n"
|
printf "${YELLOW}Verifying installation...${NC}\n"
|
||||||
if command -v sshm >/dev/null 2>&1; then
|
if command -v sshm >/dev/null 2>&1; then
|
||||||
# Use the full path to ensure we're using the newly installed version
|
sshm --version
|
||||||
"$EXECUTABLE_PATH" --version 2>/dev/null || echo "Version check failed, but installation completed"
|
|
||||||
else
|
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"
|
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
|
fi
|
||||||
|
@ -35,31 +35,8 @@ 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
|
||||||
@ -77,7 +54,6 @@ type Model struct {
|
|||||||
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
|
||||||
|
@ -1,490 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
@ -39,9 +39,6 @@ type Styles struct {
|
|||||||
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
|
||||||
@ -108,18 +105,6 @@ 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,11 +46,6 @@ 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:
|
||||||
@ -141,45 +136,6 @@ 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 {
|
||||||
@ -197,13 +153,6 @@ 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)
|
||||||
@ -375,17 +324,6 @@ 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,10 +23,6 @@ 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()
|
||||||
}
|
}
|
||||||
@ -66,7 +62,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 • (f)orward • / 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 • / 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