diff --git a/packages/api/model.go b/packages/api/model.go index 7e4cb85b..3ec2833b 100644 --- a/packages/api/model.go +++ b/packages/api/model.go @@ -853,24 +853,32 @@ 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"` + TargetHost string `json:"targetHost,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..3f3f0bf9 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") - } + path := args[0] - _, err = time.ParseDuration(durationStr) + reason, err := cmd.Flags().GetString("reason") 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") - - 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 + util.HandleError(err, "Unable to parse reason flag") } durationStr, err := cmd.Flags().GetString("duration") @@ -388,190 +49,31 @@ 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") - } - + targetHost, err := cmd.Flags().GetString("target") if err != nil { - util.HandleError(err, "Unable to get logged in user details") + util.HandleError(err, "Unable to parse target flag") } - 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") } + isConnected := util.ValidateInfisicalAPIConnection() 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, targetHost, 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)") + pamAccessCmd.Flags().String("target", "", "Target host to connect to (for accounts that allow multiple hosts, e.g. Windows AD)") - 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..44c09cac --- /dev/null +++ b/packages/pam/local/access.go @@ -0,0 +1,586 @@ +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" + AccountTypeWindowsAd = "windows-ad" +) + +// 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, targetHost string, port int) { + // Normalize path for display (ensure leading slash) + displayPath := normalizePath(path) + + log.Info().Msgf("Starting PAM access for: %s", strings.TrimPrefix(displayPath, "/")) + log.Info().Msgf("Session duration: %s", durationStr) + + httpClient := resty.New() + httpClient.SetAuthToken(accessToken) + httpClient.SetHeader("User-Agent", api.USER_AGENT) + + pamResponse, err := CallPAMAccessWithMFA(httpClient, api.PAMAccessRequest{ + Path: path, + Duration: durationStr, + Reason: reason, + TargetHost: targetHost, + }, true) + 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 { + // Database types - all use the same proxy mechanism with different display configs + case AccountTypePostgres, AccountTypeMySQL, AccountTypeMsSQL, AccountTypeMongoDB, AccountTypeOracleDB: + startDatabaseProxy(httpClient, &pamResponse, displayPath, durationStr, port) + + case AccountTypeSSH: + startSSHAccess(httpClient, &pamResponse, displayPath, durationStr, port) + case AccountTypeRedis: + util.PrintErrorMessageAndExit("Redis access not yet supported in the new PAM model") + case AccountTypeKubernetes: + startKubernetesProxy(httpClient, &pamResponse, displayPath, durationStr, port) + case AccountTypeAwsIam: + startAWSAccess(httpClient, &pamResponse, displayPath, durationStr, port) + case AccountTypeWindows, AccountTypeWindowsAd: + startRDPProxy(httpClient, &pamResponse, displayPath, durationStr, port) + default: + util.PrintErrorMessageAndExit(fmt.Sprintf("Unsupported account type: %s", pamResponse.AccountType)) + } +} + +// 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@127.0.0.1:%d/%s", username, port, database) + }, + UsageExamples: func(username, database string, port int) []string { + return []string{ + fmt.Sprintf("psql -h 127.0.0.1 -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@127.0.0.1:%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@127.0.0.1:%d?database=%s", username, port, database) + }, + UsageExamples: func(username, database string, port int) []string { + return []string{ + fmt.Sprintf("sqlcmd -S 127.0.0.1,%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://127.0.0.1:%d/%s", port, database) + }, + UsageExamples: func(username, database string, port int) []string { + return []string{ + fmt.Sprintf("mongosh --host 127.0.0.1 --port %d %s", port, database), + } + }, + }, + AccountTypeOracleDB: { + TypeLabel: "Oracle", + DefaultPort: 1521, + ConnectionString: func(username, database string, port int) string { + 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@127.0.0.1:%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{ + 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 + } + + // Parse path into folder and account + folder, account := parsePath(path) + + 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() +} + +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, + // Windows AD is brokered through the Windows RDP gateway protocol + resourceType: AccountTypeWindows, + 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) + util.PrintfStderr("\n") + util.PrintfStderr("**********************************************************************\n") + util.PrintfStderr(" RDP Proxy Session Started! \n") + util.PrintfStderr("**********************************************************************\n") + util.PrintfStderr("\n") + if folder != "" { + util.PrintfStderr(" Folder: %s\n", folder) + } + 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 != "" { + util.PrintfStderr("\n") + util.PrintfStderr(" .rdp file: %s\n", proxy.rdpFilePath) + } + 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) + go func() { + sig := <-sigChan + log.Info().Msgf("Received signal %v, initiating graceful shutdown...", sig) + proxy.gracefulShutdown() + }() + + 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) + if username != "" { + fmt.Printf(" Username: %s\n", username) + } + 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 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) + 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") + fmt.Printf("**********************************************************************\n") + fmt.Printf(" %s Proxy Session Started! \n", config.TypeLabel) + 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) + if username != "" { + fmt.Printf(" Username: %s\n", username) + } + fmt.Printf(" Password: (not required)\n") + if database != "" { + fmt.Printf(" Database: %s\n", database) + } + fmt.Printf("\n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf(" How to Connect \n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf("\n") + fmt.Printf(" Use your preferred database client (CLI, GUI, or IDE) to connect\n") + 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) + 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") +} + +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/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") +} diff --git a/packages/pam/local/base-proxy.go b/packages/pam/local/base-proxy.go index 0ef603cd..394ec20b 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 @@ -295,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: + } } } @@ -317,8 +322,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 +340,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 +366,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 +389,6 @@ func CallPAMAccessWithMFA( } } } - // Return original error if not MFA/reason-related return api.PAMAccessResponse{}, err } @@ -398,7 +396,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 +460,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..27209336 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,129 +25,12 @@ 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 { - 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 { @@ -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) diff --git a/packages/pam/local/kubernetes-proxy.go b/packages/pam/local/kubernetes-proxy.go index 0e94fe72..1d94c594 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,141 +22,40 @@ 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://127.0.0.1:%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 { 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/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()) } 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 { 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) }) }