diff --git a/packages/api/model.go b/packages/api/model.go index 3ec2833b..9ab327e5 100644 --- a/packages/api/model.go +++ b/packages/api/model.go @@ -970,6 +970,8 @@ type PAMSessionCredentials struct { Realm string `json:"realm,omitempty"` KDCAddress string `json:"kdcAddress,omitempty"` SPN string `json:"spn,omitempty"` + Token string `json:"token,omitempty"` + ServiceAccountEmail string `json:"serviceAccountEmail,omitempty"` } type MFASessionStatus string diff --git a/packages/pam/handlers/gcp/proxy.go b/packages/pam/handlers/gcp/proxy.go new file mode 100644 index 00000000..08a21ccc --- /dev/null +++ b/packages/pam/handlers/gcp/proxy.go @@ -0,0 +1,386 @@ +package gcp + +import ( + "bufio" + "bytes" + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/binary" + "encoding/pem" + "errors" + "fmt" + "io" + "math/big" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Infisical/infisical-merge/packages/pam/session" + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +type GCPProxyConfig struct { + Token string + SessionID string + SessionLogger session.SessionLogger +} + +type GCPProxy struct { + config GCPProxyConfig + caCert *x509.Certificate + caKey crypto.Signer +} + +func NewGCPProxy(config GCPProxyConfig) *GCPProxy { + return &GCPProxy{config: config} +} + +const maxPEMSize = 1 << 20 // 1 MiB + +func readLengthPrefixed(conn net.Conn) ([]byte, error) { + lenBuf := make([]byte, 4) + if _, err := io.ReadFull(conn, lenBuf); err != nil { + return nil, err + } + length := binary.BigEndian.Uint32(lenBuf) + if length > maxPEMSize { + return nil, fmt.Errorf("length prefix %d exceeds max allowed size", length) + } + data := make([]byte, length) + if _, err := io.ReadFull(conn, data); err != nil { + return nil, err + } + return data, nil +} + +func (p *GCPProxy) HandleConnection(ctx context.Context, clientConn net.Conn) error { + defer clientConn.Close() + + l := log.With().Str("sessionID", p.config.SessionID).Logger() + + l.Info().Msg("New GCP IAM connection, reading CA from client") + + caCertPEM, err := readLengthPrefixed(clientConn) + if err != nil { + return fmt.Errorf("failed to read CA cert: %w", err) + } + caKeyPEM, err := readLengthPrefixed(clientConn) + if err != nil { + return fmt.Errorf("failed to read CA key: %w", err) + } + + certBlock, _ := pem.Decode(caCertPEM) + if certBlock == nil { + return fmt.Errorf("failed to decode CA cert PEM") + } + p.caCert, err = x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return fmt.Errorf("failed to parse CA cert: %w", err) + } + + keyBlock, _ := pem.Decode(caKeyPEM) + if keyBlock == nil { + return fmt.Errorf("failed to decode CA key PEM") + } + p.caKey, err = x509.ParseECPrivateKey(keyBlock.Bytes) + if err != nil { + return fmt.Errorf("failed to parse CA key: %w", err) + } + + l.Info().Msg("CA loaded, starting HTTP proxy") + + reader := bufio.NewReader(clientConn) + + transport := &http.Transport{ + DisableKeepAlives: false, + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + TLSNextProto: make(map[string]func(authority string, c *tls.Conn) http.RoundTripper), + } + client := &http.Client{Transport: transport} + + for { + select { + case <-ctx.Done(): + l.Info().Msg("Context cancelled, closing GCP proxy connection") + return ctx.Err() + default: + } + + reqCh := make(chan *http.Request, 1) + errCh := make(chan error, 1) + + go func() { + req, err := http.ReadRequest(reader) + if err != nil { + errCh <- err + } else { + reqCh <- req + } + }() + + var req *http.Request + select { + case <-ctx.Done(): + l.Info().Msg("Context cancelled while reading HTTP request") + return ctx.Err() + case err := <-errCh: + if errors.Is(err, io.EOF) { + l.Info().Msg("Client closed connection") + return nil + } + l.Error().Err(err).Msg("Failed to read HTTP request") + return fmt.Errorf("failed to read HTTP request: %w", err) + case req = <-reqCh: + } + + if req.Method == http.MethodConnect { + return p.handleConnect(ctx, clientConn, req, client, l) + } + + if err := p.handleRequest(ctx, clientConn, req, client, l); err != nil { + return err + } + } +} + +func (p *GCPProxy) handleConnect(ctx context.Context, clientConn net.Conn, connectReq *http.Request, client *http.Client, l zerolog.Logger) error { + targetHost := connectReq.Host + if !strings.Contains(targetHost, ":") { + targetHost += ":443" + } + + hostname := strings.Split(targetHost, ":")[0] + if !isGCPHost(hostname) { + writeErrorResponseTo(clientConn, fmt.Sprintf("host %q is not a Google Cloud API endpoint", hostname)) + return fmt.Errorf("rejected CONNECT to non-GCP host: %s", hostname) + } + + _, err := clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) + if err != nil { + return fmt.Errorf("failed to send CONNECT response: %w", err) + } + + l.Info().Str("host", targetHost).Msg("CONNECT tunnel established, starting TLS termination") + + tlsCert, err := p.generateSignedCert(hostname) + if err != nil { + return fmt.Errorf("failed to generate TLS cert for %s: %w", hostname, err) + } + + tlsConn := tls.Server(clientConn, &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + }) + if err := tlsConn.HandshakeContext(ctx); err != nil { + return fmt.Errorf("TLS handshake failed: %w", err) + } + defer tlsConn.Close() + + tlsReader := bufio.NewReader(tlsConn) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + reqCh := make(chan *http.Request, 1) + errCh := make(chan error, 1) + + go func() { + req, err := http.ReadRequest(tlsReader) + if err != nil { + errCh <- err + } else { + reqCh <- req + } + }() + + var req *http.Request + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-errCh: + if errors.Is(err, io.EOF) { + l.Info().Msg("Client closed TLS connection") + return nil + } + return fmt.Errorf("failed to read HTTP request from TLS: %w", err) + case req = <-reqCh: + } + + if req.Host == "" { + req.Host = hostname + } + + if err := p.handleRequest(ctx, tlsConn, req, client, l); err != nil { + return err + } + } +} + +func (p *GCPProxy) handleRequest(_ context.Context, writer io.Writer, req *http.Request, client *http.Client, l zerolog.Logger) error { + requestId := uuid.New() + l.Info(). + Str("method", req.Method). + Str("host", req.Host). + Str("url", req.URL.String()). + Str("reqId", requestId.String()). + Msg("Received HTTP request") + + if !isGCPHost(req.Host) { + l.Warn().Str("host", req.Host).Msg("Rejected non-GCP request") + writeErrorResponseTo(writer, fmt.Sprintf("host %q is not a Google Cloud API endpoint", req.Host)) + return nil + } + + reqBody, err := io.ReadAll(req.Body) + if err != nil { + l.Error().Err(err).Msg("Failed to read request body") + writeErrorResponseTo(writer, "failed to read request body") + return err + } + + if err := p.config.SessionLogger.LogHttpEvent(session.HttpEvent{ + Timestamp: time.Now(), + RequestId: requestId.String(), + EventType: session.HttpEventRequest, + URL: req.URL.String(), + Method: req.Method, + Headers: req.Header, + Body: reqBody, + }); err != nil { + l.Error().Err(err).Msg("Failed to log HTTP request event") + } + + host := req.Host + path := req.URL.RequestURI() + if req.URL.Scheme != "" && req.URL.Host != "" { + if !isGCPHost(req.URL.Host) { + l.Warn().Str("urlHost", req.URL.Host).Msg("Rejected non-GCP request URL host") + writeErrorResponseTo(writer, fmt.Sprintf("host %q is not a Google Cloud API endpoint", req.URL.Host)) + return nil + } + host = req.URL.Host + path = req.URL.Path + if req.URL.RawQuery != "" { + path += "?" + req.URL.RawQuery + } + } + + targetURL, err := url.Parse(fmt.Sprintf("https://%s%s", host, path)) + if err != nil { + l.Error().Err(err).Msg("Failed to parse target URL") + writeErrorResponseTo(writer, "failed to parse target URL") + return nil + } + + proxyReq, err := http.NewRequest(req.Method, targetURL.String(), bytes.NewReader(reqBody)) + if err != nil { + l.Error().Err(err).Msg("Failed to create proxy request") + writeErrorResponseTo(writer, "failed to create proxy request") + return nil + } + + proxyReq.Header = req.Header.Clone() + proxyReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.config.Token)) + + resp, err := client.Do(proxyReq) + if err != nil { + l.Error().Err(err).Str("url", targetURL.String()).Msg("Failed to forward request to GCP") + writeErrorResponseTo(writer, "failed to forward request to GCP") + return nil + } + + resp.Header.Del("Connection") + l.Info().Str("status", resp.Status).Msg("Writing response to connection") + + var bodyCopy bytes.Buffer + resp.Body = struct { + io.ReadCloser + }{ + ReadCloser: io.NopCloser(io.TeeReader(resp.Body, &bodyCopy)), + } + + if err := resp.Write(writer); err != nil { + if errors.Is(err, io.EOF) { + l.Info().Msg("Client closed connection") + } else { + l.Error().Err(err).Msg("Failed to write response to connection") + resp.Body.Close() + return fmt.Errorf("failed to write response to connection: %w", err) + } + } + + resp.Body.Close() + + if err := p.config.SessionLogger.LogHttpEvent(session.HttpEvent{ + Timestamp: time.Now(), + RequestId: requestId.String(), + EventType: session.HttpEventResponse, + Status: resp.Status, + Headers: resp.Header, + Body: bodyCopy.Bytes(), + }); err != nil { + l.Error().Err(err).Msg("Failed to log HTTP response event") + } + + return nil +} + +func (p *GCPProxy) generateSignedCert(hostname string) (tls.Certificate, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, err + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return tls.Certificate{}, err + } + + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: hostname}, + NotBefore: time.Now().Add(-1 * time.Minute), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{hostname}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, p.caCert, &key.PublicKey, p.caKey) + if err != nil { + return tls.Certificate{}, err + } + + return tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: key, + }, nil +} + +func isGCPHost(host string) bool { + if strings.Contains(host, "@") { + return false + } + h := strings.Split(host, ":")[0] + return h == "googleapis.com" || strings.HasSuffix(h, ".googleapis.com") +} + +func writeErrorResponseTo(w io.Writer, message string) { + body := fmt.Sprintf("{\"message\": \"gateway: %s\"}", message) + resp := fmt.Sprintf("HTTP/1.1 502 Bad Gateway\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s", len(body), body) + w.Write([]byte(resp)) +} diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go index 44c09cac..839f1f44 100644 --- a/packages/pam/local/access.go +++ b/packages/pam/local/access.go @@ -26,6 +26,7 @@ const ( AccountTypeRedis = "redis" AccountTypeKubernetes = "kubernetes" AccountTypeAwsIam = "aws-iam" + AccountTypeGcpIam = "gcp-iam" AccountTypeWindows = "windows" AccountTypeWindowsAd = "windows-ad" ) @@ -92,6 +93,8 @@ func StartPAMAccess(accessToken, path, reason, durationStr, targetHost string, p startKubernetesProxy(httpClient, &pamResponse, displayPath, durationStr, port) case AccountTypeAwsIam: startAWSAccess(httpClient, &pamResponse, displayPath, durationStr, port) + case AccountTypeGcpIam: + startGCPProxy(httpClient, &pamResponse, displayPath, durationStr, port) case AccountTypeWindows, AccountTypeWindowsAd: startRDPProxy(httpClient, &pamResponse, displayPath, durationStr, port) default: diff --git a/packages/pam/local/gcp-proxy.go b/packages/pam/local/gcp-proxy.go new file mode 100644 index 00000000..1949b121 --- /dev/null +++ b/packages/pam/local/gcp-proxy.go @@ -0,0 +1,416 @@ +package pam + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/binary" + "encoding/pem" + "fmt" + "io" + "math/big" + "net" + "os" + "os/exec" + "os/signal" + "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" +) + +type GCPProxyServer struct { + BaseProxyServer + server net.Listener + port int + gcloudConfigured bool + tokenFilePath string + caFilePath string + caCertPEM []byte + caKeyPEM []byte +} + +func (p *GCPProxyServer) generateCA() error { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("failed to generate CA key: %w", err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return fmt.Errorf("failed to generate serial: %w", err) + } + + caExpiry := time.Until(p.sessionExpiry) + 5*time.Minute + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "Infisical PAM GCP Proxy CA"}, + NotBefore: time.Now().Add(-1 * time.Minute), + NotAfter: time.Now().Add(caExpiry), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLen: 0, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return fmt.Errorf("failed to create CA certificate: %w", err) + } + + p.caCertPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return fmt.Errorf("failed to marshal CA key: %w", err) + } + p.caKeyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + return nil +} + +func (p *GCPProxyServer) Start(port int) error { + var err error + if port == 0 { + p.server, err = net.Listen("tcp", "127.0.0.1:0") + } else { + p.server, err = net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + } + + if err != nil { + return fmt.Errorf("failed to start server: %w", err) + } + + addr := p.server.Addr().(*net.TCPAddr) + p.port = addr.Port + + return nil +} + +func (p *GCPProxyServer) SetupGcloudProxy() error { + tokenFile, err := os.CreateTemp("", "infisical-gcp-token-*") + if err != nil { + return fmt.Errorf("failed to create token file: %w", err) + } + if _, err := tokenFile.WriteString("PROXY_MANAGED"); err != nil { + tokenFile.Close() + os.Remove(tokenFile.Name()) + return fmt.Errorf("failed to write token file: %w", err) + } + tokenFile.Close() + p.tokenFilePath = tokenFile.Name() + + caFile, err := os.CreateTemp("", "infisical-gcp-ca-*") + if err != nil { + return fmt.Errorf("failed to create CA file: %w", err) + } + if _, err := caFile.Write(p.caCertPEM); err != nil { + caFile.Close() + os.Remove(caFile.Name()) + return fmt.Errorf("failed to write CA file: %w", err) + } + caFile.Close() + p.caFilePath = caFile.Name() + + commands := [][]string{ + {"config", "set", "proxy/type", "http"}, + {"config", "set", "proxy/address", "localhost"}, + {"config", "set", "proxy/port", fmt.Sprintf("%d", p.port)}, + {"config", "set", "auth/access_token_file", p.tokenFilePath}, + {"config", "set", "core/custom_ca_certs_file", p.caFilePath}, + } + for _, args := range commands { + if err := exec.Command("gcloud", args...).Run(); err != nil { + p.RevertGcloudProxy() + return fmt.Errorf("failed to run gcloud %v: %w", args, err) + } + } + p.gcloudConfigured = true + log.Info().Int("port", p.port).Msg("Configured gcloud proxy settings") + return nil +} + +func (p *GCPProxyServer) RevertGcloudProxy() { + if !p.gcloudConfigured { + return + } + properties := []string{"proxy/type", "proxy/address", "proxy/port", "auth/access_token_file", "core/custom_ca_certs_file"} + for _, prop := range properties { + cmd := exec.Command("gcloud", "config", "unset", prop) + if err := cmd.Run(); err != nil { + log.Warn().Err(err).Str("property", prop).Msg("Failed to unset gcloud proxy property") + } + } + if p.tokenFilePath != "" { + os.Remove(p.tokenFilePath) + } + if p.caFilePath != "" { + os.Remove(p.caFilePath) + } + p.gcloudConfigured = false + log.Info().Msg("Reverted gcloud proxy settings") +} + +func (p *GCPProxyServer) gracefulShutdown() { + p.shutdownOnce.Do(func() { + log.Info().Msg("Starting graceful shutdown of GCP proxy...") + + p.RevertGcloudProxy() + p.NotifySessionTermination() + + close(p.shutdownCh) + + if p.server != nil { + p.server.Close() + } + + p.cancel() + + p.WaitForConnectionsWithTimeout(10 * time.Second) + + log.Info().Msg("GCP proxy shutdown complete") + os.Exit(0) + }) +} + +func (p *GCPProxyServer) Run() { + defer p.server.Close() + + for { + select { + case <-p.ctx.Done(): + log.Info().Msg("Context cancelled, stopping proxy server") + return + case <-p.shutdownCh: + log.Info().Msg("Shutdown signal received, stopping proxy server") + return + default: + if time.Now().After(p.sessionExpiry) { + log.Warn().Msg("GCP session expired, shutting down proxy") + p.gracefulShutdown() + return + } + + if tcpListener, ok := p.server.(*net.TCPListener); ok { + tcpListener.SetDeadline(time.Now().Add(1 * time.Second)) + } + + conn, err := p.server.Accept() + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + select { + case <-p.ctx.Done(): + return + case <-p.shutdownCh: + return + default: + log.Error().Err(err).Msg("Failed to accept connection") + continue + } + } + + p.activeConnections.Add(1) + go p.handleConnection(conn) + } + } +} + +func writeLengthPrefixed(conn net.Conn, data []byte) error { + lenBuf := make([]byte, 4) + binary.BigEndian.PutUint32(lenBuf, uint32(len(data))) + if _, err := conn.Write(lenBuf); err != nil { + return err + } + _, err := conn.Write(data) + return err +} + +func (p *GCPProxyServer) handleConnection(clientConn net.Conn) { + defer func() { + clientConn.Close() + p.activeConnections.Done() + }() + + log.Info().Msgf("New connection from %s", clientConn.RemoteAddr()) + + select { + case <-p.ctx.Done(): + log.Info().Msg("Context cancelled, closing connection immediately") + return + default: + } + + relayConn, err := p.CreateRelayConnection() + if err != nil { + log.Error().Err(err).Msg("Failed to connect to relay") + return + } + defer relayConn.Close() + + gatewayConn, err := p.CreateGatewayConnection(relayConn, ALPNInfisicalPAMProxy) + if err != nil { + log.Error().Err(err).Msg("Failed to connect to gateway") + return + } + defer gatewayConn.Close() + + if err := writeLengthPrefixed(gatewayConn, p.caCertPEM); err != nil { + log.Error().Err(err).Msg("Failed to send CA cert to gateway") + return + } + if err := writeLengthPrefixed(gatewayConn, p.caKeyPEM); err != nil { + log.Error().Err(err).Msg("Failed to send CA key to gateway") + return + } + + log.Info().Msg("Established connection to GCP IAM resource") + + connCtx, connCancel := context.WithCancel(p.ctx) + defer connCancel() + + done := make(chan struct{}, 2) + + go func() { + defer connCancel() + _, err := io.Copy(clientConn, gatewayConn) + if err != nil { + select { + case <-connCtx.Done(): + default: + log.Debug().Err(err).Msg("Gateway to client copy ended") + } + } + done <- struct{}{} + }() + + go func() { + defer connCancel() + _, err := io.Copy(gatewayConn, clientConn) + if err != nil { + select { + case <-connCtx.Done(): + default: + log.Debug().Err(err).Msg("Client to gateway copy ended") + } + } + done <- struct{}{} + }() + + select { + case <-done: + case <-connCtx.Done(): + log.Info().Msg("Connection cancelled by context") + } + + log.Info().Msgf("Connection closed for client: %s", clientConn.RemoteAddr().String()) +} + +func startGCPProxy(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 := &GCPProxyServer{ + 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.generateCA(); err != nil { + util.HandleError(err, "Failed to generate CA for proxy") + return + } + + err = proxy.Start(port) + if err != nil { + util.HandleError(err, "Failed to start proxy server") + return + } + + if err := proxy.SetupGcloudProxy(); err != nil { + log.Warn().Err(err).Msg("Failed to configure gcloud proxy settings automatically. You can set them manually.") + } + defer proxy.RevertGcloudProxy() + + folder, account := parsePath(path) + serviceAccountEmail := response.Metadata["serviceAccountEmail"] + + log.Info().Msgf("GCP IAM proxy server listening on port %d", proxy.port) + printGCPSessionInfo(folder, account, duration, serviceAccountEmail, 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) + go func() { + <-sigChan + log.Warn().Msg("Forced exit") + os.Exit(1) + }() + proxy.gracefulShutdown() + }() + + proxy.Run() +} + +func printGCPSessionInfo(folder, account string, duration time.Duration, serviceAccountEmail string, port int) { + fmt.Printf("\n") + fmt.Printf("**********************************************************************\n") + fmt.Printf(" GCP IAM 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(" Service Account: %s\n", serviceAccountEmail) + fmt.Printf(" Duration: %s\n", duration.String()) + fmt.Printf("\n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf(" How to Connect \n") + fmt.Printf("----------------------------------------------------------------------\n") + fmt.Printf("\n") + fmt.Printf(" Your gcloud CLI has been configured to use this proxy.\n") + fmt.Printf(" You can now use gcloud commands as usual:\n") + fmt.Printf("\n") + fmt.Printf(" Examples:\n") + util.PrintfStderr(" $ gcloud compute instances list\n") + util.PrintfStderr(" $ gcloud storage ls\n") + fmt.Printf("\n") + fmt.Printf(" Press Ctrl+C to stop the proxy.\n") + fmt.Printf("\n") + fmt.Printf("**********************************************************************\n") + fmt.Printf("\n") +} diff --git a/packages/pam/pam-proxy.go b/packages/pam/pam-proxy.go index 7e65e3e2..b22d78ba 100644 --- a/packages/pam/pam-proxy.go +++ b/packages/pam/pam-proxy.go @@ -14,6 +14,7 @@ import ( "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/pam/handlers" + "github.com/Infisical/infisical-merge/packages/pam/handlers/gcp" "github.com/Infisical/infisical-merge/packages/pam/handlers/kubernetes" "github.com/Infisical/infisical-merge/packages/pam/handlers/mongodb" "github.com/Infisical/infisical-merge/packages/pam/handlers/mssql" @@ -57,6 +58,7 @@ func GetSupportedResourceTypes() []string { session.ResourceTypeRedis, session.ResourceTypeMongodb, session.ResourceTypeOracledb, + session.ResourceTypeGcpIam, } // Only advertise RDP when the real bridge is compiled in. A stub // build would otherwise accept RDP session routing and fail every @@ -172,7 +174,15 @@ func compilePolicyPatterns(config *api.PAMPolicyRuleConfig, sessionID string, ru // the browser RDP flow (RDCleanPath over the inbound stream) when the // resource is Windows; ignored for other resource types. func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMConfig, httpClient *resty.Client, browserRDP bool) error { - credentials, err := pamConfig.CredentialsManager.GetPAMSessionCredentials(pamConfig.SessionId, pamConfig.ExpiryTime) + credentialExpiryTime := pamConfig.ExpiryTime + if pamConfig.ResourceType == session.ResourceTypeGcpIam { + gcpTokenMaxLifetime := time.Now().Add(1 * time.Hour) + if gcpTokenMaxLifetime.Before(credentialExpiryTime) { + credentialExpiryTime = gcpTokenMaxLifetime + } + } + + credentials, err := pamConfig.CredentialsManager.GetPAMSessionCredentials(pamConfig.SessionId, credentialExpiryTime) if err != nil { log.Error().Err(err).Str("sessionId", pamConfig.SessionId).Msg("Failed to retrieve PAM session credentials") return fmt.Errorf("failed to retrieve PAM session credentials: %w", err) @@ -492,6 +502,18 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo return proxy.HandleConnectionRDCleanPath(ctx, handlerConn) } return proxy.HandleConnection(ctx, handlerConn) + case session.ResourceTypeGcpIam: + gcpConfig := gcp.GCPProxyConfig{ + Token: credentials.Token, + SessionID: pamConfig.SessionId, + SessionLogger: sessionLogger, + } + proxy := gcp.NewGCPProxy(gcpConfig) + log.Info(). + Str("sessionId", pamConfig.SessionId). + Str("serviceAccountEmail", credentials.ServiceAccountEmail). + Msg("Starting GCP IAM PAM proxy") + return proxy.HandleConnection(ctx, handlerConn) default: return fmt.Errorf("unsupported resource type: %s", pamConfig.ResourceType) } diff --git a/packages/pam/session/credentials.go b/packages/pam/session/credentials.go index d0e8e84d..c64421d5 100644 --- a/packages/pam/session/credentials.go +++ b/packages/pam/session/credentials.go @@ -38,6 +38,8 @@ type PAMCredentials struct { Realm string KDCAddress string SPN string + Token string + ServiceAccountEmail string PolicyRules *api.PAMPolicyRules } @@ -194,6 +196,8 @@ func (cm *CredentialsManager) GetPAMSessionCredentials(sessionId string, expiryT Realm: response.Credentials.Realm, KDCAddress: response.Credentials.KDCAddress, SPN: response.Credentials.SPN, + Token: response.Credentials.Token, + ServiceAccountEmail: response.Credentials.ServiceAccountEmail, PolicyRules: response.PolicyRules, } diff --git a/packages/pam/session/uploader.go b/packages/pam/session/uploader.go index 6a8d424a..7f8a3e5b 100644 --- a/packages/pam/session/uploader.go +++ b/packages/pam/session/uploader.go @@ -34,6 +34,7 @@ const ( ResourceTypeMongodb = "mongodb" ResourceTypeOracledb = "oracledb" ResourceTypeWindows = "windows" + ResourceTypeGcpIam = "gcp-iam" ) type SessionFileInfo struct { @@ -80,7 +81,7 @@ func NewSessionUploader(httpClient *resty.Client, credentialsManager *Credential func ParseSessionFilename(filename string) (*SessionFileInfo, error) { // Try new format first: pam_session_{sessionID}_{resourceType}_expires_{timestamp}.enc // Build regex pattern using constants - resourceTypePattern := fmt.Sprintf("(%s|%s|%s|%s|%s|%s|%s|%s|%s)", ResourceTypeSSH, ResourceTypePostgres, ResourceTypeRedis, ResourceTypeMysql, ResourceTypeMssql, ResourceTypeKubernetes, ResourceTypeMongodb, ResourceTypeOracledb, ResourceTypeWindows) + resourceTypePattern := fmt.Sprintf("(%s|%s|%s|%s|%s|%s|%s|%s|%s|%s)", ResourceTypeSSH, ResourceTypePostgres, ResourceTypeRedis, ResourceTypeMysql, ResourceTypeMssql, ResourceTypeKubernetes, ResourceTypeMongodb, ResourceTypeOracledb, ResourceTypeWindows, ResourceTypeGcpIam) newFormatRegex := regexp.MustCompile(fmt.Sprintf(`^pam_session_(.+)_%s_expires_(\d+)\.enc$`, resourceTypePattern)) matches := newFormatRegex.FindStringSubmatch(filename) @@ -692,7 +693,7 @@ func (su *SessionUploader) uploadSessionFile(fileInfo *SessionFileInfo) error { return api.CallUploadPamSessionLogs(su.httpClient, fileInfo.SessionID, api.UploadPAMSessionLogsRequest{Logs: logs}) } - if fileInfo.ResourceType == ResourceTypeKubernetes { + if fileInfo.ResourceType == ResourceTypeKubernetes || fileInfo.ResourceType == ResourceTypeGcpIam { httpEvents, err := ReadEncryptedHttpEventsFromFile(fileInfo.Filename, encryptionKey) if err != nil { return fmt.Errorf("failed to read Kubernetes session file: %w", err)