From b2a8e37627bed39c409fc8240bcf0970cad465de Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 17 Jun 2026 04:37:39 +0800 Subject: [PATCH 01/13] feat: add cli support postgres pam --- packages/api/model.go | 13 +- packages/cmd/pam.go | 532 +-------------------------- packages/pam/local/access.go | 206 +++++++++++ packages/pam/local/base-proxy.go | 20 +- packages/pam/local/database-proxy.go | 138 +------ 5 files changed, 241 insertions(+), 668 deletions(-) create mode 100644 packages/pam/local/access.go diff --git a/packages/api/model.go b/packages/api/model.go index 92879031..a17441f5 100644 --- a/packages/api/model.go +++ b/packages/api/model.go @@ -852,24 +852,31 @@ type RegisterGatewayResponse struct { } type PAMAccessRequest struct { - Duration string `json:"duration,omitempty"` + // New path-based fields (PAM revamp) + Path string `json:"path,omitempty"` + + // Legacy resource-based fields (kept so we can temporarily keep the other proxy files for furtherr revamp) ResourceName string `json:"resourceName,omitempty"` AccountName string `json:"accountName,omitempty"` ProjectId string `json:"projectId,omitempty"` MfaSessionId string `json:"mfaSessionId,omitempty"` - Reason string `json:"reason,omitempty"` + + // Common fields + Duration string `json:"duration,omitempty"` + Reason string `json:"reason,omitempty"` } type PAMAccessResponse struct { SessionId string `json:"sessionId"` + AccountType string `json:"accountType"` ResourceType string `json:"resourceType"` + RelayHost string `json:"relayHost"` RelayClientCertificate string `json:"relayClientCertificate"` RelayClientPrivateKey string `json:"relayClientPrivateKey"` RelayServerCertificateChain string `json:"relayServerCertificateChain"` GatewayClientCertificate string `json:"gatewayClientCertificate"` GatewayClientPrivateKey string `json:"gatewayClientPrivateKey"` GatewayServerCertificateChain string `json:"gatewayServerCertificateChain"` - RelayHost string `json:"relayHost"` Metadata map[string]string `json:"metadata,omitempty"` } diff --git a/packages/cmd/pam.go b/packages/cmd/pam.go index 5f9d99b0..d40d4b6b 100644 --- a/packages/cmd/pam.go +++ b/packages/cmd/pam.go @@ -1,36 +1,13 @@ package cmd import ( - "os" "time" pam "github.com/Infisical/infisical-merge/packages/pam/local" "github.com/Infisical/infisical-merge/packages/util" - "github.com/mattn/go-isatty" - "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) -func readReasonFlag(cmd *cobra.Command) string { - reason, _ := cmd.Flags().GetString("reason") - return reason -} - -func resolveReason(cmd *cobra.Command) string { - if cmd.Flags().Changed("reason") { - reason, _ := cmd.Flags().GetString("reason") - return reason - } - if !isatty.IsTerminal(os.Stdin.Fd()) { - return "" - } - reason, err := pam.PromptForReason(false) - if err != nil { - return "" - } - return reason -} - var pamCmd = &cobra.Command{ Use: "pam", Short: "PAM-related commands", @@ -39,338 +16,22 @@ var pamCmd = &cobra.Command{ Args: cobra.NoArgs, } -// ==================== Database Commands ==================== - -var pamDbCmd = &cobra.Command{ - Use: "db", - Short: "Database-related PAM commands", - Long: "Database-related PAM commands for Infisical", - DisableFlagsInUseLine: true, - Args: cobra.NoArgs, -} - -var pamDbAccessCmd = &cobra.Command{ - Use: "access", - Short: "Access PAM database accounts", - Long: "Access PAM database accounts for Infisical. This starts a local database proxy server that you can use to connect to databases directly.", - Example: "infisical pam db access --resource infisical-shared-cloud-instances --account infisical --project-id b38bef10-2685-43c4-9a2c-635206d60bec --duration 4h", - DisableFlagsInUseLine: true, - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - util.RequireLogin() - - resourceName, _ := cmd.Flags().GetString("resource") - accountName, _ := cmd.Flags().GetString("account") - - if resourceName == "" || accountName == "" { - util.PrintErrorMessageAndExit("Both --resource and --account flags are required") - } - - projectID, err := cmd.Flags().GetString("project-id") - if err != nil { - util.HandleError(err, "Unable to parse project-id flag") - } - - if projectID == "" { - workspaceFile, err := util.GetWorkSpaceFromFile() - if err != nil { - util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --project-id flag") - } - projectID = workspaceFile.WorkspaceId - } - - durationStr, err := cmd.Flags().GetString("duration") - if err != nil { - util.HandleError(err, "Unable to parse duration flag") - } - - _, err = time.ParseDuration(durationStr) - if err != nil { - util.HandleError(err, "Invalid duration format. Use formats like '1h', '30m', '2h30m'") - } - - port, err := cmd.Flags().GetInt("port") - if err != nil { - util.HandleError(err, "Unable to parse port flag") - } - - reason := resolveReason(cmd) - - log.Debug().Msg("PAM Database Access: Trying to fetch secrets using logged in details") - - loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) - isConnected := util.ValidateInfisicalAPIConnection() - - if isConnected { - log.Debug().Msg("PAM Database Access: Connected to Infisical instance, checking logged in creds") - } - - if err != nil { - util.HandleError(err, "Unable to get logged in user details") - } - - if isConnected && loggedInUserDetails.LoginExpired { - loggedInUserDetails = util.EstablishUserLoginSession() - } - - pam.StartDatabaseLocalProxy(loggedInUserDetails.UserCredentials.JTWToken, pam.PAMAccessParams{ - ResourceName: resourceName, - AccountName: accountName, - Reason: reason, - }, projectID, durationStr, port) - }, -} - -// ==================== SSH Commands ==================== - -var pamSshCmd = &cobra.Command{ - Use: "ssh", - Short: "SSH-related PAM commands", - Long: "SSH-related PAM commands for Infisical", - DisableFlagsInUseLine: true, - Args: cobra.NoArgs, -} - -var pamSshAccessCmd = &cobra.Command{ - Use: "access", - Short: "Start interactive SSH session to PAM account", - Long: "Start an interactive SSH session to a PAM-managed SSH account. This command automatically launches an SSH client connected through the Infisical Gateway.", - Example: "infisical pam ssh access --resource prod-servers --account root --project-id --duration 1h", - DisableFlagsInUseLine: true, - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - runSSHCommand(cmd, args, pam.SSHAccessOptions{}) - }, -} - -var pamSshExecCmd = &cobra.Command{ - Use: "exec [command]", - Short: "Execute a command on a PAM SSH account", - Long: `Execute a single command on a PAM-managed SSH account and return the output. -This is useful for CI/CD pipelines and scripting where interactive sessions are not needed.`, - Example: ` # Run a command and get output - infisical pam ssh exec "ls -la /var/log" --resource prod-servers --account root --project-id - - # Use in a script - OUTPUT=$(infisical pam ssh exec "cat /etc/hostname" --resource prod-servers --account root --project-id )`, +var pamAccessCmd = &cobra.Command{ + 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", DisableFlagsInUseLine: true, Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - runSSHCommand(cmd, args, pam.SSHAccessOptions{ - ExecCommand: args[0], - }) - }, -} - -var pamSshProxyCmd = &cobra.Command{ - Use: "proxy", - Short: "Start SSH proxy for SCP, SFTP, or rsync", - Long: `Start an SSH proxy without launching an interactive session. -This is useful for file transfers using SCP, SFTP, rsync, or other SSH-based tools. -The proxy prints connection details and waits until terminated with Ctrl+C.`, - Example: ` # Start the proxy - infisical pam ssh proxy --resource prod-servers --account root --project-id - - # Then in another terminal, use SCP: - scp -P -o StrictHostKeyChecking=no local-file.txt root@127.0.0.1:/remote/path/ - - # Or use rsync: - rsync -e "ssh -p -o StrictHostKeyChecking=no" local-dir/ root@127.0.0.1:/remote/path/`, - DisableFlagsInUseLine: true, - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - runSSHCommand(cmd, args, pam.SSHAccessOptions{ - ProxyOnly: true, - }) - }, -} - -// runSSHCommand is the shared implementation for all SSH subcommands -func runSSHCommand(cmd *cobra.Command, args []string, options pam.SSHAccessOptions) { - util.RequireLogin() - - resourceName, _ := cmd.Flags().GetString("resource") - accountName, _ := cmd.Flags().GetString("account") - - if resourceName == "" || accountName == "" { - util.PrintErrorMessageAndExit("Both --resource and --account flags are required") - } - - durationStr, err := cmd.Flags().GetString("duration") - if err != nil { - util.HandleError(err, "Unable to parse duration flag") - } - - _, err = time.ParseDuration(durationStr) - if err != nil { - util.HandleError(err, "Invalid duration format. Use formats like '1h', '30m', '2h30m'") - } - - projectID, err := cmd.Flags().GetString("project-id") - if err != nil { - util.HandleError(err, "Unable to parse project-id flag") - } - - if projectID == "" { - workspaceFile, err := util.GetWorkSpaceFromFile() - if err != nil { - util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --project-id flag") - } - projectID = workspaceFile.WorkspaceId - } - - var reason string - if options.ExecCommand != "" { - reason = readReasonFlag(cmd) - } else { - reason = resolveReason(cmd) - } - - log.Debug().Msg("PAM SSH: Trying to fetch credentials using logged in details") - - loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) - isConnected := util.ValidateInfisicalAPIConnection() - - if isConnected { - log.Debug().Msg("PAM SSH: Connected to Infisical instance, checking logged in creds") - } - - if err != nil { - util.HandleError(err, "Unable to get logged in user details") - } - - if isConnected && loggedInUserDetails.LoginExpired { - loggedInUserDetails = util.EstablishUserLoginSession() - } - - pam.StartSSHLocalProxy(loggedInUserDetails.UserCredentials.JTWToken, pam.PAMAccessParams{ - ResourceName: resourceName, - AccountName: accountName, - Reason: reason, - }, projectID, durationStr, options) -} - -// ==================== Kubernetes Commands ==================== - -var pamKubernetesCmd = &cobra.Command{ - Use: "kubernetes", - Aliases: []string{"k8s"}, - Short: "Kubernetes-related PAM commands", - Long: "Kubernetes-related PAM commands for Infisical", - DisableFlagsInUseLine: true, - Args: cobra.NoArgs, -} - -var pamKubernetesAccessCmd = &cobra.Command{ - Use: "access", - Short: "Access Kubernetes PAM account", - Long: "Access Kubernetes via a PAM-managed Kubernetes account. This command automatically launches a proxy connected to your Kubernetes cluster through the Infisical Gateway.", - Example: "infisical pam kubernetes access --resource prod-cluster --account developer --project-id b38bef10-2685-43c4-9a2c-635206d60bec --duration 4h", - DisableFlagsInUseLine: true, - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - util.RequireLogin() - - resourceName, _ := cmd.Flags().GetString("resource") - accountName, _ := cmd.Flags().GetString("account") - - if resourceName == "" || accountName == "" { - util.PrintErrorMessageAndExit("Both --resource and --account flags are required") - } - - durationStr, err := cmd.Flags().GetString("duration") - if err != nil { - util.HandleError(err, "Unable to parse duration flag") - } - - _, err = time.ParseDuration(durationStr) - if err != nil { - util.HandleError(err, "Invalid duration format. Use formats like '1h', '30m', '2h30m'") - } - - port, err := cmd.Flags().GetInt("port") - if err != nil { - util.HandleError(err, "Unable to parse port flag") - } - - projectID, err := cmd.Flags().GetString("project-id") - if err != nil { - util.HandleError(err, "Unable to parse project-id flag") - } - - if projectID == "" { - workspaceFile, err := util.GetWorkSpaceFromFile() - if err != nil { - util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --project-id flag") - } - projectID = workspaceFile.WorkspaceId - } - - reason := resolveReason(cmd) - - log.Debug().Msg("PAM Kubernetes Access: Trying to fetch credentials using logged in details") - - loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) - isConnected := util.ValidateInfisicalAPIConnection() - - if isConnected { - log.Debug().Msg("PAM Kubernetes Access: Connected to Infisical instance, checking logged in creds") - } - - if err != nil { - util.HandleError(err, "Unable to get logged in user details") - } - - if isConnected && loggedInUserDetails.LoginExpired { - loggedInUserDetails = util.EstablishUserLoginSession() - } - - pam.StartKubernetesLocalProxy(loggedInUserDetails.UserCredentials.JTWToken, pam.PAMAccessParams{ - ResourceName: resourceName, - AccountName: accountName, - Reason: reason, - }, projectID, durationStr, port) - }, -} - -// ==================== Redis Commands ==================== - -var pamRedisCmd = &cobra.Command{ - Use: "redis", - Short: "Redis-related PAM commands", - Long: "Redis-related PAM commands for Infisical", - DisableFlagsInUseLine: true, - Args: cobra.NoArgs, -} - -var pamRedisAccessCmd = &cobra.Command{ - Use: "access", - Short: "Access PAM Redis accounts", - Long: "Access PAM Redis accounts for Infisical. This starts a local Redis proxy server that you can use to connect to Redis directly.", - Example: "infisical pam redis access --resource my-redis-resource --account redis-admin --duration 4h --port 6379 --project-id ", - DisableFlagsInUseLine: true, - Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { util.RequireLogin() - resourceName, _ := cmd.Flags().GetString("resource") - accountName, _ := cmd.Flags().GetString("account") + path := args[0] - if resourceName == "" || accountName == "" { - util.PrintErrorMessageAndExit("Both --resource and --account flags are required") - } - - projectID, err := cmd.Flags().GetString("project-id") + reason, err := cmd.Flags().GetString("reason") if err != nil { - util.HandleError(err, "Unable to parse project-id flag") - } - - if projectID == "" { - workspaceFile, err := util.GetWorkSpaceFromFile() - if err != nil { - util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --project-id flag") - } - projectID = workspaceFile.WorkspaceId + util.HandleError(err, "Unable to parse reason flag") } durationStr, err := cmd.Flags().GetString("duration") @@ -388,190 +49,25 @@ var pamRedisAccessCmd = &cobra.Command{ util.HandleError(err, "Unable to parse port flag") } - reason := resolveReason(cmd) - - log.Debug().Msg("PAM Redis Access: Trying to fetch secrets using logged in details") - loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) - isConnected := util.ValidateInfisicalAPIConnection() - - if isConnected { - log.Debug().Msg("PAM Redis Access: Connected to Infisical instance, checking logged in creds") - } - if err != nil { util.HandleError(err, "Unable to get logged in user details") } - if isConnected && loggedInUserDetails.LoginExpired { - loggedInUserDetails = util.EstablishUserLoginSession() - } - - pam.StartRedisLocalProxy(loggedInUserDetails.UserCredentials.JTWToken, pam.PAMAccessParams{ - ResourceName: resourceName, - AccountName: accountName, - Reason: reason, - }, projectID, durationStr, port) - }, -} - -// ==================== RDP Commands ==================== - -var pamRdpCmd = &cobra.Command{ - Use: "rdp", - Short: "RDP-related PAM commands", - Long: "RDP-related PAM commands for Infisical (Windows Server / Remote Desktop)", - DisableFlagsInUseLine: true, - Args: cobra.NoArgs, -} - -var pamRdpAccessCmd = &cobra.Command{ - Use: "access", - Short: "Access PAM Windows/RDP accounts", - Long: "Access a PAM-managed Windows target over RDP. This starts a local loopback proxy that your RDP client connects to; the session tunnels through the Infisical Gateway with credentials injected server-side.", - Example: "infisical pam rdp access --resource windows-prod --account administrator --duration 1h --project-id ", - DisableFlagsInUseLine: true, - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - util.RequireLogin() - - resourceName, _ := cmd.Flags().GetString("resource") - accountName, _ := cmd.Flags().GetString("account") - - if resourceName == "" || accountName == "" { - util.PrintErrorMessageAndExit("Both --resource and --account flags are required") - } - - projectID, err := cmd.Flags().GetString("project-id") - if err != nil { - util.HandleError(err, "Unable to parse project-id flag") - } - - if projectID == "" { - workspaceFile, err := util.GetWorkSpaceFromFile() - if err != nil { - util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --project-id flag") - } - projectID = workspaceFile.WorkspaceId - } - - durationStr, err := cmd.Flags().GetString("duration") - if err != nil { - util.HandleError(err, "Unable to parse duration flag") - } - - _, err = time.ParseDuration(durationStr) - if err != nil { - util.HandleError(err, "Invalid duration format. Use formats like '1h', '30m', '2h30m'") - } - - port, err := cmd.Flags().GetInt("port") - if err != nil { - util.HandleError(err, "Unable to parse port flag") - } - - noLaunch, err := cmd.Flags().GetBool("no-launch") - if err != nil { - util.HandleError(err, "Unable to parse no-launch flag") - } - - reason := resolveReason(cmd) - - log.Debug().Msg("PAM RDP Access: Trying to start session using logged in details") - - loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) isConnected := util.ValidateInfisicalAPIConnection() - - if isConnected { - log.Debug().Msg("PAM RDP Access: Connected to Infisical instance, checking logged in creds") - } - - if err != nil { - util.HandleError(err, "Unable to get logged in user details") - } - if isConnected && loggedInUserDetails.LoginExpired { loggedInUserDetails = util.EstablishUserLoginSession() } - pam.StartRDPLocalProxy(loggedInUserDetails.UserCredentials.JTWToken, pam.PAMAccessParams{ - ResourceName: resourceName, - AccountName: accountName, - Reason: reason, - }, projectID, durationStr, port, noLaunch) + pam.StartPAMAccess(loggedInUserDetails.UserCredentials.JTWToken, path, reason, durationStr, port) }, } func init() { - // Database commands - pamDbCmd.AddCommand(pamDbAccessCmd) - pamDbAccessCmd.Flags().String("resource", "", "Name of the PAM resource to access") - pamDbAccessCmd.Flags().String("account", "", "Name of the account within the resource") - pamDbAccessCmd.Flags().String("duration", "1h", "Duration for database access session (e.g., '1h', '30m', '2h30m')") - pamDbAccessCmd.Flags().Int("port", 0, "Port for the local database proxy server (0 for auto-assign)") - pamDbAccessCmd.Flags().String("project-id", "", "Project ID of the account to access") - pamDbAccessCmd.Flags().String("reason", "", "Reason for accessing the account (stored for audit purposes)") - pamDbAccessCmd.MarkFlagRequired("resource") - pamDbAccessCmd.MarkFlagRequired("account") - - // SSH commands - shared flags helper - addSSHFlags := func(cmd *cobra.Command) { - cmd.Flags().String("resource", "", "Name of the PAM resource to access") - cmd.Flags().String("account", "", "Name of the account within the resource") - cmd.Flags().String("duration", "1h", "Duration for SSH access session (e.g., '1h', '30m', '2h30m')") - cmd.Flags().String("project-id", "", "Project ID of the account to access") - cmd.Flags().String("reason", "", "Reason for accessing the account (stored for audit purposes)") - cmd.MarkFlagRequired("resource") - cmd.MarkFlagRequired("account") - } - - pamSshCmd.AddCommand(pamSshAccessCmd) - addSSHFlags(pamSshAccessCmd) - - pamSshCmd.AddCommand(pamSshExecCmd) - addSSHFlags(pamSshExecCmd) - - pamSshCmd.AddCommand(pamSshProxyCmd) - addSSHFlags(pamSshProxyCmd) - - // Kubernetes commands - pamKubernetesCmd.AddCommand(pamKubernetesAccessCmd) - pamKubernetesAccessCmd.Flags().String("resource", "", "Name of the PAM resource to access") - pamKubernetesAccessCmd.Flags().String("account", "", "Name of the account within the resource") - pamKubernetesAccessCmd.Flags().String("duration", "1h", "Duration for kubernetes access session (e.g., '1h', '30m', '2h30m')") - pamKubernetesAccessCmd.Flags().Int("port", 0, "Port for the local kubernetes proxy server (0 for auto-assign)") - pamKubernetesAccessCmd.Flags().String("project-id", "", "Project ID of the account to access") - pamKubernetesAccessCmd.Flags().String("reason", "", "Reason for accessing the account (stored for audit purposes)") - pamKubernetesAccessCmd.MarkFlagRequired("resource") - pamKubernetesAccessCmd.MarkFlagRequired("account") - - // Redis commands - pamRedisCmd.AddCommand(pamRedisAccessCmd) - pamRedisAccessCmd.Flags().String("resource", "", "Name of the PAM resource to access") - pamRedisAccessCmd.Flags().String("account", "", "Name of the account within the resource") - pamRedisAccessCmd.Flags().String("duration", "1h", "Duration for Redis access session (e.g., '1h', '30m', '2h30m')") - pamRedisAccessCmd.Flags().Int("port", 0, "Port for the local Redis proxy server (0 for auto-assign)") - pamRedisAccessCmd.Flags().String("project-id", "", "Project ID of the account to access") - pamRedisAccessCmd.Flags().String("reason", "", "Reason for accessing the account (stored for audit purposes)") - pamRedisAccessCmd.MarkFlagRequired("resource") - pamRedisAccessCmd.MarkFlagRequired("account") - - // RDP commands - pamRdpCmd.AddCommand(pamRdpAccessCmd) - pamRdpAccessCmd.Flags().String("resource", "", "Name of the PAM resource to access") - pamRdpAccessCmd.Flags().String("account", "", "Name of the account within the resource") - pamRdpAccessCmd.Flags().String("duration", "1h", "Duration for RDP access session (e.g., '1h', '30m', '2h30m')") - pamRdpAccessCmd.Flags().Int("port", 0, "Port for the local RDP proxy server (0 for auto-assign)") - pamRdpAccessCmd.Flags().String("project-id", "", "Project ID of the account to access") - pamRdpAccessCmd.Flags().Bool("no-launch", false, "Do not auto-launch the system RDP client; print connection details only") - pamRdpAccessCmd.Flags().String("reason", "", "Reason for accessing the account (stored for audit purposes)") - pamRdpAccessCmd.MarkFlagRequired("resource") - pamRdpAccessCmd.MarkFlagRequired("account") + pamAccessCmd.Flags().String("reason", "", "Reason for accessing the account (stored for audit purposes)") + 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)") - pamCmd.AddCommand(pamDbCmd) - pamCmd.AddCommand(pamSshCmd) - pamCmd.AddCommand(pamKubernetesCmd) - pamCmd.AddCommand(pamRedisCmd) - pamCmd.AddCommand(pamRdpCmd) + pamCmd.AddCommand(pamAccessCmd) RootCmd.AddCommand(pamCmd) } diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go new file mode 100644 index 00000000..665221da --- /dev/null +++ b/packages/pam/local/access.go @@ -0,0 +1,206 @@ +package pam + +import ( + "context" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/go-resty/resty/v2" + "github.com/rs/zerolog/log" +) + +// Account type constants (match API enum) +const ( + AccountTypePostgres = "postgres" + AccountTypeSSH = "ssh" + AccountTypeMySQL = "mysql" + AccountTypeMsSQL = "mssql" + AccountTypeMongoDB = "mongodb" + AccountTypeOracleDB = "oracledb" + AccountTypeRedis = "redis" + AccountTypeKubernetes = "kubernetes" + AccountTypeAwsIam = "aws-iam" + AccountTypeWindows = "windows" + AccountTypeActiveDirectory = "active-directory" +) + +// normalizePath ensures the path has a leading slash for display purposes. +// Both "/folder/account" and "folder/account" are accepted as input. +func normalizePath(path string) string { + if !strings.HasPrefix(path, "/") { + return "/" + path + } + return path +} + +// parsePath extracts the folder and account name from a path like "/folder/account" +func parsePath(path string) (folder, account string) { + // Remove leading slash if present + cleanPath := strings.TrimPrefix(path, "/") + parts := strings.SplitN(cleanPath, "/", 2) + if len(parts) == 2 { + return parts[0], parts[1] + } + // If no slash, treat the whole thing as account name + return "", cleanPath +} + +// 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 string, port int) { + // Normalize path for display (ensure leading slash) + displayPath := normalizePath(path) + + log.Info().Msgf("Starting PAM access for: %s", displayPath) + log.Info().Msgf("Session duration: %s", durationStr) + + httpClient := resty.New() + httpClient.SetAuthToken(accessToken) + httpClient.SetHeader("User-Agent", api.USER_AGENT) + + pamResponse, err := api.CallPAMAccess(httpClient, api.PAMAccessRequest{ + Path: path, + Duration: durationStr, + Reason: reason, + }) + if err != nil { + util.HandleError(err, "Failed to create PAM session") + return + } + + log.Info().Msgf("Session created with ID: %s", pamResponse.SessionId) + log.Info().Msgf("Account type: %s", pamResponse.AccountType) + + // Route based on account type from API response + switch pamResponse.AccountType { + case AccountTypePostgres: + startPostgresProxy(httpClient, &pamResponse, displayPath, durationStr, port) + case AccountTypeSSH: + util.PrintErrorMessageAndExit("SSH access not yet supported in the new PAM model") + case AccountTypeMySQL: + util.PrintErrorMessageAndExit("MySQL access not yet supported in the new PAM model") + case AccountTypeMsSQL: + util.PrintErrorMessageAndExit("MsSQL access not yet supported in the new PAM model") + case AccountTypeMongoDB: + util.PrintErrorMessageAndExit("MongoDB access not yet supported in the new PAM model") + case AccountTypeOracleDB: + util.PrintErrorMessageAndExit("OracleDB access not yet supported in the new PAM model") + case AccountTypeRedis: + util.PrintErrorMessageAndExit("Redis access not yet supported in the new PAM model") + case AccountTypeKubernetes: + util.PrintErrorMessageAndExit("Kubernetes access not yet supported in the new PAM model") + case AccountTypeAwsIam: + util.PrintErrorMessageAndExit("AWS IAM access not yet supported in the new PAM model") + case AccountTypeWindows: + util.PrintErrorMessageAndExit("Windows/RDP access not yet supported in the new PAM model") + case AccountTypeActiveDirectory: + util.PrintErrorMessageAndExit("Active Directory access not yet supported in the new PAM model") + default: + util.PrintErrorMessageAndExit(fmt.Sprintf("Unsupported account type: %s", pamResponse.AccountType)) + } +} + +// startPostgresProxy starts a local Postgres proxy for the given session +func startPostgresProxy(httpClient *resty.Client, response *api.PAMAccessResponse, path, durationStr string, port int) { + duration, err := time.ParseDuration(durationStr) + if err != nil { + util.HandleError(err, "Failed to parse duration") + return + } + + ctx, cancel := context.WithCancel(context.Background()) + + proxy := &DatabaseProxyServer{ + BaseProxyServer: 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{}), + }, + } + + if err := proxy.ValidateResourceTypeSupported(); err != nil { + util.HandleError(err, "Gateway version outdated") + return + } + + err = proxy.Start(port) + if err != nil { + util.HandleError(err, "Failed to start proxy server") + return + } + + // Get connection details from metadata + username, ok := response.Metadata["username"] + if !ok { + util.HandleError(fmt.Errorf("PAM response metadata is missing 'username'"), "Failed to start proxy server") + return + } + database, ok := response.Metadata["database"] + if !ok { + util.HandleError(fmt.Errorf("PAM response metadata is missing 'database'"), "Failed to start proxy server") + return + } + + // Parse path into folder and account + folder, account := parsePath(path) + + log.Info().Msgf("Database proxy server listening on port %d", proxy.port) + fmt.Printf("\n") + fmt.Printf("**********************************************************************\n") + fmt.Printf(" Database Proxy Session Started! \n") + fmt.Printf("**********************************************************************\n") + fmt.Printf("\n") + if folder != "" { + fmt.Printf(" Folder: %s\n", folder) + } + fmt.Printf(" Account: %s\n", account) + fmt.Printf(" Duration: %s\n", duration.String()) + fmt.Printf("\n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf(" Connection Details \n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf("\n") + fmt.Printf(" Host: localhost\n") + fmt.Printf(" Port: %d\n", proxy.port) + fmt.Printf(" Username: %s\n", username) + fmt.Printf(" Password: (injected by gateway)\n") + fmt.Printf(" Database: %s\n", database) + fmt.Printf("\n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf(" Connection String \n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf("\n") + util.PrintfStderr(" postgres://%s@localhost:%d/%s\n", username, proxy.port, database) + fmt.Printf("\n") + fmt.Printf("**********************************************************************\n") + fmt.Printf("\n") + + // Handle shutdown signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + go func() { + sig := <-sigChan + log.Info().Msgf("Received signal %v, initiating graceful shutdown...", sig) + proxy.gracefulShutdown() + }() + + proxy.Run() +} diff --git a/packages/pam/local/base-proxy.go b/packages/pam/local/base-proxy.go index 0ef603cd..0cc52108 100644 --- a/packages/pam/local/base-proxy.go +++ b/packages/pam/local/base-proxy.go @@ -26,6 +26,8 @@ import ( "github.com/rs/zerolog/log" ) +// PAMAccessParams holds the legacy resource-based access parameters. +// Used by the old proxy implementations (ssh, redis, kubernetes, rdp). type PAMAccessParams struct { ResourceName string AccountName string @@ -317,8 +319,6 @@ func (b *BaseProxyServer) WaitForConnectionsWithTimeout(timeout time.Duration) { } } -const reasonRequiredErrorName = "PAM_REASON_REQUIRED" - func PromptForReason(required bool) (string, error) { label := "Reason for access" prompt := promptui.Prompt{ @@ -337,18 +337,18 @@ func PromptForReason(required bool) (string, error) { return strings.TrimSpace(result), nil } -// CallPAMAccessWithMFA attempts to access a PAM account and handles MFA if required -// This is a shared function used by all PAM proxies +const reasonRequiredErrorName = "PAM_REASON_REQUIRED" + +// CallPAMAccessWithMFA attempts to access a PAM account and handles MFA if required. +// This is used by the legacy proxy implementations. func CallPAMAccessWithMFA( httpClient *resty.Client, pamRequest api.PAMAccessRequest, interactive bool, ) (api.PAMAccessResponse, error) { - // Initial request pamResponse, err := api.CallPAMAccess(httpClient, pamRequest) if err != nil { if apiErr, ok := err.(*api.APIError); ok { - // Reason required by account policy if apiErr.Name == reasonRequiredErrorName { if !interactive || !isatty.IsTerminal(os.Stdin.Fd()) { return api.PAMAccessResponse{}, fmt.Errorf( @@ -363,21 +363,17 @@ func CallPAMAccessWithMFA( return CallPAMAccessWithMFA(httpClient, pamRequest, interactive) } - // MFA required if apiErr.Name == "SESSION_MFA_REQUIRED" { - // Extract MFA details from error if details, ok := apiErr.Details.(map[string]interface{}); ok { mfaSessionId, _ := details["mfaSessionId"].(string) mfaMethod, _ := details["mfaMethod"].(string) if mfaSessionId != "" { - // Handle MFA flow err := util.HandleMFASession(httpClient, mfaSessionId, mfaMethod, config.INFISICAL_URL) if err != nil { return api.PAMAccessResponse{}, fmt.Errorf("MFA verification failed: %w", err) } - // Retry request with MFA session ID log.Debug().Msg("Retrying PAM access with MFA session...") pamRequest.MfaSessionId = mfaSessionId pamResponse, err = api.CallPAMAccess(httpClient, pamRequest) @@ -390,7 +386,6 @@ func CallPAMAccessWithMFA( } } } - // Return original error if not MFA/reason-related return api.PAMAccessResponse{}, err } @@ -398,7 +393,7 @@ func CallPAMAccessWithMFA( } // HandleApprovalWorkflow checks if an error is due to an approval policy and handles the approval request flow. -// Returns true if the error was handled (either approval request created or user declined), false otherwise. +// Returns true if the error was handled, false otherwise. func HandleApprovalWorkflow(httpClient *resty.Client, err error, projectID string, accessParams PAMAccessParams, durationStr string) bool { var apiErr *api.APIError if !errors.As(err, &apiErr) || apiErr.ErrorMessage != "A policy is in place for this resource" { @@ -462,3 +457,4 @@ func askForApprovalRequestTrigger() (bool, error) { } return strings.ToLower(result) == "y", nil } + diff --git a/packages/pam/local/database-proxy.go b/packages/pam/local/database-proxy.go index c418795c..872a13ee 100644 --- a/packages/pam/local/database-proxy.go +++ b/packages/pam/local/database-proxy.go @@ -6,21 +6,15 @@ import ( "io" "net" "os" - "os/signal" - "syscall" "time" - "github.com/Infisical/infisical-merge/packages/pam/handlers/oracle" - "github.com/Infisical/infisical-merge/packages/pam/session" - "github.com/Infisical/infisical-merge/packages/util" - "github.com/go-resty/resty/v2" "github.com/rs/zerolog/log" ) type DatabaseProxyServer struct { - BaseProxyServer // Embed common functionality - server net.Listener - port int + BaseProxyServer + server net.Listener + port int } type ALPN string @@ -31,123 +25,6 @@ const ( ALPNInfisicalPAMCapabilities ALPN = "infisical-pam-capabilities" ) -func StartDatabaseLocalProxy(accessToken string, accessParams PAMAccessParams, projectID string, durationStr string, port int) { - log.Info().Msgf("Starting database proxy for account: %s", accessParams.GetDisplayName()) - log.Info().Msgf("Session duration: %s", durationStr) - - httpClient := resty.New() - httpClient.SetAuthToken(accessToken) - httpClient.SetHeader("User-Agent", "infisical-cli") - - pamRequest := accessParams.ToAPIRequest(projectID, durationStr) - - pamResponse, err := CallPAMAccessWithMFA(httpClient, pamRequest, true) - if err != nil { - if HandleApprovalWorkflow(httpClient, err, projectID, accessParams, durationStr) { - return - } - util.HandleError(err, "Failed to access PAM account") - return - } - - log.Info().Msgf("Database session created with ID: %s", pamResponse.SessionId) - - duration, err := time.ParseDuration(durationStr) - if err != nil { - util.HandleError(err, "Failed to parse duration") - return - } - - ctx, cancel := context.WithCancel(context.Background()) - - proxy := &DatabaseProxyServer{ - BaseProxyServer: BaseProxyServer{ - httpClient: httpClient, - relayHost: pamResponse.RelayHost, - relayClientCert: pamResponse.RelayClientCertificate, - relayClientKey: pamResponse.RelayClientPrivateKey, - relayServerCertChain: pamResponse.RelayServerCertificateChain, - gatewayClientCert: pamResponse.GatewayClientCertificate, - gatewayClientKey: pamResponse.GatewayClientPrivateKey, - gatewayServerCertChain: pamResponse.GatewayServerCertificateChain, - sessionExpiry: time.Now().Add(duration), - sessionId: pamResponse.SessionId, - resourceType: pamResponse.ResourceType, - ctx: ctx, - cancel: cancel, - shutdownCh: make(chan struct{}), - }, - } - - if err := proxy.ValidateResourceTypeSupported(); err != nil { - util.HandleError(err, "Gateway version outdated") - return - } - - err = proxy.Start(port) - if err != nil { - util.HandleError(err, "Failed to start proxy server") - return - } - - if port == 0 { - fmt.Printf("Database proxy started for account %s with duration %s on port %d (auto-assigned)\n", accessParams.GetDisplayName(), duration.String(), proxy.port) - } else { - fmt.Printf("Database proxy started for account %s with duration %s on port %d\n", accessParams.GetDisplayName(), duration.String(), proxy.port) - } - - username, ok := pamResponse.Metadata["username"] - if !ok { - util.HandleError(fmt.Errorf("PAM response metadata is missing 'username'"), "Failed to start proxy server") - return - } - database, ok := pamResponse.Metadata["database"] - if !ok { - util.HandleError(fmt.Errorf("PAM response metadata is missing 'database'"), "Failed to start proxy server") - return - } - - log.Info().Msgf("Database proxy server listening on port %d", proxy.port) - fmt.Printf("\n") - fmt.Printf("**********************************************************************\n") - fmt.Printf(" Database Proxy Session Started! \n") - fmt.Printf("----------------------------------------------------------------------\n") - fmt.Printf("Resource: %s\n", accessParams.ResourceName) - fmt.Printf("Account: %s\n", accessParams.AccountName) - fmt.Printf("\n") - fmt.Printf("You can now connect to your database using this connection string:\n") - - switch pamResponse.ResourceType { - case session.ResourceTypePostgres: - util.PrintfStderr("postgres://%s@localhost:%d/%s", username, proxy.port, database) - case session.ResourceTypeMysql: - util.PrintfStderr("mysql://%s@localhost:%d/%s", username, proxy.port, database) - case session.ResourceTypeMssql: - util.PrintfStderr("sqlserver://%s@localhost:%d?database=%s&encrypt=false&trustServerCertificate=true", username, proxy.port, database) - case session.ResourceTypeMongodb: - util.PrintfStderr("mongodb://localhost:%d/%s?serverSelectionTimeoutMS=15000", proxy.port, database) - case session.ResourceTypeOracledb: - util.PrintfStderr("%s/%s@localhost:%d/%s", username, oracle.ProxyPasswordPlaceholder, proxy.port, database) - util.PrintfStderr("\njdbc:oracle:thin:@localhost:%d/%s (user: %s, password: %s)", proxy.port, database, username, oracle.ProxyPasswordPlaceholder) - util.PrintfStderr("\n\nNote: the password shown is a protocol placeholder required by Oracle, not a secret.") - default: - util.PrintfStderr("localhost:%d", proxy.port) - } - util.PrintfStderr("\n**********************************************************************\n") - util.PrintfStderr("\n") - - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - go func() { - sig := <-sigChan - log.Info().Msgf("Received signal %v, initiating graceful shutdown...", sig) - proxy.gracefulShutdown() - }() - - proxy.Run() -} - func (p *DatabaseProxyServer) Start(port int) error { var err error if port == 0 { @@ -170,21 +47,16 @@ func (p *DatabaseProxyServer) gracefulShutdown() { p.shutdownOnce.Do(func() { log.Info().Msg("Starting graceful shutdown of database proxy...") - // Send session termination notification before cancelling context p.NotifySessionTermination() - // Signal the accept loop to stop close(p.shutdownCh) - // Close the server to stop accepting new connections if p.server != nil { p.server.Close() } - // Cancel context to signal all goroutines to stop p.cancel() - // Wait for connections to close p.WaitForConnectionsWithTimeout(10 * time.Second) log.Info().Msg("Database proxy shutdown complete") @@ -204,7 +76,6 @@ func (p *DatabaseProxyServer) Run() { log.Info().Msg("Shutdown signal received, stopping proxy server") return default: - // Check if session has expired if time.Now().After(p.sessionExpiry) { log.Warn().Msg("Database session expired, shutting down proxy") p.gracefulShutdown() @@ -231,7 +102,6 @@ func (p *DatabaseProxyServer) Run() { } } - // Track active connection p.activeConnections.Add(1) go p.handleConnection(conn) } @@ -274,7 +144,6 @@ func (p *DatabaseProxyServer) handleConnection(clientConn net.Conn) { gatewayErrCh, clientErrCh := p.NewDisconnectChannels() - // Gateway → Client: if this side closes first, the gateway dropped the connection go func() { defer connCancel() _, err := io.Copy(clientConn, gatewayConn) @@ -288,7 +157,6 @@ func (p *DatabaseProxyServer) handleConnection(clientConn net.Conn) { gatewayErrCh <- err }() - // Client → Gateway: if this side closes first, the client disconnected normally go func() { defer connCancel() _, err := io.Copy(gatewayConn, clientConn) From 7381996d3548a84496b1a2d2984669915155b0bc Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 17 Jun 2026 04:55:18 +0800 Subject: [PATCH 02/13] misc: addressed feedback --- packages/pam/local/access.go | 185 ++++++++++++++++++++++++++--------- 1 file changed, 141 insertions(+), 44 deletions(-) diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go index 665221da..fb482312 100644 --- a/packages/pam/local/access.go +++ b/packages/pam/local/access.go @@ -57,7 +57,7 @@ func StartPAMAccess(accessToken, path, reason, durationStr string, port int) { // Normalize path for display (ensure leading slash) displayPath := normalizePath(path) - log.Info().Msgf("Starting PAM access for: %s", displayPath) + log.Info().Msgf("Starting PAM access for: %s", strings.TrimPrefix(displayPath, "/")) log.Info().Msgf("Session duration: %s", durationStr) httpClient := resty.New() @@ -79,18 +79,13 @@ func StartPAMAccess(accessToken, path, reason, durationStr string, port int) { // Route based on account type from API response switch pamResponse.AccountType { - case AccountTypePostgres: - startPostgresProxy(httpClient, &pamResponse, displayPath, durationStr, port) + // Database types - all use the same proxy mechanism with different display configs + case AccountTypePostgres, AccountTypeMySQL, AccountTypeMsSQL, AccountTypeMongoDB, AccountTypeOracleDB: + startDatabaseProxy(httpClient, &pamResponse, displayPath, durationStr, port) + + // Non-database types - not yet implemented case AccountTypeSSH: util.PrintErrorMessageAndExit("SSH access not yet supported in the new PAM model") - case AccountTypeMySQL: - util.PrintErrorMessageAndExit("MySQL access not yet supported in the new PAM model") - case AccountTypeMsSQL: - util.PrintErrorMessageAndExit("MsSQL access not yet supported in the new PAM model") - case AccountTypeMongoDB: - util.PrintErrorMessageAndExit("MongoDB access not yet supported in the new PAM model") - case AccountTypeOracleDB: - util.PrintErrorMessageAndExit("OracleDB access not yet supported in the new PAM model") case AccountTypeRedis: util.PrintErrorMessageAndExit("Redis access not yet supported in the new PAM model") case AccountTypeKubernetes: @@ -106,14 +101,104 @@ func StartPAMAccess(accessToken, path, reason, durationStr string, port int) { } } -// startPostgresProxy starts a local Postgres proxy for the given session -func startPostgresProxy(httpClient *resty.Client, response *api.PAMAccessResponse, path, durationStr string, port int) { +// DatabaseDisplayConfig holds the display configuration for a database type +type DatabaseDisplayConfig struct { + TypeLabel string // e.g., "PostgreSQL", "MySQL", "SQL Server" + DefaultPort int // default port for this database type + ConnectionString func(username, database string, port int) string // builds the connection string + UsageExamples func(username, database string, port int) []string // CLI usage examples +} + +// databaseConfigs maps account types to their display configurations +var databaseConfigs = map[string]DatabaseDisplayConfig{ + AccountTypePostgres: { + TypeLabel: "PostgreSQL", + DefaultPort: 5432, + ConnectionString: func(username, database string, port int) string { + return fmt.Sprintf("postgres://%s@localhost:%d/%s", username, port, database) + }, + UsageExamples: func(username, database string, port int) []string { + return []string{ + fmt.Sprintf("psql -h localhost -p %d -U %s -d %s", port, username, database), + } + }, + }, + AccountTypeMySQL: { + TypeLabel: "MySQL", + DefaultPort: 3306, + ConnectionString: func(username, database string, port int) string { + return fmt.Sprintf("mysql://%s@localhost:%d/%s", username, port, database) + }, + UsageExamples: func(username, database string, port int) []string { + return []string{ + fmt.Sprintf("mysql -h 127.0.0.1 -P %d -u %s %s", port, username, database), + } + }, + }, + AccountTypeMsSQL: { + TypeLabel: "SQL Server", + DefaultPort: 1433, + ConnectionString: func(username, database string, port int) string { + return fmt.Sprintf("sqlserver://%s@localhost:%d?database=%s", username, port, database) + }, + UsageExamples: func(username, database string, port int) []string { + return []string{ + fmt.Sprintf("sqlcmd -S localhost,%d -U %s -d %s", port, username, database), + } + }, + }, + AccountTypeMongoDB: { + TypeLabel: "MongoDB", + DefaultPort: 27017, + ConnectionString: func(username, database string, port int) string { + return fmt.Sprintf("mongodb://localhost:%d/%s", port, database) + }, + UsageExamples: func(username, database string, port int) []string { + return []string{ + fmt.Sprintf("mongosh --host localhost --port %d %s", port, database), + } + }, + }, + AccountTypeOracleDB: { + TypeLabel: "Oracle", + DefaultPort: 1521, + ConnectionString: func(username, database string, port int) string { + return fmt.Sprintf("%s@localhost:%d/%s", username, port, database) + }, + UsageExamples: func(username, database string, port int) []string { + return []string{ + fmt.Sprintf("sqlplus %s@localhost:%d/%s", username, port, database), + } + }, + }, +} + +// startDatabaseProxy starts a local database proxy for any SQL-like database type +func startDatabaseProxy(httpClient *resty.Client, response *api.PAMAccessResponse, path, durationStr string, port int) { + config, ok := databaseConfigs[response.AccountType] + if !ok { + util.PrintErrorMessageAndExit(fmt.Sprintf("No display config for database type: %s", response.AccountType)) + return + } + duration, err := time.ParseDuration(durationStr) if err != nil { util.HandleError(err, "Failed to parse duration") return } + // Get connection details from metadata (validate before starting proxy) + username, ok := response.Metadata["username"] + if !ok { + util.HandleError(fmt.Errorf("PAM response metadata is missing 'username'"), "Failed to start proxy server") + return + } + database, ok := response.Metadata["database"] + if !ok { + util.HandleError(fmt.Errorf("PAM response metadata is missing 'database'"), "Failed to start proxy server") + return + } + ctx, cancel := context.WithCancel(context.Background()) proxy := &DatabaseProxyServer{ @@ -146,25 +231,30 @@ func startPostgresProxy(httpClient *resty.Client, response *api.PAMAccessRespons return } - // Get connection details from metadata - username, ok := response.Metadata["username"] - if !ok { - util.HandleError(fmt.Errorf("PAM response metadata is missing 'username'"), "Failed to start proxy server") - return - } - database, ok := response.Metadata["database"] - if !ok { - util.HandleError(fmt.Errorf("PAM response metadata is missing 'database'"), "Failed to start proxy server") - return - } - // Parse path into folder and account folder, account := parsePath(path) - log.Info().Msgf("Database proxy server listening on port %d", proxy.port) + log.Info().Msgf("%s proxy server listening on port %d", config.TypeLabel, proxy.port) + printDatabaseSessionInfo(config, folder, account, duration, username, database, proxy.port) + + // Handle shutdown signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + go func() { + sig := <-sigChan + log.Info().Msgf("Received signal %v, initiating graceful shutdown...", sig) + proxy.gracefulShutdown() + }() + + proxy.Run() +} + +// printDatabaseSessionInfo prints the connection info banner for database sessions +func printDatabaseSessionInfo(config DatabaseDisplayConfig, folder, account string, duration time.Duration, username, database string, port int) { fmt.Printf("\n") fmt.Printf("**********************************************************************\n") - fmt.Printf(" Database Proxy Session Started! \n") + fmt.Printf(" %s Proxy Session Started! \n", config.TypeLabel) fmt.Printf("**********************************************************************\n") fmt.Printf("\n") if folder != "" { @@ -178,29 +268,36 @@ func startPostgresProxy(httpClient *resty.Client, response *api.PAMAccessRespons fmt.Printf("----------------------------------------------------------------------\n") fmt.Printf("\n") fmt.Printf(" Host: localhost\n") - fmt.Printf(" Port: %d\n", proxy.port) - fmt.Printf(" Username: %s\n", username) + fmt.Printf(" Port: %d\n", port) + if username != "" { + fmt.Printf(" Username: %s\n", username) + } fmt.Printf(" Password: (injected by gateway)\n") - fmt.Printf(" Database: %s\n", database) + if database != "" { + fmt.Printf(" Database: %s\n", database) + } fmt.Printf("\n") fmt.Printf("----------------------------------------------------------------------\n") - fmt.Printf(" Connection String \n") + fmt.Printf(" How to Connect \n") fmt.Printf("----------------------------------------------------------------------\n") fmt.Printf("\n") - util.PrintfStderr(" postgres://%s@localhost:%d/%s\n", username, proxy.port, database) + fmt.Printf(" Use your preferred database client (CLI, GUI, or IDE) to connect\n") + fmt.Printf(" to localhost:%d. The password will be injected automatically.\n", port) + fmt.Printf("\n") + if config.UsageExamples != nil { + examples := config.UsageExamples(username, database, port) + if len(examples) > 0 { + fmt.Printf(" Example:\n") + for _, ex := range examples { + util.PrintfStderr(" $ %s\n", ex) + } + fmt.Printf("\n") + } + } + fmt.Printf(" Connection string:\n") + connStr := config.ConnectionString(username, database, port) + util.PrintfStderr(" %s\n", connStr) fmt.Printf("\n") fmt.Printf("**********************************************************************\n") fmt.Printf("\n") - - // Handle shutdown signals - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - go func() { - sig := <-sigChan - log.Info().Msgf("Received signal %v, initiating graceful shutdown...", sig) - proxy.gracefulShutdown() - }() - - proxy.Run() } From b4c53df67c8a23b87e6ff4d5f615b73af7c03342 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Fri, 19 Jun 2026 13:12:48 -0400 Subject: [PATCH 03/13] feat(pam): add RDP CLI access and fix session termination handling - Wire Windows/RDP case in StartPAMAccess to use RDPProxyServer - Use gateway disconnect detection so CLI exits on server-side session termination --- packages/pam/local/access.go | 95 ++++++++++++++++++++++++++++++++- packages/pam/local/rdp-proxy.go | 12 ++--- 2 files changed, 98 insertions(+), 9 deletions(-) diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go index fb482312..e2a5e9b3 100644 --- a/packages/pam/local/access.go +++ b/packages/pam/local/access.go @@ -93,7 +93,7 @@ func StartPAMAccess(accessToken, path, reason, durationStr string, port int) { case AccountTypeAwsIam: util.PrintErrorMessageAndExit("AWS IAM access not yet supported in the new PAM model") case AccountTypeWindows: - util.PrintErrorMessageAndExit("Windows/RDP access not yet supported in the new PAM model") + startRDPProxy(httpClient, &pamResponse, displayPath, durationStr, port) case AccountTypeActiveDirectory: util.PrintErrorMessageAndExit("Active Directory access not yet supported in the new PAM model") default: @@ -250,6 +250,99 @@ func startDatabaseProxy(httpClient *resty.Client, response *api.PAMAccessRespons proxy.Run() } +func startRDPProxy(httpClient *resty.Client, response *api.PAMAccessResponse, path, durationStr string, port int) { + duration, err := time.ParseDuration(durationStr) + if err != nil { + util.HandleError(err, "Failed to parse duration") + return + } + + username, ok := response.Metadata["username"] + if !ok { + util.HandleError(fmt.Errorf("PAM response metadata is missing 'username'"), "Failed to start RDP proxy") + return + } + + ctx, cancel := context.WithCancel(context.Background()) + + proxy := &RDPProxyServer{ + BaseProxyServer: 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{}), + }, + } + + if err := proxy.ValidateResourceTypeSupported(); err != nil { + util.HandleError(err, "Gateway version outdated") + return + } + + if err := proxy.Start(port); err != nil { + util.HandleError(err, "Failed to start RDP proxy server") + return + } + + rdpFilePath, err := writeRDPFile(proxy.port, response.SessionId, username) + if err != nil { + log.Warn().Err(err).Msg("Failed to write .rdp file; proxy still running") + } else { + proxy.rdpFilePath = rdpFilePath + } + + folder, account := parsePath(path) + + log.Info().Msgf("RDP proxy server listening on port %d", proxy.port) + fmt.Printf("\n") + fmt.Printf("**********************************************************************\n") + fmt.Printf(" RDP Proxy Session Started! \n") + fmt.Printf("**********************************************************************\n") + fmt.Printf("\n") + if folder != "" { + fmt.Printf(" Folder: %s\n", folder) + } + fmt.Printf(" Account: %s\n", account) + fmt.Printf(" Duration: %s\n", duration.String()) + fmt.Printf("\n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf(" Connection Details \n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf("\n") + fmt.Printf(" Host: 127.0.0.1\n") + fmt.Printf(" Port: %d\n", proxy.port) + fmt.Printf(" Username: %s\n", username) + fmt.Printf(" Password: (leave blank)\n") + if proxy.rdpFilePath != "" { + fmt.Printf("\n") + fmt.Printf(" .rdp file: %s\n", proxy.rdpFilePath) + } + fmt.Printf("\n") + fmt.Printf(" Press Ctrl+C to terminate the session.\n") + fmt.Printf("**********************************************************************\n") + fmt.Printf("\n") + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigChan + log.Info().Msgf("Received signal %v, initiating graceful shutdown...", sig) + proxy.gracefulShutdown() + }() + + proxy.Run() +} + // printDatabaseSessionInfo prints the connection info banner for database sessions func printDatabaseSessionInfo(config DatabaseDisplayConfig, folder, account string, duration time.Duration, username, database string, port int) { fmt.Printf("\n") diff --git a/packages/pam/local/rdp-proxy.go b/packages/pam/local/rdp-proxy.go index de021915..c8067a2c 100644 --- a/packages/pam/local/rdp-proxy.go +++ b/packages/pam/local/rdp-proxy.go @@ -266,7 +266,7 @@ func (p *RDPProxyServer) handleConnection(clientConn net.Conn) { connCtx, connCancel := context.WithCancel(p.ctx) defer connCancel() - done := make(chan struct{}, 2) + gatewayErrCh, clientErrCh := p.NewDisconnectChannels() go func() { defer connCancel() @@ -278,7 +278,7 @@ func (p *RDPProxyServer) handleConnection(clientConn net.Conn) { log.Debug().Err(err).Msg("Gateway to client copy ended") } } - done <- struct{}{} + gatewayErrCh <- err }() go func() { @@ -291,14 +291,10 @@ func (p *RDPProxyServer) handleConnection(clientConn net.Conn) { log.Debug().Err(err).Msg("Client to gateway copy ended") } } - done <- struct{}{} + clientErrCh <- err }() - select { - case <-done: - case <-connCtx.Done(): - log.Info().Msg("Connection cancelled by context") - } + p.WaitForDisconnect(gatewayErrCh, clientErrCh, connCtx) log.Info().Msgf("RDP connection closed for client: %s", clientConn.RemoteAddr().String()) } From 8ba72130213f04b072c5399c814e918d8e16dbc1 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Fri, 19 Jun 2026 15:25:55 -0400 Subject: [PATCH 04/13] fix(pam): write RDP session banner to stderr instead of stdout --- packages/pam/local/access.go | 46 ++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go index e2a5e9b3..785a7c05 100644 --- a/packages/pam/local/access.go +++ b/packages/pam/local/access.go @@ -304,33 +304,33 @@ func startRDPProxy(httpClient *resty.Client, response *api.PAMAccessResponse, pa folder, account := parsePath(path) log.Info().Msgf("RDP proxy server listening on port %d", proxy.port) - fmt.Printf("\n") - fmt.Printf("**********************************************************************\n") - fmt.Printf(" RDP Proxy Session Started! \n") - fmt.Printf("**********************************************************************\n") - fmt.Printf("\n") + util.PrintfStderr("\n") + util.PrintfStderr("**********************************************************************\n") + util.PrintfStderr(" RDP Proxy Session Started! \n") + util.PrintfStderr("**********************************************************************\n") + util.PrintfStderr("\n") if folder != "" { - fmt.Printf(" Folder: %s\n", folder) + util.PrintfStderr(" Folder: %s\n", folder) } - fmt.Printf(" Account: %s\n", account) - fmt.Printf(" Duration: %s\n", duration.String()) - fmt.Printf("\n") - fmt.Printf("----------------------------------------------------------------------\n") - fmt.Printf(" Connection Details \n") - fmt.Printf("----------------------------------------------------------------------\n") - fmt.Printf("\n") - fmt.Printf(" Host: 127.0.0.1\n") - fmt.Printf(" Port: %d\n", proxy.port) - fmt.Printf(" Username: %s\n", username) - fmt.Printf(" Password: (leave blank)\n") + util.PrintfStderr(" Account: %s\n", account) + util.PrintfStderr(" Duration: %s\n", duration.String()) + util.PrintfStderr("\n") + util.PrintfStderr("----------------------------------------------------------------------\n") + util.PrintfStderr(" Connection Details \n") + util.PrintfStderr("----------------------------------------------------------------------\n") + util.PrintfStderr("\n") + util.PrintfStderr(" Host: 127.0.0.1\n") + util.PrintfStderr(" Port: %d\n", proxy.port) + util.PrintfStderr(" Username: %s\n", username) + util.PrintfStderr(" Password: (leave blank)\n") if proxy.rdpFilePath != "" { - fmt.Printf("\n") - fmt.Printf(" .rdp file: %s\n", proxy.rdpFilePath) + util.PrintfStderr("\n") + util.PrintfStderr(" .rdp file: %s\n", proxy.rdpFilePath) } - fmt.Printf("\n") - fmt.Printf(" Press Ctrl+C to terminate the session.\n") - fmt.Printf("**********************************************************************\n") - fmt.Printf("\n") + util.PrintfStderr("\n") + util.PrintfStderr(" Press Ctrl+C to terminate the session.\n") + util.PrintfStderr("**********************************************************************\n") + util.PrintfStderr("\n") sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) From 82425cd15d136311c1a2a21e79f9e406513418ba Mon Sep 17 00:00:00 2001 From: bernie-g Date: Fri, 19 Jun 2026 16:45:15 -0400 Subject: [PATCH 05/13] fix(pam): resolve gateway disconnect race in WaitForDisconnect --- packages/pam/local/base-proxy.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/pam/local/base-proxy.go b/packages/pam/local/base-proxy.go index 0cc52108..394ec20b 100644 --- a/packages/pam/local/base-proxy.go +++ b/packages/pam/local/base-proxy.go @@ -297,9 +297,12 @@ func (b *BaseProxyServer) WaitForDisconnect(gatewayErrCh, clientErrCh <-chan err case <-gatewayErrCh: b.HandleGatewayDisconnect() case <-clientErrCh: - // Normal client disconnect, proxy stays running case <-connCtx.Done(): - log.Info().Msg("Connection cancelled by context") + select { + case <-gatewayErrCh: + b.HandleGatewayDisconnect() + default: + } } } From 8afd9cb06ee5989a8cb80c7ec1d0546749a8ab1c Mon Sep 17 00:00:00 2001 From: bernie-g Date: Mon, 22 Jun 2026 14:18:23 -0400 Subject: [PATCH 06/13] feat: add ssh cli access to pam revamp Wire SSH account type into the new path-based `pam access` flow. Starts a local TCP proxy and prints connection info with hint commands, matching the database proxy pattern. --- packages/pam/local/access.go | 103 ++++++++++++++- packages/pam/local/ssh-proxy.go | 220 +------------------------------- 2 files changed, 102 insertions(+), 221 deletions(-) diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go index fb482312..c513a27b 100644 --- a/packages/pam/local/access.go +++ b/packages/pam/local/access.go @@ -83,9 +83,8 @@ func StartPAMAccess(accessToken, path, reason, durationStr string, port int) { case AccountTypePostgres, AccountTypeMySQL, AccountTypeMsSQL, AccountTypeMongoDB, AccountTypeOracleDB: startDatabaseProxy(httpClient, &pamResponse, displayPath, durationStr, port) - // Non-database types - not yet implemented case AccountTypeSSH: - util.PrintErrorMessageAndExit("SSH access not yet supported in the new PAM model") + startSSHAccess(httpClient, &pamResponse, displayPath, durationStr, port) case AccountTypeRedis: util.PrintErrorMessageAndExit("Redis access not yet supported in the new PAM model") case AccountTypeKubernetes: @@ -250,6 +249,106 @@ func startDatabaseProxy(httpClient *resty.Client, response *api.PAMAccessRespons proxy.Run() } +func startSSHAccess(httpClient *resty.Client, response *api.PAMAccessResponse, path, durationStr string, port int) { + duration, err := time.ParseDuration(durationStr) + if err != nil { + util.HandleError(err, "Failed to parse duration") + return + } + + username, ok := response.Metadata["username"] + if !ok { + util.HandleError(fmt.Errorf("PAM response metadata is missing 'username'"), "Failed to start SSH session") + return + } + + ctx, cancel := context.WithCancel(context.Background()) + + proxy := &SSHProxyServer{ + BaseProxyServer: 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{}), + }, + } + + if err := proxy.ValidateResourceTypeSupported(); err != nil { + util.HandleError(err, "Gateway version outdated") + return + } + + err = proxy.Start(port) + if err != nil { + util.HandleError(err, "Failed to start SSH proxy server") + return + } + + folder, account := parsePath(path) + + log.Info().Msgf("SSH proxy server listening on port %d", proxy.port) + printSSHSessionInfo(folder, account, duration, username, proxy.port) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + go func() { + sig := <-sigChan + log.Info().Msgf("Received signal %v, initiating graceful shutdown...", sig) + proxy.gracefulShutdown() + }() + + proxy.Run() +} + +func printSSHSessionInfo(folder, account string, duration time.Duration, username string, port int) { + fmt.Printf("\n") + fmt.Printf("**********************************************************************\n") + fmt.Printf(" SSH Proxy Session Started! \n") + fmt.Printf("**********************************************************************\n") + fmt.Printf("\n") + if folder != "" { + fmt.Printf(" Folder: %s\n", folder) + } + fmt.Printf(" Account: %s\n", account) + fmt.Printf(" Duration: %s\n", duration.String()) + fmt.Printf("\n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf(" Connection Details \n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf("\n") + fmt.Printf(" Host: 127.0.0.1\n") + fmt.Printf(" Port: %d\n", port) + fmt.Printf(" Username: %s\n", username) + fmt.Printf(" Password: (injected by gateway)\n") + fmt.Printf("\n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf(" How to Connect \n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf("\n") + fmt.Printf(" Use your preferred SSH client to connect to 127.0.0.1:%d.\n", port) + fmt.Printf(" Credentials will be injected automatically by the gateway.\n") + fmt.Printf("\n") + fmt.Printf(" Examples:\n") + util.PrintfStderr(" $ ssh -p %d -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null %s@127.0.0.1\n", port, username) + util.PrintfStderr(" $ scp -P %d -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null %s@127.0.0.1:\n", port, username) + fmt.Printf("\n") + fmt.Printf(" Press Ctrl+C to stop the proxy.\n") + fmt.Printf("\n") + fmt.Printf("**********************************************************************\n") + fmt.Printf("\n") +} + // printDatabaseSessionInfo prints the connection info banner for database sessions func printDatabaseSessionInfo(config DatabaseDisplayConfig, folder, account string, duration time.Duration, username, database string, port int) { fmt.Printf("\n") diff --git a/packages/pam/local/ssh-proxy.go b/packages/pam/local/ssh-proxy.go index efa3ba88..cd026568 100644 --- a/packages/pam/local/ssh-proxy.go +++ b/packages/pam/local/ssh-proxy.go @@ -6,16 +6,8 @@ import ( "io" "net" "os" - "os/exec" - "os/signal" - "strconv" - "strings" - "syscall" "time" - "github.com/Infisical/infisical-merge/packages/pam/session" - "github.com/Infisical/infisical-merge/packages/util" - "github.com/go-resty/resty/v2" "github.com/rs/zerolog/log" ) @@ -23,138 +15,6 @@ type SSHProxyServer struct { BaseProxyServer // Embed common functionality server net.Listener port int - sshProcess *exec.Cmd - options SSHAccessOptions - sshExitCode int // Exit code from SSH process (for exec mode) -} - -// SSHAccessOptions configures SSH access behavior -type SSHAccessOptions struct { - ExecCommand string // If set, run this command instead of interactive shell - ProxyOnly bool // If true, start proxy without launching SSH client -} - -func StartSSHLocalProxy(accessToken string, accessParams PAMAccessParams, projectID string, durationStr string, options SSHAccessOptions) { - httpClient := resty.New() - httpClient.SetAuthToken(accessToken) - httpClient.SetHeader("User-Agent", "infisical-cli") - - pamRequest := accessParams.ToAPIRequest(projectID, durationStr) - - interactive := options.ExecCommand == "" - pamResponse, err := CallPAMAccessWithMFA(httpClient, pamRequest, interactive) - if err != nil { - if HandleApprovalWorkflow(httpClient, err, projectID, accessParams, durationStr) { - return - } - util.HandleError(err, "Failed to access PAM account") - return - } - - // Verify this is an SSH resource - if pamResponse.ResourceType != session.ResourceTypeSSH { - util.HandleError(fmt.Errorf("account is not an SSH resource, got: %s", pamResponse.ResourceType), "Invalid resource type") - return - } - - duration, err := time.ParseDuration(durationStr) - if err != nil { - util.HandleError(err, "Failed to parse duration") - return - } - - ctx, cancel := context.WithCancel(context.Background()) - - proxy := &SSHProxyServer{ - BaseProxyServer: BaseProxyServer{ - httpClient: httpClient, - relayHost: pamResponse.RelayHost, - relayClientCert: pamResponse.RelayClientCertificate, - relayClientKey: pamResponse.RelayClientPrivateKey, - relayServerCertChain: pamResponse.RelayServerCertificateChain, - gatewayClientCert: pamResponse.GatewayClientCertificate, - gatewayClientKey: pamResponse.GatewayClientPrivateKey, - gatewayServerCertChain: pamResponse.GatewayServerCertificateChain, - sessionExpiry: time.Now().Add(duration), - sessionId: pamResponse.SessionId, - resourceType: pamResponse.ResourceType, - ctx: ctx, - cancel: cancel, - shutdownCh: make(chan struct{}), - }, - options: options, - } - - if err := proxy.ValidateResourceTypeSupported(); err != nil { - util.HandleError(err, "Gateway version outdated") - return - } - - // Start the local TCP proxy on a random port - err = proxy.Start(0) // 0 = random port - if err != nil { - util.HandleError(err, "Failed to start SSH proxy server") - return - } - - // Extract metadata - username, ok := pamResponse.Metadata["username"] - if !ok { - util.HandleError(fmt.Errorf("PAM response metadata is missing 'username'"), "Failed to start proxy server") - return - } - - log.Debug(). - Str("sessionID", pamResponse.SessionId). - Str("username", username). - Int("port", proxy.port). - Msg("SSH proxy ready") - - // Set up signal handling - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - go func() { - sig := <-sigChan - log.Debug().Msgf("Received signal %v, initiating graceful shutdown...", sig) - proxy.gracefulShutdown() - }() - - // Start the proxy server in a goroutine - go proxy.Run() - - // Give the proxy a moment to start accepting connections - time.Sleep(500 * time.Millisecond) - - if options.ProxyOnly { - // Proxy-only mode: print connection info and wait - fmt.Printf("SSH proxy listening on 127.0.0.1:%d\n", proxy.port) - fmt.Printf("Username: %s\n", username) - fmt.Printf("Session expires: %s\n", proxy.sessionExpiry.Format(time.RFC3339)) - fmt.Println("") - fmt.Println("Use this proxy with SSH, SCP, SFTP, or rsync:") - fmt.Printf(" ssh -p %d -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null %s@127.0.0.1\n", proxy.port, username) - fmt.Printf(" scp -P %d -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null %s@127.0.0.1:\n", proxy.port, username) - fmt.Println("") - fmt.Println("Press Ctrl+C to stop the proxy.") - - // Wait for context cancellation (Ctrl+C triggers gracefulShutdown which cancels context) - <-proxy.ctx.Done() - } else { - // Launch SSH client connected to the local proxy (transparent to user) - err = proxy.launchSSHClient(username) - if err != nil { - log.Error().Err(err).Msg("Failed to launch SSH client") - proxy.gracefulShutdown() - return - } - - // Wait for SSH process to complete - proxy.waitForSSHCompletion() - - // SSH client exited, shutdown gracefully - proxy.gracefulShutdown() - } } func (p *SSHProxyServer) Start(port int) error { @@ -177,81 +37,10 @@ func (p *SSHProxyServer) Start(port int) error { return nil } -func (p *SSHProxyServer) launchSSHClient(username string) error { - // Build SSH command: ssh -p @localhost [command] - sshArgs := []string{ - "-p", strconv.Itoa(p.port), - "-o", "StrictHostKeyChecking=no", // Skip host key verification (we're connecting to localhost) - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", - fmt.Sprintf("%s@127.0.0.1", username), - } - - // If exec command is specified, append it (non-interactive mode) - if p.options.ExecCommand != "" { - sshArgs = append(sshArgs, p.options.ExecCommand) - } - - p.sshProcess = exec.Command("ssh", sshArgs...) - p.sshProcess.Stdin = os.Stdin - p.sshProcess.Stdout = os.Stdout - p.sshProcess.Stderr = os.Stderr - - log.Debug().Msgf("Executing: ssh %s", formatSSHArgs(sshArgs)) - - err := p.sshProcess.Start() - if err != nil { - return fmt.Errorf("failed to start SSH client: %w", err) - } - - log.Debug().Msgf("SSH client started with PID: %d", p.sshProcess.Process.Pid) - return nil -} - -// formatSSHArgs formats SSH arguments for logging, quoting args with spaces -func formatSSHArgs(args []string) string { - formatted := make([]string, len(args)) - for i, arg := range args { - if strings.ContainsRune(arg, ' ') { - formatted[i] = fmt.Sprintf("%q", arg) - } else { - formatted[i] = arg - } - } - return strings.Join(formatted, " ") -} - -func (p *SSHProxyServer) waitForSSHCompletion() { - if p.sshProcess == nil { - return - } - - err := p.sshProcess.Wait() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - p.sshExitCode = exitErr.ExitCode() - log.Debug().Msgf("SSH client exited with code: %d", p.sshExitCode) - } else { - log.Error().Err(err).Msg("Error waiting for SSH client") - p.sshExitCode = 1 - } - } else { - p.sshExitCode = 0 - log.Debug().Msg("SSH client exited successfully") - } -} - func (p *SSHProxyServer) gracefulShutdown() { p.shutdownOnce.Do(func() { log.Debug().Msg("Starting graceful shutdown of SSH proxy...") - // Kill SSH process if it's still running - if p.sshProcess != nil && p.sshProcess.Process != nil { - log.Debug().Msg("Terminating SSH client process") - p.sshProcess.Process.Signal(syscall.SIGTERM) - } - - // Send session termination notification before cancelling context p.NotifySessionTermination() // Signal the accept loop to stop @@ -269,14 +58,7 @@ func (p *SSHProxyServer) gracefulShutdown() { p.WaitForConnectionsWithTimeout(10 * time.Second) log.Debug().Msg("SSH proxy shutdown complete") - - // Only propagate SSH exit code in exec mode (non-interactive) - // For interactive sessions, always exit 0 on clean shutdown - exitCode := 0 - if p.options.ExecCommand != "" { - exitCode = p.sshExitCode - } - os.Exit(exitCode) + os.Exit(0) }) } From da3f91b38391848713d3c677188cef8ef14dd374 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Mon, 22 Jun 2026 14:26:30 -0400 Subject: [PATCH 07/13] fix: guard empty username in SSH session banner Match the database banner's `if username != ""` check for consistency. --- packages/pam/local/access.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go index c513a27b..4dfd0a21 100644 --- a/packages/pam/local/access.go +++ b/packages/pam/local/access.go @@ -329,7 +329,9 @@ func printSSHSessionInfo(folder, account string, duration time.Duration, usernam fmt.Printf("\n") fmt.Printf(" Host: 127.0.0.1\n") fmt.Printf(" Port: %d\n", port) - fmt.Printf(" Username: %s\n", username) + if username != "" { + fmt.Printf(" Username: %s\n", username) + } fmt.Printf(" Password: (injected by gateway)\n") fmt.Printf("\n") fmt.Printf("----------------------------------------------------------------------\n") From b9e31158724393ee2770ddfff32fde7161b47a6e Mon Sep 17 00:00:00 2001 From: bernie-g Date: Mon, 22 Jun 2026 18:13:51 -0400 Subject: [PATCH 08/13] fix: remove "injected" terminology from SSH session banner --- packages/pam/local/access.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go index 4dfd0a21..0d073af5 100644 --- a/packages/pam/local/access.go +++ b/packages/pam/local/access.go @@ -332,14 +332,13 @@ func printSSHSessionInfo(folder, account string, duration time.Duration, usernam if username != "" { fmt.Printf(" Username: %s\n", username) } - fmt.Printf(" Password: (injected by gateway)\n") fmt.Printf("\n") fmt.Printf("----------------------------------------------------------------------\n") fmt.Printf(" How to Connect \n") fmt.Printf("----------------------------------------------------------------------\n") fmt.Printf("\n") fmt.Printf(" Use your preferred SSH client to connect to 127.0.0.1:%d.\n", port) - fmt.Printf(" Credentials will be injected automatically by the gateway.\n") + fmt.Printf(" Credentials are handled automatically by the gateway.\n") fmt.Printf("\n") fmt.Printf(" Examples:\n") util.PrintfStderr(" $ ssh -p %d -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null %s@127.0.0.1\n", port, username) From 3ca627782d39c4da3eeba667d2dd33306636dc2d Mon Sep 17 00:00:00 2001 From: Saif Ur Rahman Date: Wed, 24 Jun 2026 20:02:17 +0530 Subject: [PATCH 09/13] feat(pam): add Kubernetes CLI access (#273) * feat(pam): add Kubernetes CLI access Wire KubernetesProxyServer into the new path-based pam access command, extract kubeconfig setup into a reusable SetupKubeconfig method, and add a Kubernetes-specific session banner. * fix(pam): remove misleading password injection copy from CLI banner * chore(pam): delete unused StartKubernetesLocalProxy --------- Co-authored-by: saif <11242541+saifsmailbox98@users.noreply.github.com> --- packages/pam/local/access.go | 96 +++++++++++++++++++- packages/pam/local/kubernetes-proxy.go | 121 ++----------------------- 2 files changed, 101 insertions(+), 116 deletions(-) diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go index 0d073af5..9fda3c43 100644 --- a/packages/pam/local/access.go +++ b/packages/pam/local/access.go @@ -88,7 +88,7 @@ func StartPAMAccess(accessToken, path, reason, durationStr string, port int) { case AccountTypeRedis: util.PrintErrorMessageAndExit("Redis access not yet supported in the new PAM model") case AccountTypeKubernetes: - util.PrintErrorMessageAndExit("Kubernetes access not yet supported in the new PAM model") + startKubernetesProxy(httpClient, &pamResponse, displayPath, durationStr, port) case AccountTypeAwsIam: util.PrintErrorMessageAndExit("AWS IAM access not yet supported in the new PAM model") case AccountTypeWindows: @@ -372,7 +372,7 @@ func printDatabaseSessionInfo(config DatabaseDisplayConfig, folder, account stri if username != "" { fmt.Printf(" Username: %s\n", username) } - fmt.Printf(" Password: (injected by gateway)\n") + fmt.Printf(" Password: (not required)\n") if database != "" { fmt.Printf(" Database: %s\n", database) } @@ -382,7 +382,7 @@ func printDatabaseSessionInfo(config DatabaseDisplayConfig, folder, account stri fmt.Printf("----------------------------------------------------------------------\n") fmt.Printf("\n") fmt.Printf(" Use your preferred database client (CLI, GUI, or IDE) to connect\n") - fmt.Printf(" to localhost:%d. The password will be injected automatically.\n", port) + fmt.Printf(" to localhost:%d. No password is needed.\n", port) fmt.Printf("\n") if config.UsageExamples != nil { examples := config.UsageExamples(username, database, port) @@ -401,3 +401,93 @@ func printDatabaseSessionInfo(config DatabaseDisplayConfig, folder, account stri fmt.Printf("**********************************************************************\n") fmt.Printf("\n") } + +func startKubernetesProxy(httpClient *resty.Client, response *api.PAMAccessResponse, path, durationStr string, port int) { + duration, err := time.ParseDuration(durationStr) + if err != nil { + util.HandleError(err, "Failed to parse duration") + return + } + + ctx, cancel := context.WithCancel(context.Background()) + + proxy := &KubernetesProxyServer{ + BaseProxyServer: 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{}), + }, + } + + if err := proxy.ValidateResourceTypeSupported(); err != nil { + util.HandleError(err, "Gateway version outdated") + return + } + + err = proxy.Start(port) + if err != nil { + util.HandleError(err, "Failed to start proxy server") + return + } + + folder, account := parsePath(path) + clusterName := fmt.Sprintf("infisical-k8s-pam/%s/%s", folder, account) + + if err := proxy.SetupKubeconfig(clusterName); err != nil { + util.HandleError(err, "Failed to configure kubeconfig") + proxy.gracefulShutdown() + return + } + + log.Info().Msgf("Kubernetes proxy server listening on port %d", proxy.port) + printKubernetesSessionInfo(folder, account, duration, clusterName, proxy.port) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + go func() { + sig := <-sigChan + log.Info().Msgf("Received signal %v, initiating graceful shutdown...", sig) + proxy.gracefulShutdown() + }() + + proxy.Run() +} + +func printKubernetesSessionInfo(folder, account string, duration time.Duration, clusterName string, port int) { + fmt.Printf("\n") + fmt.Printf("**********************************************************************\n") + fmt.Printf(" Kubernetes Proxy Session Started! \n") + fmt.Printf("**********************************************************************\n") + fmt.Printf("\n") + if folder != "" { + fmt.Printf(" Folder: %s\n", folder) + } + fmt.Printf(" Account: %s\n", account) + fmt.Printf(" Duration: %s\n", duration.String()) + fmt.Printf("\n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf(" Connection Details \n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf("\n") + fmt.Printf(" Your kubectl context has been switched to: %s\n", clusterName) + fmt.Printf(" You can now use kubectl commands to access your Kubernetes cluster.\n") + fmt.Printf("\n") + fmt.Printf(" Example:\n") + util.PrintfStderr(" $ kubectl get pods\n") + util.PrintfStderr(" $ kubectl get namespaces\n") + fmt.Printf("\n") + fmt.Printf("**********************************************************************\n") + fmt.Printf("\n") +} diff --git a/packages/pam/local/kubernetes-proxy.go b/packages/pam/local/kubernetes-proxy.go index 0e94fe72..6bb87a98 100644 --- a/packages/pam/local/kubernetes-proxy.go +++ b/packages/pam/local/kubernetes-proxy.go @@ -6,12 +6,8 @@ import ( "io" "net" "os" - "os/signal" - "syscall" "time" - "github.com/Infisical/infisical-merge/packages/util" - "github.com/go-resty/resty/v2" "github.com/rs/zerolog/log" "k8s.io/client-go/tools/clientcmd" k8sapi "k8s.io/client-go/tools/clientcmd/api" @@ -26,133 +22,32 @@ type KubernetesProxyServer struct { kubeConfigOriginalContext string } -func StartKubernetesLocalProxy(accessToken string, accessParams PAMAccessParams, projectId, durationStr string, port int) { - log.Info(). - Str("projectId", projectId). - Str("account", accessParams.GetDisplayName()). - Str("duration", durationStr). - Msg("Starting kubernetes proxy") - httpClient := resty.New() - httpClient.SetAuthToken(accessToken) - httpClient.SetHeader("User-Agent", "infisical-cli") - - pamRequest := accessParams.ToAPIRequest(projectId, durationStr) - - pamResponse, err := CallPAMAccessWithMFA(httpClient, pamRequest, true) - if err != nil { - if HandleApprovalWorkflow(httpClient, err, projectId, accessParams, durationStr) { - return - } - util.HandleError(err, "Failed to access PAM account") - return - } - - if pamResponse.ResourceType != "kubernetes" { - util.HandleError(err, "Invalid PAM response type, expected kubernetes but got %s", pamResponse.ResourceType) - return - } - - log.Info().Msgf("Kubernetes session created with ID: %s", pamResponse.SessionId) - - duration, err := time.ParseDuration(durationStr) - if err != nil { - util.HandleError(err, "Failed to parse duration") - return - } - - ctx, cancel := context.WithCancel(context.Background()) - - proxy := &KubernetesProxyServer{ - BaseProxyServer: BaseProxyServer{ - httpClient: httpClient, - relayHost: pamResponse.RelayHost, - relayClientCert: pamResponse.RelayClientCertificate, - relayClientKey: pamResponse.RelayClientPrivateKey, - relayServerCertChain: pamResponse.RelayServerCertificateChain, - gatewayClientCert: pamResponse.GatewayClientCertificate, - gatewayClientKey: pamResponse.GatewayClientPrivateKey, - gatewayServerCertChain: pamResponse.GatewayServerCertificateChain, - sessionExpiry: time.Now().Add(duration), - sessionId: pamResponse.SessionId, - resourceType: pamResponse.ResourceType, - ctx: ctx, - cancel: cancel, - shutdownCh: make(chan struct{}), - }, - } - - if err := proxy.ValidateResourceTypeSupported(); err != nil { - util.HandleError(err, "Gateway version outdated") - return - } - - err = proxy.Start(port) - if err != nil { - util.HandleError(err, "Failed to start proxy server") - return - } - - if port == 0 { - fmt.Printf("Kubernetes proxy started for account %s with duration %s on port %d (auto-assigned)\n", accessParams.GetDisplayName(), duration.String(), proxy.port) - } else { - fmt.Printf("Kubernetes proxy started for account %s with duration %s on port %d\n", accessParams.GetDisplayName(), duration.String(), proxy.port) - } - - // TODO: we should let the user decide whether if they want to update kubeconfig or not - // TODO: ideally, lock the files to avoid others from writing to it - // TODO: use clientcmd.ModifyConfig instead? +func (p *KubernetesProxyServer) SetupKubeconfig(clusterName string) error { configLoader := clientcmd.NewDefaultClientConfigLoadingRules() config, err := configLoader.Load() if err != nil { - log.Fatal().Err(err).Msg("Failed to load kubernetes config") - return + return fmt.Errorf("failed to load kubernetes config: %w", err) } - // Build cluster name for kubeconfig context - clusterName := fmt.Sprintf("infisical-k8s-pam/%s/%s", accessParams.ResourceName, accessParams.AccountName) - config.Clusters[clusterName] = &k8sapi.Cluster{ - Server: fmt.Sprintf("http://localhost:%d", proxy.port), + Server: fmt.Sprintf("http://localhost:%d", p.port), } config.AuthInfos[clusterName] = &k8sapi.AuthInfo{} config.Contexts[clusterName] = &k8sapi.Context{ Cluster: clusterName, AuthInfo: clusterName, } - proxy.kubeConfigOriginalContext = config.CurrentContext + p.kubeConfigOriginalContext = config.CurrentContext config.CurrentContext = clusterName kubeconfig := configLoader.GetDefaultFilename() if err = clientcmd.WriteToFile(*config, kubeconfig); err != nil { - log.Fatal().Err(err).Str("kubeconfig", kubeconfig).Msg("Failed to write kubernetes config") - return + return fmt.Errorf("failed to write kubernetes config: %w", err) } log.Info().Str("kubeconfig", kubeconfig).Msg("Updated kubeconfig file") - proxy.kubeConfigClusterName = clusterName - proxy.kubeConfigPath = kubeconfig - - log.Info().Msgf("Kubernetes proxy server listening on port %d", proxy.port) - fmt.Printf("\n") - fmt.Printf("**********************************************************************\n") - fmt.Printf(" Kubernetes Proxy Session Started! \n") - fmt.Printf("----------------------------------------------------------------------\n") - fmt.Printf("Resource: %s\n", accessParams.ResourceName) - fmt.Printf("Account: %s\n", accessParams.AccountName) - fmt.Printf("\nYour kubectl context has been switched to: %s\n", clusterName) - fmt.Printf("You can now use kubectl commands to access your Kubernetes cluster.\n") - fmt.Printf("\n") - // TODO: write kubectl config - - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - go func() { - sig := <-sigChan - log.Info().Msgf("Received signal %v, initiating graceful shutdown...", sig) - proxy.gracefulShutdown() - }() - - proxy.Run() + p.kubeConfigClusterName = clusterName + p.kubeConfigPath = kubeconfig + return nil } func (p *KubernetesProxyServer) Start(port int) error { From ea50b08b963876e5e8f02fc5b3c0942859fdf29f Mon Sep 17 00:00:00 2001 From: Saif Ur Rahman Date: Thu, 25 Jun 2026 01:39:47 +0530 Subject: [PATCH 10/13] feat(pam): add MFA and interactive reason support to new access flow (#276) Switch access.go to use CallPAMAccessWithMFA instead of direct CallPAMAccess. This adds interactive reason prompting (catches PAM_REASON_REQUIRED, prompts with PromptForReason) and MFA handling (catches SESSION_MFA_REQUIRED, opens browser, polls) to the new path-based access flow for all account types. Co-authored-by: saif <11242541+saifsmailbox98@users.noreply.github.com> --- packages/pam/local/access.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go index 9fda3c43..53c0dcbe 100644 --- a/packages/pam/local/access.go +++ b/packages/pam/local/access.go @@ -64,11 +64,11 @@ func StartPAMAccess(accessToken, path, reason, durationStr string, port int) { httpClient.SetAuthToken(accessToken) httpClient.SetHeader("User-Agent", api.USER_AGENT) - pamResponse, err := api.CallPAMAccess(httpClient, api.PAMAccessRequest{ + pamResponse, err := CallPAMAccessWithMFA(httpClient, api.PAMAccessRequest{ Path: path, Duration: durationStr, Reason: reason, - }) + }, true) if err != nil { util.HandleError(err, "Failed to create PAM session") return From e5be6cb1befdfcbda9337132323ab4f9aa953c70 Mon Sep 17 00:00:00 2001 From: Vlad Ligai Date: Thu, 25 Jun 2026 19:27:43 -0400 Subject: [PATCH 11/13] fix(pam): bind local proxies to loopback The database, redis, and kubernetes local PAM proxies created their TCP listener with an empty host (":0" / ":"), which Go resolves to all interfaces. These proxies are only meant to be used by the client on the same machine, so they should not be reachable from elsewhere. Bind the listeners to 127.0.0.1 so they accept local connections only, and advertise 127.0.0.1 in the printed connection strings and kubeconfig so the client connects to exactly the address the proxy listens on. This matches the ssh and rdp proxies, which already bind loopback. --- packages/pam/local/access.go | 22 ++++++------ packages/pam/local/database-proxy.go | 4 +-- packages/pam/local/kubernetes-proxy.go | 6 ++-- packages/pam/local/proxy_loopback_test.go | 41 +++++++++++++++++++++++ packages/pam/local/redis-proxy.go | 8 ++--- 5 files changed, 61 insertions(+), 20 deletions(-) create mode 100644 packages/pam/local/proxy_loopback_test.go diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go index 53c0dcbe..10be3ef0 100644 --- a/packages/pam/local/access.go +++ b/packages/pam/local/access.go @@ -114,11 +114,11 @@ var databaseConfigs = map[string]DatabaseDisplayConfig{ TypeLabel: "PostgreSQL", DefaultPort: 5432, ConnectionString: func(username, database string, port int) string { - return fmt.Sprintf("postgres://%s@localhost:%d/%s", username, port, database) + return fmt.Sprintf("postgres://%s@127.0.0.1:%d/%s", username, port, database) }, UsageExamples: func(username, database string, port int) []string { return []string{ - fmt.Sprintf("psql -h localhost -p %d -U %s -d %s", port, username, database), + fmt.Sprintf("psql -h 127.0.0.1 -p %d -U %s -d %s", port, username, database), } }, }, @@ -126,7 +126,7 @@ var databaseConfigs = map[string]DatabaseDisplayConfig{ TypeLabel: "MySQL", DefaultPort: 3306, ConnectionString: func(username, database string, port int) string { - return fmt.Sprintf("mysql://%s@localhost:%d/%s", username, port, database) + return fmt.Sprintf("mysql://%s@127.0.0.1:%d/%s", username, port, database) }, UsageExamples: func(username, database string, port int) []string { return []string{ @@ -138,11 +138,11 @@ var databaseConfigs = map[string]DatabaseDisplayConfig{ TypeLabel: "SQL Server", DefaultPort: 1433, ConnectionString: func(username, database string, port int) string { - return fmt.Sprintf("sqlserver://%s@localhost:%d?database=%s", username, port, database) + return fmt.Sprintf("sqlserver://%s@127.0.0.1:%d?database=%s", username, port, database) }, UsageExamples: func(username, database string, port int) []string { return []string{ - fmt.Sprintf("sqlcmd -S localhost,%d -U %s -d %s", port, username, database), + fmt.Sprintf("sqlcmd -S 127.0.0.1,%d -U %s -d %s", port, username, database), } }, }, @@ -150,11 +150,11 @@ var databaseConfigs = map[string]DatabaseDisplayConfig{ TypeLabel: "MongoDB", DefaultPort: 27017, ConnectionString: func(username, database string, port int) string { - return fmt.Sprintf("mongodb://localhost:%d/%s", port, database) + return fmt.Sprintf("mongodb://127.0.0.1:%d/%s", port, database) }, UsageExamples: func(username, database string, port int) []string { return []string{ - fmt.Sprintf("mongosh --host localhost --port %d %s", port, database), + fmt.Sprintf("mongosh --host 127.0.0.1 --port %d %s", port, database), } }, }, @@ -162,11 +162,11 @@ var databaseConfigs = map[string]DatabaseDisplayConfig{ TypeLabel: "Oracle", DefaultPort: 1521, ConnectionString: func(username, database string, port int) string { - return fmt.Sprintf("%s@localhost:%d/%s", username, port, database) + return fmt.Sprintf("%s@127.0.0.1:%d/%s", username, port, database) }, UsageExamples: func(username, database string, port int) []string { return []string{ - fmt.Sprintf("sqlplus %s@localhost:%d/%s", username, port, database), + fmt.Sprintf("sqlplus %s@127.0.0.1:%d/%s", username, port, database), } }, }, @@ -367,7 +367,7 @@ func printDatabaseSessionInfo(config DatabaseDisplayConfig, folder, account stri fmt.Printf(" Connection Details \n") fmt.Printf("----------------------------------------------------------------------\n") fmt.Printf("\n") - fmt.Printf(" Host: localhost\n") + fmt.Printf(" Host: 127.0.0.1\n") fmt.Printf(" Port: %d\n", port) if username != "" { fmt.Printf(" Username: %s\n", username) @@ -382,7 +382,7 @@ func printDatabaseSessionInfo(config DatabaseDisplayConfig, folder, account stri fmt.Printf("----------------------------------------------------------------------\n") fmt.Printf("\n") fmt.Printf(" Use your preferred database client (CLI, GUI, or IDE) to connect\n") - fmt.Printf(" to localhost:%d. No password is needed.\n", port) + fmt.Printf(" to 127.0.0.1:%d. No password is needed.\n", port) fmt.Printf("\n") if config.UsageExamples != nil { examples := config.UsageExamples(username, database, port) diff --git a/packages/pam/local/database-proxy.go b/packages/pam/local/database-proxy.go index 872a13ee..27209336 100644 --- a/packages/pam/local/database-proxy.go +++ b/packages/pam/local/database-proxy.go @@ -28,9 +28,9 @@ const ( func (p *DatabaseProxyServer) Start(port int) error { var err error if port == 0 { - p.server, err = net.Listen("tcp", ":0") + p.server, err = net.Listen("tcp", "127.0.0.1:0") // Bind to 127.0.0.1 only } else { - p.server, err = net.Listen("tcp", fmt.Sprintf(":%d", port)) + p.server, err = net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) } if err != nil { diff --git a/packages/pam/local/kubernetes-proxy.go b/packages/pam/local/kubernetes-proxy.go index 6bb87a98..1d94c594 100644 --- a/packages/pam/local/kubernetes-proxy.go +++ b/packages/pam/local/kubernetes-proxy.go @@ -31,7 +31,7 @@ func (p *KubernetesProxyServer) SetupKubeconfig(clusterName string) error { } config.Clusters[clusterName] = &k8sapi.Cluster{ - Server: fmt.Sprintf("http://localhost:%d", p.port), + Server: fmt.Sprintf("http://127.0.0.1:%d", p.port), } config.AuthInfos[clusterName] = &k8sapi.AuthInfo{} config.Contexts[clusterName] = &k8sapi.Context{ @@ -53,9 +53,9 @@ func (p *KubernetesProxyServer) SetupKubeconfig(clusterName string) error { func (p *KubernetesProxyServer) Start(port int) error { var err error if port == 0 { - p.server, err = net.Listen("tcp", ":0") + p.server, err = net.Listen("tcp", "127.0.0.1:0") // Bind to 127.0.0.1 only } else { - p.server, err = net.Listen("tcp", fmt.Sprintf(":%d", port)) + p.server, err = net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) } if err != nil { diff --git a/packages/pam/local/proxy_loopback_test.go b/packages/pam/local/proxy_loopback_test.go new file mode 100644 index 00000000..92e0c473 --- /dev/null +++ b/packages/pam/local/proxy_loopback_test.go @@ -0,0 +1,41 @@ +package pam + +import ( + "net" + "testing" +) + +// TestLocalProxiesBindLoopback guards that the local PAM proxies bind to a +// loopback address rather than all interfaces. Start() only creates the +// listener (the accept loop lives in Run), so it can be exercised in isolation +// without a gateway or an active session. +func TestLocalProxiesBindLoopback(t *testing.T) { + cases := []struct { + name string + start func() (net.Listener, error) + }{ + {"database", func() (net.Listener, error) { p := &DatabaseProxyServer{}; err := p.Start(0); return p.server, err }}, + {"redis", func() (net.Listener, error) { p := &RedisProxyServer{}; err := p.Start(0); return p.server, err }}, + {"kubernetes", func() (net.Listener, error) { p := &KubernetesProxyServer{}; err := p.Start(0); return p.server, err }}, + {"ssh", func() (net.Listener, error) { p := &SSHProxyServer{}; err := p.Start(0); return p.server, err }}, + {"rdp", func() (net.Listener, error) { p := &RDPProxyServer{}; err := p.Start(0); return p.server, err }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ln, err := tc.start() + if err != nil { + t.Fatalf("Start: %v", err) + } + defer func() { _ = ln.Close() }() + + addr, ok := ln.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("unexpected listener address type %T", ln.Addr()) + } + if !addr.IP.IsLoopback() { + t.Fatalf("%s proxy bound to %s; must bind a loopback address, not all interfaces", tc.name, addr.IP) + } + }) + } +} diff --git a/packages/pam/local/redis-proxy.go b/packages/pam/local/redis-proxy.go index 901f1659..5e26defa 100644 --- a/packages/pam/local/redis-proxy.go +++ b/packages/pam/local/redis-proxy.go @@ -107,9 +107,9 @@ func StartRedisLocalProxy(accessToken string, accessParams PAMAccessParams, proj util.PrintfStderr("\n") util.PrintfStderr("You can now connect to your Redis instance using:\n") if username != "" { - util.PrintfStderr("redis://%s@localhost:%d", username, proxy.port) + util.PrintfStderr("redis://%s@127.0.0.1:%d", username, proxy.port) } else { - util.PrintfStderr("redis://localhost:%d", proxy.port) + util.PrintfStderr("redis://127.0.0.1:%d", proxy.port) } util.PrintfStderr("\n**********************************************************************\n") util.PrintfStderr("\n") @@ -129,9 +129,9 @@ func StartRedisLocalProxy(accessToken string, accessParams PAMAccessParams, proj func (p *RedisProxyServer) Start(port int) error { var err error if port == 0 { - p.server, err = net.Listen("tcp", ":0") + p.server, err = net.Listen("tcp", "127.0.0.1:0") // Bind to 127.0.0.1 only } else { - p.server, err = net.Listen("tcp", fmt.Sprintf(":%d", port)) + p.server, err = net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) } if err != nil { From ad22cf2296aba353d172dc7c8ad3982a7840a344 Mon Sep 17 00:00:00 2001 From: x Date: Sat, 27 Jun 2026 05:08:08 -0400 Subject: [PATCH 12/13] target flag + domain acc tweaks --- packages/api/model.go | 5 ++-- packages/cmd/pam.go | 8 ++++++- packages/pam/local/access.go | 44 ++++++++++++++++++------------------ 3 files changed, 32 insertions(+), 25 deletions(-) diff --git a/packages/api/model.go b/packages/api/model.go index b96ba3fd..61d39dce 100644 --- a/packages/api/model.go +++ b/packages/api/model.go @@ -862,8 +862,9 @@ type PAMAccessRequest struct { MfaSessionId string `json:"mfaSessionId,omitempty"` // Common fields - Duration string `json:"duration,omitempty"` - Reason string `json:"reason,omitempty"` + Duration string `json:"duration,omitempty"` + Reason string `json:"reason,omitempty"` + TargetHost string `json:"targetHost,omitempty"` } type PAMAccessResponse struct { diff --git a/packages/cmd/pam.go b/packages/cmd/pam.go index d40d4b6b..3f3f0bf9 100644 --- a/packages/cmd/pam.go +++ b/packages/cmd/pam.go @@ -49,6 +49,11 @@ The path format is: /folder/account-name (leading slash optional)`, util.HandleError(err, "Unable to parse port flag") } + targetHost, err := cmd.Flags().GetString("target") + if err != nil { + util.HandleError(err, "Unable to parse target flag") + } + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { util.HandleError(err, "Unable to get logged in user details") @@ -59,7 +64,7 @@ The path format is: /folder/account-name (leading slash optional)`, loggedInUserDetails = util.EstablishUserLoginSession() } - pam.StartPAMAccess(loggedInUserDetails.UserCredentials.JTWToken, path, reason, durationStr, port) + pam.StartPAMAccess(loggedInUserDetails.UserCredentials.JTWToken, path, reason, durationStr, targetHost, port) }, } @@ -67,6 +72,7 @@ func init() { pamAccessCmd.Flags().String("reason", "", "Reason for accessing the account (stored for audit purposes)") 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)") pamCmd.AddCommand(pamAccessCmd) RootCmd.AddCommand(pamCmd) diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go index 41284164..b37972d3 100644 --- a/packages/pam/local/access.go +++ b/packages/pam/local/access.go @@ -17,17 +17,17 @@ import ( // Account type constants (match API enum) const ( - AccountTypePostgres = "postgres" - AccountTypeSSH = "ssh" - AccountTypeMySQL = "mysql" - AccountTypeMsSQL = "mssql" - AccountTypeMongoDB = "mongodb" - AccountTypeOracleDB = "oracledb" - AccountTypeRedis = "redis" - AccountTypeKubernetes = "kubernetes" - AccountTypeAwsIam = "aws-iam" - AccountTypeWindows = "windows" - AccountTypeActiveDirectory = "active-directory" + AccountTypePostgres = "postgres" + AccountTypeSSH = "ssh" + AccountTypeMySQL = "mysql" + AccountTypeMsSQL = "mssql" + AccountTypeMongoDB = "mongodb" + AccountTypeOracleDB = "oracledb" + AccountTypeRedis = "redis" + AccountTypeKubernetes = "kubernetes" + AccountTypeAwsIam = "aws-iam" + AccountTypeWindows = "windows" + AccountTypeWindowsAd = "windows-ad" ) // normalizePath ensures the path has a leading slash for display purposes. @@ -53,7 +53,7 @@ func parsePath(path string) (folder, account 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 string, port int) { +func StartPAMAccess(accessToken, path, reason, durationStr, targetHost string, port int) { // Normalize path for display (ensure leading slash) displayPath := normalizePath(path) @@ -65,9 +65,10 @@ func StartPAMAccess(accessToken, path, reason, durationStr string, port int) { httpClient.SetHeader("User-Agent", api.USER_AGENT) pamResponse, err := CallPAMAccessWithMFA(httpClient, api.PAMAccessRequest{ - Path: path, - Duration: durationStr, - Reason: reason, + Path: path, + Duration: durationStr, + Reason: reason, + TargetHost: targetHost, }, true) if err != nil { util.HandleError(err, "Failed to create PAM session") @@ -91,10 +92,8 @@ func StartPAMAccess(accessToken, path, reason, durationStr string, port int) { startKubernetesProxy(httpClient, &pamResponse, displayPath, durationStr, port) case AccountTypeAwsIam: util.PrintErrorMessageAndExit("AWS IAM access not yet supported in the new PAM model") - case AccountTypeWindows: + case AccountTypeWindows, AccountTypeWindowsAd: startRDPProxy(httpClient, &pamResponse, displayPath, durationStr, port) - case AccountTypeActiveDirectory: - util.PrintErrorMessageAndExit("Active Directory access not yet supported in the new PAM model") default: util.PrintErrorMessageAndExit(fmt.Sprintf("Unsupported account type: %s", pamResponse.AccountType)) } @@ -276,10 +275,11 @@ func startRDPProxy(httpClient *resty.Client, response *api.PAMAccessResponse, pa gatewayServerCertChain: response.GatewayServerCertificateChain, sessionExpiry: time.Now().Add(duration), sessionId: response.SessionId, - resourceType: response.AccountType, - ctx: ctx, - cancel: cancel, - shutdownCh: make(chan struct{}), + // Windows AD is brokered through the Windows RDP gateway protocol + resourceType: AccountTypeWindows, + ctx: ctx, + cancel: cancel, + shutdownCh: make(chan struct{}), }, } From 1436c0df8e9cce93f07b6fd91bb961afda15a4db Mon Sep 17 00:00:00 2001 From: Saif Ur Rahman Date: Wed, 1 Jul 2026 00:12:20 +0530 Subject: [PATCH 13/13] feat(pam): add AWS IAM CLI access (#285) * feat(pam): add AWS IAM CLI access Write temporary STS credentials to ~/.aws/credentials under a named profile (infisical-pam//). Credentials are cleaned up on Ctrl+C or session expiry. Follows the same pattern as Kubernetes kubeconfig management. * fix(pam): handle stale AWS credentials profile from previous crash Use cfg.Section() instead of cfg.NewSection() so that a leftover profile from a killed session is silently overwritten instead of causing a fatal error. Also always chmod credentials file to 0600 after write (not just when creating it), and guard against negative remaining duration from clock skew. * fix(pam): check credential expiry before writing to disk Move the expiry guard before the file write so expired credentials are never written to ~/.aws/credentials. * fix(pam): end backend session on CLI cleanup Call the session termination API before removing the local AWS credentials profile, so the session is marked as ended on the backend. * fix(pam): use user-facing terminate endpoint for AWS IAM session cleanup The /end endpoint requires gateway auth. Use /terminate (JWT auth) instead since the CLI authenticates as a user, not a gateway. * revert(pam): remove session end call from CLI cleanup The /end endpoint requires gateway auth and the /terminate endpoint marks the session as "Terminated" (admin action) instead of "Ended" (normal close). Neither is appropriate for a user-initiated Ctrl+C. --------- Co-authored-by: saif <11242541+saifsmailbox98@users.noreply.github.com> --- packages/pam/local/access.go | 2 +- packages/pam/local/aws-iam-access.go | 170 +++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 packages/pam/local/aws-iam-access.go diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go index b37972d3..44c09cac 100644 --- a/packages/pam/local/access.go +++ b/packages/pam/local/access.go @@ -91,7 +91,7 @@ func StartPAMAccess(accessToken, path, reason, durationStr, targetHost string, p case AccountTypeKubernetes: startKubernetesProxy(httpClient, &pamResponse, displayPath, durationStr, port) case AccountTypeAwsIam: - util.PrintErrorMessageAndExit("AWS IAM access not yet supported in the new PAM model") + startAWSAccess(httpClient, &pamResponse, displayPath, durationStr, port) case AccountTypeWindows, AccountTypeWindowsAd: startRDPProxy(httpClient, &pamResponse, displayPath, durationStr, port) default: diff --git a/packages/pam/local/aws-iam-access.go b/packages/pam/local/aws-iam-access.go new file mode 100644 index 00000000..0cc4a289 --- /dev/null +++ b/packages/pam/local/aws-iam-access.go @@ -0,0 +1,170 @@ +package pam + +import ( + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/go-resty/resty/v2" + "github.com/rs/zerolog/log" + "gopkg.in/ini.v1" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/util" +) + +func startAWSAccess(_ *resty.Client, response *api.PAMAccessResponse, path, _ string, _ int) { + expiresAtStr := response.Metadata["expiresAt"] + accessKeyId := response.Metadata["accessKeyId"] + secretAccessKey := response.Metadata["secretAccessKey"] + sessionToken := response.Metadata["sessionToken"] + + if accessKeyId == "" || secretAccessKey == "" || sessionToken == "" || expiresAtStr == "" { + util.PrintErrorMessageAndExit("Backend did not return AWS IAM credentials in session metadata") + return + } + + expiresAt, err := time.Parse(time.RFC3339, expiresAtStr) + if err != nil { + util.PrintErrorMessageAndExit(fmt.Sprintf("Failed to parse credential expiry time: %v", err)) + return + } + + remaining := time.Until(expiresAt) + if remaining <= 0 { + util.PrintErrorMessageAndExit("AWS credentials returned by the backend are already expired") + return + } + + folder, account := parsePath(path) + profileName := fmt.Sprintf("infisical-pam/%s/%s", folder, account) + + credFilePath := awsCredentialsFilePath() + createdFile := false + + dir := filepath.Dir(credFilePath) + if err := os.MkdirAll(dir, 0o700); err != nil { + util.PrintErrorMessageAndExit(fmt.Sprintf("Failed to create directory %s: %v", dir, err)) + return + } + + if _, statErr := os.Stat(credFilePath); os.IsNotExist(statErr) { + createdFile = true + } + + cfg, err := ini.LooseLoad(credFilePath) + if err != nil { + util.PrintErrorMessageAndExit(fmt.Sprintf("Failed to load AWS credentials file: %v", err)) + return + } + + section := cfg.Section(profileName) + section.Key("aws_access_key_id").SetValue(accessKeyId) + section.Key("aws_secret_access_key").SetValue(secretAccessKey) + section.Key("aws_session_token").SetValue(sessionToken) + + if err := cfg.SaveTo(credFilePath); err != nil { + util.PrintErrorMessageAndExit(fmt.Sprintf("Failed to write AWS credentials file: %v", err)) + return + } + + _ = os.Chmod(credFilePath, 0o600) + + log.Info().Str("profile", profileName).Str("file", credFilePath).Msg("AWS credentials written") + + printAWSSessionInfo(folder, account, remaining, profileName, expiresAt) + + cleanup := func() { + removeAWSProfile(credFilePath, profileName, createdFile) + } + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + select { + case sig := <-sigChan: + log.Info().Msgf("Received signal %v, cleaning up...", sig) + cleanup() + case <-time.After(remaining): + fmt.Printf("\n AWS session expired. Cleaning up credentials...\n\n") + cleanup() + } +} + +func removeAWSProfile(credFilePath, profileName string, createdFile bool) { + cfg, err := ini.LooseLoad(credFilePath) + if err != nil { + log.Error().Err(err).Msg("Failed to load AWS credentials file for cleanup") + return + } + + cfg.DeleteSection(profileName) + + // If we created the file and it's now empty (only DEFAULT section with no keys), remove it + if createdFile && len(cfg.Sections()) <= 1 && len(cfg.Section("DEFAULT").Keys()) == 0 { + if removeErr := os.Remove(credFilePath); removeErr != nil { + log.Error().Err(removeErr).Msg("Failed to remove AWS credentials file") + } else { + log.Info().Str("file", credFilePath).Msg("Removed AWS credentials file (created by this session)") + } + return + } + + if err := cfg.SaveTo(credFilePath); err != nil { + log.Error().Err(err).Msg("Failed to save AWS credentials file after cleanup") + return + } + + log.Info().Str("profile", profileName).Msg("Removed AWS credentials profile") +} + +func awsCredentialsFilePath() string { + if envPath := os.Getenv("AWS_SHARED_CREDENTIALS_FILE"); envPath != "" { + return envPath + } + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(".", ".aws", "credentials") + } + return filepath.Join(home, ".aws", "credentials") +} + +func printAWSSessionInfo(folder, account string, duration time.Duration, profileName string, expiresAt time.Time) { + fmt.Printf("\n") + fmt.Printf("**********************************************************************\n") + fmt.Printf(" AWS IAM Session Started! \n") + fmt.Printf("**********************************************************************\n") + fmt.Printf("\n") + if folder != "" { + fmt.Printf(" Folder: %s\n", folder) + } + fmt.Printf(" Account: %s\n", account) + fmt.Printf(" Duration: %s\n", duration.Round(time.Second).String()) + fmt.Printf(" Expires: %s\n", expiresAt.Local().Format("2006-01-02 15:04:05 MST")) + fmt.Printf("\n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf(" Connection Details \n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf("\n") + fmt.Printf(" AWS credentials written to: %s\n", awsCredentialsFilePath()) + fmt.Printf(" Profile name: %s\n", profileName) + fmt.Printf("\n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf(" How to Connect \n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf("\n") + fmt.Printf(" Use the AWS CLI with the profile:\n") + util.PrintfStderr(" $ aws s3 ls --profile \"%s\"\n", profileName) + fmt.Printf("\n") + fmt.Printf(" Or set the AWS_PROFILE environment variable:\n") + util.PrintfStderr(" $ export AWS_PROFILE=\"%s\"\n", profileName) + util.PrintfStderr(" $ aws sts get-caller-identity\n") + fmt.Printf("\n") + fmt.Printf(" Press Ctrl+C to stop and remove the credentials profile.\n") + fmt.Printf("\n") + fmt.Printf("**********************************************************************\n") + fmt.Printf("\n") +}