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:
parent
216af78d4d
commit
94e10171d2
188
README.md
188
README.md
|
|
@ -30,11 +30,28 @@ port forwarding management.
|
|||
|
||||
## 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
|
||||
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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## Common CLI Commands
|
||||
## TUI
|
||||
|
||||
```bash
|
||||
# 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
|
||||
Running `sshkeeper` without arguments opens the TUI.
|
||||
|
||||
# Or use the interactive CLI prompt
|
||||
sshkeeper add
|
||||
### Main Window
|
||||
|
||||
# Inspect profiles
|
||||
sshkeeper list
|
||||
sshkeeper show web
|
||||
sshkeeper search prod
|
||||
```
|
||||
sshkeeper 0 servers
|
||||
Vault unlocked | 0 OK | 0 FAIL
|
||||
|
||||
# Connect and test
|
||||
sshkeeper connect web
|
||||
sshkeeper c web
|
||||
sshkeeper test web
|
||||
sshkeeper run web "uptime"
|
||||
NAME ALIAS ROUTE AUTH GROUP STATUS
|
||||
|
||||
# Groups and templates
|
||||
sshkeeper group list
|
||||
sshkeeper template list
|
||||
sshkeeper template add uptime "uptime"
|
||||
sshkeeper run-template web uptime
|
||||
No servers yet. Press Ctrl+A to add one.
|
||||
|
||||
# Tags and startup command
|
||||
sshkeeper add web --host 10.0.0.10 --user deploy --auth key --tags prod,web --startup-command "tmux attach -t ops"
|
||||
sshkeeper edit web --tags prod,web --startup-command "tmux attach -t ops"
|
||||
Enter: connect | Ctrl+X: actions | Ctrl+A: add | Ctrl+E: edit
|
||||
Ctrl+F: search | Ins: select | ?: hotkeys | F1: help | Ctrl+Q: quit
|
||||
```
|
||||
|
||||
# OpenSSH config
|
||||
sshkeeper ssh-config generate
|
||||
sshkeeper ssh-config install-include
|
||||
### Quick Help (?)
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -145,9 +173,12 @@ sshkeeper forward list web
|
|||
```
|
||||
|
||||
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
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| **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.
|
||||
|
||||
|
|
@ -175,7 +206,7 @@ sshkeeper tunnel stop <id>
|
|||
### Connect vs Tunnel
|
||||
|
||||
| Action | Command | TUI | Description |
|
||||
|---|---|---|---|
|
||||
|--------|---------|-----|-------------|
|
||||
| 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 |
|
||||
| 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 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
|
||||
|
||||

|
||||
|
||||
### Edit Server
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Template Manager
|
||||
|
||||

