Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions e2e/pam/ssh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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 <path> -- <command>`, 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 <setup-url> | 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".
Expand Down Expand Up @@ -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) {
Expand Down
49 changes: 42 additions & 7 deletions packages/cmd/pam.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,32 @@ var pamCmd = &cobra.Command{
}

var pamAccessCmd = &cobra.Command{
Use: "access <path>",
Use: "access <path> [-- <command>]",
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 {
Expand All @@ -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.")
}

Comment thread
lb-vn marked this conversation as resolved.
util.RequireLogin()

loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true)
if err != nil {
util.HandleError(err, "Unable to get logged in user details")
Expand All @@ -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,
})
},
}

Expand Down Expand Up @@ -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')")
Expand Down
116 changes: 110 additions & 6 deletions packages/pam/local/access.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -98,14 +111,22 @@ 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
case AccountTypePostgres, AccountTypeMySQL, AccountTypeMsSQL, AccountTypeMongoDB, AccountTypeOracleDB:
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:
Expand All @@ -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))
}
}
Expand All @@ -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{
Expand Down Expand Up @@ -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{
Expand All @@ -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
}
Expand Down Expand Up @@ -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")
Expand Down
34 changes: 34 additions & 0 deletions packages/pam/local/ssh-shell-resize.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading