From b3878de9af0fe5132fb70a0caeac130d64543433 Mon Sep 17 00:00:00 2001 From: swanadiva Date: Tue, 23 Jun 2026 14:26:58 +0700 Subject: [PATCH] feat: Go SSH interactive shell default, --native flag for system SSH --- cmd/hostkeeper/connect.go | 24 +++++----- cmd/hostkeeper/connect_test.go | 2 +- go.mod | 1 + go.sum | 2 + pkg/ssh/client.go | 82 ++++++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 12 deletions(-) diff --git a/cmd/hostkeeper/connect.go b/cmd/hostkeeper/connect.go index 03e92da..2210f91 100644 --- a/cmd/hostkeeper/connect.go +++ b/cmd/hostkeeper/connect.go @@ -19,14 +19,14 @@ import ( var ( connectTimeout int - connectDirect bool + connectNative bool ) // connectCmd represents the connect command var connectCmd = &cobra.Command{ Use: "connect ", Short: "Connect to a saved SSH host", - Long: `Connect to a saved SSH host using native SSH client with stored credentials. + Long: `Connect to a saved SSH host using stored credentials. Examples: # Connect to a host by name @@ -35,15 +35,15 @@ Examples: # Connect with a specific timeout hostkeeper connect myserver --timeout 60 - # Use Go SSH client (direct mode) instead of system SSH - hostkeeper connect myserver --direct`, + # Use native system SSH instead of Go SSH client + hostkeeper connect myserver --native`, Args: cobra.ExactArgs(1), RunE: runConnect, } func init() { connectCmd.Flags().IntVar(&connectTimeout, "timeout", 30, "Connection timeout in seconds") - connectCmd.Flags().BoolVar(&connectDirect, "direct", false, "Use direct SSH instead of native client") + connectCmd.Flags().BoolVar(&connectNative, "native", false, "Use native system SSH instead of Go SSH client") rootCmd.AddCommand(connectCmd) } @@ -77,11 +77,11 @@ func runConnect(cmd *cobra.Command, args []string) error { fmt.Printf("Connecting to %s (%s@%s:%d)...\n", host.Name, host.Username, host.Hostname, host.Port) // Choose connection method - if connectDirect { - return connectDirectSSH(host) + if connectNative { + return connectWithNativeSSH(host) } - return connectWithNativeSSH(host) + return connectDirectSSH(host) } // findHost finds a host by ID first, then by name @@ -156,9 +156,11 @@ func connectDirectSSH(host *models.Host) error { } defer client.Close() - fmt.Printf("Connected to %s\n", host.Name) - fmt.Println("Interactive shell not yet implemented in direct mode") - fmt.Println("Use --direct=false (default) for native SSH experience") + fmt.Printf("Connected to %s (%s@%s:%d)\n", host.Name, host.Username, host.Hostname, host.Port) + + if err := client.Shell(); err != nil { + return fmt.Errorf("shell session failed: %w", err) + } return nil } diff --git a/cmd/hostkeeper/connect_test.go b/cmd/hostkeeper/connect_test.go index 5acc7ba..fb992e5 100644 --- a/cmd/hostkeeper/connect_test.go +++ b/cmd/hostkeeper/connect_test.go @@ -21,7 +21,7 @@ func TestConnectCommandExists(t *testing.T) { } func TestConnectCommandFlags(t *testing.T) { - expectedFlags := []string{"timeout", "direct"} + expectedFlags := []string{"timeout", "native"} for _, flagName := range expectedFlags { if connectCmd.Flags().Lookup(flagName) == nil { t.Errorf("flag '%s' should be defined", flagName) diff --git a/go.mod b/go.mod index 6dc3bc4..9d15e91 100644 --- a/go.mod +++ b/go.mod @@ -40,5 +40,6 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.53.0 // indirect golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect ) diff --git a/go.sum b/go.sum index a531b54..9c3bf03 100644 --- a/go.sum +++ b/go.sum @@ -77,6 +77,8 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/ssh/client.go b/pkg/ssh/client.go index 7af9579..4b0a75f 100644 --- a/pkg/ssh/client.go +++ b/pkg/ssh/client.go @@ -4,8 +4,13 @@ import ( "context" "fmt" "net" + "os" + "os/signal" + "syscall" "time" + "golang.org/x/term" + "git.tukangketik.id/swanadiva/hostkeeper/internal/errors" "git.tukangketik.id/swanadiva/hostkeeper/internal/models" cryptossh "golang.org/x/crypto/ssh" @@ -101,6 +106,83 @@ func (c *Client) Execute(_ context.Context, cmd string) (string, error) { return string(output), nil } +// Shell opens an interactive shell session +func (c *Client) Shell() 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() + + // Get current terminal state + fd := int(os.Stdin.Fd()) + oldState, err := term.MakeRaw(fd) + if err != nil { + return fmt.Errorf("failed to set raw terminal: %w", err) + } + defer term.Restore(fd, oldState) + + // Set up terminal modes + modes := cryptossh.TerminalModes{ + cryptossh.ECHO: 1, + cryptossh.TTY_OP_ISPEED: 14400, + cryptossh.TTY_OP_OSPEED: 14400, + } + + // Get terminal size + width, height, err := term.GetSize(fd) + if err != nil { + width = 80 + height = 24 + } + + // Request PTY + if err := session.RequestPty("xterm-256color", height, width, modes); err != nil { + return fmt.Errorf("failed to request PTY: %w", err) + } + + // Handle window changes + sigwinch := make(chan os.Signal, 1) + signal.Notify(sigwinch, os.Signal(syscall.SIGWINCH)) + go func() { + for range sigwinch { + w, h, err := term.GetSize(fd) + if err != nil { + continue + } + session.WindowChange(h, w) + } + }() + defer signal.Stop(sigwinch) + + // Link I/O + session.Stdin = os.Stdin + session.Stdout = os.Stdout + session.Stderr = os.Stderr + + // Start shell + if err := session.Shell(); err != nil { + return fmt.Errorf("failed to start shell: %w", err) + } + + // Wait for shell to exit + if err := session.Wait(); err != nil { + if exitErr, ok := err.(*cryptossh.ExitError); ok { + if exitErr.ExitStatus() != 0 { + return fmt.Errorf("shell exited with status %d", exitErr.ExitStatus()) + } + return nil + } + return fmt.Errorf("shell session error: %w", err) + } + + return nil +} + // Close closes the SSH connection func (c *Client) Close() error { if c.client != nil {