|
||||
|
||||
### Route and Forwarding
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
| 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
|
||||
|
||||
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:
|
||||
|
||||
| Data | Default path |
|
||||
| --- | --- |
|
||||
|------|-------------|
|
||||
| Config | `~/.config/sshkeeper/config.toml` |
|
||||
| Database | `~/.local/share/sshkeeper/sshkeeper.db` |
|
||||
| Vault | `~/.local/share/sshkeeper/vault.bin` |
|
||||
|
|
@ -310,8 +279,11 @@ sshkeeper/
|
|||
├── internal/ssh/ # OpenSSH command building, PTY prompt handling
|
||||
├── internal/tui/ # Bubble Tea UI
|
||||
├── internal/vault/ # Encrypted vault
|
||||
├── internal/tunnel/ # Tunnel state management
|
||||
├── docs/guide.md # User guide
|
||||
├── build.sh # Build binary to bin/
|
||||
├── release.sh # Build release tarballs to dist/
|
||||
└── main.go
|
||||
```
|
||||
|
||||
## License
|
||||
|
|
|
|||
231
docs/guide.md
231
docs/guide.md
|
|
@ -40,21 +40,15 @@ sshkeeper — это консольный менеджер SSH-подключе
|
|||
- XChaCha20-Poly1305 (шифрование vault)
|
||||
- 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
|
||||
git clone git@git.mirv.top:mirivlad/sshkeeper.git
|
||||
|
|
@ -63,6 +57,7 @@ go build -o ~/.local/bin/sshkeeper .
|
|||
```
|
||||
|
||||
Или через скрипт:
|
||||
|
||||
```bash
|
||||
./build.sh # сборка в bin/
|
||||
./release.sh # сборка релизных архивов в dist/
|
||||
|
|
@ -70,6 +65,14 @@ go build -o ~/.local/bin/sshkeeper .
|
|||
|
||||
**Требования:** 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.
|
||||
|
||||
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` — прямое подключение
|
||||
- `→ bastion → user@host:port` — через бастион
|
||||
- `→ bastion → … → user@host:port` — через цепочку
|
||||
- **AUTH** — метод аутентификации (key/password/agent/key+phrase)
|
||||
- **GROUP** — группа сервера
|
||||
- **STATUS** — результат последнего теста (OK/FAIL/?)
|
||||
|
||||
| Столбец | Описание |
|
||||
|---------|----------|
|
||||
| NAME | Отображаемое имя сервера |
|
||||
| ALIAS | Уникальный идентификатор |
|
||||
| ROUTE | Маршрут подключения (● direct, → via, ⇒ chain) |
|
||||
| AUTH | Метод аутентификации (key/password/agent/key+phrase) |
|
||||
| GROUP | Группа сервера |
|
||||
| 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
|
||||
↑/↓ Navigate list
|
||||
Tab/↓ Next field
|
||||
Shift+Tab/↑ Previous field
|
||||
/ Open dropdown picker
|
||||
Esc Back / Cancel
|
||||
Ctrl+A Add server
|
||||
Ctrl+E Edit server
|
||||
Ctrl+W Manage port forwards
|
||||
Ctrl+X Action menu
|
||||
? This help screen
|
||||
Ctrl+Q Quit
|
||||
Navigation
|
||||
↑/↓ 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
|
||||
Shift+Tab / ↑ Previous field
|
||||
/ Open dropdown picker
|
||||
|
||||
Server list
|
||||
Enter Connect to server
|
||||
Ctrl+A Add server
|
||||
Ctrl+E Edit server
|
||||
Ctrl+F Search
|
||||
Ctrl+X Action menu
|
||||
Ins Select / deselect
|
||||
|
||||
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 — выбрать из существующих групп
|
||||
- `Enter` на кнопке Test — проверить подключение
|
||||
- `Enter` на кнопке Save — сохранить
|
||||
- `Esc` — отмена
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---------|----------|
|
||||
| `Tab` или `↓` | Следующее поле |
|
||||
| `Shift+Tab` или `↑` | Предыдущее поле |
|
||||
| `/` на Auth Method | Выбрать из списка (password/key/key_passphrase/agent) |
|
||||
| `/` на 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:
|
||||
|
||||
```
|
||||
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`
|
||||
|
||||
**Через CLI:**
|
||||
|
||||
```bash
|
||||
sshkeeper route set web --jumps bastion
|
||||
sshkeeper route set prod --jumps bastion,dmz-gw
|
||||
|
|
@ -274,7 +348,7 @@ sshkeeper route clear web
|
|||
|
||||
### Ключевое различие
|
||||
|
||||
- **Port Forward** — сохранённое правило проброса порода. Просто конфигурация, не запускает процесс.
|
||||
- **Port Forward** — сохранённое правило проброса порта. Просто конфигурация, не запускает процесс.
|
||||
- **Tunnel** — запущенный SSH-процесс, который активирует один или несколько forwards.
|
||||
|
||||
Аналогия: 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
|
||||
|
||||
|
|
@ -331,6 +408,8 @@ Add Port Forward
|
|||
2. Remote port on SSH server → service on my machine
|
||||
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 Port: 15432
|
||||
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`, появится предупреждение.
|
||||
|
||||
|
|
@ -362,6 +444,7 @@ Add Port Forward
|
|||
- **Start tunnels in background** — туннель в фоне с PID
|
||||
|
||||
**Через CLI:**
|
||||
|
||||
```bash
|
||||
# SSH-сессия с туннелями
|
||||
sshkeeper tunnel web
|
||||
|
|
@ -389,6 +472,7 @@ Tunnel Manager
|
|||
```
|
||||
|
||||
**Через CLI:**
|
||||
|
||||
```bash
|
||||
sshkeeper tunnel list
|
||||
sshkeeper tunnel stop <id>
|
||||
|
|
@ -602,21 +686,24 @@ sshkeeper connect secure
|
|||
| `Ctrl+F` | Поиск |
|
||||
| `Ctrl+X` | Меню действий |
|
||||
| `Ins` | Выбрать/снять выбор |
|
||||
| `?` / `F1` | Помощь |
|
||||
| `?` | Краткая справка по клавишам |
|
||||
| `F1` | Полная справка по приложению |
|
||||
| `Ctrl+Q` | Выход |
|
||||
|
||||
### Меню действий (Ctrl+X)
|
||||
|
||||
- **Connect** — стандартное SSH-подключение
|
||||
- **Connect with tunnels** — SSH + все активные forwards
|
||||
- **Start tunnels only** — туннель без shell
|
||||
- **Start tunnels in background** — фоновый туннель
|
||||
- **Manage port forwards** — управление forwards
|
||||
- **Manage tunnels** — список туннелей
|
||||
- **Manage route** — настройка маршрута
|
||||
- **Test connection** — проверка подключения
|
||||
- **Edit** — редактирование сервера
|
||||
- **Delete** — удаление (с подтверждением)
|
||||
| Действие | Описание |
|
||||
|----------|----------|
|
||||
| Connect | Стандартное SSH-подключение |
|
||||
| Connect with tunnels | SSH + все активные forwards |
|
||||
| Start tunnels only | Туннель без shell |
|
||||
| Start tunnels in background | Фоновый туннель |
|
||||
| Manage port forwards | Управление forwards |
|
||||
| Manage tunnels | Список туннелей |
|
||||
| Manage route | Настройка маршрута |
|
||||
| Test connection | Проверка подключения |
|
||||
| Edit | Редактирование сервера |
|
||||
| Delete | Удаление (с подтверждением) |
|
||||
|
||||
### Формы (добавление/редактирование)
|
||||
|
||||
|
|
|
|||
|
|
@ -13,16 +13,7 @@ import (
|
|||
|
||||
func TestKeyPassphraseTestUsesVaultSecret(t *testing.T) {
|
||||
script := filepath.Join(t.TempDir(), "fake-ssh")
|
||||
if err := os.WriteFile(script, []byte(`#!/bin/sh
|
||||
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 {
|
||||
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 {
|
||||
t.Fatalf("write fake ssh: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -86,9 +77,7 @@ func TestConnectRunsStartupCommand(t *testing.T) {
|
|||
dir := t.TempDir()
|
||||
argsFile := filepath.Join(dir, "args")
|
||||
script := filepath.Join(dir, "fake-ssh")
|
||||
if err := os.WriteFile(script, []byte(fmt.Sprintf(`#!/bin/sh
|
||||
printf '%%s\n' "$@" > %q
|
||||
`, argsFile)), 0o700); err != nil {
|
||||
if err := os.WriteFile(script, []byte(fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > %q\n", argsFile)), 0o700); err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@ const (
|
|||
screenForwardForm
|
||||
screenTunnelManager
|
||||
screenConfirm
|
||||
screenFullHelp
|
||||
)
|
||||
|
||||
// --- Result type — returned from TUI to caller ---
|
||||
|
|
@ -237,6 +238,7 @@ type tuiModel struct {
|
|||
forwardForm *forwardFormModel
|
||||
confirmMsg string
|
||||
confirmAction func() tea.Cmd
|
||||
fullHelp *fullHelpModel
|
||||
}
|
||||
func New(servers []*model.Server) *tuiModel {
|
||||
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)
|
||||
case screenConfirm:
|
||||
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:
|
||||
m.helpScreen = newHelpScreenModel(m.width, m.height)
|
||||
m.screen = screenHelp
|
||||
m.fullHelp = newFullHelpModel(m.width, m.height)
|
||||
m.screen = screenFullHelp
|
||||
return m, nil
|
||||
|
||||
case tea.KeyCtrlW:
|
||||
|
|
@ -971,6 +975,11 @@ func (m *tuiModel) View() string {
|
|||
b.WriteString(m.helpScreen.View())
|
||||
}
|
||||
|
||||
case screenFullHelp:
|
||||
if m.fullHelp != nil {
|
||||
b.WriteString(m.fullHelp.View())
|
||||
}
|
||||
|
||||
case screenActionMenu:
|
||||
if m.actionMenu != nil {
|
||||
b.WriteString(m.actionMenu.View())
|
||||
|
|
@ -1280,6 +1289,19 @@ func (m *tuiModel) viewConfirm() 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) {
|
||||
if msg.Type == tea.KeyEsc {
|
||||
m.screen = screenForwardList
|
||||
|
|
@ -1759,14 +1781,15 @@ func (m *tuiModel) listHelpItems(selectedCount int, hasBackgroundResult bool) []
|
|||
if hasBackgroundResult {
|
||||
items = append(items, helpItem{Key: "Esc", Action: "clear result"})
|
||||
}
|
||||
items = append(items,
|
||||
items = append(items,
|
||||
helpItem{Key: "Enter", Action: "connect"},
|
||||
helpItem{Key: "Ctrl+X", Action: "actions"},
|
||||
helpItem{Key: "Ctrl+A", Action: "add"},
|
||||
helpItem{Key: "Ctrl+E", Action: "edit"},
|
||||
helpItem{Key: "Ctrl+F", Action: "search"},
|
||||
helpItem{Key: "Ins", Action: insAction},
|
||||
helpItem{Key: "?", Action: "help"},
|
||||
helpItem{Key: "?", Action: "hotkeys"},
|
||||
helpItem{Key: "F1", Action: "help"},
|
||||
helpItem{Key: "Ctrl+Q", Action: "quit"},
|
||||
)
|
||||
return items
|
||||
|
|
|
|||
|
|
@ -480,7 +480,7 @@ func (fm *forwardFormModel) View() string {
|
|||
b.WriteString(fm.descInput.View())
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Type selector — visible radio items
|
||||
// Type selector — visible radio items with descriptions
|
||||
b.WriteString(sectionStyle.Render("Type"))
|
||||
b.WriteString("\n")
|
||||
for i, t := range forwardTypes {
|
||||
|
|
@ -494,6 +494,17 @@ func (fm *forwardFormModel) View() string {
|
|||
b.WriteString(style.Render(line))
|
||||
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")
|
||||
|
||||
// Dynamic fields based on type
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@ package tui
|
|||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// --- Help screen ---
|
||||
// --- Help screen (?) ---
|
||||
|
||||
type helpScreenModel struct {
|
||||
list list.Model
|
||||
|
|
@ -17,31 +18,24 @@ type helpScreenModel struct {
|
|||
|
||||
func newHelpScreenModel(w, h int) *helpScreenModel {
|
||||
items := []list.Item{
|
||||
helpScreenItem{key: "Enter", action: "Connect to server", section: "Navigation"},
|
||||
helpScreenItem{key: "↑/↓", action: "Navigate list", section: "Navigation"},
|
||||
helpScreenItem{key: "Enter", action: "Connect / Confirm", section: "Actions"},
|
||||
helpScreenItem{key: "Esc", action: "Back / Cancel", section: "Actions"},
|
||||
helpScreenItem{key: "Tab/↓", action: "Next field", section: "Forms"},
|
||||
helpScreenItem{key: "Shift+Tab/↑", action: "Previous field", 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: "Actions"},
|
||||
helpScreenItem{key: "Ctrl+E", action: "Edit server", section: "Actions"},
|
||||
helpScreenItem{key: "Ctrl+W", action: "Manage port forwards", section: "Actions"},
|
||||
helpScreenItem{key: "Ctrl+X", action: "Action menu (delete, test, tags, tunnel)", section: "Actions"},
|
||||
helpScreenItem{key: "Ctrl+D", action: "Delete server", section: "Actions"},
|
||||
helpScreenItem{key: "Ctrl+T", action: "Test connection", section: "Actions"},
|
||||
helpScreenItem{key: "Ctrl+F", action: "Search", section: "Actions"},
|
||||
helpScreenItem{key: "Ctrl+G", action: "Tags manager", section: "Actions"},
|
||||
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"},
|
||||
helpScreenItem{key: "Ctrl+A", action: "Add server", section: "Server list"},
|
||||
helpScreenItem{key: "Ctrl+E", action: "Edit server", section: "Server list"},
|
||||
helpScreenItem{key: "Ctrl+F", action: "Search", section: "Server list"},
|
||||
helpScreenItem{key: "Ctrl+X", action: "Action menu", section: "Server list"},
|
||||
helpScreenItem{key: "Ins", action: "Select / deselect", section: "Server list"},
|
||||
helpScreenItem{key: "Ctrl+W", action: "Manage port forwards", section: "Forwards"},
|
||||
helpScreenItem{key: "?", action: "This quick help", section: "Other"},
|
||||
helpScreenItem{key: "F1", action: "Full documentation", section: "Other"},
|
||||
helpScreenItem{key: "Ctrl+Q", action: "Quit", section: "Other"},
|
||||
}
|
||||
|
||||
l := list.New(items, helpScreenDelegate{}, w, h-4)
|
||||
l.Title = "sshkeeper — Help"
|
||||
l.Title = "sshkeeper — Quick Help"
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetFilteringEnabled(false)
|
||||
l.Styles.Title = titleStyle
|
||||
|
|
@ -88,7 +82,7 @@ func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyEsc, tea.KeyEnter:
|
||||
return m, nil // caller checks screen transition
|
||||
return m, nil
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
|
|
@ -104,6 +98,167 @@ func (m *helpScreenModel) View() string {
|
|||
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 ---
|
||||
|
||||
type actionMenuItem struct {
|
||||
|
|
|
|||
Loading…
Reference in New Issue