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 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
![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
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

View File

@ -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 | Удаление (с подтверждением) |
### Формы (добавление/редактирование)

View File

@ -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)
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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 {