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:
+12
-12
@@ -3,7 +3,7 @@
|
||||
> **Purpose**: Enable seamless continuation of development by any agent/LLM across sessions
|
||||
>
|
||||
> **Last Updated**: 2024-06-22 (Session 2)
|
||||
> **Current Status**: Implementation In Progress - Tasks 1-4 Complete
|
||||
> **Current Status**: Implementation In Progress - Tasks 1-5 Complete
|
||||
> **Phase**: MVP Development (Phase 1)
|
||||
|
||||
---
|
||||
@@ -26,9 +26,9 @@
|
||||
✅ **Task 2**: Core data models (`internal/models/models.go`) + JSON storage (`pkg/storage/`)
|
||||
✅ **Task 3**: Configuration management (`pkg/config/config.go`)
|
||||
✅ **Task 4**: Error handling framework (`internal/errors/`) + tests passing
|
||||
✅ **Task 5**: SSH client (`pkg/ssh/`) + tests passing
|
||||
|
||||
### What Needs to Happen Next
|
||||
🔄 **Task 5**: SSH Client Implementation (`pkg/ssh/`)
|
||||
🔄 **Task 6**: CLI Framework Setup (Cobra commands in `cmd/hostkeeper/`)
|
||||
🔄 Build and test core features
|
||||
🔄 Prepare MVP release
|
||||
@@ -46,13 +46,13 @@
|
||||
| **Core Models** | ✅ 100% | Host, KeyPair, Snippet, AppConfig models + JSON storage |
|
||||
| **Config** | ✅ 100% | Cross-platform config management |
|
||||
| **Errors** | ✅ 100% | AppError + ConnectionError + SSH error handler |
|
||||
| **SSH Client** | 🔲 0% | Connection and authentication |
|
||||
| **SSH Client** | ✅ 100% | Password + key auth, Execute, Connect/Close |
|
||||
| **CLI Commands** | 🔲 0% | User interface commands |
|
||||
| **TUI** | 🔲 0% | Terminal user interface |
|
||||
| **Testing** | 🔲 0% | Test suite and integration |
|
||||
| **Documentation** | 🔲 0% | Usage guides and API docs |
|
||||
|
||||
### Overall Progress: **~30% Complete** (Tasks 1-4 done)
|
||||
### Overall Progress: **~40% Complete** (Tasks 1-5 done)
|
||||
|
||||
---
|
||||
|
||||
@@ -94,14 +94,14 @@
|
||||
- `internal/errors/connection_errors.go` — ConnectionError, HandleSSHError, FormatConnectionError
|
||||
- `test/errors_test.go` — 5 test functions, all passing
|
||||
|
||||
#### 🔲 Task 5: SSH Client Implementation
|
||||
- **Status**: Not Started
|
||||
#### ✅ Task 5: SSH Client Implementation
|
||||
- **Status**: ✅ Completed (all tests passing)
|
||||
- **Priority**: CRITICAL
|
||||
- **Estimated Time**: 3-4 hours
|
||||
- **Dependencies**: Task 4 complete
|
||||
- **Deliverables**: SSH connection client
|
||||
- **Files to Create**:
|
||||
- `pkg/ssh/*.go`
|
||||
- **Deliverables**: SSH connection client with auth
|
||||
- **Files Created**:
|
||||
- `pkg/ssh/client.go` — Client struct, Connect, Execute, Close, IsConnected
|
||||
- `pkg/ssh/auth.go` — Password/key/both auth, default key discovery
|
||||
- `test/ssh/ssh_test.go` — 4 test functions, all passing
|
||||
|
||||
#### 🔲 Task 6: CLI Framework Setup
|
||||
- **Status**: Not Started
|
||||
@@ -128,7 +128,7 @@
|
||||
- [x] Core data models and storage
|
||||
- [x] Configuration management
|
||||
- [x] Error handling framework
|
||||
- [ ] SSH client implementation
|
||||
- [x] SSH client implementation
|
||||
- [ ] CLI framework setup
|
||||
|
||||
### Next Sprint
|
||||
|
||||
+129
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package ssh_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/pkg/ssh"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
host := &models.Host{
|
||||
ID: "test-host",
|
||||
Name: "Test Server",
|
||||
Hostname: "localhost",
|
||||
Port: 22,
|
||||
Username: "testuser",
|
||||
Auth: models.AuthConfig{
|
||||
Type: "password",
|
||||
Password: "testpass",
|
||||
},
|
||||
}
|
||||
|
||||
client := ssh.NewClient(host, 30*time.Second)
|
||||
if client == nil {
|
||||
t.Fatal("Failed to create SSH client")
|
||||
}
|
||||
|
||||
if client.IsConnected() {
|
||||
t.Error("Expected client to not be connected initially")
|
||||
}
|
||||
|
||||
// Close should be safe even when not connected
|
||||
if err := client.Close(); err != nil {
|
||||
t.Errorf("Expected nil error on close when not connected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectFailure(t *testing.T) {
|
||||
// Test connecting to a non-existent server
|
||||
host := &models.Host{
|
||||
ID: "test-host",
|
||||
Name: "Non-existent Server",
|
||||
Hostname: "127.0.0.1",
|
||||
Port: 9999, // Port that's likely not running SSH
|
||||
Username: "testuser",
|
||||
Auth: models.AuthConfig{
|
||||
Type: "password",
|
||||
Password: "testpass",
|
||||
},
|
||||
}
|
||||
|
||||
client := ssh.NewClient(host, 2*time.Second)
|
||||
ctx := context.Background()
|
||||
err := client.Connect(ctx)
|
||||
|
||||
// We expect connection to fail
|
||||
if err == nil {
|
||||
t.Log("Connection succeeded (unexpected - SSH server may be running on port 9999)")
|
||||
_ = client.Close()
|
||||
} else {
|
||||
t.Logf("Connection failed as expected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteWithoutConnection(t *testing.T) {
|
||||
host := &models.Host{
|
||||
ID: "test-host",
|
||||
Name: "Test Server",
|
||||
Hostname: "localhost",
|
||||
Port: 22,
|
||||
Username: "testuser",
|
||||
Auth: models.AuthConfig{
|
||||
Type: "password",
|
||||
Password: "testpass",
|
||||
},
|
||||
}
|
||||
|
||||
client := ssh.NewClient(host, 30*time.Second)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.Execute(ctx, "echo hello")
|
||||
if err == nil {
|
||||
t.Error("Expected error when executing command without connection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClient(t *testing.T) {
|
||||
host := &models.Host{
|
||||
ID: "test-host",
|
||||
Name: "Test Server",
|
||||
Hostname: "localhost",
|
||||
Port: 22,
|
||||
Username: "testuser",
|
||||
Auth: models.AuthConfig{
|
||||
Type: "password",
|
||||
Password: "testpass",
|
||||
},
|
||||
}
|
||||
|
||||
client := ssh.NewClient(host, 30*time.Second)
|
||||
if client.GetClient() != nil {
|
||||
t.Error("Expected nil underlying client before connecting")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user