sshkeeper: v0.2.0 stabilization — dual help screens, route icons, forward UX, tests, docs

- Split help into two modes: ? (quick hotkeys) and F1 (full documentation)
- Add visual route icons: ● direct, → via, ⇒ chain
- Forward type selector: visible radio items with descriptions
- Forward delete: confirmation dialog
- Forward list: column headers (NAME/TYPE/LISTEN/TARGET/ON)
- Footer hints on all screens show ? and F1
- Comprehensive SSH command builder tests (18 new tests)
- Fix duplicate test functions
- Update README.md: install from source as primary, fix tables, repo links
- Update docs/guide.md: split help section, fix tables, fix typos
- All tests pass, build succeeds
This commit is contained in:
mirivlad 2026-06-05 18:19:38 +08:00
parent 216af78d4d
commit 94e10171d2
6 changed files with 708 additions and 219 deletions

188
README.md
View File

@ -30,11 +30,28 @@ port forwarding management.
## Install ## Install
### Install from release ### Build from source
Download the latest Linux x86_64 release from: ```bash
git clone git@git.mirv.top:mirivlad/sshkeeper.git
cd sshkeeper
go build -o ~/.local/bin/sshkeeper .
```
https://github.com/mirivlad/sshkeeper/releases/latest Or use the build scripts:
```bash
./build.sh # Build binary to bin/
./release.sh # Build release tarballs to dist/
```
Requirements: Go 1.25+, Linux x86_64, system OpenSSH.
**Source repositories:**
- Main public: [github.com/mirivlad/sshkeeper](https://github.com/mirivlad/sshkeeper)
- Mirror/dev: `git@git.mirv.top:mirivlad/sshkeeper`
### Install from release (after v0.2.0 publication)
```bash ```bash
tar -xzf sshkeeper_v0.2.0_linux_amd64.tar.gz tar -xzf sshkeeper_v0.2.0_linux_amd64.tar.gz
@ -43,16 +60,6 @@ sudo install -m 0755 sshkeeper-linux-amd64 /usr/local/bin/sshkeeper
sshkeeper sshkeeper
``` ```
### Build from source
```bash
git clone https://github.com/mirivlad/sshkeeper.git
cd sshkeeper
go build -o ~/.local/bin/sshkeeper .
```
Requirements: Go 1.25+, Linux x86_64, system OpenSSH.
## First Run ## First Run
Run the TUI or any command. On the first run, `sshkeeper` creates its config, Run the TUI or any command. On the first run, `sshkeeper` creates its config,
@ -68,41 +75,62 @@ You can also initialize explicitly:
sshkeeper init sshkeeper init
``` ```
## Common CLI Commands ## TUI
```bash Running `sshkeeper` without arguments opens the TUI.
# Add profiles with flags
sshkeeper add web --host 10.0.0.10 --user deploy --auth key
sshkeeper add prod --host 10.0.0.20 --user root --auth password
sshkeeper add bastion --host bastion.example.org --user admin --auth key_passphrase --identity-file ~/.ssh/id_rsa
# Or use the interactive CLI prompt ### Main Window
sshkeeper add
# Inspect profiles ```
sshkeeper list sshkeeper 0 servers
sshkeeper show web Vault unlocked | 0 OK | 0 FAIL
sshkeeper search prod
# Connect and test NAME ALIAS ROUTE AUTH GROUP STATUS
sshkeeper connect web
sshkeeper c web
sshkeeper test web
sshkeeper run web "uptime"
# Groups and templates No servers yet. Press Ctrl+A to add one.
sshkeeper group list
sshkeeper template list
sshkeeper template add uptime "uptime"
sshkeeper run-template web uptime
# Tags and startup command Enter: connect | Ctrl+X: actions | Ctrl+A: add | Ctrl+E: edit
sshkeeper add web --host 10.0.0.10 --user deploy --auth key --tags prod,web --startup-command "tmux attach -t ops" Ctrl+F: search | Ins: select | ?: hotkeys | F1: help | Ctrl+Q: quit
sshkeeper edit web --tags prod,web --startup-command "tmux attach -t ops" ```
# OpenSSH config ### Quick Help (?)
sshkeeper ssh-config generate
sshkeeper ssh-config install-include Press `?` on any screen for a compact hotkey reference.
### Full Help (F1)
Press `F1` on any screen for full documentation including routes, port
forwarding, tunnels, and vault.
### Key Reference
| Key | Action |
|-----|--------|
| Enter | Connect to selected server |
| Ctrl+A | Add server |
| Ctrl+E | Edit server |
| Ctrl+F | Search |
| Ctrl+W | Manage port forwards for selected server |
| Ctrl+X | Action menu (connect, tunnels, forwards, route, test, edit, delete) |
| Ins | Select / deselect a server |
| ? | Quick help (hotkeys) |
| F1 | Full documentation |
| Ctrl+Q / Ctrl+C | Quit |
Templates are global entities and can run on any server. Foreground template
runs leave the TUI, show the SSH session in the terminal, and then return to the
TUI. Background runs execute the command and show per-server output in a result
screen.
In add/edit forms:
| Key | Action |
|-----|--------|
| Tab / Down | Next field |
| Shift+Tab / Up | Previous field |
| `/` on Auth Method or Group | Pick from list |
| Enter | Move to action / activate |
| Esc | Back |
## Routes, Tunnels, and Port Forwards ## Routes, Tunnels, and Port Forwards
@ -145,9 +173,12 @@ sshkeeper forward list web
``` ```
Forward types: Forward types:
- **Local** — port on your machine → service reachable from SSH server
- **Remote** — port on SSH server → service on your machine | Type | Description |
- **SOCKS** — local dynamic SOCKS proxy through SSH |------|-------------|
| **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. 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.
@ -175,7 +206,7 @@ sshkeeper tunnel stop <id>
### Connect vs Tunnel ### Connect vs Tunnel
| Action | Command | TUI | Description | | Action | Command | TUI | Description |
|---|---|---|---| |--------|---------|-----|-------------|
| Connect | `sshkeeper connect <alias>` | `Enter` | Standard SSH session, no port forwards | | Connect | `sshkeeper connect <alias>` | `Enter` | Standard SSH session, no port forwards |
| Connect with tunnels | `sshkeeper tunnel <alias>` | Action menu → Connect with tunnels | SSH session with all enabled forwards active | | Connect with tunnels | `sshkeeper tunnel <alias>` | Action menu → Connect with tunnels | SSH session with all enabled forwards active |
| Start tunnels only | `sshkeeper tunnel <alias> --forward-only` | Action menu → Start tunnels only | Foreground tunnel, no shell | | Start tunnels only | `sshkeeper tunnel <alias> --forward-only` | Action menu → Start tunnels only | Foreground tunnel, no shell |
@ -183,68 +214,6 @@ sshkeeper tunnel stop <id>
| Manage port forwards | `sshkeeper forward` | Action menu → Manage port forwards | Add/edit/delete forward rules | | 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 | | 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.
Commands that need secrets ask for the master password in that process. Adding
`key` or `agent` profiles does not require unlocking the vault; adding
`password` or `key_passphrase` profiles asks for the master password before
storing the secret.
## Руководство
Подробная инструкция с примерами сценариев: [docs/guide.md](docs/guide.md)
## TUI
Running `sshkeeper` without arguments opens the TUI.
## Screenshots
### Main Window
![sshkeeper main window](docs/screenshots/screen_1.png)
### Edit Server
![sshkeeper edit server form](docs/screenshots/screen_2.png)
![sshkeeper group picker](docs/screenshots/screen_3.png)
### Template Manager
![sshkeeper template manager](docs/screenshots/screen_4.png)
### Route and Forwarding
![sshkeeper route screen](docs/screenshots/screen_5_route.png)
![sshkeeper port forwards](docs/screenshots/screen_6_forwards.png)
| Key | Action |
| --- | --- |
| Enter | Connect to selected server |
| Ctrl+A | Add server |
| Ctrl+E | Edit server |
| Ctrl+F | Search |
| Ctrl+X | Action menu (connect, tunnels, forwards, route, test, edit, delete) |
| ? / F1 | Full help screen |
| Ctrl+Q / Ctrl+C | Quit |
Templates are global entities and can run on any server. Foreground template
runs leave the TUI, show the SSH session in the terminal, and then return to the
TUI. Background runs execute the command and show per-server output in a result
screen.
In add/edit forms:
| Key | Action |
| --- | --- |
| Tab / Down | Next field |
| Shift+Tab / Up | Previous field |
| `/` on Auth Method or Group | Pick from list |
| Enter | Move to action / activate |
| Esc | Back |
## Vault ## Vault
The vault stores SSH passwords and key passphrases encrypted on disk. The vault stores SSH passwords and key passphrases encrypted on disk.
@ -281,7 +250,7 @@ before using it for high-risk environments.
`sshkeeper` uses XDG-style app directories: `sshkeeper` uses XDG-style app directories:
| Data | Default path | | Data | Default path |
| --- | --- | |------|-------------|
| Config | `~/.config/sshkeeper/config.toml` | | Config | `~/.config/sshkeeper/config.toml` |
| Database | `~/.local/share/sshkeeper/sshkeeper.db` | | Database | `~/.local/share/sshkeeper/sshkeeper.db` |
| Vault | `~/.local/share/sshkeeper/vault.bin` | | Vault | `~/.local/share/sshkeeper/vault.bin` |
@ -310,8 +279,11 @@ 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
├── internal/tunnel/ # Tunnel state management
├── docs/guide.md # User guide
├── build.sh # Build binary to bin/ ├── build.sh # Build binary to bin/
├── release.sh # Build release tarballs to dist/ ├── release.sh # Build release tarballs to dist/
└── main.go
``` ```
## License ## License

View File

@ -40,21 +40,15 @@ sshkeeper — это консольный менеджер SSH-подключе
- XChaCha20-Poly1305 (шифрование vault) - XChaCha20-Poly1305 (шифрование vault)
- Argon2id (KDF для мастер-пароля) - Argon2id (KDF для мастер-пароля)
**Исходники:** `git.mirv.top:mirivlad/sshkeeper` **Исходники:**
- Основной публичный репозиторий: [github.com/mirivlad/sshkeeper](https://github.com/mirivlad/sshkeeper)
- Зеркало/рабочий репозиторий: `git@git.mirv.top:mirivlad/sshkeeper`
--- ---
## Установка ## Установка
### Из релиза ### Из исходников (основной способ)
```bash
tar -xzf sshkeeper_v0.2.0_linux_amd64.tar.gz
chmod +x sshkeeper-linux-amd64
sudo install -m 0755 sshkeeper-linux-amd64 /usr/local/bin/sshkeeper
```
### Из исходников
```bash ```bash
git clone git@git.mirv.top:mirivlad/sshkeeper.git git clone git@git.mirv.top:mirivlad/sshkeeper.git
@ -63,6 +57,7 @@ go build -o ~/.local/bin/sshkeeper .
``` ```
Или через скрипт: Или через скрипт:
```bash ```bash
./build.sh # сборка в bin/ ./build.sh # сборка в bin/
./release.sh # сборка релизных архивов в dist/ ./release.sh # сборка релизных архивов в dist/
@ -70,6 +65,14 @@ go build -o ~/.local/bin/sshkeeper .
**Требования:** Go 1.25+, Linux x86_64, системный OpenSSH. **Требования:** Go 1.25+, Linux x86_64, системный OpenSSH.
### Из релиза (после публикации v0.2.0)
```bash
tar -xzf sshkeeper_v0.2.0_linux_amd64.tar.gz
chmod +x sshkeeper-linux-amd64
sudo install -m 0755 sshkeeper-linux-amd64 /usr/local/bin/sshkeeper
```
--- ---
## Первый запуск ## Первый запуск
@ -113,52 +116,119 @@ Vault unlocked | 0 OK | 0 FAIL
No servers yet. Press Ctrl+A to add one. No servers yet. Press Ctrl+A to add one.
Enter: connect | Ctrl+X: actions | Ctrl+A: add | Ctrl+E: edit Enter: connect | Ctrl+X: actions | Ctrl+A: add | Ctrl+E: edit
Ctrl+F: search | Ins: select | ?: help | Ctrl+Q: quit Ctrl+F: search | Ins: select | ?: hotkeys | F1: help | Ctrl+Q: quit
``` ```
**Столбцы:** **Столбцы:**
- **NAME** — отображаемое имя сервера
- **ALIAS** — уникальный идентификатор | Столбец | Описание |
- **ROUTE** — маршрут подключения: |---------|----------|
- ` direct → user@host:port` — прямое подключение | NAME | Отображаемое имя сервера |
- `→ bastion → user@host:port` — через бастион | ALIAS | Уникальный идентификатор |
- `→ bastion → … → user@host:port` — через цепочку | ROUTE | Маршрут подключения (● direct, → via, ⇒ chain) |
- **AUTH** — метод аутентификации (key/password/agent/key+phrase) | AUTH | Метод аутентификации (key/password/agent/key+phrase) |
- **GROUP** — группа сервера | GROUP | Группа сервера |
- **STATUS**результат последнего теста (OK/FAIL/?) | STATUS | Результат последнего теста (OK/FAIL/?) |
**Навигация:** **Навигация:**
- `↑/↓` — перемещение по списку
- `Enter` — подключиться к серверу
- `Ctrl+A` — добавить сервер
- `Ctrl+E` — редактировать сервер
- `Ctrl+X` — меню действий
- `Ctrl+F` — поиск
- `Ins` — выбрать/снять выбор (для групповых операций)
- `?` или `F1` — полная справка
- `Ctrl+Q` — выход
### Экран помощи | Клавиша | Действие |
|---------|----------|
| `↑/↓` | Перемещение по списку |
| `Enter` | Подключиться к серверу |
| `Ctrl+A` | Добавить сервер |
| `Ctrl+E` | Редактировать сервер |
| `Ctrl+X` | Меню действий |
| `Ctrl+F` | Поиск |
| `Ins` | Выбрать/снять выбор |
| `?` | Краткая справка по клавишам |
| `F1` | Полная справка по приложению |
| `Ctrl+Q` | Выход |
Нажмите `?` или `F1` на любом экране: ### Быстрая справка по клавишам
Нажмите `?` на любом экране:
``` ```
sshkeeper — Help sshkeeper — Quick Help
Enter Connect to server Navigation
↑/↓ Navigate list ↑/↓ Move through list
PgUp/PgDn Scroll page
Home/End Jump to start/end
Actions
Enter Select / Confirm / Open
Esc Back / Cancel / Close
Tab / ↓ Next field Tab / ↓ Next field
Shift+Tab / ↑ Previous field Shift+Tab / ↑ Previous field
/ Open dropdown picker / Open dropdown picker
Esc Back / Cancel
Server list
Enter Connect to server
Ctrl+A Add server Ctrl+A Add server
Ctrl+E Edit server Ctrl+E Edit server
Ctrl+W Manage port forwards Ctrl+F Search
Ctrl+X Action menu Ctrl+X Action menu
? This help screen Ins Select / deselect
Ctrl+Q Quit
Port forwards
Ctrl+A Add forward
Enter / Ctrl+E Edit forward
Ctrl+D Delete forward
Other
? This quick help
F1 Full documentation
Esc / Enter / ? / q — close
``` ```
### Полная справка по приложению
Нажмите `F1` на любом экране. Это полная документация по sshkeeper:
```
sshkeeper — Full Help
What is sshkeeper
sshkeeper is a Linux console SSH connection manager.
It stores server profiles, secrets, and launches the system ssh client.
Navigation
↑/↓ Move through list
Tab/↓ Next field
Shift+Tab/↑ Previous field
/ Open dropdown picker
Global actions
Enter Select / Confirm / Open
Esc Back / Cancel / Close
? Quick help (hotkeys)
F1 Full documentation
Ctrl+Q Quit
Server list
Enter Connect to server
Ctrl+A Add server
Ctrl+E Edit server
Ctrl+F Search
Ctrl+X Action menu
Ins Select / deselect
...
↑/↓ scroll — q/Esc/Enter close
```
**Навигация по полной справке:**
| Клавиша | Действие |
|---------|----------|
| `↑/↓` | Прокрутка |
| `PgUp/PgDn` | Прокрутка по странице |
| `q` / `Esc` / `Enter` | Закрыть справку |
--- ---
## Управление серверами ## Управление серверами
@ -190,13 +260,16 @@ Add Server
``` ```
**Навигация по форме:** **Навигация по форме:**
- `Tab` или `↓` — следующее поле
- `Shift+Tab` или `↑` — предыдущее поле | Клавиша | Действие |
- `/` на поле Auth Method — выбрать из списка (password/key/key_passphrase/agent) |---------|----------|
- `/` на поле Group — выбрать из существующих групп | `Tab` или `↓` | Следующее поле |
- `Enter` на кнопке Test — проверить подключение | `Shift+Tab` или `↑` | Предыдущее поле |
- `Enter` на кнопке Save — сохранить | `/` на Auth Method | Выбрать из списка (password/key/key_passphrase/agent) |
- `Esc` — отмена | `/` на Group | Выбрать из существующих групп |
| `Enter` на Test | Проверить подключение |
| `Enter` на Save | Сохранить |
| `Esc` | Отмена |
### Редактирование сервера ### Редактирование сервера
@ -234,7 +307,7 @@ Add Server
Сервер подключается напрямую: Сервер подключается напрямую:
``` ```
ROUTE: direct → root@web.example.org:22 ROUTE: direct → root@web.example.org:22
``` ```
### Через бастион ### Через бастион
@ -250,7 +323,7 @@ ROUTE: → bastion → root@internal.web:22
Сервер доступен через несколько jump hosts: Сервер доступен через несколько jump hosts:
``` ```
ROUTE: bastion → dmz-gw → … → root@secure.internal:22 ROUTE: bastion → dmz-gw → … → root@secure.internal:22
``` ```
### Настройка маршрута ### Настройка маршрута
@ -261,6 +334,7 @@ ROUTE: → bastion → dmz-gw → … → root@secure.internal:22
3. Или введите адрес напрямую: `user@bastion.example.com:2222` 3. Или введите адрес напрямую: `user@bastion.example.com:2222`
**Через CLI:** **Через CLI:**
```bash ```bash
sshkeeper route set web --jumps bastion sshkeeper route set web --jumps bastion
sshkeeper route set prod --jumps bastion,dmz-gw sshkeeper route set prod --jumps bastion,dmz-gw
@ -274,7 +348,7 @@ sshkeeper route clear web
### Ключевое различие ### Ключевое различие
- **Port Forward** — сохранённое правило проброса порода. Просто конфигурация, не запускает процесс. - **Port Forward** — сохранённое правило проброса порта. Просто конфигурация, не запускает процесс.
- **Tunnel** — запущенный SSH-процесс, который активирует один или несколько forwards. - **Tunnel** — запущенный SSH-процесс, который активирует один или несколько forwards.
Аналогия: forward — это рецепт, tunnel — это готовое блюдо. Аналогия: forward — это рецепт, tunnel — это готовое блюдо.
@ -310,10 +384,13 @@ Port Forwards — web
``` ```
**Действия:** **Действия:**
- `Ctrl+A` — добавить forward
- `Enter` или `Ctrl+E` — редактировать выбранный | Клавиша | Действие |
- `Ctrl+D` — удалить (с подтверждением) |---------|----------|
- `Esc` — назад | `Ctrl+A` | Добавить forward |
| `Enter` или `Ctrl+E` | Редактировать выбранный |
| `Ctrl+D` | Удалить (с подтверждением) |
| `Esc` | Назад |
### Добавление forward ### Добавление forward
@ -331,6 +408,8 @@ Add Port Forward
2. Remote port on SSH server → service on my machine 2. Remote port on SSH server → service on my machine
3. SOCKS local dynamic SOCKS proxy through SSH 3. SOCKS local dynamic SOCKS proxy through SSH
Opens a local port on this machine and forwards it through SSH to the target address.
Listen Address: 127.0.0.1 Listen Address: 127.0.0.1
Listen Port: 15432 Listen Port: 15432
Target Host: 127.0.0.1 Target Host: 127.0.0.1
@ -346,9 +425,12 @@ Add Port Forward
``` ```
**Поля зависят от типа:** **Поля зависят от типа:**
- **Local:** Listen Address, Listen Port, Target Host, Target Port
- **Remote:** Remote Listen Addr, Remote Listen Port, Local Target Host, Local Target Port | Тип | Поля |
- **SOCKS:** Listen Address, Listen Port (target поля скрыты) |-----|------|
| Local | Listen Address, Listen Port, Target Host, Target Port |
| Remote | Remote Listen Addr, Remote Listen Port, Local Target Host, Local Target Port |
| SOCKS | Listen Address, Listen Port (target поля скрыты) |
**По умолчанию Listen Address = `127.0.0.1`** (только локальный доступ). Если введёте `0.0.0.0`, появится предупреждение. **По умолчанию Listen Address = `127.0.0.1`** (только локальный доступ). Если введёте `0.0.0.0`, появится предупреждение.
@ -362,6 +444,7 @@ Add Port Forward
- **Start tunnels in background** — туннель в фоне с PID - **Start tunnels in background** — туннель в фоне с PID
**Через CLI:** **Через CLI:**
```bash ```bash
# SSH-сессия с туннелями # SSH-сессия с туннелями
sshkeeper tunnel web sshkeeper tunnel web
@ -389,6 +472,7 @@ Tunnel Manager
``` ```
**Через CLI:** **Через CLI:**
```bash ```bash
sshkeeper tunnel list sshkeeper tunnel list
sshkeeper tunnel stop <id> sshkeeper tunnel stop <id>
@ -602,21 +686,24 @@ sshkeeper connect secure
| `Ctrl+F` | Поиск | | `Ctrl+F` | Поиск |
| `Ctrl+X` | Меню действий | | `Ctrl+X` | Меню действий |
| `Ins` | Выбрать/снять выбор | | `Ins` | Выбрать/снять выбор |
| `?` / `F1` | Помощь | | `?` | Краткая справка по клавишам |
| `F1` | Полная справка по приложению |
| `Ctrl+Q` | Выход | | `Ctrl+Q` | Выход |
### Меню действий (Ctrl+X) ### Меню действий (Ctrl+X)
- **Connect** — стандартное SSH-подключение | Действие | Описание |
- **Connect with tunnels** — SSH + все активные forwards |----------|----------|
- **Start tunnels only** — туннель без shell | Connect | Стандартное SSH-подключение |
- **Start tunnels in background** — фоновый туннель | Connect with tunnels | SSH + все активные forwards |
- **Manage port forwards** — управление forwards | Start tunnels only | Туннель без shell |
- **Manage tunnels** — список туннелей | Start tunnels in background | Фоновый туннель |
- **Manage route** — настройка маршрута | Manage port forwards | Управление forwards |
- **Test connection** — проверка подключения | Manage tunnels | Список туннелей |
- **Edit** — редактирование сервера | Manage route | Настройка маршрута |
- **Delete** — удаление (с подтверждением) | Test connection | Проверка подключения |
| Edit | Редактирование сервера |
| Delete | Удаление (с подтверждением) |
### Формы (добавление/редактирование) ### Формы (добавление/редактирование)

View File

@ -13,16 +13,7 @@ import (
func TestKeyPassphraseTestUsesVaultSecret(t *testing.T) { func TestKeyPassphraseTestUsesVaultSecret(t *testing.T) {
script := filepath.Join(t.TempDir(), "fake-ssh") script := filepath.Join(t.TempDir(), "fake-ssh")
if err := os.WriteFile(script, []byte(`#!/bin/sh if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf 'Enter passphrase for key: '\nIFS= read -r passphrase\nif [ \"$passphrase\" = \"key-secret\" ]; then\n echo SSHKEEPER_OK\n exit 0\nfi\necho denied\nexit 1\n"), 0o700); err != nil {
printf 'Enter passphrase for key: '
IFS= read -r passphrase
if [ "$passphrase" = "key-secret" ]; then
echo SSHKEEPER_OK
exit 0
fi
echo denied
exit 1
`), 0o700); err != nil {
t.Fatalf("write fake ssh: %v", err) t.Fatalf("write fake ssh: %v", err)
} }
@ -86,9 +77,7 @@ func TestConnectRunsStartupCommand(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
argsFile := filepath.Join(dir, "args") argsFile := filepath.Join(dir, "args")
script := filepath.Join(dir, "fake-ssh") script := filepath.Join(dir, "fake-ssh")
if err := os.WriteFile(script, []byte(fmt.Sprintf(`#!/bin/sh if err := os.WriteFile(script, []byte(fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > %q\n", argsFile)), 0o700); err != nil {
printf '%%s\n' "$@" > %q
`, argsFile)), 0o700); err != nil {
t.Fatalf("write fake ssh: %v", err) t.Fatalf("write fake ssh: %v", err)
} }
@ -114,3 +103,255 @@ printf '%%s\n' "$@" > %q
t.Fatalf("expected startup command in ssh args, got:\n%s", data) t.Fatalf("expected startup command in ssh args, got:\n%s", data)
} }
} }
func TestBuildSSHArgs_Simple(t *testing.T) {
server := &model.Server{Host: "example.org", Port: 22, User: "root"}
args := BuildSSHArgsSimple(server)
expected := []string{"-p", "22", "-o", "StrictHostKeyChecking=accept-new", "root@example.org"}
if len(args) != len(expected) {
t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args)
}
for i, a := range args {
if a != expected[i] {
t.Fatalf("arg[%d]: expected %q, got %q", i, expected[i], a)
}
}
}
func TestBuildSSHArgs_CustomPort(t *testing.T) {
server := &model.Server{Host: "example.org", Port: 2222, User: "deploy"}
args := BuildSSHArgsSimple(server)
if args[1] != "2222" {
t.Fatalf("expected port 2222, got %s", args[1])
}
if args[len(args)-1] != "deploy@example.org" {
t.Fatalf("expected target deploy@example.org, got %s", args[len(args)-1])
}
}
func TestBuildSSHArgs_WithIdentityFile(t *testing.T) {
server := &model.Server{Host: "example.org", Port: 22, User: "root", IdentityFile: "~/.ssh/id_ed25519"}
args := BuildSSHArgsSimple(server)
found := false
for _, a := range args {
if a == "-i" {
found = true
break
}
}
if !found {
t.Fatalf("expected -i flag in args: %v", args)
}
}
func TestBuildSSHArgs_WithProxyJump(t *testing.T) {
server := &model.Server{Host: "internal.example.org", Port: 22, User: "root", ProxyJump: "bastion.example.org"}
args := BuildSSHArgsSimple(server)
found := false
for _, a := range args {
if a == "-J" {
found = true
break
}
}
if !found {
t.Fatalf("expected -J flag in args: %v", args)
}
}
func TestBuildSSHArgs_ForwardLocal(t *testing.T) {
server := &model.Server{Host: "db.internal", Port: 22, User: "root"}
forwards := []*model.Forward{{Type: model.ForwardLocal, LocalAddr: "127.0.0.1", LocalPort: 15432, RemoteAddr: "127.0.0.1", RemotePort: 5432}}
args := BuildSSHArgs(server, forwards, false)
found := false
for _, a := range args {
if a == "-L" {
found = true
break
}
}
if !found {
t.Fatalf("expected -L flag in args: %v", args)
}
}
func TestBuildSSHArgs_ForwardRemote(t *testing.T) {
server := &model.Server{Host: "web.internal", Port: 22, User: "root"}
forwards := []*model.Forward{{Type: model.ForwardRemote, LocalAddr: "127.0.0.1", LocalPort: 8080, RemoteAddr: "0.0.0.0", RemotePort: 80}}
args := BuildSSHArgs(server, forwards, false)
found := false
for _, a := range args {
if a == "-R" {
found = true
break
}
}
if !found {
t.Fatalf("expected -R flag in args: %v", args)
}
}
func TestBuildSSHArgs_ForwardDynamic(t *testing.T) {
server := &model.Server{Host: "jump.example.org", Port: 22, User: "me"}
forwards := []*model.Forward{{Type: model.ForwardDynamic, LocalAddr: "127.0.0.1", LocalPort: 1080}}
args := BuildSSHArgs(server, forwards, false)
found := false
for _, a := range args {
if a == "-D" {
found = true
break
}
}
if !found {
t.Fatalf("expected -D flag in args: %v", args)
}
}
func TestBuildSSHArgs_ForwardOnly(t *testing.T) {
server := &model.Server{Host: "db.internal", Port: 22, User: "root"}
forwards := []*model.Forward{{Type: model.ForwardLocal, LocalAddr: "127.0.0.1", LocalPort: 15432, RemoteAddr: "127.0.0.1", RemotePort: 5432}}
args := BuildSSHArgs(server, forwards, true)
found := false
for _, a := range args {
if a == "-N" {
found = true
break
}
}
if !found {
t.Fatalf("expected -N flag in args: %v", args)
}
}
func TestBuildSSHArgs_ExitOnForwardFailure(t *testing.T) {
server := &model.Server{Host: "db.internal", Port: 22, User: "root"}
forwards := []*model.Forward{{Type: model.ForwardLocal, LocalAddr: "127.0.0.1", LocalPort: 15432, RemoteAddr: "127.0.0.1", RemotePort: 5432}}
args := BuildSSHArgs(server, forwards, false)
found := false
for _, a := range args {
if a == "ExitOnForwardFailure=yes" {
found = true
break
}
}
if !found {
t.Fatalf("expected ExitOnForwardFailure=yes in args: %v", args)
}
}
func TestBuildSSHArgs_MultipleForwards(t *testing.T) {
server := &model.Server{Host: "multi.internal", Port: 22, User: "root"}
forwards := []*model.Forward{
{Type: model.ForwardLocal, LocalAddr: "127.0.0.1", LocalPort: 15432, RemoteAddr: "127.0.0.1", RemotePort: 5432},
{Type: model.ForwardLocal, LocalAddr: "127.0.0.1", LocalPort: 18080, RemoteAddr: "internal.web", RemotePort: 80},
{Type: model.ForwardDynamic, LocalAddr: "127.0.0.1", LocalPort: 1080},
}
args := BuildSSHArgs(server, forwards, false)
lCount, dCount := 0, 0
for _, a := range args {
switch a {
case "-L":
lCount++
case "-D":
dCount++
}
}
if lCount != 2 {
t.Fatalf("expected 2 -L flags, got %d: %v", lCount, args)
}
if dCount != 1 {
t.Fatalf("expected 1 -D flag, got %d: %v", dCount, args)
}
}
func TestBuildSSHArgs_RouteAndForwards(t *testing.T) {
server := &model.Server{Host: "secure.internal", Port: 22, User: "root", Route: model.Route{Hops: []model.RouteHop{{Alias: "bastion", IsProfile: true}}}}
forwards := []*model.Forward{{Type: model.ForwardLocal, LocalAddr: "127.0.0.1", LocalPort: 15432, RemoteAddr: "127.0.0.1", RemotePort: 5432}}
args := BuildSSHArgs(server, forwards, false)
hasJ, hasL := false, false
for _, a := range args {
if a == "-J" {
hasJ = true
}
if a == "-L" {
hasL = true
}
}
if !hasJ {
t.Fatalf("expected -J flag in args: %v", args)
}
if !hasL {
t.Fatalf("expected -L flag in args: %v", args)
}
}
func TestBuildForwardArgs_Local(t *testing.T) {
fwd := &model.Forward{Type: model.ForwardLocal, LocalAddr: "127.0.0.1", LocalPort: 8080, RemoteAddr: "internal.web", RemotePort: 80}
args := BuildForwardArgs([]*model.Forward{fwd}, true)
expected := []string{"-L", "127.0.0.1:8080:internal.web:80", "-o", "ExitOnForwardFailure=yes"}
if len(args) != len(expected) {
t.Fatalf("expected %v, got %v", expected, args)
}
}
func TestBuildForwardArgs_Remote(t *testing.T) {
fwd := &model.Forward{Type: model.ForwardRemote, LocalAddr: "127.0.0.1", LocalPort: 2222, RemoteAddr: "0.0.0.0", RemotePort: 22}
args := BuildForwardArgs([]*model.Forward{fwd}, true)
expected := []string{"-R", "0.0.0.0:22:127.0.0.1:2222", "-o", "ExitOnForwardFailure=yes"}
if len(args) != len(expected) {
t.Fatalf("expected %v, got %v", expected, args)
}
}
func TestBuildForwardArgs_Dynamic(t *testing.T) {
fwd := &model.Forward{Type: model.ForwardDynamic, LocalAddr: "127.0.0.1", LocalPort: 1080}
args := BuildForwardArgs([]*model.Forward{fwd}, true)
expected := []string{"-D", "127.0.0.1:1080", "-o", "ExitOnForwardFailure=yes"}
if len(args) != len(expected) {
t.Fatalf("expected %v, got %v", expected, args)
}
}
func TestBuildForwardArgs_Empty(t *testing.T) {
args := BuildForwardArgs(nil, true)
if len(args) != 0 {
t.Fatalf("expected no args, got %v", args)
}
}
func TestForwardHumanExplanation(t *testing.T) {
tests := []struct {
fwd *model.Forward
want string
}{
{&model.Forward{Type: model.ForwardLocal, LocalAddr: "127.0.0.1", LocalPort: 15432, RemoteAddr: "127.0.0.1", RemotePort: 5432}, "Port 127.0.0.1:15432 on this machine will be forwarded through to 127.0.0.1:5432."},
{&model.Forward{Type: model.ForwardRemote, LocalAddr: "127.0.0.1", LocalPort: 8080, RemoteAddr: "0.0.0.0", RemotePort: 80}, "Port 0.0.0.0:80 on will be forwarded to 127.0.0.1:8080 on this machine."},
{&model.Forward{Type: model.ForwardDynamic, LocalAddr: "127.0.0.1", LocalPort: 1080}, "SOCKS proxy on 127.0.0.1:1080 will route traffic through ."},
}
for _, tt := range tests {
got := tt.fwd.ForwardHumanExplanation("")
if got != tt.want {
t.Fatalf("ForwardHumanExplanation() = %q, want %q", got, tt.want)
}
}
}
func TestForwardListenTarget(t *testing.T) {
tests := []struct {
fwd *model.Forward
listen string
target string
}{
{&model.Forward{Type: model.ForwardLocal, LocalAddr: "127.0.0.1", LocalPort: 15432, RemoteAddr: "127.0.0.1", RemotePort: 5432}, "127.0.0.1:15432", "127.0.0.1:5432"},
{&model.Forward{Type: model.ForwardRemote, LocalAddr: "127.0.0.1", LocalPort: 8080, RemoteAddr: "0.0.0.0", RemotePort: 80}, "0.0.0.0:80", "127.0.0.1:8080"},
{&model.Forward{Type: model.ForwardDynamic, LocalAddr: "127.0.0.1", LocalPort: 1080}, "127.0.0.1:1080", "SOCKS"},
}
for _, tt := range tests {
if got := tt.fwd.ForwardListen(); got != tt.listen {
t.Fatalf("ForwardListen() = %q, want %q", got, tt.listen)
}
if got := tt.fwd.ForwardTarget(); got != tt.target {
t.Fatalf("ForwardTarget() = %q, want %q", got, tt.target)
}
}
}

View File

@ -194,6 +194,7 @@ const (
screenForwardForm screenForwardForm
screenTunnelManager screenTunnelManager
screenConfirm screenConfirm
screenFullHelp
) )
// --- Result type — returned from TUI to caller --- // --- Result type — returned from TUI to caller ---
@ -237,6 +238,7 @@ type tuiModel struct {
forwardForm *forwardFormModel forwardForm *forwardFormModel
confirmMsg string confirmMsg string
confirmAction func() tea.Cmd confirmAction func() tea.Cmd
fullHelp *fullHelpModel
} }
func New(servers []*model.Server) *tuiModel { func New(servers []*model.Server) *tuiModel {
items := make([]list.Item, len(servers)) items := make([]list.Item, len(servers))
@ -516,6 +518,8 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateTunnelManager(msg) return m.updateTunnelManager(msg)
case screenConfirm: case screenConfirm:
return m.updateConfirm(msg) return m.updateConfirm(msg)
case screenFullHelp:
return m.updateFullHelp(msg)
} }
} }
@ -609,8 +613,8 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case tea.KeyF1: case tea.KeyF1:
m.helpScreen = newHelpScreenModel(m.width, m.height) m.fullHelp = newFullHelpModel(m.width, m.height)
m.screen = screenHelp m.screen = screenFullHelp
return m, nil return m, nil
case tea.KeyCtrlW: case tea.KeyCtrlW:
@ -971,6 +975,11 @@ func (m *tuiModel) View() string {
b.WriteString(m.helpScreen.View()) b.WriteString(m.helpScreen.View())
} }
case screenFullHelp:
if m.fullHelp != nil {
b.WriteString(m.fullHelp.View())
}
case screenActionMenu: case screenActionMenu:
if m.actionMenu != nil { if m.actionMenu != nil {
b.WriteString(m.actionMenu.View()) b.WriteString(m.actionMenu.View())
@ -1280,6 +1289,19 @@ func (m *tuiModel) viewConfirm() string {
return b.String() return b.String()
} }
func (m *tuiModel) updateFullHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
updated, _ := m.fullHelp.Update(msg)
if fh, ok := updated.(*fullHelpModel); ok {
m.fullHelp = fh
}
if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEnter {
m.screen = screenList
m.fullHelp = nil
return m, nil
}
return m, nil
}
func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.Type == tea.KeyEsc { if msg.Type == tea.KeyEsc {
m.screen = screenForwardList m.screen = screenForwardList
@ -1766,7 +1788,8 @@ func (m *tuiModel) listHelpItems(selectedCount int, hasBackgroundResult bool) []
helpItem{Key: "Ctrl+E", Action: "edit"}, helpItem{Key: "Ctrl+E", Action: "edit"},
helpItem{Key: "Ctrl+F", Action: "search"}, helpItem{Key: "Ctrl+F", Action: "search"},
helpItem{Key: "Ins", Action: insAction}, helpItem{Key: "Ins", Action: insAction},
helpItem{Key: "?", Action: "help"}, helpItem{Key: "?", Action: "hotkeys"},
helpItem{Key: "F1", Action: "help"},
helpItem{Key: "Ctrl+Q", Action: "quit"}, helpItem{Key: "Ctrl+Q", Action: "quit"},
) )
return items return items

View File

@ -480,7 +480,7 @@ func (fm *forwardFormModel) View() string {
b.WriteString(fm.descInput.View()) b.WriteString(fm.descInput.View())
b.WriteString("\n\n") b.WriteString("\n\n")
// Type selector — visible radio items // Type selector — visible radio items with descriptions
b.WriteString(sectionStyle.Render("Type")) b.WriteString(sectionStyle.Render("Type"))
b.WriteString("\n") b.WriteString("\n")
for i, t := range forwardTypes { for i, t := range forwardTypes {
@ -494,6 +494,17 @@ func (fm *forwardFormModel) View() string {
b.WriteString(style.Render(line)) b.WriteString(style.Render(line))
b.WriteString("\n") b.WriteString("\n")
} }
// Show human-readable explanation for selected type
if fm.typeIdx >= 0 && fm.typeIdx < len(forwardTypes) {
explanations := map[model.ForwardType]string{
model.ForwardLocal: "Opens a local port on this machine and forwards it through SSH to the target address.",
model.ForwardRemote: "Opens a port on the remote SSH server and forwards it back to this machine.",
model.ForwardDynamic: "Creates a local SOCKS proxy that routes all traffic through the SSH server.",
}
if exp, ok := explanations[forwardTypes[fm.typeIdx].value]; ok {
b.WriteString(helpStyle.Render(fmt.Sprintf(" %s\n", exp)))
}
}
b.WriteString("\n") b.WriteString("\n")
// Dynamic fields based on type // Dynamic fields based on type

View File

@ -3,12 +3,13 @@ package tui
import ( import (
"fmt" "fmt"
"io" "io"
"strings"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbletea" "github.com/charmbracelet/bubbletea"
) )
// --- Help screen --- // --- Help screen (?) ---
type helpScreenModel struct { type helpScreenModel struct {
list list.Model list list.Model
@ -17,31 +18,24 @@ type helpScreenModel struct {
func newHelpScreenModel(w, h int) *helpScreenModel { func newHelpScreenModel(w, h int) *helpScreenModel {
items := []list.Item{ items := []list.Item{
helpScreenItem{key: "Enter", action: "Connect to server", section: "Navigation"}, helpScreenItem{key: "Enter", action: "Connect / Confirm", section: "Actions"},
helpScreenItem{key: "↑/↓", action: "Navigate list", section: "Navigation"}, helpScreenItem{key: "Esc", action: "Back / Cancel", section: "Actions"},
helpScreenItem{key: "Tab/↓", action: "Next field", section: "Forms"}, helpScreenItem{key: "Tab/↓", action: "Next field", section: "Forms"},
helpScreenItem{key: "Shift+Tab/↑", action: "Previous field", section: "Forms"}, helpScreenItem{key: "Shift+Tab/↑", action: "Previous field", section: "Forms"},
helpScreenItem{key: "/", action: "Open dropdown picker", section: "Forms"}, helpScreenItem{key: "/", action: "Open dropdown picker", section: "Forms"},
helpScreenItem{key: "Esc", action: "Back / Cancel", section: "Navigation"}, helpScreenItem{key: "Ctrl+A", action: "Add server", section: "Server list"},
helpScreenItem{key: "Ctrl+A", action: "Add server", section: "Actions"}, helpScreenItem{key: "Ctrl+E", action: "Edit server", section: "Server list"},
helpScreenItem{key: "Ctrl+E", action: "Edit server", section: "Actions"}, helpScreenItem{key: "Ctrl+F", action: "Search", section: "Server list"},
helpScreenItem{key: "Ctrl+W", action: "Manage port forwards", section: "Actions"}, helpScreenItem{key: "Ctrl+X", action: "Action menu", section: "Server list"},
helpScreenItem{key: "Ctrl+X", action: "Action menu (delete, test, tags, tunnel)", section: "Actions"}, helpScreenItem{key: "Ins", action: "Select / deselect", section: "Server list"},
helpScreenItem{key: "Ctrl+D", action: "Delete server", section: "Actions"}, helpScreenItem{key: "Ctrl+W", action: "Manage port forwards", section: "Forwards"},
helpScreenItem{key: "Ctrl+T", action: "Test connection", section: "Actions"}, helpScreenItem{key: "?", action: "This quick help", section: "Other"},
helpScreenItem{key: "Ctrl+F", action: "Search", section: "Actions"}, helpScreenItem{key: "F1", action: "Full documentation", section: "Other"},
helpScreenItem{key: "Ctrl+G", action: "Tags manager", section: "Actions"}, helpScreenItem{key: "Ctrl+Q", action: "Quit", section: "Other"},
helpScreenItem{key: "Ctrl+P", action: "Templates manager", section: "Actions"},
helpScreenItem{key: "Ctrl+R", action: "Run template", section: "Templates"},
helpScreenItem{key: "Ctrl+B", action: "Run in background", section: "Templates"},
helpScreenItem{key: "Ins", action: "Select / deselect", section: "Selection"},
helpScreenItem{key: "Ctrl+X", action: "Action menu", section: "Actions"},
helpScreenItem{key: "?", action: "This help screen", section: "Navigation"},
helpScreenItem{key: "Ctrl+Q", action: "Quit", section: "Navigation"},
} }
l := list.New(items, helpScreenDelegate{}, w, h-4) l := list.New(items, helpScreenDelegate{}, w, h-4)
l.Title = "sshkeeper — Help" l.Title = "sshkeeper — Quick Help"
l.SetShowStatusBar(false) l.SetShowStatusBar(false)
l.SetFilteringEnabled(false) l.SetFilteringEnabled(false)
l.Styles.Title = titleStyle l.Styles.Title = titleStyle
@ -88,7 +82,7 @@ func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg: case tea.KeyMsg:
switch msg.Type { switch msg.Type {
case tea.KeyEsc, tea.KeyEnter: case tea.KeyEsc, tea.KeyEnter:
return m, nil // caller checks screen transition return m, nil
} }
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
@ -104,6 +98,167 @@ func (m *helpScreenModel) View() string {
return m.list.View() return m.list.View()
} }
// --- Full help (F1) ---
type fullHelpModel struct {
width int
height int
offset int
}
func newFullHelpModel(w, h int) *fullHelpModel {
return &fullHelpModel{width: w, height: h}
}
func (m *fullHelpModel) Init() tea.Cmd { return nil }
func (m *fullHelpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEsc, tea.KeyEnter:
return m, nil
case tea.KeyRunes:
switch msg.String() {
case "q", "Q":
return m, nil
case "j", "J":
m.offset++
case "k", "K":
if m.offset > 0 {
m.offset--
}
}
case tea.KeyDown:
m.offset++
case tea.KeyUp:
if m.offset > 0 {
m.offset--
}
case tea.KeyHome:
m.offset = 0
case tea.KeyEnd:
m.offset = 100
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
}
return m, nil
}
func (m *fullHelpModel) View() string {
var b strings.Builder
b.WriteString(titleStyle.Render("sshkeeper — Full Help"))
b.WriteString("\n\n")
sections := []struct {
title string
rows [][2]string
}{
{"What is sshkeeper", [][2]string{
{"", "sshkeeper is a Linux console SSH connection manager."},
{"", "It stores server profiles, secrets, and launches the system ssh client."},
{"", ""},
}},
{"Navigation", [][2]string{
{"↑/↓", "Move through list"},
{"Tab/↓", "Next field"},
{"Shift+Tab/↑", "Previous field"},
{"/", "Open dropdown picker"},
}},
{"Global actions", [][2]string{
{"Enter", "Select / Confirm / Open"},
{"Esc", "Back / Cancel / Close"},
{"?", "Quick help (hotkeys)"},
{"F1", "Full documentation"},
{"Ctrl+Q", "Quit"},
}},
{"Server list", [][2]string{
{"Enter", "Connect to server"},
{"Ctrl+A", "Add server"},
{"Ctrl+E", "Edit server"},
{"Ctrl+F", "Search"},
{"Ctrl+X", "Action menu"},
{"Ins", "Select / deselect"},
}},
{"Action menu (Ctrl+X)", [][2]string{
{"Connect", "Standard SSH session"},
{"Connect with tunnels", "SSH + all enabled forwards"},
{"Start tunnels only", "Forwards without shell"},
{"Start tunnels in bg", "Background tunnel process"},
{"Manage port forwards", "Add / edit / delete forwards"},
{"Manage tunnels", "View and stop running tunnels"},
{"Manage route", "Configure ProxyJump / bastions"},
{"Test connection", "Check if server is reachable"},
{"Edit", "Edit server profile"},
{"Delete", "Remove server profile"},
}},
{"Routes / ProxyJump", [][2]string{
{"", "Routes define how to reach a server through jump hosts."},
{"● direct", "No jump host"},
{"→ via", "One bastion"},
{"⇒ chain", "Multiple bastions"},
{"", ""},
{"CLI:", "sshkeeper route set <alias> --jumps bastion"},
}},
{"Port forwarding", [][2]string{
{"", "A forward is a saved rule — just configuration."},
{"Local", "Local port → remote service"},
{"Remote", "Remote port → local service"},
{"SOCKS", "Dynamic SOCKS proxy through SSH"},
{"", ""},
{"Ctrl+A", "Add forward"},
{"Enter/Ctrl+E", "Edit forward"},
{"Ctrl+D", "Delete forward (with confirmation)"},
}},
{"Tunnels", [][2]string{
{"", "A tunnel is a running SSH process that activates forwards."},
{"", ""},
{"CLI:", "sshkeeper tunnel <alias>"},
{"CLI:", "sshkeeper tunnel <alias> --forward-only"},
{"CLI:", "sshkeeper tunnel <alias> --background"},
}},
}
for _, sec := range sections {
b.WriteString(sectionStyle.Render(sec.title))
b.WriteString("\n")
for _, row := range sec.rows {
if row[0] == "" {
b.WriteString(fmt.Sprintf(" %s\n", row[1]))
} else {
b.WriteString(fmt.Sprintf(" %-16s %s\n", row[0], row[1]))
}
}
b.WriteString("\n")
}
b.WriteString(helpStyle.Render(" ↑/↓ scroll — q/Esc/Enter close"))
// Simple scroll
lines := strings.Split(b.String(), "\n")
maxLines := m.height - 1
if maxLines < 5 {
maxLines = 5
}
start := m.offset
if start > len(lines)-maxLines {
start = len(lines) - maxLines
}
if start < 0 {
start = 0
}
end := start + maxLines
if end > len(lines) {
end = len(lines)
}
return strings.Join(lines[start:end], "\n")
}
// --- Action menu --- // --- Action menu ---
type actionMenuItem struct { type actionMenuItem struct {