From 6cf281c3495b3b3e0baf72407e509122f38636af Mon Sep 17 00:00:00 2001 From: mirivlad Date: Wed, 3 Jun 2026 18:27:05 +0800 Subject: [PATCH] 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 --- README.md | 89 ++++++++++++++++++++++---------------- internal/tui/app.go | 2 +- internal/tui/app_test.go | 24 +++++----- internal/tui/tunnel.go | 10 ++--- internal/tunnel/manager.go | 6 +-- 5 files changed, 72 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 3982a8f..e7d85c0 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,9 @@ port forwarding management. secrets in command-line arguments. - Key, SSH-agent, password, and key+passphrase auth modes. - **Routes / ProxyJump** — manage bastion hosts and jump chains with human-readable display. -- **Port forwarding** — local, remote, and dynamic (SOCKS) forwards with OpenSSH preview. -- **Tunnel mode** — `ssh -N` for forward-only sessions. +- **Port forwarding** — named local/remote/SOCKS forwards with type selector, validation, and OpenSSH preview. +- **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. - Import from `~/.ssh/config`. @@ -125,44 +126,62 @@ sshkeeper route show prod # 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 -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 -# [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 - -```bash -sshkeeper forward add bastion --type dynamic --local-port 1080 -sshkeeper forward list bastion -# [1] -D 0.0.0.0:1080 -``` - -### Forward-only tunnel (ssh -N) - -```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 +Forward types: +- **Local** — port on your machine → service reachable from SSH server +- **Remote** — port on SSH server → service on your machine +- **SOCKS** — local dynamic SOCKS proxy through SSH + +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 + +A **tunnel** is a running SSH process that activates one or more port forwards. ```bash +# Connect with all enabled forwards active (interactive session) 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 ``` -## Connect vs Tunnel +### Connect vs Tunnel -- **`sshkeeper connect `** (or `Enter` in TUI) — standard SSH session, no port forwards. -- **`sshkeeper forward`** (or `Ctrl+W` in TUI) — manage port forwards for a server. -- **`sshkeeper tunnel `** — start SSH session **with** all configured forwards active. -- **`sshkeeper tunnel --forward-only`** — start tunnel only (`ssh -N`), useful for background port forwarding. -- **TUI Action Menu** (`Ctrl+X`) — offers Connect, Start tunnel, and Tunnel mode for the selected server. +| Action | Command | TUI | Description | +|---|---|---|---| +| Connect | `sshkeeper connect ` | `Enter` | Standard SSH session, no port forwards | +| Connect with tunnels | `sshkeeper tunnel ` | Action menu → Connect with tunnels | SSH session with all enabled forwards active | +| Start tunnels only | `sshkeeper tunnel --forward-only` | Action menu → Start tunnels only | Foreground tunnel, no shell | +| Start tunnels in background | `sshkeeper tunnel --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`, `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 | | --- | --- | | 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+E | Edit server | -| Ctrl+D | Delete server | -| Ctrl+T | Test connection | | Ctrl+F | Search | -| Ctrl+G | Manage tags | -| Ctrl+P | Manage global command templates | -| Ctrl+W | Manage port forwards for selected server | +| Ctrl+X | Action menu (connect, tunnels, forwards, route, test, edit, delete) | | ? / F1 | Full help screen | -| Ctrl+X | Action menu (delete, test, tags, vault) | | Ctrl+Q / Ctrl+C | Quit | 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/tui/ # Bubble Tea UI ├── internal/vault/ # Encrypted vault -└── main.go +├── build.sh # Build binary to bin/ +├── release.sh # Build release tarballs to dist/ ``` ## License diff --git a/internal/tui/app.go b/internal/tui/app.go index df54b1d..aeae70c 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -223,7 +223,7 @@ type tuiModel struct { tagMode string tagOldName string selected map[string]bool - tunnelScreen *tunnelScreenModel + tunnelScreen *tunnelScreenModel bgResults []templateRunResult err error success string diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 88141bd..0b0ed82 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -711,11 +711,12 @@ func TestForwardSaveSuccessReturnsToList(t *testing.T) { m.screen = screenForwardForm // Fill in form - m.forwardForm.inputs[0].SetValue("local") - m.forwardForm.inputs[1].SetValue("0.0.0.0") - m.forwardForm.inputs[2].SetValue("8080") - m.forwardForm.inputs[3].SetValue("internal.web") - m.forwardForm.inputs[4].SetValue("80") + m.forwardForm.nameInput.SetValue("Test Forward") + m.forwardForm.descInput.SetValue("test") + m.forwardForm.inputs[0].SetValue("127.0.0.1") + m.forwardForm.inputs[1].SetValue("8080") + m.forwardForm.inputs[2].SetValue("internal.web") + m.forwardForm.inputs[3].SetValue("80") // Simulate saveDoneMsg arriving through tuiModel.Update (as async cmd would) updated, cmd := m.Update(saveDoneMsg{err: nil}) @@ -783,23 +784,22 @@ func TestActionMenuClosesOnAllActions(t *testing.T) { 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.screen = screenActionMenu - // Find "Tags" item for i := 0; i < 10; 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 } } - 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}) m = updated.(*tuiModel) if m.actionMenu != nil { - t.Fatal("expected actionMenu nil after tags") + t.Fatal("expected actionMenu nil after forwards") } - if m.screen != screenTags { - t.Fatalf("expected screenTags, got %v", m.screen) + if m.screen != screenForwardList { + t.Fatalf("expected screenForwardList, got %v", m.screen) } } diff --git a/internal/tui/tunnel.go b/internal/tui/tunnel.go index d00f9cf..5646243 100644 --- a/internal/tui/tunnel.go +++ b/internal/tui/tunnel.go @@ -14,11 +14,11 @@ import ( // --- Tunnel manager screen --- type tunnelScreenModel struct { - list list.Model - tunnels []*model.TunnelState - width int - height int - err error + list list.Model + tunnels []*model.TunnelState + width int + height int + err error } type tunnelItem struct { diff --git a/internal/tunnel/manager.go b/internal/tunnel/manager.go index a23e747..e2f6929 100644 --- a/internal/tunnel/manager.go +++ b/internal/tunnel/manager.go @@ -15,9 +15,9 @@ import ( ) var ( - mu sync.Mutex - states = map[int64]*model.TunnelState{} - dataDir string + mu sync.Mutex + states = map[int64]*model.TunnelState{} + dataDir string ) // Init initializes the tunnel state manager with the data directory.