feat: Go SSH interactive shell default, --native flag for system SSH
This commit is contained in:
+13
-11
@@ -19,14 +19,14 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
connectTimeout int
|
connectTimeout int
|
||||||
connectDirect bool
|
connectNative bool
|
||||||
)
|
)
|
||||||
|
|
||||||
// connectCmd represents the connect command
|
// connectCmd represents the connect command
|
||||||
var connectCmd = &cobra.Command{
|
var connectCmd = &cobra.Command{
|
||||||
Use: "connect <host-name-or-id>",
|
Use: "connect <host-name-or-id>",
|
||||||
Short: "Connect to a saved SSH host",
|
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:
|
Examples:
|
||||||
# Connect to a host by name
|
# Connect to a host by name
|
||||||
@@ -35,15 +35,15 @@ Examples:
|
|||||||
# Connect with a specific timeout
|
# Connect with a specific timeout
|
||||||
hostkeeper connect myserver --timeout 60
|
hostkeeper connect myserver --timeout 60
|
||||||
|
|
||||||
# Use Go SSH client (direct mode) instead of system SSH
|
# Use native system SSH instead of Go SSH client
|
||||||
hostkeeper connect myserver --direct`,
|
hostkeeper connect myserver --native`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runConnect,
|
RunE: runConnect,
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
connectCmd.Flags().IntVar(&connectTimeout, "timeout", 30, "Connection timeout in seconds")
|
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)
|
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)
|
fmt.Printf("Connecting to %s (%s@%s:%d)...\n", host.Name, host.Username, host.Hostname, host.Port)
|
||||||
|
|
||||||
// Choose connection method
|
// Choose connection method
|
||||||
if connectDirect {
|
if connectNative {
|
||||||
return connectDirectSSH(host)
|
return connectWithNativeSSH(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
return connectWithNativeSSH(host)
|
return connectDirectSSH(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
// findHost finds a host by ID first, then by name
|
// findHost finds a host by ID first, then by name
|
||||||
@@ -156,9 +156,11 @@ func connectDirectSSH(host *models.Host) error {
|
|||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
fmt.Printf("Connected to %s\n", host.Name)
|
fmt.Printf("Connected to %s (%s@%s:%d)\n", host.Name, host.Username, host.Hostname, host.Port)
|
||||||
fmt.Println("Interactive shell not yet implemented in direct mode")
|
|
||||||
fmt.Println("Use --direct=false (default) for native SSH experience")
|
if err := client.Shell(); err != nil {
|
||||||
|
return fmt.Errorf("shell session failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func TestConnectCommandExists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConnectCommandFlags(t *testing.T) {
|
func TestConnectCommandFlags(t *testing.T) {
|
||||||
expectedFlags := []string{"timeout", "direct"}
|
expectedFlags := []string{"timeout", "native"}
|
||||||
for _, flagName := range expectedFlags {
|
for _, flagName := range expectedFlags {
|
||||||
if connectCmd.Flags().Lookup(flagName) == nil {
|
if connectCmd.Flags().Lookup(flagName) == nil {
|
||||||
t.Errorf("flag '%s' should be defined", flagName)
|
t.Errorf("flag '%s' should be defined", flagName)
|
||||||
|
|||||||
@@ -40,5 +40,6 @@ require (
|
|||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto v0.53.0 // indirect
|
golang.org/x/crypto v0.53.0 // indirect
|
||||||
golang.org/x/sys v0.46.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
|
golang.org/x/text v0.38.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
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/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 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -4,8 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
|
||||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/errors"
|
"git.tukangketik.id/swanadiva/hostkeeper/internal/errors"
|
||||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||||
cryptossh "golang.org/x/crypto/ssh"
|
cryptossh "golang.org/x/crypto/ssh"
|
||||||
@@ -101,6 +106,83 @@ func (c *Client) Execute(_ context.Context, cmd string) (string, error) {
|
|||||||
return string(output), nil
|
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
|
// Close closes the SSH connection
|
||||||
func (c *Client) Close() error {
|
func (c *Client) Close() error {
|
||||||
if c.client != nil {
|
if c.client != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user