Securing LDAP with TLS certificates using ClusterIssuer in TKG v1.4

Over the last month or so, I have looked at various ways of securing Tanzu Kubernetes Grid (TKG) clusters. One recent post covered the integration of LDAP through Dex and Pinniped so you can control who can access the the non-admin context of your TKG cluster. I’ve also looked at how TKG clusters that do not have direct access to the internet can use a HTTP/HTTPS proxy. Similarly,  I looked at some tips when deploying TKG in an air-gapped environment, pulling all the necessary images from our external image registry and pushing them to a local Harbor registry. In another post, I looked at how TLS certificates can be used implement secure ingress connectivity to an application running in a TKG cluster. And finally I examined network policies that are available via the Antrea CNI (Container Network Interface). These policies can be managed from within a TKG cluster or from Tanzu Mission Control (TMC).As you can see, there are lots of options to secure (or harden) a TKG cluster.

In this post, I will look at how to secure LDAP communication using TLS certificates. LDAP integration with a TKG cluster is provided via Dex and Pinniped packages. Note that TKG already uses a self-signed Issuer to generate TLS certificates to secure HTTPS traffic to Pinniped and Dex by default; Issuers and ClusterIssuers represent certificate authorities (CAs) that are able to generate signed certificates, and are available via the Cert Manager package. This default Issuer method can be modified. In the official documentation, two alternate methods to secure HTTPS traffic are offered. The first method suggests creating your own TLS secret. This is a little complex as it involves the manual creation of Certificate Signing Requests (CSRs), then the creation of certificates with X509 extensions, before eventually wrapping the certificates up in Kubernetes secrets. A more elegant approach is offered through the use of a ClusterIssuer which can be created with a specific CA.  Both methods require a Pinniped reconfiguration, as we shall see. This procedure assumes that you have already deployed a TKG v1.4 management cluster with LDAP already integrated. It also assumes availability of a Load Balancer.  This deployment is on vSphere, so the NSX Advanced Load Balancer provides this functionality. I will now step through the second method suggested by the official docs, and see how to create a ClusterIssuer with a custom CA to secure Pinniped and Dex HTTPS communication.

1. Use LoadBalancer Services for Pinniped and DEX

Before we begin integrating LDAP and secure communication with certificates, we need to convert the Pinniped and Dex services from NodePort to LoadBalancer. Thus, an NSX Advanced Load Balancer (or similar) is required when deploying TKG on vSphere. I have outlined these steps in an earlier post. Once the services are of type LoadBalancer, the pinniped post deploy job must be deleted.  Wait for the job to successfully restart before proceeding. This can take some time (3-4 minutes). I usually use a watch (-w) to monitor its restart.

$ kubectl get jobs -n pinniped-supervisor
NAME                       COMPLETIONS   DURATION   AGE
pinniped-post-deploy-job   1/1           3m53s      14h


$ kubectl delete jobs pinniped-post-deploy-job -n pinniped-supervisor
job.batch "pinniped-post-deploy-job" deleted


$ kubectl get jobs -n pinniped-supervisor -w
NAME                       COMPLETIONS   DURATION   AGE
pinniped-post-deploy-job   0/1                      0s
pinniped-post-deploy-job   0/1                      0s
pinniped-post-deploy-job   0/1           0s         0s
pinniped-post-deploy-job   1/1           11s        11s

2. Create a local Certificate Authority (CA)

If you already have a CA, then this step is not necessary. However, since many readers use home labs or test environments which may not have access to a Certificate Service to retrieve a CA, we can simply create our own local CA for the purposes of this exercise.

As mentioned, Issuers and ClusterIssuers are K8s resources that represent certificate authorities (CAs), and are available through Cert Manager (which is already installed on the TKG management cluster). Issuers and ClusterIssuers generate signed certificates when they receive certificate signing requests (CRs). All certificates created in Cert Manager require an issuer. This is what we are creating here, and the resulting ClusterIssuer, which will be referenced by Pinniped and Dex, handles the CSRs and Certificates.

