Argo CD in k3d with end-to-end encryption and trusted HTTPS certs

Published on May 31, 2024

The non-trusted certificate warnings when I spun up Argo CD locally in a k3d cluster were bugging me. Let's fix them and get Argo CD spun up locally without these warnings, with trusted certificates and end-to-end encrypted flows.

TL;DR

Spin up k3d cluster:

k3d cluster create my-cluster -p "443:443@loadbalancer"

Install Argo CD with helm:

helm repo add argo https://argoproj.github.io/argo-helm
helm install argocd argo/argo-cd --namespace argocd --create-namespace

Apply traefik route:

kubectl apply -f - << EOF
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRouteTCP
metadata:
  name: argocd-route
  namespace: argocd
spec:
  entryPoints:
    - websecure
  routes:
    - match: HostSNI('argocd.local')
      services:
        - name: argocd-server
          port: 443
  tls:
    passthrough: true
EOF

Register argocd.local in /etc/hosts

echo "127.0.0.1 argocd.local" | sudo tee -a /etc/hosts

Create local CA

openssl genrsa -out ~/local/certs/local-ca.key 2048
openssl req -new -x509 -subj "/CN=Local CA" \
  -key ~/local/certs/local-ca.key \
  -out ~/local/certs/local-ca.crt

Trust local CA

sudo security add-trusted-cert -d -r trustRoot \
  -k "/Library/KeyChains/System.keychain" \
  ~/local/certs/local-ca.crt

Create certificate for Argo CD

openssl req -subj '/CN=argocd.local' \
  -new -newkey rsa:2048 -sha256 -noenc -x509 \
  -addext "subjectAltName = DNS:argocd.local" \
  -CA ~/local/certs/local-ca.crt \
  -CAkey ~/local/certs/local-ca.key \
  -keyout ~/local/certs/argocd-local.key \
  -out ~/local/certs/argocd-local.crt

Set as Argo CD certificate

kubectl create -n argocd secret tls argocd-server-tls \
  --cert=$HOME/local/certs/argocd-local.crt \
  --key=$HOME/local/certs/argocd-local.key

Enjoy https://argocd.local with no certificate warnings. If you want to go into a little more depth into any of those steps, read on.

Spin up Argo CD in a local k3d cluster

Create our k3d cluster with HTTPS port enabled

k3d cluster create my-cluster -p "443:443@loadbalancer"

And install Argo CD

helm repo add argo https://argoproj.github.io/argo-helm
helm install argocd argo/argo-cd --namespace argocd --create-namespace

Now we're ready to experiment with certificate and route configuration to get a trusted end-to-end encrypted flow to the Argo CD dashboard.

Install local Argo CD route

Create our traefik argocd-route.yaml to give us a route to connect to Argo CD. Note that this uses a IngressRouteTCP which allows us to configure TLS and in particular allow traefik, with passthrough: true, to indicate that TLS should not terminate at traefik and instead rely on termination on the backend service.

# argocd-route.yaml
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRouteTCP
metadata:
  name: argocd-route
  namespace: argocd
spec:
  entryPoints:
    - websecure
  routes:
    - match: HostSNI(`argocd.local`)
      services:
        - name: argocd-server
          port: 443
  tls:
    passthrough: true

Apply the resource.

kubectl apply -f argocd-route.yaml

We'll also register the local domain name for argocd.local in our /etc/hosts, so we can use that domain name to access Argo CD in the cluster. The virtual hosting configuration in the HostSNI in the IngressRouteTCP resource above, picks up on this host we have come in on and routes through to Argo CD.

127.0.0.1 argocd.local

When we access the Argo CD dashboard on https://argocd.local we get the expected certificate warnings that we are going to fix.

Argo CD certificate warnings

We do also see a warning when we login with the CLI

❯ argocd login argocd.local --grpc-web
WARNING: server certificate had error: tls: failed to verify
certificate: x509: certificate signed by unknown authority.
Proceed insecurely (y/n)?

And digging below the surface, we also see this warning signal with curl

curl -v https://argocd.local

* Host argocd.local:443 was resolved.
* IPv6: (none)
* IPv4: 127.0.0.1
*   Trying 127.0.0.1:443...
* Connected to argocd.local (127.0.0.1) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
*  CAfile: /etc/ssl/cert.pem
*  CApath: none
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* SSL certificate problem: unable to get local issuer certificate
* Closing connection
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

We can see that the certificate is the one that Argo CD has deployed by default and hence not surprising that we are getting those warnings.

echo | openssl s_client -showcerts -connect argocd.local:443 2>/dev/null

CONNECTED(00000003)
---
Certificate chain
 0 s:O=Argo CD
   i:O=Argo CD
   a:PKEY: rsaEncryption, 2048 (bit); sigalg: RSA-SHA256
   v:NotBefore: May 31 13:27:14 2024 GMT; NotAfter: May 31 13:27:14 2025 GMT
-----BEGIN CERTIFICATE-----

Argo CD documentation on TLS Configuration guides us on options, and we'll essentially update the cert and key as described in the section on "Inbound TLS options for argocd-server".

Create local certificate authority

We could create a self-signed certificate and set up trust for that self-signed certificate, but I anticipate creating a few certificates to help with other routes beyond Argo CD. So instead I'm going to create create a local certificate authority (CA) that I can set up trust for, and then any certificates I create with that CA will also be trusted.

Note also that public certificate authorities will not generate certificates for internal names, like .local addresses. If we used a CA like Let's Encrypt we'd need to change our local domain naming scheme. That's a pretty good option too, however it does create an external dependency which I don't need at this point.

I'll create these in a local ~/local/certs folder where I keep, and reuse, local test certificates.

mkdir -p ~/local/certs
openssl genrsa -out ~/local/certs/local-ca.key 2048
openssl req -new -x509 -subj "/CN=Local CA" \
  -key ~/local/certs/local-ca.key \
  -out ~/local/certs/local-ca.crt

On macOS, we can trust this local CA with the security command, but you can do this in other ways if you prefer.

sudo security add-trusted-cert -d -r trustRoot \
  -k "/Library/KeyChains/System.keychain" \
  ~/local/certs/local-ca.crt

Create certificate signed by our local CA

Now we are ready to create a self-signed CA. I'll do this with a one-liner, but if you are likely to renew the certificate at some point in the future, then the 3 step process may help - 1) create private key, 2) create certificate signing request (CSR), 3) sign certificate. Since we're just working on temporary stack, the one-liner is good enough.

openssl req -subj '/CN=argocd.local' \
  -new -newkey rsa:2048 -sha256 -noenc -x509 \
  -addext "subjectAltName = DNS:argocd.local" \
  -CA ~/local/certs/local-ca.crt \
  -CAkey ~/local/certs/local-ca.key \
  -keyout ~/local/certs/argocd-local.key \
  -out ~/local/certs/argocd-local.crt

Now, update the TLS secret argocd-server-tls that drives the Argo CD certification used for TLS termination. Argo CD reloads this once this secret changes.

kubectl create -n argocd secret tls argocd-server-tls \
  --cert=$HOME/local/certs/argocd-local.crt \
  --key=$HOME/local/certs/argocd-local.key

The certificate is now trusted when we access the dashboard and we not longer have the certificate warnings in the browser.

Argo CD certificate warnings.

We can confirm the appropriate certificate that we created for argocd.local is being used.

echo | openssl s_client -showcerts -connect argocd.local:443 2>/dev/null

CONNECTED(00000003)
---
Certificate chain
 0 s:CN=argocd.local
   i:CN=Local CA
   a:PKEY: rsaEncryption, 2048 (bit); sigalg: RSA-SHA256
   v:NotBefore: May 31 13:46:12 2024 GMT; NotAfter: Jun 30 13:46:12 2024 GMT

Why do we need to set a subjectAltName?

If we create certificate without the subjectAltName (SAN), e.g. with:

openssl req -subj '/CN=argocd.local'   \
  -new -newkey rsa:2048 -sha256 -noenc -x509 \
  -CA ~/local/certs/local-ca.crt        \
  -CAkey ~/local/certs/local-ca.key     \
  -out ~/local/certs/argocd-local.crt   \
  -keyout ~/local/certs/argocd-local.key

And the update the Argo CD TLS certificate.

kubectl create secret tls argocd-server-tls \
  --save-config --dry-run=client             \
  --key=$HOME/local/certs/argocd-local.key \
  --cert=$HOME/local/certs/argocd-local.crt \
  -o yaml | \
kubectl apply -n argocd -f -

Then the certificate warning will show again in the browser. Access to the endpoint with curl will not give a warning, although the argocd command line does give us a useful message indicating that the SAN is missing.

❯ argocd login argocd.local --grpc-web
WARNING: server certificate had error: tls: failed to verify certificate: x509: certificate relies on legacy Common Name field, use SANs instead. Proceed insecurely (y/n)?

A Stack Exchange post gives a good description on why SAN is needed on local domains nowadays.

To fix the certificate, regenerate the certificate with the SAN and update the argocd-server-tls secret and certificate. Access to the Argo CD dashboard should be all valid again.

No more certificate warnings

Although Argo CD was functional without fixing these certificate warnings, it is less of distraction when we fixed it right. An alternative approach would be to disable TLS termination on Argo CD by setting server.insecure to true.

helm install argocd argo/argo-cd --namespace argocd --create-namespace
  --set configs.params."server\.insecure"=true

We could then set up TLS termination in traefik, along with the appropriate certificate, with traefik TLS configuration. Having unencrypted traffic inside the cluster is OK, but feels less than good, especially since Argo CD has TLS termination on, by default.

We could also leave the flow unencrypted outside of the cluster, and come on HTTP over port 80. However, we would never want to leave the flow unencrypted in a non-local deployment, and doing so locally just doesn't feel right.

So all-in-all, unencrypted flows on purely local stacks are not a major concern, however local stacks are always a safe place to practice this and be ready for production like stacks.

Clean up the cluster

Delete the cluster once done.

k3d cluster delete my-cluster

If you are not using the CA certificate again, you may want to remove that trust, since it is no longer needed. On macOS it can be deleted with the Keychain Access tool.

Delete CA certificate trust.