diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index b320426..5915ab1 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -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 diff --git a/pkg/ssh/auth.go b/pkg/ssh/auth.go new file mode 100644 index 0000000..10873df --- /dev/null +++ b/pkg/ssh/auth.go @@ -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/ + 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 +} \ No newline at end of file diff --git a/pkg/ssh/client.go b/pkg/ssh/client.go new file mode 100644 index 0000000..7af9579 --- /dev/null +++ b/pkg/ssh/client.go @@ -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 +} \ No newline at end of file diff --git a/test/ssh/ssh_test.go b/test/ssh/ssh_test.go new file mode 100644 index 0000000..4f8aa6c --- /dev/null +++ b/test/ssh/ssh_test.go @@ -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") + } +} \ No newline at end of file