sshkeeper: complete port forwarding UX redesign
- Forward form: type selector (Local/Remote/SOCKS) with radio items - Dynamic fields: listen addr/port, target addr/port based on type - Default listen: 127.0.0.1, warning for 0.0.0.0 - Forward list: table view NAME/TYPE/LISTEN/TARGET/ENABLED - Forward edit: Enter/Ctrl+E opens pre-filled edit form - Human explanation and OpenSSH preview for selected forward - Tunnel state manager: PID tracking, start/stop, state file - Tunnel manager screen: list running tunnels, stop, refresh - Action menu: Connect/Connect with tunnels/Start tunnels only/Start tunnels in background/Manage port forwards/Manage tunnels/Manage route/Test/Edit/Delete - Help screen: updated shortcuts - CLI: tunnel --background for detached tunnel process - README: updated with forward vs tunnel examples, new hotkeys
This commit is contained in:
parent
4726a6874c
commit
6cf281c349
89
README.md
89
README.md
|
|
@ -22,8 +22,9 @@ port forwarding management.
|
||||||
secrets in command-line arguments.
|
secrets in command-line arguments.
|
||||||
- Key, SSH-agent, password, and key+passphrase auth modes.
|
- Key, SSH-agent, password, and key+passphrase auth modes.
|
||||||
- **Routes / ProxyJump** — manage bastion hosts and jump chains with human-readable display.
|
- **Routes / ProxyJump** — manage bastion hosts and jump chains with human-readable display.
|
||||||
- **Port forwarding** — local, remote, and dynamic (SOCKS) forwards with OpenSSH preview.
|
- **Port forwarding** — named local/remote/SOCKS forwards with type selector, validation, and OpenSSH preview.
|
||||||
- **Tunnel mode** — `ssh -N` for forward-only sessions.
|
- **Tunnel management** — start/stop/restart tunnels, PID tracking, background tunnels, runtime state.
|
||||||
|
- **Tunnel vs Forward** — clear separation: forward = saved rule, tunnel = running SSH process.
|
||||||
- Groups, tags, command templates, search, and OpenSSH config generation.
|
- Groups, tags, command templates, search, and OpenSSH config generation.
|
||||||
- Import from `~/.ssh/config`.
|
- Import from `~/.ssh/config`.
|
||||||
|
|
||||||
|
|
@ -125,44 +126,62 @@ sshkeeper route show prod
|
||||||
# ProxyJump: bastion,dmz-gw
|
# ProxyJump: bastion,dmz-gw
|
||||||
```
|
```
|
||||||
|
|
||||||
### Local port forward
|
### Port forwards
|
||||||
|
|
||||||
|
A **port forward** is a saved rule that describes how to tunnel traffic through SSH.
|
||||||
|
It does not start any process — it is just configuration.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sshkeeper forward add web --type local --local-port 8080 --remote-addr internal.web --remote-port 80
|
# Local forward: access a remote service from your machine
|
||||||
|
sshkeeper forward add web --name "Local PostgreSQL" --type local --local-port 15432 --remote-addr 127.0.0.1 --remote-port 5432
|
||||||
|
|
||||||
|
# SOCKS proxy: route browser traffic through SSH server
|
||||||
|
sshkeeper forward add bastion --name "SOCKS Proxy" --type dynamic --local-port 1080
|
||||||
|
|
||||||
|
# List forwards for a server
|
||||||
sshkeeper forward list web
|
sshkeeper forward list web
|
||||||
# [1] -L 0.0.0.0:8080:internal.web:80
|
# [1] Local PostgreSQL Local 127.0.0.1:15432 127.0.0.1:5432 yes
|
||||||
|
# [2] SOCKS Proxy SOCKS 127.0.0.1:1080 SOCKS yes
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dynamic SOCKS proxy
|
Forward types:
|
||||||
|
- **Local** — port on your machine → service reachable from SSH server
|
||||||
```bash
|
- **Remote** — port on SSH server → service on your machine
|
||||||
sshkeeper forward add bastion --type dynamic --local-port 1080
|
- **SOCKS** — local dynamic SOCKS proxy through SSH
|
||||||
sshkeeper forward list bastion
|
|
||||||
# [1] -D 0.0.0.0:1080
|
Default listen address is `127.0.0.1` (localhost only). Use `0.0.0.0` with caution — the port will be accessible from the network.
|
||||||
```
|
|
||||||
|
### Tunnels
|
||||||
### Forward-only tunnel (ssh -N)
|
|
||||||
|
A **tunnel** is a running SSH process that activates one or more port forwards.
|
||||||
```bash
|
|
||||||
sshkeeper tunnel web --forward-only
|
|
||||||
# Starting tunnel to web with 1 forward(s)...
|
|
||||||
# Tunnel mode (ssh -N). Press Ctrl+C to exit.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Session with forwards
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Connect with all enabled forwards active (interactive session)
|
||||||
sshkeeper tunnel web
|
sshkeeper tunnel web
|
||||||
# Starts SSH session with all configured forwards active.
|
|
||||||
|
# Start tunnels only (foreground, no shell)
|
||||||
|
sshkeeper tunnel web --forward-only
|
||||||
|
|
||||||
|
# Start tunnels in background (detached process)
|
||||||
|
sshkeeper tunnel web --background
|
||||||
|
|
||||||
|
# List running tunnels
|
||||||
|
sshkeeper tunnel list
|
||||||
|
|
||||||
|
# Stop a tunnel
|
||||||
|
sshkeeper tunnel stop <id>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Connect vs Tunnel
|
### Connect vs Tunnel
|
||||||
|
|
||||||
- **`sshkeeper connect <alias>`** (or `Enter` in TUI) — standard SSH session, no port forwards.
|
| Action | Command | TUI | Description |
|
||||||
- **`sshkeeper forward`** (or `Ctrl+W` in TUI) — manage port forwards for a server.
|
|---|---|---|---|
|
||||||
- **`sshkeeper tunnel <alias>`** — start SSH session **with** all configured forwards active.
|
| Connect | `sshkeeper connect <alias>` | `Enter` | Standard SSH session, no port forwards |
|
||||||
- **`sshkeeper tunnel <alias> --forward-only`** — start tunnel only (`ssh -N`), useful for background port forwarding.
|
| Connect with tunnels | `sshkeeper tunnel <alias>` | Action menu → Connect with tunnels | SSH session with all enabled forwards active |
|
||||||
- **TUI Action Menu** (`Ctrl+X`) — offers Connect, Start tunnel, and Tunnel mode for the selected server.
|
| Start tunnels only | `sshkeeper tunnel <alias> --forward-only` | Action menu → Start tunnels only | Foreground tunnel, no shell |
|
||||||
|
| Start tunnels in background | `sshkeeper tunnel <alias> --background` | Action menu → Start tunnels in background | Detached tunnel process with PID tracking |
|
||||||
|
| Manage port forwards | `sshkeeper forward` | Action menu → Manage port forwards | Add/edit/delete forward rules |
|
||||||
|
| Manage tunnels | `sshkeeper tunnel list/stop` | Action menu → Manage tunnels | View running tunnels, stop, restart |
|
||||||
|
|
||||||
Commands that only read profile metadata, such as `list`, `show`, `search`,
|
Commands that only read profile metadata, such as `list`, `show`, `search`,
|
||||||
`config path`, `group list`, and `export`, do not require the master password.
|
`config path`, `group list`, and `export`, do not require the master password.
|
||||||
|
|
@ -200,18 +219,11 @@ Running `sshkeeper` without arguments opens the TUI.
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Enter | Connect to selected server |
|
| Enter | Connect to selected server |
|
||||||
| Ctrl+R | Pick and run a command template on the selected servers |
|
|
||||||
| Insert | Select or unselect a server, then move to the next row |
|
|
||||||
| Ctrl+A | Add server |
|
| Ctrl+A | Add server |
|
||||||
| Ctrl+E | Edit server |
|
| Ctrl+E | Edit server |
|
||||||
| Ctrl+D | Delete server |
|
|
||||||
| Ctrl+T | Test connection |
|
|
||||||
| Ctrl+F | Search |
|
| Ctrl+F | Search |
|
||||||
| Ctrl+G | Manage tags |
|
| Ctrl+X | Action menu (connect, tunnels, forwards, route, test, edit, delete) |
|
||||||
| Ctrl+P | Manage global command templates |
|
|
||||||
| Ctrl+W | Manage port forwards for selected server |
|
|
||||||
| ? / F1 | Full help screen |
|
| ? / F1 | Full help screen |
|
||||||
| Ctrl+X | Action menu (delete, test, tags, vault) |
|
|
||||||
| Ctrl+Q / Ctrl+C | Quit |
|
| Ctrl+Q / Ctrl+C | Quit |
|
||||||
|
|
||||||
Templates are global entities and can run on any server. Foreground template
|
Templates are global entities and can run on any server. Foreground template
|
||||||
|
|
@ -294,7 +306,8 @@ sshkeeper/
|
||||||
├── internal/ssh/ # OpenSSH command building, PTY prompt handling
|
├── internal/ssh/ # OpenSSH command building, PTY prompt handling
|
||||||
├── internal/tui/ # Bubble Tea UI
|
├── internal/tui/ # Bubble Tea UI
|
||||||
├── internal/vault/ # Encrypted vault
|
├── internal/vault/ # Encrypted vault
|
||||||
└── main.go
|
├── build.sh # Build binary to bin/
|
||||||
|
├── release.sh # Build release tarballs to dist/
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ type tuiModel struct {
|
||||||
tagMode string
|
tagMode string
|
||||||
tagOldName string
|
tagOldName string
|
||||||
selected map[string]bool
|
selected map[string]bool
|
||||||
tunnelScreen *tunnelScreenModel
|
tunnelScreen *tunnelScreenModel
|
||||||
bgResults []templateRunResult
|
bgResults []templateRunResult
|
||||||
err error
|
err error
|
||||||
success string
|
success string
|
||||||
|
|
|
||||||
|
|
@ -711,11 +711,12 @@ func TestForwardSaveSuccessReturnsToList(t *testing.T) {
|
||||||
m.screen = screenForwardForm
|
m.screen = screenForwardForm
|
||||||
|
|
||||||
// Fill in form
|
// Fill in form
|
||||||
m.forwardForm.inputs[0].SetValue("local")
|
m.forwardForm.nameInput.SetValue("Test Forward")
|
||||||
m.forwardForm.inputs[1].SetValue("0.0.0.0")
|
m.forwardForm.descInput.SetValue("test")
|
||||||
m.forwardForm.inputs[2].SetValue("8080")
|
m.forwardForm.inputs[0].SetValue("127.0.0.1")
|
||||||
m.forwardForm.inputs[3].SetValue("internal.web")
|
m.forwardForm.inputs[1].SetValue("8080")
|
||||||
m.forwardForm.inputs[4].SetValue("80")
|
m.forwardForm.inputs[2].SetValue("internal.web")
|
||||||
|
m.forwardForm.inputs[3].SetValue("80")
|
||||||
|
|
||||||
// Simulate saveDoneMsg arriving through tuiModel.Update (as async cmd would)
|
// Simulate saveDoneMsg arriving through tuiModel.Update (as async cmd would)
|
||||||
updated, cmd := m.Update(saveDoneMsg{err: nil})
|
updated, cmd := m.Update(saveDoneMsg{err: nil})
|
||||||
|
|
@ -783,23 +784,22 @@ func TestActionMenuClosesOnAllActions(t *testing.T) {
|
||||||
t.Fatal("expected actionMenu nil after delete")
|
t.Fatal("expected actionMenu nil after delete")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test tags closes menu and goes to tags screen
|
// Test forwards closes menu and goes to forward list
|
||||||
m.actionMenu = newActionMenuModel(m.width, m.height)
|
m.actionMenu = newActionMenuModel(m.width, m.height)
|
||||||
m.screen = screenActionMenu
|
m.screen = screenActionMenu
|
||||||
// Find "Tags" item
|
|
||||||
for i := 0; i < 10; i++ {
|
for i := 0; i < 10; i++ {
|
||||||
m.actionMenu.list.Select(i)
|
m.actionMenu.list.Select(i)
|
||||||
if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "tags" {
|
if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "forwards" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ListTags = func() ([]string, error) { return []string{}, nil }
|
ListForwards = func(serverID int64) ([]*model.Forward, error) { return []*model.Forward{}, nil }
|
||||||
updated, _ = m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
|
updated, _ = m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
m = updated.(*tuiModel)
|
m = updated.(*tuiModel)
|
||||||
if m.actionMenu != nil {
|
if m.actionMenu != nil {
|
||||||
t.Fatal("expected actionMenu nil after tags")
|
t.Fatal("expected actionMenu nil after forwards")
|
||||||
}
|
}
|
||||||
if m.screen != screenTags {
|
if m.screen != screenForwardList {
|
||||||
t.Fatalf("expected screenTags, got %v", m.screen)
|
t.Fatalf("expected screenForwardList, got %v", m.screen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,11 @@ import (
|
||||||
// --- Tunnel manager screen ---
|
// --- Tunnel manager screen ---
|
||||||
|
|
||||||
type tunnelScreenModel struct {
|
type tunnelScreenModel struct {
|
||||||
list list.Model
|
list list.Model
|
||||||
tunnels []*model.TunnelState
|
tunnels []*model.TunnelState
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
type tunnelItem struct {
|
type tunnelItem struct {
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,9 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
states = map[int64]*model.TunnelState{}
|
states = map[int64]*model.TunnelState{}
|
||||||
dataDir string
|
dataDir string
|
||||||
)
|
)
|
||||||
|
|
||||||
// Init initializes the tunnel state manager with the data directory.
|
// Init initializes the tunnel state manager with the data directory.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue