diff --git a/packages/gateway-v2/test_connection_handler.go b/packages/gateway-v2/test_connection_handler.go index 8e85c0bc..7940425f 100644 --- a/packages/gateway-v2/test_connection_handler.go +++ b/packages/gateway-v2/test_connection_handler.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/stdlib" mssql "github.com/microsoft/go-mssqldb" "github.com/microsoft/go-mssqldb/msdsn" + "github.com/smallnest/resp3" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" ) @@ -49,6 +50,7 @@ var connectionTestMux = sync.OnceValue(func() *http.ServeMux { const ( testConnModeSQL = "sql" testConnModeMongoDB = "mongodb" + testConnModeRedis = "redis" testConnModeLDAP = "ldap" testConnModeKubernetes = "kubernetes" testConnModeSSH = "ssh" @@ -81,6 +83,14 @@ type mongoTestParams struct { SslCertificate string `json:"sslCertificate"` } +type redisTestParams struct { + Username string `json:"username"` + Password string `json:"password"` + SslEnabled bool `json:"sslEnabled"` + SslRejectUnauthorized *bool `json:"sslRejectUnauthorized"` + SslCertificate string `json:"sslCertificate"` +} + type ldapTestParams struct { Username string `json:"username"` Password string `json:"password"` @@ -232,6 +242,92 @@ func doMongoConnectionTest(ctx context.Context, host string, port int, params mo return client.Ping(ctx, nil) } +// doRedisConnectionTest authenticates against the target Redis and PINGs it +func doRedisConnectionTest(ctx context.Context, host string, port int, params redisTestParams) error { + timeout := testConnDefaultTimeout + if deadline, ok := ctx.Deadline(); ok { + timeout = time.Until(deadline) + } + + addr := net.JoinHostPort(host, strconv.Itoa(port)) + dialer := &net.Dialer{Timeout: timeout} + + var conn net.Conn + if params.SslEnabled { + tlsConfig, err := buildTestTLSConfig(host, params.SslCertificate, params.SslRejectUnauthorized) + if err != nil { + return err + } + if conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsConfig); err != nil { + return err + } + } else { + var err error + if conn, err = dialer.DialContext(ctx, "tcp", addr); err != nil { + return err + } + } + defer conn.Close() + + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + + writer := resp3.NewWriter(conn) + reader := bufio.NewReader(io.LimitReader(conn, maxRedisTestReplyBytes)) + + // only authenticate when password is supplied + if params.Password != "" { + var err error + if params.Username != "" && params.Username != "default" { + err = writer.WriteCommand("AUTH", params.Username, params.Password) + } else { + err = writer.WriteCommand("AUTH", params.Password) + } + if err != nil { + return err + } + reply, err := readRedisReplyLine(reader) + if err != nil { + return err + } + if reply != "+OK" { + return fmt.Errorf("redis authentication failed: %s", redisReplyErrorText(reply)) + } + } + + if err := writer.WriteCommand("PING"); err != nil { + return err + } + reply, err := readRedisReplyLine(reader) + if err != nil { + return err + } + if len(reply) > 0 && (reply[0] == '-' || reply[0] == '!') { + return fmt.Errorf("redis PING failed: %s", redisReplyErrorText(reply)) + } + return nil +} + +const maxRedisTestReplyBytes = 64 * 1024 + +// readRedisReplyLine reads a single RESP reply line (bounded by the LimitReader) and trims the CRLF +func readRedisReplyLine(r *bufio.Reader) (string, error) { + line, err := r.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimRight(line, "\r\n"), nil +} + +// redisReplyErrorText strips a RESP error prefix ('-' or '!') for use in an error message +func redisReplyErrorText(line string) string { + if len(line) > 0 && (line[0] == '-' || line[0] == '!') { + return strings.TrimSpace(line[1:]) + } + return strings.TrimSpace(line) +} + // doLdapConnectionTest binds to the target directory with the supplied credentials (the Windows AD auth check) func doLdapConnectionTest(ctx context.Context, host string, port int, params ldapTestParams) error { timeout := testConnDefaultTimeout @@ -451,6 +547,12 @@ func handleTestConnection(w http.ResponseWriter, r *http.Request) { return } op = func() error { return doMongoConnectionTest(ctx, target.host, target.port, params) } + case testConnModeRedis: + var params redisTestParams + if !decode(¶ms) { + return + } + op = func() error { return doRedisConnectionTest(ctx, target.host, target.port, params) } case testConnModeLDAP: var params ldapTestParams if !decode(¶ms) { diff --git a/packages/pam/handlers/redis/proxy.go b/packages/pam/handlers/redis/proxy.go index 6a6c8a4d..2a022910 100644 --- a/packages/pam/handlers/redis/proxy.go +++ b/packages/pam/handlers/redis/proxy.go @@ -69,9 +69,15 @@ func (p *RedisProxy) HandleConnection(ctx context.Context, clientConn net.Conn) defer func(selfToClientRedisConn *RedisConn) { _ = selfToClientRedisConn.Close() }(selfToClientRedisConn) // Only authenticate if credentials are provided - if p.config.InjectUsername != "" && p.config.InjectPassword != "" { - if err := selfToClientRedisConn.Writer().WriteCommand("AUTH", p.config.InjectUsername, p.config.InjectPassword); err != nil { - return err + if p.config.InjectPassword != "" { + var writeErr error + if p.config.InjectUsername != "" && p.config.InjectUsername != "default" { + writeErr = selfToClientRedisConn.Writer().WriteCommand("AUTH", p.config.InjectUsername, p.config.InjectPassword) + } else { + writeErr = selfToClientRedisConn.Writer().WriteCommand("AUTH", p.config.InjectPassword) + } + if writeErr != nil { + return writeErr } if err := selfToClientRedisConn.Writer().Flush(); err != nil { return err diff --git a/packages/pam/handlers/redis/relay_handler.go b/packages/pam/handlers/redis/relay_handler.go index 24d2d19c..55049f33 100644 --- a/packages/pam/handlers/redis/relay_handler.go +++ b/packages/pam/handlers/redis/relay_handler.go @@ -134,9 +134,15 @@ func (h *RelayHandler) Handle(ctx context.Context) error { } switch value.Type { case resp3.TypeArray: + if len(value.Elems) == 0 { + if err := h.clientToSelfConn.WriteValue(&resp3.Value{Type: resp3.TypeSimpleError, Err: "ERR empty command"}, true); err != nil { + return err + } + continue + } cmd := value.Elems[0] if cmd.Type != resp3.TypeBlobString { - return fmt.Errorf("expected SimpleString, got %s", cmd.Type) + return fmt.Errorf("expected blob string command, got %c", cmd.Type) } cmdStr := strings.ToLower(value.Elems[0].Str) switch cmdStr { diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go index 55b1507b..c8e12f1d 100644 --- a/packages/pam/local/access.go +++ b/packages/pam/local/access.go @@ -107,7 +107,7 @@ func StartPAMAccess(accessToken, path, reason, durationStr, targetHost string, p case AccountTypeSSH: startSSHAccess(httpClient, &pamResponse, displayPath, durationStr, port) case AccountTypeRedis: - util.PrintErrorMessageAndExit("Redis access not yet supported in the new PAM model") + startRedisProxy(httpClient, &pamResponse, displayPath, durationStr, port) case AccountTypeKubernetes: startKubernetesProxy(httpClient, &pamResponse, displayPath, durationStr, port) case AccountTypeAwsIam: @@ -381,6 +381,103 @@ func startDatabaseProxy(httpClient *resty.Client, response *api.PAMAccessRespons proxy.Run() } +func startRedisProxy(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 := &RedisProxyServer{ + 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 proxy server") + return + } + + folder, account := parsePath(path) + log.Info().Msgf("Redis proxy server listening on port %d", proxy.port) + printRedisSessionInfo(folder, account, duration, response.Metadata["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 printRedisSessionInfo(folder, account string, duration time.Duration, username string, port int) { + fmt.Printf("\n") + fmt.Printf("**********************************************************************\n") + fmt.Printf(" Redis 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(" Password: (not required)\n") + fmt.Printf("\n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf(" How to Connect \n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf("\n") + fmt.Printf(" Use your preferred Redis client to connect to 127.0.0.1:%d.\n", port) + fmt.Printf(" No password is needed.\n") + fmt.Printf("\n") + fmt.Printf(" Example:\n") + util.PrintfStderr(" $ redis-cli -h 127.0.0.1 -p %d\n", port) + fmt.Printf("\n") + fmt.Printf(" Connection string:\n") + if username != "" { + util.PrintfStderr(" redis://%s@127.0.0.1:%d\n", username, port) + } else { + util.PrintfStderr(" redis://127.0.0.1:%d\n", port) + } + fmt.Printf("\n") + fmt.Printf("**********************************************************************\n") + fmt.Printf("\n") +} + func startRDPProxy(httpClient *resty.Client, response *api.PAMAccessResponse, path, durationStr string, port int) { duration, err := time.ParseDuration(durationStr) if err != nil { diff --git a/packages/pam/local/base-proxy.go b/packages/pam/local/base-proxy.go index 394ec20b..0bab7ecf 100644 --- a/packages/pam/local/base-proxy.go +++ b/packages/pam/local/base-proxy.go @@ -5,7 +5,6 @@ import ( "crypto/tls" "crypto/x509" "encoding/json" - "errors" "fmt" "io" "net" @@ -26,39 +25,6 @@ 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 - Reason string -} - -// GetDisplayName returns a user-friendly display name for the access params -func (p PAMAccessParams) GetDisplayName() string { - return fmt.Sprintf("%s/%s", p.ResourceName, p.AccountName) -} - -// ToAPIRequest converts PAMAccessParams to an api.PAMAccessRequest -func (p PAMAccessParams) ToAPIRequest(projectID, duration string) api.PAMAccessRequest { - return api.PAMAccessRequest{ - Duration: duration, - ResourceName: p.ResourceName, - AccountName: p.AccountName, - ProjectId: projectID, - Reason: p.Reason, - } -} - -// ToApprovalRequestData converts PAMAccessParams to api.PAMAccessApprovalRequestPayloadRequestData -func (p PAMAccessParams) ToApprovalRequestData(duration string) api.PAMAccessApprovalRequestPayloadRequestData { - return api.PAMAccessApprovalRequestPayloadRequestData{ - ResourceName: p.ResourceName, - AccountName: p.AccountName, - AccessDuration: duration, - } -} - // BaseProxyServer contains common functionality for all local proxy types type BaseProxyServer struct { httpClient *resty.Client @@ -394,70 +360,3 @@ func CallPAMAccessWithMFA( return pamResponse, nil } - -// HandleApprovalWorkflow checks if an error is due to an approval policy and handles the approval request flow. -// 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" { - return false - } - - details, ok := apiErr.Details.(map[string]any) - if !ok { - return false - } - - log.Info().Msgf("Account is protected by approval policy: %s", details["policyName"]) - - shouldSendRequest, promptErr := askForApprovalRequestTrigger() - if promptErr != nil { - if errors.Is(promptErr, promptui.ErrAbort) { - log.Info().Msgf("Approval request was not created.") - } else { - util.HandleError(promptErr, "Failed to send PAM account request") - } - return true - } - - if !shouldSendRequest { - log.Info().Msgf("Approval request was not created.") - return true - } - - approvalReq, reqErr := api.CallPAMAccessApprovalRequest(httpClient, api.PAMAccessApprovalRequest{ - ProjectId: projectID, - RequestData: accessParams.ToApprovalRequestData(durationStr), - }) - if reqErr != nil { - util.HandleError(reqErr, "Failed to send PAM account request") - return true - } - - url := fmt.Sprintf("%s/organizations/%s/projects/pam/%s/approval-requests/%s", - strings.TrimSuffix(config.INFISICAL_URL, "/api"), - approvalReq.Request.OrgId, - approvalReq.Request.ProjectId, - approvalReq.Request.ID) - - if browserErr := util.OpenBrowser(url); browserErr != nil { - log.Error().Msgf("Failed to do browser redirect: %v", browserErr) - } - - log.Info().Msgf("Approval request created.") - log.Info().Msgf("View details at: %s", url) - return true -} - -func askForApprovalRequestTrigger() (bool, error) { - prompt := promptui.Prompt{ - Label: "This action requires approval. You may create an approval request now. Continue?", - IsConfirm: true, - } - result, err := prompt.Run() - if err != nil { - return false, err - } - return strings.ToLower(result) == "y", nil -} - diff --git a/packages/pam/local/redis-proxy.go b/packages/pam/local/redis-proxy.go index 5e26defa..ad781458 100644 --- a/packages/pam/local/redis-proxy.go +++ b/packages/pam/local/redis-proxy.go @@ -6,13 +6,8 @@ import ( "io" "net" "os" - "os/signal" - "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" ) @@ -22,110 +17,6 @@ type RedisProxyServer struct { port int } -func StartRedisLocalProxy(accessToken string, accessParams PAMAccessParams, projectID string, durationStr string, port int) { - log.Info().Msgf("Starting Redis 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 - } - - // Verify this is a Redis resource - if pamResponse.ResourceType != session.ResourceTypeRedis { - util.HandleError(fmt.Errorf("account is not a Redis resource, got: %s", pamResponse.ResourceType), "Invalid resource type") - return - } - - log.Info().Msgf("Redis 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 := &RedisProxyServer{ - 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 { - util.PrintfStderr("Redis proxy started for account %s with duration %s on port %d (auto-assigned)\n", accessParams.GetDisplayName(), duration.String(), proxy.port) - } else { - util.PrintfStderr("Redis 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 { - username = "" // Redis may not always have username - } - - log.Info().Msgf("Redis proxy server listening on port %d", proxy.port) - util.PrintfStderr("\n") - util.PrintfStderr("**********************************************************************\n") - util.PrintfStderr(" Redis Proxy Session Started! \n") - util.PrintfStderr("----------------------------------------------------------------------\n") - util.PrintfStderr("Resource: %s, Account: %s\n", accessParams.ResourceName, accessParams.AccountName) - util.PrintfStderr("\n") - util.PrintfStderr("You can now connect to your Redis instance using:\n") - if username != "" { - util.PrintfStderr("redis://%s@127.0.0.1:%d", username, proxy.port) - } else { - util.PrintfStderr("redis://127.0.0.1:%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 *RedisProxyServer) Start(port int) error { var err error if port == 0 { @@ -252,7 +143,7 @@ func (p *RedisProxyServer) handleConnection(clientConn net.Conn) { gatewayErrCh, clientErrCh := p.NewDisconnectChannels() - // Gateway → Client: if this side closes first, the gateway dropped the connection + // Gateway → Client go func() { defer connCancel() _, err := io.Copy(clientConn, gatewayConn) @@ -266,7 +157,7 @@ func (p *RedisProxyServer) handleConnection(clientConn net.Conn) { gatewayErrCh <- err }() - // Client → Gateway: if this side closes first, the client disconnected normally + // Client → Gateway go func() { defer connCancel() _, err := io.Copy(gatewayConn, clientConn)