Skip to content

Commit 67b4ce5

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 67b4ce5

File tree

3 files changed

+548
-0
lines changed

3 files changed

+548
-0
lines changed

cmd/ctl/check_config.go

Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
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("mTLS certificates:")
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+
// Ensure applications are allowed in any namespace
138+
out = append(out, checkResult{
139+
name: "Verifying Apps in any Namespace enabled",
140+
err: verifyAppsAnyNamespace(ctx, kubeClient, principalNS),
141+
})
142+
143+
// CA secret exists in principal namespace and is a valid TLS secret
144+
out = append(out, checkResult{
145+
name: fmt.Sprintf("Verifying principal public CA certificate exists and is valid (%s/%s)", principalNS, config.SecretNamePrincipalCA),
146+
err: principalCheckCA(ctx, kubeClient.Clientset, principalNS),
147+
})
148+
149+
// Principal gRPC TLS secret exists in principal namespace and is valid
150+
out = append(out, checkResult{
151+
name: fmt.Sprintf("Verifying principal gRPC TLS certificate exists and is valid (%s/%s)", principalNS, config.SecretNamePrincipalTLS),
152+
err: certSecretValid(ctx, kubeClient.Clientset, principalNS, config.SecretNamePrincipalTLS),
153+
})
154+
155+
// Resource proxy TLS secret exists and is valid
156+
out = append(out, checkResult{
157+
name: fmt.Sprintf("Verifying resource proxy TLS certificate exists and is valid (%s/%s)", principalNS, config.SecretNameProxyTLS),
158+
err: certSecretValid(ctx, kubeClient.Clientset, principalNS, config.SecretNameProxyTLS),
159+
})
160+
161+
// JWT signing key exists
162+
out = append(out, checkResult{
163+
name: fmt.Sprintf("Verifying JWT signing key exists and is parseable (%s/%s)", principalNS, config.SecretNameJWT),
164+
err: jwtKeyValid(ctx, kubeClient.Clientset, principalNS, config.SecretNameJWT),
165+
})
166+
167+
// Route host matches TLS SANs (OpenShift-only)
168+
out = append(out, checkResult{
169+
name: "Verifying principal TLS secret ips/dns match Route host (OpenShift)",
170+
err: verifyRouteHostMatchesCert(ctx, kubeClient, principalNS, config.SecretNamePrincipalTLS),
171+
})
172+
173+
return out
174+
}
175+
176+
// RunAgentChecks validates agent-side security assets and cross-validates them
177+
// against the principal cluster.
178+
func RunAgentChecks(ctx context.Context, agentKube kubernetes.Interface, agentNS string, principalKube kubernetes.Interface, principalNS string) []checkResult {
179+
out := []checkResult{}
180+
181+
// Agent CA secret exists and has CA data (opaque secret with ca.crt)
182+
out = append(out, checkResult{
183+
name: fmt.Sprintf("Verifying agent CA secret exists and contains CA cert (%s/%s)", agentNS, config.SecretNameAgentCA),
184+
err: agentCASecretValid(ctx, agentKube, agentNS, config.SecretNameAgentCA),
185+
})
186+
187+
// Agent client TLS secret exists and is valid and not expired
188+
out = append(out, checkResult{
189+
name: fmt.Sprintf("Verifying agent mTLS certificate exists and is not expired (%s/%s)", agentNS, config.SecretNameAgentClientCert),
190+
err: clientCertNotExpired(ctx, agentKube, agentNS, config.SecretNameAgentClientCert),
191+
})
192+
193+
// Namespace with same name as agent cert subject exists on principal cluster
194+
out = append(out, checkResult{
195+
name: "Verifying namespace on principal matches agent certificate subject",
196+
err: namespaceMatchesAgentSubject(ctx, agentKube, agentNS, principalKube, principalNS),
197+
})
198+
199+
// Agent client TLS is signed by principal CA
200+
out = append(out, checkResult{
201+
name: "Verifying agent mTLS certificate is signed by principal CA certificate",
202+
err: clientCertSignedByPrincipalCA(ctx, agentKube, agentNS, principalKube, principalNS),
203+
})
204+
205+
return out
206+
}
207+
208+
func principalCheckCA(ctx context.Context, kubeClient kubernetes.Interface, ns string) error {
209+
_, err := tlsutil.TLSCertFromSecret(ctx, kubeClient, ns, config.SecretNamePrincipalCA)
210+
return err
211+
}
212+
213+
func certSecretValid(ctx context.Context, kubeClient kubernetes.Interface, ns, name string) error {
214+
parsed, err := x509FromTLSSecret(ctx, kubeClient, ns, name)
215+
if err != nil {
216+
return err
217+
}
218+
if time.Now().After(parsed.NotAfter) {
219+
return fmt.Errorf("certificate in secret %s/%s is expired", ns, name)
220+
}
221+
return nil
222+
}
223+
224+
func jwtKeyValid(ctx context.Context, kubeClient kubernetes.Interface, ns, name string) error {
225+
key, err := tlsutil.JWTSigningKeyFromSecret(ctx, kubeClient, ns, name)
226+
if err != nil {
227+
return err
228+
}
229+
// Require RSA for now
230+
if _, ok := key.(*rsa.PrivateKey); !ok {
231+
return fmt.Errorf("JWT signing key is not an RSA private key")
232+
}
233+
return nil
234+
}
235+
236+
// verifyArgoCDClusterScoped checks operator CR or argocd-cm to ensure no namespaced mode is configured.
237+
func verifyArgoCDClusterScoped(ctx context.Context, kc *kube.KubernetesClient, ns string) error {
238+
// Try operator CR first: group argoproj.io, version v1alpha1, resource argocds
239+
gvr := schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "argocds"}
240+
// If CRD exists and an ArgoCD instance exists in ns, assert spec.applicationNamespaces is empty
241+
if kc.DynamicClient != nil {
242+
list, err := kc.DynamicClient.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{})
243+
if err == nil && list != nil && len(list.Items) > 0 {
244+
for _, it := range list.Items {
245+
if arr, found, _ := unstructured.NestedSlice(it.Object, "spec", "applicationNamespaces"); found && len(arr) > 0 {
246+
return fmt.Errorf("argo CD configured for namespaced mode via spec.applicationNamespaces")
247+
}
248+
}
249+
return nil
250+
}
251+
}
252+
// Fallback when Operator CR is absent: check the core Argo CD ConfigMap (argocd-cm)
253+
// and fail if application.namespaces is set (namespaced mode). If the ConfigMap
254+
// does not exist or the key is empty, assume cluster-scoped and pass.
255+
cm, err := kc.Clientset.CoreV1().ConfigMaps(ns).Get(ctx, "argocd-cm", metav1.GetOptions{})
256+
if err == nil {
257+
if v := strings.TrimSpace(cm.Data["application.namespaces"]); v != "" {
258+
return fmt.Errorf("argocd-cm sets application.namespaces (%s)", v)
259+
}
260+
return nil
261+
}
262+
// If neither CR nor ConfigMap found, do not fail the check
263+
return nil
264+
}
265+
266+
// verifyAppsAnyNamespace mirrors cluster-scoped requirement.
267+
func verifyAppsAnyNamespace(ctx context.Context, kc *kube.KubernetesClient, ns string) error {
268+
// Same evaluation as cluster-scoped check.
269+
return verifyArgoCDClusterScoped(ctx, kc, ns)
270+
}
271+
272+
// verifyRouteHostMatchesCert checks OpenShift Route host in ns is present in IPS?DNS of the given TLS secret.
273+
func verifyRouteHostMatchesCert(ctx context.Context, kc *kube.KubernetesClient, ns string, tlsSecretName string) error {
274+
// Discover route API by attempting a list via dynamic client
275+
if kc.DynamicClient == nil {
276+
return nil // skip
277+
}
278+
// Load cert IPS/DNS
279+
parsed, err := x509FromTLSSecret(ctx, kc.Clientset, ns, tlsSecretName)
280+
if err != nil {
281+
return err
282+
}
283+
dns := map[string]struct{}{}
284+
for _, d := range parsed.DNSNames {
285+
dns[strings.ToLower(d)] = struct{}{}
286+
}
287+
ips := map[string]struct{}{}
288+
for _, ip := range parsed.IPAddresses {
289+
ips[strings.ToLower(ip.String())] = struct{}{}
290+
}
291+
292+
// Try routes.route.openshift.io
293+
gvr := schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"}
294+
routes, err := kc.DynamicClient.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{})
295+
if err != nil || routes == nil {
296+
return nil // skip if Route API not present or inaccessible
297+
}
298+
// Pass if any route host matches SANs
299+
for _, r := range routes.Items {
300+
host, _, _ := unstructured.NestedString(r.Object, "spec", "host")
301+
if host == "" {
302+
continue
303+
}
304+
h := strings.ToLower(host)
305+
if _, ok := dns[h]; ok {
306+
return nil
307+
}
308+
if _, ok := ips[h]; ok {
309+
return nil
310+
}
311+
}
312+
return fmt.Errorf("no OpenShift Route host in namespace matches TLS SANs")
313+
}
314+
315+
func agentCASecretValid(ctx context.Context, kubeClient kubernetes.Interface, ns, name string) error {
316+
sec, err := kubeClient.CoreV1().Secrets(ns).Get(ctx, name, metav1.GetOptions{})
317+
if err != nil {
318+
return err
319+
}
320+
if len(sec.Data) == 0 {
321+
return fmt.Errorf("%s/%s: empty secret", ns, name)
322+
}
323+
// Expect a "ca.crt" field with PEM data
324+
if _, ok := sec.Data["ca.crt"]; !ok {
325+
return fmt.Errorf("%s/%s: missing ca.crt field", ns, name)
326+
}
327+
// Validate PEM parses into certs
328+
_, err = tlsutil.X509CertPoolFromSecret(ctx, kubeClient, ns, name, "ca.crt")
329+
return err
330+
}
331+
332+
func clientCertNotExpired(ctx context.Context, kubeClient kubernetes.Interface, ns, name string) error {
333+
parsed, err := x509FromTLSSecret(ctx, kubeClient, ns, name)
334+
if err != nil {
335+
return err
336+
}
337+
if time.Now().After(parsed.NotAfter) {
338+
return fmt.Errorf("agent certificate expired at %s", parsed.NotAfter)
339+
}
340+
return nil
341+
}
342+
343+
func clientCertSignedByPrincipalCA(ctx context.Context, agentKube kubernetes.Interface, agentNS string, principalKube kubernetes.Interface, principalNS string) error {
344+
agentX509, err := x509FromTLSSecret(ctx, agentKube, agentNS, config.SecretNameAgentClientCert)
345+
if err != nil {
346+
return err
347+
}
348+
caX509, err := x509FromTLSSecret(ctx, principalKube, principalNS, config.SecretNamePrincipalCA)
349+
if err != nil {
350+
return err
351+
}
352+
if err := agentX509.CheckSignatureFrom(caX509); err != nil {
353+
return fmt.Errorf("agent certificate not signed by principal CA: %w", err)
354+
}
355+
return nil
356+
}
357+
358+
func namespaceMatchesAgentSubject(ctx context.Context, agentKube kubernetes.Interface, agentNS string, principalKube kubernetes.Interface, principalNS string) error {
359+
agentX509, err := x509FromTLSSecret(ctx, agentKube, agentNS, config.SecretNameAgentClientCert)
360+
if err != nil {
361+
return err
362+
}
363+
subj := strings.TrimSpace(agentX509.Subject.CommonName)
364+
if subj == "" {
365+
return fmt.Errorf("agent certificate subject (CN) is empty")
366+
}
367+
// Validate namespace exists on principal cluster
368+
_, err = principalKube.CoreV1().Namespaces().Get(ctx, subj, metav1.GetOptions{})
369+
if err != nil {
370+
if k8serrors.IsNotFound(err) {
371+
return fmt.Errorf("namespace '%s' not found on principal cluster", subj)
372+
}
373+
return err
374+
}
375+
// optional: ensure agent's own namespace exists (sanity)
376+
_, _ = agentKube.CoreV1().Namespaces().Get(ctx, agentNS, metav1.GetOptions{})
377+
return nil
378+
}
379+
380+
// x509FromTLSSecret retrieves a Kubernetes TLS secret and parses the first certificate
381+
// in the chain into an *x509.Certificate.
382+
func x509FromTLSSecret(ctx context.Context, kubeClient kubernetes.Interface, ns, name string) (*x509.Certificate, error) {
383+
cert, err := tlsutil.TLSCertFromSecret(ctx, kubeClient, ns, name)
384+
if err != nil {
385+
return nil, err
386+
}
387+
if len(cert.Certificate) == 0 || cert.Certificate[0] == nil {
388+
return nil, fmt.Errorf("%s/%s: secret does not contain certificate data", ns, name)
389+
}
390+
parsed, err := x509.ParseCertificate(cert.Certificate[0])
391+
if err != nil {
392+
return nil, fmt.Errorf("could not parse certificate in secret %s/%s: %w", ns, name, err)
393+
}
394+
return parsed, nil
395+
}

0 commit comments

Comments
 (0)