diff --git a/e2e/pam/ssh_test.go b/e2e/pam/ssh_test.go index 3c17f7f6..2fe95c22 100644 --- a/e2e/pam/ssh_test.go +++ b/e2e/pam/ssh_test.go @@ -73,6 +73,7 @@ func runSSHSessionAndVerify(t *testing.T, ctx context.Context, infra *PAMTestInf Args: []string{ "pam", "access", fmt.Sprintf("%s/%s", folderName, accountName), "--duration", "5m", + "--proxy", "--port", fmt.Sprintf("%d", freePort), }, Env: map[string]string{ @@ -137,6 +138,44 @@ func runSSHSessionAndVerify(t *testing.T, ctx context.Context, infra *PAMTestInf require.Contains(t, output, expectedOutput, "command output should contain %q", expectedOutput) } +// runSSHCommandAndVerify runs `pam access -- `, which connects straight through. +func runSSHCommandAndVerify(t *testing.T, ctx context.Context, infra *PAMTestInfra, folderName, accountName string, command []string, expectedOutput string) { + args := []string{ + "pam", "access", fmt.Sprintf("%s/%s", folderName, accountName), + "--duration", "5m", "--", + } + pamCmd := helpers.Command{ + Test: t, + RunMethod: helpers.RunMethodSubprocess, + DisableTempHomeDir: true, + Args: append(args, command...), + Env: map[string]string{ + "HOME": infra.SharedHomeDir, + "INFISICAL_API_URL": infra.Infisical.ApiUrl(t), + }, + } + pamCmd.Start(ctx) + t.Cleanup(pamCmd.Stop) + + // Exits on completion, so EnsureCmdRunning would read the exit as a failure. + result := helpers.WaitFor(t, helpers.WaitForOptions{ + Timeout: 60 * time.Second, + Interval: time.Second, + Condition: func() helpers.ConditionResult { + if pamCmd.IsRunning() { + return helpers.ConditionWait + } + if pamCmd.ExitCode() != 0 { + pamCmd.DumpOutput() + return helpers.ConditionBreakEarly + } + return helpers.ConditionSuccess + }, + }) + require.Equal(t, helpers.WaitSuccess, result, "running a command over SSH should succeed") + require.Contains(t, pamCmd.Stdout(), expectedOutput, "remote command output should contain %q", expectedOutput) +} + // configureCertAuth mirrors the real setup flow: run the dashboard's `curl | bash` on // the SSH server. Fetching the script also provisions the account's SSH CA. pipefail is required, // or a failed curl leaves bash exiting 0 and the missing CA surfaces later as "ssh: no key found". @@ -230,6 +269,11 @@ func runSSHAuthTest(t *testing.T, ctx context.Context, infra *PAMTestInfra, fold marker := fmt.Sprintf("hello-%s", method) runSSHSessionAndVerify(t, ctx, infra, folderName, accountName, "echo "+marker, marker) + + // Cover the direct path on one auth method rather than tripling the container count. + if method == "password" { + runSSHCommandAndVerify(t, ctx, infra, folderName, accountName, []string{"echo", "direct-" + marker}, "direct-"+marker) + } } func TestPAM_SSH(t *testing.T) { diff --git a/packages/cmd/pam.go b/packages/cmd/pam.go index 6799b72d..2a5bf0b0 100644 --- a/packages/cmd/pam.go +++ b/packages/cmd/pam.go @@ -25,17 +25,32 @@ var pamCmd = &cobra.Command{ } var pamAccessCmd = &cobra.Command{ - Use: "access ", + Use: "access [-- ]", Short: "Launch a PAM session for the account at the given path", Long: `Launch a PAM session for the account at the given path. -The path format is: /folder/account-name (leading slash optional)`, - Example: "infisical pam access /production/postgres-main --duration 2h", +The path format is: folder/account-name + +SSH accounts connect you straight to a shell on the target. Pass --proxy for a local +proxy to point your own SSH, SCP or SFTP client at instead, or pass a command after +'--' to run just that command and exit. Every other account type starts a local proxy +or credential helper, which --proxy does not change.`, + Example: ` infisical pam access production/postgres-main --duration 2h + infisical pam access servers/prod-bastion + infisical pam access servers/prod-bastion -- systemctl status nginx + infisical pam access servers/prod-bastion --proxy`, DisableFlagsInUseLine: true, - Args: cobra.ExactArgs(1), + Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - util.RequireLogin() - path := args[0] + var command []string + if dash := cmd.ArgsLenAtDash(); dash >= 0 { + if dash != 1 { + util.PrintErrorMessageAndExit("Only one account path may be given. Put the remote command after '--', for example:\n infisical pam access servers/prod-bastion -- uptime") + } + command = args[1:] + } else if len(args) > 1 { + util.PrintErrorMessageAndExit(fmt.Sprintf("Unexpected argument %q. To run a command on the target, put it after '--', for example:\n infisical pam access %s -- uptime", args[1], path)) + } reason, err := cmd.Flags().GetString("reason") if err != nil { @@ -62,6 +77,17 @@ The path format is: /folder/account-name (leading slash optional)`, util.HandleError(err, "Unable to parse target flag") } + proxy, err := cmd.Flags().GetBool("proxy") + if err != nil { + util.HandleError(err, "Unable to parse proxy flag") + } + + if proxy && len(command) > 0 { + util.PrintErrorMessageAndExit("--proxy starts a local proxy for your own client, so it cannot also run a command on the target. Drop one of the two.") + } + + util.RequireLogin() + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { util.HandleError(err, "Unable to get logged in user details") @@ -72,7 +98,15 @@ The path format is: /folder/account-name (leading slash optional)`, loggedInUserDetails = util.EstablishUserLoginSession() } - pam.StartPAMAccess(loggedInUserDetails.UserCredentials.JTWToken, path, reason, durationStr, targetHost, port) + pam.StartPAMAccess(loggedInUserDetails.UserCredentials.JTWToken, pam.AccessOptions{ + Path: path, + Reason: reason, + Duration: durationStr, + TargetHost: targetHost, + Port: port, + Proxy: proxy, + Command: command, + }) }, } @@ -350,6 +384,7 @@ func init() { pamAccessCmd.Flags().String("duration", "1h", "Duration for access session (e.g., '1h', '30m', '2h30m')") pamAccessCmd.Flags().Int("port", 0, "Port for the local proxy server (0 for auto-assign)") pamAccessCmd.Flags().String("target", "", "Target host to connect to (for accounts that allow multiple hosts, e.g. Windows AD)") + pamAccessCmd.Flags().Bool("proxy", false, "Start a local proxy to point your own client at, instead of connecting you to the target. Only SSH accounts connect directly today") pamAgenticAccessCmd.Flags().StringArray("account", nil, "Account to expose, as folder/account. Repeatable. Defaults to every account you can launch") pamAgenticAccessCmd.Flags().String("duration", "1h", "How long each PAM session may last (e.g. '1h', '30m', '2h30m')") diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go index d0debefa..46784186 100644 --- a/packages/pam/local/access.go +++ b/packages/pam/local/access.go @@ -59,9 +59,22 @@ func parsePath(path string) (folder, account string) { return "", cleanPath } +// AccessOptions is one invocation of `infisical pam access`. +type AccessOptions struct { + Path string + Reason string + Duration string + TargetHost string + Port int + Proxy bool + Command []string +} + // StartPAMAccess initiates a PAM session for the account at the given path. // The account type is determined from the API response and routed to the appropriate handler. -func StartPAMAccess(accessToken, path, reason, durationStr, targetHost string, port int) { +func StartPAMAccess(accessToken string, opts AccessOptions) { + path, reason, durationStr, targetHost, port := opts.Path, opts.Reason, opts.Duration, opts.TargetHost, opts.Port + // Normalize path for display (ensure leading slash) displayPath := normalizePath(path) @@ -98,6 +111,14 @@ func StartPAMAccess(accessToken, path, reason, durationStr, targetHost string, p log.Info().Msgf("Session created with ID: %s", pamResponse.SessionId) log.Info().Msgf("Account type: %s", pamResponse.AccountType) + if len(opts.Command) > 0 && pamResponse.AccountType != AccountTypeSSH { + endSession(httpClient, pamResponse.SessionId) + util.PrintErrorMessageAndExit(fmt.Sprintf( + "Commands can only be run against SSH accounts, and %s is a %s account. Drop the '--' and this command starts a local proxy for it instead.", + strings.TrimPrefix(displayPath, "/"), pamResponse.AccountType)) + return + } + // Route based on account type from API response switch pamResponse.AccountType { // Database types - all use the same proxy mechanism with different display configs @@ -105,7 +126,7 @@ func StartPAMAccess(accessToken, path, reason, durationStr, targetHost string, p startDatabaseProxy(httpClient, &pamResponse, displayPath, durationStr, port) case AccountTypeSSH: - startSSHAccess(httpClient, &pamResponse, displayPath, durationStr, port) + startSSHAccess(httpClient, &pamResponse, displayPath, opts) case AccountTypeRedis: startRedisProxy(httpClient, &pamResponse, displayPath, durationStr, port) case AccountTypeKubernetes: @@ -119,6 +140,7 @@ func StartPAMAccess(accessToken, path, reason, durationStr, targetHost string, p case AccountTypeWindows, AccountTypeWindowsAd: startRDPProxy(httpClient, &pamResponse, displayPath, durationStr, port) default: + endSession(httpClient, pamResponse.SessionId) util.PrintErrorMessageAndExit(fmt.Sprintf("Unsupported account type: %s", pamResponse.AccountType)) } } @@ -141,6 +163,13 @@ func CreateSession(httpClient *resty.Client, path, reason, targetHost string, du return &response, nil } +// endSession releases a created session on paths that exit before a proxy or shell owns it +func endSession(httpClient *resty.Client, sessionId string) { + if err := api.CallPAMSessionTermination(httpClient, sessionId); err != nil { + log.Debug().Err(err).Msg("Failed to end session while exiting early") + } +} + // NewLiveSession converts an access response into the session details a proxy dials through. func NewLiveSession(response *api.PAMAccessResponse, expiry time.Time) LiveSession { return LiveSession{ @@ -689,19 +718,90 @@ func startRDPProxy(httpClient *resty.Client, response *api.PAMAccessResponse, pa proxy.Run() } -func startSSHAccess(httpClient *resty.Client, response *api.PAMAccessResponse, path, durationStr string, port int) { - duration, err := time.ParseDuration(durationStr) +// startSSHAccess connects straight through as a shell, or by way of a local proxy. +func startSSHAccess(httpClient *resty.Client, response *api.PAMAccessResponse, path string, opts AccessOptions) { + duration, err := time.ParseDuration(opts.Duration) if err != nil { + endSession(httpClient, response.SessionId) util.HandleError(err, "Failed to parse duration") return } username, ok := response.Metadata["username"] if !ok { + endSession(httpClient, response.SessionId) util.HandleError(fmt.Errorf("PAM response metadata is missing 'username'"), "Failed to start SSH session") return } + if opts.Proxy { + startSSHProxy(httpClient, response, path, duration, username, opts.Port) + return + } + + if len(opts.Command) == 0 && !hasInteractiveTerminal() { + util.PrintfStderr("No terminal attached, so starting a local SSH proxy instead. Pass --proxy to ask for one directly.\n") + startSSHProxy(httpClient, response, path, duration, username, opts.Port) + return + } + + startSSHShell(httpClient, response, path, duration, username, opts.Command) +} + +func hasInteractiveTerminal() bool { + return isatty.IsTerminal(os.Stdin.Fd()) && isatty.IsTerminal(os.Stdout.Fd()) +} + +func startSSHShell(httpClient *resty.Client, response *api.PAMAccessResponse, path string, duration time.Duration, username string, command []string) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + transport := &BaseProxyServer{ + httpClient: httpClient, + relayHost: response.RelayHost, + relayClientCert: response.RelayClientCertificate, + relayClientKey: response.RelayClientPrivateKey, + relayServerCertChain: response.RelayServerCertificateChain, + gatewayClientCert: response.GatewayClientCertificate, + gatewayClientKey: response.GatewayClientPrivateKey, + gatewayServerCertChain: response.GatewayServerCertificateChain, + sessionExpiry: time.Now().Add(duration), + sessionId: response.SessionId, + resourceType: response.AccountType, + ctx: ctx, + cancel: cancel, + shutdownCh: make(chan struct{}), + } + + // Armed before the gateway round-trip below and the dial that follows, so an interrupt during + // either still ends the session that StartPAMAccess already created. + watch, stopWatching := watchForSessionEnd(transport) + defer stopWatching() + + if err := transport.ValidateResourceTypeSupported(); err != nil { + transport.NotifySessionTermination() + util.HandleError(err, "Gateway version outdated") + return + } + + if len(command) == 0 { + util.PrintfStderr("Connecting to %s as %s (session ends in %s)...\n", strings.TrimPrefix(path, "/"), username, duration.String()) + } + + exitCode, err := RunSSHShell(transport, watch, username, command) + + transport.NotifySessionTermination() + + if err != nil { + util.HandleError(err, "SSH session failed") + return + } + if exitCode != 0 { + os.Exit(exitCode) + } +} + +func startSSHProxy(httpClient *resty.Client, response *api.PAMAccessResponse, path string, duration time.Duration, username string, port int) { ctx, cancel := context.WithCancel(context.Background()) proxy := &SSHProxyServer{ @@ -724,12 +824,13 @@ func startSSHAccess(httpClient *resty.Client, response *api.PAMAccessResponse, p } if err := proxy.ValidateResourceTypeSupported(); err != nil { + proxy.NotifySessionTermination() util.HandleError(err, "Gateway version outdated") return } - err = proxy.Start(port) - if err != nil { + if err := proxy.Start(port); err != nil { + proxy.NotifySessionTermination() util.HandleError(err, "Failed to start SSH proxy server") return } @@ -785,6 +886,9 @@ func printSSHSessionInfo(folder, account string, duration time.Duration, usernam util.PrintfStderr(" $ %s\n", ex) } fmt.Printf("\n") + fmt.Printf(" Run this from a terminal without --proxy and it connects you straight\n") + fmt.Printf(" to a shell on the target instead.\n") + fmt.Printf("\n") fmt.Printf(" Press Ctrl+C to stop the proxy.\n") fmt.Printf("\n") fmt.Printf("**********************************************************************\n") diff --git a/packages/pam/local/ssh-shell-resize.go b/packages/pam/local/ssh-shell-resize.go new file mode 100644 index 00000000..7b632e69 --- /dev/null +++ b/packages/pam/local/ssh-shell-resize.go @@ -0,0 +1,34 @@ +//go:build !windows + +package pam + +import ( + "os" + "os/signal" + "syscall" + + "golang.org/x/crypto/ssh" +) + +func watchTerminalResize(session *ssh.Session) (stop func()) { + resized := make(chan os.Signal, 1) + signal.Notify(resized, syscall.SIGWINCH) + + done := make(chan struct{}) + go func() { + for { + select { + case <-resized: + width, height := terminalSize() + _ = session.WindowChange(height, width) + case <-done: + return + } + } + }() + + return func() { + signal.Stop(resized) + close(done) + } +} diff --git a/packages/pam/local/ssh-shell-resize_windows.go b/packages/pam/local/ssh-shell-resize_windows.go new file mode 100644 index 00000000..42ce80d9 --- /dev/null +++ b/packages/pam/local/ssh-shell-resize_windows.go @@ -0,0 +1,36 @@ +package pam + +import ( + "time" + + "golang.org/x/crypto/ssh" +) + +const terminalResizePollInterval = 250 * time.Millisecond + +// Windows has no SIGWINCH, so the size is polled +func watchTerminalResize(session *ssh.Session) (stop func()) { + done := make(chan struct{}) + + go func() { + ticker := time.NewTicker(terminalResizePollInterval) + defer ticker.Stop() + + width, height := terminalSize() + for { + select { + case <-ticker.C: + currentWidth, currentHeight := terminalSize() + if currentWidth == width && currentHeight == height { + continue + } + width, height = currentWidth, currentHeight + _ = session.WindowChange(height, width) + case <-done: + return + } + } + }() + + return func() { close(done) } +} diff --git a/packages/pam/local/ssh-shell.go b/packages/pam/local/ssh-shell.go new file mode 100644 index 00000000..a5ed8465 --- /dev/null +++ b/packages/pam/local/ssh-shell.go @@ -0,0 +1,264 @@ +package pam + +import ( + "errors" + "fmt" + "io" + "net" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/Infisical/infisical-merge/packages/util" + "github.com/rs/zerolog/log" + "golang.org/x/crypto/ssh" + "golang.org/x/term" +) + +const ( + sshTunnelAddr = "infisical-pam-gateway:22" + sshExitCodeUnavailable = 255 +) + +var sshClientVersion = "SSH-2.0-Infisical_" + sanitizeSSHVersion(util.CLI_VERSION) + +// sanitizeSSHVersion strips what RFC 4253 disallows in a software version: whitespace and minus +func sanitizeSSHVersion(version string) string { + if version == "" { + return "unknown" + } + return strings.Map(func(r rune) rune { + if r <= ' ' || r > '~' || r == '-' { + return '_' + } + return r + }, version) +} + +// RunSSHShell attaches the terminal to a shell on the target, or runs one command, and returns the remote exit code +func RunSSHShell(transport *BaseProxyServer, watch *sessionWatch, username string, command []string) (int, error) { + client, err := dialSSHOverTunnel(transport, username) + if err != nil { + return 0, err + } + defer client.Close() + watch.attach(client) + + session, err := client.NewSession() + if err != nil { + return 0, fmt.Errorf("failed to open SSH session: %w", err) + } + defer session.Close() + + if len(command) > 0 { + err = runSSHCommand(session, command, os.Stdin, os.Stdout, os.Stderr) + } else { + err = runInteractiveShell(session) + } + + sessionExpired := false + select { + case <-watch.expired: + sessionExpired = true + util.PrintfStderr("\nPAM session expired.\n") + default: + } + + return sshExitCode(err, sessionExpired) +} + +func dialSSHOverTunnel(transport *BaseProxyServer, username string) (*ssh.Client, error) { + relayConn, err := transport.CreateRelayConnection() + if err != nil { + return nil, fmt.Errorf("failed to connect to relay: %w", err) + } + + gatewayConn, err := transport.CreateGatewayConnection(relayConn, ALPNInfisicalPAMProxy) + if err != nil { + relayConn.Close() + return nil, fmt.Errorf("failed to connect to gateway: %w", err) + } + + client, err := newSSHClient(gatewayConn, username) + if err != nil { + gatewayConn.Close() + return nil, err + } + return client, nil +} + +func newSSHClient(conn net.Conn, username string) (*ssh.Client, error) { + sshConn, chans, reqs, err := ssh.NewClientConn(conn, sshTunnelAddr, &ssh.ClientConfig{ + User: username, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + ClientVersion: sshClientVersion, + }) + if err != nil { + return nil, fmt.Errorf("failed to establish SSH connection through the gateway: %w", err) + } + return ssh.NewClient(sshConn, chans, reqs), nil +} + +func runInteractiveShell(session *ssh.Session) error { + fd := int(os.Stdin.Fd()) + width, height := terminalSize() + + // ECHO is the remote pty's, which echoes once the local terminal is raw. + modes := ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.TTY_OP_ISPEED: 14400, + ssh.TTY_OP_OSPEED: 14400, + } + + if err := session.RequestPty(terminalType(), height, width, modes); err != nil { + return fmt.Errorf("failed to request a terminal on the target: %w", err) + } + + remoteStdin, err := session.StdinPipe() + if err != nil { + return fmt.Errorf("failed to open remote stdin: %w", err) + } + session.Stdout = os.Stdout + session.Stderr = os.Stderr + + restore, err := makeTerminalRaw(fd) + if err != nil { + return err + } + defer restore() + + if err := session.Shell(); err != nil { + return fmt.Errorf("failed to start a shell on the target: %w", err) + } + + stopResizing := watchTerminalResize(session) + defer stopResizing() + + go func() { + _, _ = io.Copy(remoteStdin, os.Stdin) + _ = remoteStdin.Close() + }() + + return session.Wait() +} + +// runSSHCommand joins arguments with spaces for the remote shell to re-parse, as `ssh host cmd` does +func runSSHCommand(session *ssh.Session, command []string, stdin io.Reader, stdout, stderr io.Writer) error { + session.Stdin = stdin + session.Stdout = stdout + session.Stderr = stderr + return session.Run(strings.Join(command, " ")) +} + +func makeTerminalRaw(fd int) (restore func(), err error) { + state, err := term.MakeRaw(fd) + if err != nil { + return nil, fmt.Errorf("failed to put the terminal in raw mode: %w", err) + } + return sync.OnceFunc(func() { + if restoreErr := term.Restore(fd, state); restoreErr != nil { + log.Debug().Err(restoreErr).Msg("Failed to restore terminal state") + } + }), nil +} + +// sessionWatch ends the PAM session on expiry or signal. +type sessionWatch struct { + mu sync.Mutex + client *ssh.Client + expired chan struct{} +} + +func (w *sessionWatch) attach(client *ssh.Client) { + w.mu.Lock() + defer w.mu.Unlock() + w.client = client +} + +// closeClient ends a connected session, reporting whether there was one to end. +func (w *sessionWatch) closeClient() bool { + w.mu.Lock() + defer w.mu.Unlock() + if w.client == nil { + return false + } + w.client.Close() + return true +} + +func watchForSessionEnd(transport *BaseProxyServer) (watch *sessionWatch, stop func()) { + watch = &sessionWatch{expired: make(chan struct{})} + done := make(chan struct{}) + + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + + go func() { + timer := time.NewTimer(time.Until(transport.sessionExpiry)) + defer timer.Stop() + + select { + case <-timer.C: + close(watch.expired) + watch.closeClient() + case sig := <-signals: + log.Debug().Msgf("Received signal %v, ending SSH session", sig) + if watch.closeClient() { + return + } + signal.Stop(signals) + transport.NotifySessionTermination() + os.Exit(exitCodeForSignal(sig)) + case <-done: + } + }() + + return watch, func() { + signal.Stop(signals) + close(done) + } +} + +func exitCodeForSignal(sig os.Signal) int { + if signum, ok := sig.(syscall.Signal); ok { + return 128 + int(signum) + } + return 1 +} + +func sshExitCode(err error, sessionExpired bool) (int, error) { + if err == nil { + return 0, nil + } + + var exitErr *ssh.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitStatus(), nil + } + + var missingErr *ssh.ExitMissingError + if sessionExpired || errors.As(err, &missingErr) || errors.Is(err, io.EOF) { + log.Debug().Err(err).Msg("Remote closed the SSH session without an exit status") + return sshExitCodeUnavailable, nil + } + + return 0, err +} + +func terminalSize() (width, height int) { + width, height, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || width <= 0 || height <= 0 { + return 80, 24 + } + return width, height +} + +func terminalType() string { + if termType := os.Getenv("TERM"); termType != "" { + return termType + } + return "xterm-256color" +} diff --git a/packages/pam/local/ssh-shell_test.go b/packages/pam/local/ssh-shell_test.go new file mode 100644 index 00000000..45a46330 --- /dev/null +++ b/packages/pam/local/ssh-shell_test.go @@ -0,0 +1,420 @@ +package pam + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "os" + "reflect" + "strings" + "testing" + "time" + + gatewayssh "github.com/Infisical/infisical-merge/packages/pam/handlers/ssh" + "github.com/Infisical/infisical-merge/packages/pam/session" + "github.com/creack/pty" + "golang.org/x/crypto/ssh" + "golang.org/x/term" +) + +const ( + testTargetUser = "target-user" + testTargetPassword = "target-password" +) + +type stubSessionLogger struct{} + +func (stubSessionLogger) LogEntry(session.SessionLogEntry) error { return nil } +func (stubSessionLogger) LogSessionEvent(session.SessionEvent) error { return nil } +func (stubSessionLogger) LogHttpEvent(session.HttpEvent) error { return nil } +func (stubSessionLogger) Close() error { return nil } + +func TestSanitizeSSHVersion(t *testing.T) { + cases := map[string]string{ + "0.44.1": "0.44.1", + "0.44.1-rc.1": "0.44.1_rc.1", + "1.0 beta": "1.0_beta", + "": "unknown", + } + + for version, want := range cases { + if got := sanitizeSSHVersion(version); got != want { + t.Errorf("sanitizeSSHVersion(%q) = %q, want %q", version, got, want) + } + } +} + +func TestSSHExitCode(t *testing.T) { + t.Run("missing status reports unavailable", func(t *testing.T) { + code, err := sshExitCode(&ssh.ExitMissingError{}, false) + if err != nil || code != sshExitCodeUnavailable { + t.Fatalf("got (%d, %v), want (%d, nil)", code, err, sshExitCodeUnavailable) + } + }) + + t.Run("expiry reports unavailable rather than an error", func(t *testing.T) { + code, err := sshExitCode(errors.New("connection reset"), true) + if err != nil || code != sshExitCodeUnavailable { + t.Fatalf("got (%d, %v), want (%d, nil)", code, err, sshExitCodeUnavailable) + } + }) + + t.Run("other failures surface", func(t *testing.T) { + want := errors.New("handshake failed") + code, err := sshExitCode(want, false) + if !errors.Is(err, want) || code != 0 { + t.Fatalf("got (%d, %v), want (0, %v)", code, err, want) + } + }) +} + +// Drives the real gateway SSH proxy, covering the handshake the CLI now performs itself. +func TestRunSSHCommandThroughGateway(t *testing.T) { + gatewayAddr := startGatewaySSHProxy(t, startFakeSSHTarget(t)) + + cases := []struct { + name string + command []string + wantStdout string + wantCode int + }{ + { + name: "runs a command and returns its output", + command: []string{"uptime"}, + wantStdout: "ran: uptime\n", + }, + { + name: "joins arguments the way ssh does", + command: []string{"systemctl", "status", "nginx"}, + wantStdout: "ran: systemctl status nginx\n", + }, + { + name: "propagates a non-zero exit code", + command: []string{"exit-7"}, + wantCode: 7, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + client := dialTestClient(t, gatewayAddr) + + sshSession, err := client.NewSession() + if err != nil { + t.Fatalf("NewSession: %v", err) + } + defer sshSession.Close() + + var stdout, stderr bytes.Buffer + runErr := runSSHCommand(sshSession, tc.command, strings.NewReader(""), &stdout, &stderr) + + code, err := sshExitCode(runErr, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if code != tc.wantCode { + t.Errorf("exit code = %d, want %d", code, tc.wantCode) + } + if stdout.String() != tc.wantStdout { + t.Errorf("stdout = %q, want %q", stdout.String(), tc.wantStdout) + } + }) + } +} + +func dialTestClient(t *testing.T, gatewayAddr string) *ssh.Client { + t.Helper() + + conn, err := net.DialTimeout("tcp", gatewayAddr, 5*time.Second) + if err != nil { + t.Fatalf("dial gateway: %v", err) + } + + // Not the target's username: the gateway ignores it and injects the account's own. + client, err := newSSHClient(conn, "whoever") + if err != nil { + conn.Close() + t.Fatalf("newSSHClient: %v", err) + } + t.Cleanup(func() { client.Close() }) + return client +} + +func startGatewaySSHProxy(t *testing.T, targetAddr string) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { listener.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + proxy := gatewayssh.NewSSHProxy(gatewayssh.SSHProxyConfig{ + TargetAddr: targetAddr, + AuthMethod: "password", + InjectUsername: testTargetUser, + InjectPassword: testTargetPassword, + SessionID: "test-session", + SessionLogger: stubSessionLogger{}, + }) + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { _ = proxy.HandleConnection(ctx, conn) }() + } + }() + + return listener.Addr().String() +} + +// startFakeSSHTarget stands in for the machine a PAM account points at. +func startFakeSSHTarget(t *testing.T) string { + t.Helper() + + config := &ssh.ServerConfig{ + PasswordCallback: func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { + if conn.User() != testTargetUser || string(password) != testTargetPassword { + return nil, fmt.Errorf("authentication failed for %q", conn.User()) + } + return nil, nil + }, + } + config.AddHostKey(newTestHostKey(t)) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { listener.Close() }) + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go serveFakeTarget(conn, config) + } + }() + + return listener.Addr().String() +} + +func serveFakeTarget(conn net.Conn, config *ssh.ServerConfig) { + defer conn.Close() + + _, channels, requests, err := ssh.NewServerConn(conn, config) + if err != nil { + return + } + go ssh.DiscardRequests(requests) + + for newChannel := range channels { + if newChannel.ChannelType() != "session" { + _ = newChannel.Reject(ssh.UnknownChannelType, "only session channels are supported") + continue + } + channel, channelRequests, err := newChannel.Accept() + if err != nil { + return + } + go serveFakeTargetChannel(channel, channelRequests) + } +} + +func serveFakeTargetChannel(channel ssh.Channel, requests <-chan *ssh.Request) { + defer channel.Close() + + for req := range requests { + switch req.Type { + case "exec": + if req.WantReply { + _ = req.Reply(true, nil) + } + exitStatus := runFakeCommand(channel, sshStringPayload(req.Payload)) + sendExitStatus(channel, exitStatus) + _ = channel.CloseWrite() + return + case "shell": + if req.WantReply { + _ = req.Reply(true, nil) + } + go echoShell(channel) + case "pty-req", "window-change", "env": + if req.WantReply { + _ = req.Reply(true, nil) + } + default: + if req.WantReply { + _ = req.Reply(false, nil) + } + } + } +} + +func runFakeCommand(channel ssh.Channel, command string) uint32 { + switch command { + case "exit-7": + return 7 + default: + _, _ = fmt.Fprintf(channel, "ran: %s\n", command) + return 0 + } +} + +func echoShell(channel ssh.Channel) { + buf := make([]byte, 1024) + for { + n, err := channel.Read(buf) + if n > 0 { + _, _ = fmt.Fprintf(channel, "shell> %s", buf[:n]) + } + if err != nil { + return + } + } +} + +func sshStringPayload(payload []byte) string { + if len(payload) < 4 { + return "" + } + length := binary.BigEndian.Uint32(payload) + if int(length) > len(payload)-4 { + return "" + } + return string(payload[4 : 4+length]) +} + +func sendExitStatus(channel ssh.Channel, status uint32) { + payload := make([]byte, 4) + binary.BigEndian.PutUint32(payload, status) + _, _ = channel.SendRequest("exit-status", false, payload) +} + +func newTestHostKey(t *testing.T) ssh.Signer { + t.Helper() + + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate host key: %v", err) + } + signer, err := ssh.NewSignerFromKey(privateKey) + if err != nil { + t.Fatalf("build host key signer: %v", err) + } + return signer +} + +// Drives the interactive path against a real terminal: raw mode, stdin pump, and restore. +func TestRunInteractiveShellOverPTY(t *testing.T) { + gatewayAddr := startGatewaySSHProxy(t, startFakeSSHTarget(t)) + client := dialTestClient(t, gatewayAddr) + + sshSession, err := client.NewSession() + if err != nil { + t.Fatalf("NewSession: %v", err) + } + defer sshSession.Close() + + primary, replica, err := pty.Open() + if err != nil { + t.Fatalf("open pty: %v", err) + } + t.Cleanup(func() { + primary.Close() + replica.Close() + }) + + // runInteractiveShell works on the process's own terminal, so point that at the pty. + originalStdin, originalStdout := os.Stdin, os.Stdout + os.Stdin, os.Stdout = replica, replica + t.Cleanup(func() { os.Stdin, os.Stdout = originalStdin, originalStdout }) + + stateBefore, err := term.GetState(int(replica.Fd())) + if err != nil { + t.Fatalf("read terminal state: %v", err) + } + + done := make(chan error, 1) + go func() { done <- runInteractiveShell(sshSession) }() + + // Raw mode has to be on before the pty stops echoing what is written here. + waitForRawMode(t, replica, stateBefore) + + if _, err := io.WriteString(primary, "hello\n"); err != nil { + t.Fatalf("write to terminal: %v", err) + } + + if got := readUntil(t, primary, "shell> hello"); !strings.Contains(got, "shell> hello") { + t.Errorf("terminal output = %q, want it to contain %q", got, "shell> hello") + } + + sshSession.Close() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("runInteractiveShell did not return after the session closed") + } + + stateAfter, err := term.GetState(int(replica.Fd())) + if err != nil { + t.Fatalf("read terminal state: %v", err) + } + if !reflect.DeepEqual(stateBefore, stateAfter) { + t.Error("terminal was not restored to its original state") + } +} + +func waitForRawMode(t *testing.T, tty *os.File, original *term.State) { + t.Helper() + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + current, err := term.GetState(int(tty.Fd())) + if err == nil && !reflect.DeepEqual(current, original) { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("terminal never entered raw mode") +} + +func readUntil(t *testing.T, reader io.Reader, want string) string { + t.Helper() + + found := make(chan string, 1) + go func() { + var seen []byte + buf := make([]byte, 256) + for { + n, err := reader.Read(buf) + seen = append(seen, buf[:n]...) + if strings.Contains(string(seen), want) || err != nil { + found <- string(seen) + return + } + } + }() + + select { + case got := <-found: + return got + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for output on the terminal") + return "" + } +}