mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2026-01-27 03:04:21 +01:00
Compare commits
7 Commits
959c084466
...
1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 2deec405f7 | |||
| 21c5d41977 | |||
| 20bc506e36 | |||
| e8c6e602a2 | |||
| b5d8d505cf | |||
| 3a72694e5a | |||
| 8f2837db78 |
30
.github/workflows/build.yml
vendored
30
.github/workflows/build.yml
vendored
@@ -34,6 +34,14 @@ jobs:
|
|||||||
- goos: darwin
|
- goos: darwin
|
||||||
goarch: arm64
|
goarch: arm64
|
||||||
suffix: darwin-arm64
|
suffix: darwin-arm64
|
||||||
|
# Windows AMD64
|
||||||
|
- goos: windows
|
||||||
|
goarch: amd64
|
||||||
|
suffix: windows-amd64
|
||||||
|
# Windows ARM64
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm64
|
||||||
|
suffix: windows-arm64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -60,19 +68,30 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
VERSION=${GITHUB_REF#refs/tags/}
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }} .
|
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||||
|
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }}.exe .
|
||||||
|
else
|
||||||
|
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }} .
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Create tarball
|
- name: Create archive
|
||||||
run: |
|
run: |
|
||||||
cd dist
|
cd dist
|
||||||
tar -czf sshm-${{ matrix.suffix }}.tar.gz sshm-${{ matrix.suffix }}
|
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||||
rm sshm-${{ matrix.suffix }}
|
zip sshm-${{ matrix.suffix }}.zip sshm-${{ matrix.suffix }}.exe
|
||||||
|
rm sshm-${{ matrix.suffix }}.exe
|
||||||
|
else
|
||||||
|
tar -czf sshm-${{ matrix.suffix }}.tar.gz sshm-${{ matrix.suffix }}
|
||||||
|
rm sshm-${{ matrix.suffix }}
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: sshm-${{ matrix.suffix }}
|
name: sshm-${{ matrix.suffix }}
|
||||||
path: dist/sshm-${{ matrix.suffix }}.tar.gz
|
path: |
|
||||||
|
dist/sshm-${{ matrix.suffix }}.tar.gz
|
||||||
|
dist/sshm-${{ matrix.suffix }}.zip
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: Create Release
|
name: Create Release
|
||||||
@@ -91,6 +110,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p release
|
mkdir -p release
|
||||||
find ./artifacts -name "*.tar.gz" -exec cp {} ./release/ \;
|
find ./artifacts -name "*.tar.gz" -exec cp {} ./release/ \;
|
||||||
|
find ./artifacts -name "*.zip" -exec cp {} ./release/ \;
|
||||||
ls -la ./release/
|
ls -la ./release/
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
|
|||||||
198
README.md
198
README.md
@@ -9,14 +9,18 @@
|
|||||||
[](https://golang.org/)
|
[](https://golang.org/)
|
||||||
[](https://github.com/Gu1llaum-3/sshm/releases)
|
[](https://github.com/Gu1llaum-3/sshm/releases)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/Gu1llaum-3/sshm/releases)
|
[](https://github.com/Gu1llaum-3/sshm/releases)
|
||||||
|
|
||||||
> **A modern, interactive SSH Manager for your terminal** 🔥
|
> **A modern, interactive SSH Manager for your terminal** 🔥
|
||||||
|
|
||||||
SSHM is a beautiful command-line tool that transforms how you manage and connect to your SSH hosts. Built with Go and featuring an intuitive TUI interface, it makes SSH connection management effortless and enjoyable.
|
SSHM is a beautiful command-line tool that transforms how you manage and connect to your SSH hosts. Built with Go and featuring an intuitive TUI interface, it makes SSH connection management effortless and enjoyable.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="images/sshm.gif" alt="Demo SSHM Terminal" width="600" />
|
<a href="images/sshm.gif" target="_blank">
|
||||||
|
<img src="images/sshm.gif" alt="Demo SSHM Terminal" width="800" />
|
||||||
|
</a>
|
||||||
|
<br>
|
||||||
|
<em>🖱️ Click on the image to view in full size</em>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
@@ -24,10 +28,12 @@ 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
|
||||||
|
- **📁 Custom Config Support** - Use any SSH configuration file with the `-c` flag
|
||||||
- **⚙️ SSH Options Support** - Add any SSH configuration option through intuitive forms
|
- **⚙️ SSH Options Support** - Add any SSH configuration option through intuitive forms
|
||||||
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
|
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
|
||||||
|
|
||||||
@@ -35,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
|
||||||
@@ -44,19 +51,26 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
|
|||||||
### 🎮 **User Experience**
|
### 🎮 **User Experience**
|
||||||
- **Zero configuration** - Works out of the box with your existing SSH setup
|
- **Zero configuration** - Works out of the box with your existing SSH setup
|
||||||
- **Keyboard shortcuts** for power users
|
- **Keyboard shortcuts** for power users
|
||||||
- **Cross-platform** - Supports Linux and macOS (Intel & Apple Silicon)
|
- **Cross-platform** - Supports Linux, macOS (Intel & Apple Silicon), and Windows
|
||||||
- **Lightweight** - Single binary with no dependencies
|
- **Lightweight** - Single binary with no dependencies
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
**One-line install (Recommended):**
|
**Unix/Linux/macOS (One-line install):**
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh | bash
|
curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Windows (PowerShell):**
|
||||||
|
```powershell
|
||||||
|
irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex
|
||||||
|
```
|
||||||
|
|
||||||
**Alternative methods:**
|
**Alternative methods:**
|
||||||
|
|
||||||
|
*Linux/macOS:*
|
||||||
```bash
|
```bash
|
||||||
# Download specific release
|
# Download specific release
|
||||||
wget https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-amd64.tar.gz
|
wget https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-amd64.tar.gz
|
||||||
@@ -66,6 +80,14 @@ tar -xzf sshm-linux-amd64.tar.gz
|
|||||||
sudo mv sshm-linux-amd64 /usr/local/bin/sshm
|
sudo mv sshm-linux-amd64 /usr/local/bin/sshm
|
||||||
```
|
```
|
||||||
|
|
||||||
|
*Windows:*
|
||||||
|
```powershell
|
||||||
|
# Download and extract
|
||||||
|
Invoke-WebRequest -Uri "https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-windows-amd64.zip" -OutFile "sshm-windows-amd64.zip"
|
||||||
|
Expand-Archive sshm-windows-amd64.zip -DestinationPath C:\tools\
|
||||||
|
# Add C:\tools to your PATH environment variable
|
||||||
|
```
|
||||||
|
|
||||||
## 📖 Usage
|
## 📖 Usage
|
||||||
|
|
||||||
### Interactive Mode
|
### Interactive Mode
|
||||||
@@ -82,9 +104,18 @@ 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
|
||||||
|
|
||||||
|
**Sorting & Filtering:**
|
||||||
|
- `s` - Switch between sorting modes (name ↔ last login)
|
||||||
|
- `n` - Sort by **name** (alphabetical)
|
||||||
|
- `r` - Sort by **recent** (last login time)
|
||||||
|
- `Tab` - Cycle between filtering modes
|
||||||
|
- Filter by **name** (default) - Search through host names
|
||||||
|
- Filter by **last login** - Sort and filter by most recently used connections
|
||||||
|
|
||||||
The interactive forms will guide you through configuration:
|
The interactive forms will guide you through configuration:
|
||||||
- **Hostname/IP** - Server address
|
- **Hostname/IP** - Server address
|
||||||
- **Username** - SSH user
|
- **Username** - SSH user
|
||||||
@@ -94,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:
|
||||||
@@ -102,15 +217,24 @@ SSHM provides both command-line operations and an interactive TUI interface:
|
|||||||
# Launch interactive TUI mode for browsing and connecting to hosts
|
# Launch interactive TUI mode for browsing and connecting to hosts
|
||||||
sshm
|
sshm
|
||||||
|
|
||||||
|
# Launch TUI with custom SSH config file
|
||||||
|
sshm -c /path/to/custom/ssh_config
|
||||||
|
|
||||||
# Add a new host using interactive form
|
# Add a new host using interactive form
|
||||||
sshm add
|
sshm add
|
||||||
|
|
||||||
# Add a new host with pre-filled hostname
|
# Add a new host with pre-filled hostname
|
||||||
sshm add hostname
|
sshm add hostname
|
||||||
|
|
||||||
|
# Add a new host with custom SSH config file
|
||||||
|
sshm add hostname -c /path/to/custom/ssh_config
|
||||||
|
|
||||||
# Edit an existing host configuration
|
# Edit an existing host configuration
|
||||||
sshm edit my-server
|
sshm edit my-server
|
||||||
|
|
||||||
|
# Edit host with custom SSH config file
|
||||||
|
sshm edit my-server -c /path/to/custom/ssh_config
|
||||||
|
|
||||||
# Show version information
|
# Show version information
|
||||||
sshm --version
|
sshm --version
|
||||||
|
|
||||||
@@ -118,6 +242,32 @@ sshm --version
|
|||||||
sshm --help
|
sshm --help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Configuration File Options
|
||||||
|
|
||||||
|
By default, SSHM uses the standard SSH configuration file at `~/.ssh/config`. You can specify a different configuration file using the `-c` flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use custom config file in TUI mode
|
||||||
|
sshm -c /path/to/custom/ssh_config
|
||||||
|
|
||||||
|
# Use custom config file with commands
|
||||||
|
sshm add hostname -c /path/to/custom/ssh_config
|
||||||
|
sshm edit hostname -c /path/to/custom/ssh_config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Platform-Specific Notes
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
- SSHM works with the built-in OpenSSH client (Windows 10/11)
|
||||||
|
- Configuration file location: `%USERPROFILE%\.ssh\config`
|
||||||
|
- Compatible with WSL SSH configurations
|
||||||
|
- Supports the same SSH options as Unix systems
|
||||||
|
|
||||||
|
**Unix/Linux/macOS:**
|
||||||
|
- Standard SSH configuration file: `~/.ssh/config`
|
||||||
|
- Full compatibility with OpenSSH features
|
||||||
|
- Preserves file permissions automatically
|
||||||
|
|
||||||
## 🏗️ Configuration
|
## 🏗️ Configuration
|
||||||
|
|
||||||
SSHM works directly with your standard SSH configuration file (`~/.ssh/config`). It adds special comment tags for enhanced functionality while maintaining full compatibility with standard SSH tools.
|
SSHM works directly with your standard SSH configuration file (`~/.ssh/config`). It adds special comment tags for enhanced functionality while maintaining full compatibility with standard SSH tools.
|
||||||
@@ -222,24 +372,44 @@ go build -o sshm .
|
|||||||
|
|
||||||
```
|
```
|
||||||
sshm/
|
sshm/
|
||||||
├── cmd/ # CLI commands (Cobra)
|
├── main.go # Application entry point
|
||||||
|
├── cmd/ # CLI commands (Cobra)
|
||||||
│ ├── root.go # Root command and interactive mode
|
│ ├── root.go # Root command and interactive mode
|
||||||
│ ├── add.go # Add host command
|
│ ├── add.go # Add host command
|
||||||
│ └── edit.go # Edit host command
|
│ ├── edit.go # Edit host command
|
||||||
|
│ └── search.go # Search command
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── config/ # SSH configuration management
|
│ ├── config/ # SSH configuration management
|
||||||
│ │ └── ssh.go # Config parsing and manipulation
|
│ │ └── ssh.go # Config parsing and manipulation
|
||||||
│ ├── ui/ # Terminal UI components
|
│ ├── history/ # Connection history tracking
|
||||||
│ │ ├── tui.go # Main TUI interface
|
│ │ └── history.go # History management and last login tracking
|
||||||
│ │ ├── add_form.go # Add host form
|
│ ├── ui/ # Terminal UI components (Bubble Tea)
|
||||||
│ │ └── edit_form.go# Edit host form
|
│ │ ├── tui.go # Main TUI interface and program setup
|
||||||
|
│ │ ├── model.go # Core TUI model and state
|
||||||
|
│ │ ├── update.go # Message handling and state updates
|
||||||
|
│ │ ├── view.go # UI rendering and layout
|
||||||
|
│ │ ├── table.go # Host list table component
|
||||||
|
│ │ ├── add_form.go # Add host form interface
|
||||||
|
│ │ ├── edit_form.go# Edit host form interface
|
||||||
|
│ │ ├── styles.go # Lip Gloss styling definitions
|
||||||
|
│ │ ├── sort.go # Sorting and filtering logic
|
||||||
|
│ │ └── utils.go # UI utility functions
|
||||||
│ └── validation/ # Input validation
|
│ └── validation/ # Input validation
|
||||||
│ └── ssh.go # SSH config validation
|
│ └── ssh.go # SSH config validation
|
||||||
|
├── images/ # Documentation assets
|
||||||
|
│ ├── logo.png # Project logo
|
||||||
|
│ └── sshm.gif # Demo animation
|
||||||
├── install/ # Installation scripts
|
├── install/ # Installation scripts
|
||||||
│ ├── unix.sh # Unix/Linux/macOS installer
|
│ ├── unix.sh # Unix/Linux/macOS installer
|
||||||
│ └── README.md # Installation guide
|
│ └── README.md # Installation guide
|
||||||
└── .github/workflows/ # CI/CD pipelines
|
├── .github/ # GitHub configuration
|
||||||
└── build.yml # Multi-platform builds
|
│ ├── copilot-instructions.md # Development guidelines
|
||||||
|
│ └── workflows/ # CI/CD pipelines
|
||||||
|
│ └── build.yml # Multi-platform builds
|
||||||
|
├── go.mod # Go module definition
|
||||||
|
├── go.sum # Go module checksums
|
||||||
|
├── LICENSE # MIT license
|
||||||
|
└── README.md # Project documentation
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
@@ -259,6 +429,8 @@ Automated releases are built for multiple platforms:
|
|||||||
| Linux | ARM64 | [sshm-linux-arm64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-arm64.tar.gz) |
|
| Linux | ARM64 | [sshm-linux-arm64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-arm64.tar.gz) |
|
||||||
| macOS | Intel | [sshm-darwin-amd64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-darwin-amd64.tar.gz) |
|
| macOS | Intel | [sshm-darwin-amd64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-darwin-amd64.tar.gz) |
|
||||||
| macOS | Apple Silicon | [sshm-darwin-arm64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-darwin-arm64.tar.gz) |
|
| macOS | Apple Silicon | [sshm-darwin-arm64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-darwin-arm64.tar.gz) |
|
||||||
|
| Windows | AMD64 | [sshm-windows-amd64.zip](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-windows-amd64.zip) |
|
||||||
|
| Windows | ARM64 | [sshm-windows-arm64.zip](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-windows-arm64.zip) |
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
|||||||
11
cmd/root.go
11
cmd/root.go
@@ -21,9 +21,14 @@ var configFile string
|
|||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "sshm",
|
Use: "sshm",
|
||||||
Short: "SSH Manager - A modern SSH connection manager",
|
Short: "SSH Manager - A modern SSH connection manager",
|
||||||
Long: `SSH Manager (sshm) is a modern command-line tool for managing SSH connections.
|
Long: `SSHM is a modern SSH manager for your terminal.
|
||||||
It provides an interactive interface to browse and connect to your SSH hosts
|
|
||||||
configured in your ~/.ssh/config file.`,
|
Main usage:
|
||||||
|
Running 'sshm' (without arguments) opens the interactive TUI window to browse, search, and connect to your SSH hosts graphically.
|
||||||
|
|
||||||
|
You can also use sshm in CLI mode for direct operations.
|
||||||
|
|
||||||
|
Hosts are read from your ~/.ssh/config file by default.`,
|
||||||
Version: version,
|
Version: version,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
// If no arguments provided, run interactive mode
|
// If no arguments provided, run interactive mode
|
||||||
|
|||||||
BIN
images/sshm.gif
BIN
images/sshm.gif
Binary file not shown.
|
Before Width: | Height: | Size: 615 KiB After Width: | Height: | Size: 797 KiB |
@@ -12,8 +12,28 @@ curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh
|
|||||||
|
|
||||||
**Note:** When using the pipe method, the installer will automatically proceed with installation if SSHM is already installed.
|
**Note:** When using the pipe method, the installer will automatically proceed with installation if SSHM is already installed.
|
||||||
|
|
||||||
|
## Windows Installation
|
||||||
|
|
||||||
|
### Quick Install (Recommended)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex
|
||||||
|
```
|
||||||
|
|
||||||
### Install Options
|
### Install Options
|
||||||
|
|
||||||
|
**Force install without prompts:**
|
||||||
|
```powershell
|
||||||
|
iex "& { $(irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1) } -Force"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Custom installation directory:**
|
||||||
|
```powershell
|
||||||
|
iex "& { $(irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1) } -InstallDir 'C:\tools'"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unix/Linux/macOS Advanced Options
|
||||||
|
|
||||||
**Force install without prompts:**
|
**Force install without prompts:**
|
||||||
```bash
|
```bash
|
||||||
FORCE_INSTALL=true bash -c "$(curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh)"
|
FORCE_INSTALL=true bash -c "$(curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh)"
|
||||||
|
|||||||
135
install/windows.ps1
Normal file
135
install/windows.ps1
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# SSHM Windows Installation Script
|
||||||
|
# Usage:
|
||||||
|
# Online: irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex
|
||||||
|
# Local: .\install\windows.ps1 -LocalBinary ".\sshm.exe"
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$InstallDir = "$env:LOCALAPPDATA\sshm",
|
||||||
|
[switch]$Force = $false,
|
||||||
|
[string]$LocalBinary = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
function Write-ColorOutput($ForegroundColor) {
|
||||||
|
$fc = $host.UI.RawUI.ForegroundColor
|
||||||
|
$host.UI.RawUI.ForegroundColor = $ForegroundColor
|
||||||
|
if ($args) {
|
||||||
|
Write-Output $args
|
||||||
|
}
|
||||||
|
$host.UI.RawUI.ForegroundColor = $fc
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Info { Write-ColorOutput Green $args }
|
||||||
|
function Write-Warning { Write-ColorOutput Yellow $args }
|
||||||
|
function Write-Error { Write-ColorOutput Red $args }
|
||||||
|
|
||||||
|
Write-Info "🚀 Installing SSHM - SSH Manager"
|
||||||
|
Write-Info ""
|
||||||
|
|
||||||
|
# Check if SSHM is already installed
|
||||||
|
$existingSSHM = Get-Command sshm -ErrorAction SilentlyContinue
|
||||||
|
if ($existingSSHM -and -not $Force) {
|
||||||
|
$currentVersion = & sshm --version 2>$null | Select-String "version" | ForEach-Object { $_.ToString().Split()[-1] }
|
||||||
|
Write-Warning "SSHM is already installed (version: $currentVersion)"
|
||||||
|
$response = Read-Host "Do you want to continue with the installation? (y/N)"
|
||||||
|
if ($response -ne "y" -and $response -ne "Y") {
|
||||||
|
Write-Info "Installation cancelled."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect architecture
|
||||||
|
$arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "386" }
|
||||||
|
Write-Info "Detected platform: Windows ($arch)"
|
||||||
|
|
||||||
|
# Check if using local binary
|
||||||
|
if ($LocalBinary -ne "") {
|
||||||
|
if (-not (Test-Path $LocalBinary)) {
|
||||||
|
Write-Error "Local binary not found: $LocalBinary"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Info "Using local binary: $LocalBinary"
|
||||||
|
$targetPath = "$InstallDir\sshm.exe"
|
||||||
|
|
||||||
|
# Create installation directory
|
||||||
|
if (-not (Test-Path $InstallDir)) {
|
||||||
|
Write-Info "Creating installation directory: $InstallDir"
|
||||||
|
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Copy local binary
|
||||||
|
Write-Info "Installing binary to: $targetPath"
|
||||||
|
Copy-Item -Path $LocalBinary -Destination $targetPath -Force
|
||||||
|
|
||||||
|
} else {
|
||||||
|
# Online installation
|
||||||
|
Write-Info "Starting online installation..."
|
||||||
|
|
||||||
|
# Get latest version
|
||||||
|
Write-Info "Fetching latest version..."
|
||||||
|
try {
|
||||||
|
$latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest"
|
||||||
|
$latestVersion = $latestRelease.tag_name
|
||||||
|
Write-Info "Target version: $latestVersion"
|
||||||
|
} catch {
|
||||||
|
Write-Error "Failed to fetch version information"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Download binary
|
||||||
|
$fileName = "sshm-windows-$arch.zip"
|
||||||
|
$downloadUrl = "https://github.com/Gu1llaum-3/sshm/releases/download/$latestVersion/$fileName"
|
||||||
|
$tempFile = "$env:TEMP\$fileName"
|
||||||
|
|
||||||
|
Write-Info "Downloading $fileName..."
|
||||||
|
try {
|
||||||
|
Invoke-WebRequest -Uri $downloadUrl -OutFile $tempFile
|
||||||
|
} catch {
|
||||||
|
Write-Error "Download failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create installation directory
|
||||||
|
if (-not (Test-Path $InstallDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract archive
|
||||||
|
Write-Info "Extracting..."
|
||||||
|
try {
|
||||||
|
Expand-Archive -Path $tempFile -DestinationPath $env:TEMP -Force
|
||||||
|
$extractedBinary = "$env:TEMP\sshm-windows-$arch.exe"
|
||||||
|
$targetPath = "$InstallDir\sshm.exe"
|
||||||
|
|
||||||
|
Move-Item -Path $extractedBinary -Destination $targetPath -Force
|
||||||
|
} catch {
|
||||||
|
Write-Error "Extraction failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
Remove-Item $tempFile -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check PATH
|
||||||
|
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
||||||
|
if ($userPath -notlike "*$InstallDir*") {
|
||||||
|
Write-Warning "The directory $InstallDir is not in your PATH."
|
||||||
|
Write-Info "Adding to user PATH..."
|
||||||
|
[Environment]::SetEnvironmentVariable("Path", "$userPath;$InstallDir", "User")
|
||||||
|
Write-Info "Please restart your terminal to use the 'sshm' command."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Info ""
|
||||||
|
Write-Info "✅ SSHM successfully installed to: $targetPath"
|
||||||
|
Write-Info "You can now use the 'sshm' command!"
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
if (Test-Path $targetPath) {
|
||||||
|
Write-Info ""
|
||||||
|
Write-Info "Verifying installation..."
|
||||||
|
& $targetPath --version
|
||||||
|
}
|
||||||
11
internal/config/permissions_unix.go
Normal file
11
internal/config/permissions_unix.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// SetSecureFilePermissions configures secure permissions on Unix systems
|
||||||
|
func SetSecureFilePermissions(filepath string) error {
|
||||||
|
// Set file permissions to 0600 (owner read/write only)
|
||||||
|
return os.Chmod(filepath, 0600)
|
||||||
|
}
|
||||||
24
internal/config/permissions_windows.go
Normal file
24
internal/config/permissions_windows.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetSecureFilePermissions configures secure permissions on Windows
|
||||||
|
func SetSecureFilePermissions(filepath string) error {
|
||||||
|
// On Windows, file permissions work differently
|
||||||
|
// We ensure the file is not read-only and has basic permissions
|
||||||
|
info, err := os.Stat(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the file is not read-only
|
||||||
|
if info.Mode()&os.ModeType == 0 {
|
||||||
|
return os.Chmod(filepath, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -22,6 +23,46 @@ type SSHHost struct {
|
|||||||
Tags []string
|
Tags []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDefaultSSHConfigPath returns the default SSH config path for the current platform
|
||||||
|
func GetDefaultSSHConfigPath() (string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
return filepath.Join(homeDir, ".ssh", "config"), nil
|
||||||
|
default:
|
||||||
|
// Linux, macOS, etc.
|
||||||
|
return filepath.Join(homeDir, ".ssh", "config"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSSHDirectory returns the .ssh directory path
|
||||||
|
func GetSSHDirectory() (string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(homeDir, ".ssh"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSSHDirectory creates the .ssh directory with appropriate permissions
|
||||||
|
func ensureSSHDirectory() error {
|
||||||
|
sshDir, err := GetSSHDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(sshDir); os.IsNotExist(err) {
|
||||||
|
// 0700 provides owner-only access across platforms
|
||||||
|
return os.MkdirAll(sshDir, 0700)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// configMutex protects SSH config file operations from race conditions
|
// configMutex protects SSH config file operations from race conditions
|
||||||
var configMutex sync.Mutex
|
var configMutex sync.Mutex
|
||||||
|
|
||||||
@@ -46,12 +87,10 @@ func backupConfig(configPath string) error {
|
|||||||
|
|
||||||
// ParseSSHConfig parses the SSH config file and returns the list of hosts
|
// ParseSSHConfig parses the SSH config file and returns the list of hosts
|
||||||
func ParseSSHConfig() ([]SSHHost, error) {
|
func ParseSSHConfig() ([]SSHHost, error) {
|
||||||
homeDir, err := os.UserHomeDir()
|
configPath, err := GetDefaultSSHConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
|
||||||
return ParseSSHConfigFile(configPath)
|
return ParseSSHConfigFile(configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,18 +98,22 @@ func ParseSSHConfig() ([]SSHHost, error) {
|
|||||||
func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
||||||
// Check if the file exists, otherwise create it (and the parent directory if needed)
|
// Check if the file exists, otherwise create it (and the parent directory if needed)
|
||||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
dir := filepath.Dir(configPath)
|
// Ensure .ssh directory exists with proper permissions
|
||||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
if err := ensureSSHDirectory(); err != nil {
|
||||||
err = os.MkdirAll(dir, 0700)
|
return nil, fmt.Errorf("failed to create .ssh directory: %w", err)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create .ssh directory: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY, 0600)
|
file, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create SSH config file: %w", err)
|
return nil, fmt.Errorf("failed to create SSH config file: %w", err)
|
||||||
}
|
}
|
||||||
file.Close()
|
file.Close()
|
||||||
|
|
||||||
|
// Set secure permissions on the config file
|
||||||
|
if err := SetSecureFilePermissions(configPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set secure permissions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// File created, return empty host list
|
// File created, return empty host list
|
||||||
return []SSHHost{}, nil
|
return []SSHHost{}, nil
|
||||||
}
|
}
|
||||||
@@ -181,11 +224,10 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
|||||||
|
|
||||||
// AddSSHHost adds a new SSH host to the config file
|
// AddSSHHost adds a new SSH host to the config file
|
||||||
func AddSSHHost(host SSHHost) error {
|
func AddSSHHost(host SSHHost) error {
|
||||||
homeDir, err := os.UserHomeDir()
|
configPath, err := GetDefaultSSHConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
|
||||||
return AddSSHHostToFile(host, configPath)
|
return AddSSHHostToFile(host, configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,11 +446,10 @@ func GetSSHHostFromFile(hostName string, configPath string) (*SSHHost, error) {
|
|||||||
|
|
||||||
// UpdateSSHHost updates an existing SSH host configuration
|
// UpdateSSHHost updates an existing SSH host configuration
|
||||||
func UpdateSSHHost(oldName string, newHost SSHHost) error {
|
func UpdateSSHHost(oldName string, newHost SSHHost) error {
|
||||||
homeDir, err := os.UserHomeDir()
|
configPath, err := GetDefaultSSHConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
|
||||||
return UpdateSSHHostInFile(oldName, newHost, configPath)
|
return UpdateSSHHostInFile(oldName, newHost, configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,11 +605,10 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
|
|||||||
|
|
||||||
// DeleteSSHHost removes an SSH host configuration from the config file
|
// DeleteSSHHost removes an SSH host configuration from the config file
|
||||||
func DeleteSSHHost(hostName string) error {
|
func DeleteSSHHost(hostName string) error {
|
||||||
homeDir, err := os.UserHomeDir()
|
configPath, err := GetDefaultSSHConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
|
||||||
return DeleteSSHHostFromFile(hostName, configPath)
|
return DeleteSSHHostFromFile(hostName, configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
73
internal/config/ssh_test.go
Normal file
73
internal/config/ssh_test.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetDefaultSSHConfigPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
goos string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"Linux", "linux", ".ssh/config"},
|
||||||
|
{"macOS", "darwin", ".ssh/config"},
|
||||||
|
{"Windows", "windows", ".ssh/config"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Save original GOOS
|
||||||
|
originalGOOS := runtime.GOOS
|
||||||
|
defer func() {
|
||||||
|
// Note: We can't actually change runtime.GOOS at runtime
|
||||||
|
// This test verifies the function logic with the current OS
|
||||||
|
_ = originalGOOS
|
||||||
|
}()
|
||||||
|
|
||||||
|
configPath, err := GetDefaultSSHConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDefaultSSHConfigPath() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(configPath, tt.expected) {
|
||||||
|
t.Errorf("Expected path to end with %q, got %q", tt.expected, configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the path uses the correct separator for current OS
|
||||||
|
expectedSeparator := string(filepath.Separator)
|
||||||
|
if !strings.Contains(configPath, expectedSeparator) && len(configPath) > len(tt.expected) {
|
||||||
|
t.Errorf("Path should use OS-specific separator %q, got %q", expectedSeparator, configPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSSHDirectory(t *testing.T) {
|
||||||
|
sshDir, err := GetSSHDirectory()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSSHDirectory() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(sshDir, ".ssh") {
|
||||||
|
t.Errorf("Expected directory to end with .ssh, got %q", sshDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the path uses the correct separator for current OS
|
||||||
|
expectedSeparator := string(filepath.Separator)
|
||||||
|
if !strings.Contains(sshDir, expectedSeparator) && len(sshDir) > 4 {
|
||||||
|
t.Errorf("Path should use OS-specific separator %q, got %q", expectedSeparator, sshDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureSSHDirectory(t *testing.T) {
|
||||||
|
// This test just ensures the function doesn't panic
|
||||||
|
// and returns without error when .ssh directory already exists
|
||||||
|
err := ensureSSHDirectory()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ensureSSHDirectory() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,4 +130,119 @@ func (m *Model) updateTableRows() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.table.SetRows(rows)
|
m.table.SetRows(rows)
|
||||||
|
|
||||||
|
// Update table height and columns based on current terminal size
|
||||||
|
m.updateTableHeight()
|
||||||
|
m.updateTableColumns()
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTableHeight dynamically adjusts table height based on terminal size
|
||||||
|
func (m *Model) updateTableHeight() {
|
||||||
|
if !m.ready {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate dynamic table height based on terminal size
|
||||||
|
// Layout breakdown:
|
||||||
|
// - ASCII title: 5 lines (1 empty + 4 text lines)
|
||||||
|
// - Search bar: 1 line
|
||||||
|
// - Sort info: 1 line
|
||||||
|
// - Help text: 2 lines (multi-line text)
|
||||||
|
// - App margins/spacing: 2 lines
|
||||||
|
// Total reserved: 11 lines, mais réduisons à 7 pour forcer plus d'espace
|
||||||
|
reservedHeight := 7 // Réduction agressive pour tester
|
||||||
|
availableHeight := m.height - reservedHeight
|
||||||
|
hostCount := len(m.table.Rows())
|
||||||
|
|
||||||
|
// Minimum height should be at least 5 rows for usability
|
||||||
|
minTableHeight := 6 // 1 header + 5 data rows
|
||||||
|
maxTableHeight := availableHeight
|
||||||
|
if maxTableHeight < minTableHeight {
|
||||||
|
maxTableHeight = minTableHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
tableHeight := 1 // header
|
||||||
|
dataRowsNeeded := hostCount
|
||||||
|
maxDataRows := maxTableHeight - 1 // subtract 1 for header
|
||||||
|
|
||||||
|
if dataRowsNeeded <= maxDataRows {
|
||||||
|
// We have enough space for all hosts
|
||||||
|
tableHeight += dataRowsNeeded
|
||||||
|
} else {
|
||||||
|
// We need to limit to available space
|
||||||
|
tableHeight += maxDataRows
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORCE: Ajoutons une ligne supplémentaire pour résoudre le problème
|
||||||
|
tableHeight += 1
|
||||||
|
|
||||||
|
// Update table height
|
||||||
|
m.table.SetHeight(tableHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTableColumns dynamically adjusts table column widths based on terminal size
|
||||||
|
func (m *Model) updateTableColumns() {
|
||||||
|
if !m.ready {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hostsToShow := m.filteredHosts
|
||||||
|
if hostsToShow == nil {
|
||||||
|
hostsToShow = m.hosts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate base column widths
|
||||||
|
nameWidth := calculateNameColumnWidth(hostsToShow)
|
||||||
|
tagsWidth := calculateTagsColumnWidth(hostsToShow)
|
||||||
|
lastLoginWidth := calculateLastLoginColumnWidth(hostsToShow, m.historyManager)
|
||||||
|
|
||||||
|
// Fixed column widths
|
||||||
|
hostnameWidth := 25
|
||||||
|
userWidth := 12
|
||||||
|
portWidth := 6
|
||||||
|
|
||||||
|
// Calculate total width needed for all columns
|
||||||
|
totalFixedWidth := hostnameWidth + userWidth + portWidth
|
||||||
|
totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth
|
||||||
|
totalWidth := totalFixedWidth + totalVariableWidth
|
||||||
|
|
||||||
|
// Available width (accounting for table borders and padding)
|
||||||
|
availableWidth := m.width - 4 // 4 chars for borders and padding
|
||||||
|
|
||||||
|
// If the table is too wide, scale down the variable columns proportionally
|
||||||
|
if totalWidth > availableWidth {
|
||||||
|
excessWidth := totalWidth - availableWidth
|
||||||
|
variableColumnsWidth := totalVariableWidth
|
||||||
|
|
||||||
|
if variableColumnsWidth > 0 {
|
||||||
|
// Reduce variable columns proportionally
|
||||||
|
nameReduction := (excessWidth * nameWidth) / variableColumnsWidth
|
||||||
|
tagsReduction := (excessWidth * tagsWidth) / variableColumnsWidth
|
||||||
|
lastLoginReduction := excessWidth - nameReduction - tagsReduction
|
||||||
|
|
||||||
|
nameWidth = max(8, nameWidth-nameReduction)
|
||||||
|
tagsWidth = max(8, tagsWidth-tagsReduction)
|
||||||
|
lastLoginWidth = max(10, lastLoginWidth-lastLoginReduction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new columns with updated widths
|
||||||
|
columns := []table.Column{
|
||||||
|
{Title: "Name", Width: nameWidth},
|
||||||
|
{Title: "Hostname", Width: hostnameWidth},
|
||||||
|
{Title: "User", Width: userWidth},
|
||||||
|
{Title: "Port", Width: portWidth},
|
||||||
|
{Title: "Tags", Width: tagsWidth},
|
||||||
|
{Title: "Last Login", Width: lastLoginWidth},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.table.SetColumns(columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// max returns the maximum of two integers
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,21 +99,12 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine table height: 1 (header) + number of hosts (max 10)
|
// Create the table with initial height (will be updated on first WindowSizeMsg)
|
||||||
hostCount := len(rows)
|
|
||||||
tableHeight := 1 // header
|
|
||||||
if hostCount < 10 {
|
|
||||||
tableHeight += hostCount
|
|
||||||
} else {
|
|
||||||
tableHeight += 10
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the table
|
|
||||||
t := table.New(
|
t := table.New(
|
||||||
table.WithColumns(columns),
|
table.WithColumns(columns),
|
||||||
table.WithRows(rows),
|
table.WithRows(rows),
|
||||||
table.WithFocused(true),
|
table.WithFocused(true),
|
||||||
table.WithHeight(tableHeight),
|
table.WithHeight(10), // Initial height, will be recalculated dynamically
|
||||||
)
|
)
|
||||||
|
|
||||||
// Style the table
|
// Style the table
|
||||||
@@ -135,6 +126,9 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
|||||||
// Initialize table styles based on initial focus state
|
// Initialize table styles based on initial focus state
|
||||||
m.updateTableStyles()
|
m.updateTableStyles()
|
||||||
|
|
||||||
|
// The table height will be properly set on the first WindowSizeMsg
|
||||||
|
// when m.ready becomes true and actual terminal dimensions are known
|
||||||
|
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.styles = NewStyles(m.width)
|
m.styles = NewStyles(m.width)
|
||||||
m.ready = true
|
m.ready = true
|
||||||
|
|
||||||
|
// Update table height and columns based on new window size
|
||||||
|
m.updateTableHeight()
|
||||||
|
m.updateTableColumns()
|
||||||
|
|
||||||
// Update sub-forms if they exist
|
// Update sub-forms if they exist
|
||||||
if m.addForm != nil {
|
if m.addForm != nil {
|
||||||
m.addForm.width = m.width
|
m.addForm.width = m.width
|
||||||
@@ -42,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:
|
||||||
@@ -66,7 +75,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
m.hosts = m.sortHosts(hosts)
|
m.hosts = m.sortHosts(hosts)
|
||||||
m.filteredHosts = m.hosts
|
|
||||||
|
// Reapply search filter if there is one active
|
||||||
|
if m.searchInput.Value() != "" {
|
||||||
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||||
|
} else {
|
||||||
|
m.filteredHosts = m.hosts
|
||||||
|
}
|
||||||
|
|
||||||
m.updateTableRows()
|
m.updateTableRows()
|
||||||
m.viewMode = ViewList
|
m.viewMode = ViewList
|
||||||
m.addForm = nil
|
m.addForm = nil
|
||||||
@@ -103,7 +119,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
m.hosts = m.sortHosts(hosts)
|
m.hosts = m.sortHosts(hosts)
|
||||||
m.filteredHosts = m.hosts
|
|
||||||
|
// Reapply search filter if there is one active
|
||||||
|
if m.searchInput.Value() != "" {
|
||||||
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||||
|
} else {
|
||||||
|
m.filteredHosts = m.hosts
|
||||||
|
}
|
||||||
|
|
||||||
m.updateTableRows()
|
m.updateTableRows()
|
||||||
m.viewMode = ViewList
|
m.viewMode = ViewList
|
||||||
m.editForm = nil
|
m.editForm = nil
|
||||||
@@ -118,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 {
|
||||||
@@ -135,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)
|
||||||
@@ -230,7 +299,14 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.hosts = m.sortHosts(hosts)
|
m.hosts = m.sortHosts(hosts)
|
||||||
m.filteredHosts = m.hosts
|
|
||||||
|
// Reapply search filter if there is one active
|
||||||
|
if m.searchInput.Value() != "" {
|
||||||
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||||
|
} else {
|
||||||
|
m.filteredHosts = m.hosts
|
||||||
|
}
|
||||||
|
|
||||||
m.updateTableRows()
|
m.updateTableRows()
|
||||||
m.deleteMode = false
|
m.deleteMode = false
|
||||||
m.deleteHost = ""
|
m.deleteHost = ""
|
||||||
@@ -299,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()
|
||||||
}
|
}
|
||||||
@@ -39,7 +43,7 @@ func (m Model) renderListView() string {
|
|||||||
components = append(components, m.styles.Header.Render(asciiTitle))
|
components = append(components, m.styles.Header.Render(asciiTitle))
|
||||||
|
|
||||||
// Add the search bar with the appropriate style based on focus
|
// Add the search bar with the appropriate style based on focus
|
||||||
searchPrompt := "Search (/ to focus, Tab to switch): "
|
searchPrompt := "Search (/ to focus): "
|
||||||
if m.searchMode {
|
if m.searchMode {
|
||||||
components = append(components, m.styles.SearchFocused.Render(searchPrompt+m.searchInput.View()))
|
components = append(components, m.styles.SearchFocused.Render(searchPrompt+m.searchInput.View()))
|
||||||
} else {
|
} else {
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user