diff --git a/README.md b/README.md index 24e8ef9..34a9520 100644 --- a/README.md +++ b/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 ### Connect vs Tunnel | Action | Command | TUI | Description | -|---|---|---|---| +|--------|---------|-----|-------------| | Connect | `sshkeeper connect ` | `Enter` | Standard SSH session, no port forwards | | Connect with tunnels | `sshkeeper tunnel ` | Action menu → Connect with tunnels | SSH session with all enabled forwards active | | Start tunnels only | `sshkeeper tunnel --forward-only` | Action menu → Start tunnels only | Foreground tunnel, no shell | @@ -183,68 +214,6 @@ sshkeeper tunnel stop | 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 diff --git a/docs/guide.md b/docs/guide.md index bb55ad9..772557a 100644 --- a/docs/guide.md +++ b/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 @@ -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 | Удаление (с подтверждением) | ### Формы (добавление/редактирование) diff --git a/internal/ssh/command_test.go b/internal/ssh/command_test.go index a873984..90d56e9 100644 --- a/internal/ssh/command_test.go +++ b/internal/ssh/command_test.go @@ -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) + } + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index fc603d8..e4cbf13 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -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 diff --git a/internal/tui/forward.go b/internal/tui/forward.go index 92d4b7c..99f887b 100644 --- a/internal/tui/forward.go +++ b/internal/tui/forward.go @@ -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 diff --git a/internal/tui/help_screen.go b/internal/tui/help_screen.go index cea28cf..1d0e061 100644 --- a/internal/tui/help_screen.go +++ b/internal/tui/help_screen.go @@ -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 --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 "}, + {"CLI:", "sshkeeper tunnel --forward-only"}, + {"CLI:", "sshkeeper tunnel --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 {