diff --git a/cmd/ctl/check_config.go b/cmd/ctl/check_config.go new file mode 100644 index 00000000..cfef37ff --- /dev/null +++ b/cmd/ctl/check_config.go @@ -0,0 +1,621 @@ +// Copyright 2025 The argocd-agent Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "crypto/rsa" + "crypto/x509" + "fmt" + "strings" + "time" + + "github.com/argoproj-labs/argocd-agent/cmd/cmdutil" + "github.com/argoproj-labs/argocd-agent/internal/config" + "github.com/argoproj-labs/argocd-agent/internal/kube" + "github.com/argoproj-labs/argocd-agent/internal/tlsutil" + "github.com/spf13/cobra" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes" +) + +type checkResult struct { + name string + err error +} + +func (r checkResult) String() string { + if r.err == nil { + return fmt.Sprintf("* %s: ✅", r.name) + } + return fmt.Sprintf("* %s: ❌\nERROR: %v", r.name, r.err) +} + +func NewCheckConfigCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "check-config", + Short: "Validate principal and agent configuration", + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, + GroupID: "config", + } + cmd.AddCommand(NewCheckConfigPrincipalCommand()) + cmd.AddCommand(NewCheckConfigAgentCommand()) + return cmd +} + +func NewCheckConfigPrincipalCommand() *cobra.Command { + command := &cobra.Command{ + Use: "principal", + Short: "Validate principal configuration", + Run: func(cmd *cobra.Command, args []string) { + if strings.TrimSpace(globalOpts.principalNamespace) == "" { + cmdutil.Fatal("--principal-namespace is required") + } + ctx := context.TODO() + clt, err := kube.NewKubernetesClientFromConfig(ctx, globalOpts.principalNamespace, "", globalOpts.principalContext) + if err != nil { + cmdutil.Fatal("Could not create Kubernetes client: %v", err) + } + results := RunPrincipalChecks(ctx, clt, globalOpts.principalNamespace) + printResultsAndExit(results) + }, + } + return command +} + +func NewCheckConfigAgentCommand() *cobra.Command { + command := &cobra.Command{ + Use: "agent", + Short: "Validate agent configuration (and principal cross-checks)", + Run: func(cmd *cobra.Command, args []string) { + if strings.TrimSpace(globalOpts.agentContext) == "" || + strings.TrimSpace(globalOpts.agentNamespace) == "" || + strings.TrimSpace(globalOpts.principalContext) == "" || + strings.TrimSpace(globalOpts.principalNamespace) == "" { + cmdutil.Fatal("--agent-context, --agent-namespace, --principal-context, --principal-namespace are all required") + } + ctx := context.TODO() + agentClt, err := kube.NewKubernetesClientFromConfig(ctx, globalOpts.agentNamespace, "", globalOpts.agentContext) + if err != nil { + cmdutil.Fatal("Could not create agent Kubernetes client: %v", err) + } + principalClt, err := kube.NewKubernetesClientFromConfig(ctx, globalOpts.principalNamespace, "", globalOpts.principalContext) + if err != nil { + cmdutil.Fatal("Could not create principal Kubernetes client: %v", err) + } + // Run principal checks as part of agent checks + results := []checkResult{} + results = append(results, RunPrincipalChecks(ctx, principalClt, globalOpts.principalNamespace)...) + results = append(results, RunAgentChecks(ctx, agentClt, globalOpts.agentNamespace, principalClt, globalOpts.principalNamespace)...) + printResultsAndExit(results) + }, + } + return command +} + +func printResultsAndExit(results []checkResult) { + hasErr := false + fmt.Println("Configuration validation results:") + for _, r := range results { + fmt.Println(r.String()) + if r.err != nil { + hasErr = true + } + } + if hasErr { + cmdutil.Fatal("one or more checks failed") + } +} + +// getArgoCDCR retrieves the ArgoCD CR from the specified namespace, if it exists. +func getArgoCDCR(ctx context.Context, kc *kube.KubernetesClient, ns string) (*unstructured.Unstructured, bool, error) { + if kc.DynamicClient == nil { + return nil, false, fmt.Errorf("dynamic client is not available") + } + gvr := schema.GroupVersionResource{Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"} + list, err := kc.DynamicClient.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{}) + if err != nil { + // If CR doesn't exist, return found=false with no error + if k8serrors.IsNotFound(err) { + return nil, false, nil + } + // For other errors, return the error + return nil, false, fmt.Errorf("failed to list ArgoCD CRs: %w", err) + } + if list == nil || len(list.Items) == 0 { + return nil, false, nil // CR not found, use defaults + } + // Use the ArgoCD CR found in the namespace + return &list.Items[0], true, nil +} + +// getPrincipalSecretNames reads secret names from ArgoCD CR if available, otherwise returns defaults. +func getPrincipalSecretNames(ctx context.Context, kc *kube.KubernetesClient, ns string) (string, string, string, string, error) { + caSecretName := config.SecretNamePrincipalCA + tlsSecretName := config.SecretNamePrincipalTLS + proxyTLSSecretName := config.SecretNameProxyTLS + jwtSecretName := config.SecretNameJWT + + // Try to read from ArgoCD CR + cr, found, err := getArgoCDCR(ctx, kc, ns) + if err != nil { + // Return error for real failures (permission denied, network issues, etc.) + return "", "", "", "", fmt.Errorf("failed to access ArgoCD CR in namespace %s: %w", ns, err) + } + if found { + // Read principal.tls.rootCASecretName + if val, found, err := unstructured.NestedString(cr.Object, "spec", "argoCDAgent", "principal", "tls", "rootCASecretName"); err != nil { + return "", "", "", "", fmt.Errorf("failed to read principal.tls.rootCASecretName from ArgoCD CR: %w", err) + } else if found && val != "" { + caSecretName = val + } + // Read principal.tls.secretName + if val, found, err := unstructured.NestedString(cr.Object, "spec", "argoCDAgent", "principal", "tls", "secretName"); err != nil { + return "", "", "", "", fmt.Errorf("failed to read principal.tls.secretName from ArgoCD CR: %w", err) + } else if found && val != "" { + tlsSecretName = val + } + // Read principal.resourceProxy.tls.secretName + if val, found, err := unstructured.NestedString(cr.Object, "spec", "argoCDAgent", "principal", "resourceProxy", "tls", "secretName"); err != nil { + return "", "", "", "", fmt.Errorf("failed to read principal.resourceProxy.tls.secretName from ArgoCD CR: %w", err) + } else if found && val != "" { + proxyTLSSecretName = val + } + // Read principal.jwt.secretName + if val, found, err := unstructured.NestedString(cr.Object, "spec", "argoCDAgent", "principal", "jwt", "secretName"); err != nil { + return "", "", "", "", fmt.Errorf("failed to read principal.jwt.secretName from ArgoCD CR: %w", err) + } else if found && val != "" { + jwtSecretName = val + } + } + return caSecretName, tlsSecretName, proxyTLSSecretName, jwtSecretName, nil +} + +// getAgentSecretNames reads secret names from ArgoCD CR if available, otherwise returns defaults. +func getAgentSecretNames(ctx context.Context, kc *kube.KubernetesClient, ns string) (string, string, error) { + caSecretName := config.SecretNameAgentCA + clientCertSecretName := config.SecretNameAgentClientCert + + // Try to read from ArgoCD CR + cr, found, err := getArgoCDCR(ctx, kc, ns) + if err != nil { + // Return error for real failures (permission denied, network issues, etc.) + return "", "", fmt.Errorf("failed to access ArgoCD CR in namespace %s: %w", ns, err) + } + if found { + // Read agent.tls.rootCASecretName + if val, found, err := unstructured.NestedString(cr.Object, "spec", "argoCDAgent", "agent", "tls", "rootCASecretName"); err != nil { + return "", "", fmt.Errorf("failed to read agent.tls.rootCASecretName from ArgoCD CR: %w", err) + } else if found && val != "" { + caSecretName = val + } + // Read agent.tls.secretName + if val, found, err := unstructured.NestedString(cr.Object, "spec", "argoCDAgent", "agent", "tls", "secretName"); err != nil { + return "", "", fmt.Errorf("failed to read agent.tls.secretName from ArgoCD CR: %w", err) + } else if found && val != "" { + clientCertSecretName = val + } + } + return caSecretName, clientCertSecretName, nil +} + +// RunPrincipalChecks validates the principal installation and related security assets. +func RunPrincipalChecks(ctx context.Context, kubeClient *kube.KubernetesClient, principalNS string) []checkResult { + out := []checkResult{} + + // Get secret names from ArgoCD CR or use defaults + caSecretName, tlsSecretName, proxyTLSSecretName, jwtSecretName, err := getPrincipalSecretNames(ctx, kubeClient, principalNS) + if err != nil { + out = append(out, checkResult{ + name: "Reading principal secret names from ArgoCD CR", + err: err, + }) + // Use defaults if CR access failed + caSecretName = config.SecretNamePrincipalCA + tlsSecretName = config.SecretNamePrincipalTLS + proxyTLSSecretName = config.SecretNameProxyTLS + jwtSecretName = config.SecretNameJWT + } + + // Ensure Argo CD is running in cluster-scoped mode + out = append(out, checkResult{ + name: "Verifying Argo CD is running in cluster-scoped mode", + err: verifyArgoCDClusterScoped(ctx, kubeClient, principalNS), + }) + + // CA secret exists in principal namespace and is a valid TLS secret + out = append(out, checkResult{ + name: fmt.Sprintf("Verifying principal public CA certificate exists and is valid (%s/%s)", principalNS, caSecretName), + err: principalCheckCA(ctx, kubeClient.Clientset, principalNS, caSecretName), + }) + + // Principal gRPC TLS secret exists in principal namespace and is valid + out = append(out, checkResult{ + name: fmt.Sprintf("Verifying principal gRPC TLS certificate exists and is valid (%s/%s)", principalNS, tlsSecretName), + err: certSecretValid(ctx, kubeClient.Clientset, principalNS, tlsSecretName), + }) + + // Resource proxy TLS secret exists and is valid + out = append(out, checkResult{ + name: fmt.Sprintf("Verifying resource proxy TLS certificate exists and is valid (%s/%s)", principalNS, proxyTLSSecretName), + err: certSecretValid(ctx, kubeClient.Clientset, principalNS, proxyTLSSecretName), + }) + + // JWT signing key exists + out = append(out, checkResult{ + name: fmt.Sprintf("Verifying JWT signing key exists and is parseable (%s/%s)", principalNS, jwtSecretName), + err: jwtKeyValid(ctx, kubeClient.Clientset, principalNS, jwtSecretName), + }) + + // Route host matches TLS SANs (OpenShift-only) + exists, err := routeAPIExists(kubeClient) + switch { + case err != nil: + out = append(out, checkResult{ + name: "Checking for OpenShift Route API availability", + err: err, + }) + case exists: + hasRoutes, err := routesExist(ctx, kubeClient, principalNS) + if err != nil { + out = append(out, checkResult{ + name: "Checking for OpenShift Routes in namespace", + err: fmt.Errorf("failed to check for OpenShift Routes in namespace %s: %w", principalNS, err), + }) + } else if hasRoutes { + out = append(out, checkResult{ + name: "Verifying principal TLS secret ips/dns match Route host (OpenShift)", + err: verifyRouteHostMatchesCert(ctx, kubeClient, principalNS, tlsSecretName), + }) + } + } + + return out +} + +// RunAgentChecks validates agent-side security assets and cross-validates them +// against the principal cluster. +func RunAgentChecks(ctx context.Context, agentKubeClient *kube.KubernetesClient, agentNS string, principalKubeClient *kube.KubernetesClient, principalNS string) []checkResult { + out := []checkResult{} + + // Get secret names from ArgoCD CR or use defaults + agentCASecretName, agentClientCertSecretName, err := getAgentSecretNames(ctx, agentKubeClient, agentNS) + if err != nil { + out = append(out, checkResult{ + name: "Reading agent secret names from ArgoCD CR", + err: err, + }) + // Use defaults if CR access failed + agentCASecretName = config.SecretNameAgentCA + agentClientCertSecretName = config.SecretNameAgentClientCert + } + principalCASecretName, _, _, _, err := getPrincipalSecretNames(ctx, principalKubeClient, principalNS) + if err != nil { + out = append(out, checkResult{ + name: "Reading principal secret names from ArgoCD CR", + err: err, + }) + // Use defaults if CR access failed + principalCASecretName = config.SecretNamePrincipalCA + } + + // Agent CA secret exists and has CA data (opaque secret with ca.crt) + out = append(out, checkResult{ + name: fmt.Sprintf("Verifying agent CA secret exists and contains CA cert (%s/%s)", agentNS, agentCASecretName), + err: agentCASecretValid(ctx, agentKubeClient.Clientset, agentNS, agentCASecretName), + }) + + // Agent client TLS secret exists and is valid and not expired + out = append(out, checkResult{ + name: fmt.Sprintf("Verifying agent mTLS certificate exists and is not expired (%s/%s)", agentNS, agentClientCertSecretName), + err: clientCertNotExpired(ctx, agentKubeClient.Clientset, agentNS, agentClientCertSecretName), + }) + + // Namespace with same name as agent cert subject exists on principal cluster + out = append(out, checkResult{ + name: "Verifying namespace on principal matches agent certificate subject", + err: namespaceMatchesAgentSubject(ctx, agentKubeClient.Clientset, agentNS, agentClientCertSecretName, principalKubeClient.Clientset), + }) + + // Agent client TLS is signed by principal CA + out = append(out, checkResult{ + name: "Verifying agent mTLS certificate is signed by principal CA certificate", + err: clientCertSignedByPrincipalCA(ctx, agentKubeClient.Clientset, agentNS, agentClientCertSecretName, principalKubeClient.Clientset, principalNS, principalCASecretName), + }) + + return out +} + +func principalCheckCA(ctx context.Context, kubeClient kubernetes.Interface, ns, secretName string) error { + _, err := tlsutil.TLSCertFromSecret(ctx, kubeClient, ns, secretName) + return err +} + +func certSecretValid(ctx context.Context, kubeClient kubernetes.Interface, ns, name string) error { + parsed, err := x509FromTLSSecret(ctx, kubeClient, ns, name) + if err != nil { + return err + } + now := time.Now() + if now.Before(parsed.NotBefore) { + return fmt.Errorf("certificate in secret %s/%s is not yet valid (valid from %s)", ns, name, parsed.NotBefore) + } + + if now.After(parsed.NotAfter) { + return fmt.Errorf("certificate in secret %s/%s is expired", ns, name) + } + return nil +} + +func jwtKeyValid(ctx context.Context, kubeClient kubernetes.Interface, ns, name string) error { + key, err := tlsutil.JWTSigningKeyFromSecret(ctx, kubeClient, ns, name) + if err != nil { + return err + } + // Require RSA for now + if _, ok := key.(*rsa.PrivateKey); !ok { + return fmt.Errorf("JWT signing key is not an RSA private key") + } + return nil +} + +// verifyArgoCDClusterScoped verifies that Argo CD is running in cluster-scoped mode by: +// 1. Checking that spec.applicationNamespaces is not set (or application.namespaces ConfigMap key is unset) +// 2. Verifying that Applications can be managed across namespaces (actual cluster-scoped behavior) +func verifyArgoCDClusterScoped(ctx context.Context, kc *kube.KubernetesClient, ns string) error { + if kc.DynamicClient == nil { + return fmt.Errorf("dynamic client is not available, cannot verify cluster-scoped mode") + } + + // First, verify applicationNamespaces is not set + appNamespacesSet := false + crNotFound := false + + // Try operator CR first: group argoproj.io, version v1beta1, resource argocds + gvr := schema.GroupVersionResource{Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"} + list, err := kc.DynamicClient.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{}) + if err != nil { + // If NotFound, allow fallback to ConfigMap check + if k8serrors.IsNotFound(err) { + crNotFound = true + } else { + // Return all other errors immediately + return fmt.Errorf("failed to list ArgoCD CRs: %w", err) + } + } else if list != nil && len(list.Items) > 0 { + for _, it := range list.Items { + // If applicationNamespaces is non-empty, Argo CD is in namespaced mode (not cluster-scoped) + arr, found, err := unstructured.NestedSlice(it.Object, "spec", "applicationNamespaces") + if err != nil { + return fmt.Errorf("failed to read applicationNamespaces from ArgoCD CR: %w", err) + } + if found && len(arr) > 0 { + appNamespacesSet = true + break + } + } + } + + // Fallback: check the core Argo CD ConfigMap (argocd-cm) + if !appNamespacesSet { + cm, err := kc.Clientset.CoreV1().ConfigMaps(ns).Get(ctx, "argocd-cm", metav1.GetOptions{}) + if err != nil { + // If both CR and ConfigMap are not found, return error + if k8serrors.IsNotFound(err) && crNotFound { + return fmt.Errorf("neither ArgoCD CR nor argocd-cm ConfigMap found in namespace %s", ns) + } + // Return all other errors + return fmt.Errorf("failed to get argocd-cm ConfigMap: %w", err) + } + if v := strings.TrimSpace(cm.Data["application.namespaces"]); v != "" { + appNamespacesSet = true + } + } + + if appNamespacesSet { + return fmt.Errorf("argo CD configured for namespaced mode (applicationNamespaces is set), must be configured for cluster-scoped mode") + } + + // Verify cluster-scoped behavior by checking if Applications can be accessed across namespaces + // In cluster-scoped mode, we should be able to list Applications from any namespace (even though + // Applications themselves are namespace-scoped resources). This verifies that Argo CD can operate + // in cluster-scoped mode, managing Applications across all namespaces. + appGVR := schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "applications"} + + // List Applications across all namespaces (cluster-scoped operation) + // This is equivalent to: kubectl get applications --all-namespaces + _, err = kc.DynamicClient.Resource(appGVR).Namespace(metav1.NamespaceAll).List(ctx, metav1.ListOptions{Limit: 1}) + if err != nil { + // If we can't list Applications cluster-wide, return the error + // This could indicate namespaced mode is restricting access or a permissions issue + return fmt.Errorf("failed to list Applications cluster-wide (required for cluster-scoped mode): %w", err) + } + // If list succeeds, we can confirm cluster-scoped mode is working + return nil +} + +// routeAPIExists checks if the OpenShift Route API exists on the cluster. +func routeAPIExists(kc *kube.KubernetesClient) (bool, error) { + if kc.Clientset == nil { + return false, fmt.Errorf("kubernetes clientset is not available") + } + _, err := kc.Clientset.Discovery().ServerResourcesForGroupVersion("route.openshift.io/v1") + if err != nil { + if meta.IsNoMatchError(err) || k8serrors.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("failed to query OpenShift Route API: %w", err) + } + return true, nil +} + +// routesExist checks if any Route resources exist in the specified namespace. +func routesExist(ctx context.Context, kc *kube.KubernetesClient, ns string) (bool, error) { + if kc.DynamicClient == nil { + return false, fmt.Errorf("dynamic client is not available") + } + gvr := schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"} + routes, err := kc.DynamicClient.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{}) + if err != nil { + return false, fmt.Errorf("failed to list OpenShift Routes: %w", err) + } + return routes != nil && len(routes.Items) > 0, nil +} + +// verifyRouteHostMatchesCert checks OpenShift Route host in ns is present in IPS/DNS of the given TLS secret. +// This function should only be called if routeAPIExists returns true. +func verifyRouteHostMatchesCert(ctx context.Context, kc *kube.KubernetesClient, ns string, tlsSecretName string) error { + if kc.DynamicClient == nil { + return fmt.Errorf("dynamic client is not available") + } + + // Route API exists, proceed with checking routes + gvr := schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"} + routes, err := kc.DynamicClient.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("failed to list OpenShift Routes: %w", err) + } + + if routes == nil { + return nil // skip if Route API not present + } + + if len(routes.Items) == 0 { + return nil // skip if no routes exist + } + + // Load cert IPS/DNS + parsed, err := x509FromTLSSecret(ctx, kc.Clientset, ns, tlsSecretName) + if err != nil { + return err + } + + // Check if any route hostname matches the certificate's SANs + for _, route := range routes.Items { + hostname, found, err := unstructured.NestedString(route.Object, "spec", "host") + if err != nil { + return fmt.Errorf("failed to read host from Route: %w", err) + } + if !found || hostname == "" { + // Skip routes without hostnames + continue + } + + // Verify if the route hostname is in the certificate's SANs + if err := parsed.VerifyHostname(hostname); err != nil { + // Hostname doesn't match certificate, try next route + continue + } + + // Found a matching route hostname, verification successful + return nil + } + + // No route hostnames matched the certificate SANs + return fmt.Errorf("no OpenShift Route host in namespace matches TLS IPS/DNS") +} + +func agentCASecretValid(ctx context.Context, kubeClient kubernetes.Interface, ns, name string) error { + sec, err := kubeClient.CoreV1().Secrets(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + if len(sec.Data) == 0 { + return fmt.Errorf("%s/%s: empty secret", ns, name) + } + // Expect a "ca.crt" field with PEM data + if _, ok := sec.Data["ca.crt"]; !ok { + return fmt.Errorf("%s/%s: missing ca.crt field", ns, name) + } + // Validate PEM parses into certs + _, err = tlsutil.X509CertPoolFromSecret(ctx, kubeClient, ns, name, "ca.crt") + return err +} + +func clientCertNotExpired(ctx context.Context, kubeClient kubernetes.Interface, ns, name string) error { + parsed, err := x509FromTLSSecret(ctx, kubeClient, ns, name) + if err != nil { + return err + } + now := time.Now() + if now.Before(parsed.NotBefore) { + return fmt.Errorf("agent certificate not yet valid (valid from %s)", parsed.NotBefore) + } + if now.After(parsed.NotAfter) { + return fmt.Errorf("agent certificate expired at %s", parsed.NotAfter) + } + return nil +} + +func clientCertSignedByPrincipalCA(ctx context.Context, agentKube kubernetes.Interface, agentNS, agentClientCertSecretName string, principalKube kubernetes.Interface, principalNS, principalCASecretName string) error { + agentX509, err := x509FromTLSSecret(ctx, agentKube, agentNS, agentClientCertSecretName) + if err != nil { + return err + } + caX509, err := x509FromTLSSecret(ctx, principalKube, principalNS, principalCASecretName) + if err != nil { + return err + } + if err := agentX509.CheckSignatureFrom(caX509); err != nil { + return fmt.Errorf("agent certificate not signed by principal CA: %w", err) + } + return nil +} + +func namespaceMatchesAgentSubject(ctx context.Context, agentKube kubernetes.Interface, agentNS, agentClientCertSecretName string, principalKube kubernetes.Interface) error { + agentX509, err := x509FromTLSSecret(ctx, agentKube, agentNS, agentClientCertSecretName) + if err != nil { + return err + } + subj := strings.TrimSpace(agentX509.Subject.CommonName) + if subj == "" { + return fmt.Errorf("agent certificate subject (CN) is empty") + } + // Validate namespace exists on principal cluster + _, err = principalKube.CoreV1().Namespaces().Get(ctx, subj, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return fmt.Errorf("namespace '%s' not found on principal cluster", subj) + } + return err + } + return nil +} + +// x509FromTLSSecret retrieves a Kubernetes TLS secret and parses the certificate +// into an *x509.Certificate. The secret must contain exactly one certificate. +func x509FromTLSSecret(ctx context.Context, kubeClient kubernetes.Interface, ns, name string) (*x509.Certificate, error) { + cert, err := tlsutil.TLSCertFromSecret(ctx, kubeClient, ns, name) + if err != nil { + return nil, err + } + if len(cert.Certificate) == 0 || cert.Certificate[0] == nil { + return nil, fmt.Errorf("%s/%s: secret does not contain certificate data", ns, name) + } + if len(cert.Certificate) > 1 { + return nil, fmt.Errorf("%s/%s: secret contains %d certificates, expected exactly one", ns, name, len(cert.Certificate)) + } + parsed, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return nil, fmt.Errorf("could not parse certificate in secret %s/%s: %w", ns, name, err) + } + return parsed, nil +} diff --git a/cmd/ctl/check_config_test.go b/cmd/ctl/check_config_test.go new file mode 100644 index 00000000..cbde160e --- /dev/null +++ b/cmd/ctl/check_config_test.go @@ -0,0 +1,1195 @@ +// Copyright 2025 The argocd-agent Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "strings" + "testing" + "time" + + "github.com/argoproj-labs/argocd-agent/internal/config" + "github.com/argoproj-labs/argocd-agent/internal/kube" + "github.com/argoproj-labs/argocd-agent/internal/tlsutil" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + fakediscovery "k8s.io/client-go/discovery/fake" + dynamicfake "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" +) + +func mustCreateTLSSecret(t *testing.T, client kubernetes.Interface, ns, name, certPEM, keyPEM string) { + t.Helper() + _, err := client.CoreV1().Secrets(ns).Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + "tls.crt": []byte(certPEM), + "tls.key": []byte(keyPEM), + }, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create secret %s/%s", ns, name) +} + +// Helper function to create an expired certificate +func createExpiredCertificate(t *testing.T, name string, signerCert *x509.Certificate, signerKey crypto.PrivateKey, ips []string, dns []string) (string, string) { + t.Helper() + ipAddresses := []net.IP{} + for _, ip := range ips { + addr := net.ParseIP(ip) + if addr != nil { + ipAddresses = append(ipAddresses, addr) + } + } + + cert := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: name, + Organization: []string{"DO NOT USE IN PRODUCTION"}, + }, + NotBefore: time.Now().Add(-2 * time.Hour), + NotAfter: time.Now().Add(-1 * time.Hour), // Expired 1 hour ago + IsCA: false, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + BasicConstraintsValid: true, + DNSNames: dns, + IPAddresses: ipAddresses, + } + + certPEM, keyPEM, err := tlsutil.GenerateCertificate(cert, signerCert, signerKey) + require.NoError(t, err, "create expired certificate") + return certPEM, keyPEM +} + +// Helper function to create a certificate signed by a different CA +func createCertSignedByDifferentCA(t *testing.T, name string, ips []string, dns []string) (string, string) { + t.Helper() + // Create a different CA + differentCAPEM, differentCAKeyPEM, err := tlsutil.GenerateCaCertificate("different-ca") + require.NoError(t, err, "generate different CA") + + // Create a fake client to parse the CA + cl := fake.NewSimpleClientset() + _, err = cl.CoreV1().Secrets("default").Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "temp-ca", Namespace: "default"}, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + "tls.crt": []byte(differentCAPEM), + "tls.key": []byte(differentCAKeyPEM), + }, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create temp CA secret") + + differentCA, err := tlsutil.TLSCertFromSecret(context.TODO(), cl, "default", "temp-ca") + require.NoError(t, err, "read different CA") + differentCASigner, err := x509.ParseCertificate(differentCA.Certificate[0]) + require.NoError(t, err, "parse different CA") + + // Create certificate signed by different CA + ipAddresses := []net.IP{} + for _, ip := range ips { + addr := net.ParseIP(ip) + if addr != nil { + ipAddresses = append(ipAddresses, addr) + } + } + + cert := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: name, + Organization: []string{"DO NOT USE IN PRODUCTION"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(0, 6, 0), + IsCA: false, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + BasicConstraintsValid: true, + DNSNames: dns, + IPAddresses: ipAddresses, + } + + certPEM, keyPEM, err := tlsutil.GenerateCertificate(cert, differentCASigner, differentCA.PrivateKey) + require.NoError(t, err, "create cert signed by different CA") + + return certPEM, keyPEM +} + +// Helper to create a certificate with empty CN +func createCertWithEmptyCN(t *testing.T, signerCert *x509.Certificate, signerKey crypto.PrivateKey) (string, string) { + t.Helper() + cert := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "", // Empty CN + Organization: []string{"DO NOT USE IN PRODUCTION"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(0, 6, 0), + IsCA: false, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + BasicConstraintsValid: true, + } + + certPEM, keyPEM, err := tlsutil.GenerateCertificate(cert, signerCert, signerKey) + require.NoError(t, err, "create cert with empty CN") + return certPEM, keyPEM +} + +func TestCheckConfigPrincipal(t *testing.T) { + t.Run("Valid configuration", func(t *testing.T) { + principalNS := "argocd" + cl := fake.NewSimpleClientset() + + // Create a scheme and register ArgoCD CRD + scheme := runtime.NewScheme() + // Create dynamic client with the registered types + dynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + {Group: "argoproj.io", Version: "v1alpha1", Resource: "applications"}: "ApplicationList", + {Group: "route.openshift.io", Version: "v1", Resource: "routes"}: "RouteList", + }) + + // Create namespace + _, err := cl.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: principalNS}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create namespace") + + // Create KubernetesClient wrapper + kubeClient := &kube.KubernetesClient{ + Clientset: cl, + DynamicClient: dynCl, + Context: context.TODO(), + Namespace: principalNS, + } + + // CA + caCertPEM, caKeyPEM, err := tlsutil.GenerateCaCertificate(config.SecretNamePrincipalCA) + require.NoError(t, err, "generate CA") + + // Principal TLS + mustCreateTLSSecret(t, cl, principalNS, config.SecretNamePrincipalCA, caCertPEM, caKeyPEM) + + // Parse CA for issuing + caCert, err := tlsutil.TLSCertFromSecret(context.TODO(), cl, principalNS, config.SecretNamePrincipalCA) + require.NoError(t, err, "read CA") + signer, err := x509.ParseCertificate(caCert.Certificate[0]) + require.NoError(t, err, "parse CA") + pCertPEM, pKeyPEM, err := tlsutil.GenerateServerCertificate("principal", signer, caCert.PrivateKey, []string{"127.0.0.1"}, []string{"localhost"}) + require.NoError(t, err, "gen principal cert") + mustCreateTLSSecret(t, cl, principalNS, config.SecretNamePrincipalTLS, pCertPEM, pKeyPEM) + + // Resource proxy TLS + rpCertPEM, rpKeyPEM, err := tlsutil.GenerateServerCertificate("resource-proxy", signer, caCert.PrivateKey, []string{"127.0.0.1"}, []string{"localhost"}) + require.NoError(t, err, "gen rp cert") + mustCreateTLSSecret(t, cl, principalNS, config.SecretNameProxyTLS, rpCertPEM, rpKeyPEM) + + // JWT secret (PKCS8 RSA) + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "gen jwt key") + pkcs8, err := x509.MarshalPKCS8PrivateKey(rsaKey) + require.NoError(t, err, "marshal pkcs8") + block := &pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8} + jwtPem := pem.EncodeToMemory(block) + _, err = cl.CoreV1().Secrets(principalNS).Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: config.SecretNameJWT, Namespace: principalNS}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "jwt.key": jwtPem, + }, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create jwt secret") + + // Create argocd-cm ConfigMap to indicate cluster-scoped mode (no application.namespaces key) + _, err = cl.CoreV1().ConfigMaps(principalNS).Create(context.TODO(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "argocd-cm", Namespace: principalNS}, + Data: map[string]string{}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create argocd-cm configmap") + + // Create an OpenShift Route that matches the principal TLS cert IPS/DNS + route := &unstructured.Unstructured{} + route.SetAPIVersion("route.openshift.io/v1") + route.SetKind("Route") + route.SetName("argocd-server") + route.SetNamespace(principalNS) + route.Object["spec"] = map[string]interface{}{ + "host": "localhost", // matches the cert IPS/DNS + } + _, err = dynCl.Resource(schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"}).Namespace(principalNS).Create(context.TODO(), route, metav1.CreateOptions{}) + require.NoError(t, err, "create route") + + res := RunPrincipalChecks(context.TODO(), kubeClient, principalNS) + for _, r := range res { + require.NoError(t, r.err, "check failed: %s", r.name) + } + }) + + t.Run("Missing CA secret", func(t *testing.T) { + principalNS := "argocd" + cl := fake.NewSimpleClientset() + + scheme := runtime.NewScheme() + dynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + {Group: "argoproj.io", Version: "v1alpha1", Resource: "applications"}: "ApplicationList", + }) + + _, err := cl.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: principalNS}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create namespace") + + // Create argocd-cm ConfigMap + _, err = cl.CoreV1().ConfigMaps(principalNS).Create(context.TODO(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "argocd-cm", Namespace: principalNS}, + Data: map[string]string{}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create argocd-cm configmap") + + kubeClient := &kube.KubernetesClient{ + Clientset: cl, + DynamicClient: dynCl, + Context: context.TODO(), + Namespace: principalNS, + } + + // Don't create CA secret, should return error + res := RunPrincipalChecks(context.TODO(), kubeClient, principalNS) + hasCAError := false + for _, r := range res { + if r.err != nil && strings.Contains(r.name, "CA certificate") { + hasCAError = true + require.NotEmpty(t, r.err.Error(), "error message should not be empty") + } + } + require.True(t, hasCAError, "expected error when CA secret is missing") + }) + + t.Run("Missing Principal TLS secret", func(t *testing.T) { + principalNS := "argocd" + cl := fake.NewSimpleClientset() + + scheme := runtime.NewScheme() + dynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + {Group: "argoproj.io", Version: "v1alpha1", Resource: "applications"}: "ApplicationList", + }) + + _, err := cl.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: principalNS}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create namespace") + + // Create CA only + caCertPEM, caKeyPEM, err := tlsutil.GenerateCaCertificate(config.SecretNamePrincipalCA) + require.NoError(t, err, "generate CA") + mustCreateTLSSecret(t, cl, principalNS, config.SecretNamePrincipalCA, caCertPEM, caKeyPEM) + + // Create argocd-cm ConfigMap + _, err = cl.CoreV1().ConfigMaps(principalNS).Create(context.TODO(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "argocd-cm", Namespace: principalNS}, + Data: map[string]string{}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create argocd-cm configmap") + + kubeClient := &kube.KubernetesClient{ + Clientset: cl, + DynamicClient: dynCl, + Context: context.TODO(), + Namespace: principalNS, + } + + // Don't create Principal TLS secret - should error + res := RunPrincipalChecks(context.TODO(), kubeClient, principalNS) + hasTLSError := false + for _, r := range res { + if r.err != nil && strings.Contains(r.name, "principal gRPC TLS") { + hasTLSError = true + require.NotEmpty(t, r.err.Error(), "error message should not be empty") + } + } + require.True(t, hasTLSError, "expected error when Principal TLS secret is missing") + }) + + t.Run("Missing Proxy TLS secret", func(t *testing.T) { + principalNS := "argocd" + cl := fake.NewSimpleClientset() + + scheme := runtime.NewScheme() + dynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + {Group: "argoproj.io", Version: "v1alpha1", Resource: "applications"}: "ApplicationList", + }) + + _, err := cl.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: principalNS}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create namespace") + + // Create CA and Principal TLS + caCertPEM, caKeyPEM, err := tlsutil.GenerateCaCertificate(config.SecretNamePrincipalCA) + require.NoError(t, err, "generate CA") + mustCreateTLSSecret(t, cl, principalNS, config.SecretNamePrincipalCA, caCertPEM, caKeyPEM) + + caCert, err := tlsutil.TLSCertFromSecret(context.TODO(), cl, principalNS, config.SecretNamePrincipalCA) + require.NoError(t, err, "read CA") + signer, err := x509.ParseCertificate(caCert.Certificate[0]) + require.NoError(t, err, "parse CA") + pCertPEM, pKeyPEM, err := tlsutil.GenerateServerCertificate("principal", signer, caCert.PrivateKey, []string{"127.0.0.1"}, []string{"localhost"}) + require.NoError(t, err, "gen principal cert") + mustCreateTLSSecret(t, cl, principalNS, config.SecretNamePrincipalTLS, pCertPEM, pKeyPEM) + + // Create argocd-cm ConfigMap + _, err = cl.CoreV1().ConfigMaps(principalNS).Create(context.TODO(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "argocd-cm", Namespace: principalNS}, + Data: map[string]string{}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create argocd-cm configmap") + + kubeClient := &kube.KubernetesClient{ + Clientset: cl, + DynamicClient: dynCl, + Context: context.TODO(), + Namespace: principalNS, + } + + // Don't create Proxy TLS secret - should error + res := RunPrincipalChecks(context.TODO(), kubeClient, principalNS) + hasProxyTLSError := false + for _, r := range res { + if r.err != nil && strings.Contains(r.name, "resource proxy TLS") { + hasProxyTLSError = true + require.NotEmpty(t, r.err.Error(), "error message should not be empty") + } + } + require.True(t, hasProxyTLSError, "expected error when Resource Proxy TLS secret is missing") + }) + + t.Run("Missing JWT secret", func(t *testing.T) { + principalNS := "argocd" + cl := fake.NewSimpleClientset() + + scheme := runtime.NewScheme() + dynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + {Group: "argoproj.io", Version: "v1alpha1", Resource: "applications"}: "ApplicationList", + }) + + _, err := cl.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: principalNS}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create namespace") + + // Create CA and TLS secrets + caCertPEM, caKeyPEM, err := tlsutil.GenerateCaCertificate(config.SecretNamePrincipalCA) + require.NoError(t, err, "generate CA") + mustCreateTLSSecret(t, cl, principalNS, config.SecretNamePrincipalCA, caCertPEM, caKeyPEM) + + caCert, err := tlsutil.TLSCertFromSecret(context.TODO(), cl, principalNS, config.SecretNamePrincipalCA) + require.NoError(t, err, "read CA") + signer, err := x509.ParseCertificate(caCert.Certificate[0]) + require.NoError(t, err, "parse CA") + pCertPEM, pKeyPEM, err := tlsutil.GenerateServerCertificate("principal", signer, caCert.PrivateKey, []string{"127.0.0.1"}, []string{"localhost"}) + require.NoError(t, err, "gen principal cert") + mustCreateTLSSecret(t, cl, principalNS, config.SecretNamePrincipalTLS, pCertPEM, pKeyPEM) + + rpCertPEM, rpKeyPEM, err := tlsutil.GenerateServerCertificate("resource-proxy", signer, caCert.PrivateKey, []string{"127.0.0.1"}, []string{"localhost"}) + require.NoError(t, err, "gen rp cert") + mustCreateTLSSecret(t, cl, principalNS, config.SecretNameProxyTLS, rpCertPEM, rpKeyPEM) + + // Create argocd-cm ConfigMap + _, err = cl.CoreV1().ConfigMaps(principalNS).Create(context.TODO(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "argocd-cm", Namespace: principalNS}, + Data: map[string]string{}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create argocd-cm configmap") + + kubeClient := &kube.KubernetesClient{ + Clientset: cl, + DynamicClient: dynCl, + Context: context.TODO(), + Namespace: principalNS, + } + + // Don't create JWT secret, should return error + res := RunPrincipalChecks(context.TODO(), kubeClient, principalNS) + hasJWTError := false + for _, r := range res { + if r.err != nil && strings.Contains(r.name, "JWT signing key") { + hasJWTError = true + require.NotEmpty(t, r.err.Error(), "error message should not be empty") + } + } + require.True(t, hasJWTError, "expected error when JWT secret is missing") + }) + + t.Run("Expired Principal TLS certificate", func(t *testing.T) { + principalNS := "argocd" + cl := fake.NewSimpleClientset() + + scheme := runtime.NewScheme() + dynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + {Group: "argoproj.io", Version: "v1alpha1", Resource: "applications"}: "ApplicationList", + }) + + _, err := cl.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: principalNS}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create namespace") + + // Create CA + caCertPEM, caKeyPEM, err := tlsutil.GenerateCaCertificate(config.SecretNamePrincipalCA) + require.NoError(t, err, "generate CA") + mustCreateTLSSecret(t, cl, principalNS, config.SecretNamePrincipalCA, caCertPEM, caKeyPEM) + + caCert, err := tlsutil.TLSCertFromSecret(context.TODO(), cl, principalNS, config.SecretNamePrincipalCA) + require.NoError(t, err, "read CA") + signer, err := x509.ParseCertificate(caCert.Certificate[0]) + require.NoError(t, err, "parse CA") + + // Create expired Principal TLS certificate + expiredCertPEM, expiredKeyPEM := createExpiredCertificate(t, "principal", signer, caCert.PrivateKey, []string{"127.0.0.1"}, []string{"localhost"}) + mustCreateTLSSecret(t, cl, principalNS, config.SecretNamePrincipalTLS, expiredCertPEM, expiredKeyPEM) + + // Create argocd-cm ConfigMap + _, err = cl.CoreV1().ConfigMaps(principalNS).Create(context.TODO(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "argocd-cm", Namespace: principalNS}, + Data: map[string]string{}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create argocd-cm configmap") + + kubeClient := &kube.KubernetesClient{ + Clientset: cl, + DynamicClient: dynCl, + Context: context.TODO(), + Namespace: principalNS, + } + + res := RunPrincipalChecks(context.TODO(), kubeClient, principalNS) + hasExpiredError := false + for _, r := range res { + if r.err != nil && strings.Contains(r.name, "principal gRPC TLS") { + hasExpiredError = true + require.Contains(t, r.err.Error(), "expired", "error should mention certificate expired") + } + } + require.True(t, hasExpiredError, "expected error when Principal TLS certificate is expired") + }) + + t.Run("Expired Proxy TLS certificate", func(t *testing.T) { + principalNS := "argocd" + cl := fake.NewSimpleClientset() + + scheme := runtime.NewScheme() + dynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + {Group: "argoproj.io", Version: "v1alpha1", Resource: "applications"}: "ApplicationList", + }) + + _, err := cl.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: principalNS}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create namespace") + + // Create CA + caCertPEM, caKeyPEM, err := tlsutil.GenerateCaCertificate(config.SecretNamePrincipalCA) + require.NoError(t, err, "generate CA") + mustCreateTLSSecret(t, cl, principalNS, config.SecretNamePrincipalCA, caCertPEM, caKeyPEM) + + caCert, err := tlsutil.TLSCertFromSecret(context.TODO(), cl, principalNS, config.SecretNamePrincipalCA) + require.NoError(t, err, "read CA") + signer, err := x509.ParseCertificate(caCert.Certificate[0]) + require.NoError(t, err, "parse CA") + + // Create valid Principal TLS + pCertPEM, pKeyPEM, err := tlsutil.GenerateServerCertificate("principal", signer, caCert.PrivateKey, []string{"127.0.0.1"}, []string{"localhost"}) + require.NoError(t, err, "gen principal cert") + mustCreateTLSSecret(t, cl, principalNS, config.SecretNamePrincipalTLS, pCertPEM, pKeyPEM) + + // Create expired Resource Proxy TLS certificate + expiredCertPEM, expiredKeyPEM := createExpiredCertificate(t, "resource-proxy", signer, caCert.PrivateKey, []string{"127.0.0.1"}, []string{"localhost"}) + mustCreateTLSSecret(t, cl, principalNS, config.SecretNameProxyTLS, expiredCertPEM, expiredKeyPEM) + + // Create argocd-cm ConfigMap + _, err = cl.CoreV1().ConfigMaps(principalNS).Create(context.TODO(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "argocd-cm", Namespace: principalNS}, + Data: map[string]string{}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create argocd-cm configmap") + + kubeClient := &kube.KubernetesClient{ + Clientset: cl, + DynamicClient: dynCl, + Context: context.TODO(), + Namespace: principalNS, + } + + res := RunPrincipalChecks(context.TODO(), kubeClient, principalNS) + hasExpiredError := false + for _, r := range res { + if r.err != nil && strings.Contains(r.name, "resource proxy TLS") { + hasExpiredError = true + require.Contains(t, r.err.Error(), "expired", "error should mention certificate expired") + } + } + require.True(t, hasExpiredError, "expected error when Resource Proxy TLS certificate is expired") + }) + + t.Run("Namespaced mode configuration", func(t *testing.T) { + principalNS := "argocd" + cl := fake.NewSimpleClientset() + + scheme := runtime.NewScheme() + dynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + {Group: "argoproj.io", Version: "v1alpha1", Resource: "applications"}: "ApplicationList", + }) + + _, err := cl.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: principalNS}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create namespace") + + // Create argocd-cm ConfigMap with application.namespaces set (namespaced mode) + _, err = cl.CoreV1().ConfigMaps(principalNS).Create(context.TODO(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "argocd-cm", Namespace: principalNS}, + Data: map[string]string{ + "application.namespaces": "default,argocd", // Namespaced mode + }, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create argocd-cm configmap") + + kubeClient := &kube.KubernetesClient{ + Clientset: cl, + DynamicClient: dynCl, + Context: context.TODO(), + Namespace: principalNS, + } + + res := RunPrincipalChecks(context.TODO(), kubeClient, principalNS) + hasNamespacedError := false + for _, r := range res { + if r.err != nil && strings.Contains(r.name, "cluster-scoped mode") { + hasNamespacedError = true + require.Contains(t, r.err.Error(), "namespaced mode", "error should mention namespaced mode") + } + } + require.True(t, hasNamespacedError, "expected error when Argo CD is in namespaced mode") + }) + + t.Run("Invalid JWT key type", func(t *testing.T) { + principalNS := "argocd" + cl := fake.NewSimpleClientset() + + scheme := runtime.NewScheme() + dynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + {Group: "argoproj.io", Version: "v1alpha1", Resource: "applications"}: "ApplicationList", + }) + + _, err := cl.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: principalNS}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create namespace") + + // Create CA and TLS secrets + caCertPEM, caKeyPEM, err := tlsutil.GenerateCaCertificate(config.SecretNamePrincipalCA) + require.NoError(t, err, "generate CA") + mustCreateTLSSecret(t, cl, principalNS, config.SecretNamePrincipalCA, caCertPEM, caKeyPEM) + + caCert, err := tlsutil.TLSCertFromSecret(context.TODO(), cl, principalNS, config.SecretNamePrincipalCA) + require.NoError(t, err, "read CA") + signer, err := x509.ParseCertificate(caCert.Certificate[0]) + require.NoError(t, err, "parse CA") + pCertPEM, pKeyPEM, err := tlsutil.GenerateServerCertificate("principal", signer, caCert.PrivateKey, []string{"127.0.0.1"}, []string{"localhost"}) + require.NoError(t, err, "gen principal cert") + mustCreateTLSSecret(t, cl, principalNS, config.SecretNamePrincipalTLS, pCertPEM, pKeyPEM) + + rpCertPEM, rpKeyPEM, err := tlsutil.GenerateServerCertificate("resource-proxy", signer, caCert.PrivateKey, []string{"127.0.0.1"}, []string{"localhost"}) + require.NoError(t, err, "gen rp cert") + mustCreateTLSSecret(t, cl, principalNS, config.SecretNameProxyTLS, rpCertPEM, rpKeyPEM) + + // Create JWT secret with ECDSA key (non-RSA), should return error + ecdsaKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err, "gen ecdsa key") + ecdsaPKCS8, err := x509.MarshalPKCS8PrivateKey(ecdsaKey) + require.NoError(t, err, "marshal ecdsa pkcs8") + block := &pem.Block{Type: "PRIVATE KEY", Bytes: ecdsaPKCS8} + jwtPem := pem.EncodeToMemory(block) + _, err = cl.CoreV1().Secrets(principalNS).Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: config.SecretNameJWT, Namespace: principalNS}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "jwt.key": jwtPem, + }, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create jwt secret with ECDSA") + + // Create argocd-cm ConfigMap + _, err = cl.CoreV1().ConfigMaps(principalNS).Create(context.TODO(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "argocd-cm", Namespace: principalNS}, + Data: map[string]string{}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create argocd-cm configmap") + + kubeClient := &kube.KubernetesClient{ + Clientset: cl, + DynamicClient: dynCl, + Context: context.TODO(), + Namespace: principalNS, + } + + res := RunPrincipalChecks(context.TODO(), kubeClient, principalNS) + hasJWTKeyTypeError := false + for _, r := range res { + if r.err != nil && strings.Contains(r.name, "JWT signing key") { + hasJWTKeyTypeError = true + require.Contains(t, r.err.Error(), "RSA", "error should mention RSA requirement") + } + } + require.True(t, hasJWTKeyTypeError, "expected error when JWT key is not RSA") + }) + + t.Run("Route host mismatch", func(t *testing.T) { + principalNS := "argocd" + cl := fake.NewSimpleClientset() + + scheme := runtime.NewScheme() + dynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + {Group: "argoproj.io", Version: "v1alpha1", Resource: "applications"}: "ApplicationList", + {Group: "route.openshift.io", Version: "v1", Resource: "routes"}: "RouteList", + }) + + _, err := cl.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: principalNS}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create namespace") + + // Create CA and TLS secrets + caCertPEM, caKeyPEM, err := tlsutil.GenerateCaCertificate(config.SecretNamePrincipalCA) + require.NoError(t, err, "generate CA") + mustCreateTLSSecret(t, cl, principalNS, config.SecretNamePrincipalCA, caCertPEM, caKeyPEM) + + caCert, err := tlsutil.TLSCertFromSecret(context.TODO(), cl, principalNS, config.SecretNamePrincipalCA) + require.NoError(t, err, "read CA") + signer, err := x509.ParseCertificate(caCert.Certificate[0]) + require.NoError(t, err, "parse CA") + // Certificate with DNS: localhost, but route will have different host + pCertPEM, pKeyPEM, err := tlsutil.GenerateServerCertificate("principal", signer, caCert.PrivateKey, []string{"127.0.0.1"}, []string{"localhost"}) + require.NoError(t, err, "gen principal cert") + mustCreateTLSSecret(t, cl, principalNS, config.SecretNamePrincipalTLS, pCertPEM, pKeyPEM) + + rpCertPEM, rpKeyPEM, err := tlsutil.GenerateServerCertificate("resource-proxy", signer, caCert.PrivateKey, []string{"127.0.0.1"}, []string{"localhost"}) + require.NoError(t, err, "gen rp cert") + mustCreateTLSSecret(t, cl, principalNS, config.SecretNameProxyTLS, rpCertPEM, rpKeyPEM) + + // JWT secret + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "gen jwt key") + pkcs8, err := x509.MarshalPKCS8PrivateKey(rsaKey) + require.NoError(t, err, "marshal pkcs8") + block := &pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8} + jwtPem := pem.EncodeToMemory(block) + _, err = cl.CoreV1().Secrets(principalNS).Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: config.SecretNameJWT, Namespace: principalNS}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "jwt.key": jwtPem, + }, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create jwt secret") + + // Create argocd-cm ConfigMap + _, err = cl.CoreV1().ConfigMaps(principalNS).Create(context.TODO(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "argocd-cm", Namespace: principalNS}, + Data: map[string]string{}, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create argocd-cm configmap") + + // Create OpenShift Route with hostname that doesn't match cert DNS + route := &unstructured.Unstructured{} + route.SetAPIVersion("route.openshift.io/v1") + route.SetKind("Route") + route.SetName("argocd-server") + route.SetNamespace(principalNS) + route.Object["spec"] = map[string]interface{}{ + "host": "mismatched-host.example.com", // Doesn't match cert DNS + } + _, err = dynCl.Resource(schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"}).Namespace(principalNS).Create(context.TODO(), route, metav1.CreateOptions{}) + require.NoError(t, err, "create route") + + // Mock the discovery API to include the Route API so routeAPIExists returns true + fakeDiscovery := cl.Discovery().(*fakediscovery.FakeDiscovery) + routeGroupVersion := schema.GroupVersion{Group: "route.openshift.io", Version: "v1"} + routeResourceList := &metav1.APIResourceList{ + GroupVersion: routeGroupVersion.String(), + APIResources: []metav1.APIResource{ + {Group: "route.openshift.io", Version: "v1", Name: "routes", SingularName: "route", Namespaced: true, Kind: "Route", Verbs: []string{"get", "list", "watch", "create", "update", "delete"}}, + }, + } + fakeDiscovery.Resources = append(fakeDiscovery.Resources, routeResourceList) + + kubeClient := &kube.KubernetesClient{ + Clientset: cl, + DynamicClient: dynCl, + Context: context.TODO(), + Namespace: principalNS, + } + + res := RunPrincipalChecks(context.TODO(), kubeClient, principalNS) + hasRouteMismatchError := false + for _, r := range res { + if r.err != nil && strings.Contains(r.name, "Route host") { + hasRouteMismatchError = true + require.NotEmpty(t, r.err.Error(), "error message should not be empty") + } + } + require.True(t, hasRouteMismatchError, "expected error when Route host doesn't match certificate DNS") + }) +} + +func TestCheckConfigAgent(t *testing.T) { + t.Run("Valid configuration", func(t *testing.T) { + agentNS := "argocd" + principalNS := "argocd" + agentCl := fake.NewSimpleClientset() + principalCl := fake.NewSimpleClientset() + + scheme := runtime.NewScheme() + agentDynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + }) + principalDynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + }) + + agentKubeClient := &kube.KubernetesClient{ + Clientset: agentCl, + DynamicClient: agentDynCl, + Context: context.TODO(), + Namespace: agentNS, + } + principalKubeClient := &kube.KubernetesClient{ + Clientset: principalCl, + DynamicClient: principalDynCl, + Context: context.TODO(), + Namespace: principalNS, + } + + // Create principal CA + caCertPEM, caKeyPEM, err := tlsutil.GenerateCaCertificate(config.SecretNamePrincipalCA) + require.NoError(t, err, "generate CA") + mustCreateTLSSecret(t, principalCl, principalNS, config.SecretNamePrincipalCA, caCertPEM, caKeyPEM) + + // Create agent CA secret on agent cluster (opaque with ca.crt) + _, err = agentCl.CoreV1().Secrets(agentNS).Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: config.SecretNameAgentCA, Namespace: agentNS}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "ca.crt": []byte(caCertPEM), + }, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create agent ca secret") + + // Issue a client cert for agent subject + caCert, err := tlsutil.TLSCertFromSecret(context.TODO(), principalCl, principalNS, config.SecretNamePrincipalCA) + require.NoError(t, err, "read CA") + signer, err := x509.ParseCertificate(caCert.Certificate[0]) + require.NoError(t, err, "parse CA") + agentName := "test-cluster" + cCert, cKey, err := tlsutil.GenerateClientCertificate(agentName, signer, caCert.PrivateKey) + require.NoError(t, err, "gen client cert") + mustCreateTLSSecret(t, agentCl, agentNS, config.SecretNameAgentClientCert, cCert, cKey) + + // Ensure matching namespace exists on principal + _, err = principalCl.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: agentName}}, metav1.CreateOptions{}) + require.NoError(t, err, "create ns") + + res := RunAgentChecks(context.TODO(), agentKubeClient, agentNS, principalKubeClient, principalNS) + for _, r := range res { + require.NoError(t, r.err, "check failed: %s", r.name) + } + }) + + t.Run("Missing agent CA secret", func(t *testing.T) { + agentNS := "argocd" + principalNS := "argocd" + agentCl := fake.NewSimpleClientset() + principalCl := fake.NewSimpleClientset() + + scheme := runtime.NewScheme() + agentDynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + }) + principalDynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + }) + + agentKubeClient := &kube.KubernetesClient{ + Clientset: agentCl, + DynamicClient: agentDynCl, + Context: context.TODO(), + Namespace: agentNS, + } + principalKubeClient := &kube.KubernetesClient{ + Clientset: principalCl, + DynamicClient: principalDynCl, + Context: context.TODO(), + Namespace: principalNS, + } + + // Create principal CA (required for principal checks) + caCertPEM, caKeyPEM, err := tlsutil.GenerateCaCertificate(config.SecretNamePrincipalCA) + require.NoError(t, err, "generate CA") + mustCreateTLSSecret(t, principalCl, principalNS, config.SecretNamePrincipalCA, caCertPEM, caKeyPEM) + + // Don't create agent CA secret, should return error + res := RunAgentChecks(context.TODO(), agentKubeClient, agentNS, principalKubeClient, principalNS) + hasErrors := false + for _, r := range res { + if r.err != nil { + hasErrors = true + require.NotEmpty(t, r.err.Error(), "error message should not be empty for: %s", r.name) + } + } + require.True(t, hasErrors, "expected checks to fail when agent CA secret is missing") + }) + + t.Run("Missing namespace on principal", func(t *testing.T) { + agentNS := "argocd" + principalNS := "argocd" + agentCl := fake.NewSimpleClientset() + principalCl := fake.NewSimpleClientset() + + scheme := runtime.NewScheme() + agentDynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + }) + principalDynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + }) + + agentKubeClient := &kube.KubernetesClient{ + Clientset: agentCl, + DynamicClient: agentDynCl, + Context: context.TODO(), + Namespace: agentNS, + } + principalKubeClient := &kube.KubernetesClient{ + Clientset: principalCl, + DynamicClient: principalDynCl, + Context: context.TODO(), + Namespace: principalNS, + } + + // Create principal CA + caCertPEM, caKeyPEM, err := tlsutil.GenerateCaCertificate(config.SecretNamePrincipalCA) + require.NoError(t, err, "generate CA") + mustCreateTLSSecret(t, principalCl, principalNS, config.SecretNamePrincipalCA, caCertPEM, caKeyPEM) + + // Create agent CA secret + _, err = agentCl.CoreV1().Secrets(agentNS).Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: config.SecretNameAgentCA, Namespace: agentNS}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "ca.crt": []byte(caCertPEM), + }, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create agent ca secret") + + // Issue a client cert for agent subject + caCert, err := tlsutil.TLSCertFromSecret(context.TODO(), principalCl, principalNS, config.SecretNamePrincipalCA) + require.NoError(t, err, "read CA") + signer, err := x509.ParseCertificate(caCert.Certificate[0]) + require.NoError(t, err, "parse CA") + agentName := "non-existent-namespace" + cCert, cKey, err := tlsutil.GenerateClientCertificate(agentName, signer, caCert.PrivateKey) + require.NoError(t, err, "gen client cert") + mustCreateTLSSecret(t, agentCl, agentNS, config.SecretNameAgentClientCert, cCert, cKey) + + // Don't create the namespace on principal, should return error + res := RunAgentChecks(context.TODO(), agentKubeClient, agentNS, principalKubeClient, principalNS) + hasNamespaceError := false + for _, r := range res { + if r.err != nil && strings.Contains(r.name, "namespace") { + hasNamespaceError = true + require.Contains(t, r.err.Error(), "not found", "error should mention namespace not found") + } + } + require.True(t, hasNamespaceError, "expected error when namespace doesn't exist on principal") + }) + + t.Run("Missing client cert secret", func(t *testing.T) { + agentNS := "argocd" + principalNS := "argocd" + agentCl := fake.NewSimpleClientset() + principalCl := fake.NewSimpleClientset() + + scheme := runtime.NewScheme() + agentDynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + }) + principalDynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + }) + + agentKubeClient := &kube.KubernetesClient{ + Clientset: agentCl, + DynamicClient: agentDynCl, + Context: context.TODO(), + Namespace: agentNS, + } + principalKubeClient := &kube.KubernetesClient{ + Clientset: principalCl, + DynamicClient: principalDynCl, + Context: context.TODO(), + Namespace: principalNS, + } + + // Create principal CA + caCertPEM, caKeyPEM, err := tlsutil.GenerateCaCertificate(config.SecretNamePrincipalCA) + require.NoError(t, err, "generate CA") + mustCreateTLSSecret(t, principalCl, principalNS, config.SecretNamePrincipalCA, caCertPEM, caKeyPEM) + + // Create agent CA secret + _, err = agentCl.CoreV1().Secrets(agentNS).Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: config.SecretNameAgentCA, Namespace: agentNS}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "ca.crt": []byte(caCertPEM), + }, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create agent ca secret") + + // Don't create agent client cert secret, should return error + res := RunAgentChecks(context.TODO(), agentKubeClient, agentNS, principalKubeClient, principalNS) + hasClientCertError := false + for _, r := range res { + if r.err != nil && strings.Contains(r.name, "mTLS certificate") { + hasClientCertError = true + require.NotEmpty(t, r.err.Error(), "error message should not be empty") + } + } + require.True(t, hasClientCertError, "expected error when agent client cert secret is missing") + }) + + t.Run("Expired client certificate", func(t *testing.T) { + agentNS := "argocd" + principalNS := "argocd" + agentCl := fake.NewSimpleClientset() + principalCl := fake.NewSimpleClientset() + + scheme := runtime.NewScheme() + agentDynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + }) + principalDynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + }) + + agentKubeClient := &kube.KubernetesClient{ + Clientset: agentCl, + DynamicClient: agentDynCl, + Context: context.TODO(), + Namespace: agentNS, + } + principalKubeClient := &kube.KubernetesClient{ + Clientset: principalCl, + DynamicClient: principalDynCl, + Context: context.TODO(), + Namespace: principalNS, + } + + // Create principal CA + caCertPEM, caKeyPEM, err := tlsutil.GenerateCaCertificate(config.SecretNamePrincipalCA) + require.NoError(t, err, "generate CA") + mustCreateTLSSecret(t, principalCl, principalNS, config.SecretNamePrincipalCA, caCertPEM, caKeyPEM) + + // Create agent CA secret + _, err = agentCl.CoreV1().Secrets(agentNS).Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: config.SecretNameAgentCA, Namespace: agentNS}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "ca.crt": []byte(caCertPEM), + }, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create agent ca secret") + + // Create expired client cert + caCert, err := tlsutil.TLSCertFromSecret(context.TODO(), principalCl, principalNS, config.SecretNamePrincipalCA) + require.NoError(t, err, "read CA") + signer, err := x509.ParseCertificate(caCert.Certificate[0]) + require.NoError(t, err, "parse CA") + agentName := "test-cluster" + expiredCertPEM, expiredKeyPEM := createExpiredCertificate(t, agentName, signer, caCert.PrivateKey, []string{}, []string{}) + mustCreateTLSSecret(t, agentCl, agentNS, config.SecretNameAgentClientCert, expiredCertPEM, expiredKeyPEM) + + res := RunAgentChecks(context.TODO(), agentKubeClient, agentNS, principalKubeClient, principalNS) + hasExpiredError := false + for _, r := range res { + if r.err != nil && strings.Contains(r.name, "mTLS certificate") { + hasExpiredError = true + require.Contains(t, r.err.Error(), "expired", "error should mention certificate expired") + } + } + require.True(t, hasExpiredError, "expected error when agent client cert is expired") + }) + + t.Run("Client cert not signed by principal CA", func(t *testing.T) { + agentNS := "argocd" + principalNS := "argocd" + agentCl := fake.NewSimpleClientset() + principalCl := fake.NewSimpleClientset() + + scheme := runtime.NewScheme() + agentDynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + }) + principalDynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + }) + + agentKubeClient := &kube.KubernetesClient{ + Clientset: agentCl, + DynamicClient: agentDynCl, + Context: context.TODO(), + Namespace: agentNS, + } + principalKubeClient := &kube.KubernetesClient{ + Clientset: principalCl, + DynamicClient: principalDynCl, + Context: context.TODO(), + Namespace: principalNS, + } + + // Create principal CA + caCertPEM, caKeyPEM, err := tlsutil.GenerateCaCertificate(config.SecretNamePrincipalCA) + require.NoError(t, err, "generate CA") + mustCreateTLSSecret(t, principalCl, principalNS, config.SecretNamePrincipalCA, caCertPEM, caKeyPEM) + + // Create agent CA secret (with principal CA) + _, err = agentCl.CoreV1().Secrets(agentNS).Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: config.SecretNameAgentCA, Namespace: agentNS}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "ca.crt": []byte(caCertPEM), + }, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create agent ca secret") + + // Create client cert signed by different CA + agentName := "test-cluster" + wrongCertPEM, wrongKeyPEM := createCertSignedByDifferentCA(t, agentName, []string{}, []string{}) + mustCreateTLSSecret(t, agentCl, agentNS, config.SecretNameAgentClientCert, wrongCertPEM, wrongKeyPEM) + + // Create namespace on principal + _, err = principalCl.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: agentName}}, metav1.CreateOptions{}) + require.NoError(t, err, "create ns") + + res := RunAgentChecks(context.TODO(), agentKubeClient, agentNS, principalKubeClient, principalNS) + hasSignatureError := false + for _, r := range res { + if r.err != nil && strings.Contains(r.name, "signed by principal CA") { + hasSignatureError = true + require.Contains(t, r.err.Error(), "not signed", "error should mention certificate not signed by principal CA") + } + } + require.True(t, hasSignatureError, "expected error when agent cert is not signed by principal CA") + }) + + t.Run("Client cert with empty CN", func(t *testing.T) { + agentNS := "argocd" + principalNS := "argocd" + agentCl := fake.NewSimpleClientset() + principalCl := fake.NewSimpleClientset() + + scheme := runtime.NewScheme() + agentDynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + }) + principalDynCl := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{ + {Group: "argoproj.io", Version: "v1beta1", Resource: "argocds"}: "ArgoCDList", + }) + + agentKubeClient := &kube.KubernetesClient{ + Clientset: agentCl, + DynamicClient: agentDynCl, + Context: context.TODO(), + Namespace: agentNS, + } + principalKubeClient := &kube.KubernetesClient{ + Clientset: principalCl, + DynamicClient: principalDynCl, + Context: context.TODO(), + Namespace: principalNS, + } + + // Create principal CA + caCertPEM, caKeyPEM, err := tlsutil.GenerateCaCertificate(config.SecretNamePrincipalCA) + require.NoError(t, err, "generate CA") + mustCreateTLSSecret(t, principalCl, principalNS, config.SecretNamePrincipalCA, caCertPEM, caKeyPEM) + + // Create agent CA secret + _, err = agentCl.CoreV1().Secrets(agentNS).Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: config.SecretNameAgentCA, Namespace: agentNS}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "ca.crt": []byte(caCertPEM), + }, + }, metav1.CreateOptions{}) + require.NoError(t, err, "create agent ca secret") + + // Create client cert with empty CN + caCert, err := tlsutil.TLSCertFromSecret(context.TODO(), principalCl, principalNS, config.SecretNamePrincipalCA) + require.NoError(t, err, "read CA") + signer, err := x509.ParseCertificate(caCert.Certificate[0]) + require.NoError(t, err, "parse CA") + emptyCNCertPEM, emptyCNKeyPEM := createCertWithEmptyCN(t, signer, caCert.PrivateKey) + mustCreateTLSSecret(t, agentCl, agentNS, config.SecretNameAgentClientCert, emptyCNCertPEM, emptyCNKeyPEM) + + res := RunAgentChecks(context.TODO(), agentKubeClient, agentNS, principalKubeClient, principalNS) + hasEmptyCNError := false + for _, r := range res { + if r.err != nil && strings.Contains(r.name, "namespace") { + hasEmptyCNError = true + require.Contains(t, r.err.Error(), "empty", "error should mention empty CN") + } + } + require.True(t, hasEmptyCNError, "expected error when agent cert has empty CN") + }) +} diff --git a/cmd/ctl/main.go b/cmd/ctl/main.go index d593ce8f..7723488f 100644 --- a/cmd/ctl/main.go +++ b/cmd/ctl/main.go @@ -49,6 +49,7 @@ func NewRootCommand() *cobra.Command { configGroup := &cobra.Group{ID: "config", Title: "Configuration"} command.AddGroup(configGroup) command.AddCommand(NewAgentCommand()) + command.AddCommand(NewCheckConfigCommand()) command.AddCommand(NewPKICommand()) command.AddCommand(NewJWTCommand()) command.AddCommand(NewVersionCommand())