I am creating my own local CA using the openssl command. First create a private key, and use the private key to create the CA. Note that the last command creates an unprotected version of the CA (no pass phrase) which is used to create a K8s secret in the next step.

$ openssl genrsa -des3 -out localRootCA.key 2048
Generating RSA private key, 2048 bit long modulus
................................................................+++
.........................................................................................................+++
e is 65537 (0x10001)
Enter pass phrase for localRootCA.key: **********
Verifying - Enter pass phrase for localRootCA.key: **********


$ openssl req -x509 -new -nodes -key localRootCA.key -reqexts v3_req -extensions v3_ca -sha256 -days 1825 -out localRootCA.pem
Enter pass phrase for localRootCA.key: ***********
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:IE
State or Province Name (full name) [Some-State]:Cork
Locality Name (eg, city) []:Cork
Organization Name (eg, company) [Internet Widgits Pty Ltd]:VMware
Organizational Unit Name (eg, section) []:OCTO
Common Name (e.g. server FQDN or YOUR name) []:pks-cli.rainpole.com
Email Address []:chogan@rainpole.com


$ openssl rsa -in localRootCA.key -out unprotected-localRootCA.key
Enter pass phrase for localRootCA.key: ***********
writing RSA key

3. Create a K8s secret that contains the local CA

This secret contains the CA that does not have a pass phrase, created in the last command of the previous step. Note that Cert Manager is already deployed as part of the TKG management cluster. The secret is created in the Cert Manager namespace.

$ kubectl create secret tls my-ca-secret --key unprotected-localRootCA.key --cert localRootCA.pem \
-n cert-manager
secret/my-ca-secret created

4. Create the ClusterIssuer

We can now create the ClusterIssuer, using the secret containing the CA from the previous step.

$ cat ca-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: ca-issuer
  namespace: cert-manager
spec:
  ca:
    secretName: my-ca-secret


$ kubectl apply -f ca-issuer.yaml
clusterissuer.cert-manager.io/ca-issuer created


$ kubectl get ClusterIssuer
NAME        READY  AGE
ca-issuer  True    9s


$ kubectl describe ClusterIssuer
Name:        ca-issuer
Namespace:
Labels:      <none>
Annotations:  <none>
API Version:  cert-manager.io/v1
Kind:        ClusterIssuer
Metadata:
  Creation Timestamp:  2021-11-22T13:55:38Z
  Generation:          1
  Managed Fields:
    API Version:  cert-manager.io/v1
    Fields Type:  FieldsV1
    fieldsV1:
      f:status:
        .:
        f:conditions:
    Manager:      controller
    Operation:    Update
    Time:        2021-11-22T13:55:38Z
    API Version:  cert-manager.io/v1
    Fields Type:  FieldsV1
    fieldsV1:
      f:metadata:
        f:annotations:
          .:
          f:kubectl.kubernetes.io/last-applied-configuration:
      f:spec:
        .:
        f:ca:
          .:
          f:secretName:
    Manager:        kubectl-client-side-apply
    Operation:      Update
    Time:            2021-11-22T13:55:38Z
  Resource Version:  10192
  UID:              a1134f01-769d-4648-a869-80ddca520ef9
Spec:
  Ca:
    Secret Name:  my-ca-secret
Status:
  Conditions:
    Last Transition Time:  2021-11-22T13:55:38Z
    Message:               Signing CA verified
    Reason:                KeyPairVerified
    Status:                True
    Type:                  Ready
Events:
  Type    Reason          Age                             From          Message
  ----    ------          ----                            ----          -------
  Normal  KeyPairVerified  <invalid> (x2 over <invalid>)  cert-manager  Signing CA verified

5. Modify Pinniped secret to use ClusterIssuer

We now need to retrieve the Pinniped add-on secret, retrieve the encoded values.yaml string, convert it from base64 to a human readable format, add the option to use a ClusterIssuer. The field custom_cluster_issuer in the values.yaml must be set to our ClusterIssuer. Then we need to re-encode the values.yaml string, add it to the Pinniped add-on secret and apply the new secret.

5.1 Retrieve the Pinniped add-on secret

Note that the name of the secret will be different to the command shown below as it contains the name of your management cluster. I am redirecting the secret (in yaml format) to a file so that I can read and modify it. To make it easier to read, many of the encoded strings below have been truncated, as has the contents of the file. The values.yaml string is highlighted in blue below. This is what needs updating.

$ kubectl get secret ldap-cert-pinniped-addon -n tkg-system -o yaml > ldap-cert-pinniped-addon-secret.yaml

$ more ldap-cert-pinniped-addon-secret.yaml
apiVersion: v1
data:
  overlays.yaml: I0Agb...wcwo=
  values.yaml: I0BkYX...xlLmNvbQo=
kind: Secret
metadata:
  annotations:
    kubectl.kubernetes.io/last-applied-configuration: |
...

5.2 Decode the values.yaml base64 string

Decode the values.yaml string since it is encoded in base64. I normally copy and paste it into a file to make it easier to work with. You can examine the configuration by using the base64 -d command on Linux. Currently, there should be no “custom_cluster_issuer” value.

$ vi pinniped-config-values.base64       # paste the contents of the values.yaml string in here


$ cat pinniped-config-values.base64 | base64 -d
#@data/values
#@overlay/match-child-defaults missing_ok=True
---
infrastructure_provider: vsphere
tkg_cluster_role: management
custom_cluster_issuer: ""
custom_tls_secret: ""
http_proxy: ""
https_proxy: ""
no_proxy: ""
identity_management_type: ldap
...

5.3 Save the decoded values.yaml to a manifest for editing

Store the decoded contents into another file so that it can be edited.

$ cat pinniped-config-values.base64 | base64 -d > pinniped-config-values.yaml

5.4 Add the custom_cluster_issuer to the Pinniped configuration

The field custom_cluster_issuer in the values.yaml must be set to the name of ClusterIssuer created in an earlier step. In this procedure, I called it ca-issuer.

#@data/values
#@overlay/match-child-defaults missing_ok=True
---
infrastructure_provider: vsphere
tkg_cluster_role: management
custom_cluster_issuer: "ca-issuer"
custom_tls_secret: ""
http_proxy: ""
https_proxy: ""
no_proxy: ""
identity_management_type: ldap
...

5.5 Re-encode the updated Pinniped configuration

We now need to convert the values.yaml configuration back into a base64 encoded string. The following command will store it in a file but you could just display it directly in the shell.

$ cat pinniped-config-values.yaml | base64 -w 0 > encoded.base64


$ cat encoded.base64
I0BkYXRhL3ZhbHVlcwoj...NvbQo=

5.6 Add the encoded configuration back to the Pinniped add-on secret

Copy the contents of the base64 encoded. values.yaml string. The addon-secret manifest that we captured a number of steps ago now needs to be updated with a new values.yaml string that has the new configuration (it now contains the custom_tls_secret setting). Replace the current values.yaml string with the new string from the previous step.

$ vi ldap-cert-pinniped-addon-secret.yaml     

apiVersion: v1
data:
  overlays.yaml: I0Agb...wcwo=
  values.yaml: I0BkYX...xlLmNvbQo=          # paste the new encoded values.yaml string in here
kind: Secret
metadata:
  annotations:
    kubectl.kubernetes.io/last-applied-configuration: |
...

5.7 Apply the updated Pinniped configuration / add-on secret

Change the add-on secret in the cluster. Simply apply our updated add-on secret with its new values.yaml.

$ kubectl apply -f ldap-cert-pinniped-addon-secret.yaml
secret/ldap-cert-pinniped-addon configured

5.8 Monitor Pinniped Reconcile

Ensure that there are no issues with the new configuration by monitoring the Pinniped application. It needs to go through a reconcile to implement the configuration change. Make sure the reconcile succeeds. You should see it go through a number of Reconciling states before eventually reconciling.

$ kubectl get app pinniped -n tkg-system -w
NAME       DESCRIPTION           SINCE-DEPLOY   AGE
pinniped   Reconcile succeeded   3m20s          15h
pinniped   Reconciling           16s            15h
pinniped   Reconciling           18s            15h
pinniped   Reconciling           20s            15h
pinniped   Reconciling           22s            15h
pinniped   Reconciling           24s            15h
pinniped   Reconciling           26s            15h
pinniped   Reconciling           28s            15h
pinniped   Reconciling           30s            15h
pinniped   Reconciling           32s            15h
pinniped   Reconciling           34s            15h
pinniped   Reconciling           36s            15h
pinniped   Reconciling           38s            15h
pinniped   Reconciling           40s            15h
pinniped   Reconciling           42s            15h
pinniped   Reconciling           44s            15h
pinniped   Reconciling           44s            15h
pinniped   Reconcile succeeded   44s            15h

6. Recreate non-admin context / access cluster as LDAP user

Pinniped and Dex are now configured to use the ClusterIssuer for Certificate Signing Requests (CSRs), and they should be sent signed Certificates from Cert Manager using the CA that was provided to the ClusterIssuer. To check that it is working, we should create (or recreate) the non-admin TKG management context.

If you are on an SSH session or non-graphical node, you will probably experience something similar to the what is shown below, where there is “no DISPLAY environment variable specified“. This is because the browser required for Dex authentication cannot be launched in this environment. This is ok, and is expected. I covered this in another post, but will add the steps here once more. We simply remove the non-admin context, and set the TANZU_CLI_PINNIPED_AUTH_LOGIN_SKIP_BROWSER environment variable, and then recreate the non-admin context. This allows us to do a Dex authentication on a different host which has a browser other than the host we are managing the TKG cluster cluster from.

$ kubectl get nodes
Error: no DISPLAY environment variable specified
^C


$ kubectl config delete-context tanzu-cli-ldap-issuer@ldap-issuer
warning: this removed your active context, use "kubectl config use-context" to select a different one
deleted context tanzu-cli-ldap-issuer@ldap-issuer from /home/cormac/.kube/config


$ export TANZU_CLI_PINNIPED_AUTH_LOGIN_SKIP_BROWSER=true


$ tanzu management-cluster kubeconfig get
You can now access the cluster by running 'kubectl config use-context tanzu-cli-ldap-issuer@ldap-issuer'

 
$ kubectl config use-context tanzu-cli-ldap-issuer@ldap-issuer
Switched to context "tanzu-cli-ldap-issuer@ldap-issuer".


$ kubectl get nodes
Please log in: https://xx.xx.xx.22/oauth2/authorize?access_type=offline&client_id=pinniped-cli\
&code_challenge=NrihaWN8AmYcBiGGLrzPlPLAbN-Rp5pBibztRtucivw&code_challenge_method=S256&nonce=\
347caf071934368b6f758dbb47f62fb5&redirect_uri=http%3A%2F%2F127.0.0.1%3A44531%2Fcallback&\
response_type=code&scope=offline_access+openid+pinniped%3Arequest-audience&state=\
8ccc783a86bfba1e1701022b4e50d3bb

Copy and paste the above “Please log in” link into a browser on a host which can reach the Pinniped and Dex services. This will present you with the standard Dex auth window where you can add the LDAP user that you wish to give access to the TKG cluster to. After adding the credentials, the browser will redirect to the IP address of the Dex service, but then it attempts to do a call back to the localhost. This will fail, and it will report an error about unable to establish a localhost connection. This is expected, as it is trying to reach the localhost where the kubectl command was run. The final step is to take the callback URL, and on the host where the kubectl command was run and the “Please log in” prompt was observed, open another shell session and run the equivalent curl command using the local host call back URL:

$ curl -L 'http://127.0.0.1:35259/callback?code=4bExu5_NESSuaY7kSCCIGcn8arVwJiVUC19UTZL-Jck.\
61FRn2BeHo7C8fYGbA0aranDsAFT3v0bRTTwq-TizA8&scope=openid+offline_access+pinniped%3Arequest-\
audience&state=edd3196e00a3eb01403d2d4e2d918fa3'

After running the command,  a “you have been logged in and may now close this tab” message should appear where the curl command was run. Unless you have already created a ClusterRoleBinding for the LDAP user you authenticated, the kubectl get nodes command run previously should error out with the following message:

Error from server (Forbidden): nodes is forbidden: User "chogan@rainpole.com" cannot list \
resource "nodes" in API group "" at the cluster scope

The user chogan@rainpole.com is the LDAP user that I authenticated in the Dex browser. Again, this error is expected as this LDAP user has not been given any privileges on the TKG management cluster.

7. Create a ClusterRoleBinding for the LDAP user

To create a ClusterRoleBinding for the LDAP user, switch back to the admin context, create the ClusterRoleBinding, switch again to the non-admin context and see if this LDAP user (who is already authenticated via Dex/Pinniped) can now successfully interact with the cluster.
$ kubectl config use-context ldap-cert-admin@ldap-cert
Switched to context "ldap-cert-admin@ldap-cert".


$ kubectl config get-contexts
CURRENT   NAME                            CLUSTER      AUTHINFO                                   NAMESPACE
*         ldap-cert-admin@ldap-cert       ldap-cert    ldap-cert-admin
          tanzu-cli-ldap-cert@ldap-cert   ldap-cert    tanzu-cli-ldap-cert


$ cat chogan-crb.yaml
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: chogan
subjects:
  - kind: User
    name: chogan@rainpole.com
    apiGroup:
roleRef:
  kind: ClusterRole
  name: cluster-admin
  apiGroup: rbac.authorization.k8s.io


$ kubectl apply -f chogan-crb.yaml
clusterrolebinding.rbac.authorization.k8s.io/chogan created


$ kubectl get clusterrolebinding chogan
NAME     ROLE                        AGE
chogan   ClusterRole/cluster-admin   6m


$ kubectl config use-context tanzu-cli-ldap-cert@ldap-cert
Switched to context "tanzu-cli-ldap-cert@ldap-cert".


$ kubectl get nodes
NAME                             STATUS   ROLES                  AGE   VERSION
ldap-cert-control-plane-77g8v    Ready    control-plane,master   86m   v1.21.2+vmware.1
ldap-cert-md-0-b7f799d64-kgcqm   Ready    <none>                 85m   v1.21.2+vmware.1

The authenticated LDAP user (chogan@rainpole.com) is now able to manage the cluster using the non-admin context. The CSRs and Certificates generated for Pinniped and Dex can be examined via the commands below. Note that there are 2 certificate requests related to CAs. One is the original Issuer (e.g. pinniped-selfsigned-ca-issuer) but the other should be the result of our customization; a ClusterIssuer (e.g. ca-issuer). You can describe the CA certificate requests to make certain. Similarly, if you examine the certificates using the describe option, you should see that the certificate is issued by the ClusterIssuer. This will verify that the ClusterIssuer is working as expected.

$ kubectl get certificaterequest -n pinniped-supervisor
NAME                  READY   AGE
pinniped-ca-4mdtl     True    53m
pinniped-ca-6nw4z     True    78m
pinniped-cert-67w7c   True    65m
pinniped-cert-c24l6   True    78m
pinniped-cert-rnckf   True    76m
pinniped-cert-zp9bj   True    53m


$ kubectl get certificates -n pinniped-supervisor
NAME            READY   SECRET                                        AGE
pinniped-ca     True    pinniped-ca-key-pair                          78m
pinniped-cert   True    pinniped-supervisor-default-tls-certificate   78m


$ kubectl get certificaterequest -n tanzu-system-auth
NAME             READY   AGE
dex-ca-ffv4q     True    79m
dex-ca-rm8mf     True    53m
dex-cert-j22n2   True    65m
dex-cert-kfvgh   True    76m
dex-cert-ktntv   True    53m
dex-cert-zlxlv   True    79m


$ kubectl get certificates -n tanzu-system-auth
NAME       READY   SECRET            AGE
dex-ca     True    dex-ca-key-pair   79m
dex-cert   True    dex-cert-tls      79m