diff --git a/config/crds/troubleshoot.sh_analyzers.yaml b/config/crds/troubleshoot.sh_analyzers.yaml index 7256fe2c..33d7a865 100644 --- a/config/crds/troubleshoot.sh_analyzers.yaml +++ b/config/crds/troubleshoot.sh_analyzers.yaml @@ -1671,6 +1671,55 @@ spec: required: - outcomes type: object + certificatesCollection: + properties: + annotations: + additionalProperties: + type: string + type: object + checkName: + type: string + collectorName: + type: string + exclude: + type: BoolString + outcomes: + items: + properties: + fail: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + pass: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + warn: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + type: object + type: array + strict: + type: BoolString + required: + - outcomes + type: object cpu: properties: annotations: diff --git a/config/crds/troubleshoot.sh_hostcollectors.yaml b/config/crds/troubleshoot.sh_hostcollectors.yaml index 646e674d..c53fe43e 100644 --- a/config/crds/troubleshoot.sh_hostcollectors.yaml +++ b/config/crds/troubleshoot.sh_hostcollectors.yaml @@ -143,6 +143,55 @@ spec: required: - outcomes type: object + certificatesCollection: + properties: + annotations: + additionalProperties: + type: string + type: object + checkName: + type: string + collectorName: + type: string + exclude: + type: BoolString + outcomes: + items: + properties: + fail: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + pass: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + warn: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + type: object + type: array + strict: + type: BoolString + required: + - outcomes + type: object cpu: properties: annotations: @@ -1002,6 +1051,19 @@ spec: - certificatePath - keyPath type: object + certificatesCollection: + properties: + collectorName: + type: string + exclude: + type: BoolString + paths: + items: + type: string + type: array + required: + - paths + type: object copy: properties: collectorName: diff --git a/config/crds/troubleshoot.sh_hostpreflights.yaml b/config/crds/troubleshoot.sh_hostpreflights.yaml index cd96b133..fd653595 100644 --- a/config/crds/troubleshoot.sh_hostpreflights.yaml +++ b/config/crds/troubleshoot.sh_hostpreflights.yaml @@ -143,6 +143,55 @@ spec: required: - outcomes type: object + certificatesCollection: + properties: + annotations: + additionalProperties: + type: string + type: object + checkName: + type: string + collectorName: + type: string + exclude: + type: BoolString + outcomes: + items: + properties: + fail: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + pass: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + warn: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + type: object + type: array + strict: + type: BoolString + required: + - outcomes + type: object cpu: properties: annotations: @@ -1002,6 +1051,19 @@ spec: - certificatePath - keyPath type: object + certificatesCollection: + properties: + collectorName: + type: string + exclude: + type: BoolString + paths: + items: + type: string + type: array + required: + - paths + type: object copy: properties: collectorName: diff --git a/config/crds/troubleshoot.sh_supportbundles.yaml b/config/crds/troubleshoot.sh_supportbundles.yaml index c3d94aa5..ab8b1d33 100644 --- a/config/crds/troubleshoot.sh_supportbundles.yaml +++ b/config/crds/troubleshoot.sh_supportbundles.yaml @@ -10403,6 +10403,55 @@ spec: required: - outcomes type: object + certificatesCollection: + properties: + annotations: + additionalProperties: + type: string + type: object + checkName: + type: string + collectorName: + type: string + exclude: + type: BoolString + outcomes: + items: + properties: + fail: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + pass: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + warn: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + type: object + type: array + strict: + type: BoolString + required: + - outcomes + type: object cpu: properties: annotations: @@ -11262,6 +11311,19 @@ spec: - certificatePath - keyPath type: object + certificatesCollection: + properties: + collectorName: + type: string + exclude: + type: BoolString + paths: + items: + type: string + type: array + required: + - paths + type: object copy: properties: collectorName: diff --git a/pkg/analyze/host_analyzer.go b/pkg/analyze/host_analyzer.go index ce8d8d6c..85c5d4d8 100644 --- a/pkg/analyze/host_analyzer.go +++ b/pkg/analyze/host_analyzer.go @@ -44,6 +44,8 @@ func GetHostAnalyzer(analyzer *troubleshootv1beta2.HostAnalyze) (HostAnalyzer, b return &AnalyzeHostFilesystemPerformance{analyzer.FilesystemPerformance}, true case analyzer.Certificate != nil: return &AnalyzeHostCertificate{analyzer.Certificate}, true + case analyzer.CertificatesCollection != nil: + return &AnalyzeHostCertificatesCollection{analyzer.CertificatesCollection}, true case analyzer.HostServices != nil: return &AnalyzeHostServices{analyzer.HostServices}, true case analyzer.HostOS != nil: diff --git a/pkg/analyze/host_certificates_collection.go b/pkg/analyze/host_certificates_collection.go new file mode 100644 index 00000000..de459daf --- /dev/null +++ b/pkg/analyze/host_certificates_collection.go @@ -0,0 +1,144 @@ +package analyzer + +import ( + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "strconv" + "time" + + "github.com/pkg/errors" + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + "github.com/replicatedhq/troubleshoot/pkg/collect" +) + +type AnalyzeHostCertificatesCollection struct { + hostAnalyzer *troubleshootv1beta2.HostCertificatesCollectionAnalyze +} + +func (a *AnalyzeHostCertificatesCollection) Title() string { + return hostAnalyzerTitleOrDefault(a.hostAnalyzer.AnalyzeMeta, "Host Certificates Collection") +} + +func (a *AnalyzeHostCertificatesCollection) IsExcluded() (bool, error) { + return isExcluded(a.hostAnalyzer.Exclude) +} + +func (a *AnalyzeHostCertificatesCollection) Analyze(getCollectedFileContents func(string) ([]byte, error)) ([]*AnalyzeResult, error) { + hostAnalyzer := a.hostAnalyzer + + collectorName := hostAnalyzer.CollectorName + if collectorName == "" { + collectorName = "certificatesCollection" + } + name := filepath.Join("host-collectors/certificatesCollection", collectorName+".json") + + certificatesInfo, err := getCollectedFileContents(name) + if err != nil { + return nil, errors.Wrap(err, "failed to get contents of certificatesCollection.json") + } + + collectorCertificates := []collect.HostCertificatesCollection{} + if err := json.Unmarshal(certificatesInfo, &collectorCertificates); err != nil { + return nil, errors.Wrap(err, "failed to parse certificatesCollection.json") + } + + var coll resultCollector + + for _, cert := range collectorCertificates { + + source := "" + + if cert.CertificatePath != "" { + source = fmt.Sprintf("obtained from %s", cert.CertificatePath) + } + + if cert.Message == collect.CertMissing { + // return the result immediately if the certificate is missing + coll.push(&AnalyzeResult{ + Title: a.Title(), + IsFail: true, + Message: fmt.Sprintf("Certificate is missing, cannot be %s", source), + }) + } else { + results, err := a.analyzeHostAnalyzeCertificatesResult(cert.CertificateChain, hostAnalyzer.Outcomes, source) + if err != nil { + return nil, err + } + for _, result := range results { + coll.push(result) + } + } + } + + return coll.get(a.Title()), nil +} + +func (a *AnalyzeHostCertificatesCollection) analyzeHostAnalyzeCertificatesResult(certificateChains []collect.ParsedCertificate, outcomes []*troubleshootv1beta2.Outcome, source string) ([]*AnalyzeResult, error) { + var coll resultCollector + var passResults []*AnalyzeResult + when := "" + message := "" + + for _, certChain := range certificateChains { + for _, outcome := range outcomes { + result := &AnalyzeResult{ + Title: a.Title(), + } + + if outcome.Fail != nil { + result.IsFail = true + when = outcome.Fail.When + message = outcome.Fail.Message + } else if outcome.Warn != nil { + result.IsWarn = true + when = outcome.Warn.When + message = outcome.Warn.Message + } else if outcome.Pass != nil { + result.IsPass = true + when = outcome.Pass.When + message = outcome.Pass.Message + } else { + return nil, errors.New("empty outcome") + } + + if result.IsPass && certChain.IsValid { + result.Message = fmt.Sprintf("%s, %s", message, source) + // if the certificate is valid, we need to wait for the warning check whether the certificate is going to expire + passResults = append(passResults, result) + } + + if result.IsFail && !certChain.IsValid { + result.Message = fmt.Sprintf("%s, %s", message, source) + // return the result immediately if the certificate is invalid + coll.push(result) + } + + if result.IsWarn && certChain.IsValid { + warnDate, _ := regexp.Compile(`notAfter \< Today \+ (\d+) days`) + warnMatch := warnDate.FindStringSubmatch(when) + if warnMatch != nil { + warnMatchDays, err := strconv.Atoi(warnMatch[1]) + if err != nil { + return nil, errors.Wrap(err, "failed to convert string to integer") + } + + targetTime := time.Now().AddDate(0, 0, warnMatchDays) + + if targetTime.After(certChain.NotAfter) { + result.Message = fmt.Sprintf("%s in %d days, %s", message, warnMatchDays, source) + // discard passResults if the certificate is going to expire in certain days + passResults = []*AnalyzeResult{} + coll.push(result) + } + } + } + } + // append passResults if the certificate is valid and not going to expire in certain days + for _, passResult := range passResults { + coll.push(passResult) + } + } + return coll.results, nil +} diff --git a/pkg/analyze/host_certificates_collection_test.go b/pkg/analyze/host_certificates_collection_test.go new file mode 100644 index 00000000..5f65bc97 --- /dev/null +++ b/pkg/analyze/host_certificates_collection_test.go @@ -0,0 +1,221 @@ +package analyzer + +import ( + "fmt" + "testing" + "time" + + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAnalyzeHostCertificatesCollection(t *testing.T) { + tests := []struct { + name string + file string + hostAnalyzer *troubleshootv1beta2.HostCertificatesCollectionAnalyze + result []*AnalyzeResult + expectErr bool + }{ + { + name: "certificate-valid", + file: fmt.Sprintf(`[{ + "certificatePath": "apiserver-kubelet-client.crt", + "certificateChain": [ + { + "certificate": "ca.crt", + "subject": "CN=kubernetes", + "subjectAlternativeNames": [ + "kubernetes" + ], + "issuer": "CN=kubernetes", + "notAfter": "%s", + "notBefore": "2023-04-19T00:30:20Z", + "isValid": true, + "isCA": true + } + ], + "message": "cert-valid" + }]`, time.Now().AddDate(1, 0, 0).Format("2006-01-02T15:04:05Z")), + hostAnalyzer: &troubleshootv1beta2.HostCertificatesCollectionAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Pass: &troubleshootv1beta2.SingleOutcome{ + Message: "Certificate is valid", + }, + }, + }, + }, + result: []*AnalyzeResult{ + { + IsPass: true, + IsWarn: false, + IsFail: false, + Title: "Host Certificates Collection", + Message: "Certificate is valid, obtained from apiserver-kubelet-client.crt", + }, + }, + }, + { + name: "certificate-invalid", + file: `[{ + "certificatePath": "apiserver-kubelet-client.crt", + "certificateChain": [ + { + "certificate": "ca.crt", + "subject": "CN=kubernetes", + "subjectAlternativeNames": [ + "kubernetes" + ], + "issuer": "CN=kubernetes", + "notAfter": "2022-04-16T00:30:20Z", + "notBefore": "2021-04-19T00:30:20Z", + "isValid": false, + "isCA": true + } + ], + "message": "cert-invalid" + }]`, + hostAnalyzer: &troubleshootv1beta2.HostCertificatesCollectionAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "notAfter < Today", + Message: "Certificate has expired", + }, + }, + }, + }, + result: []*AnalyzeResult{ + { + IsPass: false, + IsWarn: false, + IsFail: true, + Title: "Host Certificates Collection", + Message: "Certificate has expired, obtained from apiserver-kubelet-client.crt", + }, + }, + }, + { + name: "certificate-about-to-expire", + file: fmt.Sprintf(`[{ + "certificatePath": "apiserver-kubelet-client.crt", + "certificateChain": [ + { + "certificate": "ca.crt", + "subject": "CN=kubernetes", + "subjectAlternativeNames": [ + "kubernetes" + ], + "issuer": "CN=kubernetes", + "notAfter": "%s", + "notBefore": "2021-04-19T00:30:20Z", + "isValid": true, + "isCA": true + } + ], + "message": "cert-valid" + }]`, time.Now().AddDate(0, 0, 5).Format("2006-01-02T15:04:05Z")), + hostAnalyzer: &troubleshootv1beta2.HostCertificatesCollectionAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Warn: &troubleshootv1beta2.SingleOutcome{ + When: "notAfter < Today + 15 days", + Message: "Certificate is about to expire", + }, + }, + }, + }, + result: []*AnalyzeResult{ + { + IsPass: false, + IsWarn: true, + IsFail: false, + Title: "Host Certificates Collection", + Message: "Certificate is about to expire in 15 days, obtained from apiserver-kubelet-client.crt", + }, + }, + }, + { + name: "certificate-missing", + file: `[{ + "certificatePath": "apiserver-kubelet-client.crt", + "message": "cert-missing" + }]`, + hostAnalyzer: &troubleshootv1beta2.HostCertificatesCollectionAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{}, + }, + result: []*AnalyzeResult{ + { + IsPass: false, + IsWarn: false, + IsFail: true, + Title: "Host Certificates Collection", + Message: "Certificate is missing, cannot be obtained from apiserver-kubelet-client.crt", + }, + }, + }, + { + name: "certificate-valid-and-about-to-expire", + file: fmt.Sprintf(`[{ + "certificatePath": "apiserver-kubelet-client.crt", + "certificateChain": [ + { + "certificate": "ca.crt", + "subject": "CN=kubernetes", + "subjectAlternativeNames": [ + "kubernetes" + ], + "issuer": "CN=kubernetes", + "notAfter": "%s", + "notBefore": "2021-04-19T00:30:20Z", + "isValid": true, + "isCA": true + } + ], + "message": "cert-valid" + }]`, time.Now().AddDate(0, 0, 5).Format("2006-01-02T15:04:05Z")), + hostAnalyzer: &troubleshootv1beta2.HostCertificatesCollectionAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Pass: &troubleshootv1beta2.SingleOutcome{ + Message: "Certificate is valid", + }, + Warn: &troubleshootv1beta2.SingleOutcome{ + When: "notAfter < Today + 15 days", + Message: "Certificate is about to expire", + }, + }, + }, + }, + result: []*AnalyzeResult{ + { + IsPass: false, + IsWarn: true, + IsFail: false, + Title: "Host Certificates Collection", + Message: "Certificate is about to expire in 15 days, obtained from apiserver-kubelet-client.crt", + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := require.New(t) + + getCollectedFileContents := func(filename string) ([]byte, error) { + return []byte(test.file), nil + } + + result, err := (&AnalyzeHostCertificatesCollection{test.hostAnalyzer}).Analyze(getCollectedFileContents) + if test.expectErr { + req.Error(err) + } else { + req.NoError(err) + } + + assert.Equal(t, test.result, result) + }) + } +} diff --git a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go index 3fa8c757..f2b5d202 100644 --- a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go +++ b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go @@ -104,6 +104,12 @@ type CertificateAnalyze struct { Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"` } +type HostCertificatesCollectionAnalyze struct { + AnalyzeMeta `json:",inline" yaml:",inline"` + CollectorName string `json:"collectorName,omitempty" yaml:"collectorName,omitempty"` + Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"` +} + type HostServicesAnalyze struct { AnalyzeMeta `json:",inline" yaml:",inline"` CollectorName string `json:"collectorName,omitempty" yaml:"collectorName,omitempty"` @@ -149,6 +155,8 @@ type HostAnalyze struct { Certificate *CertificateAnalyze `json:"certificate,omitempty" yaml:"certificate,omitempty"` + CertificatesCollection *HostCertificatesCollectionAnalyze `json:"certificatesCollection,omitempty" yaml:"certificatesCollection,omitempty"` + HostServices *HostServicesAnalyze `json:"hostServices,omitempty" yaml:"hostServices,omitempty"` HostOS *HostOSAnalyze `json:"hostOS,omitempty" yaml:"hostOS,omitempty"` diff --git a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go index eb9a2bbc..260a416e 100644 --- a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go +++ b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go @@ -169,6 +169,11 @@ type Certificate struct { KeyPath string `json:"keyPath" yaml:"keyPath"` } +type HostCertificatesCollection struct { + HostCollectorMeta `json:",inline" yaml:",inline"` + Paths []string `json:"paths" yaml:"paths"` +} + type HostServices struct { HostCollectorMeta `json:",inline" yaml:",inline"` } @@ -180,28 +185,29 @@ type HostRun struct { } type HostCollect struct { - CPU *CPU `json:"cpu,omitempty" yaml:"cpu,omitempty"` - Memory *Memory `json:"memory,omitempty" yaml:"memory,omitempty"` - TCPLoadBalancer *TCPLoadBalancer `json:"tcpLoadBalancer,omitempty" yaml:"tcpLoadBalancer,omitempty"` - HTTPLoadBalancer *HTTPLoadBalancer `json:"httpLoadBalancer,omitempty" yaml:"httpLoadBalancer,omitempty"` - TCPPortStatus *TCPPortStatus `json:"tcpPortStatus,omitempty" yaml:"tcpPortStatus,omitempty"` - UDPPortStatus *UDPPortStatus `json:"udpPortStatus,omitempty" yaml:"udpPortStatus,omitempty"` - Kubernetes *Kubernetes `json:"kubernetes,omitempty" yaml:"kubernetes,omitempty"` - IPV4Interfaces *IPV4Interfaces `json:"ipv4Interfaces,omitempty" yaml:"ipv4Interfaces,omitempty"` - SubnetAvailable *SubnetAvailable `json:"subnetAvailable,omitempty" yaml:"subnetAvailable,omitempty"` - DiskUsage *DiskUsage `json:"diskUsage,omitempty" yaml:"diskUsage,omitempty"` - HTTP *HostHTTP `json:"http,omitempty" yaml:"http,omitempty"` - Time *HostTime `json:"time,omitempty" yaml:"time,omitempty"` - BlockDevices *HostBlockDevices `json:"blockDevices,omitempty" yaml:"blockDevices,omitempty"` - SystemPackages *HostSystemPackages `json:"systemPackages,omitempty" yaml:"systemPackages,omitempty"` - KernelModules *HostKernelModules `json:"kernelModules,omitempty" yaml:"kernelModules,omitempty"` - TCPConnect *TCPConnect `json:"tcpConnect,omitempty" yaml:"tcpConnect,omitempty"` - FilesystemPerformance *FilesystemPerformance `json:"filesystemPerformance,omitempty" yaml:"filesystemPerformance,omitempty"` - Certificate *Certificate `json:"certificate,omitempty" yaml:"certificate,omitempty"` - HostServices *HostServices `json:"hostServices,omitempty" yaml:"hostServices,omitempty"` - HostOS *HostOS `json:"hostOS,omitempty" yaml:"hostOS,omitempty"` - HostRun *HostRun `json:"run,omitempty" yaml:"run,omitempty"` - HostCopy *HostCopy `json:"copy,omitempty" yaml:"copy,omitempty"` + CPU *CPU `json:"cpu,omitempty" yaml:"cpu,omitempty"` + Memory *Memory `json:"memory,omitempty" yaml:"memory,omitempty"` + TCPLoadBalancer *TCPLoadBalancer `json:"tcpLoadBalancer,omitempty" yaml:"tcpLoadBalancer,omitempty"` + HTTPLoadBalancer *HTTPLoadBalancer `json:"httpLoadBalancer,omitempty" yaml:"httpLoadBalancer,omitempty"` + TCPPortStatus *TCPPortStatus `json:"tcpPortStatus,omitempty" yaml:"tcpPortStatus,omitempty"` + UDPPortStatus *UDPPortStatus `json:"udpPortStatus,omitempty" yaml:"udpPortStatus,omitempty"` + Kubernetes *Kubernetes `json:"kubernetes,omitempty" yaml:"kubernetes,omitempty"` + IPV4Interfaces *IPV4Interfaces `json:"ipv4Interfaces,omitempty" yaml:"ipv4Interfaces,omitempty"` + SubnetAvailable *SubnetAvailable `json:"subnetAvailable,omitempty" yaml:"subnetAvailable,omitempty"` + DiskUsage *DiskUsage `json:"diskUsage,omitempty" yaml:"diskUsage,omitempty"` + HTTP *HostHTTP `json:"http,omitempty" yaml:"http,omitempty"` + Time *HostTime `json:"time,omitempty" yaml:"time,omitempty"` + BlockDevices *HostBlockDevices `json:"blockDevices,omitempty" yaml:"blockDevices,omitempty"` + SystemPackages *HostSystemPackages `json:"systemPackages,omitempty" yaml:"systemPackages,omitempty"` + KernelModules *HostKernelModules `json:"kernelModules,omitempty" yaml:"kernelModules,omitempty"` + TCPConnect *TCPConnect `json:"tcpConnect,omitempty" yaml:"tcpConnect,omitempty"` + FilesystemPerformance *FilesystemPerformance `json:"filesystemPerformance,omitempty" yaml:"filesystemPerformance,omitempty"` + Certificate *Certificate `json:"certificate,omitempty" yaml:"certificate,omitempty"` + CertificatesCollection *HostCertificatesCollection `json:"certificatesCollection,omitempty" yaml:"certificatesCollection,omitempty"` + HostServices *HostServices `json:"hostServices,omitempty" yaml:"hostServices,omitempty"` + HostOS *HostOS `json:"hostOS,omitempty" yaml:"hostOS,omitempty"` + HostRun *HostRun `json:"run,omitempty" yaml:"run,omitempty"` + HostCopy *HostCopy `json:"copy,omitempty" yaml:"copy,omitempty"` } func (c *HostCollect) GetName() string { diff --git a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go index 41b74933..43626b3a 100644 --- a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go +++ b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go @@ -1687,6 +1687,11 @@ func (in *HostAnalyze) DeepCopyInto(out *HostAnalyze) { *out = new(CertificateAnalyze) (*in).DeepCopyInto(*out) } + if in.CertificatesCollection != nil { + in, out := &in.CertificatesCollection, &out.CertificatesCollection + *out = new(HostCertificatesCollectionAnalyze) + (*in).DeepCopyInto(*out) + } if in.HostServices != nil { in, out := &in.HostServices, &out.HostServices *out = new(HostServicesAnalyze) @@ -1725,6 +1730,54 @@ func (in *HostBlockDevices) DeepCopy() *HostBlockDevices { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostCertificatesCollection) DeepCopyInto(out *HostCertificatesCollection) { + *out = *in + in.HostCollectorMeta.DeepCopyInto(&out.HostCollectorMeta) + if in.Paths != nil { + in, out := &in.Paths, &out.Paths + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostCertificatesCollection. +func (in *HostCertificatesCollection) DeepCopy() *HostCertificatesCollection { + if in == nil { + return nil + } + out := new(HostCertificatesCollection) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostCertificatesCollectionAnalyze) DeepCopyInto(out *HostCertificatesCollectionAnalyze) { + *out = *in + in.AnalyzeMeta.DeepCopyInto(&out.AnalyzeMeta) + if in.Outcomes != nil { + in, out := &in.Outcomes, &out.Outcomes + *out = make([]*Outcome, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(Outcome) + (*in).DeepCopyInto(*out) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostCertificatesCollectionAnalyze. +func (in *HostCertificatesCollectionAnalyze) DeepCopy() *HostCertificatesCollectionAnalyze { + if in == nil { + return nil + } + out := new(HostCertificatesCollectionAnalyze) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HostCollect) DeepCopyInto(out *HostCollect) { *out = *in @@ -1818,6 +1871,11 @@ func (in *HostCollect) DeepCopyInto(out *HostCollect) { *out = new(Certificate) (*in).DeepCopyInto(*out) } + if in.CertificatesCollection != nil { + in, out := &in.CertificatesCollection, &out.CertificatesCollection + *out = new(HostCertificatesCollection) + (*in).DeepCopyInto(*out) + } if in.HostServices != nil { in, out := &in.HostServices, &out.HostServices *out = new(HostServices) diff --git a/pkg/collect/certificates.go b/pkg/collect/certificates.go index 190e09f5..4fff00d7 100644 --- a/pkg/collect/certificates.go +++ b/pkg/collect/certificates.go @@ -3,10 +3,8 @@ package collect import ( "bytes" "context" - "crypto/tls" "crypto/x509" "encoding/json" - "encoding/pem" "time" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" @@ -194,27 +192,6 @@ func secretCertCollector(secretName string, namespace string, client kubernetes. return results } -// decode pem and validate data source contains -func decodePem(certPEMBlock []byte) (tls.Certificate, string) { - var cert tls.Certificate - var trackErrors string - var certDERBlock *pem.Block - - for { - certDERBlock, certPEMBlock = pem.Decode(certPEMBlock) - if certDERBlock == nil { - break - } - if certDERBlock.Type == "CERTIFICATE" { - cert.Certificate = append(cert.Certificate, certDERBlock.Bytes) - } - } - if len(cert.Certificate) == 0 { - trackErrors = "No certificates found in" - } - return cert, trackErrors -} - // Certificate parser func CertParser(certName string, certs []byte, currentTime time.Time) ([]ParsedCertificate, []string) { certInfo := []ParsedCertificate{} diff --git a/pkg/collect/host_certificates_collection.go b/pkg/collect/host_certificates_collection.go new file mode 100644 index 00000000..ecb0c89a --- /dev/null +++ b/pkg/collect/host_certificates_collection.go @@ -0,0 +1,106 @@ +package collect + +import ( + "bytes" + "crypto/x509" + "encoding/json" + "io/ioutil" + "path/filepath" + "time" + + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" +) + +const CertMissing = "cert-missing" +const CertValid = "cert-valid" +const CertInvalid = "cert-invalid" + +type CollectHostCertificatesCollection struct { + hostCollector *troubleshootv1beta2.HostCertificatesCollection + BundlePath string +} + +type HostCertificatesCollection struct { + CertificatePath string `json:"certificatePath,omitempty"` + CertificateChain []ParsedCertificate `json:"certificateChain,omitempty"` + Message string `json:"message,omitempty"` +} + +func (c *CollectHostCertificatesCollection) Title() string { + return hostCollectorTitleOrDefault(c.hostCollector.HostCollectorMeta, "Host Certificates Collection") +} + +func (c *CollectHostCertificatesCollection) IsExcluded() (bool, error) { + return isExcluded(c.hostCollector.Exclude) +} + +func (c *CollectHostCertificatesCollection) Collect(progressChan chan<- interface{}) (map[string][]byte, error) { + var results []HostCertificatesCollection + + for _, certPath := range c.hostCollector.Paths { + results = append(results, HostCertsParser(certPath)) + } + + resultsJson, errResultJson := json.MarshalIndent(results, "", "\t") + if errResultJson != nil { + return nil, errResultJson + } + + collectorName := c.hostCollector.CollectorName + if collectorName == "" { + collectorName = "certificatesCollection" + } + name := filepath.Join("host-collectors/certificatesCollection", collectorName+".json") + + output := NewResult() + output.SaveResult(c.BundlePath, name, bytes.NewBuffer(resultsJson)) + + return output, nil +} + +func HostCertsParser(certPath string) HostCertificatesCollection { + var certInfo []ParsedCertificate + + cert, err := ioutil.ReadFile(certPath) + if err != nil { + return HostCertificatesCollection{ + CertificatePath: certPath, + Message: CertMissing, + } + } + + certChain, _ := decodePem(cert) + + if len(certChain.Certificate) == 0 { + return HostCertificatesCollection{ + CertificatePath: certPath, + Message: CertInvalid, + } + } + + for _, cert := range certChain.Certificate { + parsedCert, errParse := x509.ParseCertificate(cert) + if errParse != nil { + return HostCertificatesCollection{ + CertificatePath: certPath, + Message: CertInvalid, + } + } + currentTime := time.Now() + certInfo = append(certInfo, ParsedCertificate{ + Subject: parsedCert.Subject.ToRDNSequence().String(), + SubjectAlternativeNames: parsedCert.DNSNames, + Issuer: parsedCert.Issuer.ToRDNSequence().String(), + NotAfter: parsedCert.NotAfter, + NotBefore: parsedCert.NotBefore, + IsValid: currentTime.Before(parsedCert.NotAfter) && currentTime.After(parsedCert.NotBefore), + IsCA: parsedCert.IsCA, + }) + } + + return HostCertificatesCollection{ + CertificatePath: certPath, + CertificateChain: certInfo, + Message: CertValid, + } +} diff --git a/pkg/collect/host_certificates_collection_test.go b/pkg/collect/host_certificates_collection_test.go new file mode 100644 index 00000000..c374eb42 --- /dev/null +++ b/pkg/collect/host_certificates_collection_test.go @@ -0,0 +1,82 @@ +package collect + +import ( + "path/filepath" + "testing" + "time" + + "github.com/replicatedhq/troubleshoot/internal/testutils" + "github.com/stretchr/testify/assert" +) + +func Test_HostCertParser(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.txt") + tests := []struct { + name string + filePath, certChain string + want HostCertificatesCollection + }{ + { + name: "valid certificate", + filePath: path, + certChain: certChains["validCert"], + want: HostCertificatesCollection{ + CertificatePath: path, + CertificateChain: []ParsedCertificate{ + { + Subject: "CN=envoy", + SubjectAlternativeNames: []string{ + "envoy", + "envoy.projectcontour", + "envoy.projectcontour.svc", + "envoy.projectcontour.svc.cluster.local", + }, + Issuer: "SERIALNUMBER=615929891,CN=Project Contour", + NotAfter: time.Date(2024, time.February, 25, 4, 27, 16, 0, time.UTC), + NotBefore: time.Date(2023, time.February, 24, 4, 27, 18, 0, time.UTC), + IsValid: true, + IsCA: false, + }, + }, + Message: "cert-valid", + }, + }, + { + name: "expired certificate", + filePath: path, + certChain: certChains["expiredCert"], + want: HostCertificatesCollection{ + CertificatePath: path, + CertificateChain: []ParsedCertificate{ + { + Subject: "O=Internet Widgits Pty Ltd,ST=Some-State,C=AU", + SubjectAlternativeNames: nil, + Issuer: "O=Internet Widgits Pty Ltd,ST=Some-State,C=AU", + NotAfter: time.Date(2015, time.September, 12, 21, 52, 2, 0, time.UTC), + NotBefore: time.Date(2012, time.September, 12, 21, 52, 2, 0, time.UTC), + IsValid: false, + IsCA: true, + }, + }, + Message: "cert-valid", + }, + }, + { + name: "missing certificate", + filePath: "", + certChain: "", + want: HostCertificatesCollection{ + CertificatePath: "", + Message: "cert-missing", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testutils.CreateTestFileWithData(t, path, tt.certChain) + got := HostCertsParser(tt.filePath) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/collect/host_collector.go b/pkg/collect/host_collector.go index 44343ea6..f365a0e4 100644 --- a/pkg/collect/host_collector.go +++ b/pkg/collect/host_collector.go @@ -51,6 +51,8 @@ func GetHostCollector(collector *troubleshootv1beta2.HostCollect, bundlePath str return &CollectHostFilesystemPerformance{collector.FilesystemPerformance, bundlePath}, true case collector.Certificate != nil: return &CollectHostCertificate{collector.Certificate, bundlePath}, true + case collector.CertificatesCollection != nil: + return &CollectHostCertificatesCollection{collector.CertificatesCollection, bundlePath}, true case collector.HostServices != nil: return &CollectHostServices{collector.HostServices, bundlePath}, true case collector.HostOS != nil: diff --git a/pkg/collect/util.go b/pkg/collect/util.go index be04b945..b348ba77 100644 --- a/pkg/collect/util.go +++ b/pkg/collect/util.go @@ -6,6 +6,7 @@ import ( "crypto/tls" "crypto/x509" "encoding/json" + "encoding/pem" "fmt" "io" "reflect" @@ -236,3 +237,24 @@ func checkForExistingServiceAccount(client kubernetes.Interface, namespace strin } return nil } + +// decode pem and validate certificate data source contains +func decodePem(certPEMBlock []byte) (tls.Certificate, string) { + var cert tls.Certificate + var trackErrors string + var certDERBlock *pem.Block + + for { + certDERBlock, certPEMBlock = pem.Decode(certPEMBlock) + if certDERBlock == nil { + break + } + if certDERBlock.Type == "CERTIFICATE" { + cert.Certificate = append(cert.Certificate, certDERBlock.Bytes) + } + } + if len(cert.Certificate) == 0 { + trackErrors = "No certificates found in" + } + return cert, trackErrors +} diff --git a/schemas/analyzer-troubleshoot-v1beta2.json b/schemas/analyzer-troubleshoot-v1beta2.json index 1f1eaff9..3f504c3a 100644 --- a/schemas/analyzer-troubleshoot-v1beta2.json +++ b/schemas/analyzer-troubleshoot-v1beta2.json @@ -2535,6 +2535,82 @@ } } }, + "certificatesCollection": { + "type": "object", + "required": [ + "outcomes" + ], + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "checkName": { + "type": "string" + }, + "collectorName": { + "type": "string" + }, + "exclude": { + "oneOf": [{"type": "string"},{"type": "boolean"}] + }, + "outcomes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fail": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "when": { + "type": "string" + } + } + }, + "pass": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "when": { + "type": "string" + } + } + }, + "warn": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "when": { + "type": "string" + } + } + } + } + } + }, + "strict": { + "oneOf": [{"type": "string"},{"type": "boolean"}] + } + } + }, "cpu": { "type": "object", "required": [ diff --git a/schemas/supportbundle-troubleshoot-v1beta2.json b/schemas/supportbundle-troubleshoot-v1beta2.json index 436b4a8d..48b9288a 100644 --- a/schemas/supportbundle-troubleshoot-v1beta2.json +++ b/schemas/supportbundle-troubleshoot-v1beta2.json @@ -9588,6 +9588,82 @@ } } }, + "certificatesCollection": { + "type": "object", + "required": [ + "outcomes" + ], + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "checkName": { + "type": "string" + }, + "collectorName": { + "type": "string" + }, + "exclude": { + "oneOf": [{"type": "string"},{"type": "boolean"}] + }, + "outcomes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fail": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "when": { + "type": "string" + } + } + }, + "pass": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "when": { + "type": "string" + } + } + }, + "warn": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "when": { + "type": "string" + } + } + } + } + } + }, + "strict": { + "oneOf": [{"type": "string"},{"type": "boolean"}] + } + } + }, "cpu": { "type": "object", "required": [ @@ -10920,6 +10996,26 @@ } } }, + "certificatesCollection": { + "type": "object", + "required": [ + "paths" + ], + "properties": { + "collectorName": { + "type": "string" + }, + "exclude": { + "oneOf": [{"type": "string"},{"type": "boolean"}] + }, + "paths": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "copy": { "type": "object", "required": [