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:
+136
@@ -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")
|
||||
}
|
||||
+80
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
},
|
||||
}
|
||||
+51
@@ -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)
|
||||
}
|
||||
+117
@@ -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
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user