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:
2026-05-26 09:11:55 +08:00
commit 883a1e66a7
34 changed files with 5244 additions and 0 deletions
+136
View File
@@ -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)")
}
+27
View File
@@ -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)
}
+97
View File
@@ -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
},
}
+39
View File
@@ -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")
}
+80
View File
@@ -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")
}
+107
View File
@@ -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
}
+137
View File
@@ -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)
}
+53
View File
@@ -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
},
}
+48
View File
@@ -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
},
}
+179
View File
@@ -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
}
+38
View File
@@ -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
},
}
+51
View File
@@ -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
},
}
+50
View File
@@ -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)
}
+117
View File
@@ -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
}
}
+168
View File
@@ -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)
}