fix: improve vault and key passphrase flow
This commit is contained in:
parent
257ce79d42
commit
4b996032a9
254
README.md
254
README.md
|
|
@ -1,25 +1,22 @@
|
|||
# sshkeeper
|
||||
|
||||
Консольный менеджер SSH-подключений для Linux. Управляет профилями серверов, секретами и запускает SSH-сессии через системный OpenSSH.
|
||||
`sshkeeper` is a Linux console manager for SSH profiles, secrets, and quick
|
||||
OpenSSH launches. It does not replace OpenSSH; it keeps connection metadata in a
|
||||
local SQLite database, keeps passwords/passphrases in an encrypted vault, and
|
||||
starts the system `ssh` client with the right options.
|
||||
|
||||
**sshkeeper не заменяет OpenSSH.** Он управляет профилями подключений, секретами и удобным запуском SSH-сессий.
|
||||
## Features
|
||||
|
||||
## Возможности
|
||||
- Bubble Tea TUI for daily interactive use.
|
||||
- CLI commands for scripting and quick edits.
|
||||
- Encrypted vault for SSH passwords and key passphrases.
|
||||
- Password and key-passphrase auth through a PTY prompt handler, without putting
|
||||
secrets in command-line arguments.
|
||||
- Key, SSH-agent, password, and key+passphrase auth modes.
|
||||
- Groups, tags, command templates, search, and OpenSSH config generation.
|
||||
- Import from `~/.ssh/config`.
|
||||
|
||||
- **TUI-интерфейс** на Bubble Tea — интерактивный терминальный интерфейс
|
||||
- **CLI-команды** для скриптов и быстрых операций
|
||||
- **Encrypted vault** (Argon2id + XChaCha20-Poly1305) для хранения паролей
|
||||
- **Парольная авторизация** через PTY-wrapper (без передачи пароля в argv)
|
||||
- **Подключение по ключу**, SSH-agent, key+passphrase
|
||||
- **Группы и теги** для организации серверов
|
||||
- **Шаблоны команд** для частых задач
|
||||
- **Генерация OpenSSH config** из профилей
|
||||
- **Импорт из ~/.ssh/config**
|
||||
- **Тестирование подключения** без сохранения
|
||||
|
||||
## Установка
|
||||
|
||||
### Из исходников
|
||||
## Install
|
||||
|
||||
```bash
|
||||
git clone https://git.mirv.top/mirivlad/sshkeeper.git
|
||||
|
|
@ -27,177 +24,140 @@ cd sshkeeper
|
|||
go build -o ~/.local/bin/sshkeeper .
|
||||
```
|
||||
|
||||
Требования: Go 1.25+, Linux x86_64
|
||||
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,
|
||||
database, and vault, then asks for a master password.
|
||||
|
||||
```bash
|
||||
# Первый запуск — создание vault и мастер-пароля
|
||||
sshkeeper init
|
||||
|
||||
# Или сразу запустить TUI (vault создастся автоматически)
|
||||
sshkeeper
|
||||
```
|
||||
|
||||
# Добавить сервер
|
||||
sshkeeper add myserver --host 10.0.0.1 --user admin --auth key
|
||||
You can also initialize explicitly:
|
||||
|
||||
# Добавить сервер с паролем
|
||||
sshkeeper add prod-web --host 10.0.0.5 --user deploy --auth password --password
|
||||
```bash
|
||||
sshkeeper init
|
||||
```
|
||||
|
||||
# Показать список серверов
|
||||
## Common CLI Commands
|
||||
|
||||
```bash
|
||||
# Add profiles
|
||||
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
|
||||
|
||||
# Inspect profiles
|
||||
sshkeeper list
|
||||
sshkeeper show web
|
||||
sshkeeper search prod
|
||||
|
||||
# Подключиться к серверу
|
||||
sshkeeper connect myserver
|
||||
sshkeeper c myserver
|
||||
# Connect and test
|
||||
sshkeeper connect web
|
||||
sshkeeper c web
|
||||
sshkeeper test web
|
||||
sshkeeper run web "uptime"
|
||||
|
||||
# Проверить подключение
|
||||
sshkeeper test myserver
|
||||
|
||||
# Запустить команду на сервере
|
||||
sshkeeper run myserver "uptime"
|
||||
|
||||
# Группы
|
||||
# Groups and templates
|
||||
sshkeeper group list
|
||||
sshkeeper template list web
|
||||
|
||||
# Редактировать сервер
|
||||
sshkeeper edit myserver --host 10.0.0.2
|
||||
|
||||
# Удалить сервер
|
||||
sshkeeper delete myserver
|
||||
|
||||
# Импорт из ~/.ssh/config
|
||||
sshkeeper import
|
||||
|
||||
# Сгенерировать OpenSSH config
|
||||
# OpenSSH config
|
||||
sshkeeper ssh-config generate
|
||||
sshkeeper ssh-config install-include
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## TUI
|
||||
|
||||
Запуск без аргументов открывает интерактивный терминальный интерфейс:
|
||||
Running `sshkeeper` without arguments opens the TUI.
|
||||
|
||||
```bash
|
||||
sshkeeper
|
||||
```
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| Enter | Connect to selected server |
|
||||
| Ctrl+A | Add server |
|
||||
| Ctrl+E | Edit server |
|
||||
| Ctrl+D | Delete server |
|
||||
| Ctrl+T | Test connection |
|
||||
| Ctrl+F | Search |
|
||||
| Ctrl+Q / Ctrl+C | Quit |
|
||||
|
||||
Клавиши (работают на любой раскладке — используются Ctrl+комбинации):
|
||||
In add/edit forms:
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---------|----------|
|
||||
| Enter | Подключиться к серверу |
|
||||
| Ctrl+A | Добавить сервер |
|
||||
| Ctrl+E | Редактировать сервер |
|
||||
| Ctrl+D | Удалить сервер |
|
||||
| Ctrl+T | Проверить подключение |
|
||||
| Ctrl+F | Поиск |
|
||||
| Ctrl+Q / Ctrl+C | Выход |
|
||||
|
||||
В форме добавления/редактирования:
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---------|----------|
|
||||
| Tab/↓ | Следующее поле |
|
||||
| Shift+Tab/↑ | Предыдущее поле |
|
||||
| Enter | Перейти к кнопке / активировать |
|
||||
| Esc | Назад |
|
||||
|
||||
Кнопки **[Test]** и **[Save]**:
|
||||
- **Test** — проверяет подключение без сохранения
|
||||
- **Save** — сохраняет профиль (не требует тест)
|
||||
|
||||
## Хранение данных
|
||||
|
||||
XDG-совместимые Пути:
|
||||
|
||||
| Файл | Путь |
|
||||
|------|------|
|
||||
| База данных | `~/.local/share/sshkeeper/sshkeeper.db` |
|
||||
| Vault | `~/.local/share/sshkeeper/vault.bin` |
|
||||
| Конфиг | `~/.config/sshkeeper/config.toml` |
|
||||
| SSH config | `~/.ssh/config.d/sshkeeper.conf` |
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| Tab / Down | Next field |
|
||||
| Shift+Tab / Up | Previous field |
|
||||
| `/` on Auth Method or Group | Pick from list |
|
||||
| Enter | Move to action / activate |
|
||||
| Esc | Back |
|
||||
|
||||
## Vault
|
||||
|
||||
Vault — зашифрованное хранилище для паролей и passphrase.
|
||||
The vault stores SSH passwords and key passphrases encrypted on disk.
|
||||
|
||||
**Шифрование:** XChaCha20-Poly1305
|
||||
**KDF:** Argon2id (4 MiB, 2 iterations)
|
||||
- Cipher: XChaCha20-Poly1305.
|
||||
- KDF: Argon2id, currently 64 MiB memory, 3 iterations.
|
||||
- Existing legacy vault files remain readable.
|
||||
- Unlock state is process-local. `sshkeeper vault unlock` verifies the master
|
||||
password, but it does not keep future shell commands unlocked.
|
||||
|
||||
При первом запуске sshkeeper создаёт vault и запрашивает мастер-пароль. При последующих запусках — запрашивает мастер-пароль для разблокировки.
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
# Разблокировать vault вручную
|
||||
sshkeeper vault unlock
|
||||
|
||||
# Заблокировать
|
||||
sshkeeper vault lock
|
||||
|
||||
# Сменить мастер-пароль
|
||||
sshkeeper vault change-password
|
||||
|
||||
# Статус
|
||||
sshkeeper vault status
|
||||
sshkeeper vault unlock
|
||||
sshkeeper vault list
|
||||
sshkeeper vault delete <alias> [ssh_password|key_passphrase]
|
||||
sshkeeper vault change-password
|
||||
```
|
||||
|
||||
## CLI-команды
|
||||
`vault list`, `vault delete`, and `vault change-password` ask for the master
|
||||
password themselves because they need to decrypt the vault in the current
|
||||
process.
|
||||
|
||||
```
|
||||
sshkeeper TUI (по умолчанию)
|
||||
sshkeeper add [alias] Добавить сервер
|
||||
sshkeeper list Список серверов
|
||||
sshkeeper show <alias> Детали сервера
|
||||
sshkeeper edit <alias> Редактировать
|
||||
sshkeeper delete <alias> Удалить
|
||||
sshkeeper connect <alias> Подключиться (c — алиас)
|
||||
sshkeeper test <alias> Проверить подключение
|
||||
sshkeeper search <query> Поиск
|
||||
sshkeeper run <alias> <cmd> Выполнить команду
|
||||
sshkeeper import Импорт из ~/.ssh/config
|
||||
sshkeeper export Экспорт
|
||||
sshkeeper group list Группы
|
||||
sshkeeper vault [subcommand] Управление vault
|
||||
sshkeeper ssh-config generate Генерация SSH config
|
||||
```
|
||||
## Data Locations
|
||||
|
||||
## Сборка
|
||||
`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` |
|
||||
| Generated OpenSSH config | `~/.ssh/config.d/sshkeeper.conf` |
|
||||
|
||||
If `XDG_CONFIG_HOME` or `XDG_DATA_HOME` are set, sshkeeper stores data under
|
||||
`$XDG_CONFIG_HOME/sshkeeper` and `$XDG_DATA_HOME/sshkeeper`.
|
||||
|
||||
## Build And Test
|
||||
|
||||
```bash
|
||||
# Собрать
|
||||
make build
|
||||
|
||||
# Установить в ~/.local/bin
|
||||
make install
|
||||
|
||||
# Запуск без сборки
|
||||
go run .
|
||||
|
||||
# Тесты (если есть)
|
||||
go test ./...
|
||||
go build -o bin/sshkeeper .
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
`bin/` is ignored by git.
|
||||
|
||||
```
|
||||
## Project Layout
|
||||
|
||||
```text
|
||||
sshkeeper/
|
||||
├── main.go # Точка входа
|
||||
├── Makefile # Сборка
|
||||
├── cmd/ # CLI-команды
|
||||
│ ├── root.go # Root command, initApp
|
||||
│ ├── tui.go # TUI launcher
|
||||
│ ├── add.go, edit.go, ... # Команды
|
||||
│ └── vault.go # Vault management
|
||||
├── internal/
|
||||
│ ├── config/ # Конфигурация, XDG paths
|
||||
│ ├── db/ # SQLite, migrations, CRUD
|
||||
│ ├── model/ # Модели данных
|
||||
│ ├── vault/ # Encrypted vault (Argon2id + XChaCha20-Poly1305)
|
||||
│ ├── ssh/ # SSH connect, test, PTY-wrapper, import, configgen
|
||||
│ └── tui/ # Bubble Tea TUI
|
||||
└── go.mod
|
||||
├── cmd/ # Cobra CLI commands and TUI launcher
|
||||
├── internal/config/ # XDG paths and config loading
|
||||
├── internal/db/ # SQLite migrations and CRUD
|
||||
├── internal/model/ # Domain models
|
||||
├── internal/ssh/ # OpenSSH command building, PTY prompt handling
|
||||
├── internal/tui/ # Bubble Tea UI
|
||||
├── internal/vault/ # Encrypted vault
|
||||
└── main.go
|
||||
```
|
||||
|
||||
## Лицензия
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
|
|
|||
29
cmd/root.go
29
cmd/root.go
|
|
@ -5,10 +5,10 @@ import (
|
|||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/mirivlad/sshkeeper/internal/config"
|
||||
"github.com/mirivlad/sshkeeper/internal/db"
|
||||
"github.com/mirivlad/sshkeeper/internal/vault"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
|
|
@ -127,9 +127,8 @@ func initApp() {
|
|||
break
|
||||
}
|
||||
} else {
|
||||
// Vault exists — need to unlock
|
||||
// Skip unlock for vault commands that handle their own unlock/lock
|
||||
if isVaultSubcommand() {
|
||||
// Vault exists — unlock only for commands that may need secrets.
|
||||
if !commandRequiresStartupVaultUnlock(os.Args[1:]) {
|
||||
vaultInstance = v
|
||||
return
|
||||
}
|
||||
|
|
@ -161,19 +160,21 @@ func initApp() {
|
|||
}
|
||||
}
|
||||
|
||||
// isVaultSubcommand checks if the current command is a vault subcommand
|
||||
func isVaultSubcommand() bool {
|
||||
args := os.Args[1:]
|
||||
for _, arg := range args {
|
||||
if arg == "vault" {
|
||||
func commandRequiresStartupVaultUnlock(args []string) bool {
|
||||
if len(args) == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Skip for help
|
||||
|
||||
for _, arg := range args {
|
||||
if arg == "-h" || arg == "--help" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "connect", "c", "run", "run-template", "test", "add", "edit", "delete":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
package cmd
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCommandRequiresStartupVaultUnlock(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{name: "root tui", args: nil, want: true},
|
||||
{name: "connect", args: []string{"connect", "prod"}, want: true},
|
||||
{name: "short connect alias", args: []string{"c", "prod"}, want: true},
|
||||
{name: "add can store secrets", args: []string{"add", "prod"}, want: true},
|
||||
{name: "vault handles its own unlock", args: []string{"vault", "list"}, want: false},
|
||||
{name: "list only reads database", args: []string{"list"}, want: false},
|
||||
{name: "show only reads database", args: []string{"show", "prod"}, want: false},
|
||||
{name: "search only reads database", args: []string{"search", "prod"}, want: false},
|
||||
{name: "config path only reads config", args: []string{"config", "path"}, want: false},
|
||||
{name: "help", args: []string{"--help"}, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := commandRequiresStartupVaultUnlock(tt.args); got != tt.want {
|
||||
t.Fatalf("commandRequiresStartupVaultUnlock(%v) = %v; want %v", tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
54
cmd/vault.go
54
cmd/vault.go
|
|
@ -28,7 +28,7 @@ var vaultCmd = &cobra.Command{
|
|||
|
||||
var vaultUnlockCmd = &cobra.Command{
|
||||
Use: "unlock",
|
||||
Short: "Unlock the vault with master password",
|
||||
Short: "Verify the vault master password",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
v := getOrCreateVault()
|
||||
|
||||
|
|
@ -69,16 +69,14 @@ var vaultUnlockCmd = &cobra.Command{
|
|||
return fmt.Errorf("create vault: %w", err)
|
||||
}
|
||||
|
||||
// Unlock immediately
|
||||
if err := v.Unlock(string(pw1)); err != nil {
|
||||
return fmt.Errorf("unlock vault: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Vault created and unlocked.")
|
||||
fmt.Println("Vault created. Commands will ask for the master password when they need secrets.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unlock existing vault
|
||||
fmt.Print("Master password: ")
|
||||
pw, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println()
|
||||
|
|
@ -90,7 +88,7 @@ var vaultUnlockCmd = &cobra.Command{
|
|||
return fmt.Errorf("unlock vault: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Vault unlocked.")
|
||||
fmt.Println("Master password accepted. Vault unlock is process-local; commands will ask again when they need secrets.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
|
@ -111,11 +109,7 @@ var vaultStatusCmd = &cobra.Command{
|
|||
Short: "Show vault status",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
v := getOrCreateVault()
|
||||
if v.IsUnlocked() {
|
||||
fmt.Println("Vault: unlocked")
|
||||
} else {
|
||||
fmt.Println("Vault: locked")
|
||||
}
|
||||
fmt.Println(formatVaultStatus(v.IsUnlocked(), vault.Exists(config.VaultPath(cfg.DataDir))))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
|
@ -126,8 +120,8 @@ var vaultChangePasswordCmd = &cobra.Command{
|
|||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
v := getOrCreateVault()
|
||||
|
||||
if !v.IsUnlocked() {
|
||||
return fmt.Errorf("vault is locked. Unlock first with 'sshkeeper vault unlock'")
|
||||
if err := unlockVaultForCommand(v); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Print("New master password: ")
|
||||
|
|
@ -166,8 +160,8 @@ var vaultListCmd = &cobra.Command{
|
|||
Short: "List stored secret metadata",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
v := getOrCreateVault()
|
||||
if !v.IsUnlocked() {
|
||||
return fmt.Errorf("vault is locked. Unlock first with 'sshkeeper vault unlock'")
|
||||
if err := unlockVaultForCommand(v); err != nil {
|
||||
return err
|
||||
}
|
||||
output, err := formatVaultSecretsList(v)
|
||||
if err != nil {
|
||||
|
|
@ -190,8 +184,8 @@ var vaultDeleteCmd = &cobra.Command{
|
|||
}
|
||||
|
||||
v := getOrCreateVault()
|
||||
if !v.IsUnlocked() {
|
||||
return fmt.Errorf("vault is locked. Unlock first with 'sshkeeper vault unlock'")
|
||||
if err := unlockVaultForCommand(v); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteVaultSecrets(v, alias, secretType); err != nil {
|
||||
return err
|
||||
|
|
@ -208,6 +202,34 @@ var vaultDeleteCmd = &cobra.Command{
|
|||
},
|
||||
}
|
||||
|
||||
func unlockVaultForCommand(v *vault.Vault) error {
|
||||
if v.IsUnlocked() {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Print("Master password: ")
|
||||
pw, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read password: %w", err)
|
||||
}
|
||||
|
||||
if err := v.Unlock(string(pw)); err != nil {
|
||||
return fmt.Errorf("unlock vault: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatVaultStatus(unlocked bool, exists bool) string {
|
||||
if !exists {
|
||||
return "Vault: not found"
|
||||
}
|
||||
if unlocked {
|
||||
return "Vault: unlocked in current process"
|
||||
}
|
||||
return "Vault: locked (vault commands unlock per command)"
|
||||
}
|
||||
|
||||
func formatVaultSecretsList(v *vault.Vault) (string, error) {
|
||||
metas, err := v.ListSecrets()
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -38,3 +38,20 @@ func TestFormatVaultSecretsListHandlesEmptyVault(t *testing.T) {
|
|||
t.Fatalf("expected empty output message, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatVaultStatusExplainsProcessLocalUnlock(t *testing.T) {
|
||||
locked := formatVaultStatus(false, true)
|
||||
if !strings.Contains(locked, "locked") || !strings.Contains(locked, "per command") {
|
||||
t.Fatalf("expected locked status to explain per-command unlock, got %q", locked)
|
||||
}
|
||||
|
||||
unlocked := formatVaultStatus(true, true)
|
||||
if !strings.Contains(unlocked, "unlocked") || !strings.Contains(unlocked, "current process") {
|
||||
t.Fatalf("expected unlocked status to mention current process, got %q", unlocked)
|
||||
}
|
||||
|
||||
missing := formatVaultStatus(false, false)
|
||||
if !strings.Contains(missing, "not found") {
|
||||
t.Fatalf("expected missing status, got %q", missing)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,25 +50,11 @@ func defaultConfig() *Config {
|
|||
func Load() (*Config, error) {
|
||||
cfg := defaultConfig()
|
||||
|
||||
// XDG paths
|
||||
configDir := os.Getenv("XDG_CONFIG_HOME")
|
||||
if configDir == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
configDir, dataDir, err := resolveDirs(os.Getenv("XDG_CONFIG_HOME"), os.Getenv("XDG_DATA_HOME"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configDir = filepath.Join(home, ".config", "sshkeeper")
|
||||
}
|
||||
cfg.ConfigDir = configDir
|
||||
|
||||
dataDir := os.Getenv("XDG_DATA_HOME")
|
||||
if dataDir == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dataDir = filepath.Join(home, ".local", "share", "sshkeeper")
|
||||
}
|
||||
cfg.DataDir = dataDir
|
||||
|
||||
// Ensure dirs exist
|
||||
|
|
@ -104,3 +90,19 @@ func Load() (*Config, error) {
|
|||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func resolveDirs(configRoot, dataRoot string) (string, string, error) {
|
||||
if configRoot == "" || dataRoot == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if configRoot == "" {
|
||||
configRoot = filepath.Join(home, ".config")
|
||||
}
|
||||
if dataRoot == "" {
|
||||
dataRoot = filepath.Join(home, ".local", "share")
|
||||
}
|
||||
}
|
||||
return filepath.Join(configRoot, "sshkeeper"), filepath.Join(dataRoot, "sshkeeper"), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveDirsUsesAppSubdirectoriesUnderXDGRoots(t *testing.T) {
|
||||
configRoot := filepath.Join(t.TempDir(), "config")
|
||||
dataRoot := filepath.Join(t.TempDir(), "data")
|
||||
|
||||
configDir, dataDir, err := resolveDirs(configRoot, dataRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve dirs: %v", err)
|
||||
}
|
||||
|
||||
if configDir != filepath.Join(configRoot, "sshkeeper") {
|
||||
t.Fatalf("config dir = %q; want app dir under XDG config root", configDir)
|
||||
}
|
||||
if dataDir != filepath.Join(dataRoot, "sshkeeper") {
|
||||
t.Fatalf("data dir = %q; want app dir under XDG data root", dataDir)
|
||||
}
|
||||
}
|
||||
|
|
@ -24,12 +24,14 @@ func Connect(cfg *config.Config, server *model.Server, getVault VaultFunc) error
|
|||
return ConnectWithPassword(cfg.SSH.Binary, args, password)
|
||||
|
||||
case model.AuthKeyPassphrase:
|
||||
// For key+passphrase, let ssh-agent handle it or prompt normally
|
||||
// TODO: use ssh-agent or similar
|
||||
fallthrough
|
||||
passphrase, err := getVault(server.Alias, "key_passphrase")
|
||||
if err != nil {
|
||||
return fmt.Errorf("get key passphrase from vault: %w", err)
|
||||
}
|
||||
return ConnectWithPassword(cfg.SSH.Binary, args, passphrase)
|
||||
|
||||
default:
|
||||
// key, agent, key+passphrase - direct execution
|
||||
// key and agent auth use direct OpenSSH execution.
|
||||
cmd := exec.Command(cfg.SSH.Binary, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
|
|
@ -56,8 +58,16 @@ func Test(cfg *config.Config, server *model.Server, getVault VaultFunc) (bool, s
|
|||
}
|
||||
return testWithPassword(cfg, args, password)
|
||||
|
||||
case model.AuthKeyPassphrase:
|
||||
args = append(args, "-o", "NumberOfPasswordPrompts=1")
|
||||
passphrase, err := getVault(server.Alias, "key_passphrase")
|
||||
if err != nil {
|
||||
return false, fmt.Sprintf("vault error: %v", err)
|
||||
}
|
||||
return testWithPassword(cfg, args, passphrase)
|
||||
|
||||
default:
|
||||
// key, agent, key+passphrase
|
||||
// key and agent auth should not prompt during tests.
|
||||
args = append(args, "-o", "BatchMode=yes")
|
||||
args = append(args, cfg.SSH.TestCommand)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mirivlad/sshkeeper/internal/config"
|
||||
"github.com/mirivlad/sshkeeper/internal/model"
|
||||
)
|
||||
|
||||
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 {
|
||||
t.Fatalf("write fake ssh: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
SSH: config.SSHConfig{
|
||||
Binary: script,
|
||||
ConnectTimeoutSec: 2,
|
||||
TestCommand: "echo SSHKEEPER_OK",
|
||||
},
|
||||
}
|
||||
server := &model.Server{
|
||||
Alias: "prod",
|
||||
Host: "example.org",
|
||||
Port: 22,
|
||||
User: "root",
|
||||
AuthMethod: model.AuthKeyPassphrase,
|
||||
IdentityFile: "/tmp/test-key",
|
||||
}
|
||||
|
||||
ok, errText := Test(cfg, server, func(alias string, secretType string) (string, error) {
|
||||
if alias != "prod" || secretType != "key_passphrase" {
|
||||
return "", fmt.Errorf("unexpected secret lookup %s %s", alias, secretType)
|
||||
}
|
||||
return "key-secret", nil
|
||||
})
|
||||
|
||||
if !ok {
|
||||
t.Fatalf("expected key passphrase test to pass, error: %s", errText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyPassphraseTestReportsVaultError(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
SSH: config.SSHConfig{
|
||||
Binary: "ssh",
|
||||
ConnectTimeoutSec: 1,
|
||||
TestCommand: "echo SSHKEEPER_OK",
|
||||
},
|
||||
}
|
||||
server := &model.Server{
|
||||
Alias: "prod",
|
||||
Host: "example.org",
|
||||
Port: 22,
|
||||
User: "root",
|
||||
AuthMethod: model.AuthKeyPassphrase,
|
||||
}
|
||||
|
||||
ok, errText := Test(cfg, server, func(alias string, secretType string) (string, error) {
|
||||
return "", fmt.Errorf("missing secret")
|
||||
})
|
||||
|
||||
if ok {
|
||||
t.Fatal("expected key passphrase test to fail when vault lookup fails")
|
||||
}
|
||||
if !strings.Contains(errText, "vault error: missing secret") {
|
||||
t.Fatalf("expected vault error, got %q", errText)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue