diff --git a/cmd/kubeadm/app/cmd/alpha/alpha.go b/cmd/kubeadm/app/cmd/alpha/alpha.go index f48d07ddcd9..b3c10c57ca8 100644 --- a/cmd/kubeadm/app/cmd/alpha/alpha.go +++ b/cmd/kubeadm/app/cmd/alpha/alpha.go @@ -30,7 +30,7 @@ func NewCmdAlpha(in io.Reader, out io.Writer) *cobra.Command { Short: "Kubeadm experimental sub-commands", } - cmd.AddCommand(newCmdCertsUtility()) + cmd.AddCommand(newCmdCertsUtility(out)) cmd.AddCommand(newCmdKubeletUtility()) cmd.AddCommand(newCmdKubeConfigUtility(out)) cmd.AddCommand(NewCmdSelfhosting(in)) diff --git a/cmd/kubeadm/app/cmd/alpha/certs.go b/cmd/kubeadm/app/cmd/alpha/certs.go index 937b99df3ee..94355cfa2ad 100644 --- a/cmd/kubeadm/app/cmd/alpha/certs.go +++ b/cmd/kubeadm/app/cmd/alpha/certs.go @@ -18,10 +18,13 @@ package alpha import ( "fmt" + "io" + "text/tabwriter" "github.com/pkg/errors" "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/util/duration" kubeadmapi "k8s.io/kubernetes/cmd/kubeadm/app/apis/kubeadm" kubeadmscheme "k8s.io/kubernetes/cmd/kubeadm/app/apis/kubeadm/scheme" kubeadmapiv1beta2 "k8s.io/kubernetes/cmd/kubeadm/app/apis/kubeadm/v1beta2" @@ -54,10 +57,14 @@ var ( Renew all known certificates necessary to run the control plane. Renewals are run unconditionally, regardless of expiration date. Renewals can also be run individually for more control. `) + + expirationLongDesc = normalizer.LongDesc(` + Checks expiration for the certificates in the local PKI managed by kubeadm. +`) ) // newCmdCertsUtility returns main command for certs phase -func newCmdCertsUtility() *cobra.Command { +func newCmdCertsUtility(out io.Writer) *cobra.Command { cmd := &cobra.Command{ Use: "certs", Aliases: []string{"certificates"}, @@ -65,6 +72,7 @@ func newCmdCertsUtility() *cobra.Command { } cmd.AddCommand(newCmdCertsRenewal()) + cmd.AddCommand(newCmdCertsExpiration(out, kubeadmconstants.KubernetesDir)) return cmd } @@ -118,7 +126,7 @@ func getRenewSubCommands(kdir string) []*cobra.Command { Short: fmt.Sprintf("Renew the %s", handler.LongName), Long: fmt.Sprintf(genericCertRenewLongDesc, handler.LongName), } - addFlags(cmd, flags) + addRenewFlags(cmd, flags) // get the implementation of renewing this certificate renewalFunc := func(handler *renewal.CertificateRenewHandler) func() { return func() { renewCert(flags, kdir, handler) } @@ -140,13 +148,13 @@ func getRenewSubCommands(kdir string) []*cobra.Command { } }, } - addFlags(allCmd, flags) + addRenewFlags(allCmd, flags) cmdList = append(cmdList, allCmd) return cmdList } -func addFlags(cmd *cobra.Command, flags *renewFlags) { +func addRenewFlags(cmd *cobra.Command, flags *renewFlags) { options.AddConfigFlag(cmd.Flags(), &flags.cfgPath) options.AddCertificateDirFlag(cmd.Flags(), &flags.cfg.CertificatesDir) options.AddKubeConfigFlag(cmd.Flags(), &flags.kubeconfigPath) @@ -197,3 +205,70 @@ func renewCert(flags *renewFlags, kdir string, handler *renewal.CertificateRenew } fmt.Printf("%s renewed\n", handler.LongName) } + +// newCmdCertsExpiration creates a new `cert check-expiration` command. +func newCmdCertsExpiration(out io.Writer, kdir string) *cobra.Command { + flags := &expirationFlags{ + cfg: kubeadmapiv1beta2.InitConfiguration{ + ClusterConfiguration: kubeadmapiv1beta2.ClusterConfiguration{ + // Setting kubernetes version to a default value in order to allow a not necessary internet lookup + KubernetesVersion: constants.CurrentKubernetesVersion.String(), + }, + }, + } + // Default values for the cobra help text + kubeadmscheme.Scheme.Default(&flags.cfg) + + cmd := &cobra.Command{ + Use: "check-expiration", + Short: "Check certificates expiration for a Kubernetes cluster", + Long: expirationLongDesc, + Run: func(cmd *cobra.Command, args []string) { + internalcfg, err := configutil.LoadOrDefaultInitConfiguration(flags.cfgPath, &flags.cfg) + kubeadmutil.CheckErr(err) + + // Get a renewal manager for the given cluster configuration + rm, err := renewal.NewManager(&internalcfg.ClusterConfiguration, kdir) + kubeadmutil.CheckErr(err) + + // Get all the certificate expiration info + yesNo := func(b bool) string { + if b { + return "yes" + } + return "no" + } + w := tabwriter.NewWriter(out, 10, 4, 3, ' ', 0) + fmt.Fprintln(w, "CERTIFICATE\tEXPIRES\tRESIDUAL TIME\tEXTERNALLY MANAGED") + for _, handler := range rm.Certificates() { + e, err := rm.GetExpirationInfo(handler.Name) + if err != nil { + kubeadmutil.CheckErr(err) + } + + s := fmt.Sprintf("%s\t%s\t%s\t%-8v", + e.Name, + e.ExpirationDate.Format("Jan 02, 2006 15:04 MST"), + duration.ShortHumanDuration(e.ResidualTime()), + yesNo(e.ExternallyManaged), + ) + + fmt.Fprintln(w, s) + } + w.Flush() + }, + } + addExpirationFlags(cmd, flags) + + return cmd +} + +type expirationFlags struct { + cfgPath string + cfg kubeadmapiv1beta2.InitConfiguration +} + +func addExpirationFlags(cmd *cobra.Command, flags *expirationFlags) { + options.AddConfigFlag(cmd.Flags(), &flags.cfgPath) + options.AddCertificateDirFlag(cmd.Flags(), &flags.cfg.CertificatesDir) +} diff --git a/cmd/kubeadm/app/constants/constants.go b/cmd/kubeadm/app/constants/constants.go index 96500595173..c00e4cfc91f 100644 --- a/cmd/kubeadm/app/constants/constants.go +++ b/cmd/kubeadm/app/constants/constants.go @@ -42,6 +42,9 @@ const ( // should be joined with KubernetesDir. TempDirForKubeadm = "tmp" + // CertificateValidity defines the validity for all the signed certificates generated by kubeadm + CertificateValidity = time.Hour * 24 * 365 + // CACertAndKeyBaseName defines certificate authority base name CACertAndKeyBaseName = "ca" // CACertName defines certificate name diff --git a/cmd/kubeadm/app/phases/certs/renewal/expiration.go b/cmd/kubeadm/app/phases/certs/renewal/expiration.go new file mode 100644 index 00000000000..dc6b81e0960 --- /dev/null +++ b/cmd/kubeadm/app/phases/certs/renewal/expiration.go @@ -0,0 +1,52 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package renewal + +import ( + "crypto/x509" + "time" +) + +// ExpirationInfo defines expiration info for a certificate +type ExpirationInfo struct { + // Name of the certificate + // For PKI certificates, it is the name defined in the certsphase package, while for certificates + // embedded in the kubeConfig files, it is the kubeConfig file name defined in the kubeadm constants package. + // If you use the CertificateRenewHandler returned by Certificates func, handler.Name already contains the right value. + Name string + + // ExpirationDate defines certificate expiration date + ExpirationDate time.Time + + // ExternallyManaged defines if the certificate is externally managed, that is when + // the signing CA certificate is provided without the certificate key (In this case kubeadm can't renew the certificate) + ExternallyManaged bool +} + +// newExpirationInfo returns a new ExpirationInfo +func newExpirationInfo(name string, cert *x509.Certificate, externallyManaged bool) *ExpirationInfo { + return &ExpirationInfo{ + Name: name, + ExpirationDate: cert.NotAfter, + ExternallyManaged: externallyManaged, + } +} + +// ResidualTime returns the time missing to expiration +func (e *ExpirationInfo) ResidualTime() time.Duration { + return e.ExpirationDate.Sub(time.Now()) +} diff --git a/cmd/kubeadm/app/phases/certs/renewal/expiration_test.go b/cmd/kubeadm/app/phases/certs/renewal/expiration_test.go new file mode 100644 index 00000000000..d8ed3b70c90 --- /dev/null +++ b/cmd/kubeadm/app/phases/certs/renewal/expiration_test.go @@ -0,0 +1,37 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package renewal + +import ( + "crypto/x509" + "math" + "testing" + "time" +) + +func TestExpirationInfo(t *testing.T) { + validity := 365 * 24 * time.Hour + cert := &x509.Certificate{ + NotAfter: time.Now().Add(validity), + } + + e := newExpirationInfo("x", cert, false) + + if math.Abs(float64(validity-e.ResidualTime())) > float64(5*time.Second) { // using 5s of tolerance becase the function is not determinstic (it uses time.Now()) and we want to avoid flakes + t.Errorf("expected IsInRenewalWindow equal to %v, saw %v", validity, e.ResidualTime()) + } +} diff --git a/cmd/kubeadm/app/phases/certs/renewal/manager.go b/cmd/kubeadm/app/phases/certs/renewal/manager.go index 88a0bec54d7..97ae9312e5e 100644 --- a/cmd/kubeadm/app/phases/certs/renewal/manager.go +++ b/cmd/kubeadm/app/phases/certs/renewal/manager.go @@ -161,21 +161,14 @@ func (rm *Manager) RenewUsingLocalCA(name string) (bool, error) { return false, errors.Errorf("%s is not a valid certificate for this cluster", name) } - // checks if the we are in the external CA case (CA certificate provided without the certificate key) - var externalCA bool - switch handler.CABaseName { - case kubeadmconstants.CACertAndKeyBaseName: - externalCA, _ = certsphase.UsingExternalCA(rm.cfg) - case kubeadmconstants.FrontProxyCACertAndKeyBaseName: - externalCA, _ = certsphase.UsingExternalFrontProxyCA(rm.cfg) - case kubeadmconstants.EtcdCACertAndKeyBaseName: - externalCA = false - default: - return false, errors.Errorf("unknown certificate authority %s", handler.CABaseName) + // checks if the certificate is externally managed (CA certificate provided without the certificate key) + externallyManaged, err := rm.IsExternallyManaged(handler) + if err != nil { + return false, err } // in case of external CA it is not possible to renew certificates, then return early - if externalCA { + if externallyManaged { return false, nil } @@ -275,6 +268,54 @@ func (rm *Manager) CreateRenewCSR(name, outdir string) error { return nil } +// GetExpirationInfo returns certificate expiration info. +// For PKI certificates, use the name defined in the certsphase package, while for certificates +// embedded in the kubeConfig files, use the kubeConfig file name defined in the kubeadm constants package. +// If you use the CertificateRenewHandler returned by Certificates func, handler.Name already contains the right value. +func (rm *Manager) GetExpirationInfo(name string) (*ExpirationInfo, error) { + handler, ok := rm.certificates[name] + if !ok { + return nil, errors.Errorf("%s is not a known certificate", name) + } + + // checks if the certificate is externally managed (CA certificate provided without the certificate key) + externallyManaged, err := rm.IsExternallyManaged(handler) + if err != nil { + return nil, err + } + + // reads the current certificate + cert, err := handler.readwriter.Read() + if err != nil { + return nil, err + } + + // returns the certificate expiration info + return newExpirationInfo(name, cert, externallyManaged), nil +} + +// IsExternallyManaged checks if we are in the external CA case (CA certificate provided without the certificate key) +func (rm *Manager) IsExternallyManaged(h *CertificateRenewHandler) (bool, error) { + switch h.CABaseName { + case kubeadmconstants.CACertAndKeyBaseName: + externallyManaged, err := certsphase.UsingExternalCA(rm.cfg) + if err != nil { + return false, errors.Wrapf(err, "Error checking external CA condition for %s certificate authority", h.CABaseName) + } + return externallyManaged, nil + case kubeadmconstants.FrontProxyCACertAndKeyBaseName: + externallyManaged, err := certsphase.UsingExternalFrontProxyCA(rm.cfg) + if err != nil { + return false, errors.Wrapf(err, "Error checking external CA condition for %s certificate authority", h.CABaseName) + } + return externallyManaged, nil + case kubeadmconstants.EtcdCACertAndKeyBaseName: + return false, nil + default: + return false, errors.Errorf("unknown certificate authority %s", h.CABaseName) + } +} + func certToConfig(cert *x509.Certificate) *certutil.Config { return &certutil.Config{ CommonName: cert.Subject.CommonName, diff --git a/cmd/kubeadm/app/phases/upgrade/staticpods_test.go b/cmd/kubeadm/app/phases/upgrade/staticpods_test.go index 6c106f9d89d..19400003ecc 100644 --- a/cmd/kubeadm/app/phases/upgrade/staticpods_test.go +++ b/cmd/kubeadm/app/phases/upgrade/staticpods_test.go @@ -697,6 +697,7 @@ func TestRenewCertsByComponent(t *testing.T) { skipCreateEtcdCA bool shouldErrorOnRenew bool certsShouldExist []*certsphase.KubeadmCert + certsShouldBeRenewed []*certsphase.KubeadmCert // NB. If empty, it will assume certsShouldBeRenewed == certsShouldExist kubeConfigShouldExist []string }{ { @@ -724,6 +725,12 @@ func TestRenewCertsByComponent(t *testing.T) { certsShouldExist: []*certsphase.KubeadmCert{ &certsphase.KubeadmCertEtcdAPIClient, &certsphase.KubeadmCertFrontProxyClient, + &certsphase.KubeadmCertAPIServer, + &certsphase.KubeadmCertKubeletClient, + }, + certsShouldBeRenewed: []*certsphase.KubeadmCert{ + &certsphase.KubeadmCertEtcdAPIClient, + &certsphase.KubeadmCertFrontProxyClient, }, externalCA: true, }, @@ -731,6 +738,12 @@ func TestRenewCertsByComponent(t *testing.T) { name: "external front-proxy-CA, renew only certificates not signed by front-proxy-CA for apiserver", component: constants.KubeAPIServer, certsShouldExist: []*certsphase.KubeadmCert{ + &certsphase.KubeadmCertEtcdAPIClient, + &certsphase.KubeadmCertFrontProxyClient, + &certsphase.KubeadmCertAPIServer, + &certsphase.KubeadmCertKubeletClient, + }, + certsShouldBeRenewed: []*certsphase.KubeadmCert{ &certsphase.KubeadmCertEtcdAPIClient, &certsphase.KubeadmCertAPIServer, &certsphase.KubeadmCertKubeletClient, @@ -849,8 +862,22 @@ func TestRenewCertsByComponent(t *testing.T) { continue } oldSerial, _ := certMaps[kubeCert.Name] - if oldSerial.Cmp(newCert.SerialNumber) == 0 { - t.Errorf("certifitate %v was not reissued", kubeCert.Name) + + shouldBeRenewed := true + if test.certsShouldBeRenewed != nil { + shouldBeRenewed = false + for _, x := range test.certsShouldBeRenewed { + if x.Name == kubeCert.Name { + shouldBeRenewed = true + } + } + } + + if shouldBeRenewed && oldSerial.Cmp(newCert.SerialNumber) == 0 { + t.Errorf("certifitate %v was not reissued when expected", kubeCert.Name) + } + if !shouldBeRenewed && oldSerial.Cmp(newCert.SerialNumber) != 0 { + t.Errorf("certifitate %v was reissued when not expected", kubeCert.Name) } } diff --git a/cmd/kubeadm/app/util/pkiutil/pki_helpers.go b/cmd/kubeadm/app/util/pkiutil/pki_helpers.go index 5b582391a43..e1d77e12607 100644 --- a/cmd/kubeadm/app/util/pkiutil/pki_helpers.go +++ b/cmd/kubeadm/app/util/pkiutil/pki_helpers.go @@ -54,7 +54,6 @@ const ( // RSAPrivateKeyBlockType is a possible value for pem.Block.Type. RSAPrivateKeyBlockType = "RSA PRIVATE KEY" rsaKeySize = 2048 - duration365d = time.Hour * 24 * 365 ) // NewCertificateAuthority creates new certificate and private key for the certificate authority @@ -572,7 +571,7 @@ func NewSignedCert(cfg *certutil.Config, key crypto.Signer, caCert *x509.Certifi IPAddresses: cfg.AltNames.IPs, SerialNumber: serial, NotBefore: caCert.NotBefore, - NotAfter: time.Now().Add(duration365d).UTC(), + NotAfter: time.Now().Add(kubeadmconstants.CertificateValidity).UTC(), KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: cfg.Usages, }