Skip to content

Commit 7afd305

Browse files
committed
feat(ctl): add a 'check-config' principal/agent commands to argocd-agentctl which will verify the user's configuration
Assisted-by: Cursor Signed-off-by: Rizwana777 <[email protected]>
1 parent 6235cf1 commit 7afd305

File tree

3 files changed

+529
-0
lines changed

3 files changed

+529
-0
lines changed

cmd/ctl/check_config.go

Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
// Copyright 2025 The argocd-agent Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"context"
19+
"crypto/rsa"
20+
"crypto/x509"
21+
"fmt"
22+
"strings"
23+
"time"
24+
25+
"github.com/argoproj-labs/argocd-agent/cmd/cmdutil"
26+
"github.com/argoproj-labs/argocd-agent/internal/config"
27+
"github.com/argoproj-labs/argocd-agent/internal/kube"
28+
"github.com/argoproj-labs/argocd-agent/internal/tlsutil"
29+
"github.com/spf13/cobra"
30+
k8serrors "k8s.io/apimachinery/pkg/api/errors"
31+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
32+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
33+
"k8s.io/apimachinery/pkg/runtime/schema"
34+
"k8s.io/client-go/kubernetes"
35+
)
36+
37+
type checkResult struct {
38+
name string
39+
err error
40+
}
41+
42+
func (r checkResult) String() string {
43+
if r.err == nil {
44+
return fmt.Sprintf("* %s: ✅", r.name)
45+
}
46+
return fmt.Sprintf("* %s: ❌\nERROR: %v", r.name, r.err)
47+
}
48+
49+
func NewCheckConfigCommand() *cobra.Command {
50+
cmd := &cobra.Command{
51+
Use: "check-config",
52+
Short: "Validate principal and agent configuration",
53+
Run: func(cmd *cobra.Command, args []string) {
54+
_ = cmd.Help()
55+
},
56+
GroupID: "config",
57+
}
58+
cmd.AddCommand(NewCheckConfigPrincipalCommand())
59+
cmd.AddCommand(NewCheckConfigAgentCommand())
60+
return cmd
61+
}
62+
63+
func NewCheckConfigPrincipalCommand() *cobra.Command {
64+
command := &cobra.Command{
65+
Use: "principal",
66+
Short: "Validate principal configuration",
67+
Run: func(cmd *cobra.Command, args []string) {
68+
if strings.TrimSpace(globalOpts.principalNamespace) == "" {
69+
cmdutil.Fatal("--principal-namespace is required")
70+
}
71+
ctx := context.TODO()
72+
clt, err := kube.NewKubernetesClientFromConfig(ctx, globalOpts.principalNamespace, "", globalOpts.principalContext)
73+
if err != nil {
74+
cmdutil.Fatal("Could not create Kubernetes client: %v", err)
75+
}
76+
results := RunPrincipalChecks(ctx, clt, globalOpts.principalNamespace)
77+
printResultsAndExit(results)
78+
},
79+
}
80+
return command
81+
}
82+
83+
func NewCheckConfigAgentCommand() *cobra.Command {
84+
command := &cobra.Command{
85+
Use: "agent",
86+
Short: "Validate agent configuration (and principal cross-checks)",
87+
Run: func(cmd *cobra.Command, args []string) {
88+
if strings.TrimSpace(globalOpts.agentContext) == "" ||
89+
strings.TrimSpace(globalOpts.agentNamespace) == "" ||
90+
strings.TrimSpace(globalOpts.principalContext) == "" ||
91+
strings.TrimSpace(globalOpts.principalNamespace) == "" {
92+
cmdutil.Fatal("--agent-context, --agent-namespace, --principal-context, --principal-namespace are all required")
93+
}
94+
ctx := context.TODO()
95+
agentClt, err := kube.NewKubernetesClientFromConfig(ctx, globalOpts.agentNamespace, "", globalOpts.agentContext)
96+
if err != nil {
97+
cmdutil.Fatal("Could not create agent Kubernetes client: %v", err)
98+
}
99+
principalClt, err := kube.NewKubernetesClientFromConfig(ctx, globalOpts.principalNamespace, "", globalOpts.principalContext)
100+
if err != nil {
101+
cmdutil.Fatal("Could not create principal Kubernetes client: %v", err)
102+
}
103+
// Run principal checks as part of agent checks
104+
results := []checkResult{}
105+
results = append(results, RunPrincipalChecks(ctx, principalClt, globalOpts.principalNamespace)...)
106+
results = append(results, RunAgentChecks(ctx, agentClt.Clientset, globalOpts.agentNamespace, principalClt.Clientset, globalOpts.principalNamespace)...)
107+
printResultsAndExit(results)
108+
},
109+
}
110+
return command
111+
}
112+
113+
func printResultsAndExit(results []checkResult) {
114+
hasErr := false
115+
fmt.Println("Configuration validation results:")
116+
for _, r := range results {
117+
fmt.Println(r.String())
118+
if r.err != nil {
119+
hasErr = true
120+
}
121+
}
122+
if hasErr {
123+
cmdutil.Fatal("one or more checks failed")
124+
}
125+
}
126+
127+
// RunPrincipalChecks validates the principal installation and related security assets.
128+
func RunPrincipalChecks(ctx context.Context, kubeClient *kube.KubernetesClient, principalNS string) []checkResult {
129+
out := []checkResult{}
130+
131+
// Ensure Argo CD is cluster-scoped
132+
out = append(out, checkResult{
133+
name: "Verifying Argo CD is cluster-scoped",
134+
err: verifyArgoCDClusterScoped(ctx, kubeClient, principalNS),
135+
})
136+
137+
// CA secret exists in principal namespace and is a valid TLS secret
138+
out = append(out, checkResult{
139+
name: fmt.Sprintf("Verifying principal public CA certificate exists and is valid (%s/%s)", principalNS, config.SecretNamePrincipalCA),
140+
err: principalCheckCA(ctx, kubeClient.Clientset, principalNS),
141+
})
142+
143+
// Principal gRPC TLS secret exists in principal namespace and is valid
144+
out = append(out, checkResult{
145+
name: fmt.Sprintf("Verifying principal gRPC TLS certificate exists and is valid (%s/%s)", principalNS, config.SecretNamePrincipalTLS),
146+
err: certSecretValid(ctx, kubeClient.Clientset, principalNS, config.SecretNamePrincipalTLS),
147+
})
148+
149+
// Resource proxy TLS secret exists and is valid
150+
out = append(out, checkResult{
151+
name: fmt.Sprintf("Verifying resource proxy TLS certificate exists and is valid (%s/%s)", principalNS, config.SecretNameProxyTLS),
152+
err: certSecretValid(ctx, kubeClient.Clientset, principalNS, config.SecretNameProxyTLS),
153+
})
154+
155+
// JWT signing key exists
156+
out = append(out, checkResult{
157+
name: fmt.Sprintf("Verifying JWT signing key exists and is parseable (%s/%s)", principalNS, config.SecretNameJWT),
158+
err: jwtKeyValid(ctx, kubeClient.Clientset, principalNS, config.SecretNameJWT),
159+
})
160+
161+
// Route host matches TLS SANs (OpenShift-only)
162+
out = append(out, checkResult{
163+
name: "Verifying principal TLS secret ips/dns match Route host (OpenShift)",
164+
err: verifyRouteHostMatchesCert(ctx, kubeClient, principalNS, config.SecretNamePrincipalTLS),
165+
})
166+
167+
return out
168+
}
169+
170+
// RunAgentChecks validates agent-side security assets and cross-validates them
171+
// against the principal cluster.
172+
func RunAgentChecks(ctx context.Context, agentKube kubernetes.Interface, agentNS string, principalKube kubernetes.Interface, principalNS string) []checkResult {
173+
out := []checkResult{}
174+
175+
// Agent CA secret exists and has CA data (opaque secret with ca.crt)
176+
out = append(out, checkResult{
177+
name: fmt.Sprintf("Verifying agent CA secret exists and contains CA cert (%s/%s)", agentNS, config.SecretNameAgentCA),
178+
err: agentCASecretValid(ctx, agentKube, agentNS, config.SecretNameAgentCA),
179+
})
180+
181+
// Agent client TLS secret exists and is valid and not expired
182+
out = append(out, checkResult{
183+
name: fmt.Sprintf("Verifying agent mTLS certificate exists and is not expired (%s/%s)", agentNS, config.SecretNameAgentClientCert),
184+
err: clientCertNotExpired(ctx, agentKube, agentNS, config.SecretNameAgentClientCert),
185+
})
186+
187+
// Namespace with same name as agent cert subject exists on principal cluster
188+
out = append(out, checkResult{
189+
name: "Verifying namespace on principal matches agent certificate subject",
190+
err: namespaceMatchesAgentSubject(ctx, agentKube, agentNS, principalKube, principalNS),
191+
})
192+
193+
// Agent client TLS is signed by principal CA
194+
out = append(out, checkResult{
195+
name: "Verifying agent mTLS certificate is signed by principal CA certificate",
196+
err: clientCertSignedByPrincipalCA(ctx, agentKube, agentNS, principalKube, principalNS),
197+
})
198+
199+
return out
200+
}
201+
202+
func principalCheckCA(ctx context.Context, kubeClient kubernetes.Interface, ns string) error {
203+
_, err := tlsutil.TLSCertFromSecret(ctx, kubeClient, ns, config.SecretNamePrincipalCA)
204+
return err
205+
}
206+
207+
func certSecretValid(ctx context.Context, kubeClient kubernetes.Interface, ns, name string) error {
208+
parsed, err := x509FromTLSSecret(ctx, kubeClient, ns, name)
209+
if err != nil {
210+
return err
211+
}
212+
if time.Now().After(parsed.NotAfter) {
213+
return fmt.Errorf("certificate in secret %s/%s is expired", ns, name)
214+
}
215+
return nil
216+
}
217+
218+
func jwtKeyValid(ctx context.Context, kubeClient kubernetes.Interface, ns, name string) error {
219+
key, err := tlsutil.JWTSigningKeyFromSecret(ctx, kubeClient, ns, name)
220+
if err != nil {
221+
return err
222+
}
223+
// Require RSA for now
224+
if _, ok := key.(*rsa.PrivateKey); !ok {
225+
return fmt.Errorf("JWT signing key is not an RSA private key")
226+
}
227+
return nil
228+
}
229+
230+
// verifyArgoCDClusterScoped checks operator CR or argocd-cm to ensure no namespaced mode is configured.
231+
func verifyArgoCDClusterScoped(ctx context.Context, kc *kube.KubernetesClient, ns string) error {
232+
// Try operator CR first: group argoproj.io, version v1alpha1, resource argocds
233+
gvr := schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "argocds"}
234+
// If CRD exists and an ArgoCD instance exists in ns, assert spec.applicationNamespaces is empty
235+
if kc.DynamicClient != nil {
236+
list, err := kc.DynamicClient.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{})
237+
if err == nil && list != nil && len(list.Items) > 0 {
238+
for _, it := range list.Items {
239+
if arr, found, _ := unstructured.NestedSlice(it.Object, "spec", "applicationNamespaces"); found && len(arr) > 0 {
240+
return fmt.Errorf("argo CD configured for namespaced mode via spec.applicationNamespaces")
241+
}
242+
}
243+
return nil
244+
}
245+
}
246+
// Fallback when Operator CR is absent: check the core Argo CD ConfigMap (argocd-cm)
247+
// and fail if application.namespaces is set (namespaced mode). If the ConfigMap
248+
// does not exist or the key is empty, assume cluster-scoped and pass.
249+
cm, err := kc.Clientset.CoreV1().ConfigMaps(ns).Get(ctx, "argocd-cm", metav1.GetOptions{})
250+
if err == nil {
251+
if v := strings.TrimSpace(cm.Data["application.namespaces"]); v != "" {
252+
return fmt.Errorf("argocd-cm sets application.namespaces (%s)", v)
253+
}
254+
return nil
255+
}
256+
// If neither CR nor ConfigMap found, do not fail the check
257+
return nil
258+
}
259+
260+
// verifyRouteHostMatchesCert checks OpenShift Route host in ns is present in IPS?DNS of the given TLS secret.
261+
func verifyRouteHostMatchesCert(ctx context.Context, kc *kube.KubernetesClient, ns string, tlsSecretName string) error {
262+
// Discover route API by attempting a list via dynamic client
263+
if kc.DynamicClient == nil {
264+
return nil // skip
265+
}
266+
// Load cert IPS/DNS
267+
parsed, err := x509FromTLSSecret(ctx, kc.Clientset, ns, tlsSecretName)
268+
if err != nil {
269+
return err
270+
}
271+
272+
// Try routes.route.openshift.io
273+
gvr := schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"}
274+
routes, err := kc.DynamicClient.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{})
275+
if err != nil || routes == nil {
276+
return nil // skip if Route API not present or inaccessible
277+
}
278+
279+
if len(routes.Items) == 0 {
280+
return nil // skip if no routes exist
281+
}
282+
283+
// Pass if any route host matches SANs
284+
for _, r := range routes.Items {
285+
host, _, _ := unstructured.NestedString(r.Object, "spec", "host")
286+
if host == "" {
287+
continue
288+
}
289+
if err := parsed.VerifyHostname(host); err == nil {
290+
return nil
291+
}
292+
}
293+
return fmt.Errorf("no OpenShift Route host in namespace matches TLS IPS/DNS")
294+
}
295+
296+
func agentCASecretValid(ctx context.Context, kubeClient kubernetes.Interface, ns, name string) error {
297+
sec, err := kubeClient.CoreV1().Secrets(ns).Get(ctx, name, metav1.GetOptions{})
298+
if err != nil {
299+
return err
300+
}
301+
if len(sec.Data) == 0 {
302+
return fmt.Errorf("%s/%s: empty secret", ns, name)
303+
}
304+
// Expect a "ca.crt" field with PEM data
305+
if _, ok := sec.Data["ca.crt"]; !ok {
306+
return fmt.Errorf("%s/%s: missing ca.crt field", ns, name)
307+
}
308+
// Validate PEM parses into certs
309+
_, err = tlsutil.X509CertPoolFromSecret(ctx, kubeClient, ns, name, "ca.crt")
310+
return err
311+
}
312+
313+
func clientCertNotExpired(ctx context.Context, kubeClient kubernetes.Interface, ns, name string) error {
314+
parsed, err := x509FromTLSSecret(ctx, kubeClient, ns, name)
315+
if err != nil {
316+
return err
317+
}
318+
if time.Now().After(parsed.NotAfter) {
319+
return fmt.Errorf("agent certificate expired at %s", parsed.NotAfter)
320+
}
321+
return nil
322+
}
323+
324+
func clientCertSignedByPrincipalCA(ctx context.Context, agentKube kubernetes.Interface, agentNS string, principalKube kubernetes.Interface, principalNS string) error {
325+
agentX509, err := x509FromTLSSecret(ctx, agentKube, agentNS, config.SecretNameAgentClientCert)
326+
if err != nil {
327+
return err
328+
}
329+
caX509, err := x509FromTLSSecret(ctx, principalKube, principalNS, config.SecretNamePrincipalCA)
330+
if err != nil {
331+
return err
332+
}
333+
if err := agentX509.CheckSignatureFrom(caX509); err != nil {
334+
return fmt.Errorf("agent certificate not signed by principal CA: %w", err)
335+
}
336+
return nil
337+
}
338+
339+
func namespaceMatchesAgentSubject(ctx context.Context, agentKube kubernetes.Interface, agentNS string, principalKube kubernetes.Interface, principalNS string) error {
340+
agentX509, err := x509FromTLSSecret(ctx, agentKube, agentNS, config.SecretNameAgentClientCert)
341+
if err != nil {
342+
return err
343+
}
344+
subj := strings.TrimSpace(agentX509.Subject.CommonName)
345+
if subj == "" {
346+
return fmt.Errorf("agent certificate subject (CN) is empty")
347+
}
348+
// Validate namespace exists on principal cluster
349+
_, err = principalKube.CoreV1().Namespaces().Get(ctx, subj, metav1.GetOptions{})
350+
if err != nil {
351+
if k8serrors.IsNotFound(err) {
352+
return fmt.Errorf("namespace '%s' not found on principal cluster", subj)
353+
}
354+
return err
355+
}
356+
// optional: ensure agent's own namespace exists (sanity)
357+
_, _ = agentKube.CoreV1().Namespaces().Get(ctx, agentNS, metav1.GetOptions{})
358+
return nil
359+
}
360+
361+
// x509FromTLSSecret retrieves a Kubernetes TLS secret and parses the first certificate
362+
// in the chain into an *x509.Certificate.
363+
func x509FromTLSSecret(ctx context.Context, kubeClient kubernetes.Interface, ns, name string) (*x509.Certificate, error) {
364+
cert, err := tlsutil.TLSCertFromSecret(ctx, kubeClient, ns, name)
365+
if err != nil {
366+
return nil, err
367+
}
368+
if len(cert.Certificate) == 0 || cert.Certificate[0] == nil {
369+
return nil, fmt.Errorf("%s/%s: secret does not contain certificate data", ns, name)
370+
}
371+
parsed, err := x509.ParseCertificate(cert.Certificate[0])
372+
if err != nil {
373+
return nil, fmt.Errorf("could not parse certificate in secret %s/%s: %w", ns, name, err)
374+
}
375+
return parsed, nil
376+
}

0 commit comments

Comments
 (0)