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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user