commit 883a1e66a7dede70288d0c89018a94ed9638e87e Author: mirivlad Date: Tue May 26 09:11:55 2026 +0800 Initial commit: sshkeeper v0.1.0 Console SSH connection manager for Linux. Features: - TUI (Bubble Tea) with server list, add/edit form, test/save - CLI commands: add, list, show, edit, delete, connect, test, search, import, export, run, group, template, vault, ssh-config - Encrypted vault (Argon2id + XChaCha20-Poly1305) for passwords - PTY-wrapper for password auth - SQLite (modernc, no CGO) for server profiles - XDG-compatible paths - OpenSSH config generation - Import from ~/.ssh/config diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0397937 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +*.db +*.bin +*.tmp +.vscode/ +.idea/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9a6ba15 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +APP=sshkeeper + +.PHONY: build run test vet fmt clean install + +build: + go build -o bin/$(APP) . + +run: + go run . + +vet: + go vet ./... + +fmt: + go fmt ./... + +test: + go test ./... + +clean: + rm -rf bin + +install: + go build -o $(HOME)/.local/bin/$(APP) . diff --git a/README.md b/README.md new file mode 100644 index 0000000..cbccc76 --- /dev/null +++ b/README.md @@ -0,0 +1,203 @@ +# sshkeeper + +Консольный менеджер SSH-подключений для Linux. Управляет профилями серверов, секретами и запускает SSH-сессии через системный OpenSSH. + +**sshkeeper не заменяет OpenSSH.** Он управляет профилями подключений, секретами и удобным запуском SSH-сессий. + +## Возможности + +- **TUI-интерфейс** на Bubble Tea — интерактивный терминальный интерфейс +- **CLI-команды** для скриптов и быстрых операций +- **Encrypted vault** (Argon2id + XChaCha20-Poly1305) для хранения паролей +- **Парольная авторизация** через PTY-wrapper (без передачи пароля в argv) +- **Подключение по ключу**, SSH-agent, key+passphrase +- **Группы и теги** для организации серверов +- **Шаблоны команд** для частых задач +- **Генерация OpenSSH config** из профилей +- **Импорт из ~/.ssh/config** +- **Тестирование подключения** без сохранения + +## Установка + +### Из исходников + +```bash +git clone https://git.mirv.top/mirivlad/sshkeeper.git +cd sshkeeper +go build -o ~/.local/bin/sshkeeper . +``` + +Требования: Go 1.25+, Linux x86_64 + +## Быстрый старт + +```bash +# Первый запуск — создание vault и мастер-пароля +sshkeeper init + +# Или сразу запустить TUI (vault создастся автоматически) +sshkeeper + +# Добавить сервер +sshkeeper add myserver --host 10.0.0.1 --user admin --auth key + +# Добавить сервер с паролем +sshkeeper add prod-web --host 10.0.0.5 --user deploy --auth password --password + +# Показать список серверов +sshkeeper list + +# Подключиться к серверу +sshkeeper connect myserver +sshkeeper c myserver + +# Проверить подключение +sshkeeper test myserver + +# Запустить команду на сервере +sshkeeper run myserver "uptime" + +# Группы +sshkeeper group list + +# Редактировать сервер +sshkeeper edit myserver --host 10.0.0.2 + +# Удалить сервер +sshkeeper delete myserver + +# Импорт из ~/.ssh/config +sshkeeper import + +# Сгенерировать OpenSSH config +sshkeeper ssh-config generate +sshkeeper ssh-config install-include +``` + +## TUI + +Запуск без аргументов открывает интерактивный терминальный интерфейс: + +```bash +sshkeeper +``` + +Клавиши: + +| Клавиша | Действие | +|---------|----------| +| Enter | Подключиться к серверу | +| a | Добавить сервер | +| e | Редктировать сервер | +| d | Удалить сервер | +| t | Проверить подключение | +| / | Поиск | +| q | Выход | + +В форме добавления/редактирования: + +| Клавиша | Действие | +|---------|----------| +| 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` | + +## Vault + +Vault — зашифрованное хранилище для паролей и passphrase. + +**Шифрование:** XChaCha20-Poly1305 +**KDF:** Argon2id (4 MiB, 2 iterations) + +При первом запуске sshkeeper создаёт vault и запрашивает мастер-пароль. При последующих запусках — запрашивает мастер-пароль для разблокировки. + +```bash +# Разблокировать vault вручную +sshkeeper vault unlock + +# Заблокировать +sshkeeper vault lock + +# Сменить мастер-пароль +sshkeeper vault change-password + +# Статус +sshkeeper vault status +``` + +## CLI-команды + +``` +sshkeeper TUI (по умолчанию) +sshkeeper add [alias] Добавить сервер +sshkeeper list Список серверов +sshkeeper show Детали сервера +sshkeeper edit Редактировать +sshkeeper delete Удалить +sshkeeper connect Подключиться (c — алиас) +sshkeeper test Проверить подключение +sshkeeper search Поиск +sshkeeper run Выполнить команду +sshkeeper import Импорт из ~/.ssh/config +sshkeeper export Экспорт +sshkeeper group list Группы +sshkeeper vault [subcommand] Управление vault +sshkeeper ssh-config generate Генерация SSH config +``` + +## Сборка + +```bash +# Собрать +make build + +# Установить в ~/.local/bin +make install + +# Запуск без сборки +go run . + +# Тесты (если есть) +go test ./... +``` + +## Структура проекта + +``` +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 +``` + +## Лицензия + +MIT diff --git a/cmd/add.go b/cmd/add.go new file mode 100644 index 0000000..df6b52f --- /dev/null +++ b/cmd/add.go @@ -0,0 +1,136 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/mirivlad/sshkeeper/internal/model" +) + +var addFlags struct { + host string + port int + user string + authMethod string + identityFile string + proxyJump string + groupName string + displayName string + notes string + tags string + password string +} + +var addCmd = &cobra.Command{ + Use: "add [alias]", + Short: "Add a new server", + Long: "Add a new server profile. If alias is provided with --host, non-interactive mode is used.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 && addFlags.host != "" { + return addNonInteractive(args[0]) + } + return fmt.Errorf("interactive add not yet implemented, use: sshkeeper add --host --user --auth ") + }, +} + +func addNonInteractive(alias string) error { + server := &model.Server{ + Alias: alias, + DisplayName: addFlags.displayName, + Host: addFlags.host, + Port: addFlags.port, + User: addFlags.user, + AuthMethod: model.AuthMethod(addFlags.authMethod), + IdentityFile: addFlags.identityFile, + ProxyJump: addFlags.proxyJump, + GroupName: addFlags.groupName, + Notes: addFlags.notes, + } + + if server.Port == 0 { + server.Port = 22 + } + if server.AuthMethod == "" { + server.AuthMethod = model.AuthKey + } + if server.DisplayName == "" { + server.DisplayName = alias + } + + // Handle password auth - store in vault + if server.AuthMethod == model.AuthPassword { + password := addFlags.password + if password == "" { + return fmt.Errorf("password auth requires --password flag or interactive mode") + } + + v := getOrCreateVault() + if !v.IsUnlocked() { + return fmt.Errorf("vault is locked. Run 'sshkeeper vault unlock' first") + } + + vaultKey := fmt.Sprintf("server:%s:ssh_password", alias) + if err := v.Put(vaultKey, "ssh_password", []byte(password)); err != nil { + return fmt.Errorf("store password in vault: %w", err) + } + if err := v.Save(); err != nil { + return fmt.Errorf("save vault: %w", err) + } + } + + // Handle key+passphrase - store passphrase in vault + if server.AuthMethod == model.AuthKeyPassphrase { + passphrase := addFlags.password + if passphrase == "" { + return fmt.Errorf("key+passphrase auth requires --password flag for the passphrase") + } + + v := getOrCreateVault() + if !v.IsUnlocked() { + return fmt.Errorf("vault is locked. Run 'sshkeeper vault unlock' first") + } + + vaultKey := fmt.Sprintf("server:%s:key_passphrase", alias) + if err := v.Put(vaultKey, "key_passphrase", []byte(passphrase)); err != nil { + return fmt.Errorf("store passphrase in vault: %w", err) + } + if err := v.Save(); err != nil { + return fmt.Errorf("save vault: %w", err) + } + } + + if err := appDB.CreateServer(server); err != nil { + return fmt.Errorf("create server: %w", err) + } + + if addFlags.tags != "" { + tagList := strings.Split(addFlags.tags, ",") + for _, t := range tagList { + t = strings.TrimSpace(t) + if t != "" { + if err := appDB.AddTagToServer(server.ID, t); err != nil { + return fmt.Errorf("add tag %s: %w", t, err) + } + } + } + } + + fmt.Println("Saved.") + return nil +} + +func init() { + addCmd.Flags().StringVar(&addFlags.host, "host", "", "Server hostname or IP") + addCmd.Flags().IntVar(&addFlags.port, "port", 22, "SSH port") + addCmd.Flags().StringVar(&addFlags.user, "user", "", "SSH username") + addCmd.Flags().StringVar(&addFlags.authMethod, "auth", "key", "Auth method: password, key, key_passphrase, agent") + addCmd.Flags().StringVar(&addFlags.identityFile, "identity-file", "", "Path to SSH private key") + addCmd.Flags().StringVar(&addFlags.proxyJump, "proxy-jump", "", "ProxyJump host") + addCmd.Flags().StringVar(&addFlags.groupName, "group", "", "Server group") + addCmd.Flags().StringVar(&addFlags.displayName, "display-name", "", "Display name") + addCmd.Flags().StringVar(&addFlags.notes, "notes", "", "Notes") + addCmd.Flags().StringVar(&addFlags.tags, "tags", "", "Comma-separated tags") + addCmd.Flags().StringVar(&addFlags.password, "password", "", "SSH password or key passphrase (stored in vault)") +} diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..c0e1880 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Configuration management", +} + +var configPathCmd = &cobra.Command{ + Use: "path", + Short: "Show config file paths", + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Printf("Config: %s/config.toml\n", cfg.ConfigDir) + fmt.Printf("DB: %s/sshkeeper.db\n", cfg.DataDir) + fmt.Printf("Vault: %s/vault.bin\n", cfg.DataDir) + return nil + }, +} + +func init() { + configCmd.AddCommand(configPathCmd) +} diff --git a/cmd/connect.go b/cmd/connect.go new file mode 100644 index 0000000..2c44f45 --- /dev/null +++ b/cmd/connect.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/mirivlad/sshkeeper/internal/model" + "github.com/mirivlad/sshkeeper/internal/ssh" +) + +var connectCmd = &cobra.Command{ + Use: "connect ", + Aliases: []string{"c"}, + Short: "Connect to a server via SSH", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + alias := args[0] + server, err := appDB.GetServer(alias) + if err != nil { + return fmt.Errorf("server not found: %s", alias) + } + + v := getOrCreateVault() + vaultFunc := func(serverAlias string, secretType string) (string, error) { + if !v.IsUnlocked() { + return "", fmt.Errorf("vault is locked. Run 'sshkeeper vault unlock' first") + } + key := fmt.Sprintf("server:%s:%s", serverAlias, secretType) + data, err := v.Get(key) + if err != nil { + return "", err + } + return string(data), nil + } + + if err := ssh.Connect(cfg, &model.Server{ + Alias: server.Alias, + Host: server.Host, + Port: server.Port, + User: server.User, + AuthMethod: server.AuthMethod, + IdentityFile: server.IdentityFile, + ProxyJump: server.ProxyJump, + }, vaultFunc); err != nil { + return err + } + + appDB.UpdateLastConnected(alias) + return nil + }, +} + +var testCmd = &cobra.Command{ + Use: "test ", + Short: "Test SSH connection", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + alias := args[0] + server, err := appDB.GetServer(alias) + if err != nil { + return fmt.Errorf("server not found: %s", alias) + } + + v := getOrCreateVault() + vaultFunc := func(serverAlias string, secretType string) (string, error) { + if !v.IsUnlocked() { + return "", fmt.Errorf("vault is locked. Run 'sshkeeper vault unlock' first") + } + key := fmt.Sprintf("server:%s:%s", serverAlias, secretType) + data, err := v.Get(key) + if err != nil { + return "", err + } + return string(data), nil + } + + ok, testErr := ssh.Test(cfg, &model.Server{ + Alias: server.Alias, + Host: server.Host, + Port: server.Port, + User: server.User, + AuthMethod: server.AuthMethod, + IdentityFile: server.IdentityFile, + ProxyJump: server.ProxyJump, + }, vaultFunc) + + if ok { + fmt.Println("Connection OK.") + appDB.UpdateTestResult(alias, model.TestOK, "") + } else { + fmt.Printf("Connection failed:\n%s\n", testErr) + appDB.UpdateTestResult(alias, model.TestFailed, testErr) + } + + return nil + }, +} diff --git a/cmd/delete.go b/cmd/delete.go new file mode 100644 index 0000000..cc8c737 --- /dev/null +++ b/cmd/delete.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var deleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a server profile", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + alias := args[0] + + if !forceDelete { + fmt.Printf("Are you sure you want to delete '%s'? (y/N): ", alias) + var response string + fmt.Scanln(&response) + if response != "y" && response != "Y" { + fmt.Println("Cancelled.") + return nil + } + } + + if err := appDB.DeleteServer(alias); err != nil { + return fmt.Errorf("delete server: %w", err) + } + + fmt.Println("Deleted.") + return nil + }, +} + +var forceDelete bool + +func init() { + deleteCmd.Flags().BoolVarP(&forceDelete, "force", "f", false, "Delete without confirmation") +} diff --git a/cmd/edit.go b/cmd/edit.go new file mode 100644 index 0000000..9480407 --- /dev/null +++ b/cmd/edit.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/mirivlad/sshkeeper/internal/model" +) + +var editCmd = &cobra.Command{ + Use: "edit ", + Short: "Edit a server profile", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + alias := args[0] + server, err := appDB.GetServer(alias) + if err != nil { + return fmt.Errorf("server not found: %s", alias) + } + + if parsedHost != "" { + server.Host = parsedHost + } + if parsedPort != 0 { + server.Port = parsedPort + } + if parsedUser != "" { + server.User = parsedUser + } + if parsedAuth != "" { + server.AuthMethod = model.AuthMethod(parsedAuth) + } + if parsedIdentity != "" { + server.IdentityFile = parsedIdentity + } + if parsedProxyJump != "" { + server.ProxyJump = parsedProxyJump + } + if parsedGroup != "" { + server.GroupName = parsedGroup + } + if parsedDisplayName != "" { + server.DisplayName = parsedDisplayName + } + if parsedNotes != "" { + server.Notes = parsedNotes + } + + if err := appDB.UpdateServer(server); err != nil { + return fmt.Errorf("update server: %w", err) + } + + fmt.Println("Saved.") + return nil + }, +} + +var ( + parsedHost string + parsedPort int + parsedUser string + parsedAuth string + parsedIdentity string + parsedProxyJump string + parsedGroup string + parsedDisplayName string + parsedNotes string +) + +func init() { + editCmd.Flags().StringVar(&parsedHost, "host", "", "Server hostname or IP") + editCmd.Flags().IntVar(&parsedPort, "port", 0, "SSH port") + editCmd.Flags().StringVar(&parsedUser, "user", "", "SSH username") + editCmd.Flags().StringVar(&parsedAuth, "auth", "", "Auth method") + editCmd.Flags().StringVar(&parsedIdentity, "identity-file", "", "Path to SSH private key") + editCmd.Flags().StringVar(&parsedProxyJump, "proxy-jump", "", "ProxyJump host") + editCmd.Flags().StringVar(&parsedGroup, "group", "", "Server group") + editCmd.Flags().StringVar(&parsedDisplayName, "display-name", "", "Display name") + editCmd.Flags().StringVar(&parsedNotes, "notes", "", "Notes") +} diff --git a/cmd/extra.go b/cmd/extra.go new file mode 100644 index 0000000..7f54d0c --- /dev/null +++ b/cmd/extra.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" + "github.com/mirivlad/sshkeeper/internal/model" + "github.com/mirivlad/sshkeeper/internal/ssh" +) + +var importCmd = &cobra.Command{ + Use: "import", + Short: "Import servers from ~/.ssh/config", + RunE: func(cmd *cobra.Command, args []string) error { + servers, err := ssh.ImportFromSSHConfig() + if err != nil { + return fmt.Errorf("import: %w", err) + } + + if len(servers) == 0 { + fmt.Println("No servers found in ~/.ssh/config") + return nil + } + + imported := 0 + for _, s := range servers { + existing, _ := appDB.GetServer(s.Alias) + if existing != nil { + fmt.Printf(" skip (exists): %s\n", s.Alias) + continue + } + if err := appDB.CreateServer(s); err != nil { + fmt.Printf(" error: %s: %v\n", s.Alias, err) + continue + } + fmt.Printf(" imported: %s (%s@%s:%d)\n", s.Alias, s.User, s.Host, s.Port) + imported++ + } + + fmt.Printf("\nImported %d servers.\n", imported) + return nil + }, +} + +var exportCmd = &cobra.Command{ + Use: "export", + Short: "Export servers to stdout", + RunE: func(cmd *cobra.Command, args []string) error { + servers, err := appDB.ListServers() + if err != nil { + return fmt.Errorf("list servers: %w", err) + } + + for _, s := range servers { + fmt.Printf("%s\t%s@%s:%d\t%s\n", s.Alias, s.User, s.Host, s.Port, s.AuthMethod) + } + return nil + }, +} + +var runCmd = &cobra.Command{ + Use: "run ", + Short: "Run a command on a server", + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + alias := args[0] + command := strings.Join(args[1:], " ") + + server, err := appDB.GetServer(alias) + if err != nil { + return fmt.Errorf("server not found: %s", alias) + } + + // Build ssh args with the command + sshArgs := buildSSHArgs(server) + sshArgs = append(sshArgs, command) + + sshCmd := exec.Command(cfg.SSH.Binary, sshArgs...) + sshCmd.Stdin = os.Stdin + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + + if err := sshCmd.Start(); err != nil { + return fmt.Errorf("start ssh: %w", err) + } + + return sshCmd.Wait() + }, +} + +func buildSSHArgs(server *model.Server) []string { + var args []string + args = append(args, "-p", fmt.Sprintf("%d", server.Port)) + if server.IdentityFile != "" { + args = append(args, "-i", server.IdentityFile) + } + if server.ProxyJump != "" { + args = append(args, "-J", server.ProxyJump) + } + args = append(args, "-o", "StrictHostKeyChecking=accept-new") + target := fmt.Sprintf("%s@%s", server.User, server.Host) + args = append(args, target) + return args +} diff --git a/cmd/group.go b/cmd/group.go new file mode 100644 index 0000000..075a842 --- /dev/null +++ b/cmd/group.go @@ -0,0 +1,137 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var groupCmd = &cobra.Command{ + Use: "group", + Short: "Group management", +} + +var groupListCmd = &cobra.Command{ + Use: "list", + Short: "List server groups", + RunE: func(cmd *cobra.Command, args []string) error { + servers, err := appDB.ListServers() + if err != nil { + return fmt.Errorf("list servers: %w", err) + } + + groups := make(map[string]int) + for _, s := range servers { + g := s.GroupName + if g == "" { + g = "(no group)" + } + groups[g]++ + } + + if len(groups) == 0 { + fmt.Println("No servers.") + return nil + } + + for name, count := range groups { + fmt.Printf(" %-20s %d servers\n", name, count) + } + return nil + }, +} + +var templateCmd = &cobra.Command{ + Use: "template", + Short: "Command template management", +} + +var templateListCmd = &cobra.Command{ + Use: "list ", + Short: "List command templates for a server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + alias := args[0] + _, err := appDB.GetServer(alias) + if err != nil { + return fmt.Errorf("server not found: %s", alias) + } + + templates, err := appDB.GetCommandTemplates(alias) + if err != nil { + return fmt.Errorf("list templates: %w", err) + } + + if len(templates) == 0 { + fmt.Println("No command templates.") + return nil + } + + for _, t := range templates { + fmt.Printf(" %-15s %s\n", t.Name, t.Command) + } + return nil + }, +} + +var templateAddCmd = &cobra.Command{ + Use: "add ", + Short: "Add a command template", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + alias := args[0] + name := args[1] + command := args[2] + + server, err := appDB.GetServer(alias) + if err != nil { + return fmt.Errorf("server not found: %s", alias) + } + + if err := appDB.AddCommandTemplate(server.ID, name, command); err != nil { + return fmt.Errorf("add template: %w", err) + } + + fmt.Println("Template added.") + return nil + }, +} + +var runTemplateCmd = &cobra.Command{ + Use: "run-template