From 8b7691c185a836b3b3f8b6a6a82cb93b93e357d6 Mon Sep 17 00:00:00 2001 From: "hua.zhong" Date: Tue, 30 Jun 2026 18:16:51 +0800 Subject: [PATCH] fix: support EKS member impersonation --- internal/api/v1/cluster/member.go | 40 +++++-- internal/api/v1/cluster/member_test.go | 100 ++++++++++++++++++ internal/api/v1/proxy/proxy.go | 11 +- internal/api/v1/webkubectl/webkubectl.go | 9 +- internal/api/v1/webkubectl/webkubectl_test.go | 34 ++++++ internal/service/v1/clusteraccess/access.go | 10 +- .../service/v1/clusteraccess/access_test.go | 47 ++++++++ 7 files changed, 232 insertions(+), 19 deletions(-) create mode 100644 internal/api/v1/cluster/member_test.go create mode 100644 internal/api/v1/webkubectl/webkubectl_test.go create mode 100644 internal/service/v1/clusteraccess/access_test.go diff --git a/internal/api/v1/cluster/member.go b/internal/api/v1/cluster/member.go index d6b0e8af..31939366 100644 --- a/internal/api/v1/cluster/member.go +++ b/internal/api/v1/cluster/member.go @@ -17,6 +17,7 @@ import ( "github.com/kataras/iris/v12" "github.com/kataras/iris/v12/context" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/clientcmd" ) // Update Cluster Member @@ -214,6 +215,28 @@ func (h *Handler) ListClusterMembers() iris.Handler { // @Success 200 {object} Member // @Security ApiKeyAuth // @Router /clusters/{cluster}/members [post] +func canContinueWithoutMemberCertificate(err error) bool { + return err != nil && strings.Contains(err.Error(), "csr approve time out") +} +func shouldUseImpersonationOnly(c *v1Cluster.Cluster) bool { + if c == nil || strings.ToLower(c.Spec.Authentication.Mode) != "configfile" { + return false + } + kubeConfig, err := clientcmd.Load(c.Spec.Authentication.ConfigFileContent) + if err != nil { + return false + } + ctx := kubeConfig.Contexts[kubeConfig.CurrentContext] + if ctx == nil { + return false + } + authInfo := kubeConfig.AuthInfos[ctx.AuthInfo] + if authInfo == nil { + return false + } + return (authInfo.Token != "" || authInfo.Exec != nil) && len(authInfo.ClientCertificateData) == 0 && len(authInfo.ClientKeyData) == 0 +} + func (h *Handler) CreateClusterMember() iris.Handler { return func(ctx *context.Context) { name := ctx.Params().GetString("name") @@ -257,12 +280,17 @@ func (h *Handler) CreateClusterMember() iris.Handler { } k := kubernetes.NewKubernetes(c) - cert, err := k.CreateCommonUser(req.Name) - if err != nil { - _ = tx.Rollback() - ctx.StatusCode(iris.StatusInternalServerError) - ctx.Values().Set("message", fmt.Sprintf("create common user failed: %s", err.Error())) - return + var cert []byte + if !shouldUseImpersonationOnly(c) { + cert, err = k.CreateCommonUser(req.Name) + if err != nil { + if !canContinueWithoutMemberCertificate(err) { + _ = tx.Rollback() + ctx.StatusCode(iris.StatusInternalServerError) + ctx.Values().Set("message", fmt.Sprintf("create common user failed: %s", err.Error())) + return + } + } } binding.Certificate = cert if err := h.clusterBindingService.CreateClusterBinding(&binding, common.DBOptions{DB: tx}); err != nil { diff --git a/internal/api/v1/cluster/member_test.go b/internal/api/v1/cluster/member_test.go new file mode 100644 index 00000000..3e782b3f --- /dev/null +++ b/internal/api/v1/cluster/member_test.go @@ -0,0 +1,100 @@ +package cluster + +import ( + "errors" + "testing" + + v1Cluster "github.com/1Panel-dev/KubePi/internal/model/v1/cluster" +) + +func TestCanContinueWithoutMemberCertificateForCSRTimeout(t *testing.T) { + if !canContinueWithoutMemberCertificate(errors.New("csr approve time out ,30 sec")) { + t.Fatalf("expected CSR timeout to allow impersonation fallback") + } +} + +func TestCanContinueWithoutMemberCertificateRejectsOtherErrors(t *testing.T) { + if canContinueWithoutMemberCertificate(errors.New("forbidden")) { + t.Fatalf("expected non-CSR errors to remain fatal") + } +} + +func TestShouldUseImpersonationOnlyForTokenKubeconfig(t *testing.T) { + c := &v1Cluster.Cluster{} + c.Spec.Authentication.Mode = "configFile" + c.Spec.Authentication.ConfigFileContent = []byte(`apiVersion: v1 +kind: Config +clusters: +- name: c + cluster: + server: https://example.invalid +contexts: +- name: c + context: + cluster: c + user: u +current-context: c +users: +- name: u + user: + token: token-value +`) + + if !shouldUseImpersonationOnly(c) { + t.Fatalf("expected token kubeconfig to skip CSR and use impersonation") + } +} +func TestShouldUseImpersonationOnlyForExecKubeconfig(t *testing.T) { + c := &v1Cluster.Cluster{} + c.Spec.Authentication.Mode = "configFile" + c.Spec.Authentication.ConfigFileContent = []byte(`apiVersion: v1 +kind: Config +clusters: +- name: c + cluster: + server: https://example.invalid +contexts: +- name: c + context: + cluster: c + user: u +current-context: c +users: +- name: u + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + command: aws +`) + + if !shouldUseImpersonationOnly(c) { + t.Fatalf("expected exec kubeconfig to skip CSR and use impersonation") + } +} + +func TestShouldUseImpersonationOnlyRejectsCertificateKubeconfig(t *testing.T) { + c := &v1Cluster.Cluster{} + c.Spec.Authentication.Mode = "configFile" + c.Spec.Authentication.ConfigFileContent = []byte(`apiVersion: v1 +kind: Config +clusters: +- name: c + cluster: + server: https://example.invalid +contexts: +- name: c + context: + cluster: c + user: u +current-context: c +users: +- name: u + user: + client-certificate-data: cert + client-key-data: key +`) + + if shouldUseImpersonationOnly(c) { + t.Fatalf("expected certificate kubeconfig to keep CSR certificate flow") + } +} diff --git a/internal/api/v1/proxy/proxy.go b/internal/api/v1/proxy/proxy.go index 79b572bd..c4f2b98a 100644 --- a/internal/api/v1/proxy/proxy.go +++ b/internal/api/v1/proxy/proxy.go @@ -2,7 +2,6 @@ package proxy import ( "encoding/json" - "encoding/pem" "errors" "fmt" "io/ioutil" @@ -16,6 +15,7 @@ import ( "github.com/1Panel-dev/KubePi/internal/api/v1/session" v1Cluster "github.com/1Panel-dev/KubePi/internal/model/v1/cluster" "github.com/1Panel-dev/KubePi/internal/service/v1/cluster" + "github.com/1Panel-dev/KubePi/internal/service/v1/clusteraccess" "github.com/1Panel-dev/KubePi/internal/service/v1/clusterbinding" "github.com/1Panel-dev/KubePi/internal/service/v1/common" pkgV1 "github.com/1Panel-dev/KubePi/pkg/api/v1" @@ -449,14 +449,7 @@ func (h *Handler) generateTLSTransport(c *v1Cluster.Cluster, profile session.Use if err != nil { return nil, err } - kubeConf.Username = "" - kubeConf.Password = "" - kubeConf.BearerToken = "" - kubeConf.BearerTokenFile = "" - kubeConf.AuthProvider = nil - kubeConf.ExecProvider = nil - kubeConf.TLSClientConfig.CertData = binding.Certificate - kubeConf.TLSClientConfig.KeyData = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: c.PrivateKey}) + clusteraccess.ApplyUserAccessConfig(kubeConf, c, binding) return rest.TransportFor(kubeConf) } diff --git a/internal/api/v1/webkubectl/webkubectl.go b/internal/api/v1/webkubectl/webkubectl.go index 217660c7..45fdbac9 100644 --- a/internal/api/v1/webkubectl/webkubectl.go +++ b/internal/api/v1/webkubectl/webkubectl.go @@ -1,12 +1,12 @@ package webkubectl import ( - "encoding/pem" "fmt" "time" "github.com/1Panel-dev/KubePi/internal/api/v1/session" "github.com/1Panel-dev/KubePi/internal/service/v1/cluster" + "github.com/1Panel-dev/KubePi/internal/service/v1/clusteraccess" "github.com/1Panel-dev/KubePi/internal/service/v1/clusterbinding" "github.com/1Panel-dev/KubePi/internal/service/v1/common" "github.com/1Panel-dev/KubePi/pkg/kubernetes" @@ -82,6 +82,10 @@ func toCmdConfig(sess *Session) *clientcmdapi.Config { Token: sess.config.BearerToken, Username: sess.config.Username, Password: sess.config.Password, + Impersonate: sess.config.Impersonate.UserName, + ImpersonateUID: sess.config.Impersonate.UID, + ImpersonateGroups: sess.config.Impersonate.Groups, + ImpersonateUserExtra: sess.config.Impersonate.Extra, } contextName := fmt.Sprintf("%s@%s", sess.Cluster, sess.User) cc.Contexts[contextName] = &clientcmdapi.Context{ @@ -123,8 +127,7 @@ func (h *Handler) CreateSession() iris.Handler { ctx.Values().Set("message", err.Error()) return } - cfg.CertData = rb.Certificate - cfg.KeyData = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: c.PrivateKey}) + clusteraccess.ApplyUserAccessConfig(cfg, c, rb) } sess.config = cfg sess.User = profile.Name diff --git a/internal/api/v1/webkubectl/webkubectl_test.go b/internal/api/v1/webkubectl/webkubectl_test.go new file mode 100644 index 00000000..725a8b27 --- /dev/null +++ b/internal/api/v1/webkubectl/webkubectl_test.go @@ -0,0 +1,34 @@ +package webkubectl + +import ( + "testing" + + "k8s.io/client-go/rest" +) + +func TestToCmdConfigPreservesImpersonation(t *testing.T) { + sess := &Session{ + Cluster: "eks", + User: "alice", + config: &rest.Config{ + Host: "https://example.invalid", + BearerToken: "admin-token", + Impersonate: rest.ImpersonationConfig{UserName: "alice", Groups: []string{"developers"}}, + }, + } + + cfg := toCmdConfig(sess) + auth := cfg.AuthInfos["alice"] + if auth == nil { + t.Fatalf("expected auth info for alice") + } + if auth.Token != "admin-token" { + t.Fatalf("expected bearer token to be preserved") + } + if auth.Impersonate != "alice" { + t.Fatalf("expected impersonation username alice, got %q", auth.Impersonate) + } + if len(auth.ImpersonateGroups) != 1 || auth.ImpersonateGroups[0] != "developers" { + t.Fatalf("expected impersonation groups to be preserved") + } +} diff --git a/internal/service/v1/clusteraccess/access.go b/internal/service/v1/clusteraccess/access.go index 6a0b409c..0b011806 100644 --- a/internal/service/v1/clusteraccess/access.go +++ b/internal/service/v1/clusteraccess/access.go @@ -35,6 +35,15 @@ func ConfigForUser(clusterName string, user User) (*rest.Config, *v1Cluster.Clus if err != nil { return nil, nil, err } + ApplyUserAccessConfig(cfg, c, binding) + return cfg, c, nil +} +func ApplyUserAccessConfig(cfg *rest.Config, c *v1Cluster.Cluster, binding *v1Cluster.Binding) { + if len(binding.Certificate) == 0 { + cfg.Impersonate = rest.ImpersonationConfig{UserName: binding.UserRef} + return + } + cfg.Username = "" cfg.Password = "" cfg.BearerToken = "" @@ -44,7 +53,6 @@ func ConfigForUser(clusterName string, user User) (*rest.Config, *v1Cluster.Clus cfg.Impersonate = rest.ImpersonationConfig{} cfg.CertData = binding.Certificate cfg.KeyData = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: c.PrivateKey}) - return cfg, c, nil } func ClientForUser(clusterName string, user User) (*kubernetesClient.Clientset, *rest.Config, *v1Cluster.Cluster, error) { diff --git a/internal/service/v1/clusteraccess/access_test.go b/internal/service/v1/clusteraccess/access_test.go new file mode 100644 index 00000000..31e08fa1 --- /dev/null +++ b/internal/service/v1/clusteraccess/access_test.go @@ -0,0 +1,47 @@ +package clusteraccess + +import ( + "testing" + + v1Cluster "github.com/1Panel-dev/KubePi/internal/model/v1/cluster" + "k8s.io/client-go/rest" +) + +func TestApplyUserAccessUsesImpersonationWhenMemberCertificateMissing(t *testing.T) { + cfg := &rest.Config{BearerToken: "admin-token"} + cluster := &v1Cluster.Cluster{PrivateKey: []byte("private-key")} + binding := &v1Cluster.Binding{UserRef: "alice"} + + ApplyUserAccessConfig(cfg, cluster, binding) + + if cfg.BearerToken != "admin-token" { + t.Fatalf("expected imported cluster bearer token to remain for impersonation, got %q", cfg.BearerToken) + } + if cfg.Impersonate.UserName != "alice" { + t.Fatalf("expected impersonation username alice, got %q", cfg.Impersonate.UserName) + } + if len(cfg.CertData) != 0 || len(cfg.KeyData) != 0 { + t.Fatalf("expected no client certificate when binding has none") + } +} + +func TestApplyUserAccessUsesMemberCertificateWhenAvailable(t *testing.T) { + cfg := &rest.Config{BearerToken: "admin-token"} + cluster := &v1Cluster.Cluster{PrivateKey: []byte("private-key")} + binding := &v1Cluster.Binding{UserRef: "alice", Certificate: []byte("cert")} + + ApplyUserAccessConfig(cfg, cluster, binding) + + if cfg.BearerToken != "" { + t.Fatalf("expected bearer token cleared for certificate auth, got %q", cfg.BearerToken) + } + if cfg.Impersonate.UserName != "" { + t.Fatalf("expected no impersonation when member certificate exists, got %q", cfg.Impersonate.UserName) + } + if string(cfg.CertData) != "cert" { + t.Fatalf("expected member certificate to be used") + } + if len(cfg.KeyData) == 0 { + t.Fatalf("expected cluster private key to be used with member certificate") + } +}