diff --git a/internal/tools/pki/certs/certs.go b/internal/tools/pki/certs/certs.go index d1d2c246..c192a729 100644 --- a/internal/tools/pki/certs/certs.go +++ b/internal/tools/pki/certs/certs.go @@ -20,7 +20,6 @@ import ( "errors" "fmt" "io" - "io/fs" "path/filepath" "sort" "strings" @@ -50,45 +49,49 @@ type Report struct { CAs []CAEntry } -type multiUnwrapper interface { - Unwrap() []error -} - // BuildFullScanReport enumerates all known control-plane certificates and kubeconfig // client certificates, returning a report split into CAs and leaf certs. // certsDir is the PKI directory (e.g. /etc/kubernetes/pki). // kubeconfigDir is the directory containing kubeconfig files (e.g. /etc/kubernetes). // Callers that want the standard layout can pass filepath.Dir(certsDir). func BuildFullScanReport(certsDir, kubeconfigDir string) (*Report, error) { - pkiExpirations, pkiErr := pki.ListCertificateExpirations( - pki.WithCertificatesDir(certsDir), - pki.WithIgnoreReadErrors(), - ) - kcExpirations, kcErr := kubeconfig.ListClientCertificateExpirations( - kubeconfig.WithKubeconfigDir(kubeconfigDir), - kubeconfig.WithIgnoreReadErrors(), - ) + pkiReport := pki.ListCertificateExpirations(pki.WithCertificatesDir(certsDir)) + kcReport := kubeconfig.ListClientCertificateExpirations(kubeconfig.WithKubeconfigDir(kubeconfigDir)) - hasExpirations := len(pkiExpirations) > 0 || len(kcExpirations) > 0 + report := &Report{} + var readErrs []error + + for _, e := range pkiReport.Entries { + switch { + case e.Err == nil: + appendPKIEntry(report, e.Name, e.NotAfter, e.IsCA, e.Authority) + case isCertMissing(e.Err): + // Missing — silent: worker/arbiter nodes dont carry the full PKI. + default: + readErrs = append(readErrs, e.Err) + } + } - err := errors.Join( - parseFullScanError("PKI certificates", certsDir, pkiErr), - parseFullScanError("kubeconfig client certificates", kubeconfigDir, kcErr), - ) - if err != nil && !hasExpirations { - return nil, fmt.Errorf("no control-plane certificates or kubeconfig client certificates found: %w", err) + for _, e := range kcReport.Entries { + switch { + case e.Err == nil: + appendKubeconfigEntry(report, e.File, e.NotAfter) + case isKubeconfigMissing(e.Err): + // Missing — silent. + default: + readErrs = append(readErrs, e.Err) + } } - if err != nil { - return nil, err + if len(readErrs) > 0 { + return nil, fmt.Errorf("listing PKI certificates in %q and kubeconfig client certificates in %q: %w", + certsDir, kubeconfigDir, errors.Join(readErrs...)) } - if !hasExpirations { + if len(report.Certs) == 0 && len(report.CAs) == 0 { return nil, fmt.Errorf("no control-plane certificates or kubeconfig client certificates found in %q and %q", certsDir, kubeconfigDir) } - report := reportFromExpirations(pkiExpirations, kcExpirations) - sort.Slice(report.Certs, func(i, j int) bool { return report.Certs[i].Name < report.Certs[j].Name }) @@ -99,60 +102,14 @@ func BuildFullScanReport(certsDir, kubeconfigDir string) (*Report, error) { return report, nil } -func reportFromExpirations(pkiExpirations []pki.CertificateExpiration, kcExpirations []kubeconfig.ClientCertificateExpiration) *Report { - report := &Report{} - - for _, exp := range pkiExpirations { - if exp.IsCA { - report.CAs = append(report.CAs, CAEntry{ - Name: pkiDisplayName(exp.Name), - Expires: exp.NotAfter, - }) - } else { - report.Certs = append(report.Certs, CertEntry{ - Name: pkiDisplayName(exp.Name), - Expires: exp.NotAfter, - Authority: pkiDisplayName(string(exp.Authority)), - }) - } - } - - for _, exp := range kcExpirations { - report.Certs = append(report.Certs, CertEntry{ - Name: kubeconfigDisplayName(exp.File), - Expires: exp.NotAfter, - Authority: string(pki.CACertName), - }) - } - - return report -} - -func parseFullScanError(subject, dir string, err error) error { - if err == nil || onlyNotExistErrors(err) { - return nil - } - - return fmt.Errorf("listing %s in %q: %w", subject, dir, err) +func isCertMissing(err error) bool { + var missing *pki.MissingError + return errors.As(err, &missing) } -func onlyNotExistErrors(err error) bool { - if err == nil { - return false - } - - var multiErr multiUnwrapper - if errors.As(err, &multiErr) { - for _, nestedErr := range multiErr.Unwrap() { - if !onlyNotExistErrors(nestedErr) { - return false - } - } - - return true - } - - return errors.Is(err, fs.ErrNotExist) +func isKubeconfigMissing(err error) bool { + var missing *kubeconfig.MissingError + return errors.As(err, &missing) } // BuildSingleFileReport inspects a single file at path. @@ -161,28 +118,15 @@ func onlyNotExistErrors(err error) bool { func BuildSingleFileReport(path string) (*Report, error) { kcExp, kcErr := kubeconfig.GetClientCertificateExpiration(path) if kcErr == nil { - return &Report{ - Certs: []CertEntry{{ - Name: kubeconfigDisplayName(kcExp.File), - Expires: kcExp.NotAfter, - Authority: string(pki.CACertName), - }}, - }, nil + report := &Report{} + appendKubeconfigEntry(report, kcExp.File, kcExp.NotAfter) + return report, nil } certExp, certErr := pki.GetCertificateExpiration(path) if certErr == nil { report := &Report{} - if certExp.IsCA { - report.CAs = []CAEntry{{Name: pkiDisplayName(certExp.Name), Expires: certExp.NotAfter}} - } else { - report.Certs = []CertEntry{{ - Name: pkiDisplayName(certExp.Name), - Expires: certExp.NotAfter, - Authority: pkiDisplayName(string(certExp.Authority)), - }} - } - + appendPKIEntry(report, certExp.Name, certExp.NotAfter, certExp.IsCA, certExp.Authority) return report, nil } @@ -192,6 +136,31 @@ func BuildSingleFileReport(path string) (*Report, error) { ) } +// appendPKIEntry appends a leaf cert or CA entry to report depending on IsCA. +func appendPKIEntry(report *Report, name string, notAfter time.Time, isCA bool, authority pki.RootCertName) { + if isCA { + report.CAs = append(report.CAs, CAEntry{ + Name: pkiDisplayName(name), + Expires: notAfter, + }) + return + } + report.Certs = append(report.Certs, CertEntry{ + Name: pkiDisplayName(name), + Expires: notAfter, + Authority: pkiDisplayName(string(authority)), + }) +} + +// appendKubeconfigEntry appends a kubeconfig client cert entry. Always a leaf cert signed by the cluster CA. +func appendKubeconfigEntry(report *Report, file kubeconfig.File, notAfter time.Time) { + report.Certs = append(report.Certs, CertEntry{ + Name: kubeconfigDisplayName(file), + Expires: notAfter, + Authority: string(pki.CACertName), + }) +} + // RenderReport writes the certificate expiration report to w in two sections: // leaf certificates followed by certificate authorities. func RenderReport(w io.Writer, report *Report) { diff --git a/internal/tools/pki/certs/cmd/certs.go b/internal/tools/pki/certs/cmd/certs.go index 550749b3..89cfbfbd 100644 --- a/internal/tools/pki/certs/cmd/certs.go +++ b/internal/tools/pki/certs/cmd/certs.go @@ -35,6 +35,7 @@ func NewCommand() *cobra.Command { } certsCmd.AddCommand(NewCheckCommand()) + certsCmd.AddCommand(NewRenewCommand()) return certsCmd } diff --git a/internal/tools/pki/certs/cmd/renew.go b/internal/tools/pki/certs/cmd/renew.go new file mode 100644 index 00000000..f5e9875f --- /dev/null +++ b/internal/tools/pki/certs/cmd/renew.go @@ -0,0 +1,112 @@ +/* +Copyright 2026 Flant JSC + +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 cmd + +import ( + "fmt" + "net" + + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/tools/pki/certs" +) + +var renewLong = templates.LongDesc(` +Renew control-plane certificates and kubeconfig client certificates. + +Renewal is unconditional — certificates are re-signed regardless of their current +expiration date. + +All Subject/SAN/Usage fields are read from the existing certificate on disk. +No cluster configuration is required — this command is designed for emergency +recovery when the Kubernetes API is unavailable. + +The signing CA must be present on disk and must not be expired. If the CA private +key is absent (external CA), the certificate is skipped. If the CA certificate has +expired, the command stops with an error — renewing leaf certificates against an +expired CA is pointless because chain validation will fail. + +After renewal, restart kube-apiserver, kube-controller-manager, kube-scheduler +and etcd so that the new certificates take effect or reboot the node/kubelet. + +© Flant JSC 2026`) + +func NewRenewCommand() *cobra.Command { + var ( + certsDir string + kubeconfigDir string + dryRun bool + san string + ) + + renewCmd := &cobra.Command{ + Use: "renew (all | PATH)", + Short: "Renew control-plane certificates and kubeconfig client certificates", + Long: renewLong, + Args: cobra.ArbitraryArgs, + Example: " d8 tools pki certs renew all\n" + + " d8 tools pki certs renew all --dry-run\n" + + " d8 tools pki certs renew all --san 192.168.0.5\n" + + " d8 tools pki certs renew /etc/kubernetes/pki/apiserver.crt\n" + + " d8 tools pki certs renew /etc/kubernetes/admin.conf\n" + + " d8 tools pki certs renew all --path /opt/k8s/pki --kubeconfig-dir /opt/k8s", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Usage() + } + if len(args) > 1 { + return fmt.Errorf("accepts exactly one argument (PATH), received %d", len(args)) + } + return certs.RunRenewSingle(cmd.OutOrStdout(), args[0], certsDir, kubeconfigDir, dryRun) + }, + } + + allCmd := &cobra.Command{ + Use: "all", + Short: "Renew all control-plane certificates and kubeconfig client certificates", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + var extraIP net.IP + if san != "" { + extraIP = net.ParseIP(san) + if extraIP == nil { + return fmt.Errorf("--san accepts only an IP address, got %q", san) + } + } + return certs.RunRenewAll(cmd.OutOrStdout(), certsDir, kubeconfigDir, dryRun, extraIP) + }, + } + + addPathFlags := func(c *cobra.Command) { + c.Flags().StringVar(&certsDir, "path", defaultCertificatesDir, + "Directory containing the PKI certificates and CA files") + c.Flags().StringVar(&kubeconfigDir, "kubeconfig-dir", "", + "Directory containing kubeconfig files (defaults to the parent of --path)") + c.Flags().BoolVar(&dryRun, "dry-run", false, + "Run all without writing any files") + } + addPathFlags(renewCmd) + addPathFlags(allCmd) + + // --san is only for a full renewal + allCmd.Flags().StringVar(&san, "san", "", + "New IP SAN to add to serving certificates (e.g. the new master IP)") + + renewCmd.AddCommand(allCmd) + + return renewCmd +} diff --git a/internal/tools/pki/certs/renew.go b/internal/tools/pki/certs/renew.go new file mode 100644 index 00000000..7e743ee5 --- /dev/null +++ b/internal/tools/pki/certs/renew.go @@ -0,0 +1,265 @@ +/* +Copyright 2026 Flant JSC + +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 certs + +import ( + "errors" + "fmt" + "io" + "net" + "path/filepath" + "strings" + "time" + + "github.com/deckhouse/deckhouse/go_lib/controlplane/constants" + "github.com/deckhouse/deckhouse/go_lib/controlplane/kubeconfig" + "github.com/deckhouse/deckhouse/go_lib/controlplane/pki" +) + +// RunRenewAll renews every known control-plane leaf certificate and kubeconfig client certificate. +func RunRenewAll(w io.Writer, certsDir, kubeconfigDirOverride string, dryRun bool, extraIP net.IP) error { + kcDir := effectiveKubeconfigDir(certsDir, kubeconfigDirOverride) + + printDryRunBanner(w, dryRun) + + var warnings []string + usedCAs := map[pki.RootCertName]struct{}{} + + leafOpts := []pki.RenewOption{pki.WithRenewDir(certsDir)} + if dryRun { + leafOpts = append(leafOpts, pki.WithDryRun()) + } + if extraIP != nil { + leafOpts = append(leafOpts, pki.WithRenewExtraIP(extraIP)) + } + + warnings = append(warnings, collectLeafWarnings(w, pki.RenewCertificates(leafOpts...), usedCAs)...) + + kcOpts := []kubeconfig.RenewOption{ + kubeconfig.WithRenewKubeconfigDir(kcDir), + kubeconfig.WithRenewPKIDir(certsDir), + } + if dryRun { + kcOpts = append(kcOpts, kubeconfig.WithDryRun()) + } + + warnings = append(warnings, collectKubeconfigWarnings(w, kubeconfig.RenewClientCerts(kcOpts...), usedCAs)...) + + warnings = append(warnings, checkCAsOutliveRenewed(w, certsDir, usedCAs)...) + + printDryRunFooter(w, dryRun) + + return warningsError(warnings) +} + +// RunRenewSingle renews a single artifact identified by path: it auto-detects whether path is a kubeconfig file or a PEM leaf certificate. +func RunRenewSingle(w io.Writer, path, certsDir, kubeconfigDirOverride string, dryRun bool) error { + if kcExp, err := kubeconfig.GetClientCertificateExpiration(path); err == nil { + return renewSingleKubeconfig(w, path, kcExp.File, certsDir, kubeconfigDirOverride, dryRun) + } + + certExp, certErr := pki.GetCertificateExpiration(path) + if certErr != nil { + return fmt.Errorf("cannot determine file type for %q: not a recognizable kubeconfig or PEM certificate: %w", path, certErr) + } + if certExp.IsCA { + return fmt.Errorf("CA certificate renewal is not supported: %q", path) + } + return renewSingleLeaf(w, path, pki.LeafCertName(certExp.Name), certsDir, dryRun) +} + +func effectiveKubeconfigDir(certsDir, override string) string { + if override != "" { + return override + } + return filepath.Dir(certsDir) +} + +func renewSingleLeaf(w io.Writer, path string, name pki.LeafCertName, pkiDir string, dryRun bool) error { + printDryRunBanner(w, dryRun) + + if !strings.HasPrefix(filepath.Clean(path), filepath.Clean(pkiDir)+string(filepath.Separator)) { + return fmt.Errorf("certificate %q is not under PKI directory %q; use --path to specify the correct directory", path, pkiDir) + } + + opts := []pki.RenewOption{pki.WithRenewDir(pkiDir), pki.WithRenewLeafs(name)} + if dryRun { + opts = append(opts, pki.WithDryRun()) + } + + usedCAs := map[pki.RootCertName]struct{}{} + warnings := collectLeafWarnings(w, pki.RenewCertificates(opts...), usedCAs) + warnings = append(warnings, checkCAsOutliveRenewed(w, pkiDir, usedCAs)...) + + printDryRunFooter(w, dryRun) + + return warningsError(warnings) +} + +func renewSingleKubeconfig(w io.Writer, path string, file kubeconfig.File, certsDirOverride, kcDirOverride string, dryRun bool) error { + printDryRunBanner(w, dryRun) + + kcDir := kcDirOverride + if kcDir == "" { + kcDir = filepath.Dir(filepath.Clean(path)) + } + + opts := []kubeconfig.RenewOption{ + kubeconfig.WithRenewKubeconfigDir(kcDir), + kubeconfig.WithRenewPKIDir(certsDirOverride), + kubeconfig.WithRenewFiles(file), + } + if dryRun { + opts = append(opts, kubeconfig.WithDryRun()) + } + + usedCAs := map[pki.RootCertName]struct{}{} + warnings := collectKubeconfigWarnings(w, kubeconfig.RenewClientCerts(opts...), usedCAs) + warnings = append(warnings, checkCAsOutliveRenewed(w, certsDirOverride, usedCAs)...) + + printDryRunFooter(w, dryRun) + + return warningsError(warnings) +} + +func printDryRunBanner(w io.Writer, dryRun bool) { + if dryRun { + fmt.Fprintln(w, "DRY RUN — no files will be modified") + } +} + +func printDryRunFooter(w io.Writer, dryRun bool) { + if dryRun { + fmt.Fprintln(w, "(dry-run) nothing was written") + } +} + +// checkCAsOutliveRenewed warns when a CA that signed a freshly renewed cert expires sooner than the new (1-year) cert. +func checkCAsOutliveRenewed(w io.Writer, certsDir string, usedCAs map[pki.RootCertName]struct{}) []string { + if len(usedCAs) == 0 { + return nil + } + + cas := make([]pki.RootCertName, 0, len(usedCAs)) + for ca := range usedCAs { + cas = append(cas, ca) + } + + report := pki.ListCertificateExpirations( + pki.WithCertificatesDir(certsDir), + pki.WithRootCertificates(cas...), + ) + + threshold := time.Now().Add(constants.CertificateValidityPeriod) + var warnings []string + for _, e := range report.Entries { + if e.Err != nil { + // A CA read failure here is already surfaced via the renew entry above. + continue + } + if e.NotAfter.Before(threshold) { + line := fmt.Sprintf("WARNING: CA %q expires %s, sooner than the renewed certificates — rotate the CA: %s", + e.Name, e.NotAfter.UTC().Format("Jan 02, 2006 15:04 MST"), e.Path) + fmt.Fprintln(w, line) + warnings = append(warnings, line) + } + } + return warnings +} + +func warningsError(warnings []string) error { + if len(warnings) == 0 { + return nil + } + return fmt.Errorf("%d certificate(s) not renewed; see output above", len(warnings)) +} + +// collectLeafWarnings prints a progress line for every leaf renewal entry +func collectLeafWarnings(w io.Writer, report pki.PKIRenewReport, usedCAs map[pki.RootCertName]struct{}) []string { + var warnings []string + for _, e := range report.Entries { + line := formatLeafEntry(pki.LeafDescription(e.Name), e) + fmt.Fprintln(w, line) + if e.Err != nil { + warnings = append(warnings, line) + continue + } + usedCAs[e.Authority] = struct{}{} + } + return warnings +} + +// collectKubeconfigWarnings mirrors collectLeafWarnings for kubeconfigs +func collectKubeconfigWarnings(w io.Writer, report kubeconfig.KubeconfigRenewReport, usedCAs map[pki.RootCertName]struct{}) []string { + var warnings []string + for _, e := range report.Entries { + line := formatKubeconfigEntry(kubeconfig.FileDescription(e.File), e) + fmt.Fprintln(w, line) + if e.Err != nil { + warnings = append(warnings, line) + continue + } + usedCAs[pki.CACertName] = struct{}{} + } + return warnings +} + +func formatLeafEntry(desc string, e pki.PKIRenewEntry) string { + var ( + missing *pki.MissingError + caMissing *pki.CAMissingError + external *pki.CAExternalError + expired *pki.CAExpiredError + ) + switch { + case e.Err == nil: + return fmt.Sprintf("%s renewed", desc) + case errors.As(e.Err, &missing): + return fmt.Sprintf("MISSING! %s: %s", desc, e.Path) + case errors.As(e.Err, &caMissing): + return fmt.Sprintf("CA %q missing, %s skipped: %s", caMissing.CAName, desc, e.Path) + case errors.As(e.Err, &external): + return fmt.Sprintf("Detected external %s, %s can't be renewed: %s", external.CAName, desc, e.Path) + case errors.As(e.Err, &expired): + return fmt.Sprintf("CA %q expired, %s skipped: %s", expired.CAName, desc, e.Path) + default: + return fmt.Sprintf("%s — error: %s", desc, e.Err.Error()) + } +} + +func formatKubeconfigEntry(desc string, e kubeconfig.KubeconfigRenewEntry) string { + var ( + missing *kubeconfig.MissingError + caMissing *kubeconfig.CAMissingError + external *kubeconfig.CAExternalError + expired *kubeconfig.CAExpiredError + ) + switch { + case e.Err == nil: + return fmt.Sprintf("%s renewed", desc) + case errors.As(e.Err, &missing): + return fmt.Sprintf("MISSING! %s: %s", desc, e.Path) + case errors.As(e.Err, &caMissing): + return fmt.Sprintf("CA %q missing, %s skipped: %s", caMissing.CAName, desc, e.Path) + case errors.As(e.Err, &external): + return fmt.Sprintf("Detected external %s, %s can't be renewed: %s", external.CAName, desc, e.Path) + case errors.As(e.Err, &expired): + return fmt.Sprintf("CA %q expired, %s skipped: %s", expired.CAName, desc, e.Path) + default: + return fmt.Sprintf("%s — error: %s", desc, e.Err.Error()) + } +}