diff --git a/cmd/kubeapps-apis/plugins/resources/v1alpha1/main.go b/cmd/kubeapps-apis/plugins/resources/v1alpha1/main.go index ff8973ee8d4..43769262b3a 100644 --- a/cmd/kubeapps-apis/plugins/resources/v1alpha1/main.go +++ b/cmd/kubeapps-apis/plugins/resources/v1alpha1/main.go @@ -30,7 +30,7 @@ func init() { // //nolint:deadcode func RegisterWithGRPCServer(opts pluginsv1alpha1.GRPCPluginRegistrationOptions) (interface{}, error) { - svr, err := NewServer(opts.ConfigGetter, opts.ClientQPS, opts.ClientBurst, opts.PluginConfigPath) + svr, err := NewServer(opts.ConfigGetter, opts.ClientQPS, opts.ClientBurst, opts.PluginConfigPath, opts.ClustersConfig) if err != nil { return nil, err } diff --git a/cmd/kubeapps-apis/plugins/resources/v1alpha1/namespaces.go b/cmd/kubeapps-apis/plugins/resources/v1alpha1/namespaces.go index 5c3edaea0eb..ede0d33407c 100644 --- a/cmd/kubeapps-apis/plugins/resources/v1alpha1/namespaces.go +++ b/cmd/kubeapps-apis/plugins/resources/v1alpha1/namespaces.go @@ -6,6 +6,8 @@ package main import ( "context" authorizationapi "k8s.io/api/authorization/v1" + "k8s.io/client-go/kubernetes" + "strings" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/gen/plugins/resources/v1alpha1" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/pkg/statuserror" @@ -112,7 +114,14 @@ func (s *Server) CanI(ctx context.Context, r *v1alpha1.CanIRequest) (*v1alpha1.C } log.InfoS("+resources CanI", "cluster", cluster, "namespace", namespace, "group", r.GetGroup(), "resource", r.GetResource(), "verb", r.GetVerb()) - typedClient, _, err := s.clientGetter(ctx, cluster) + var typedClient kubernetes.Interface + var err error + if s.kubeappsCluster != cluster && strings.ToLower(r.GetVerb()) == "list" && strings.ToLower(r.GetResource()) == "namespaces" { + // Listing namespaces in additional clusters might involve using the provided service account token + typedClient, _, err = s.clusterServiceAccountClientGetter(ctx, cluster) + } else { + typedClient, _, err = s.clientGetter(ctx, cluster) + } if err != nil { return nil, status.Errorf(codes.Internal, "unable to get the k8s client: '%v'", err) } diff --git a/cmd/kubeapps-apis/plugins/resources/v1alpha1/namespaces_filtering.go b/cmd/kubeapps-apis/plugins/resources/v1alpha1/namespaces_filtering.go index 4d3b114f822..a8e4a1fb01d 100644 --- a/cmd/kubeapps-apis/plugins/resources/v1alpha1/namespaces_filtering.go +++ b/cmd/kubeapps-apis/plugins/resources/v1alpha1/namespaces_filtering.go @@ -41,22 +41,22 @@ func (s *Server) GetAccessibleNamespaces(ctx context.Context, cluster string, tr namespaceList = append(namespaceList, trustedNamespaces...) } else { - typedClient, _, err := s.clientGetter(ctx, cluster) + typedClient, _, err := s.clusterServiceAccountClientGetter(ctx, cluster) if err != nil { return nil, status.Errorf(codes.Internal, "unable to get the k8s client: '%v'", err) } // Try to list namespaces with the user token, for backward compatibility - namespaces, err := typedClient.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{}) + backgroundCtx := context.Background() + namespaces, err := typedClient.CoreV1().Namespaces().List(backgroundCtx, metav1.ListOptions{}) if err != nil { if k8sErrors.IsForbidden(err) { - backgroundCtx := context.Background() // The user doesn't have permissions to list namespaces, use the current pod's service account - userClient, err := s.serviceAccountClientGetter.Typed(backgroundCtx) + userClient, err := s.localServiceAccountClientGetter.Typed(backgroundCtx) if err != nil { return nil, err } - namespaces, err = userClient.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{}) + namespaces, err = userClient.CoreV1().Namespaces().List(backgroundCtx, metav1.ListOptions{}) if err != nil && k8sErrors.IsForbidden(err) { // Not even the configured kubeapps-apis service account has permission return nil, err diff --git a/cmd/kubeapps-apis/plugins/resources/v1alpha1/namespaces_test.go b/cmd/kubeapps-apis/plugins/resources/v1alpha1/namespaces_test.go index f1abba8d8ea..7bf99b3d76a 100644 --- a/cmd/kubeapps-apis/plugins/resources/v1alpha1/namespaces_test.go +++ b/cmd/kubeapps-apis/plugins/resources/v1alpha1/namespaces_test.go @@ -611,9 +611,12 @@ func TestGetNamespaceNames(t *testing.T) { clientGetter: func(context.Context, string) (kubernetes.Interface, dynamic.Interface, error) { return fakeClient, nil, nil }, - serviceAccountClientGetter: backgroundClientGetter, - clientQPS: 5, - pluginConfig: pluginConfig, + clusterServiceAccountClientGetter: func(context.Context, string) (kubernetes.Interface, dynamic.Interface, error) { + return fakeClient, nil, nil + }, + localServiceAccountClientGetter: backgroundClientGetter, + clientQPS: 5, + pluginConfig: pluginConfig, } ctx := context.Background() @@ -714,8 +717,8 @@ func TestCanI(t *testing.T) { clientGetter: func(context.Context, string) (kubernetes.Interface, dynamic.Interface, error) { return fakeClient, nil, nil }, - serviceAccountClientGetter: backgroundClientGetter, - clientQPS: 5, + localServiceAccountClientGetter: backgroundClientGetter, + clientQPS: 5, } response, err := s.CanI(context.Background(), tc.request) diff --git a/cmd/kubeapps-apis/plugins/resources/v1alpha1/server.go b/cmd/kubeapps-apis/plugins/resources/v1alpha1/server.go index 2c5dfbed073..fe6296745b9 100644 --- a/cmd/kubeapps-apis/plugins/resources/v1alpha1/server.go +++ b/cmd/kubeapps-apis/plugins/resources/v1alpha1/server.go @@ -9,6 +9,7 @@ import ( "fmt" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/pkg/clientgetter" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/resources/v1alpha1/common" + "github.com/vmware-tanzu/kubeapps/pkg/kube" "os" "sync" @@ -46,9 +47,12 @@ type Server struct { // non-test implementation. clientGetter clientGetter + // clusterServiceAccountClientGetter gets a client getter with service account for additional clusters + clusterServiceAccountClientGetter clientGetter + // for interactions with k8s API server in the context of // kubeapps-internal-kubeappsapis service account - serviceAccountClientGetter clientgetter.BackgroundClientGetterFunc + localServiceAccountClientGetter clientgetter.BackgroundClientGetterFunc // corePackagesClientGetter holds a function to obtain the core.packages.v1alpha1 // client. It is similarly initialised in NewServer() below. @@ -67,6 +71,8 @@ type Server struct { pluginConfig *common.ResourcesPluginConfig clientQPS float32 + + kubeappsCluster string } // createRESTMapper returns a rest mapper configured with the APIs of the @@ -101,7 +107,7 @@ func createRESTMapper(clientQPS float32, clientBurst int) (meta.RESTMapper, erro return restmapper.NewDiscoveryRESTMapper(groupResources), nil } -func NewServer(configGetter core.KubernetesConfigGetter, clientQPS float32, clientBurst int, pluginConfigPath string) (*Server, error) { +func NewServer(configGetter core.KubernetesConfigGetter, clientQPS float32, clientBurst int, pluginConfigPath string, clustersConfig kube.ClustersConfig) (*Server, error) { mapper, err := createRESTMapper(clientQPS, clientBurst) if err != nil { return nil, err @@ -119,29 +125,13 @@ func NewServer(configGetter core.KubernetesConfigGetter, clientQPS float32, clie log.Info("+resources using default config since pluginConfigPath is empty") } - // Get the "in-cluster" client getter - backgroundClientGetter := clientgetter.NewBackgroundClientGetter(configGetter, clientgetter.Options{}) - return &Server{ - clientGetter: func(ctx context.Context, cluster string) (kubernetes.Interface, dynamic.Interface, error) { - if configGetter == nil { - return nil, nil, status.Errorf(codes.Internal, "configGetter arg required") - } - config, err := configGetter(ctx, cluster) - if err != nil { - return nil, nil, status.Errorf(codes.FailedPrecondition, "unable to get config : %v", err.Error()) - } - dynamicClient, err := dynamic.NewForConfig(config) - if err != nil { - return nil, nil, status.Errorf(codes.FailedPrecondition, "unable to get dynamic client : %s", err.Error()) - } - typedClient, err := kubernetes.NewForConfig(config) - if err != nil { - return nil, nil, status.Errorf(codes.FailedPrecondition, "unable to get typed client: %s", err.Error()) - } - return typedClient, dynamicClient, nil - }, - serviceAccountClientGetter: backgroundClientGetter, + // Get the client getter with context auth + clientGetter: newClientGetter(configGetter, false, clustersConfig), + // Get the additional cluster client getter with service account + clusterServiceAccountClientGetter: newClientGetter(configGetter, true, clustersConfig), + // Get the "in-cluster" client getter + localServiceAccountClientGetter: clientgetter.NewBackgroundClientGetter(configGetter, clientgetter.Options{}), corePackagesClientGetter: func() (pkgsGRPCv1alpha1.PackagesServiceClient, error) { port := os.Getenv("PORT") conn, err := grpc.Dial("localhost:"+port, grpc.WithTransportCredentials(insecure.NewCredentials())) @@ -158,11 +148,51 @@ func NewServer(configGetter core.KubernetesConfigGetter, clientQPS float32, clie } return mapping.Resource, mapping.Scope.Name(), nil }, - clientQPS: clientQPS, - pluginConfig: pluginConfig, + clientQPS: clientQPS, + pluginConfig: pluginConfig, + kubeappsCluster: clustersConfig.KubeappsClusterName, }, nil } +func newClientGetter(configGetter core.KubernetesConfigGetter, useServiceAccount bool, clustersConfig kube.ClustersConfig) clientGetter { + return func(ctx context.Context, cluster string) (kubernetes.Interface, dynamic.Interface, error) { + if configGetter == nil { + return nil, nil, status.Errorf(codes.Internal, "configGetter arg required") + } + restConfig, err := configGetter(ctx, cluster) + if err != nil { + return nil, nil, status.Errorf(codes.FailedPrecondition, "unable to get config : %v", err.Error()) + } + if err := setupRestConfigForCluster(restConfig, cluster, useServiceAccount, clustersConfig); err != nil { + return nil, nil, err + } + dynamicClient, err := dynamic.NewForConfig(restConfig) + if err != nil { + return nil, nil, status.Errorf(codes.FailedPrecondition, "unable to get dynamic client : %s", err.Error()) + } + typedClient, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return nil, nil, status.Errorf(codes.FailedPrecondition, "unable to get typed client: %s", err.Error()) + } + return typedClient, dynamicClient, nil + } +} + +func setupRestConfigForCluster(restConfig *rest.Config, cluster string, useServiceAccount bool, clustersConfig kube.ClustersConfig) error { + // Override client config with the service token for additional cluster + // Added from #5034 after deprecation of "kubeops" + if cluster != clustersConfig.KubeappsClusterName && useServiceAccount { + additionalCluster, ok := clustersConfig.Clusters[cluster] + if !ok { + return status.Errorf(codes.Internal, "cluster %q has no configuration", cluster) + } + if additionalCluster.ServiceToken != "" { + restConfig.BearerToken = additionalCluster.ServiceToken + } + } + return nil +} + // GetResources returns the resources for an installed package. func (s *Server) GetResources(r *v1alpha1.GetResourcesRequest, stream v1alpha1.ResourcesService_GetResourcesServer) error { namespace := r.GetInstalledPackageRef().GetContext().GetNamespace() diff --git a/cmd/kubeapps-apis/plugins/resources/v1alpha1/server_test.go b/cmd/kubeapps-apis/plugins/resources/v1alpha1/server_test.go index c04a81764ab..5292a1da4f9 100644 --- a/cmd/kubeapps-apis/plugins/resources/v1alpha1/server_test.go +++ b/cmd/kubeapps-apis/plugins/resources/v1alpha1/server_test.go @@ -6,7 +6,9 @@ package main import ( "context" "errors" + "github.com/vmware-tanzu/kubeapps/pkg/kube" "io" + "k8s.io/client-go/rest" "net" "reflect" "testing" @@ -529,3 +531,102 @@ func TestGetServiceAccountNames(t *testing.T) { }) } } + +func TestSetupConfigForCluster(t *testing.T) { + testCases := []struct { + name string + restConfig *rest.Config + cluster string + useServiceAccount bool + clustersConfig kube.ClustersConfig + expectedRestConfig *rest.Config + expectedErrorCode codes.Code + }{ + { + name: "config is not modified for kubeapps cluster", + cluster: "default", + clustersConfig: kube.ClustersConfig{ + KubeappsClusterName: "default", + Clusters: map[string]kube.ClusterConfig{ + "default": {}, + }, + }, + restConfig: &rest.Config{}, + expectedRestConfig: &rest.Config{}, + }, + { + name: "config is not modified for additional clusters and no service account", + cluster: "additional-1", + clustersConfig: kube.ClustersConfig{ + KubeappsClusterName: "default", + Clusters: map[string]kube.ClusterConfig{ + "default": {}, + "additional-1": {}, + }, + }, + restConfig: &rest.Config{}, + expectedRestConfig: &rest.Config{}, + }, + { + name: "config setup fails for additional clusters with no cluster config data", + cluster: "additional-1", + useServiceAccount: true, + clustersConfig: kube.ClustersConfig{ + KubeappsClusterName: "default", + Clusters: map[string]kube.ClusterConfig{ + "default": {}, + }, + }, + restConfig: &rest.Config{}, + expectedRestConfig: &rest.Config{}, + expectedErrorCode: codes.Internal, + }, + { + name: "config is not modified for additional clusters with no configured service token", + cluster: "additional-1", + useServiceAccount: true, + clustersConfig: kube.ClustersConfig{ + KubeappsClusterName: "default", + Clusters: map[string]kube.ClusterConfig{ + "default": {}, + "additional-1": {}, + }, + }, + restConfig: &rest.Config{}, + expectedRestConfig: &rest.Config{}, + }, + { + name: "config is modified for additional clusters when configured service token", + cluster: "additional-1", + useServiceAccount: true, + clustersConfig: kube.ClustersConfig{ + KubeappsClusterName: "default", + Clusters: map[string]kube.ClusterConfig{ + "default": {}, + "additional-1": { + ServiceToken: "service-token-1", + }, + }, + }, + restConfig: &rest.Config{}, + expectedRestConfig: &rest.Config{ + BearerToken: "service-token-1", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + + err := setupRestConfigForCluster(tc.restConfig, tc.cluster, tc.useServiceAccount, tc.clustersConfig) + + if got, want := status.Code(err), tc.expectedErrorCode; !cmp.Equal(got, want, nil) { + t.Errorf("mismatch (-want +got):\n%s", cmp.Diff(want, got, nil)) + } + + if got, want := tc.restConfig, tc.expectedRestConfig; !cmp.Equal(got, want, nil) { + t.Errorf("mismatch (-want +got):\n%s", cmp.Diff(want, got, nil)) + } + }) + } +} diff --git a/script/e2e-test.sh b/script/e2e-test.sh index 57e39db5950..00a1089411b 100755 --- a/script/e2e-test.sh +++ b/script/e2e-test.sh @@ -299,7 +299,7 @@ if [ "$USE_MULTICLUSTER_OIDC_ENV" = true ]; then "--set" "clusters[1].name=second-cluster" "--set" "clusters[1].apiServiceURL=https://${ADDITIONAL_CLUSTER_IP}:6443" "--set" "clusters[1].insecure=true" - "--set" "clusters[1].serviceToken=ZXlKaGJHY2lPaUpTVXpJMU5pSXNJbXRwWkNJNklsbHpiSEp5TlZwM1QwaG9WSE5PYkhVdE5GQkRablY2TW0wd05rUmtMVmxFWVV4MlZEazNaeTEyUmxFaWZRLmV5SnBjM01pT2lKcmRXSmxjbTVsZEdWekwzTmxjblpwWTJWaFkyTnZkVzUwSWl3aWEzVmlaWEp1WlhSbGN5NXBieTl6WlhKMmFXTmxZV05qYjNWdWRDOXVZVzFsYzNCaFkyVWlPaUprWldaaGRXeDBJaXdpYTNWaVpYSnVaWFJsY3k1cGJ5OXpaWEoyYVdObFlXTmpiM1Z1ZEM5elpXTnlaWFF1Ym1GdFpTSTZJbXQxWW1WaGNIQnpMVzVoYldWemNHRmpaUzFrYVhOamIzWmxjbmt0ZEc5clpXNHRjV295Ym1naUxDSnJkV0psY201bGRHVnpMbWx2TDNObGNuWnBZMlZoWTJOdmRXNTBMM05sY25acFkyVXRZV05qYjNWdWRDNXVZVzFsSWpvaWEzVmlaV0Z3Y0hNdGJtRnRaWE53WVdObExXUnBjMk52ZG1WeWVTSXNJbXQxWW1WeWJtVjBaWE11YVc4dmMyVnlkbWxqWldGalkyOTFiblF2YzJWeWRtbGpaUzFoWTJOdmRXNTBMblZwWkNJNkltVXhaakE1WmpSakxUTTRNemt0TkRJME15MWhZbUptTFRKaU5HWm1OREZrWW1RMllTSXNJbk4xWWlJNkluTjVjM1JsYlRwelpYSjJhV05sWVdOamIzVnVkRHBrWldaaGRXeDBPbXQxWW1WaGNIQnpMVzVoYldWemNHRmpaUzFrYVhOamIzWmxjbmtpZlEuTnh6V2dsUGlrVWpROVQ1NkpWM2xJN1VWTUVSR3J2bklPSHJENkh4dUVwR0luLWFUUzV5Q0pDa3Z0cTF6S3Z3b05sc2MyX0YxaTdFOUxWRGFwbC1UQlhleUN5Rl92S1B1TDF4dTdqZFBMZ1dKT1pQX3JMcXppaDV4ZlkxalFoOHNhdTRZclFJLUtqb3U1UkRRZ0tOQS1BaS1lRlFOZVh2bmlUNlBKYWVkc184V0t3dHRMMC1wdHpYRnBnOFl5dkx6N0U1UWdTR2tjNWpDVXlsS0RvZVRUaVRSOEc2RHFHYkFQQUYwREt0b3MybU9Geno4SlJYNHhoQmdvaUcxVTVmR1g4Z3hnTU1SV0VHRE9kaGMyeXRvcFdRUkRpYmhvaldNS3VDZlNua09zMDRGYTBkYmEwQ0NTbld2a29LZ3Z4QVR5aVVrWm9wV3VpZ1JJNFd5dDkzbXhR" + "--set" "clusters[1].serviceToken=$(kubectl --context=kind-kubeapps-ci-additional --kubeconfig=${HOME}/.kube/kind-config-kubeapps-ci-additional get secret kubeapps-namespace-discovery -o go-template='{{.data.token | base64decode}}')" ) fi diff --git a/script/makefiles/deploy-dev.mk b/script/makefiles/deploy-dev.mk index 6575a35388b..411648c1400 100644 --- a/script/makefiles/deploy-dev.mk +++ b/script/makefiles/deploy-dev.mk @@ -38,7 +38,8 @@ deploy-dev-kubeapps: helm --kubeconfig=${CLUSTER_CONFIG} upgrade --install kubeapps ./chart/kubeapps --namespace kubeapps --create-namespace \ --values ./site/content/docs/latest/reference/manifests/kubeapps-local-dev-values.yaml \ --values ./site/content/docs/latest/reference/manifests/kubeapps-local-dev-auth-proxy-values.yaml \ - --values ./site/content/docs/latest/reference/manifests/kubeapps-local-dev-additional-kind-cluster.yaml + --values ./site/content/docs/latest/reference/manifests/kubeapps-local-dev-additional-kind-cluster.yaml \ + --set clusters[1].serviceToken=$(shell kubectl --kubeconfig=${ADDITIONAL_CLUSTER_CONFIG} get secret kubeapps-namespace-discovery -o go-template="{{.data.token | base64decode}}") deploy-dev: deploy-dependencies deploy-dev-kubeapps @echo "\nYou can now simply open your browser at https://localhost/ to access Kubeapps!" diff --git a/site/content/docs/latest/reference/manifests/kubeapps-local-dev-namespace-discovery-rbac.yaml b/site/content/docs/latest/reference/manifests/kubeapps-local-dev-namespace-discovery-rbac.yaml index 7746d87262e..0c4d92436b7 100644 --- a/site/content/docs/latest/reference/manifests/kubeapps-local-dev-namespace-discovery-rbac.yaml +++ b/site/content/docs/latest/reference/manifests/kubeapps-local-dev-namespace-discovery-rbac.yaml @@ -49,5 +49,3 @@ metadata: annotations: "kubernetes.io/service-account.name": kubeapps-namespace-discovery type: "kubernetes.io/service-account-token" -data: - token: ZXlKaGJHY2lPaUpTVXpJMU5pSXNJbXRwWkNJNklsbHpiSEp5TlZwM1QwaG9WSE5PYkhVdE5GQkRablY2TW0wd05rUmtMVmxFWVV4MlZEazNaeTEyUmxFaWZRLmV5SnBjM01pT2lKcmRXSmxjbTVsZEdWekwzTmxjblpwWTJWaFkyTnZkVzUwSWl3aWEzVmlaWEp1WlhSbGN5NXBieTl6WlhKMmFXTmxZV05qYjNWdWRDOXVZVzFsYzNCaFkyVWlPaUprWldaaGRXeDBJaXdpYTNWaVpYSnVaWFJsY3k1cGJ5OXpaWEoyYVdObFlXTmpiM1Z1ZEM5elpXTnlaWFF1Ym1GdFpTSTZJbXQxWW1WaGNIQnpMVzVoYldWemNHRmpaUzFrYVhOamIzWmxjbmt0ZEc5clpXNHRjV295Ym1naUxDSnJkV0psY201bGRHVnpMbWx2TDNObGNuWnBZMlZoWTJOdmRXNTBMM05sY25acFkyVXRZV05qYjNWdWRDNXVZVzFsSWpvaWEzVmlaV0Z3Y0hNdGJtRnRaWE53WVdObExXUnBjMk52ZG1WeWVTSXNJbXQxWW1WeWJtVjBaWE11YVc4dmMyVnlkbWxqWldGalkyOTFiblF2YzJWeWRtbGpaUzFoWTJOdmRXNTBMblZwWkNJNkltVXhaakE1WmpSakxUTTRNemt0TkRJME15MWhZbUptTFRKaU5HWm1OREZrWW1RMllTSXNJbk4xWWlJNkluTjVjM1JsYlRwelpYSjJhV05sWVdOamIzVnVkRHBrWldaaGRXeDBPbXQxWW1WaGNIQnpMVzVoYldWemNHRmpaUzFrYVhOamIzWmxjbmtpZlEuTnh6V2dsUGlrVWpROVQ1NkpWM2xJN1VWTUVSR3J2bklPSHJENkh4dUVwR0luLWFUUzV5Q0pDa3Z0cTF6S3Z3b05sc2MyX0YxaTdFOUxWRGFwbC1UQlhleUN5Rl92S1B1TDF4dTdqZFBMZ1dKT1pQX3JMcXppaDV4ZlkxalFoOHNhdTRZclFJLUtqb3U1UkRRZ0tOQS1BaS1lRlFOZVh2bmlUNlBKYWVkc184V0t3dHRMMC1wdHpYRnBnOFl5dkx6N0U1UWdTR2tjNWpDVXlsS0RvZVRUaVRSOEc2RHFHYkFQQUYwREt0b3MybU9Geno4SlJYNHhoQmdvaUcxVTVmR1g4Z3hnTU1SV0VHRE9kaGMyeXRvcFdRUkRpYmhvaldNS3VDZlNua09zMDRGYTBkYmEwQ0NTbld2a29LZ3Z4QVR5aVVrWm9wV3VpZ1JJNFd5dDkzbXhR