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
This commit is contained in:
commit
883a1e66a7
|
|
@ -0,0 +1,6 @@
|
||||||
|
bin/
|
||||||
|
*.db
|
||||||
|
*.bin
|
||||||
|
*.tmp
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
@ -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) .
|
||||||
|
|
@ -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 <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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
@ -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 <alias> --host <host> --user <user> --auth <method>")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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 <alias>",
|
||||||
|
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 <alias>",
|
||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var deleteCmd = &cobra.Command{
|
||||||
|
Use: "delete <alias>",
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var editCmd = &cobra.Command{
|
||||||
|
Use: "edit <alias>",
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
@ -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 <alias> <command>",
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -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 <alias>",
|
||||||
|
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 <alias> <name> <command>",
|
||||||
|
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 <alias> <template>",
|
||||||
|
Short: "Run a command template on a server",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
alias := args[0]
|
||||||
|
templateName := args[1]
|
||||||
|
|
||||||
|
templates, err := appDB.GetCommandTemplates(alias)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list templates: %w", err)
|
||||||
|
}
|
||||||
|
if len(templates) == 0 {
|
||||||
|
return fmt.Errorf("server not found or no templates: %s", alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
var command string
|
||||||
|
for _, t := range templates {
|
||||||
|
if t.Name == templateName {
|
||||||
|
command = t.Command
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if command == "" {
|
||||||
|
return fmt.Errorf("template not found: %s", templateName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Running '%s' on %s...\n", command, alias)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
groupCmd.AddCommand(groupListCmd)
|
||||||
|
templateCmd.AddCommand(templateListCmd)
|
||||||
|
templateCmd.AddCommand(templateAddCmd)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/config"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
var initCmd = &cobra.Command{
|
||||||
|
Use: "init",
|
||||||
|
Short: "Initialize sshkeeper",
|
||||||
|
Long: "Create config, database, and vault directories.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs := []string{cfg.ConfigDir, cfg.DataDir}
|
||||||
|
for _, dir := range dirs {
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("create dir %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open database (triggers migrations)
|
||||||
|
database, err := db.Open(cfg.DataDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open database: %w", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
// Create empty vault if not exists
|
||||||
|
vaultPath := config.VaultPath(cfg.DataDir)
|
||||||
|
if _, err := os.Stat(vaultPath); os.IsNotExist(err) {
|
||||||
|
f, err := os.OpenFile(vaultPath, os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create vault: %w", err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Created config: %s/config.toml\n", cfg.ConfigDir)
|
||||||
|
fmt.Printf("Created database: %s/sshkeeper.db\n", cfg.DataDir)
|
||||||
|
fmt.Printf("Created vault: %s/vault.bin\n", cfg.DataDir)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Next step: run 'sshkeeper vault unlock' to set master password.")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var listCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List all servers",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
servers, err := appDB.ListServers()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list servers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(servers) == 0 {
|
||||||
|
fmt.Println("No servers. Use 'sshkeeper add' to add one.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
||||||
|
fmt.Println(headerStyle.Render(fmt.Sprintf("%-20s %-25s %-8s %-12s %s", "ALIAS", "TARGET", "AUTH", "STATUS", "LAST TEST")))
|
||||||
|
fmt.Println("─────────────────────────────────────────────────────────────────────────")
|
||||||
|
|
||||||
|
for _, s := range servers {
|
||||||
|
statusChar := "?"
|
||||||
|
if s.LastTestStatus == model.TestOK {
|
||||||
|
statusChar = "✓"
|
||||||
|
} else if s.LastTestStatus == model.TestFailed {
|
||||||
|
statusChar = "!"
|
||||||
|
}
|
||||||
|
|
||||||
|
target := fmt.Sprintf("%s@%s:%d", s.User, s.Host, s.Port)
|
||||||
|
lastTest := "never"
|
||||||
|
if s.LastTestAt != nil {
|
||||||
|
lastTest = s.LastTestAt.Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-20s %-25s %-8s [%s] %s\n", s.Alias, target, s.AuthMethod, statusChar, lastTest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/config"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/db"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/vault"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cfg *config.Config
|
||||||
|
appDB *db.DB
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "sshkeeper",
|
||||||
|
Short: "sshkeeper — SSH connection manager",
|
||||||
|
Long: `sshkeeper is a console SSH connection manager for Linux.
|
||||||
|
It manages server profiles, secrets, and provides a convenient way
|
||||||
|
to launch SSH sessions using the system OpenSSH client.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runTUI()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func Execute() {
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
cobra.OnInitialize(initApp)
|
||||||
|
rootCmd.AddCommand(initCmd)
|
||||||
|
rootCmd.AddCommand(addCmd)
|
||||||
|
rootCmd.AddCommand(listCmd)
|
||||||
|
rootCmd.AddCommand(showCmd)
|
||||||
|
rootCmd.AddCommand(editCmd)
|
||||||
|
rootCmd.AddCommand(deleteCmd)
|
||||||
|
rootCmd.AddCommand(connectCmd)
|
||||||
|
rootCmd.AddCommand(testCmd)
|
||||||
|
rootCmd.AddCommand(searchCmd)
|
||||||
|
rootCmd.AddCommand(vaultCmd)
|
||||||
|
rootCmd.AddCommand(sshConfigCmd)
|
||||||
|
rootCmd.AddCommand(configCmd)
|
||||||
|
rootCmd.AddCommand(importCmd)
|
||||||
|
rootCmd.AddCommand(exportCmd)
|
||||||
|
rootCmd.AddCommand(runCmd)
|
||||||
|
rootCmd.AddCommand(groupCmd)
|
||||||
|
rootCmd.AddCommand(templateCmd)
|
||||||
|
rootCmd.AddCommand(runTemplateCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initApp() {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
cfg, err = config.Load()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
appDB, err = db.Open(cfg.DataDir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error opening database: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle vault: create on first run, unlock on subsequent runs
|
||||||
|
vaultPath := config.VaultPath(cfg.DataDir)
|
||||||
|
v := vault.New(vaultPath)
|
||||||
|
|
||||||
|
if !vault.Exists(vaultPath) {
|
||||||
|
// First run — create vault
|
||||||
|
fmt.Println("Welcome to sshkeeper!")
|
||||||
|
fmt.Println("No vault found. Let's create one.")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
for {
|
||||||
|
fmt.Print("Create master password: ")
|
||||||
|
pw1, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error reading password: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pw1) == 0 {
|
||||||
|
fmt.Println("Password cannot be empty. Try again.")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Repeat master password: ")
|
||||||
|
pw2, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error reading password: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(pw1) != string(pw2) {
|
||||||
|
fmt.Println("Passwords do not match. Try again.")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := vault.Create(vaultPath, string(pw1)); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating vault: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock immediately after creation
|
||||||
|
if err := v.Unlock(string(pw1)); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error unlocking vault: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultInstance = v
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Vault created and unlocked. You're ready to go!")
|
||||||
|
fmt.Println()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Vault exists — need to unlock
|
||||||
|
// Skip unlock for vault commands that handle their own unlock/lock
|
||||||
|
if isVaultSubcommand() {
|
||||||
|
vaultInstance = v
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for attempts := 0; attempts < 3; attempts++ {
|
||||||
|
fmt.Print("Master password: ")
|
||||||
|
pw, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error reading password: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := v.Unlock(string(pw)); err != nil {
|
||||||
|
remaining := 2 - attempts
|
||||||
|
if remaining > 0 {
|
||||||
|
fmt.Printf("Invalid password. %d attempts remaining.\n", remaining)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "Too many failed attempts. Run 'sshkeeper vault unlock' to try again.\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultInstance = v
|
||||||
|
fmt.Println("Vault unlocked.")
|
||||||
|
fmt.Println()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isVaultSubcommand checks if the current command is a vault subcommand
|
||||||
|
func isVaultSubcommand() bool {
|
||||||
|
args := os.Args[1:]
|
||||||
|
for _, arg := range args {
|
||||||
|
if arg == "vault" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Skip for help
|
||||||
|
for _, arg := range args {
|
||||||
|
if arg == "-h" || arg == "--help" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var searchCmd = &cobra.Command{
|
||||||
|
Use: "search <query>",
|
||||||
|
Short: "Search servers",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
query := args[0]
|
||||||
|
servers, err := appDB.SearchServers(query)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("search: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(servers) == 0 {
|
||||||
|
fmt.Println("No servers found.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range servers {
|
||||||
|
statusChar := "?"
|
||||||
|
if s.LastTestStatus == "ok" {
|
||||||
|
statusChar = "✓"
|
||||||
|
} else if s.LastTestStatus == "failed" {
|
||||||
|
statusChar = "!"
|
||||||
|
}
|
||||||
|
target := fmt.Sprintf("%s@%s:%d", s.User, s.Host, s.Port)
|
||||||
|
fmt.Printf("[%s] %-20s %s\n", statusChar, s.Alias, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var showCmd = &cobra.Command{
|
||||||
|
Use: "show <alias>",
|
||||||
|
Short: "Show server details",
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Alias: %s\n", server.Alias)
|
||||||
|
fmt.Printf("Display Name: %s\n", server.DisplayName)
|
||||||
|
fmt.Printf("Host: %s\n", server.Host)
|
||||||
|
fmt.Printf("Port: %d\n", server.Port)
|
||||||
|
fmt.Printf("User: %s\n", server.User)
|
||||||
|
fmt.Printf("Auth Method: %s\n", server.AuthMethod)
|
||||||
|
if server.IdentityFile != "" {
|
||||||
|
fmt.Printf("Identity: %s\n", server.IdentityFile)
|
||||||
|
}
|
||||||
|
if server.ProxyJump != "" {
|
||||||
|
fmt.Printf("ProxyJump: %s\n", server.ProxyJump)
|
||||||
|
}
|
||||||
|
if server.GroupName != "" {
|
||||||
|
fmt.Printf("Group: %s\n", server.GroupName)
|
||||||
|
}
|
||||||
|
if server.Notes != "" {
|
||||||
|
fmt.Printf("Notes: %s\n", server.Notes)
|
||||||
|
}
|
||||||
|
fmt.Printf("Test Status: %s\n", server.LastTestStatus)
|
||||||
|
if server.LastTestAt != nil {
|
||||||
|
fmt.Printf("Last Test: %s\n", server.LastTestAt.Format("2006-01-02 15:04:05"))
|
||||||
|
}
|
||||||
|
if server.LastTestError != "" {
|
||||||
|
fmt.Printf("Last Error: %s\n", server.LastTestError)
|
||||||
|
}
|
||||||
|
if server.LastConnectedAt != nil {
|
||||||
|
fmt.Printf("Last Connect: %s\n", server.LastConnectedAt.Format("2006-01-02 15:04:05"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
var sshConfigCmd = &cobra.Command{
|
||||||
|
Use: "ssh-config",
|
||||||
|
Short: "OpenSSH config management",
|
||||||
|
}
|
||||||
|
|
||||||
|
var sshConfigGenerateCmd = &cobra.Command{
|
||||||
|
Use: "generate",
|
||||||
|
Short: "Generate OpenSSH config from server profiles",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
servers, err := appDB.ListServers()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list servers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ssh.WriteConfig(servers); err != nil {
|
||||||
|
return fmt.Errorf("write config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
fmt.Printf("Config written to: %s/.ssh/config.d/sshkeeper.conf\n", home)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var sshConfigInstallIncludeCmd = &cobra.Command{
|
||||||
|
Use: "install-include",
|
||||||
|
Short: "Add Include directive to ~/.ssh/config",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := ssh.InstallInclude(); err != nil {
|
||||||
|
return fmt.Errorf("install include: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println("Include directive added to ~/.ssh/config")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
sshConfigCmd.AddCommand(sshConfigGenerateCmd)
|
||||||
|
sshConfigCmd.AddCommand(sshConfigInstallIncludeCmd)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/model"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/ssh"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runTUI() error {
|
||||||
|
servers, err := appDB.ListServers()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load servers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultFunc := func(sa string, st string) (string, error) {
|
||||||
|
v := getOrCreateVault()
|
||||||
|
if !v.IsUnlocked() {
|
||||||
|
return "", fmt.Errorf("vault is locked")
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("server:%s:%s", sa, st)
|
||||||
|
data, err := v.Get(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tui.ListServers = func() ([]*model.Server, error) {
|
||||||
|
return appDB.ListServers()
|
||||||
|
}
|
||||||
|
tui.SearchServers = func(query string) ([]*model.Server, error) {
|
||||||
|
return appDB.SearchServers(query)
|
||||||
|
}
|
||||||
|
tui.DeleteServer = func(alias string) error {
|
||||||
|
return appDB.DeleteServer(alias)
|
||||||
|
}
|
||||||
|
tui.TestConnection = func(server *model.Server) (bool, string) {
|
||||||
|
return ssh.Test(cfg, server, vaultFunc)
|
||||||
|
}
|
||||||
|
tui.SaveServer = func(server *model.Server, password string) error {
|
||||||
|
if password != "" {
|
||||||
|
v := getOrCreateVault()
|
||||||
|
vaultKey := fmt.Sprintf("server:%s:ssh_password", server.Alias)
|
||||||
|
secretType := "ssh_password"
|
||||||
|
if server.AuthMethod == model.AuthKeyPassphrase {
|
||||||
|
vaultKey = fmt.Sprintf("server:%s:key_passphrase", server.Alias)
|
||||||
|
secretType = "key_passphrase"
|
||||||
|
}
|
||||||
|
if err := v.Put(vaultKey, secretType, []byte(password)); err != nil {
|
||||||
|
return fmt.Errorf("store secret: %w", err)
|
||||||
|
}
|
||||||
|
if err := v.Save(); err != nil {
|
||||||
|
return fmt.Errorf("save vault: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, _ := appDB.GetServer(server.Alias)
|
||||||
|
if existing != nil {
|
||||||
|
server.ID = existing.ID
|
||||||
|
return appDB.UpdateServer(server)
|
||||||
|
}
|
||||||
|
return appDB.CreateServer(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run TUI in a loop — if user requests connect, handle it and restart TUI
|
||||||
|
for {
|
||||||
|
m := tui.New(servers)
|
||||||
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
|
if _, err := p.Run(); err != nil {
|
||||||
|
return fmt.Errorf("TUI error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if TUI requested a connect action
|
||||||
|
result := m.Result()
|
||||||
|
if result != nil && result.Action == "connect" && result.Server != nil {
|
||||||
|
// TUI has exited, terminal is restored by tea.WithAltScreen.
|
||||||
|
// Now connect.
|
||||||
|
server := result.Server
|
||||||
|
|
||||||
|
// Re-fetch fresh server data from DB
|
||||||
|
fresh, err := appDB.GetServer(server.Alias)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Server not found: %s\n", server.Alias)
|
||||||
|
servers, _ = appDB.ListServers()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Connecting to %s@%s:%d...\n", fresh.User, fresh.Host, fresh.Port)
|
||||||
|
|
||||||
|
if err := ssh.Connect(cfg, fresh, vaultFunc); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Connection error: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("Connection closed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
appDB.UpdateLastConnected(server.Alias)
|
||||||
|
|
||||||
|
// Wait for user to press Enter before returning to TUI
|
||||||
|
fmt.Println("\n[Press Enter to return to sshkeeper]")
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
os.Stdin.Read(buf)
|
||||||
|
|
||||||
|
// Reload servers for TUI
|
||||||
|
servers, _ = appDB.ListServers()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal quit (q or Esc)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/config"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/vault"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
var vaultInstance *vault.Vault
|
||||||
|
|
||||||
|
func getOrCreateVault() *vault.Vault {
|
||||||
|
if vaultInstance == nil {
|
||||||
|
vaultInstance = vault.New(config.VaultPath(cfg.DataDir))
|
||||||
|
}
|
||||||
|
return vaultInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
var vaultCmd = &cobra.Command{
|
||||||
|
Use: "vault",
|
||||||
|
Short: "Vault management commands",
|
||||||
|
}
|
||||||
|
|
||||||
|
var vaultUnlockCmd = &cobra.Command{
|
||||||
|
Use: "unlock",
|
||||||
|
Short: "Unlock the vault with master password",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
v := getOrCreateVault()
|
||||||
|
|
||||||
|
if v.IsUnlocked() {
|
||||||
|
fmt.Println("Vault is already unlocked.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultPath := config.VaultPath(cfg.DataDir)
|
||||||
|
|
||||||
|
// Check if vault exists and has content
|
||||||
|
info, err := os.Stat(vaultPath)
|
||||||
|
if os.IsNotExist(err) || info.Size() == 0 {
|
||||||
|
// New vault - create with master password
|
||||||
|
fmt.Print("Create master password: ")
|
||||||
|
pw1, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pw1) == 0 {
|
||||||
|
return fmt.Errorf("password cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Repeat master password: ")
|
||||||
|
pw2, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(pw1) != string(pw2) {
|
||||||
|
return fmt.Errorf("passwords do not match")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := vault.Create(vaultPath, string(pw1)); err != nil {
|
||||||
|
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.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock existing vault
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Vault unlocked.")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var vaultLockCmd = &cobra.Command{
|
||||||
|
Use: "lock",
|
||||||
|
Short: "Lock the vault",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
v := getOrCreateVault()
|
||||||
|
v.Lock()
|
||||||
|
fmt.Println("Vault locked.")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var vaultStatusCmd = &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var vaultChangePasswordCmd = &cobra.Command{
|
||||||
|
Use: "change-password",
|
||||||
|
Short: "Change master password",
|
||||||
|
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'")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("New master password: ")
|
||||||
|
pw1, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pw1) == 0 {
|
||||||
|
return fmt.Errorf("password cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Repeat new master password: ")
|
||||||
|
pw2, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(pw1) != string(pw2) {
|
||||||
|
return fmt.Errorf("passwords do not match")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := v.ChangePassword(string(pw1)); err != nil {
|
||||||
|
return fmt.Errorf("change password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Master password changed.")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
vaultCmd.AddCommand(vaultUnlockCmd)
|
||||||
|
vaultCmd.AddCommand(vaultLockCmd)
|
||||||
|
vaultCmd.AddCommand(vaultStatusCmd)
|
||||||
|
vaultCmd.AddCommand(vaultChangePasswordCmd)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
module github.com/mirivlad/sshkeeper
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.4.0
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/creack/pty v1.1.24
|
||||||
|
github.com/spf13/cobra v1.10.2
|
||||||
|
golang.org/x/crypto v0.52.0
|
||||||
|
golang.org/x/term v0.43.0
|
||||||
|
modernc.org/sqlite v1.50.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
|
golang.org/x/text v0.37.0 // indirect
|
||||||
|
modernc.org/libc v1.72.3 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||||
|
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||||
|
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
|
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
|
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||||
|
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
|
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||||
|
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||||
|
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||||
|
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||||
|
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||||
|
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
SSH SSHConfig `toml:"ssh"`
|
||||||
|
Vault VaultConfig `toml:"vault"`
|
||||||
|
UI UIConfig `toml:"ui"`
|
||||||
|
|
||||||
|
// resolved paths
|
||||||
|
ConfigDir string `toml:"-"`
|
||||||
|
DataDir string `toml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SSHConfig struct {
|
||||||
|
Binary string `toml:"binary"`
|
||||||
|
ConnectTimeoutSec int `toml:"connect_timeout_seconds"`
|
||||||
|
TestCommand string `toml:"test_command"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VaultConfig struct {
|
||||||
|
AutoLockMinutes int `toml:"auto_lock_minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UIConfig struct {
|
||||||
|
ShowSecurityHints bool `toml:"show_security_hints"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
SSH: SSHConfig{
|
||||||
|
Binary: "/usr/bin/ssh",
|
||||||
|
ConnectTimeoutSec: 10,
|
||||||
|
TestCommand: "echo SSHKEEPER_OK",
|
||||||
|
},
|
||||||
|
Vault: VaultConfig{
|
||||||
|
AutoLockMinutes: 15,
|
||||||
|
},
|
||||||
|
UI: UIConfig{
|
||||||
|
ShowSecurityHints: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
cfg := defaultConfig()
|
||||||
|
|
||||||
|
// XDG paths
|
||||||
|
configDir := os.Getenv("XDG_CONFIG_HOME")
|
||||||
|
if configDir == "" {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
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
|
||||||
|
if err := os.MkdirAll(configDir, 0700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dataDir, 0700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
configFile := filepath.Join(configDir, "config.toml")
|
||||||
|
|
||||||
|
// Write default config if not exists
|
||||||
|
if _, err := os.Stat(configFile); os.IsNotExist(err) {
|
||||||
|
f, err := os.OpenFile(configFile, os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if err := toml.NewEncoder(f).Encode(cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse existing config
|
||||||
|
if _, err := toml.DecodeFile(configFile, cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply paths since toml decode might overwrite
|
||||||
|
cfg.ConfigDir = configDir
|
||||||
|
cfg.DataDir = dataDir
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import "path/filepath"
|
||||||
|
|
||||||
|
func DBPath(dataDir string) string {
|
||||||
|
return filepath.Join(dataDir, "sshkeeper.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
func VaultPath(dataDir string) string {
|
||||||
|
return filepath.Join(dataDir, "vault.bin")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
type DB struct {
|
||||||
|
conn *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(dataDir string) (*DB, error) {
|
||||||
|
dbPath := filepath.Join(dataDir, "sshkeeper.db")
|
||||||
|
|
||||||
|
conn, err := sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.Ping(); err != nil {
|
||||||
|
return nil, fmt.Errorf("ping database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db := &DB{conn: conn}
|
||||||
|
|
||||||
|
if err := db.migrate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("migrate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Chmod(dbPath, 0600)
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) Close() error {
|
||||||
|
return db.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) migrate() error {
|
||||||
|
entries, err := migrationsFS.ReadDir("migrations")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migrations dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, err := migrationsFS.ReadFile("migrations/" + entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migration %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
if _, err := db.conn.Exec(string(content)); err != nil {
|
||||||
|
return fmt.Errorf("exec migration %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS servers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
alias TEXT NOT NULL UNIQUE,
|
||||||
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL DEFAULT 22,
|
||||||
|
user TEXT NOT NULL DEFAULT '',
|
||||||
|
auth_method TEXT NOT NULL DEFAULT 'key',
|
||||||
|
identity_file TEXT NOT NULL DEFAULT '',
|
||||||
|
proxy_jump TEXT NOT NULL DEFAULT '',
|
||||||
|
group_name TEXT NOT NULL DEFAULT '',
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_connected_at DATETIME,
|
||||||
|
last_test_at DATETIME,
|
||||||
|
last_test_status TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
last_test_error TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS server_tags (
|
||||||
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (server_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS forwards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL DEFAULT 'local',
|
||||||
|
local_addr TEXT NOT NULL DEFAULT '',
|
||||||
|
local_port INTEGER NOT NULL DEFAULT 0,
|
||||||
|
remote_addr TEXT NOT NULL DEFAULT '',
|
||||||
|
remote_port INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS command_templates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
command TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (db *DB) CreateServer(s *model.Server) error {
|
||||||
|
result, err := db.conn.Exec(`
|
||||||
|
INSERT INTO servers (alias, display_name, host, port, user, auth_method, identity_file, proxy_jump, group_name, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
s.Alias, s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod, s.IdentityFile, s.ProxyJump, s.GroupName, s.Notes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.ID, _ = result.LastInsertId()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateServer(s *model.Server) error {
|
||||||
|
_, err := db.conn.Exec(`
|
||||||
|
UPDATE servers SET
|
||||||
|
display_name=?, host=?, port=?, user=?, auth_method=?,
|
||||||
|
identity_file=?, proxy_jump=?, group_name=?, notes=?, updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE alias=?`,
|
||||||
|
s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod,
|
||||||
|
s.IdentityFile, s.ProxyJump, s.GroupName, s.Notes, s.Alias)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) DeleteServer(alias string) error {
|
||||||
|
_, err := db.conn.Exec("DELETE FROM servers WHERE alias=?", alias)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetServer(alias string) (*model.Server, error) {
|
||||||
|
var s model.Server
|
||||||
|
var lastConnected, lastTest sql.NullTime
|
||||||
|
err := db.conn.QueryRow(`
|
||||||
|
SELECT id, alias, display_name, host, port, user, auth_method,
|
||||||
|
identity_file, proxy_jump, group_name, notes,
|
||||||
|
created_at, updated_at, last_connected_at,
|
||||||
|
last_test_at, last_test_status, last_test_error
|
||||||
|
FROM servers WHERE alias=?`, alias).Scan(
|
||||||
|
&s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod,
|
||||||
|
&s.IdentityFile, &s.ProxyJump, &s.GroupName, &s.Notes,
|
||||||
|
&s.CreatedAt, &s.UpdatedAt, &lastConnected,
|
||||||
|
&lastTest, &s.LastTestStatus, &s.LastTestError)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lastConnected.Valid {
|
||||||
|
s.LastConnectedAt = &lastConnected.Time
|
||||||
|
}
|
||||||
|
if lastTest.Valid {
|
||||||
|
s.LastTestAt = &lastTest.Time
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ListServers() ([]*model.Server, error) {
|
||||||
|
rows, err := db.conn.Query(`
|
||||||
|
SELECT id, alias, display_name, host, port, user, auth_method,
|
||||||
|
identity_file, proxy_jump, group_name, notes,
|
||||||
|
created_at, updated_at, last_connected_at,
|
||||||
|
last_test_at, last_test_status, last_test_error
|
||||||
|
FROM servers ORDER BY alias`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var servers []*model.Server
|
||||||
|
for rows.Next() {
|
||||||
|
var s model.Server
|
||||||
|
var lastConnected, lastTest sql.NullTime
|
||||||
|
err := rows.Scan(
|
||||||
|
&s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod,
|
||||||
|
&s.IdentityFile, &s.ProxyJump, &s.GroupName, &s.Notes,
|
||||||
|
&s.CreatedAt, &s.UpdatedAt, &lastConnected,
|
||||||
|
&lastTest, &s.LastTestStatus, &s.LastTestError)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lastConnected.Valid {
|
||||||
|
s.LastConnectedAt = &lastConnected.Time
|
||||||
|
}
|
||||||
|
if lastTest.Valid {
|
||||||
|
s.LastTestAt = &lastTest.Time
|
||||||
|
}
|
||||||
|
servers = append(servers, &s)
|
||||||
|
}
|
||||||
|
return servers, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) SearchServers(query string) ([]*model.Server, error) {
|
||||||
|
pattern := "%" + query + "%"
|
||||||
|
rows, err := db.conn.Query(`
|
||||||
|
SELECT id, alias, display_name, host, port, user, auth_method,
|
||||||
|
identity_file, proxy_jump, group_name, notes,
|
||||||
|
created_at, updated_at, last_connected_at,
|
||||||
|
last_test_at, last_test_status, last_test_error
|
||||||
|
FROM servers
|
||||||
|
WHERE alias LIKE ? OR display_name LIKE ? OR host LIKE ? OR user LIKE ? OR group_name LIKE ?
|
||||||
|
ORDER BY alias`, pattern, pattern, pattern, pattern, pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var servers []*model.Server
|
||||||
|
for rows.Next() {
|
||||||
|
var s model.Server
|
||||||
|
var lastConnected, lastTest sql.NullTime
|
||||||
|
err := rows.Scan(
|
||||||
|
&s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod,
|
||||||
|
&s.IdentityFile, &s.ProxyJump, &s.GroupName, &s.Notes,
|
||||||
|
&s.CreatedAt, &s.UpdatedAt, &lastConnected,
|
||||||
|
&lastTest, &s.LastTestStatus, &s.LastTestError)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lastConnected.Valid {
|
||||||
|
s.LastConnectedAt = &lastConnected.Time
|
||||||
|
}
|
||||||
|
if lastTest.Valid {
|
||||||
|
s.LastTestAt = &lastTest.Time
|
||||||
|
}
|
||||||
|
servers = append(servers, &s)
|
||||||
|
}
|
||||||
|
return servers, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateTestResult(alias string, status model.TestStatus, testErr string) error {
|
||||||
|
_, err := db.conn.Exec(`
|
||||||
|
UPDATE servers SET last_test_at=CURRENT_TIMESTAMP, last_test_status=?, last_test_error=?
|
||||||
|
WHERE alias=?`, status, testErr, alias)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateLastConnected(alias string) error {
|
||||||
|
_, err := db.conn.Exec("UPDATE servers SET last_connected_at=CURRENT_TIMESTAMP WHERE alias=?", alias)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag methods
|
||||||
|
func (db *DB) AddTagToServer(serverID int64, tagName string) error {
|
||||||
|
var tagID int64
|
||||||
|
err := db.conn.QueryRow("SELECT id FROM tags WHERE name=?", tagName).Scan(&tagID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
result, err := db.conn.Exec("INSERT INTO tags (name) VALUES (?)", tagName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tagID, _ = result.LastInsertId()
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = db.conn.Exec("INSERT OR IGNORE INTO server_tags (server_id, tag_id) VALUES (?, ?)", serverID, tagID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetServerTags(serverID int64) ([]string, error) {
|
||||||
|
rows, err := db.conn.Query(`
|
||||||
|
SELECT t.name FROM tags t
|
||||||
|
JOIN server_tags st ON st.tag_id = t.id
|
||||||
|
WHERE st.server_id = ?
|
||||||
|
ORDER BY t.name`, serverID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tags []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tags = append(tags, name)
|
||||||
|
}
|
||||||
|
return tags, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward methods
|
||||||
|
func (db *DB) AddForward(serverID int64, fwdType model.ForwardType, localAddr string, localPort int, remoteAddr string, remotePort int) error {
|
||||||
|
_, err := db.conn.Exec(`
|
||||||
|
INSERT INTO forwards (server_id, type, local_addr, local_port, remote_addr, remote_port)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
serverID, fwdType, localAddr, localPort, remoteAddr, remotePort)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetForwards(serverID int64) ([]*model.Forward, error) {
|
||||||
|
rows, err := db.conn.Query(`
|
||||||
|
SELECT id, server_id, type, local_addr, local_port, remote_addr, remote_port
|
||||||
|
FROM forwards WHERE server_id=?`, serverID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var forwards []*model.Forward
|
||||||
|
for rows.Next() {
|
||||||
|
var f model.Forward
|
||||||
|
if err := rows.Scan(&f.ID, &f.ServerID, &f.Type, &f.LocalAddr, &f.LocalPort, &f.RemoteAddr, &f.RemotePort); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
forwards = append(forwards, &f)
|
||||||
|
}
|
||||||
|
return forwards, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure time import is used
|
||||||
|
var _ time.Time
|
||||||
|
|
||||||
|
// Command template methods
|
||||||
|
func (db *DB) AddCommandTemplate(serverID int64, name, command string) error {
|
||||||
|
_, err := db.conn.Exec(
|
||||||
|
"INSERT INTO command_templates (server_id, name, command) VALUES (?, ?, ?)",
|
||||||
|
serverID, name, command)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetCommandTemplates(serverAlias string) ([]*model.CommandTemplate, error) {
|
||||||
|
rows, err := db.conn.Query(`
|
||||||
|
SELECT ct.id, ct.server_id, ct.name, ct.command
|
||||||
|
FROM command_templates ct
|
||||||
|
JOIN servers s ON s.id = ct.server_id
|
||||||
|
WHERE s.alias = ?
|
||||||
|
ORDER BY ct.name`, serverAlias)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var templates []*model.CommandTemplate
|
||||||
|
for rows.Next() {
|
||||||
|
var t model.CommandTemplate
|
||||||
|
if err := rows.Scan(&t.ID, &t.ServerID, &t.Name, &t.Command); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
templates = append(templates, &t)
|
||||||
|
}
|
||||||
|
return templates, rows.Err()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type AuthMethod string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuthPassword AuthMethod = "password"
|
||||||
|
AuthKey AuthMethod = "key"
|
||||||
|
AuthKeyPassphrase AuthMethod = "key_passphrase"
|
||||||
|
AuthAgent AuthMethod = "agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TestStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TestUnknown TestStatus = "unknown"
|
||||||
|
TestOK TestStatus = "ok"
|
||||||
|
TestFailed TestStatus = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
User string `json:"user"`
|
||||||
|
AuthMethod AuthMethod `json:"auth_method"`
|
||||||
|
IdentityFile string `json:"identity_file"`
|
||||||
|
ProxyJump string `json:"proxy_jump"`
|
||||||
|
GroupName string `json:"group_name"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
LastConnectedAt *time.Time `json:"last_connected_at"`
|
||||||
|
LastTestAt *time.Time `json:"last_test_at"`
|
||||||
|
LastTestStatus TestStatus `json:"last_test_status"`
|
||||||
|
LastTestError string `json:"last_test_error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SecretType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SecretSSHPassword SecretType = "ssh_password"
|
||||||
|
SecretKeyPassphrase SecretType = "key_passphrase"
|
||||||
|
SecretSudoPassword SecretType = "sudo_password"
|
||||||
|
SecretCustom SecretType = "custom_secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Secret struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type SecretType `json:"type"`
|
||||||
|
Nonce []byte `json:"nonce"`
|
||||||
|
Data []byte `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ForwardType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ForwardLocal ForwardType = "local"
|
||||||
|
ForwardRemote ForwardType = "remote"
|
||||||
|
ForwardDynamic ForwardType = "dynamic"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Forward struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ServerID int64 `json:"server_id"`
|
||||||
|
Type ForwardType `json:"type"`
|
||||||
|
LocalAddr string `json:"local_addr"`
|
||||||
|
LocalPort int `json:"local_port"`
|
||||||
|
RemoteAddr string `json:"remote_addr"`
|
||||||
|
RemotePort int `json:"remote_port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tag struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommandTemplate struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ServerID int64 `json:"server_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/config"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VaultFunc func(serverAlias string, secretType string) (string, error)
|
||||||
|
|
||||||
|
func Connect(cfg *config.Config, server *model.Server, getVault VaultFunc) error {
|
||||||
|
args := buildArgs(server)
|
||||||
|
|
||||||
|
switch server.AuthMethod {
|
||||||
|
case model.AuthPassword:
|
||||||
|
password, err := getVault(server.Alias, "ssh_password")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get password from vault: %w", err)
|
||||||
|
}
|
||||||
|
return ConnectWithPassword(cfg.SSH.Binary, args, password)
|
||||||
|
|
||||||
|
case model.AuthKeyPassphrase:
|
||||||
|
// For key+passphrase, we need to handle the passphrase
|
||||||
|
// For now, let ssh-agent handle it or prompt normally
|
||||||
|
// TODO: use ssh-agent or similar
|
||||||
|
fallthrough
|
||||||
|
|
||||||
|
default:
|
||||||
|
// key, agent, key+passphrase - direct execution
|
||||||
|
cmd := exec.Command(cfg.SSH.Binary, args...)
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("start ssh: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test(cfg *config.Config, server *model.Server, getVault VaultFunc) (bool, string) {
|
||||||
|
args := buildArgs(server)
|
||||||
|
args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec))
|
||||||
|
|
||||||
|
switch server.AuthMethod {
|
||||||
|
case model.AuthPassword:
|
||||||
|
// For password auth, we can't use BatchMode
|
||||||
|
// Use a short timeout and try to connect
|
||||||
|
args = append(args, "-o", "NumberOfPasswordPrompts=1")
|
||||||
|
password, err := getVault(server.Alias, "ssh_password")
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Sprintf("vault error: %v", err)
|
||||||
|
}
|
||||||
|
return testWithPassword(cfg, args, password)
|
||||||
|
|
||||||
|
default:
|
||||||
|
// key, agent, key+passphrase
|
||||||
|
args = append(args, "-o", "BatchMode=yes")
|
||||||
|
args = append(args, cfg.SSH.TestCommand)
|
||||||
|
|
||||||
|
cmd := exec.Command(cfg.SSH.Binary, args...)
|
||||||
|
cmd.Stdin = nil
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return false, strings.TrimSpace(string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
result := strings.TrimSpace(string(output))
|
||||||
|
if result == "SSHKEEPER_OK" {
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
return false, result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWithPassword(cfg *config.Config, args []string, password string) (bool, string) {
|
||||||
|
// For password test, we use PTY approach with a short timeout
|
||||||
|
// This is a simplified version - in production, use ConnectWithPassword
|
||||||
|
// with a test command
|
||||||
|
args = append(args, cfg.SSH.TestCommand)
|
||||||
|
|
||||||
|
cmd := exec.Command(cfg.SSH.Binary, args...)
|
||||||
|
cmd.Stdin = nil
|
||||||
|
cmd.Stdout = nil
|
||||||
|
cmd.Stderr = nil
|
||||||
|
|
||||||
|
// Use a timeout
|
||||||
|
done := make(chan error, 1)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
done <- cmd.Wait()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
return true, ""
|
||||||
|
case <-time.After(time.Duration(cfg.SSH.ConnectTimeoutSec) * time.Second):
|
||||||
|
cmd.Process.Kill()
|
||||||
|
return false, "connection timeout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildArgs(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable strict host key checking for first connection
|
||||||
|
// In production, this should be configurable
|
||||||
|
args = append(args, "-o", "StrictHostKeyChecking=accept-new")
|
||||||
|
|
||||||
|
target := fmt.Sprintf("%s@%s", server.User, server.Host)
|
||||||
|
args = append(args, target)
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateConfig creates OpenSSH config content from server profiles
|
||||||
|
func GenerateConfig(servers []*model.Server) (string, error) {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString("# Generated by sshkeeper. Do not edit manually.\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("# Generated at: %s\n\n", time.Now().Format(time.RFC3339)))
|
||||||
|
|
||||||
|
for _, s := range servers {
|
||||||
|
sb.WriteString(fmt.Sprintf("Host %s\n", s.Alias))
|
||||||
|
sb.WriteString(fmt.Sprintf(" HostName %s\n", s.Host))
|
||||||
|
if s.Port != 22 {
|
||||||
|
sb.WriteString(fmt.Sprintf(" Port %d\n", s.Port))
|
||||||
|
}
|
||||||
|
if s.User != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" User %s\n", s.User))
|
||||||
|
}
|
||||||
|
if s.IdentityFile != "" && s.AuthMethod != model.AuthPassword {
|
||||||
|
sb.WriteString(fmt.Sprintf(" IdentityFile %s\n", s.IdentityFile))
|
||||||
|
}
|
||||||
|
if s.ProxyJump != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" ProxyJump %s\n", s.ProxyJump))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteConfig writes the generated config to ~/.ssh/config.d/sshkeeper.conf
|
||||||
|
func WriteConfig(servers []*model.Server) error {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
configD := home + "/.ssh/config.d"
|
||||||
|
if err := os.MkdirAll(configD, 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := GenerateConfig(servers)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
configFile := configD + "/sshkeeper.conf"
|
||||||
|
tmpFile := configFile + ".tmp"
|
||||||
|
|
||||||
|
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.Rename(tmpFile, configFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallInclude adds "Include ~/.ssh/config.d/*.conf" to ~/.ssh/config
|
||||||
|
func InstallInclude() error {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sshDir := home + "/.ssh"
|
||||||
|
configD := sshDir + "/config.d"
|
||||||
|
mainConfig := sshDir + "/config"
|
||||||
|
|
||||||
|
if err := os.MkdirAll(configD, 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
includeLine := "Include ~/.ssh/config.d/*.conf"
|
||||||
|
|
||||||
|
// Check if already included
|
||||||
|
if data, err := os.ReadFile(mainConfig); err == nil {
|
||||||
|
if strings.Contains(string(data), "Include ~/.ssh/config.d") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepend include line
|
||||||
|
f, err := os.OpenFile(mainConfig, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
_, err = f.WriteString("\n" + includeLine + "\n")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImportFromSSHConfig parses ~/.ssh/config and returns server profiles
|
||||||
|
func ImportFromSSHConfig() ([]*model.Server, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(home, ".ssh", "config")
|
||||||
|
f, err := os.Open(configPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("~/.ssh/config not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var servers []*model.Server
|
||||||
|
var current *model.Server
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.ToLower(fields[0])
|
||||||
|
value := strings.Join(fields[1:], " ")
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "host":
|
||||||
|
if current != nil && current.Host != "" {
|
||||||
|
servers = append(servers, current)
|
||||||
|
}
|
||||||
|
// Skip wildcard hosts and patterns
|
||||||
|
if strings.Contains(value, "*") || strings.Contains(value, "?") {
|
||||||
|
current = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current = &model.Server{
|
||||||
|
Alias: value,
|
||||||
|
Host: value,
|
||||||
|
Port: 22,
|
||||||
|
User: "",
|
||||||
|
AuthMethod: model.AuthKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
case "hostname":
|
||||||
|
if current != nil {
|
||||||
|
current.Host = value
|
||||||
|
}
|
||||||
|
|
||||||
|
case "port":
|
||||||
|
if current != nil {
|
||||||
|
if port, err := strconv.Atoi(value); err == nil {
|
||||||
|
current.Port = port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "user":
|
||||||
|
if current != nil {
|
||||||
|
current.User = value
|
||||||
|
}
|
||||||
|
|
||||||
|
case "identityfile":
|
||||||
|
if current != nil {
|
||||||
|
current.IdentityFile = value
|
||||||
|
if current.AuthMethod == model.AuthKey {
|
||||||
|
current.AuthMethod = model.AuthKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "proxyjump":
|
||||||
|
if current != nil {
|
||||||
|
current.ProxyJump = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't forget the last host
|
||||||
|
if current != nil && current.Host != "" {
|
||||||
|
servers = append(servers, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return servers, scanner.Err()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
var passwordPromptRe = regexp.MustCompile(`(?i)(password|passphrase).*:\s*$`)
|
||||||
|
|
||||||
|
// ConnectWithPassword runs SSH through a PTY, detects the password prompt,
|
||||||
|
// sends the password, and then bridges the user terminal to the SSH session.
|
||||||
|
func ConnectWithPassword(sshBinary string, args []string, password string) error {
|
||||||
|
// Start SSH with PTY
|
||||||
|
cmd := exec.Command(sshBinary, args...)
|
||||||
|
cmd.Env = os.Environ()
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
Setsid: true,
|
||||||
|
Setctty: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
ptmx, err := pty.Start(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("start ssh with pty: %w", err)
|
||||||
|
}
|
||||||
|
defer ptmx.Close()
|
||||||
|
|
||||||
|
// Save terminal state and set to raw
|
||||||
|
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set raw terminal: %w", err)
|
||||||
|
}
|
||||||
|
defer term.Restore(int(os.Stdin.Fd()), oldState)
|
||||||
|
|
||||||
|
// Channel to signal when password has been sent
|
||||||
|
passwordSent := make(chan bool, 1)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
|
||||||
|
// Read from PTY, detect password prompt
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
var accumulated strings.Builder
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, err := ptmx.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
data := buf[:n]
|
||||||
|
accumulated.Write(data)
|
||||||
|
|
||||||
|
// Write to stdout
|
||||||
|
os.Stdout.Write(data)
|
||||||
|
|
||||||
|
// Check for password prompt
|
||||||
|
if !<-passwordSent {
|
||||||
|
text := accumulated.String()
|
||||||
|
if passwordPromptRe.MatchString(text) {
|
||||||
|
passwordSent <- true
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
ptmx.Write([]byte(password + "\r"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset accumulated buffer periodically to avoid unbounded growth
|
||||||
|
if accumulated.Len() > 8192 {
|
||||||
|
s := accumulated.String()
|
||||||
|
accumulated.Reset()
|
||||||
|
accumulated.WriteString(s[len(s)-2048:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
done <- err
|
||||||
|
} else {
|
||||||
|
done <- nil
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Copy stdin to PTY
|
||||||
|
go func() {
|
||||||
|
io.Copy(ptmx, os.Stdin)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for command completion
|
||||||
|
err = cmd.Wait()
|
||||||
|
passwordSent <- false // signal to stop
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,680 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/list"
|
||||||
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
"github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Styles ---
|
||||||
|
|
||||||
|
var (
|
||||||
|
titleStyle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("12")).
|
||||||
|
MarginLeft(2)
|
||||||
|
|
||||||
|
selectedStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("15")).
|
||||||
|
Background(lipgloss.Color("4")).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
normalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15"))
|
||||||
|
|
||||||
|
testOKStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
|
||||||
|
testFailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
|
||||||
|
|
||||||
|
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).MarginLeft(2)
|
||||||
|
|
||||||
|
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
|
||||||
|
successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
|
||||||
|
|
||||||
|
focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true)
|
||||||
|
blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("7"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Messages ---
|
||||||
|
|
||||||
|
type serversLoadedMsg struct {
|
||||||
|
servers []*model.Server
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type testDoneMsg struct {
|
||||||
|
ok bool
|
||||||
|
err string
|
||||||
|
}
|
||||||
|
|
||||||
|
type saveDoneMsg struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectRequestMsg — TUI requests a connect action to be handled outside
|
||||||
|
type connectRequestMsg struct {
|
||||||
|
server *model.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Server list item ---
|
||||||
|
|
||||||
|
type serverItem struct {
|
||||||
|
server *model.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i serverItem) Title() string { return i.server.Alias }
|
||||||
|
func (i serverItem) Description() string { return fmt.Sprintf("%s@%s:%d %s", i.server.User, i.server.Host, i.server.Port, i.server.AuthMethod) }
|
||||||
|
func (i serverItem) FilterValue() string { return i.server.Alias + " " + i.server.DisplayName + " " + i.server.Host + " " + i.server.User }
|
||||||
|
|
||||||
|
// --- External callbacks ---
|
||||||
|
|
||||||
|
var (
|
||||||
|
ListServers func() ([]*model.Server, error)
|
||||||
|
SearchServers func(query string) ([]*model.Server, error)
|
||||||
|
DeleteServer func(alias string) error
|
||||||
|
TestConnection func(server *model.Server) (bool, string)
|
||||||
|
SaveServer func(server *model.Server, password string) error
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Screen type ---
|
||||||
|
|
||||||
|
type screen int
|
||||||
|
|
||||||
|
const (
|
||||||
|
screenList screen = iota
|
||||||
|
screenForm
|
||||||
|
screenSearch
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Result type — returned from TUI to caller ---
|
||||||
|
|
||||||
|
type TUIResult struct {
|
||||||
|
Server *model.Server
|
||||||
|
Action string // "connect"
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main TUI model ---
|
||||||
|
|
||||||
|
type tuiModel struct {
|
||||||
|
screen screen
|
||||||
|
list list.Model
|
||||||
|
servers []*model.Server
|
||||||
|
searchInput textinput.Model
|
||||||
|
form *formModel
|
||||||
|
err error
|
||||||
|
success string
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
result *TUIResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(servers []*model.Server) *tuiModel {
|
||||||
|
items := make([]list.Item, len(servers))
|
||||||
|
for i, s := range servers {
|
||||||
|
items[i] = serverItem{server: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
|
||||||
|
l.Title = "sshkeeper"
|
||||||
|
l.SetShowStatusBar(false)
|
||||||
|
l.SetFilteringEnabled(false)
|
||||||
|
l.Styles.Title = titleStyle
|
||||||
|
|
||||||
|
search := textinput.New()
|
||||||
|
search.Placeholder = "Search..."
|
||||||
|
search.CharLimit = 64
|
||||||
|
|
||||||
|
return &tuiModel{
|
||||||
|
screen: screenList,
|
||||||
|
list: l,
|
||||||
|
servers: servers,
|
||||||
|
searchInput: search,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) Result() *TUIResult {
|
||||||
|
return m.result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
m.list.SetSize(msg.Width, msg.Height-4)
|
||||||
|
if m.form != nil {
|
||||||
|
m.form.width = msg.Width
|
||||||
|
m.form.height = msg.Height
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case serversLoadedMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.err = msg.err
|
||||||
|
} else {
|
||||||
|
m.servers = msg.servers
|
||||||
|
items := make([]list.Item, len(msg.servers))
|
||||||
|
for i, s := range msg.servers {
|
||||||
|
items[i] = serverItem{server: s}
|
||||||
|
}
|
||||||
|
m.list.SetItems(items)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case connectRequestMsg:
|
||||||
|
// Store result and quit TUI — caller will handle the connect
|
||||||
|
m.result = &TUIResult{
|
||||||
|
Server: msg.server,
|
||||||
|
Action: "connect",
|
||||||
|
}
|
||||||
|
return m, tea.Quit
|
||||||
|
|
||||||
|
case testDoneMsg:
|
||||||
|
if m.form != nil {
|
||||||
|
m.form.testing = false
|
||||||
|
if msg.ok {
|
||||||
|
m.form.testResult = "Connection OK."
|
||||||
|
m.form.testOK = true
|
||||||
|
} else {
|
||||||
|
m.form.testResult = fmt.Sprintf("Connection failed:\n%s", msg.err)
|
||||||
|
m.form.testOK = false
|
||||||
|
}
|
||||||
|
m.form.testResultTime = time.Now()
|
||||||
|
m.form.err = nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case saveDoneMsg:
|
||||||
|
if m.form != nil {
|
||||||
|
m.form.saving = false
|
||||||
|
if msg.err != nil {
|
||||||
|
m.form.err = msg.err
|
||||||
|
m.form.saved = false
|
||||||
|
} else {
|
||||||
|
m.form.saved = true
|
||||||
|
m.form.savedTime = time.Now()
|
||||||
|
m.form.err = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch m.screen {
|
||||||
|
case screenList:
|
||||||
|
return m.updateList(msg)
|
||||||
|
case screenForm:
|
||||||
|
return m.updateForm(msg)
|
||||||
|
case screenSearch:
|
||||||
|
return m.updateSearch(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "q", "ctrl+c":
|
||||||
|
return m, tea.Quit
|
||||||
|
|
||||||
|
case "/":
|
||||||
|
m.screen = screenSearch
|
||||||
|
m.searchInput.Focus()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case "a":
|
||||||
|
m.form = newFormModel(m.width, m.height)
|
||||||
|
m.screen = screenForm
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case "e":
|
||||||
|
if item, ok := m.list.SelectedItem().(serverItem); ok {
|
||||||
|
m.form = newEditFormModel(item.server, m.width, m.height)
|
||||||
|
m.screen = screenForm
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case "d":
|
||||||
|
if item, ok := m.list.SelectedItem().(serverItem); ok {
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
err := DeleteServer(item.server.Alias)
|
||||||
|
if err != nil {
|
||||||
|
return saveDoneMsg{err: err}
|
||||||
|
}
|
||||||
|
servers, err := ListServers()
|
||||||
|
return serversLoadedMsg{servers: servers, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "t":
|
||||||
|
if item, ok := m.list.SelectedItem().(serverItem); ok {
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
ok, testErr := TestConnection(item.server)
|
||||||
|
return testDoneMsg{ok: ok, err: testErr}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "enter":
|
||||||
|
if item, ok := m.list.SelectedItem().(serverItem); ok {
|
||||||
|
// Request connect — TUI will quit and caller handles it
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
return connectRequestMsg{server: item.server}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.list, cmd = m.list.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc":
|
||||||
|
m.screen = screenList
|
||||||
|
m.searchInput.Blur()
|
||||||
|
m.searchInput.SetValue("")
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case "enter":
|
||||||
|
m.screen = screenList
|
||||||
|
m.searchInput.Blur()
|
||||||
|
query := m.searchInput.Value()
|
||||||
|
if query != "" {
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
servers, err := SearchServers(query)
|
||||||
|
return serversLoadedMsg{servers: servers, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
servers, err := ListServers()
|
||||||
|
return serversLoadedMsg{servers: servers, err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc":
|
||||||
|
m.screen = screenList
|
||||||
|
m.form = nil
|
||||||
|
m.err = nil
|
||||||
|
m.success = ""
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, cmd := m.form.Update(msg)
|
||||||
|
if fm, ok := updated.(*formModel); ok {
|
||||||
|
m.form = fm
|
||||||
|
}
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
switch m.screen {
|
||||||
|
case screenList:
|
||||||
|
b.WriteString(m.list.View())
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(helpStyle.Render("Enter connect | a add | e edit | d delete | t test | / search | q quit"))
|
||||||
|
|
||||||
|
case screenSearch:
|
||||||
|
b.WriteString("Search: " + m.searchInput.View() + "\n")
|
||||||
|
b.WriteString(helpStyle.Render("Enter search | Esc cancel"))
|
||||||
|
|
||||||
|
case screenForm:
|
||||||
|
b.WriteString(m.form.View())
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.err != nil {
|
||||||
|
b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
|
||||||
|
m.err = nil
|
||||||
|
}
|
||||||
|
if m.success != "" {
|
||||||
|
b.WriteString("\n" + successStyle.Render(m.success))
|
||||||
|
m.success = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Form model ---
|
||||||
|
|
||||||
|
type formModel struct {
|
||||||
|
edit bool
|
||||||
|
server *model.Server
|
||||||
|
inputs []textinput.Model
|
||||||
|
password textinput.Model
|
||||||
|
focusIdx int
|
||||||
|
testResult string
|
||||||
|
testOK bool
|
||||||
|
testResultTime time.Time
|
||||||
|
testing bool
|
||||||
|
saving bool
|
||||||
|
saved bool
|
||||||
|
savedTime time.Time
|
||||||
|
err error
|
||||||
|
spinner spinner.Model
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFormModel(w, h int) *formModel {
|
||||||
|
inputs := make([]textinput.Model, 10)
|
||||||
|
labels := []string{
|
||||||
|
"Alias",
|
||||||
|
"Display Name",
|
||||||
|
"Host",
|
||||||
|
"Port",
|
||||||
|
"User",
|
||||||
|
"Auth Method (password/key/key_passphrase/agent)",
|
||||||
|
"Identity File",
|
||||||
|
"ProxyJump",
|
||||||
|
"Group",
|
||||||
|
"Notes",
|
||||||
|
}
|
||||||
|
for i, label := range labels {
|
||||||
|
inputs[i] = textinput.New()
|
||||||
|
inputs[i].Placeholder = label
|
||||||
|
inputs[i].CharLimit = 128
|
||||||
|
}
|
||||||
|
|
||||||
|
pw := textinput.New()
|
||||||
|
pw.Placeholder = "Password / Passphrase (stored in vault)"
|
||||||
|
pw.CharLimit = 256
|
||||||
|
pw.EchoMode = textinput.EchoPassword
|
||||||
|
|
||||||
|
s := spinner.New()
|
||||||
|
s.Spinner = spinner.Dot
|
||||||
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("12"))
|
||||||
|
|
||||||
|
inputs[0].Focus()
|
||||||
|
|
||||||
|
return &formModel{
|
||||||
|
inputs: inputs,
|
||||||
|
password: pw,
|
||||||
|
focusIdx: 0,
|
||||||
|
spinner: s,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEditFormModel(s *model.Server, w, h int) *formModel {
|
||||||
|
fm := newFormModel(w, h)
|
||||||
|
fm.edit = true
|
||||||
|
fm.server = s
|
||||||
|
fm.inputs[0].SetValue(s.Alias)
|
||||||
|
fm.inputs[1].SetValue(s.DisplayName)
|
||||||
|
fm.inputs[2].SetValue(s.Host)
|
||||||
|
fm.inputs[3].SetValue(fmt.Sprintf("%d", s.Port))
|
||||||
|
fm.inputs[4].SetValue(s.User)
|
||||||
|
fm.inputs[5].SetValue(string(s.AuthMethod))
|
||||||
|
fm.inputs[6].SetValue(s.IdentityFile)
|
||||||
|
fm.inputs[7].SetValue(s.ProxyJump)
|
||||||
|
fm.inputs[8].SetValue(s.GroupName)
|
||||||
|
fm.inputs[9].SetValue(s.Notes)
|
||||||
|
return fm
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
// Handle test/save completion
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case testDoneMsg:
|
||||||
|
fm.testing = false
|
||||||
|
if msg.ok {
|
||||||
|
fm.testResult = "Connection OK."
|
||||||
|
fm.testOK = true
|
||||||
|
} else {
|
||||||
|
fm.testResult = fmt.Sprintf("Connection failed:\n%s", msg.err)
|
||||||
|
fm.testOK = false
|
||||||
|
}
|
||||||
|
fm.testResultTime = time.Now()
|
||||||
|
fm.err = nil
|
||||||
|
return fm, nil
|
||||||
|
case saveDoneMsg:
|
||||||
|
fm.saving = false
|
||||||
|
if msg.err != nil {
|
||||||
|
fm.err = msg.err
|
||||||
|
fm.saved = false
|
||||||
|
} else {
|
||||||
|
fm.saved = true
|
||||||
|
fm.savedTime = time.Now()
|
||||||
|
fm.err = nil
|
||||||
|
}
|
||||||
|
return fm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle spinner tick while testing/saving
|
||||||
|
if fm.testing || fm.saving {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
fm.spinner, cmd = fm.spinner.Update(msg)
|
||||||
|
if _, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
return fm, cmd
|
||||||
|
}
|
||||||
|
return fm, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "tab", "down":
|
||||||
|
fm.focusIdx++
|
||||||
|
total := len(fm.inputs) + 3
|
||||||
|
if fm.focusIdx >= total {
|
||||||
|
fm.focusIdx = 0
|
||||||
|
}
|
||||||
|
fm.updateFocus()
|
||||||
|
return fm, nil
|
||||||
|
|
||||||
|
case "shift+tab", "up":
|
||||||
|
fm.focusIdx--
|
||||||
|
if fm.focusIdx < 0 {
|
||||||
|
total := len(fm.inputs) + 3
|
||||||
|
fm.focusIdx = total - 1
|
||||||
|
}
|
||||||
|
fm.updateFocus()
|
||||||
|
return fm, nil
|
||||||
|
|
||||||
|
case "enter":
|
||||||
|
switch {
|
||||||
|
case fm.focusIdx == len(fm.inputs)+1:
|
||||||
|
return fm, fm.runTest()
|
||||||
|
case fm.focusIdx == len(fm.inputs)+2:
|
||||||
|
return fm, fm.runSave()
|
||||||
|
default:
|
||||||
|
fm.focusIdx++
|
||||||
|
total := len(fm.inputs) + 3
|
||||||
|
if fm.focusIdx >= total {
|
||||||
|
fm.focusIdx = 0
|
||||||
|
}
|
||||||
|
fm.updateFocus()
|
||||||
|
return fm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case "esc":
|
||||||
|
return fm, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fm.focusIdx < len(fm.inputs) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
fm.inputs[fm.focusIdx], cmd = fm.inputs[fm.focusIdx].Update(msg)
|
||||||
|
return fm, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
if fm.focusIdx == len(fm.inputs) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
fm.password, cmd = fm.password.Update(msg)
|
||||||
|
return fm, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
return fm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) updateFocus() {
|
||||||
|
for i := range fm.inputs {
|
||||||
|
fm.inputs[i].Blur()
|
||||||
|
fm.inputs[i].Prompt = blurredStyle.Render(fm.inputs[i].Placeholder + ": ")
|
||||||
|
}
|
||||||
|
fm.password.Blur()
|
||||||
|
fm.password.Prompt = blurredStyle.Render(fm.password.Placeholder + ": ")
|
||||||
|
|
||||||
|
if fm.focusIdx < len(fm.inputs) {
|
||||||
|
fm.inputs[fm.focusIdx].Focus()
|
||||||
|
fm.inputs[fm.focusIdx].Prompt = focusedStyle.Render(fm.inputs[fm.focusIdx].Placeholder + "> ")
|
||||||
|
} else if fm.focusIdx == len(fm.inputs) {
|
||||||
|
fm.password.Focus()
|
||||||
|
fm.password.Prompt = focusedStyle.Render(fm.password.Placeholder + "> ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) runTest() tea.Cmd {
|
||||||
|
fm.testing = true
|
||||||
|
fm.testResult = ""
|
||||||
|
fm.err = nil
|
||||||
|
fm.saved = false
|
||||||
|
|
||||||
|
s := fm.buildServer()
|
||||||
|
return tea.Batch(
|
||||||
|
fm.spinner.Tick,
|
||||||
|
func() tea.Msg {
|
||||||
|
if s.AuthMethod == model.AuthPassword && fm.password.Value() == "" {
|
||||||
|
return testDoneMsg{ok: false, err: "Password is required for password auth."}
|
||||||
|
}
|
||||||
|
ok, testErr := TestConnection(s)
|
||||||
|
return testDoneMsg{ok: ok, err: testErr}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) runSave() tea.Cmd {
|
||||||
|
fm.saving = true
|
||||||
|
fm.err = nil
|
||||||
|
fm.saved = false
|
||||||
|
fm.testResult = ""
|
||||||
|
|
||||||
|
s := fm.buildServer()
|
||||||
|
pw := fm.password.Value()
|
||||||
|
|
||||||
|
return tea.Batch(
|
||||||
|
fm.spinner.Tick,
|
||||||
|
func() tea.Msg {
|
||||||
|
if s.Alias == "" {
|
||||||
|
return saveDoneMsg{err: fmt.Errorf("alias is required")}
|
||||||
|
}
|
||||||
|
if s.Host == "" {
|
||||||
|
return saveDoneMsg{err: fmt.Errorf("host is required")}
|
||||||
|
}
|
||||||
|
err := SaveServer(s, pw)
|
||||||
|
return saveDoneMsg{err: err}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) buildServer() *model.Server {
|
||||||
|
port := 22
|
||||||
|
fmt.Sscanf(fm.inputs[3].Value(), "%d", &port)
|
||||||
|
authMethod := model.AuthMethod(fm.inputs[5].Value())
|
||||||
|
if authMethod == "" {
|
||||||
|
authMethod = model.AuthKey
|
||||||
|
}
|
||||||
|
return &model.Server{
|
||||||
|
Alias: fm.inputs[0].Value(),
|
||||||
|
DisplayName: fm.inputs[1].Value(),
|
||||||
|
Host: fm.inputs[2].Value(),
|
||||||
|
Port: port,
|
||||||
|
User: fm.inputs[4].Value(),
|
||||||
|
AuthMethod: authMethod,
|
||||||
|
IdentityFile: fm.inputs[6].Value(),
|
||||||
|
ProxyJump: fm.inputs[7].Value(),
|
||||||
|
GroupName: fm.inputs[8].Value(),
|
||||||
|
Notes: fm.inputs[9].Value(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
title := "Add Server"
|
||||||
|
if fm.edit {
|
||||||
|
title = "Edit Server: " + fm.server.Alias
|
||||||
|
}
|
||||||
|
b.WriteString(titleStyle.Render(title))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
for i := range fm.inputs {
|
||||||
|
b.WriteString(fm.inputs[i].View())
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(fm.password.View())
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
showResults := time.Since(fm.testResultTime) < 10*time.Second || time.Since(fm.savedTime) < 10*time.Second
|
||||||
|
|
||||||
|
if fm.testing {
|
||||||
|
b.WriteString("\n" + fm.spinner.View() + " Testing connection...\n")
|
||||||
|
} else if fm.saving {
|
||||||
|
b.WriteString("\n" + fm.spinner.View() + " Saving...\n")
|
||||||
|
} else if showResults {
|
||||||
|
if fm.testResult != "" {
|
||||||
|
b.WriteString("\n")
|
||||||
|
if fm.testOK {
|
||||||
|
b.WriteString(testOKStyle.Render("✓ " + fm.testResult))
|
||||||
|
} else {
|
||||||
|
b.WriteString(testFailStyle.Render("✗ " + fm.testResult))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
if fm.saved {
|
||||||
|
b.WriteString("\n" + successStyle.Render("✓ Saved.") + "\n")
|
||||||
|
}
|
||||||
|
if fm.err != nil {
|
||||||
|
b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testBtn := "[ Test ]"
|
||||||
|
saveBtn := "[ Save ]"
|
||||||
|
|
||||||
|
if fm.focusIdx == len(fm.inputs)+1 {
|
||||||
|
testBtn = selectedStyle.Render(testBtn)
|
||||||
|
} else {
|
||||||
|
testBtn = normalStyle.Render(testBtn)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fm.focusIdx == len(fm.inputs)+2 {
|
||||||
|
saveBtn = selectedStyle.Render(saveBtn)
|
||||||
|
} else {
|
||||||
|
saveBtn = normalStyle.Render(saveBtn)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n" + testBtn + " " + saveBtn + "\n\n")
|
||||||
|
b.WriteString(helpStyle.Render("Tab/↓ next | ↑ prev | Enter select | Esc back"))
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,463 @@
|
||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
"golang.org/x/crypto/chacha20poly1305"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
currentVersion = 1
|
||||||
|
saltLen = 32
|
||||||
|
nonceLen = 24
|
||||||
|
keyLen = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
type KDFMeta struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MemoryKiB int `json:"memory_kib"`
|
||||||
|
Iterations int `json:"iterations"`
|
||||||
|
Parallelism int `json:"parallelism"`
|
||||||
|
Salt string `json:"salt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Record struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
Ciphertext string `json:"ciphertext"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VaultFile struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
KDF KDFMeta `json:"kdf"`
|
||||||
|
Records []Record `json:"records"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Vault struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
path string
|
||||||
|
masterKey []byte
|
||||||
|
records map[string][]byte // id -> plaintext
|
||||||
|
modified bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(path string) *Vault {
|
||||||
|
return &Vault{
|
||||||
|
path: path,
|
||||||
|
records: make(map[string][]byte),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists checks if vault file exists and has content
|
||||||
|
func Exists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return info.Size() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initializes a new vault with a master password
|
||||||
|
func Create(path string, masterPassword string) error {
|
||||||
|
salt := make([]byte, saltLen)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||||
|
return fmt.Errorf("generate salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kdf := KDFMeta{
|
||||||
|
Name: "argon2id",
|
||||||
|
MemoryKiB: 4096,
|
||||||
|
Iterations: 2,
|
||||||
|
Parallelism: 1,
|
||||||
|
Salt: base64.StdEncoding.EncodeToString(salt),
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Deriving key...")
|
||||||
|
|
||||||
|
key := argon2.IDKey([]byte(masterPassword), salt, uint32(kdf.Iterations), uint32(kdf.MemoryKiB)*1024, uint8(kdf.Parallelism), keyLen)
|
||||||
|
|
||||||
|
// Verify key is valid by doing a test encrypt/decrypt
|
||||||
|
vf := VaultFile{
|
||||||
|
Version: currentVersion,
|
||||||
|
KDF: kdf,
|
||||||
|
Records: []Record{},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(vf)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal vault: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create vault file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if _, err := f.Write(data); err != nil {
|
||||||
|
return fmt.Errorf("write vault: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear key from memory
|
||||||
|
for i := range key {
|
||||||
|
key[i] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock decrypts the vault with master password
|
||||||
|
func (v *Vault) Unlock(masterPassword string) error {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(v.path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read vault file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var vf VaultFile
|
||||||
|
if err := json.Unmarshal(data, &vf); err != nil {
|
||||||
|
return fmt.Errorf("parse vault: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if vf.Version != currentVersion {
|
||||||
|
return fmt.Errorf("unsupported vault version: %d", vf.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
salt, err := base64.StdEncoding.DecodeString(vf.KDF.Salt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := argon2.IDKey([]byte(masterPassword), salt, uint32(vf.KDF.Iterations), uint32(vf.KDF.MemoryKiB)*1024, uint8(vf.KDF.Parallelism), keyLen)
|
||||||
|
|
||||||
|
// Try to decrypt first record to verify password
|
||||||
|
if len(vf.Records) > 0 {
|
||||||
|
if _, err := decryptRecord(key, vf.Records[0]); err != nil {
|
||||||
|
return fmt.Errorf("invalid master password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
v.masterKey = key
|
||||||
|
v.records = make(map[string][]byte)
|
||||||
|
|
||||||
|
for _, rec := range vf.Records {
|
||||||
|
plaintext, err := decryptRecord(key, rec)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decrypt record %s: %w", rec.ID, err)
|
||||||
|
}
|
||||||
|
v.records[rec.ID] = plaintext
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock clears the master key and records from memory
|
||||||
|
func (v *Vault) Lock() {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
|
||||||
|
if v.masterKey != nil {
|
||||||
|
for i := range v.masterKey {
|
||||||
|
v.masterKey[i] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.masterKey = nil
|
||||||
|
v.records = make(map[string][]byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUnlocked returns whether the vault is currently unlocked
|
||||||
|
func (v *Vault) IsUnlocked() bool {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
return v.masterKey != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put stores a secret in memory (not persisted until Save)
|
||||||
|
func (v *Vault) Put(id string, secretType string, plaintext []byte) error {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
|
||||||
|
if v.masterKey == nil {
|
||||||
|
return fmt.Errorf("vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
v.records[id] = plaintext
|
||||||
|
v.modified = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a secret
|
||||||
|
func (v *Vault) Get(id string) ([]byte, error) {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
|
||||||
|
if v.masterKey == nil {
|
||||||
|
return nil, fmt.Errorf("vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := v.records[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("secret not found: %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]byte, len(data))
|
||||||
|
copy(result, data)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a secret
|
||||||
|
func (v *Vault) Delete(id string) {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
delete(v.records, id)
|
||||||
|
v.modified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save persists encrypted vault to disk
|
||||||
|
func (v *Vault) Save() error {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
|
||||||
|
if v.masterKey == nil {
|
||||||
|
return fmt.Errorf("vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
salt, err := base64.StdEncoding.DecodeString(v.getSalt())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
kdf := KDFMeta{
|
||||||
|
Name: "argon2id",
|
||||||
|
MemoryKiB: 4096,
|
||||||
|
Iterations: 2,
|
||||||
|
Parallelism: 1,
|
||||||
|
Salt: base64.StdEncoding.EncodeToString(salt),
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Deriving key...")
|
||||||
|
|
||||||
|
var records []Record
|
||||||
|
for id, plaintext := range v.records {
|
||||||
|
rec, err := encryptRecord(v.masterKey, id, plaintext)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encrypt record %s: %w", id, err)
|
||||||
|
}
|
||||||
|
records = append(records, rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
vf := VaultFile{
|
||||||
|
Version: currentVersion,
|
||||||
|
KDF: kdf,
|
||||||
|
Records: records,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(vf)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal vault: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := v.path + ".tmp"
|
||||||
|
f, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temp vault: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := f.Write(data); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("write vault: %w", err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
if err := os.Rename(tmpPath, v.path); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("rename vault: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v.modified = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword re-encrypts the vault with a new master password
|
||||||
|
func (v *Vault) ChangePassword(newPassword string) error {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
|
||||||
|
if v.masterKey == nil {
|
||||||
|
return fmt.Errorf("vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
salt := make([]byte, saltLen)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||||
|
return fmt.Errorf("generate salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newKey := argon2.IDKey([]byte(newPassword), salt, 3, 8192*1024, 1, keyLen)
|
||||||
|
|
||||||
|
kdf := KDFMeta{
|
||||||
|
Name: "argon2id",
|
||||||
|
MemoryKiB: 4096,
|
||||||
|
Iterations: 2,
|
||||||
|
Parallelism: 1,
|
||||||
|
Salt: base64.StdEncoding.EncodeToString(salt),
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Deriving key...")
|
||||||
|
|
||||||
|
var records []Record
|
||||||
|
for id, plaintext := range v.records {
|
||||||
|
rec, err := encryptRecord(newKey, id, plaintext)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encrypt record: %w", err)
|
||||||
|
}
|
||||||
|
records = append(records, rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
vf := VaultFile{
|
||||||
|
Version: currentVersion,
|
||||||
|
KDF: kdf,
|
||||||
|
Records: records,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(vf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := v.path + ".tmp"
|
||||||
|
f, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := f.Write(data); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
if err := os.Rename(tmpPath, v.path); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap key
|
||||||
|
for i := range v.masterKey {
|
||||||
|
v.masterKey[i] = 0
|
||||||
|
}
|
||||||
|
v.masterKey = newKey
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get salt from existing vault
|
||||||
|
func (v *Vault) getSalt() string {
|
||||||
|
data, err := os.ReadFile(v.path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var vf VaultFile
|
||||||
|
if err := json.Unmarshal(data, &vf); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return vf.KDF.Salt
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptRecord(key []byte, id string, plaintext []byte) (Record, error) {
|
||||||
|
aead, err := chacha20poly1305.NewX(key)
|
||||||
|
if err != nil {
|
||||||
|
return Record{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, nonceLen)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return Record{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := aead.Seal(nil, nonce, plaintext, []byte(id))
|
||||||
|
|
||||||
|
return Record{
|
||||||
|
ID: id,
|
||||||
|
Nonce: base64.StdEncoding.EncodeToString(nonce),
|
||||||
|
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptRecord(key []byte, rec Record) ([]byte, error) {
|
||||||
|
aead, err := chacha20poly1305.NewX(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, err := base64.StdEncoding.DecodeString(rec.Nonce)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext, err := base64.StdEncoding.DecodeString(rec.Ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode ciphertext: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext, err := aead.Open(nil, nonce, ciphertext, []byte(rec.ID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decrypt failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword checks if a master password is correct without unlocking
|
||||||
|
func VerifyPassword(path string, masterPassword string) (bool, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var vf VaultFile
|
||||||
|
if err := json.Unmarshal(data, &vf); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
salt, err := base64.StdEncoding.DecodeString(vf.KDF.Salt)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
key := argon2.IDKey([]byte(masterPassword), salt, uint32(vf.KDF.Iterations), uint32(vf.KDF.MemoryKiB)*1024, uint8(vf.KDF.Parallelism), keyLen)
|
||||||
|
defer func() {
|
||||||
|
for i := range key {
|
||||||
|
key[i] = 0
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if len(vf.Records) == 0 {
|
||||||
|
// Empty vault, try a test encryption
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = decryptRecord(key, vf.Records[0])
|
||||||
|
return err == nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constant-time comparison to prevent timing attacks
|
||||||
|
func SecureCompare(a, b string) bool {
|
||||||
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure time import is used
|
||||||
|
var _ time.Duration
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/mirivlad/sshkeeper/cmd"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cmd.Execute()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue