Check TLS certificate expiration

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

Relates-To: #391

Change-Id: Ia1d4524f5228542cf3fc4b29074c668eca3c55bb
This commit is contained in:
guhaneswaran20 2020-10-30 07:16:17 +00:00
parent 334ff8041b
commit ecfb38c7bf
5 changed files with 183 additions and 24 deletions

View File

@ -15,8 +15,16 @@
package checkexpiration
import (
"crypto/x509"
"encoding/pem"
"fmt"
"log"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"opendev.org/airship/airshipctl/pkg/config"
"opendev.org/airship/airshipctl/pkg/errors"
"opendev.org/airship/airshipctl/pkg/k8s/client"
)
@ -24,14 +32,12 @@ import (
type CertificateExpirationStore struct {
Kclient client.Interface
Settings config.Factory
ExpirationThreshold int
}
// Expiration captures expiration information of all expirable entities in the cluster
type Expiration struct{}
// NewStore returns an instance of a CertificateExpirationStore
func NewStore(cfgFactory config.Factory, clientFactory client.Factory,
kubeconfig string, _ string) (CertificateExpirationStore, error) {
kubeconfig, _ string, expirationThreshold int) (CertificateExpirationStore, error) {
airshipconfig, err := cfgFactory()
if err != nil {
return CertificateExpirationStore{}, err
@ -49,12 +55,78 @@ func NewStore(cfgFactory config.Factory, clientFactory client.Factory,
return CertificateExpirationStore{
Kclient: kclient,
Settings: cfgFactory,
ExpirationThreshold: expirationThreshold,
}, nil
}
// GetExpiringCertificates checks for the expiration data
// NOT IMPLEMENTED (guhan)
// TODO (guhan) check for TLS certificates, workload kubeconfig and node certificates
func (store CertificateExpirationStore) GetExpiringCertificates(expirationThreshold int) (Expiration, error) {
return Expiration{}, errors.ErrNotImplemented{What: "check certificate expiration logic"}
// GetExpiringTLSCertificates returns the list of TLS certificates whose expiration date
// falls within the given expirationThreshold
func (store CertificateExpirationStore) GetExpiringTLSCertificates() ([]TLSSecret, error) {
secrets, err := store.getAllTLSCertificates()
if err != nil {
return nil, err
}
tlsData := make([]TLSSecret, 0)
for _, secret := range secrets.Items {
expiringCertificates := store.getExpiringCertificates(secret)
if len(expiringCertificates) > 0 {
tlsData = append(tlsData, TLSSecret{
Name: secret.Name,
Namespace: secret.Namespace,
ExpiringCertificates: expiringCertificates,
})
}
}
return tlsData, nil
}
// getAllTLSCertificates juist returns all the k8s secrets with tyoe as TLS
func (store CertificateExpirationStore) getAllTLSCertificates() (*corev1.SecretList, error) {
secretTypeFieldSelector := fmt.Sprintf("type=%s", corev1.SecretTypeTLS)
listOptions := metav1.ListOptions{FieldSelector: secretTypeFieldSelector}
return store.Kclient.ClientSet().CoreV1().Secrets("").List(listOptions)
}
// getExpiringCertificates skims through all the TLS certificates and returns the ones
// lesser than threshold
func (store CertificateExpirationStore) getExpiringCertificates(secret corev1.Secret) map[string]string {
expiringCertificates := map[string]string{}
for _, certName := range []string{corev1.TLSCertKey, corev1.ServiceAccountRootCAKey} {
if cert, found := secret.Data[certName]; found {
expirationDate, err := extractExpirationDateFromCertificate(cert)
if err != nil {
log.Printf("Unable to parse certificate for %s in secret %s in namespace %s: %v",
certName, secret.Name, secret.Namespace, err)
continue
}
if isWithinDuration(expirationDate, store.ExpirationThreshold) {
expiringCertificates[certName] = expirationDate.String()
}
}
}
return expiringCertificates
}
// isWithinDuration checks if the certificate expirationDate is within the duration (input)
func isWithinDuration(expirationDate time.Time, duration int) bool {
if duration < 0 {
return true
}
daysUntilExpiration := int(time.Until(expirationDate).Hours() / 24)
return 0 <= daysUntilExpiration && daysUntilExpiration < duration
}
// extractExpirationDateFromCertificate parses the certificate and returns the expiration date
func extractExpirationDateFromCertificate(certData []byte) (time.Time, error) {
block, _ := pem.Decode(certData)
if block == nil {
return time.Time{}, ErrPEMFail{Context: "decode", Err: "no PEM data could be found"}
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return time.Time{}, ErrPEMFail{Context: "parse", Err: err.Error()}
}
return cert.NotAfter, nil
}

View File

@ -39,21 +39,30 @@ type CheckCommand struct {
ClientFactory client.Factory
}
// TLSSecret captures expiration information of certificates embedded in TLS secrets
type TLSSecret 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") {
return ErrInvalidFormat{RequestedFormat: c.Options.FormatType}
}
secretStore, err := NewStore(c.CfgFactory, c.ClientFactory, c.Options.Kubeconfig, c.Options.KubeContext)
secretStore, err := NewStore(c.CfgFactory, c.ClientFactory, c.Options.Kubeconfig,
c.Options.KubeContext, c.Options.Threshold)
if err != nil {
return err
}
expirationInfo, err := secretStore.GetExpiringCertificates(c.Options.Threshold)
expirationInfo, err := secretStore.GetExpiringTLSCertificates()
if err != nil {
return err
}
if c.Options.FormatType == "yaml" {
err = yaml.WriteOut(w, expirationInfo)
if err != nil {

View File

@ -16,11 +16,15 @@ package checkexpiration_test
import (
"bytes"
"io/ioutil"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/kubectl/pkg/scheme"
"opendev.org/airship/airshipctl/pkg/cluster/checkexpiration"
"opendev.org/airship/airshipctl/pkg/config"
@ -30,7 +34,28 @@ import (
)
const (
testNotImplementedErr = "not implemented: check certificate expiration logic"
testThreshold = 5000
expectedJSONOutput = `[
{
"name": "test-cluster-etcd",
"namespace": "default",
"certificate": {
"ca.crt": "2030-08-31 10:12:49 +0000 UTC",
"tls.crt": "2030-08-31 10:12:49 +0000 UTC"
}
}
]`
expectedYAMLOutput = `
---
- certificate:
ca.crt: 2030-08-31 10:12:49 +0000 UTC
tls.crt: 2030-08-31 10:12:49 +0000 UTC
name: test-cluster-etcd
namespace: default
...
`
)
func TestRunE(t *testing.T) {
@ -59,11 +84,12 @@ func TestRunE(t *testing.T) {
return cfg, nil
},
checkFlags: checkexpiration.CheckFlags{
Threshold: 5000,
Threshold: testThreshold,
FormatType: "json",
Kubeconfig: "",
},
testErr: testNotImplementedErr,
testErr: "",
expectedOutput: expectedJSONOutput,
},
{
testCaseName: "valid-input-format-yaml",
@ -72,17 +98,17 @@ func TestRunE(t *testing.T) {
return cfg, nil
},
checkFlags: checkexpiration.CheckFlags{
Threshold: 5000,
Threshold: testThreshold,
FormatType: "yaml",
},
testErr: testNotImplementedErr,
testErr: "",
expectedOutput: expectedYAMLOutput,
},
}
for _, tt := range tests {
t.Run(tt.testCaseName, func(t *testing.T) {
var objects []runtime.Object
// TODO (guhan) append a dummy object for testing
objects := []runtime.Object{getTLSSecret(t)}
ra := fake.WithTypedObjects(objects...)
command := checkexpiration.CheckCommand{
@ -99,8 +125,38 @@ func TestRunE(t *testing.T) {
if tt.testErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.testErr)
} else {
require.NoError(t, err)
switch tt.checkFlags.FormatType {
case "json":
assert.JSONEq(t, tt.expectedOutput, buffer.String())
case "yaml":
assert.YAMLEq(t, tt.expectedOutput, buffer.String())
}
}
// TODO (guhan) add else part to check the actual vs expected o/p
})
}
}
func getTLSSecret(t *testing.T) *v1.Secret {
t.Helper()
object := readObjectFromFile(t, "testdata/tls-secret.yaml")
secret, ok := object.(*v1.Secret)
require.True(t, ok)
return secret
}
func readObjectFromFile(t *testing.T, fileName string) runtime.Object {
t.Helper()
contents, err := ioutil.ReadFile(fileName)
require.NoError(t, err)
jsonContents, err := yaml.ToJSON(contents)
require.NoError(t, err)
object, err := runtime.Decode(scheme.Codecs.UniversalDeserializer(), jsonContents)
require.NoError(t, err)
return object
}

View File

@ -24,3 +24,13 @@ type ErrInvalidFormat struct {
func (e ErrInvalidFormat) Error() string {
return fmt.Sprintf("invalid output format specified %s. Allowed values are json|yaml", e.RequestedFormat)
}
// ErrPEMFail is called where there is a PEM related failure while parsing the certificate block
type ErrPEMFail struct {
Context string
Err string
}
func (e ErrPEMFail) Error() string {
return fmt.Sprintf("failed to %s certificate PEM: %s", e.Context, e.Err)
}

View File

@ -0,0 +1,12 @@
apiVersion: v1
data:
tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUN5ekNDQWJPZ0F3SUJBZ0lCQURBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwcmRXSmwKY201bGRHVnpNQjRYRFRJd01Ea3dNakV3TURjME9Wb1hEVE13TURnek1URXdNVEkwT1Zvd0ZURVRNQkVHQTFVRQpBeE1LYTNWaVpYSnVaWFJsY3pDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTHdVCkErWWRNcWF0clI4WWVHcEVYTFlIeVZHRi9Na0g1L3FqWEdpcmxITCtiNEhXYmg2enkvSWY3N1MzNWhnTzFPSkcKcG5wdmk5ck5ISEMyV3hNblVUOFVxSE1lYjNyb0phMlFpME1sNGlNTFFncTd0TGhLQ04zTnFwYmk5OEJ4d1VxSAo3eGkzWmU3WEZ2NVJyRlpFa2hicW9ycVJRZzg0cHdRTTNvMkh1NmJSWElETjc5bnVMV3piZ0pYUzhwMytnZHFuCkxIa3owOGN4VkxxZmJPOFMxOFEwdWJUbnY3RjVBblBPZkhJY2xyR2h3MFVUZXRWZGxuVmNISnRpZ29xYjBBdysKOUY5WkNaN0ZZUzE2eEJ0L0N0OHpKZEQ3MEFGZ2NMRm4vSTJlSG05bTFSK0FISWxJU3ZLbzl0OVBtekJxWS9tbgpHOUFoektYSlBTSHRPbEJqMXBzQ0F3RUFBYU1tTUNRd0RnWURWUjBQQVFIL0JBUURBZ0trTUJJR0ExVWRFd0VCCi93UUlNQVlCQWY4Q0FRQXdEUVlKS29aSWh2Y05BUUVMQlFBRGdnRUJBQVAzLzdmYzhBNlFjWU5CUnQxdDRWQ0QKRnYwMDB0Q09mVjVxdnNOU2RQYTRZY2NaTDBmT2dUVmtNbGczbTVRMmVKUkpVTHR5V0NHQ3BNcHRFR2duMGI3eQpIWkhtWkpkZjZBN0twbFRqQWxyemRDOUpPTVBrQUtjaXhNcWhPcDFxdTI0dHl5eEZQZWdMeTE2SU5ZMGl5ZzI2ClJhYkowREdQcTNZUitwZHphRkZ2YUN6bk1yTGtDckJpV0xvUmdrK2xaT3NoUU1EaHl4Y1crQW5mcHRINHlxM2YKZjhhdFZPVGprMGVIYVlQUlNKSlFlM0RLOXFEY0V4bVFib1orM1NLYXRtK0RNMWVieE5CNlVKaGdYWEluZ3dQQwp4czV0azJkUGlJaXZldXZYbGVqTkZ1c0RzekV1NU9ZN08xVzZNSUdmNlBPQ3JmSzk0S3JhVWVhWng4bUlRcE09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
ca.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUN5ekNDQWJPZ0F3SUJBZ0lCQURBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwcmRXSmwKY201bGRHVnpNQjRYRFRJd01Ea3dNakV3TURjME9Wb1hEVE13TURnek1URXdNVEkwT1Zvd0ZURVRNQkVHQTFVRQpBeE1LYTNWaVpYSnVaWFJsY3pDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTHdVCkErWWRNcWF0clI4WWVHcEVYTFlIeVZHRi9Na0g1L3FqWEdpcmxITCtiNEhXYmg2enkvSWY3N1MzNWhnTzFPSkcKcG5wdmk5ck5ISEMyV3hNblVUOFVxSE1lYjNyb0phMlFpME1sNGlNTFFncTd0TGhLQ04zTnFwYmk5OEJ4d1VxSAo3eGkzWmU3WEZ2NVJyRlpFa2hicW9ycVJRZzg0cHdRTTNvMkh1NmJSWElETjc5bnVMV3piZ0pYUzhwMytnZHFuCkxIa3owOGN4VkxxZmJPOFMxOFEwdWJUbnY3RjVBblBPZkhJY2xyR2h3MFVUZXRWZGxuVmNISnRpZ29xYjBBdysKOUY5WkNaN0ZZUzE2eEJ0L0N0OHpKZEQ3MEFGZ2NMRm4vSTJlSG05bTFSK0FISWxJU3ZLbzl0OVBtekJxWS9tbgpHOUFoektYSlBTSHRPbEJqMXBzQ0F3RUFBYU1tTUNRd0RnWURWUjBQQVFIL0JBUURBZ0trTUJJR0ExVWRFd0VCCi93UUlNQVlCQWY4Q0FRQXdEUVlKS29aSWh2Y05BUUVMQlFBRGdnRUJBQVAzLzdmYzhBNlFjWU5CUnQxdDRWQ0QKRnYwMDB0Q09mVjVxdnNOU2RQYTRZY2NaTDBmT2dUVmtNbGczbTVRMmVKUkpVTHR5V0NHQ3BNcHRFR2duMGI3eQpIWkhtWkpkZjZBN0twbFRqQWxyemRDOUpPTVBrQUtjaXhNcWhPcDFxdTI0dHl5eEZQZWdMeTE2SU5ZMGl5ZzI2ClJhYkowREdQcTNZUitwZHphRkZ2YUN6bk1yTGtDckJpV0xvUmdrK2xaT3NoUU1EaHl4Y1crQW5mcHRINHlxM2YKZjhhdFZPVGprMGVIYVlQUlNKSlFlM0RLOXFEY0V4bVFib1orM1NLYXRtK0RNMWVieE5CNlVKaGdYWEluZ3dQQwp4czV0azJkUGlJaXZldXZYbGVqTkZ1c0RzekV1NU9ZN08xVzZNSUdmNlBPQ3JmSzk0S3JhVWVhWng4bUlRcE09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
tls.key: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFb2dJQkFBS0NBUUVBdkJRRDVoMHlwcTJ0SHhoNGFrUmN0Z2ZKVVlYOHlRZm4rcU5jYUt1VWN2NXZnZFp1CkhyUEw4aC92dExmbUdBN1U0a2FtZW0rTDJzMGNjTFpiRXlkUlB4U29jeDV2ZXVnbHJaQ0xReVhpSXd0Q0NydTAKdUVvSTNjMnFsdUwzd0hIQlNvZnZHTGRsN3RjVy9sR3NWa1NTRnVxaXVwRkNEemluQkF6ZWpZZTdwdEZjZ00zdgoyZTR0Yk51QWxkTHluZjZCMnFjc2VUUFR4ekZVdXA5czd4TFh4RFM1dE9lL3NYa0NjODU4Y2h5V3NhSERSUk42CjFWMldkVndjbTJLQ2lwdlFERDcwWDFrSm5zVmhMWHJFRzM4SzN6TWwwUHZRQVdCd3NXZjhqWjRlYjJiVkg0QWMKaVVoSzhxajIzMCtiTUdwaithY2IwQ0hNcGNrOUllMDZVR1BXbXdJREFRQUJBb0lCQUJESkZJUUFEUm8xRytOUAprc2VoTEVrT3J0ZjR4bFBHd2R4cm9mNnhlWUU5MWdQWGVHS0RGMnVYa0JRbjZZQXlLcXU3TkhadTZDTng5TnpXCldaQi9ETkE5YnI4L2N5R2NBR2phSXFPdWlOMHB6dzRZTEl2YUI2cU1CWEtMOVNLV3hISjdhVXBpYTlXQ0dzbzkKemN5eE4veVZta3BlVm0vM1ZXaVdJWEt1TDRBMnZmaHJjbHdVc3NwNFA4Q1dZUFZVbExOT0JsYlQrNy9qQ1RubwpyRDVYcjNDVEFiUmNBNEdvcGx1ZFphaUtqY2FpZXpYeHdTaGFmWkFURWJJM0xwejZSTnRBMDRLbjEyR0JLRDQwCk1zV21xUU1zRE4zUkgveFdKbnp5TFVSTGVlcDdRUTdXQytZYmRmdFpTZ3FKUkRNYmw1aHdwUXRLeFBoUmhHakcKSGEwWUVIa0NnWUVBNkdjSCtzUzczd3FwUmloZXJ3aFVEY21Na3VEZDBiMkEyWlhpcjBRUStKUXZrVHVSd2NZVgpHdDU3Zm40UnBybFUwNzByMTZRQzcrdTU0OVdQVlFpbExvMURvVUVHMC9QVEhSV2ZQVjBtOWh2cGF2akdtMG1zCmpmd20rMXZ1WGFiS0RMblg0dXcxQ0JKSU9vdmJ6RnhrTGJWZk91YVhrN1BhckhiOGdHRHR2ZjhDZ1lFQXp5elcKZ3VtT25tMmVNYy80ZHJmMDI2Q1doZk1ZMXF0VnFEb2s4anVlMVlEaVdhZEMwOGo3VEVoQ1dTQUZPOC9ZOVBDRApKQ3cvcGpUZThERlRLZEw3d2t5ZVUrS2JJRFRLNHJ5clMzenZmV3Q4Zjg2ZmU5SVd3L3FDaEN2cm5tSTdNeS8rCkowL1BCZFFGL0xkeS9PWlVGcW9wblJNS1VnVTJXMWhxRTJOcUgyVUNnWUExV0NqeHU2U3YvcDk2Tmh2OXF6aTMKN1hKeDZHR2lHaEJ3WVVJbUhzYVNlRmt1eWZDYi9ONnRTekluaDhKL2RYenVHVGJ1Q1h5UEc1bVFuVjJJRkRMdQpLNGpCZzg2UWFpQWtSZWxHU1pKKzNVdEh2WkRBNWpsUVlmZUVyTVpiQXNUUUJQeHozdW9SVHpqN0QwMUZiRk9tClZrSmtuN2RkTk9SVnYvNFhiYWhFZXdLQmdENURTM1NzbktBZ2NacW0xaFZYMDg3dHhFOGRjQ21UOUhwS2Z6QU4KbXY2dmJWZGtYVUVvOWQxSEdpbU81Z1BEdzRCWmlCQW0vRG9IU2JrR0dlaEg4RUhFcFJDdzJjNGtENVYwL2tZQgpsamdyUlk5am1hcXN5UXE5RHR5S0ZwWFREOWVpWk0rTHZMd1RySGoyNlNmNFVPMCsxcUxPUmh2QVZVVytuS0tYCkRoM0JBb0dBRjVEU0R2U2ltVjhtaHFWbjc2M1dwOUJXN0RwbTAxTE5qMHRZbVFhZVpyK3VoZ1BKdW5SYzhBUE4KWXRudjlsY04wSkx1MzhWaWI3eVFzVmgvVUsvYVdwS2w1YUZzTXdRMVNJTUJXUTVEeVR0VEE0VGZXQzFUSUYxUQpuempLd0NSTHBaZ1F6ZkNCaTBIT3doZ0pUaDZBNng4SG1hYXU5ZmZLdE90SUpkTUZ5d1E9Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg==
kind: Secret
metadata:
labels:
cluster.x-k8s.io/cluster-name: test-cluster
name: test-cluster-etcd
namespace: default
type: Opaque