Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 63 additions & 94 deletions internal/tools/pki/certs/certs.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
"errors"
"fmt"
"io"
"io/fs"
"path/filepath"
"sort"
"strings"
Expand Down Expand Up @@ -50,45 +49,49 @@
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))

Check failure on line 58 in internal/tools/pki/certs/certs.go

View workflow job for this annotation

GitHub Actions / Test

assignment mismatch: 1 variable but pki.ListCertificateExpirations returns 2 values

Check failure on line 58 in internal/tools/pki/certs/certs.go

View workflow job for this annotation

GitHub Actions / Build for PR

assignment mismatch: 1 variable but pki.ListCertificateExpirations returns 2 values

Check failure on line 58 in internal/tools/pki/certs/certs.go

View workflow job for this annotation

GitHub Actions / Lint

assignment mismatch: 1 variable but pki.ListCertificateExpirations returns 2 values
kcReport := kubeconfig.ListClientCertificateExpirations(kubeconfig.WithKubeconfigDir(kubeconfigDir))

Check failure on line 59 in internal/tools/pki/certs/certs.go

View workflow job for this annotation

GitHub Actions / Test

assignment mismatch: 1 variable but kubeconfig.ListClientCertificateExpirations returns 2 values

Check failure on line 59 in internal/tools/pki/certs/certs.go

View workflow job for this annotation

GitHub Actions / Build for PR

assignment mismatch: 1 variable but kubeconfig.ListClientCertificateExpirations returns 2 values

Check failure on line 59 in internal/tools/pki/certs/certs.go

View workflow job for this annotation

GitHub Actions / Lint

assignment mismatch: 1 variable but kubeconfig.ListClientCertificateExpirations returns 2 values

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
})
Expand All @@ -99,60 +102,14 @@
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

Check failure on line 106 in internal/tools/pki/certs/certs.go

View workflow job for this annotation

GitHub Actions / Test

undefined: pki.MissingError

Check failure on line 106 in internal/tools/pki/certs/certs.go

View workflow job for this annotation

GitHub Actions / Build for PR

undefined: pki.MissingError

Check failure on line 106 in internal/tools/pki/certs/certs.go

View workflow job for this annotation

GitHub Actions / Lint

undefined: 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

Check failure on line 111 in internal/tools/pki/certs/certs.go

View workflow job for this annotation

GitHub Actions / Test

undefined: kubeconfig.MissingError

Check failure on line 111 in internal/tools/pki/certs/certs.go

View workflow job for this annotation

GitHub Actions / Build for PR

undefined: kubeconfig.MissingError

Check failure on line 111 in internal/tools/pki/certs/certs.go

View workflow job for this annotation

GitHub Actions / Lint

undefined: kubeconfig.MissingError
return errors.As(err, &missing)
}

// BuildSingleFileReport inspects a single file at path.
Expand All @@ -161,28 +118,15 @@
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
}

Expand All @@ -192,6 +136,31 @@
)
}

// 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) {
Expand Down
1 change: 1 addition & 0 deletions internal/tools/pki/certs/cmd/certs.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func NewCommand() *cobra.Command {
}

certsCmd.AddCommand(NewCheckCommand())
certsCmd.AddCommand(NewRenewCommand())

return certsCmd
}
112 changes: 112 additions & 0 deletions internal/tools/pki/certs/cmd/renew.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading