From 426340e31c99e3d9262e817ee717767e73409741 Mon Sep 17 00:00:00 2001
From: guhaneswaran20 <guhan.e20@gmail.com>
Date: Fri, 30 Oct 2020 10:08:42 +0000
Subject: [PATCH] check node certificate expiration

Reference:- https://hackmd.io/aGaz7YXSSHybGcyol8vYEw

Relates-To: #391

Change-Id: I8c9c83dfb2eb11af48857fb96404dcf2eb3eaa55
---
 .../checkexpiration/checkexpiration.go        | 92 ++++++++++++++++++-
 pkg/cluster/checkexpiration/command.go        | 14 +++
 pkg/cluster/checkexpiration/command_test.go   | 55 ++++++++++-
 .../checkexpiration/testdata/node.yaml        |  6 ++
 4 files changed, 162 insertions(+), 5 deletions(-)
 create mode 100644 pkg/cluster/checkexpiration/testdata/node.yaml

diff --git a/pkg/cluster/checkexpiration/checkexpiration.go b/pkg/cluster/checkexpiration/checkexpiration.go
index aa65e3ed9..58cc2887d 100644
--- a/pkg/cluster/checkexpiration/checkexpiration.go
+++ b/pkg/cluster/checkexpiration/checkexpiration.go
@@ -32,7 +32,9 @@ import (
 )
 
 const (
-	kubeconfigIdentifierSuffix = "-kubeconfig"
+	kubeconfigIdentifierSuffix   = "-kubeconfig"
+	timeFormat                   = "Jan 02, 2006 15:04 MST"
+	nodeCertExpirationAnnotation = "cert-expiration"
 )
 
 // CertificateExpirationStore is the customized client store
@@ -258,3 +260,91 @@ func (store CertificateExpirationStore) getKubeconfSecrets() ([]corev1.Secret, e
 	kubeconfigs = filterOwners(kubeconfigs, "KubeadmControlPlane")
 	return kubeconfigs, nil
 }
+
+// GetExpiringNodeCertificates runs through all the nodes and identifies expiration
+func (store CertificateExpirationStore) GetExpiringNodeCertificates() ([]NodeCert, error) {
+	// Node will be updated with an annotation with the expiry content (Activity
+	// of HostConfig Operator - 'check-expiry' CR Object) every day (Cron like
+	// activity is performed by reconcile tag in the Operator) Below code is
+	// implemented to just read the annotation, parse it, identify expirable
+	// content and report back
+
+	// Expected Annotation Format:
+	// "cert-expiration": "{ admin.conf: Aug 06, 2021 12:36 UTC },
+	// 					   { apiserver: Aug 06, 2021 12:36 UTC },
+	//                     { apiserver-etcd-client: Aug 06, 2021 12:36 UTC },
+	//                     { apiserver-kubelet-client: Aug 06, 2021 12:36 UTC },
+	//                     { controller-manager.conf: Aug 06, 2021 12:36 UTC },
+	//                     { etcd-healthcheck-client: Aug 06, 2021 12:36 UTC },
+	//                     { etcd-peer: Aug 06, 2021 12:36 UTC },
+	//                     { etcd-server: Aug 06, 2021 12:36 UTC },
+	//                     { front-proxy-client: Aug 06, 2021 12:36 UTC },
+	//                     { scheduler.conf: Aug 06, 2021 12:36 UTC },
+	//                     { ca: Aug 04, 2030 12:36 UTC },
+	//                     { etcd-ca: Aug 04, 2030 12:36 UTC },
+	//                     { front-proxy-ca: Aug 04, 2030 12:36 UTC }"
+
+	nodes, err := store.getNodes(metav1.ListOptions{})
+	if err != nil {
+		return nil, err
+	}
+
+	nodeData := make([]NodeCert, 0)
+	for _, node := range nodes.Items {
+		expiringNodeCertificates := store.getExpiringNodeCertificates(node)
+		if len(expiringNodeCertificates) > 0 {
+			nodeData = append(nodeData, NodeCert{
+				Name:                 node.Name,
+				Namespace:            node.Namespace,
+				ExpiringCertificates: expiringNodeCertificates,
+			})
+		}
+	}
+	return nodeData, nil
+}
+
+// getSecrets returns the Nodes list based on the listOptions
+func (store CertificateExpirationStore) getNodes(listOptions metav1.ListOptions) (*corev1.NodeList, error) {
+	return store.Kclient.ClientSet().CoreV1().Nodes().List(listOptions)
+}
+
+// getExpiringNodeCertificates skims through all the node certificates and returns
+// the ones lesser than threshold
+func (store CertificateExpirationStore) getExpiringNodeCertificates(node corev1.Node) map[string]string {
+	if cert, found := node.ObjectMeta.Annotations[nodeCertExpirationAnnotation]; found {
+		certificateList := splitAsList(cert)
+
+		expiringCertificates := map[string]string{}
+		for _, certificate := range certificateList {
+			certificateName, expirationDate := identifyCertificateNameAndExpirationDate(certificate)
+			if certificateName != "" && isWithinDuration(expirationDate, store.ExpirationThreshold) {
+				expiringCertificates[certificateName] = expirationDate.String()
+			}
+		}
+		return expiringCertificates
+	}
+	log.Printf("%s annotation missing for node %s in %s", nodeCertExpirationAnnotation,
+		node.Name, node.Namespace)
+	return nil
+}
+
+// splitAsList performes the required string manipulations and returns list of items
+func splitAsList(value string) []string {
+	return strings.Split(strings.ReplaceAll(value, "{", ""), "},")
+}
+
+// identifyCertificateNameAndExpirationDate performs string manipulations and returns
+// certificate name and its expiration date
+func identifyCertificateNameAndExpirationDate(certificate string) (string, time.Time) {
+	certificateName := strings.TrimSpace(strings.Split(certificate, ":")[0])
+	expirationDate := strings.TrimSpace(strings.Split(certificate, ":")[1]) +
+		":" +
+		strings.TrimSpace(strings.ReplaceAll(strings.Split(certificate, ":")[2], "}", ""))
+
+	formattedExpirationDate, err := time.Parse(timeFormat, expirationDate)
+	if err != nil {
+		log.Printf(err.Error())
+		return "", time.Time{}
+	}
+	return certificateName, formattedExpirationDate
+}
diff --git a/pkg/cluster/checkexpiration/command.go b/pkg/cluster/checkexpiration/command.go
index 524c6e258..0765b237a 100644
--- a/pkg/cluster/checkexpiration/command.go
+++ b/pkg/cluster/checkexpiration/command.go
@@ -44,6 +44,7 @@ type CheckCommand struct {
 type ExpirationStore struct {
 	TLSSecrets []TLSSecret  `json:"tlsSecrets,omitempty" yaml:"tlsSecrets,omitempty"`
 	Kubeconfs  []Kubeconfig `json:"kubeconfs,omitempty" yaml:"kubeconfs,omitempty"`
+	NodeCerts  []NodeCert   `json:"nodeCerts,omitempty" yaml:"nodeCerts,omitempty"`
 }
 
 // TLSSecret captures expiration information of certificates embedded in TLS secrets
@@ -68,6 +69,13 @@ type kubeconfData struct {
 	ExpirationDate  string `json:"expirationDate,omitempty" yaml:"expirationDate,omitempty"`
 }
 
+// NodeCert captures certificate expiry information for certificates on each node
+type NodeCert struct {
+	Name                 string            `json:"name,omitempty" yaml:"name,omitempty"`
+	Namespace            string            `json:"namespace,omitempty" yaml:"namespace,omitempty"`
+	ExpiringCertificates map[string]string `json:"certificate,omitempty" yaml:"certificate,omitempty"`
+}
+
 // RunE is the implementation of check command
 func (c *CheckCommand) RunE(w io.Writer) error {
 	if !strings.EqualFold(c.Options.FormatType, "json") && !strings.EqualFold(c.Options.FormatType, "yaml") {
@@ -112,8 +120,14 @@ func (store CertificateExpirationStore) GetExpiringCertificates() ExpirationStor
 		log.Printf(err.Error())
 	}
 
+	expiringNodeCertificates, err := store.GetExpiringNodeCertificates()
+	if err != nil {
+		log.Printf(err.Error())
+	}
+
 	return ExpirationStore{
 		TLSSecrets: expiringTLSCertificates,
 		Kubeconfs:  expiringKubeConfCertificates,
+		NodeCerts:  expiringNodeCertificates,
 	}
 }
diff --git a/pkg/cluster/checkexpiration/command_test.go b/pkg/cluster/checkexpiration/command_test.go
index 67aae85f6..63b1da05d 100644
--- a/pkg/cluster/checkexpiration/command_test.go
+++ b/pkg/cluster/checkexpiration/command_test.go
@@ -66,7 +66,27 @@ const (
 					}
 				]
 			}
-		]
+		],
+		"nodeCerts": [
+					{
+						"name": "test-node",
+						"certificate": {
+							"admin.conf": "2021-08-06 12:36:00 +0000 UTC",
+							"apiserver": "2021-08-06 12:36:00 +0000 UTC",
+							"apiserver-etcd-client": "2021-08-06 12:36:00 +0000 UTC",
+							"apiserver-kubelet-client": "2021-08-06 12:36:00 +0000 UTC",
+							"ca": "2021-08-04 12:36:00 +0000 UTC",
+							"controller-manager.conf": "2021-08-06 12:36:00 +0000 UTC",
+							"etcd-ca": "2021-08-04 12:36:00 +0000 UTC",
+							"etcd-healthcheck-client": "2021-08-06 12:36:00 +0000 UTC",
+							"etcd-peer": "2021-08-06 12:36:00 +0000 UTC",
+							"etcd-server": "2021-08-06 12:36:00 +0000 UTC",
+							"front-proxy-ca": "2021-08-04 12:36:00 +0000 UTC",
+							"front-proxy-client": "2021-08-06 12:36:00 +0000 UTC",
+							"scheduler.conf": "2021-08-06 12:36:00 +0000 UTC"
+					}
+				}
+			]
 	}`
 
 	expectedYAMLOutput = `
@@ -88,6 +108,22 @@ tlsSecrets:
     tls.crt: 2030-08-31 10:12:49 +0000 UTC
   name: test-cluster-etcd
   namespace: default
+nodeCerts:
+- name: test-node
+  certificate:
+    admin.conf: 2021-08-06 12:36:00 +0000 UTC
+    apiserver: 2021-08-06 12:36:00 +0000 UTC
+    apiserver-etcd-client: 2021-08-06 12:36:00 +0000 UTC
+    apiserver-kubelet-client: 2021-08-06 12:36:00 +0000 UTC
+    ca: 2021-08-04 12:36:00 +0000 UTC
+    controller-manager.conf: 2021-08-06 12:36:00 +0000 UTC
+    etcd-ca: 2021-08-04 12:36:00 +0000 UTC
+    etcd-healthcheck-client: 2021-08-06 12:36:00 +0000 UTC
+    etcd-peer: 2021-08-06 12:36:00 +0000 UTC
+    etcd-server: 2021-08-06 12:36:00 +0000 UTC
+    front-proxy-ca: 2021-08-04 12:36:00 +0000 UTC
+    front-proxy-client: 2021-08-06 12:36:00 +0000 UTC
+    scheduler.conf: 2021-08-06 12:36:00 +0000 UTC
 ...
 `
 )
@@ -143,8 +179,9 @@ func TestRunE(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.testCaseName, func(t *testing.T) {
 			objects := []runtime.Object{
-				getObject(t, "testdata/tls-secret.yaml"),
-				getObject(t, "testdata/kubeconfig.yaml"),
+				getSecretObject(t, "testdata/tls-secret.yaml"),
+				getSecretObject(t, "testdata/kubeconfig.yaml"),
+				getNodeObject(t, "testdata/node.yaml"),
 			}
 			ra := fake.WithTypedObjects(objects...)
 
@@ -176,7 +213,7 @@ func TestRunE(t *testing.T) {
 	}
 }
 
-func getObject(t *testing.T, fileName string) *v1.Secret {
+func getSecretObject(t *testing.T, fileName string) *v1.Secret {
 	t.Helper()
 
 	object := readObjectFromFile(t, fileName)
@@ -186,6 +223,16 @@ func getObject(t *testing.T, fileName string) *v1.Secret {
 	return secret
 }
 
+func getNodeObject(t *testing.T, fileName string) *v1.Node {
+	t.Helper()
+
+	object := readObjectFromFile(t, fileName)
+	node, ok := object.(*v1.Node)
+	require.True(t, ok)
+
+	return node
+}
+
 func readObjectFromFile(t *testing.T, fileName string) runtime.Object {
 	t.Helper()
 
diff --git a/pkg/cluster/checkexpiration/testdata/node.yaml b/pkg/cluster/checkexpiration/testdata/node.yaml
new file mode 100644
index 000000000..7d4d174ea
--- /dev/null
+++ b/pkg/cluster/checkexpiration/testdata/node.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: Node
+metadata:
+  annotations:
+    cert-expiration: "{ admin.conf: Aug 06, 2021 12:36 UTC },{ apiserver: Aug 06, 2021 12:36 UTC },{ apiserver-etcd-client: Aug 06, 2021 12:36 UTC },{ apiserver-kubelet-client: Aug 06, 2021 12:36 UTC },{ controller-manager.conf: Aug 06, 2021 12:36 UTC },{ etcd-healthcheck-client: Aug 06, 2021 12:36 UTC },{ etcd-peer: Aug 06, 2021 12:36 UTC },{ etcd-server: Aug 06, 2021 12:36 UTC },{ front-proxy-client: Aug 06, 2021 12:36 UTC },{ scheduler.conf: Aug 06, 2021 12:36 UTC },{ ca: Aug 04, 2021 12:36 UTC },{ etcd-ca: Aug 04, 2021 12:36 UTC },{ front-proxy-ca: Aug 04, 2021 12:36 UTC }"
+  name: test-node
\ No newline at end of file