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
+138
View File
@@ -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
}
+100
View File
@@ -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
}
+105
View File
@@ -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()
}
+100
View File
@@ -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
}