@@ -20,7 +20,6 @@ import (
2020 "errors"
2121 "fmt"
2222 "io"
23- "io/fs"
2423 "path/filepath"
2524 "sort"
2625 "strings"
@@ -50,44 +49,53 @@ type Report struct {
5049 CAs []CAEntry
5150}
5251
53- type multiUnwrapper interface {
54- Unwrap () []error
55- }
56-
5752// BuildFullScanReport enumerates all known control-plane certificates and kubeconfig
5853// client certificates, returning a report split into CAs and leaf certs.
5954// certsDir is the PKI directory (e.g. /etc/kubernetes/pki).
6055// kubeconfigDir is the directory containing kubeconfig files (e.g. /etc/kubernetes).
6156// Callers that want the standard layout can pass filepath.Dir(certsDir).
6257func BuildFullScanReport (certsDir , kubeconfigDir string ) (* Report , error ) {
63- pkiExpirations , pkiErr := pki .ListCertificateExpirations (
64- pki .WithCertificatesDir (certsDir ),
65- pki .WithIgnoreReadErrors (),
66- )
67- kcExpirations , kcErr := kubeconfig .ListClientCertificateExpirations (
68- kubeconfig .WithKubeconfigDir (kubeconfigDir ),
69- kubeconfig .WithIgnoreReadErrors (),
70- )
58+ pkiReport , err := pki .ListCertificateExpirations (pki .WithCertificatesDir (certsDir ))
59+ if err != nil {
60+ return nil , fmt .Errorf ("listing PKI certificates in %q: %w" , certsDir , err )
61+ }
7162
72- hasExpirations := len ( pkiExpirations ) > 0 || len ( kcExpirations ) > 0
63+ kcReport := kubeconfig . ListClientCertificateExpirations ( kubeconfig . WithKubeconfigDir ( kubeconfigDir ))
7364
74- err := errors .Join (
75- parseFullScanError ("PKI certificates" , certsDir , pkiErr ),
76- parseFullScanError ("kubeconfig client certificates" , kubeconfigDir , kcErr ),
77- )
78- if err != nil && ! hasExpirations {
79- return nil , fmt .Errorf ("no control-plane certificates or kubeconfig client certificates found: %w" , err )
65+ report := & Report {}
66+
67+ var readErrs []error
68+
69+ for _ , e := range pkiReport .Entries {
70+ switch {
71+ case e .Err == nil :
72+ appendPKIEntry (report , e .Name , e .NotAfter , e .IsCA , e .Authority )
73+ case isCertMissing (e .Err ):
74+ // Missing — silent: worker/arbiter nodes don't carry the full PKI.
75+ default :
76+ readErrs = append (readErrs , e .Err )
77+ }
8078 }
8179
82- if err != nil {
83- return nil , err
80+ for _ , e := range kcReport .Entries {
81+ switch {
82+ case e .Err == nil :
83+ appendKubeconfigEntry (report , e .File , e .NotAfter )
84+ case isKubeconfigMissing (e .Err ):
85+ // Missing — silent.
86+ default :
87+ readErrs = append (readErrs , e .Err )
88+ }
8489 }
8590
86- if ! hasExpirations {
87- return nil , fmt .Errorf ("no control-plane certificates or kubeconfig client certificates found in %q and %q" , certsDir , kubeconfigDir )
91+ if len (readErrs ) > 0 {
92+ return nil , fmt .Errorf ("listing PKI certificates in %q and kubeconfig client certificates in %q: %w" ,
93+ certsDir , kubeconfigDir , errors .Join (readErrs ... ))
8894 }
8995
90- report := reportFromExpirations (pkiExpirations , kcExpirations )
96+ if len (report .Certs ) == 0 && len (report .CAs ) == 0 {
97+ return nil , fmt .Errorf ("no control-plane certificates or kubeconfig client certificates found in %q and %q" , certsDir , kubeconfigDir )
98+ }
9199
92100 sort .Slice (report .Certs , func (i , j int ) bool {
93101 return report .Certs [i ].Name < report .Certs [j ].Name
@@ -99,60 +107,14 @@ func BuildFullScanReport(certsDir, kubeconfigDir string) (*Report, error) {
99107 return report , nil
100108}
101109
102- func reportFromExpirations (pkiExpirations []pki.CertificateExpiration , kcExpirations []kubeconfig.ClientCertificateExpiration ) * Report {
103- report := & Report {}
104-
105- for _ , exp := range pkiExpirations {
106- if exp .IsCA {
107- report .CAs = append (report .CAs , CAEntry {
108- Name : pkiDisplayName (exp .Name ),
109- Expires : exp .NotAfter ,
110- })
111- } else {
112- report .Certs = append (report .Certs , CertEntry {
113- Name : pkiDisplayName (exp .Name ),
114- Expires : exp .NotAfter ,
115- Authority : pkiDisplayName (string (exp .Authority )),
116- })
117- }
118- }
119-
120- for _ , exp := range kcExpirations {
121- report .Certs = append (report .Certs , CertEntry {
122- Name : kubeconfigDisplayName (exp .File ),
123- Expires : exp .NotAfter ,
124- Authority : string (pki .CACertName ),
125- })
126- }
127-
128- return report
129- }
130-
131- func parseFullScanError (subject , dir string , err error ) error {
132- if err == nil || onlyNotExistErrors (err ) {
133- return nil
134- }
135-
136- return fmt .Errorf ("listing %s in %q: %w" , subject , dir , err )
110+ func isCertMissing (err error ) bool {
111+ var missing * pki.MissingError
112+ return errors .As (err , & missing )
137113}
138114
139- func onlyNotExistErrors (err error ) bool {
140- if err == nil {
141- return false
142- }
143-
144- var multiErr multiUnwrapper
145- if errors .As (err , & multiErr ) {
146- for _ , nestedErr := range multiErr .Unwrap () {
147- if ! onlyNotExistErrors (nestedErr ) {
148- return false
149- }
150- }
151-
152- return true
153- }
154-
155- return errors .Is (err , fs .ErrNotExist )
115+ func isKubeconfigMissing (err error ) bool {
116+ var missing * kubeconfig.MissingError
117+ return errors .As (err , & missing )
156118}
157119
158120// BuildSingleFileReport inspects a single file at path.
@@ -161,27 +123,16 @@ func onlyNotExistErrors(err error) bool {
161123func BuildSingleFileReport (path string ) (* Report , error ) {
162124 kcExp , kcErr := kubeconfig .GetClientCertificateExpiration (path )
163125 if kcErr == nil {
164- return & Report {
165- Certs : []CertEntry {{
166- Name : kubeconfigDisplayName (kcExp .File ),
167- Expires : kcExp .NotAfter ,
168- Authority : string (pki .CACertName ),
169- }},
170- }, nil
126+ report := & Report {}
127+ appendKubeconfigEntry (report , kcExp .File , kcExp .NotAfter )
128+
129+ return report , nil
171130 }
172131
173132 certExp , certErr := pki .GetCertificateExpiration (path )
174133 if certErr == nil {
175134 report := & Report {}
176- if certExp .IsCA {
177- report .CAs = []CAEntry {{Name : pkiDisplayName (certExp .Name ), Expires : certExp .NotAfter }}
178- } else {
179- report .Certs = []CertEntry {{
180- Name : pkiDisplayName (certExp .Name ),
181- Expires : certExp .NotAfter ,
182- Authority : pkiDisplayName (string (certExp .Authority )),
183- }}
184- }
135+ appendPKIEntry (report , certExp .Name , certExp .NotAfter , certExp .IsCA , certExp .Authority )
185136
186137 return report , nil
187138 }
@@ -192,6 +143,33 @@ func BuildSingleFileReport(path string) (*Report, error) {
192143 )
193144}
194145
146+ // appendPKIEntry appends a leaf cert or CA entry to report depending on IsCA.
147+ func appendPKIEntry (report * Report , name string , notAfter time.Time , isCA bool , authority pki.RootCertName ) {
148+ if isCA {
149+ report .CAs = append (report .CAs , CAEntry {
150+ Name : pkiDisplayName (name ),
151+ Expires : notAfter ,
152+ })
153+
154+ return
155+ }
156+
157+ report .Certs = append (report .Certs , CertEntry {
158+ Name : pkiDisplayName (name ),
159+ Expires : notAfter ,
160+ Authority : pkiDisplayName (string (authority )),
161+ })
162+ }
163+
164+ // appendKubeconfigEntry appends a kubeconfig client cert entry. Always a leaf cert signed by the cluster CA.
165+ func appendKubeconfigEntry (report * Report , file kubeconfig.File , notAfter time.Time ) {
166+ report .Certs = append (report .Certs , CertEntry {
167+ Name : kubeconfigDisplayName (file ),
168+ Expires : notAfter ,
169+ Authority : string (pki .CACertName ),
170+ })
171+ }
172+
195173// RenderReport writes the certificate expiration report to w in two sections:
196174// leaf certificates followed by certificate authorities.
197175func RenderReport (w io.Writer , report * Report ) {
0 commit comments