From 39efb9d1abfba1576228674c265aa72471e6c13a Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Tue, 14 Apr 2026 05:11:14 +0530 Subject: [PATCH 1/7] feat: add gateway-mediated kubernetes authentication for PAM Add impersonation support to the PAM Kubernetes proxy. When auth method is gateway-kubernetes-auth, the gateway reads its own pod token, sets Impersonate-User/Group headers, auto-discovers the K8s API from env vars, and uses the pod CA cert for TLS. --- packages/api/model.go | 2 + packages/pam/handlers/kubernetes/proxy.go | 46 +++++++++++++++++++++-- packages/pam/pam-proxy.go | 29 ++++++++++++++ packages/pam/session/credentials.go | 4 ++ 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/packages/api/model.go b/packages/api/model.go index 7e1adf8e..f5235af4 100644 --- a/packages/api/model.go +++ b/packages/api/model.go @@ -864,6 +864,8 @@ type PAMSessionCredentials struct { Certificate string `json:"certificate,omitempty"` Url string `json:"url,omitempty"` ServiceAccountToken string `json:"serviceAccountToken,omitempty"` + ServiceAccountName string `json:"serviceAccountName,omitempty"` + Namespace string `json:"namespace,omitempty"` } type MFASessionStatus string diff --git a/packages/pam/handlers/kubernetes/proxy.go b/packages/pam/handlers/kubernetes/proxy.go index 39b52825..bba376ed 100644 --- a/packages/pam/handlers/kubernetes/proxy.go +++ b/packages/pam/handlers/kubernetes/proxy.go @@ -11,11 +11,13 @@ import ( "net" "net/http" "net/url" + "os" "strings" "sync" "time" "github.com/Infisical/infisical-merge/packages/pam/session" + "github.com/Infisical/infisical-merge/packages/util" "github.com/google/uuid" "github.com/rs/zerolog/log" ) @@ -24,6 +26,8 @@ type KubernetesProxyConfig struct { TargetApiServer string AuthMethod string InjectServiceAccountToken string + ImpersonateNamespace string + ImpersonateServiceAccount string TLSConfig *tls.Config SessionID string SessionLogger session.SessionLogger @@ -40,6 +44,33 @@ func NewKubernetesProxy(config KubernetesProxyConfig) *KubernetesProxy { return &KubernetesProxy{config: config} } +// injectAuthHeaders sets the appropriate auth headers based on the configured auth method. +// For service-account-token: injects the stored Bearer token. +// For gateway-kubernetes-auth: reads the gateway pod's own token (fresh each call) and sets +// Impersonate-User/Group headers to act as the target service account. +func (p *KubernetesProxy) injectAuthHeaders(headers http.Header) error { + switch p.config.AuthMethod { + case "service-account-token": + headers.Set("Authorization", fmt.Sprintf("Bearer %s", p.config.InjectServiceAccountToken)) + case "gateway-kubernetes-auth": + // Read fresh on each request — K8s auto-rotates projected volume tokens + token, err := os.ReadFile(util.KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH) + if err != nil { + return fmt.Errorf("gateway not running in K8s cluster, unable to read pod service account token: %w", err) + } + headers.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(string(token)))) + + saUser := fmt.Sprintf("system:serviceaccount:%s:%s", + p.config.ImpersonateNamespace, p.config.ImpersonateServiceAccount) + headers.Set("Impersonate-User", saUser) + headers.Set("Impersonate-Group", "system:serviceaccounts") + headers.Add("Impersonate-Group", fmt.Sprintf("system:serviceaccounts:%s", p.config.ImpersonateNamespace)) + default: + return fmt.Errorf("unsupported Kubernetes auth method: %s", p.config.AuthMethod) + } + return nil +} + func buildHttpInternalServerError(message string) string { return fmt.Sprintf("HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\n\r\n{\"message\": \"gateway: %s\"}", message) } @@ -165,7 +196,14 @@ func (p *KubernetesProxy) HandleConnection(ctx context.Context, clientConn net.C continue // Continue to next request } proxyReq.Header = req.Header.Clone() - proxyReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.config.InjectServiceAccountToken)) + if err := p.injectAuthHeaders(proxyReq.Header); err != nil { + l.Error().Err(err).Msg("Failed to inject auth headers") + _, err = clientConn.Write([]byte(buildHttpInternalServerError(err.Error()))) + if err != nil { + return err + } + continue + } resp, err := selfServerClient.Do(proxyReq) if err != nil { @@ -255,8 +293,10 @@ func (p *KubernetesProxy) forwardWebsocketConnection( sb.WriteString(fmt.Sprintf("%s %s HTTP/1.1\r\n", req.Method, newUrl.RequestURI())) headers := req.Header.Clone() headers.Set("Host", newUrl.Host) - // Inject the auth header - headers.Set("Authorization", fmt.Sprintf("Bearer %s", p.config.InjectServiceAccountToken)) + if err := p.injectAuthHeaders(headers); err != nil { + l.Error().Err(err).Msg("Failed to inject auth headers for websocket") + return err + } for key, values := range headers { for _, value := range values { sb.WriteString(fmt.Sprintf("%s: %s\r\n", key, value)) diff --git a/packages/pam/pam-proxy.go b/packages/pam/pam-proxy.go index b4cf2650..d78a5d85 100644 --- a/packages/pam/pam-proxy.go +++ b/packages/pam/pam-proxy.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "net/url" + "os" "time" "github.com/Infisical/infisical-merge/packages/pam/handlers" @@ -17,6 +18,7 @@ import ( "github.com/Infisical/infisical-merge/packages/pam/handlers/redis" "github.com/Infisical/infisical-merge/packages/pam/handlers/ssh" "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" ) @@ -297,10 +299,37 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo SessionID: pamConfig.SessionId, SessionLogger: sessionLogger, } + + // For gateway-kubernetes-auth, override target URL and TLS with pod's in-cluster credentials + if credentials.AuthMethod == "gateway-kubernetes-auth" { + kubernetesConfig.ImpersonateNamespace = credentials.Namespace + kubernetesConfig.ImpersonateServiceAccount = credentials.ServiceAccountName + + // Auto-discover K8s API URL from env vars + k8sHost := util.KUBERNETES_SERVICE_HOST_ENV_NAME + k8sPort := util.KUBERNETES_SERVICE_PORT_HTTPS_ENV_NAME + if host, port := os.Getenv(k8sHost), os.Getenv(k8sPort); host != "" && port != "" { + kubernetesConfig.TargetApiServer = fmt.Sprintf("https://%s:%s", host, port) + } + + // Use pod's in-cluster CA cert with strict TLS (ignore resource SSL settings) + caCert, err := os.ReadFile(util.KUBERNETES_SERVICE_ACCOUNT_CA_CERT_PATH) + if err != nil { + log.Warn().Err(err).Msg("Failed to read pod CA cert, falling back to resource TLS config") + } else { + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + kubernetesConfig.TLSConfig = &tls.Config{ + RootCAs: caCertPool, + } + } + } + proxy := kubernetes.NewKubernetesProxy(kubernetesConfig) log.Info(). Str("sessionId", pamConfig.SessionId). Str("target", kubernetesConfig.TargetApiServer). + Str("authMethod", credentials.AuthMethod). Msg("Starting Kubernetes PAM proxy") return proxy.HandleConnection(ctx, conn) case session.ResourceTypeMongodb: diff --git a/packages/pam/session/credentials.go b/packages/pam/session/credentials.go index 1e2901b1..2ac6da2b 100644 --- a/packages/pam/session/credentials.go +++ b/packages/pam/session/credentials.go @@ -25,6 +25,8 @@ type PAMCredentials struct { SSLCertificate string Url string ServiceAccountToken string + ServiceAccountName string + Namespace string } type cachedCredentials struct { @@ -100,6 +102,8 @@ func (cm *CredentialsManager) GetPAMSessionCredentials(sessionId string, expiryT SSLCertificate: response.Credentials.SSLCertificate, Url: response.Credentials.Url, ServiceAccountToken: response.Credentials.ServiceAccountToken, + ServiceAccountName: response.Credentials.ServiceAccountName, + Namespace: response.Credentials.Namespace, } cm.cacheMutex.Lock() From 894431c04037e9c4fb9b67c35d421201115d5a36 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:48:24 +0530 Subject: [PATCH 2/7] fix: address review feedback on gateway k8s auth - Treat empty authMethod as service-account-token for backwards compat - Add empty namespace/SA name guard before constructing impersonation headers - Use net.JoinHostPort for IPv6-safe URL construction - Sanitize error messages sent to kubectl client (use static strings) - Write HTTP 500 before returning on websocket auth failure - Log warning when KUBERNETES_SERVICE_HOST env vars are missing --- packages/pam/handlers/kubernetes/proxy.go | 8 ++++++-- packages/pam/pam-proxy.go | 11 +++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/pam/handlers/kubernetes/proxy.go b/packages/pam/handlers/kubernetes/proxy.go index bba376ed..0b6b16c9 100644 --- a/packages/pam/handlers/kubernetes/proxy.go +++ b/packages/pam/handlers/kubernetes/proxy.go @@ -50,9 +50,12 @@ func NewKubernetesProxy(config KubernetesProxyConfig) *KubernetesProxy { // Impersonate-User/Group headers to act as the target service account. func (p *KubernetesProxy) injectAuthHeaders(headers http.Header) error { switch p.config.AuthMethod { - case "service-account-token": + case "service-account-token", "": headers.Set("Authorization", fmt.Sprintf("Bearer %s", p.config.InjectServiceAccountToken)) case "gateway-kubernetes-auth": + if p.config.ImpersonateNamespace == "" || p.config.ImpersonateServiceAccount == "" { + return fmt.Errorf("gateway-kubernetes-auth requires non-empty namespace and service account name") + } // Read fresh on each request — K8s auto-rotates projected volume tokens token, err := os.ReadFile(util.KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH) if err != nil { @@ -198,7 +201,7 @@ func (p *KubernetesProxy) HandleConnection(ctx context.Context, clientConn net.C proxyReq.Header = req.Header.Clone() if err := p.injectAuthHeaders(proxyReq.Header); err != nil { l.Error().Err(err).Msg("Failed to inject auth headers") - _, err = clientConn.Write([]byte(buildHttpInternalServerError(err.Error()))) + _, err = clientConn.Write([]byte(buildHttpInternalServerError("failed to configure auth headers"))) if err != nil { return err } @@ -295,6 +298,7 @@ func (p *KubernetesProxy) forwardWebsocketConnection( headers.Set("Host", newUrl.Host) if err := p.injectAuthHeaders(headers); err != nil { l.Error().Err(err).Msg("Failed to inject auth headers for websocket") + clientConn.Write([]byte(buildHttpInternalServerError("failed to configure auth headers"))) return err } for key, values := range headers { diff --git a/packages/pam/pam-proxy.go b/packages/pam/pam-proxy.go index d78a5d85..b7c57512 100644 --- a/packages/pam/pam-proxy.go +++ b/packages/pam/pam-proxy.go @@ -6,6 +6,7 @@ import ( "crypto/x509" "encoding/json" "fmt" + "net" "net/url" "os" "time" @@ -306,10 +307,12 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo kubernetesConfig.ImpersonateServiceAccount = credentials.ServiceAccountName // Auto-discover K8s API URL from env vars - k8sHost := util.KUBERNETES_SERVICE_HOST_ENV_NAME - k8sPort := util.KUBERNETES_SERVICE_PORT_HTTPS_ENV_NAME - if host, port := os.Getenv(k8sHost), os.Getenv(k8sPort); host != "" && port != "" { - kubernetesConfig.TargetApiServer = fmt.Sprintf("https://%s:%s", host, port) + if host, port := os.Getenv(util.KUBERNETES_SERVICE_HOST_ENV_NAME), os.Getenv(util.KUBERNETES_SERVICE_PORT_HTTPS_ENV_NAME); host != "" && port != "" { + kubernetesConfig.TargetApiServer = fmt.Sprintf("https://%s", net.JoinHostPort(host, port)) + } else { + log.Warn(). + Str("sessionId", pamConfig.SessionId). + Msg("KUBERNETES_SERVICE_HOST or KUBERNETES_SERVICE_PORT_HTTPS not set; gateway-kubernetes-auth requires the gateway to run inside a K8s pod") } // Use pod's in-cluster CA cert with strict TLS (ignore resource SSL settings) From 789e57896009f88a609a62e7e48f0ad36ce56ddd Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:31:39 +0530 Subject: [PATCH 3/7] fix: address second round of review feedback - Add system:authenticated to Impersonate-Group headers (required for K8s API discovery endpoints) - Fail fast when KUBERNETES_SERVICE_HOST env vars are missing instead of logging warning and continuing with broken state - Check AppendCertsFromPEM return value before overriding TLS config - Use _, _ for discarded write error in websocket path --- packages/pam/handlers/kubernetes/proxy.go | 3 ++- packages/pam/pam-proxy.go | 21 ++++++++++++--------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/pam/handlers/kubernetes/proxy.go b/packages/pam/handlers/kubernetes/proxy.go index 0b6b16c9..4d28838c 100644 --- a/packages/pam/handlers/kubernetes/proxy.go +++ b/packages/pam/handlers/kubernetes/proxy.go @@ -68,6 +68,7 @@ func (p *KubernetesProxy) injectAuthHeaders(headers http.Header) error { headers.Set("Impersonate-User", saUser) headers.Set("Impersonate-Group", "system:serviceaccounts") headers.Add("Impersonate-Group", fmt.Sprintf("system:serviceaccounts:%s", p.config.ImpersonateNamespace)) + headers.Add("Impersonate-Group", "system:authenticated") default: return fmt.Errorf("unsupported Kubernetes auth method: %s", p.config.AuthMethod) } @@ -298,7 +299,7 @@ func (p *KubernetesProxy) forwardWebsocketConnection( headers.Set("Host", newUrl.Host) if err := p.injectAuthHeaders(headers); err != nil { l.Error().Err(err).Msg("Failed to inject auth headers for websocket") - clientConn.Write([]byte(buildHttpInternalServerError("failed to configure auth headers"))) + _, _ = clientConn.Write([]byte(buildHttpInternalServerError("failed to configure auth headers"))) return err } for key, values := range headers { diff --git a/packages/pam/pam-proxy.go b/packages/pam/pam-proxy.go index b7c57512..2788449e 100644 --- a/packages/pam/pam-proxy.go +++ b/packages/pam/pam-proxy.go @@ -307,13 +307,11 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo kubernetesConfig.ImpersonateServiceAccount = credentials.ServiceAccountName // Auto-discover K8s API URL from env vars - if host, port := os.Getenv(util.KUBERNETES_SERVICE_HOST_ENV_NAME), os.Getenv(util.KUBERNETES_SERVICE_PORT_HTTPS_ENV_NAME); host != "" && port != "" { - kubernetesConfig.TargetApiServer = fmt.Sprintf("https://%s", net.JoinHostPort(host, port)) - } else { - log.Warn(). - Str("sessionId", pamConfig.SessionId). - Msg("KUBERNETES_SERVICE_HOST or KUBERNETES_SERVICE_PORT_HTTPS not set; gateway-kubernetes-auth requires the gateway to run inside a K8s pod") + host, port := os.Getenv(util.KUBERNETES_SERVICE_HOST_ENV_NAME), os.Getenv(util.KUBERNETES_SERVICE_PORT_HTTPS_ENV_NAME) + if host == "" || port == "" { + return fmt.Errorf("gateway-kubernetes-auth requires KUBERNETES_SERVICE_HOST and KUBERNETES_SERVICE_PORT_HTTPS to be set; gateway must run inside a Kubernetes pod") } + kubernetesConfig.TargetApiServer = fmt.Sprintf("https://%s", net.JoinHostPort(host, port)) // Use pod's in-cluster CA cert with strict TLS (ignore resource SSL settings) caCert, err := os.ReadFile(util.KUBERNETES_SERVICE_ACCOUNT_CA_CERT_PATH) @@ -321,9 +319,14 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo log.Warn().Err(err).Msg("Failed to read pod CA cert, falling back to resource TLS config") } else { caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCert) - kubernetesConfig.TLSConfig = &tls.Config{ - RootCAs: caCertPool, + if caCertPool.AppendCertsFromPEM(caCert) { + kubernetesConfig.TLSConfig = &tls.Config{ + RootCAs: caCertPool, + } + } else { + log.Warn(). + Str("sessionId", pamConfig.SessionId). + Msg("Failed to parse pod CA cert PEM, falling back to resource TLS config") } } } From 8c01f48340251337cddb963858dcb2bef68da5be Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:32:00 +0530 Subject: [PATCH 4/7] fix: address third round of review feedback - Strip client-supplied Impersonate-* headers before injecting auth to prevent privilege escalation - Fail fast at session setup when namespace/SA name are empty instead of failing per-request - Return error instead of falling back when pod CA cert can't be read or parsed (fallback TLS config has InsecureSkipVerify: true) --- packages/pam/handlers/kubernetes/proxy.go | 9 +++++++++ packages/pam/pam-proxy.go | 23 +++++++++++------------ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/pam/handlers/kubernetes/proxy.go b/packages/pam/handlers/kubernetes/proxy.go index 4d28838c..930f35dc 100644 --- a/packages/pam/handlers/kubernetes/proxy.go +++ b/packages/pam/handlers/kubernetes/proxy.go @@ -49,6 +49,15 @@ func NewKubernetesProxy(config KubernetesProxyConfig) *KubernetesProxy { // For gateway-kubernetes-auth: reads the gateway pod's own token (fresh each call) and sets // Impersonate-User/Group headers to act as the target service account. func (p *KubernetesProxy) injectAuthHeaders(headers http.Header) error { + // Strip any client-supplied impersonation headers to prevent privilege escalation + headers.Del("Impersonate-User") + headers.Del("Impersonate-Group") + for key := range headers { + if strings.HasPrefix(strings.ToLower(key), "impersonate-extra-") { + headers.Del(key) + } + } + switch p.config.AuthMethod { case "service-account-token", "": headers.Set("Authorization", fmt.Sprintf("Bearer %s", p.config.InjectServiceAccountToken)) diff --git a/packages/pam/pam-proxy.go b/packages/pam/pam-proxy.go index 2788449e..bf961ece 100644 --- a/packages/pam/pam-proxy.go +++ b/packages/pam/pam-proxy.go @@ -305,6 +305,9 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo if credentials.AuthMethod == "gateway-kubernetes-auth" { kubernetesConfig.ImpersonateNamespace = credentials.Namespace kubernetesConfig.ImpersonateServiceAccount = credentials.ServiceAccountName + if credentials.Namespace == "" || credentials.ServiceAccountName == "" { + return fmt.Errorf("gateway-kubernetes-auth requires non-empty namespace and service account name") + } // Auto-discover K8s API URL from env vars host, port := os.Getenv(util.KUBERNETES_SERVICE_HOST_ENV_NAME), os.Getenv(util.KUBERNETES_SERVICE_PORT_HTTPS_ENV_NAME) @@ -316,18 +319,14 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo // Use pod's in-cluster CA cert with strict TLS (ignore resource SSL settings) caCert, err := os.ReadFile(util.KUBERNETES_SERVICE_ACCOUNT_CA_CERT_PATH) if err != nil { - log.Warn().Err(err).Msg("Failed to read pod CA cert, falling back to resource TLS config") - } else { - caCertPool := x509.NewCertPool() - if caCertPool.AppendCertsFromPEM(caCert) { - kubernetesConfig.TLSConfig = &tls.Config{ - RootCAs: caCertPool, - } - } else { - log.Warn(). - Str("sessionId", pamConfig.SessionId). - Msg("Failed to parse pod CA cert PEM, falling back to resource TLS config") - } + return fmt.Errorf("gateway-kubernetes-auth: failed to read pod CA cert for strict TLS: %w", err) + } + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + return fmt.Errorf("gateway-kubernetes-auth: pod CA cert PEM is invalid or empty; cannot establish strict TLS") + } + kubernetesConfig.TLSConfig = &tls.Config{ + RootCAs: caCertPool, } } From 983ce85aeafa09539336c95078a1bc62cc813bc7 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:57:06 +0530 Subject: [PATCH 5/7] fix: address fourth round of review feedback - Add defer sessionLogger.Close() in HandlePAMProxy to prevent file descriptor leak on early return paths - Strip Impersonate-Uid header alongside User/Group/Extra-* - Fail fast at session setup when namespace/SA name are empty --- packages/pam/handlers/kubernetes/proxy.go | 1 + packages/pam/pam-proxy.go | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/packages/pam/handlers/kubernetes/proxy.go b/packages/pam/handlers/kubernetes/proxy.go index 930f35dc..e7196b8d 100644 --- a/packages/pam/handlers/kubernetes/proxy.go +++ b/packages/pam/handlers/kubernetes/proxy.go @@ -52,6 +52,7 @@ func (p *KubernetesProxy) injectAuthHeaders(headers http.Header) error { // Strip any client-supplied impersonation headers to prevent privilege escalation headers.Del("Impersonate-User") headers.Del("Impersonate-Group") + headers.Del("Impersonate-Uid") for key := range headers { if strings.HasPrefix(strings.ToLower(key), "impersonate-extra-") { headers.Del(key) diff --git a/packages/pam/pam-proxy.go b/packages/pam/pam-proxy.go index bf961ece..eff0255b 100644 --- a/packages/pam/pam-proxy.go +++ b/packages/pam/pam-proxy.go @@ -161,6 +161,11 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo if err != nil { return fmt.Errorf("failed to create session logger: %w", err) } + defer func() { + if err := sessionLogger.Close(); err != nil { + log.Error().Err(err).Str("sessionId", pamConfig.SessionId).Msg("Failed to close session logger") + } + }() pamConfig.SessionUploader.RegisterSession(pamConfig.SessionId) serverName := credentials.Host From 0696f88888f0530f8709d6744bae3ac7ea02cdb4 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Wed, 15 Apr 2026 02:47:35 +0530 Subject: [PATCH 6/7] fix(pam): prevent kubernetes session from dying after websocket exec The local Kubernetes proxy used WaitForDisconnect which treats any gateway-side connection close as a session-level disconnect, shutting down the entire proxy. This works for persistent protocols (SSH, DB) but not Kubernetes, where each kubectl command is a separate connection and the gateway closing after handling a request is normal. Replace with a simple per-connection wait that lets the proxy stay alive for subsequent kubectl commands. --- packages/pam/local/kubernetes-proxy.go | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/pam/local/kubernetes-proxy.go b/packages/pam/local/kubernetes-proxy.go index 36be649c..aef8b0aa 100644 --- a/packages/pam/local/kubernetes-proxy.go +++ b/packages/pam/local/kubernetes-proxy.go @@ -304,9 +304,14 @@ func (p *KubernetesProxyServer) handleConnection(clientConn net.Conn) { connCtx, connCancel := context.WithCancel(p.ctx) defer connCancel() - gatewayErrCh, clientErrCh := p.NewDisconnectChannels() - - // Gateway → Client: if this side closes first, the gateway dropped the connection + // For Kubernetes, each kubectl command opens a separate connection. + // Unlike persistent protocols (SSH, databases), the gateway closing after + // handling a request is normal — not a session-level disconnect. + // So we just wait for either side to finish and return, without triggering + // HandleGatewayDisconnect which would shut down the entire proxy. + done := make(chan struct{}, 2) + + // Gateway → Client go func() { defer connCancel() _, err := io.Copy(clientConn, gatewayConn) @@ -317,10 +322,10 @@ func (p *KubernetesProxyServer) handleConnection(clientConn net.Conn) { log.Debug().Err(err).Msg("Gateway to client copy ended") } } - gatewayErrCh <- err + done <- struct{}{} }() - // Client → Gateway: if this side closes first, the client disconnected normally + // Client → Gateway go func() { defer connCancel() _, err := io.Copy(gatewayConn, clientConn) @@ -331,10 +336,15 @@ func (p *KubernetesProxyServer) handleConnection(clientConn net.Conn) { log.Debug().Err(err).Msg("Client to gateway copy ended") } } - clientErrCh <- err + done <- struct{}{} }() - p.WaitForDisconnect(gatewayErrCh, clientErrCh, connCtx) + // Wait for either side to finish — this is a per-connection close, not a session close + select { + case <-done: + case <-connCtx.Done(): + log.Info().Msg("Connection cancelled by context") + } log.Info().Msgf("Connection closed for client: %s", clientConn.RemoteAddr().String()) } From cc5a24a94f9743e15481ff7ff638c20e80c0aaad Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Fri, 17 Apr 2026 03:33:22 +0530 Subject: [PATCH 7/7] fix(pam): remove stale cluster entry from kubeconfig on session end gracefulShutdown deleted config.Contexts twice instead of also deleting config.Clusters, leaving the http://localhost: cluster entry orphaned in kubeconfig after each session. --- packages/pam/local/kubernetes-proxy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pam/local/kubernetes-proxy.go b/packages/pam/local/kubernetes-proxy.go index aef8b0aa..c41fa795 100644 --- a/packages/pam/local/kubernetes-proxy.go +++ b/packages/pam/local/kubernetes-proxy.go @@ -191,7 +191,7 @@ func (p *KubernetesProxyServer) gracefulShutdown() { delete(config.Contexts, p.kubeConfigClusterName) delete(config.AuthInfos, p.kubeConfigClusterName) - delete(config.Contexts, p.kubeConfigClusterName) + delete(config.Clusters, p.kubeConfigClusterName) if p.kubeConfigOriginalContext != "" { config.CurrentContext = p.kubeConfigOriginalContext }