feat: Implement SSH client with authentication support (Task 5)

- Add SSH client with Connect/Execute/Close/IsConnected
- Implement password, key, and both authentication methods
- Support default key discovery (~/.ssh/id_ed25519, id_rsa, etc.)
- Support passphrase-protected keys via ParsePrivateKeyWithPassphrase
- Path expansion for ~ in KeyID
- Integrate with HandleSSHError for user-friendly errors
- All 4 SSH tests passing + 5 error tests still passing (9 total)

Progress: Tasks 1-5 complete (~40%)
This commit is contained in:
swanadiva
2026-06-22 16:16:16 +07:00
parent 6fd25e4d02
commit 8ea4b6e990
4 changed files with 367 additions and 12 deletions
+129
View File
@@ -0,0 +1,129 @@
package ssh
import (
"fmt"
"os"
"path/filepath"
"strings"
cryptossh "golang.org/x/crypto/ssh"
)
// getAuthMethods returns SSH authentication methods based on host config
func (c *Client) getAuthMethods() ([]cryptossh.AuthMethod, error) {
var authMethods []cryptossh.AuthMethod
switch c.host.Auth.Type {
case "password":
if c.host.Auth.Password == "" {
return nil, fmt.Errorf("password auth requires password")
}
authMethods = append(authMethods, cryptossh.Password(c.host.Auth.Password))
case "key":
signer, err := c.getKeySigner()
if err != nil {
return nil, fmt.Errorf("failed to setup key authentication: %w", err)
}
authMethods = append(authMethods, cryptossh.PublicKeys(signer))
case "both":
// Try password first
if c.host.Auth.Password != "" {
authMethods = append(authMethods, cryptossh.Password(c.host.Auth.Password))
}
// Then try key
signer, err := c.getKeySigner()
if err == nil {
authMethods = append(authMethods, cryptossh.PublicKeys(signer))
}
default:
return nil, fmt.Errorf("unsupported authentication type: %s", c.host.Auth.Type)
}
if len(authMethods) == 0 {
return nil, fmt.Errorf("no authentication methods configured")
}
return authMethods, nil
}
// getKeySigner returns an SSH signer for key-based authentication
//
// Phase 1: Loads key from KeyID as a file path, or from common SSH key locations
// Phase 2: Will integrate with the storage layer for encrypted key storage
func (c *Client) getKeySigner() (cryptossh.Signer, error) {
var keyPath string
if c.host.Auth.KeyID != "" {
// If KeyID looks like a path, use it directly
if strings.HasPrefix(c.host.Auth.KeyID, "/") || strings.HasPrefix(c.host.Auth.KeyID, "~") {
keyPath = expandPath(c.host.Auth.KeyID)
} else {
// Try ~/.ssh/<keyID>
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
keyPath = filepath.Join(home, ".ssh", c.host.Auth.KeyID)
}
} else {
// Fall back to default key locations
keyPath = getDefaultKeyPath()
}
// Read the key file
keyData, err := os.ReadFile(keyPath) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("failed to read key file %s: %w", keyPath, err)
}
// Parse the key (support passphrase-protected keys in Phase 2)
var signer cryptossh.Signer
if c.host.Auth.Password != "" {
signer, err = cryptossh.ParsePrivateKeyWithPassphrase(keyData, []byte(c.host.Auth.Password))
} else {
signer, err = cryptossh.ParsePrivateKey(keyData)
}
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
return signer, nil
}
// getDefaultKeyPath returns the first existing default SSH key path
func getDefaultKeyPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
candidates := []string{
"id_ed25519",
"id_rsa",
"id_ecdsa",
"id_dsa",
}
for _, name := range candidates {
path := filepath.Join(home, ".ssh", name)
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
// expandPath expands ~ to the home directory
func expandPath(path string) string {
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err == nil {
return filepath.Join(home, path[2:])
}
}
return path
}
+120
View File
@@ -0,0 +1,120 @@
package ssh
import (
"context"
"fmt"
"net"
"time"
"git.tukangketik.id/swanadiva/hostkeeper/internal/errors"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
cryptossh "golang.org/x/crypto/ssh"
)
// Client represents an SSH client
type Client struct {
host *models.Host
timeout time.Duration
client *cryptossh.Client
config *cryptossh.ClientConfig
}
// NewClient creates a new SSH client
func NewClient(host *models.Host, timeout time.Duration) *Client {
return &Client{
host: host,
timeout: timeout,
}
}
// Connect establishes an SSH connection
func (c *Client) Connect(ctx context.Context) error {
// Create SSH configuration
if err := c.setupConfig(); err != nil {
return fmt.Errorf("failed to setup SSH config: %w", err)
}
// Create connection context with timeout
connCtx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
// Establish TCP connection
address := fmt.Sprintf("%s:%d", c.host.Hostname, c.host.Port)
conn, err := c.dialTCP(connCtx, address)
if err != nil {
return errors.HandleSSHError(err)
}
// Establish SSH connection over TCP
sshConn, chans, reqs, err := cryptossh.NewClientConn(conn, address, c.config)
if err != nil {
conn.Close()
return errors.HandleSSHError(err)
}
c.client = cryptossh.NewClient(sshConn, chans, reqs)
return nil
}
// dialTCP establishes a TCP connection
func (c *Client) dialTCP(ctx context.Context, address string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, "tcp", address)
}
// setupConfig creates SSH client configuration
func (c *Client) setupConfig() error {
config := &cryptossh.ClientConfig{
User: c.host.Username,
HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), //nolint:gosec // Phase 1 - will be improved in Phase 2
Timeout: c.timeout,
}
// Configure authentication methods
authMethods, err := c.getAuthMethods()
if err != nil {
return fmt.Errorf("failed to setup authentication: %w", err)
}
config.Auth = authMethods
c.config = config
return nil
}
// Execute runs a command on the remote server
func (c *Client) Execute(_ context.Context, cmd string) (string, error) {
if c.client == nil {
return "", fmt.Errorf("not connected to server")
}
session, err := c.client.NewSession()
if err != nil {
return "", fmt.Errorf("failed to create session: %w", err)
}
defer session.Close()
output, err := session.CombinedOutput(cmd)
if err != nil {
return string(output), fmt.Errorf("command execution failed: %w", err)
}
return string(output), nil
}
// Close closes the SSH connection
func (c *Client) Close() error {
if c.client != nil {
return c.client.Close()
}
return nil
}
// GetClient returns the underlying SSH client
func (c *Client) GetClient() *cryptossh.Client {
return c.client
}
// IsConnected returns true if the client has an active connection
func (c *Client) IsConnected() bool {
return c.client != nil
}