├── .github ├── dependabot.yml └── workflows │ ├── release.yaml │ └── test-and-snapshot.yaml ├── .gitignore ├── .goreleaser.yaml ├── Dockerfile ├── Dockerfile.local ├── LICENSE ├── Makefile ├── README.md ├── config └── config.go ├── contrib └── grafana │ └── dashboard.json ├── examples ├── example.prometheus.yml └── ssl_exporter.yaml ├── go.mod ├── go.sum ├── prober ├── file.go ├── file_test.go ├── http_file.go ├── http_file_test.go ├── https.go ├── https_test.go ├── kubeconfig.go ├── kubeconfig_test.go ├── kubernetes.go ├── kubernetes_test.go ├── metrics.go ├── metrics_test.go ├── prober.go ├── tcp.go ├── tcp_test.go ├── test.go ├── tls.go └── tls_test.go ├── ssl_exporter.go ├── ssl_exporter_test.go └── test ├── https.go ├── tcp.go └── test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | 8 | - package-ecosystem: "gomod" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | 14 | - name: Unshallow 15 | run: git fetch --prune --unshallow 16 | 17 | - name: Set up Qemu 18 | uses: docker/setup-qemu-action@v3 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: 1.22.x 24 | 25 | - name: Login to Docker Hub 26 | uses: docker/login-action@v3 27 | with: 28 | username: ${{ secrets.DOCKERHUB_USERNAME }} 29 | password: ${{ secrets.DOCKERHUB_TOKEN }} 30 | 31 | - name: Release with GoReleaser 32 | run: make release 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /.github/workflows/test-and-snapshot.yaml: -------------------------------------------------------------------------------- 1 | name: test-and-snapshot 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: 1.22.x 21 | 22 | - name: Test 23 | run: make test 24 | 25 | snapshot: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - name: Set up Qemu 31 | uses: docker/setup-qemu-action@v3 32 | 33 | - name: Set up Go 34 | uses: actions/setup-go@v5 35 | with: 36 | go-version: 1.22.x 37 | 38 | - name: Build release snapshot 39 | run: make snapshot 40 | 41 | - name: Archive release snapshot 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: release-snapshot 45 | path: | 46 | bin/*.tar.gz 47 | bin/*.txt 48 | bin/*.yaml 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # KetBrains IDEs files 15 | .idea 16 | 17 | ssl_exporter -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | dist: bin 2 | builds: 3 | - binary: ssl_exporter 4 | env: 5 | - CGO_ENABLED=0 6 | goos: 7 | - linux 8 | - darwin 9 | - windows 10 | goarch: 11 | - "386" 12 | - amd64 13 | - arm 14 | - arm64 15 | - mips64le 16 | flags: 17 | - -v 18 | ldflags: | 19 | -X github.com/prometheus/common/version.Version={{.Version}} 20 | -X github.com/prometheus/common/version.Revision={{.Commit}} 21 | -X github.com/prometheus/common/version.Branch={{.Env.APP_BRANCH}} 22 | -X github.com/prometheus/common/version.BuildUser={{.Env.APP_USER}}@{{.Env.APP_HOST}} 23 | -X github.com/prometheus/common/version.BuildDate={{.Date}} 24 | release: 25 | github: 26 | owner: ribbybibby 27 | name: ssl_exporter 28 | dockers: 29 | - image_templates: 30 | - "{{.Env.APP_DOCKER_IMAGE_NAME}}:{{.Version}}-amd64" 31 | dockerfile: Dockerfile 32 | use: buildx 33 | build_flag_templates: 34 | - "--pull" 35 | - "--label=org.opencontainers.image.created={{.Date}}" 36 | - "--label=org.opencontainers.image.name={{.ProjectName}}" 37 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 38 | - "--label=org.opencontainers.image.version={{.Version}}" 39 | - "--label=org.opencontainers.image.source={{.GitURL}}" 40 | - "--platform=linux/amd64" 41 | - image_templates: 42 | - "{{.Env.APP_DOCKER_IMAGE_NAME}}:{{.Version}}-arm64" 43 | dockerfile: Dockerfile 44 | use: buildx 45 | build_flag_templates: 46 | - "--pull" 47 | - "--label=org.opencontainers.image.created={{.Date}}" 48 | - "--label=org.opencontainers.image.name={{.ProjectName}}" 49 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 50 | - "--label=org.opencontainers.image.version={{.Version}}" 51 | - "--label=org.opencontainers.image.source={{.GitURL}}" 52 | - "--platform=linux/arm64" 53 | goarch: arm64 54 | docker_manifests: 55 | - name_template: "{{.Env.APP_DOCKER_IMAGE_NAME}}:{{.Version}}" 56 | image_templates: 57 | - "{{.Env.APP_DOCKER_IMAGE_NAME}}:{{.Version}}-amd64" 58 | - "{{.Env.APP_DOCKER_IMAGE_NAME}}:{{.Version}}-arm64" 59 | - name_template: "{{.Env.APP_DOCKER_IMAGE_NAME}}:latest" 60 | image_templates: 61 | - "{{.Env.APP_DOCKER_IMAGE_NAME}}:{{.Version}}-amd64" 62 | - "{{.Env.APP_DOCKER_IMAGE_NAME}}:{{.Version}}-arm64" 63 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.15 as build 2 | RUN apk --update add ca-certificates 3 | RUN echo "ssl:*:100:ssl" > /tmp/group && \ 4 | echo "ssl:*:100:100::/:/ssl_exporter" > /tmp/passwd 5 | 6 | 7 | FROM scratch 8 | 9 | COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 10 | COPY --from=build /tmp/group \ 11 | /tmp/passwd \ 12 | /etc/ 13 | COPY ssl_exporter / 14 | 15 | USER ssl:ssl 16 | EXPOSE 9219/tcp 17 | ENTRYPOINT ["/ssl_exporter"] 18 | -------------------------------------------------------------------------------- /Dockerfile.local: -------------------------------------------------------------------------------- 1 | FROM golang:1.18-buster AS build 2 | 3 | ADD . /tmp/ssl_exporter 4 | 5 | RUN cd /tmp/ssl_exporter && \ 6 | echo "ssl:*:100:ssl" > group && \ 7 | echo "ssl:*:100:100::/:/ssl_exporter" > passwd && \ 8 | make 9 | 10 | 11 | FROM scratch 12 | 13 | COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 14 | COPY --from=build /tmp/ssl_exporter/group \ 15 | /tmp/ssl_exporter/passwd \ 16 | /etc/ 17 | COPY --from=build /tmp/ssl_exporter/ssl_exporter / 18 | 19 | USER ssl:ssl 20 | EXPOSE 9219/tcp 21 | ENTRYPOINT ["/ssl_exporter"] 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOPATH := $(shell go env GOPATH) 2 | 3 | BIN_DIR ?= $(shell pwd)/bin 4 | BIN_NAME ?= ssl_exporter$(shell go env GOEXE) 5 | DOCKER_IMAGE_NAME ?= ssl-exporter 6 | DOCKER_IMAGE_TAG ?= $(subst /,-,$(shell git rev-parse --abbrev-ref HEAD)) 7 | 8 | # Race detector is only supported on amd64. 9 | RACE := $(shell test $$(go env GOARCH) != "amd64" || (echo "-race")) 10 | 11 | export APP_HOST ?= $(shell hostname) 12 | export APP_BRANCH ?= $(shell git describe --all --contains --dirty HEAD) 13 | export APP_USER := $(shell id -u --name) 14 | export APP_DOCKER_IMAGE_NAME := ribbybibby/$(DOCKER_IMAGE_NAME) 15 | 16 | all: clean format vet build test 17 | 18 | style: 19 | @echo ">> checking code style" 20 | @! gofmt -s -d . | grep '^' 21 | 22 | test: 23 | @echo ">> running tests" 24 | go test -short -v $(RACE) ./... 25 | 26 | format: 27 | @echo ">> formatting code" 28 | @go fmt ./... 29 | 30 | vet: 31 | @echo ">> vetting code" 32 | @go vet $(pkgs) 33 | 34 | build: 35 | @echo ">> building binary" 36 | @CGO_ENABLED=0 go build -v \ 37 | -ldflags "-X github.com/prometheus/common/version.Version=dev \ 38 | -X github.com/prometheus/common/version.Revision=$(shell git rev-parse HEAD) \ 39 | -X github.com/prometheus/common/version.Branch=$(APP_BRANCH) \ 40 | -X github.com/prometheus/common/version.BuildUser=$(APP_USER)@$(APP_HOST) \ 41 | -X github.com/prometheus/common/version.BuildDate=$(shell date '+%Y%m%d-%H:%M:%S') \ 42 | " \ 43 | -o $(BIN_NAME) . 44 | 45 | docker: 46 | @echo ">> building docker image" 47 | @docker build -t "$(DOCKER_IMAGE_NAME):$(DOCKER_IMAGE_TAG)" -f Dockerfile.local . 48 | 49 | $(GOPATH)/bin/goreleaser: 50 | @go install github.com/goreleaser/goreleaser@v1.2.2 51 | 52 | snapshot: $(GOPATH)/bin/goreleaser 53 | @echo ">> building snapshot" 54 | @$(GOPATH)/bin/goreleaser --snapshot --skip-sign --skip-validate --skip-publish --rm-dist 55 | 56 | release: $(GOPATH)/bin/goreleaser 57 | @$(GOPATH)/bin/goreleaser release 58 | 59 | clean: 60 | @echo ">> removing build artifacts" 61 | @rm -Rf $(BIN_DIR) 62 | @rm -Rf $(BIN_NAME) 63 | 64 | .PHONY: all style test format vet build docker snapshot release clean 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SSL Certificate Exporter 2 | 3 | Exports metrics for certificates collected from various sources: 4 | - [TCP probes](#tcp) 5 | - [HTTPS probes](#https) 6 | - [PEM files](#file) 7 | - [Remote PEM files](#http_file) 8 | - [Kubernetes secrets](#kubernetes) 9 | - [Kubeconfig files](#kubeconfig) 10 | 11 | The metrics are labelled with fields from the certificate, which allows for 12 | informational dashboards and flexible alert routing. 13 | 14 | ## Building 15 | 16 | make 17 | ./ssl_exporter 18 | 19 | Similarly to the blackbox_exporter, visiting 20 | [http://localhost:9219/probe?target=example.com:443](http://localhost:9219/probe?target=example.com:443) 21 | will return certificate metrics for example.com. The `ssl_probe_success` 22 | metric indicates if the probe has been successful. 23 | 24 | ### Docker 25 | 26 | docker run -p 9219:9219 ribbybibby/ssl-exporter:latest 27 | 28 | ### Release process 29 | 30 | - Create a release in Github with a semver tag and GH actions will: 31 | - Add a changelog 32 | - Upload binaries 33 | - Build and push a Docker image 34 | 35 | ## Usage 36 | 37 | ``` 38 | usage: ssl_exporter [] 39 | 40 | Flags: 41 | -h, --help Show context-sensitive help (also try --help-long and 42 | --help-man). 43 | --web.listen-address=":9219" 44 | Address to listen on for web interface and telemetry. 45 | --web.metrics-path="/metrics" 46 | Path under which to expose metrics 47 | --web.probe-path="/probe" Path under which to expose the probe endpoint 48 | --config.file="" SSL exporter configuration file 49 | --log.level="info" Only log messages with the given severity or above. Valid 50 | levels: [debug, info, warn, error, fatal] 51 | --log.format="logger:stderr" 52 | Set the log target and format. Example: 53 | "logger:syslog?appname=bob&local=7" or 54 | "logger:stdout?json=true" 55 | --version Show application version. 56 | ``` 57 | 58 | ## Metrics 59 | 60 | | Metric | Meaning | Labels | Probers | 61 | | ------------------------------ | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------- | 62 | | ssl_cert_not_after | The date after which a peer certificate expires. Expressed as a Unix Epoch Time. | serial_no, issuer_cn, cn, dnsnames, ips, emails, ou | tcp, https | 63 | | ssl_cert_not_before | The date before which a peer certificate is not valid. Expressed as a Unix Epoch Time. | serial_no, issuer_cn, cn, dnsnames, ips, emails, ou | tcp, https | 64 | | ssl_file_cert_not_after | The date after which a certificate found by the file prober expires. Expressed as a Unix Epoch Time. | file, serial_no, issuer_cn, cn, dnsnames, ips, emails, ou | file | 65 | | ssl_file_cert_not_before | The date before which a certificate found by the file prober is not valid. Expressed as a Unix Epoch Time. | file, serial_no, issuer_cn, cn, dnsnames, ips, emails, ou | file | 66 | | ssl_kubernetes_cert_not_after | The date after which a certificate found by the kubernetes prober expires. Expressed as a Unix Epoch Time. | namespace, secret, key, serial_no, issuer_cn, cn, dnsnames, ips, emails, ou | kubernetes | 67 | | ssl_kubernetes_cert_not_before | The date before which a certificate found by the kubernetes prober is not valid. Expressed as a Unix Epoch Time. | namespace, secret, key, serial_no, issuer_cn, cn, dnsnames, ips, emails, ou | kubernetes | 68 | | ssl_kubeconfig_cert_not_after | The date after which a certificate found by the kubeconfig prober expires. Expressed as a Unix Epoch Time. | kubeconfig, name, type, serial_no, issuer_cn, cn, dnsnames, ips, emails, ou | kubeconfig | 69 | | ssl_kubeconfig_cert_not_before | The date before which a certificate found by the kubeconfig prober is not valid. Expressed as a Unix Epoch Time. | kubeconfig, name, type, serial_no, issuer_cn, cn, dnsnames, ips, emails, ou | kubeconfig | 70 | | ssl_ocsp_response_next_update | The nextUpdate value in the OCSP response. Expressed as a Unix Epoch Time | | tcp, https | 71 | | ssl_ocsp_response_produced_at | The producedAt value in the OCSP response. Expressed as a Unix Epoch Time | | tcp, https | 72 | | ssl_ocsp_response_revoked_at | The revocationTime value in the OCSP response. Expressed as a Unix Epoch Time | | tcp, https | 73 | | ssl_ocsp_response_status | The status in the OCSP response. 0=Good 1=Revoked 2=Unknown | | tcp, https | 74 | | ssl_ocsp_response_stapled | Does the connection state contain a stapled OCSP response? Boolean. | | tcp, https | 75 | | ssl_ocsp_response_this_update | The thisUpdate value in the OCSP response. Expressed as a Unix Epoch Time | | tcp, https | 76 | | ssl_probe_success | Was the probe successful? Boolean. | | all | 77 | | ssl_prober | The prober used by the exporter to connect to the target. Boolean. | prober | all | 78 | | ssl_tls_version_info | The TLS version used. Always 1. | version | tcp, https | 79 | | ssl_verified_cert_not_after | The date after which a certificate in the verified chain expires. Expressed as a Unix Epoch Time. | chain_no, serial_no, issuer_cn, cn, dnsnames, ips, emails, ou | tcp, https | 80 | | ssl_verified_cert_not_before | The date before which a certificate in the verified chain is not valid. Expressed as a Unix Epoch Time. | chain_no, serial_no, issuer_cn, cn, dnsnames, ips, emails, ou | tcp, https | 81 | 82 | ## Configuration 83 | 84 | ### TCP 85 | 86 | Just like with the blackbox_exporter, you should pass the targets to a single 87 | instance of the exporter in a scrape config with a clever bit of relabelling. 88 | This allows you to leverage service discovery and keeps configuration 89 | centralised to your Prometheus config. 90 | 91 | ```yml 92 | scrape_configs: 93 | - job_name: "ssl" 94 | metrics_path: /probe 95 | static_configs: 96 | - targets: 97 | - example.com:443 98 | - prometheus.io:443 99 | relabel_configs: 100 | - source_labels: [__address__] 101 | target_label: __param_target 102 | - source_labels: [__param_target] 103 | target_label: instance 104 | - target_label: __address__ 105 | replacement: 127.0.0.1:9219 # SSL exporter. 106 | ``` 107 | 108 | ### HTTPS 109 | 110 | By default the exporter will make a TCP connection to the target. This will be 111 | suitable for most cases but if you want to take advantage of http proxying you 112 | can use a HTTPS client by setting the `https` module parameter: 113 | 114 | ```yml 115 | scrape_configs: 116 | - job_name: "ssl" 117 | metrics_path: /probe 118 | params: 119 | module: ["https"] # <----- 120 | static_configs: 121 | - targets: 122 | - example.com:443 123 | - prometheus.io:443 124 | relabel_configs: 125 | - source_labels: [__address__] 126 | target_label: __param_target 127 | - source_labels: [__param_target] 128 | target_label: instance 129 | - target_label: __address__ 130 | replacement: 127.0.0.1:9219 131 | ``` 132 | 133 | This will use proxy servers discovered by the environment variables `HTTP_PROXY`, 134 | `HTTPS_PROXY` and `ALL_PROXY`. Or, you can set the `https.proxy_url` option in the module 135 | configuration. 136 | 137 | The latter takes precedence. 138 | 139 | ### File 140 | 141 | The `file` prober exports `ssl_file_cert_not_after` and 142 | `ssl_file_cert_not_before` for PEM encoded certificates found in local files. 143 | 144 | Files local to the exporter can be scraped by providing them as the target 145 | parameter: 146 | 147 | ``` 148 | curl "localhost:9219/probe?module=file&target=/etc/ssl/cert.pem" 149 | ``` 150 | 151 | The target parameter supports globbing (as provided by the 152 | [doublestar](https://github.com/bmatcuk/doublestar) package), 153 | which allows you to capture multiple files at once: 154 | 155 | ``` 156 | curl "localhost:9219/probe?module=file&target=/etc/ssl/**/*.pem" 157 | ``` 158 | 159 | One specific usage of this prober could be to run the exporter as a DaemonSet in 160 | Kubernetes and then scrape each instance to check the expiry of certificates on 161 | each node: 162 | 163 | ```yml 164 | scrape_configs: 165 | - job_name: "ssl-kubernetes-file" 166 | metrics_path: /probe 167 | params: 168 | module: ["file"] 169 | target: ["/etc/kubernetes/**/*.crt"] 170 | kubernetes_sd_configs: 171 | - role: node 172 | relabel_configs: 173 | - source_labels: [__address__] 174 | regex: ^(.*):(.*)$ 175 | target_label: __address__ 176 | replacement: ${1}:9219 177 | ``` 178 | 179 | ### HTTP File 180 | 181 | The `http_file` prober exports `ssl_cert_not_after` and 182 | `ssl_cert_not_before` for PEM encoded certificates found at the 183 | specified URL. 184 | 185 | ``` 186 | curl "localhost:9219/probe?module=http_file&target=https://www.paypalobjects.com/marketing/web/logos/paypal_com.pem" 187 | ``` 188 | 189 | Here's a sample Prometheus configuration: 190 | 191 | ```yml 192 | scrape_configs: 193 | - job_name: 'ssl-http-files' 194 | metrics_path: /probe 195 | params: 196 | module: ["http_file"] 197 | static_configs: 198 | - targets: 199 | - 'https://www.paypalobjects.com/marketing/web/logos/paypal_com.pem' 200 | - 'https://d3frv9g52qce38.cloudfront.net/amazondefault/amazon_web_services_inc_2024.pem' 201 | relabel_configs: 202 | - source_labels: [__address__] 203 | target_label: __param_target 204 | - source_labels: [__param_target] 205 | target_label: instance 206 | - target_label: __address__ 207 | replacement: 127.0.0.1:9219 208 | ``` 209 | 210 | For proxying to the target resource, this prober will use proxy servers 211 | discovered in the environment variables `HTTP_PROXY`, `HTTPS_PROXY` and 212 | `ALL_PROXY`. Or, you can set the `http_file.proxy_url` option in the module 213 | configuration. 214 | 215 | The latter takes precedence. 216 | 217 | ### Kubernetes 218 | 219 | The `kubernetes` prober exports `ssl_kubernetes_cert_not_after` and 220 | `ssl_kubernetes_cert_not_before` for PEM encoded certificates found in secrets 221 | of type `kubernetes.io/tls`. 222 | 223 | Provide the namespace and name of the secret in the form `/` as 224 | the target: 225 | 226 | ``` 227 | curl "localhost:9219/probe?module=kubernetes&target=kube-system/secret-name" 228 | ``` 229 | 230 | Both the namespace and name portions of the target support glob matching (as provided by the 231 | [doublestar](https://github.com/bmatcuk/doublestar) package): 232 | 233 | ``` 234 | curl "localhost:9219/probe?module=kubernetes&target=kube-system/*" 235 | 236 | ``` 237 | 238 | ``` 239 | curl "localhost:9219/probe?module=kubernetes&target=*/*" 240 | 241 | ``` 242 | 243 | The exporter retrieves credentials and context configuration from the following 244 | sources in the following order: 245 | 246 | - The `kubeconfig` path in the module configuration 247 | - The `$KUBECONFIG` environment variable 248 | - The default configuration file (`$HOME/.kube/config`) 249 | - The in-cluster environment, if running in a pod 250 | 251 | ```yml 252 | - job_name: "ssl-kubernetes" 253 | metrics_path: /probe 254 | params: 255 | module: ["kubernetes"] 256 | static_configs: 257 | - targets: 258 | - "test-namespace/nginx-cert" 259 | relabel_configs: 260 | - source_labels: [ __address__ ] 261 | target_label: __param_target 262 | - source_labels: [ __param_target ] 263 | target_label: instance 264 | - target_label: __address__ 265 | replacement: 127.0.0.1:9219 266 | ``` 267 | 268 | ### Kubeconfig 269 | 270 | The `kubeconfig` prober exports `ssl_kubeconfig_cert_not_after` and 271 | `ssl_kubeconfig_cert_not_before` for PEM encoded certificates found in the specified kubeconfig file. 272 | 273 | Kubeconfigs local to the exporter can be scraped by providing them as the target 274 | parameter: 275 | 276 | ``` 277 | curl "localhost:9219/probe?module=kubeconfig&target=/etc/kubernetes/admin.conf" 278 | ``` 279 | 280 | One specific usage of this prober could be to run the exporter as a DaemonSet in 281 | Kubernetes and then scrape each instance to check the expiry of certificates on 282 | each node: 283 | 284 | ```yml 285 | scrape_configs: 286 | - job_name: "ssl-kubernetes-kubeconfig" 287 | metrics_path: /probe 288 | params: 289 | module: ["kubeconfig"] 290 | target: ["/etc/kubernetes/admin.conf"] 291 | kubernetes_sd_configs: 292 | - role: node 293 | relabel_configs: 294 | - source_labels: [__address__] 295 | regex: ^(.*):(.*)$ 296 | target_label: __address__ 297 | replacement: ${1}:9219 298 | ``` 299 | 300 | ## Configuration file 301 | 302 | You can provide further module configuration by providing the path to a 303 | configuration file with `--config.file`. The file is written in yaml format, 304 | defined by the schema below. 305 | 306 | ``` 307 | # The default module to use. If omitted, then the module must be provided by the 308 | # 'module' query parameter 309 | default_module: 310 | 311 | # Module configuration 312 | modules: [] 313 | ``` 314 | 315 | ### \ 316 | 317 | ``` 318 | # The type of probe (https, tcp, file, kubernetes, kubeconfig) 319 | prober: 320 | 321 | # The probe target. If set, then the 'target' query parameter is ignored. 322 | # If omitted, then the 'target' query parameter is required. 323 | target: 324 | 325 | # How long the probe will wait before giving up. 326 | [ timeout: ] 327 | 328 | # Configuration for TLS 329 | [ tls_config: ] 330 | 331 | # The specific probe configuration 332 | [ https: ] 333 | [ tcp: ] 334 | [ kubernetes: ] 335 | [ http_file: ] 336 | ``` 337 | 338 | ### 339 | 340 | ``` 341 | # Disable target certificate validation. 342 | [ insecure_skip_verify: | default = false ] 343 | 344 | # Configure TLS renegotiation support. 345 | # Valid options: never, once, freely 346 | [ renegotiation: | default = never ] 347 | 348 | # The CA cert to use for the targets. 349 | [ ca_file: ] 350 | 351 | # The client cert file for the targets. 352 | [ cert_file: ] 353 | 354 | # The client key file for the targets. 355 | [ key_file: ] 356 | 357 | # Used to verify the hostname for the targets. 358 | [ server_name: ] 359 | ``` 360 | 361 | ### 362 | 363 | ``` 364 | # HTTP proxy server to use to connect to the targets. 365 | [ proxy_url: ] 366 | ``` 367 | 368 | ### 369 | 370 | ``` 371 | # Use the STARTTLS command before starting TLS for those protocols that support it (smtp, ftp, imap, pop3, postgres) 372 | [ starttls: ] 373 | ``` 374 | 375 | ### 376 | 377 | ``` 378 | # The path of a kubeconfig file to configure the probe 379 | [ kubeconfig: ] 380 | ``` 381 | 382 | ### 383 | 384 | ``` 385 | # HTTP proxy server to use to connect to the targets. 386 | [ proxy_url: ] 387 | ``` 388 | 389 | ## Example Queries 390 | 391 | Certificates that expire within 7 days: 392 | 393 | ``` 394 | ssl_cert_not_after - time() < 86400 * 7 395 | ``` 396 | 397 | Wildcard certificates that are expiring: 398 | 399 | ``` 400 | ssl_cert_not_after{cn=~"\*.*"} - time() < 86400 * 7 401 | ``` 402 | 403 | Certificates that expire within 7 days in the verified chain that expires 404 | latest: 405 | 406 | ``` 407 | ssl_verified_cert_not_after{chain_no="0"} - time() < 86400 * 7 408 | ``` 409 | 410 | Number of certificates presented by the server: 411 | 412 | ``` 413 | count(ssl_cert_not_after) by (instance) 414 | ``` 415 | 416 | Identify failed probes: 417 | 418 | ``` 419 | ssl_probe_success == 0 420 | ``` 421 | 422 | ## Peer Certificates vs Verified Chain Certificates 423 | 424 | Metrics are exported for the `NotAfter` and `NotBefore` fields for peer 425 | certificates as well as for the verified chain that is 426 | constructed by the client. 427 | 428 | The former only includes the certificates that are served explicitly by the 429 | target, while the latter can contain multiple chains of trust that are 430 | constructed from root certificates held by the client to the target's server 431 | certificate. 432 | 433 | This has important implications when monitoring certificate expiry. 434 | 435 | For instance, it may be the case that `ssl_cert_not_after` reports that the root 436 | certificate served by the target is expiring soon even though clients can form 437 | another, much longer lived, chain of trust using another valid root certificate 438 | held locally. In this case, you may want to use `ssl_verified_cert_not_after` to 439 | alert on expiry instead, as this will contain the chain that the client actually 440 | constructs: 441 | 442 | ``` 443 | ssl_verified_cert_not_after{chain_no="0"} - time() < 86400 * 7 444 | ``` 445 | 446 | Each chain is numbered by the exporter in reverse order of expiry, so that 447 | `chain_no="0"` is the chain that will expire the latest. Therefore the query 448 | above will only alert when the chain of trust between the exporter and the 449 | target is truly nearing expiry. 450 | 451 | It's very important to note that a query of this kind only represents the chain 452 | of trust between the exporter and the target. Genuine clients may hold different 453 | root certs than the exporter and therefore have different verified chains of 454 | trust. 455 | 456 | ## Grafana 457 | 458 | You can find a simple dashboard [here](contrib/grafana/dashboard.json) that tracks 459 | certificate expiration dates and target connection errors. 460 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "net/url" 7 | "os" 8 | "time" 9 | 10 | pconfig "github.com/prometheus/common/config" 11 | yaml "gopkg.in/yaml.v3" 12 | ) 13 | 14 | var ( 15 | // DefaultConfig is the default configuration that is used when no 16 | // configuration file is provided 17 | DefaultConfig = &Config{ 18 | DefaultModule: "tcp", 19 | Modules: map[string]Module{ 20 | "tcp": { 21 | Prober: "tcp", 22 | }, 23 | "http": { 24 | Prober: "https", 25 | }, 26 | "https": { 27 | Prober: "https", 28 | }, 29 | "file": { 30 | Prober: "file", 31 | }, 32 | "http_file": { 33 | Prober: "http_file", 34 | }, 35 | "kubernetes": { 36 | Prober: "kubernetes", 37 | }, 38 | "kubeconfig": { 39 | Prober: "kubeconfig", 40 | }, 41 | }, 42 | } 43 | ) 44 | 45 | // LoadConfig loads configuration from a file 46 | func LoadConfig(confFile string) (*Config, error) { 47 | var c *Config 48 | 49 | yamlReader, err := os.Open(confFile) 50 | if err != nil { 51 | return c, fmt.Errorf("error reading config file: %s", err) 52 | } 53 | defer yamlReader.Close() 54 | decoder := yaml.NewDecoder(yamlReader) 55 | decoder.KnownFields(true) 56 | 57 | if err = decoder.Decode(&c); err != nil { 58 | return c, fmt.Errorf("error parsing config file: %s", err) 59 | } 60 | 61 | return c, nil 62 | } 63 | 64 | // Config configures the exporter 65 | type Config struct { 66 | DefaultModule string `yaml:"default_module"` 67 | Modules map[string]Module `yaml:"modules"` 68 | } 69 | 70 | // Module configures a prober 71 | type Module struct { 72 | Prober string `yaml:"prober,omitempty"` 73 | Target string `yaml:"target,omitempty"` 74 | Timeout time.Duration `yaml:"timeout,omitempty"` 75 | TLSConfig TLSConfig `yaml:"tls_config,omitempty"` 76 | HTTPS HTTPSProbe `yaml:"https,omitempty"` 77 | TCP TCPProbe `yaml:"tcp,omitempty"` 78 | Kubernetes KubernetesProbe `yaml:"kubernetes,omitempty"` 79 | HTTPFile HTTPFileProbe `yaml:"http_file,omitempty"` 80 | } 81 | 82 | // TLSConfig is a superset of config.TLSConfig that supports TLS renegotiation 83 | type TLSConfig struct { 84 | CAFile string `yaml:"ca_file,omitempty"` 85 | CertFile string `yaml:"cert_file,omitempty"` 86 | KeyFile string `yaml:"key_file,omitempty"` 87 | ServerName string `yaml:"server_name,omitempty"` 88 | InsecureSkipVerify bool `yaml:"insecure_skip_verify"` 89 | // Renegotiation controls what types of TLS renegotiation are supported. 90 | // Supported values: never (default), once, freely. 91 | Renegotiation renegotiation `yaml:"renegotiation,omitempty"` 92 | } 93 | 94 | type renegotiation tls.RenegotiationSupport 95 | 96 | func (r *renegotiation) UnmarshalYAML(unmarshal func(interface{}) error) error { 97 | var v string 98 | if err := unmarshal(&v); err != nil { 99 | return err 100 | } 101 | switch v { 102 | case "", "never": 103 | *r = renegotiation(tls.RenegotiateNever) 104 | case "once": 105 | *r = renegotiation(tls.RenegotiateOnceAsClient) 106 | case "freely": 107 | *r = renegotiation(tls.RenegotiateFreelyAsClient) 108 | default: 109 | return fmt.Errorf("unsupported TLS renegotiation type %s", v) 110 | } 111 | 112 | return nil 113 | } 114 | 115 | // NewTLSConfig creates a new tls.Config from the given TLSConfig, 116 | // plus our local extensions 117 | func NewTLSConfig(cfg *TLSConfig) (*tls.Config, error) { 118 | tlsConfig, err := pconfig.NewTLSConfig(&pconfig.TLSConfig{ 119 | CAFile: cfg.CAFile, 120 | CertFile: cfg.CertFile, 121 | KeyFile: cfg.KeyFile, 122 | ServerName: cfg.ServerName, 123 | InsecureSkipVerify: cfg.InsecureSkipVerify, 124 | }) 125 | if err != nil { 126 | return nil, err 127 | } 128 | 129 | tlsConfig.Renegotiation = tls.RenegotiationSupport(cfg.Renegotiation) 130 | 131 | return tlsConfig, nil 132 | } 133 | 134 | // TCPProbe configures a tcp probe 135 | type TCPProbe struct { 136 | StartTLS string `yaml:"starttls,omitempty"` 137 | } 138 | 139 | // HTTPSProbe configures a https probe 140 | type HTTPSProbe struct { 141 | ProxyURL URL `yaml:"proxy_url,omitempty"` 142 | } 143 | 144 | // KubernetesProbe configures a kubernetes probe 145 | type KubernetesProbe struct { 146 | Kubeconfig string `yaml:"kubeconfig,omitempty"` 147 | } 148 | 149 | // HTTPFileProbe configures a http_file probe 150 | type HTTPFileProbe struct { 151 | ProxyURL URL `yaml:"proxy_url,omitempty"` 152 | } 153 | 154 | // URL is a custom URL type that allows validation at configuration load time 155 | type URL struct { 156 | *url.URL 157 | } 158 | 159 | // UnmarshalYAML implements the yaml.Unmarshaler interface for URLs. 160 | func (u *URL) UnmarshalYAML(unmarshal func(interface{}) error) error { 161 | var s string 162 | if err := unmarshal(&s); err != nil { 163 | return err 164 | } 165 | 166 | urlp, err := url.Parse(s) 167 | if err != nil { 168 | return err 169 | } 170 | u.URL = urlp 171 | return nil 172 | } 173 | -------------------------------------------------------------------------------- /contrib/grafana/dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": "-- Grafana --", 7 | "enable": true, 8 | "hide": true, 9 | "iconColor": "rgba(0, 211, 255, 1)", 10 | "name": "Annotations & Alerts", 11 | "type": "dashboard" 12 | } 13 | ] 14 | }, 15 | "description": "Shows certificate expiration times, as well as failed ssl connects", 16 | "editable": true, 17 | "gnetId": 11279, 18 | "graphTooltip": 0, 19 | "id": 2, 20 | "iteration": 1583741464883, 21 | "links": [], 22 | "panels": [ 23 | { 24 | "cacheTimeout": null, 25 | "colorBackground": false, 26 | "colorPostfix": false, 27 | "colorPrefix": false, 28 | "colorValue": false, 29 | "colors": ["#299c46", "rgba(237, 129, 40, 0.89)", "#d44a3a"], 30 | "datasource": "Prometheus", 31 | "decimals": 0, 32 | "description": "", 33 | "format": "none", 34 | "gauge": { 35 | "maxValue": 100, 36 | "minValue": 0, 37 | "show": false, 38 | "thresholdLabels": false, 39 | "thresholdMarkers": true 40 | }, 41 | "gridPos": { 42 | "h": 7, 43 | "w": 4, 44 | "x": 0, 45 | "y": 0 46 | }, 47 | "id": 9, 48 | "interval": null, 49 | "links": [], 50 | "mappingType": 1, 51 | "mappingTypes": [ 52 | { 53 | "name": "value to text", 54 | "value": 1 55 | }, 56 | { 57 | "name": "range to text", 58 | "value": 2 59 | } 60 | ], 61 | "maxDataPoints": 100, 62 | "nullPointMode": "connected", 63 | "nullText": null, 64 | "options": {}, 65 | "postfix": "", 66 | "postfixFontSize": "50%", 67 | "prefix": "", 68 | "prefixFontSize": "50%", 69 | "rangeMaps": [ 70 | { 71 | "from": "null", 72 | "text": "N/A", 73 | "to": "null" 74 | } 75 | ], 76 | "sparkline": { 77 | "fillColor": "rgba(31, 118, 189, 0.18)", 78 | "full": false, 79 | "lineColor": "rgb(31, 120, 193)", 80 | "show": false, 81 | "ymax": null, 82 | "ymin": null 83 | }, 84 | "tableColumn": "", 85 | "targets": [ 86 | { 87 | "expr": "count(max(ssl_cert_not_after{instance=~\"$instance\",job=~\"$job\"}) by (issuer_cn,serial_no))", 88 | "refId": "A" 89 | } 90 | ], 91 | "thresholds": "", 92 | "timeFrom": null, 93 | "timeShift": null, 94 | "title": "Total Unique Certificates", 95 | "transparent": true, 96 | "type": "singlestat", 97 | "valueFontSize": "80%", 98 | "valueMaps": [ 99 | { 100 | "op": "=", 101 | "text": "N/A", 102 | "value": "null" 103 | } 104 | ], 105 | "valueName": "current" 106 | }, 107 | { 108 | "cacheTimeout": null, 109 | "colorBackground": false, 110 | "colorPostfix": false, 111 | "colorPrefix": false, 112 | "colorValue": false, 113 | "colors": ["#299c46", "rgba(237, 129, 40, 0.89)", "#d44a3a"], 114 | "datasource": "Prometheus", 115 | "decimals": 0, 116 | "description": "", 117 | "format": "none", 118 | "gauge": { 119 | "maxValue": 100, 120 | "minValue": 0, 121 | "show": false, 122 | "thresholdLabels": false, 123 | "thresholdMarkers": true 124 | }, 125 | "gridPos": { 126 | "h": 7, 127 | "w": 4, 128 | "x": 4, 129 | "y": 0 130 | }, 131 | "id": 10, 132 | "interval": null, 133 | "links": [], 134 | "mappingType": 1, 135 | "mappingTypes": [ 136 | { 137 | "name": "value to text", 138 | "value": 1 139 | }, 140 | { 141 | "name": "range to text", 142 | "value": 2 143 | } 144 | ], 145 | "maxDataPoints": 100, 146 | "nullPointMode": "connected", 147 | "nullText": null, 148 | "options": {}, 149 | "postfix": "", 150 | "postfixFontSize": "50%", 151 | "prefix": "", 152 | "prefixFontSize": "50%", 153 | "rangeMaps": [ 154 | { 155 | "from": "null", 156 | "text": "N/A", 157 | "to": "null" 158 | } 159 | ], 160 | "sparkline": { 161 | "fillColor": "rgba(31, 118, 189, 0.18)", 162 | "full": false, 163 | "lineColor": "rgb(31, 120, 193)", 164 | "show": false, 165 | "ymax": null, 166 | "ymin": null 167 | }, 168 | "tableColumn": "", 169 | "targets": [ 170 | { 171 | "expr": "count(max(ssl_cert_not_after{instance=~\"$instance\",job=~\"$job\"}) by (instance))", 172 | "refId": "A" 173 | } 174 | ], 175 | "thresholds": "", 176 | "timeFrom": null, 177 | "timeShift": null, 178 | "title": "Total Probe Targets", 179 | "transparent": true, 180 | "type": "singlestat", 181 | "valueFontSize": "80%", 182 | "valueMaps": [ 183 | { 184 | "op": "=", 185 | "text": "N/A", 186 | "value": "null" 187 | } 188 | ], 189 | "valueName": "current" 190 | }, 191 | { 192 | "aliasColors": {}, 193 | "bars": false, 194 | "dashLength": 10, 195 | "dashes": false, 196 | "datasource": "Prometheus", 197 | "fill": 0, 198 | "fillGradient": 0, 199 | "gridPos": { 200 | "h": 8, 201 | "w": 14, 202 | "x": 9, 203 | "y": 0 204 | }, 205 | "hiddenSeries": false, 206 | "id": 6, 207 | "interval": "", 208 | "legend": { 209 | "alignAsTable": false, 210 | "avg": false, 211 | "current": false, 212 | "hideEmpty": false, 213 | "hideZero": false, 214 | "max": false, 215 | "min": false, 216 | "rightSide": false, 217 | "show": true, 218 | "total": false, 219 | "values": false 220 | }, 221 | "lines": false, 222 | "linewidth": 0, 223 | "nullPointMode": "null", 224 | "options": { 225 | "dataLinks": [ 226 | { 227 | "targetBlank": true, 228 | "title": "Open URL", 229 | "url": "https://${__field.labels.instance}" 230 | } 231 | ] 232 | }, 233 | "percentage": false, 234 | "pointradius": 2, 235 | "points": true, 236 | "renderer": "flot", 237 | "seriesOverrides": [], 238 | "spaceLength": 10, 239 | "stack": true, 240 | "steppedLine": false, 241 | "targets": [ 242 | { 243 | "expr": "(up{job=~\"$job\", instance=~\"$instance\"} == 0 or ssl_probe_success{job=~\"$job\", instance=~\"$instance\"} == 0)^0", 244 | "format": "time_series", 245 | "instant": false, 246 | "legendFormat": "{{instance}}", 247 | "refId": "A" 248 | } 249 | ], 250 | "thresholds": [], 251 | "timeFrom": null, 252 | "timeRegions": [], 253 | "timeShift": null, 254 | "title": "Failed SSL Connects History", 255 | "tooltip": { 256 | "shared": true, 257 | "sort": 1, 258 | "value_type": "individual" 259 | }, 260 | "transparent": true, 261 | "type": "graph", 262 | "xaxis": { 263 | "buckets": null, 264 | "mode": "time", 265 | "name": null, 266 | "show": true, 267 | "values": [] 268 | }, 269 | "yaxes": [ 270 | { 271 | "decimals": 0, 272 | "format": "short", 273 | "label": "", 274 | "logBase": 1, 275 | "max": null, 276 | "min": "0", 277 | "show": true 278 | }, 279 | { 280 | "decimals": 0, 281 | "format": "short", 282 | "label": "", 283 | "logBase": 1, 284 | "max": null, 285 | "min": "0", 286 | "show": false 287 | } 288 | ], 289 | "yaxis": { 290 | "align": false, 291 | "alignLevel": null 292 | } 293 | }, 294 | { 295 | "columns": [], 296 | "datasource": "Prometheus", 297 | "description": "Possible reasons:\n- site is down\n- server is down\n- certificate has expired\n- certificate's CA is not trusted by the exporter\n- other connection errors\n- other certificate errors", 298 | "fontSize": "100%", 299 | "gridPos": { 300 | "h": 5, 301 | "w": 24, 302 | "x": 0, 303 | "y": 8 304 | }, 305 | "id": 4, 306 | "links": [], 307 | "maxPerRow": 2, 308 | "options": {}, 309 | "pageSize": 10, 310 | "repeat": "job", 311 | "repeatDirection": "h", 312 | "scopedVars": { 313 | "job": { 314 | "selected": false, 315 | "text": "ssl", 316 | "value": "ssl" 317 | } 318 | }, 319 | "scroll": true, 320 | "showHeader": true, 321 | "sort": { 322 | "col": 2, 323 | "desc": false 324 | }, 325 | "styles": [ 326 | { 327 | "alias": "Time", 328 | "align": "auto", 329 | "dateFormat": "YYYY-MM-DD HH:mm:ss", 330 | "pattern": "Time", 331 | "type": "hidden" 332 | }, 333 | { 334 | "alias": "", 335 | "align": "auto", 336 | "colorMode": null, 337 | "colors": [ 338 | "rgba(245, 54, 54, 0.9)", 339 | "rgba(237, 129, 40, 0.89)", 340 | "rgba(50, 172, 45, 0.97)" 341 | ], 342 | "decimals": 2, 343 | "pattern": "job", 344 | "thresholds": [], 345 | "type": "hidden", 346 | "unit": "short" 347 | }, 348 | { 349 | "alias": "", 350 | "align": "auto", 351 | "colorMode": null, 352 | "colors": [ 353 | "rgba(245, 54, 54, 0.9)", 354 | "rgba(237, 129, 40, 0.89)", 355 | "rgba(50, 172, 45, 0.97)" 356 | ], 357 | "dateFormat": "YYYY-MM-DD HH:mm:ss", 358 | "decimals": 2, 359 | "mappingType": 1, 360 | "pattern": "__name__", 361 | "thresholds": [], 362 | "type": "hidden", 363 | "unit": "short" 364 | }, 365 | { 366 | "alias": "SSL Failed", 367 | "align": "auto", 368 | "colorMode": "row", 369 | "colors": [ 370 | "rgba(245, 54, 54, 0.9)", 371 | "rgba(237, 129, 40, 0.89)", 372 | "rgba(50, 172, 45, 0.97)" 373 | ], 374 | "dateFormat": "YYYY-MM-DD HH:mm:ss", 375 | "decimals": 0, 376 | "mappingType": 1, 377 | "pattern": "Value", 378 | "thresholds": ["1"], 379 | "type": "number", 380 | "unit": "short" 381 | }, 382 | { 383 | "alias": "", 384 | "align": "auto", 385 | "colorMode": "row", 386 | "colors": [ 387 | "#F2495C", 388 | "rgba(237, 129, 40, 0.89)", 389 | "rgba(50, 172, 45, 0.97)" 390 | ], 391 | "dateFormat": "YYYY-MM-DD HH:mm:ss", 392 | "decimals": 2, 393 | "link": true, 394 | "linkTargetBlank": true, 395 | "linkTooltip": "${__cell}", 396 | "linkUrl": "https://${__cell:raw}", 397 | "mappingType": 1, 398 | "pattern": "instance", 399 | "preserveFormat": false, 400 | "rangeMaps": [], 401 | "sanitize": true, 402 | "thresholds": [""], 403 | "type": "string", 404 | "unit": "short", 405 | "valueMaps": [] 406 | } 407 | ], 408 | "targets": [ 409 | { 410 | "expr": "ssl_probe_success{instance=~\"$instance\",job=~\"$job\"} == 0", 411 | "format": "table", 412 | "instant": true, 413 | "intervalFactor": 1, 414 | "legendFormat": "", 415 | "refId": "A" 416 | } 417 | ], 418 | "timeFrom": null, 419 | "timeShift": null, 420 | "title": "Failed SSL Connects - $job", 421 | "transform": "table", 422 | "transparent": true, 423 | "type": "table" 424 | }, 425 | { 426 | "cacheTimeout": null, 427 | "columns": [], 428 | "datasource": "Prometheus", 429 | "description": "", 430 | "fontSize": "100%", 431 | "gridPos": { 432 | "h": 25, 433 | "w": 24, 434 | "x": 0, 435 | "y": 18 436 | }, 437 | "id": 2, 438 | "interval": "", 439 | "links": [], 440 | "options": {}, 441 | "pageSize": null, 442 | "pluginVersion": "6.1.6", 443 | "scroll": true, 444 | "showHeader": true, 445 | "sort": { 446 | "col": 8, 447 | "desc": false 448 | }, 449 | "styles": [ 450 | { 451 | "alias": "Expires In", 452 | "align": "auto", 453 | "colorMode": "cell", 454 | "colors": [ 455 | "rgba(245, 54, 54, 0.9)", 456 | "rgba(237, 129, 40, 0.89)", 457 | "rgba(50, 172, 45, 0.97)" 458 | ], 459 | "decimals": 1, 460 | "link": false, 461 | "pattern": "Value", 462 | "thresholds": ["3", "7"], 463 | "type": "number", 464 | "unit": "d" 465 | }, 466 | { 467 | "alias": "", 468 | "align": "auto", 469 | "colorMode": null, 470 | "colors": [ 471 | "rgba(245, 54, 54, 0.9)", 472 | "rgba(237, 129, 40, 0.89)", 473 | "rgba(50, 172, 45, 0.97)" 474 | ], 475 | "dateFormat": "YYYY-MM-DD HH:mm:ss", 476 | "decimals": 2, 477 | "mappingType": 1, 478 | "pattern": "Time", 479 | "thresholds": [], 480 | "type": "hidden", 481 | "unit": "short" 482 | }, 483 | { 484 | "alias": "", 485 | "align": "auto", 486 | "colorMode": null, 487 | "colors": [ 488 | "rgba(245, 54, 54, 0.9)", 489 | "rgba(237, 129, 40, 0.89)", 490 | "rgba(50, 172, 45, 0.97)" 491 | ], 492 | "dateFormat": "YYYY-MM-DD HH:mm:ss", 493 | "decimals": 2, 494 | "mappingType": 1, 495 | "pattern": "job", 496 | "thresholds": [], 497 | "type": "hidden", 498 | "unit": "short" 499 | }, 500 | { 501 | "alias": "", 502 | "align": "auto", 503 | "colorMode": null, 504 | "colors": [ 505 | "rgba(245, 54, 54, 0.9)", 506 | "rgba(237, 129, 40, 0.89)", 507 | "rgba(50, 172, 45, 0.97)" 508 | ], 509 | "dateFormat": "YYYY-MM-DD HH:mm:ss", 510 | "decimals": 2, 511 | "link": true, 512 | "linkTargetBlank": true, 513 | "linkTooltip": "${__cell_6}", 514 | "linkUrl": "https://${__cell:raw}/", 515 | "mappingType": 1, 516 | "pattern": "instance", 517 | "sanitize": false, 518 | "thresholds": [], 519 | "type": "string", 520 | "unit": "short" 521 | }, 522 | { 523 | "alias": "", 524 | "align": "auto", 525 | "colorMode": null, 526 | "colors": [ 527 | "rgba(245, 54, 54, 0.9)", 528 | "rgba(237, 129, 40, 0.89)", 529 | "rgba(50, 172, 45, 0.97)" 530 | ], 531 | "dateFormat": "YYYY-MM-DD HH:mm:ss", 532 | "decimals": 2, 533 | "mappingType": 1, 534 | "pattern": "dnsnames", 535 | "thresholds": [], 536 | "type": "hidden", 537 | "unit": "short" 538 | }, 539 | { 540 | "alias": "", 541 | "align": "auto", 542 | "colorMode": null, 543 | "colors": [ 544 | "rgba(245, 54, 54, 0.9)", 545 | "rgba(237, 129, 40, 0.89)", 546 | "rgba(50, 172, 45, 0.97)" 547 | ], 548 | "dateFormat": "YYYY-MM-DD HH:mm:ss", 549 | "decimals": 2, 550 | "mappingType": 1, 551 | "pattern": "ou", 552 | "thresholds": [], 553 | "type": "hidden", 554 | "unit": "short" 555 | } 556 | ], 557 | "targets": [ 558 | { 559 | "expr": "((ssl_cert_not_after{instance=~\"$instance\",job=~\"$job\"} - time())/24/60/60)", 560 | "format": "table", 561 | "instant": true, 562 | "interval": "", 563 | "intervalFactor": 1, 564 | "legendFormat": "", 565 | "refId": "A" 566 | } 567 | ], 568 | "timeFrom": null, 569 | "timeShift": null, 570 | "title": "SSL Certificates", 571 | "transform": "table", 572 | "type": "table" 573 | } 574 | ], 575 | "refresh": "5m", 576 | "schemaVersion": 22, 577 | "style": "dark", 578 | "tags": ["ssl", "tls"], 579 | "templating": { 580 | "list": [ 581 | { 582 | "allValue": null, 583 | "current": { 584 | "text": "All", 585 | "value": ["$__all"] 586 | }, 587 | "datasource": "Prometheus", 588 | "definition": "label_values(ssl_probe_success, job)", 589 | "hide": 0, 590 | "includeAll": true, 591 | "label": "Job", 592 | "multi": true, 593 | "name": "job", 594 | "options": [], 595 | "query": "label_values(ssl_probe_success, job)", 596 | "refresh": 1, 597 | "regex": "", 598 | "skipUrlSync": false, 599 | "sort": 5, 600 | "tagValuesQuery": "", 601 | "tags": [], 602 | "tagsQuery": "", 603 | "type": "query", 604 | "useTags": false 605 | }, 606 | { 607 | "allValue": null, 608 | "current": { 609 | "text": "All", 610 | "value": ["$__all"] 611 | }, 612 | "datasource": "Prometheus", 613 | "definition": "label_values({job=~\"$job\"}, instance)", 614 | "hide": 0, 615 | "includeAll": true, 616 | "label": "Instance", 617 | "multi": true, 618 | "name": "instance", 619 | "options": [], 620 | "query": "label_values({job=~\"$job\"}, instance)", 621 | "refresh": 1, 622 | "regex": "", 623 | "skipUrlSync": false, 624 | "sort": 1, 625 | "tagValuesQuery": "", 626 | "tags": [], 627 | "tagsQuery": "", 628 | "type": "query", 629 | "useTags": false 630 | } 631 | ] 632 | }, 633 | "time": { 634 | "from": "now-1h", 635 | "to": "now" 636 | }, 637 | "timepicker": { 638 | "refresh_intervals": ["30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], 639 | "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] 640 | }, 641 | "timezone": "browser", 642 | "title": "SSL/TLS Exporter", 643 | "uid": "HyKQlVGWk", 644 | "version": 1 645 | } 646 | -------------------------------------------------------------------------------- /examples/example.prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 1m 3 | 4 | scrape_configs: 5 | - job_name: 'ssl' 6 | metrics_path: /probe 7 | static_configs: 8 | - targets: 9 | - 'google.co.uk:443' 10 | - 'prometheus.io:443' 11 | - 'example.com:443' 12 | - 'helloworld.letsencrypt.org:443' 13 | - 'expired.badssl.com:443' 14 | relabel_configs: 15 | - source_labels: [__address__] 16 | target_label: __param_target 17 | - source_labels: [__param_target] 18 | target_label: instance 19 | - target_label: __address__ 20 | replacement: 127.0.0.1:9219 # SSL exporter. 21 | - job_name: 'ssl-files' 22 | metrics_path: /probe 23 | params: 24 | module: ["file"] 25 | target: ["/etc/ssl/**/*.pem"] 26 | static_configs: 27 | - targets: 28 | - 127.0.0.1:9219 29 | - job_name: 'ssl-kubernetes-secrets' 30 | metrics_path: /probe 31 | params: 32 | module: ["kubernetes"] 33 | target: ["kube-system/*"] 34 | static_configs: 35 | - targets: 36 | - 127.0.0.1:9219 37 | - job_name: 'ssl-http-files' 38 | metrics_path: /probe 39 | params: 40 | module: ["http_file"] 41 | static_configs: 42 | - targets: 43 | - 'https://www.paypalobjects.com/marketing/web/logos/paypal_com.pem' 44 | - 'https://d3frv9g52qce38.cloudfront.net/amazondefault/amazon_web_services_inc_2024.pem' 45 | relabel_configs: 46 | - source_labels: [__address__] 47 | target_label: __param_target 48 | - source_labels: [__param_target] 49 | target_label: instance 50 | - target_label: __address__ 51 | replacement: 127.0.0.1:9219 52 | -------------------------------------------------------------------------------- /examples/ssl_exporter.yaml: -------------------------------------------------------------------------------- 1 | default_module: https 2 | modules: 3 | https: 4 | prober: https 5 | https_insecure: 6 | prober: https 7 | tls_config: 8 | insecure_skip_verify: true 9 | https_renegotiation: 10 | prober: https 11 | tls_config: 12 | renegotiation: freely 13 | https_proxy: 14 | prober: https 15 | https: 16 | proxy_url: "socks5://localhost:8123" 17 | https_timeout: 18 | prober: https 19 | timeout: 3s 20 | tcp: 21 | prober: tcp 22 | tcp_servername: 23 | prober: tcp 24 | tls_config: 25 | server_name: example.com 26 | tcp_client_auth: 27 | prober: tcp 28 | tls_config: 29 | ca_file: /etc/tls/ca.crt 30 | cert_file: /etc/tls/tls.crt 31 | key_file: /etc/tls/tls.key 32 | tcp_smtp_starttls: 33 | prober: tcp 34 | tcp: 35 | starttls: smtp 36 | file: 37 | prober: file 38 | file_ca_certificates: 39 | prober: file 40 | target: /etc/ssl/certs/ca-certificates.crt 41 | http_file: 42 | prober: http_file 43 | http_file_proxy: 44 | prober: http_file 45 | http_file: 46 | proxy_url: "socks5://localhost:8123" 47 | kubernetes: 48 | prober: kubernetes 49 | kubernetes_kubeconfig: 50 | prober: kubernetes 51 | kubernetes: 52 | kubeconfig: /root/.kube/config 53 | kubeconfig: 54 | prober: kubeconfig 55 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ribbybibby/ssl_exporter/v2 2 | 3 | require ( 4 | github.com/alecthomas/kingpin/v2 v2.4.0 5 | github.com/bmatcuk/doublestar/v2 v2.0.4 6 | github.com/go-kit/log v0.2.1 7 | github.com/prometheus/client_golang v1.19.1 8 | github.com/prometheus/client_model v0.6.1 9 | github.com/prometheus/common v0.53.0 10 | golang.org/x/crypto v0.25.0 11 | gopkg.in/yaml.v3 v3.0.1 12 | k8s.io/api v0.30.0 13 | k8s.io/apimachinery v0.30.0 14 | k8s.io/client-go v1.5.2 15 | ) 16 | 17 | require ( 18 | github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 // indirect 19 | github.com/beorn7/perks v1.0.1 // indirect 20 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 21 | github.com/davecgh/go-spew v1.1.1 // indirect 22 | github.com/emicklei/go-restful/v3 v3.12.0 // indirect 23 | github.com/evanphx/json-patch v4.12.0+incompatible // indirect 24 | github.com/go-logfmt/logfmt v0.6.0 // indirect 25 | github.com/go-logr/logr v1.4.1 // indirect 26 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 27 | github.com/go-openapi/jsonreference v0.21.0 // indirect 28 | github.com/go-openapi/swag v0.23.0 // indirect 29 | github.com/gogo/protobuf v1.3.2 // indirect 30 | github.com/golang/protobuf v1.5.4 // indirect 31 | github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect 32 | github.com/google/gofuzz v1.2.0 // indirect 33 | github.com/google/uuid v1.6.0 // indirect 34 | github.com/imdario/mergo v0.3.16 // indirect 35 | github.com/josharian/intern v1.0.0 // indirect 36 | github.com/jpillora/backoff v1.0.0 // indirect 37 | github.com/json-iterator/go v1.1.12 // indirect 38 | github.com/mailru/easyjson v0.7.7 // indirect 39 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 40 | github.com/modern-go/reflect2 v1.0.2 // indirect 41 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 42 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect 43 | github.com/pkg/errors v0.9.1 // indirect 44 | github.com/prometheus/procfs v0.14.0 // indirect 45 | github.com/spf13/pflag v1.0.5 // indirect 46 | github.com/xhit/go-str2duration/v2 v2.1.0 // indirect 47 | golang.org/x/net v0.24.0 // indirect 48 | golang.org/x/oauth2 v0.19.0 // indirect 49 | golang.org/x/sys v0.22.0 // indirect 50 | golang.org/x/term v0.22.0 // indirect 51 | golang.org/x/text v0.16.0 // indirect 52 | golang.org/x/time v0.5.0 // indirect 53 | google.golang.org/protobuf v1.33.0 // indirect 54 | gopkg.in/inf.v0 v0.9.1 // indirect 55 | gopkg.in/yaml.v2 v2.4.0 // indirect 56 | k8s.io/klog/v2 v2.120.1 // indirect 57 | k8s.io/kube-openapi v0.0.0-20240423202451-8948a665c108 // indirect 58 | k8s.io/utils v0.0.0-20240423183400-0849a56e8f22 // indirect 59 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 60 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 61 | sigs.k8s.io/yaml v1.4.0 // indirect 62 | ) 63 | 64 | replace ( 65 | k8s.io/api => k8s.io/api v0.29.0 66 | k8s.io/apimachinery => k8s.io/apimachinery v0.29.0 67 | k8s.io/client-go => k8s.io/client-go v0.29.0 68 | ) 69 | 70 | go 1.22 71 | 72 | toolchain go1.22.1 73 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= 2 | github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= 3 | github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 h1:ez/4by2iGztzR4L0zgAOR8lTQK9VlyBVVd7G4omaOQs= 4 | github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= 5 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 6 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 7 | github.com/bmatcuk/doublestar/v2 v2.0.4 h1:6I6oUiT/sU27eE2OFcWqBhL1SwjyvQuOssxT4a1yidI= 8 | github.com/bmatcuk/doublestar/v2 v2.0.4/go.mod h1:QMmcs3H2AUQICWhfzLXz+IYln8lRQmTZRptLie8RgRw= 9 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 10 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/emicklei/go-restful/v3 v3.12.0 h1:y2DdzBAURM29NFF94q6RaY4vjIH1rtwDapwQtU84iWk= 15 | github.com/emicklei/go-restful/v3 v3.12.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 16 | github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= 17 | github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= 18 | github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= 19 | github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= 20 | github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 21 | github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 22 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 23 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 24 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 25 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 26 | github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= 27 | github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= 28 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 29 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 30 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= 31 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 32 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 33 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 34 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 35 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 36 | github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 h1:0VpGH+cDhbDtdcweoyCVsF3fhN8kejK6rFe/2FFX2nU= 37 | github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49/go.mod h1:BkkQ4L1KS1xMt2aWSPStnn55ChGC0DPOn2FQYj+f25M= 38 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 39 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 40 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 41 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 42 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 43 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 44 | github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= 45 | github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 46 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 47 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 48 | github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= 49 | github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= 50 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 51 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 52 | github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= 53 | github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 54 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 55 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 56 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 57 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 58 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 59 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 60 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 61 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 62 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 63 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 64 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 65 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 66 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 67 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 68 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 69 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 70 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 71 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= 72 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 73 | github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= 74 | github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= 75 | github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= 76 | github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= 77 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 78 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 79 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 80 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 81 | github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= 82 | github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= 83 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 84 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 85 | github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE= 86 | github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U= 87 | github.com/prometheus/procfs v0.14.0 h1:Lw4VdGGoKEZilJsayHf0B+9YgLGREba2C6xr+Fdfq6s= 88 | github.com/prometheus/procfs v0.14.0/go.mod h1:XL+Iwz8k8ZabyZfMFHPiilCniixqQarAy5Mu67pHlNQ= 89 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 90 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 91 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 92 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 93 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 94 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 95 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 96 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 97 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 98 | github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= 99 | github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= 100 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 101 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 102 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 103 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 104 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 105 | golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= 106 | golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= 107 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 108 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 109 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 110 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 111 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 112 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 113 | golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= 114 | golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= 115 | golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg= 116 | golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8= 117 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 118 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 119 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 120 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 121 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 122 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 123 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 124 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 125 | golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= 126 | golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= 127 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 128 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 129 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 130 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 131 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 132 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 133 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 134 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 135 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 136 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 137 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 138 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 139 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 140 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 141 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 142 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 143 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 144 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 145 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 146 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 147 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 148 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 149 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 150 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 151 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 152 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 153 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 154 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 155 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 156 | k8s.io/api v0.29.0 h1:NiCdQMY1QOp1H8lfRyeEf8eOwV6+0xA6XEE44ohDX2A= 157 | k8s.io/api v0.29.0/go.mod h1:sdVmXoz2Bo/cb77Pxi71IPTSErEW32xa4aXwKH7gfBA= 158 | k8s.io/apimachinery v0.29.0 h1:+ACVktwyicPz0oc6MTMLwa2Pw3ouLAfAon1wPLtG48o= 159 | k8s.io/apimachinery v0.29.0/go.mod h1:eVBxQ/cwiJxH58eK/jd/vAk4mrxmVlnpBH5J2GbMeis= 160 | k8s.io/client-go v0.29.0 h1:KmlDtFcrdUzOYrBhXHgKw5ycWzc3ryPX5mQe0SkG3y8= 161 | k8s.io/client-go v0.29.0/go.mod h1:yLkXH4HKMAywcrD82KMSmfYg2DlE8mepPR4JGSo5n38= 162 | k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= 163 | k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 164 | k8s.io/kube-openapi v0.0.0-20240423202451-8948a665c108 h1:Q8Z7VlGhcJgBHJHYugJ/K/7iB8a2eSxCyxdVjJp+lLY= 165 | k8s.io/kube-openapi v0.0.0-20240423202451-8948a665c108/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= 166 | k8s.io/utils v0.0.0-20240423183400-0849a56e8f22 h1:ao5hUqGhsqdm+bYbjH/pRkCs0unBGe9UyDahzs9zQzQ= 167 | k8s.io/utils v0.0.0-20240423183400-0849a56e8f22/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 168 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= 169 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= 170 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= 171 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= 172 | sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 173 | sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 174 | -------------------------------------------------------------------------------- /prober/file.go: -------------------------------------------------------------------------------- 1 | package prober 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/bmatcuk/doublestar/v2" 8 | "github.com/go-kit/log" 9 | "github.com/prometheus/client_golang/prometheus" 10 | "github.com/ribbybibby/ssl_exporter/v2/config" 11 | ) 12 | 13 | // ProbeFile collects certificate metrics from local files 14 | func ProbeFile(ctx context.Context, logger log.Logger, target string, module config.Module, registry *prometheus.Registry) error { 15 | errCh := make(chan error, 1) 16 | 17 | go func() { 18 | files, err := doublestar.Glob(target) 19 | if err != nil { 20 | errCh <- err 21 | return 22 | } 23 | 24 | if len(files) == 0 { 25 | errCh <- fmt.Errorf("No files found") 26 | } else { 27 | errCh <- collectFileMetrics(logger, files, registry) 28 | } 29 | }() 30 | 31 | select { 32 | case <-ctx.Done(): 33 | return fmt.Errorf("context timeout, ran out of time") 34 | case err := <-errCh: 35 | return err 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /prober/file_test.go: -------------------------------------------------------------------------------- 1 | package prober 2 | 3 | import ( 4 | "context" 5 | "crypto/x509" 6 | "encoding/pem" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "testing" 12 | "time" 13 | 14 | "github.com/ribbybibby/ssl_exporter/v2/config" 15 | "github.com/ribbybibby/ssl_exporter/v2/test" 16 | 17 | "github.com/prometheus/client_golang/prometheus" 18 | ) 19 | 20 | // TestProbeFile tests a file 21 | func TestProbeFile(t *testing.T) { 22 | cert, certFile, err := createTestFile("", "tls*.crt") 23 | if err != nil { 24 | t.Fatalf(err.Error()) 25 | } 26 | defer os.Remove(certFile) 27 | 28 | module := config.Module{} 29 | 30 | registry := prometheus.NewRegistry() 31 | 32 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 33 | defer cancel() 34 | 35 | if err := ProbeFile(ctx, newTestLogger(), certFile, module, registry); err != nil { 36 | t.Fatalf("error: %s", err) 37 | } 38 | 39 | checkFileMetrics(cert, certFile, registry, t) 40 | } 41 | 42 | // TestProbeFileGlob tests matching a file with a glob 43 | func TestProbeFileGlob(t *testing.T) { 44 | cert, certFile, err := createTestFile("", "tls*.crt") 45 | if err != nil { 46 | t.Fatalf(err.Error()) 47 | } 48 | defer os.Remove(certFile) 49 | 50 | module := config.Module{} 51 | 52 | registry := prometheus.NewRegistry() 53 | 54 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 55 | defer cancel() 56 | 57 | glob := filepath.Dir(certFile) + "/*.crt" 58 | 59 | if err := ProbeFile(ctx, newTestLogger(), glob, module, registry); err != nil { 60 | t.Fatalf("error: %s", err) 61 | } 62 | 63 | checkFileMetrics(cert, certFile, registry, t) 64 | } 65 | 66 | // TestProbeFileGlobDoubleStar tests matching a file with a ** glob 67 | func TestProbeFileGlobDoubleStar(t *testing.T) { 68 | tmpDir, err := ioutil.TempDir("", "testdir") 69 | if err != nil { 70 | t.Fatalf(err.Error()) 71 | } 72 | cert, certFile, err := createTestFile(tmpDir, "tls*.crt") 73 | if err != nil { 74 | t.Fatalf(err.Error()) 75 | } 76 | defer os.Remove(certFile) 77 | 78 | module := config.Module{} 79 | 80 | registry := prometheus.NewRegistry() 81 | 82 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 83 | defer cancel() 84 | 85 | glob := filepath.Dir(filepath.Dir(certFile)) + "/**/*.crt" 86 | 87 | if err := ProbeFile(ctx, newTestLogger(), glob, module, registry); err != nil { 88 | t.Fatalf("error: %s", err) 89 | } 90 | 91 | checkFileMetrics(cert, certFile, registry, t) 92 | } 93 | 94 | // TestProbeFileGlobDoubleStarMultiple tests matching multiple files with a ** glob 95 | func TestProbeFileGlobDoubleStarMultiple(t *testing.T) { 96 | tmpDir, err := ioutil.TempDir("", "testdir") 97 | if err != nil { 98 | t.Fatalf(err.Error()) 99 | } 100 | defer os.RemoveAll(tmpDir) 101 | 102 | tmpDir1, err := ioutil.TempDir(tmpDir, "testdir") 103 | if err != nil { 104 | t.Fatalf(err.Error()) 105 | } 106 | cert1, certFile1, err := createTestFile(tmpDir1, "1*.crt") 107 | if err != nil { 108 | t.Fatalf(err.Error()) 109 | } 110 | 111 | tmpDir2, err := ioutil.TempDir(tmpDir, "testdir") 112 | if err != nil { 113 | t.Fatalf(err.Error()) 114 | } 115 | cert2, certFile2, err := createTestFile(tmpDir2, "2*.crt") 116 | if err != nil { 117 | t.Fatalf(err.Error()) 118 | } 119 | 120 | module := config.Module{} 121 | 122 | registry := prometheus.NewRegistry() 123 | 124 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 125 | defer cancel() 126 | 127 | glob := tmpDir + "/**/*.crt" 128 | 129 | if err := ProbeFile(ctx, newTestLogger(), glob, module, registry); err != nil { 130 | t.Fatalf("error: %s", err) 131 | } 132 | 133 | checkFileMetrics(cert1, certFile1, registry, t) 134 | checkFileMetrics(cert2, certFile2, registry, t) 135 | } 136 | 137 | // Create a certificate and write it to a file 138 | func createTestFile(dir, filename string) (*x509.Certificate, string, error) { 139 | certPEM, _ := test.GenerateTestCertificate(time.Now().Add(time.Hour * 1)) 140 | block, _ := pem.Decode([]byte(certPEM)) 141 | cert, err := x509.ParseCertificate(block.Bytes) 142 | if err != nil { 143 | return nil, "", err 144 | } 145 | tmpFile, err := ioutil.TempFile(dir, filename) 146 | if err != nil { 147 | return nil, tmpFile.Name(), err 148 | } 149 | if _, err := tmpFile.Write(certPEM); err != nil { 150 | return nil, tmpFile.Name(), err 151 | } 152 | if err := tmpFile.Close(); err != nil { 153 | return nil, tmpFile.Name(), err 154 | } 155 | 156 | return cert, tmpFile.Name(), nil 157 | } 158 | 159 | // Check metrics 160 | func checkFileMetrics(cert *x509.Certificate, certFile string, registry *prometheus.Registry, t *testing.T) { 161 | mfs, err := registry.Gather() 162 | if err != nil { 163 | t.Fatal(err) 164 | } 165 | ips := "," 166 | for _, ip := range cert.IPAddresses { 167 | ips = ips + ip.String() + "," 168 | } 169 | expectedResults := []*registryResult{ 170 | ®istryResult{ 171 | Name: "ssl_file_cert_not_after", 172 | LabelValues: map[string]string{ 173 | "file": certFile, 174 | "serial_no": cert.SerialNumber.String(), 175 | "issuer_cn": cert.Issuer.CommonName, 176 | "cn": cert.Subject.CommonName, 177 | "dnsnames": "," + strings.Join(cert.DNSNames, ",") + ",", 178 | "ips": ips, 179 | "emails": "," + strings.Join(cert.EmailAddresses, ",") + ",", 180 | "ou": "," + strings.Join(cert.Subject.OrganizationalUnit, ",") + ",", 181 | }, 182 | Value: float64(cert.NotAfter.Unix()), 183 | }, 184 | ®istryResult{ 185 | Name: "ssl_file_cert_not_before", 186 | LabelValues: map[string]string{ 187 | "file": certFile, 188 | "serial_no": cert.SerialNumber.String(), 189 | "issuer_cn": cert.Issuer.CommonName, 190 | "cn": cert.Subject.CommonName, 191 | "dnsnames": "," + strings.Join(cert.DNSNames, ",") + ",", 192 | "ips": ips, 193 | "emails": "," + strings.Join(cert.EmailAddresses, ",") + ",", 194 | "ou": "," + strings.Join(cert.Subject.OrganizationalUnit, ",") + ",", 195 | }, 196 | Value: float64(cert.NotBefore.Unix()), 197 | }, 198 | } 199 | checkRegistryResults(expectedResults, mfs, t) 200 | } 201 | -------------------------------------------------------------------------------- /prober/http_file.go: -------------------------------------------------------------------------------- 1 | package prober 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/go-kit/log" 10 | "github.com/prometheus/client_golang/prometheus" 11 | "github.com/ribbybibby/ssl_exporter/v2/config" 12 | ) 13 | 14 | // ProbeHTTPFile collects certificate metrics from a remote file via http 15 | func ProbeHTTPFile(ctx context.Context, logger log.Logger, target string, module config.Module, registry *prometheus.Registry) error { 16 | proxy := http.ProxyFromEnvironment 17 | if module.HTTPFile.ProxyURL.URL != nil { 18 | proxy = http.ProxyURL(module.HTTPFile.ProxyURL.URL) 19 | } 20 | 21 | tlsConfig, err := config.NewTLSConfig(&module.TLSConfig) 22 | if err != nil { 23 | return fmt.Errorf("creating TLS config: %w", err) 24 | } 25 | 26 | client := &http.Client{ 27 | Transport: &http.Transport{ 28 | TLSClientConfig: tlsConfig, 29 | Proxy: proxy, 30 | DisableKeepAlives: true, 31 | }, 32 | } 33 | 34 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) 35 | if err != nil { 36 | return fmt.Errorf("creating http request: %w", err) 37 | } 38 | req.Header.Set("User-Agent", userAgent) 39 | resp, err := client.Do(req) 40 | if err != nil { 41 | return fmt.Errorf("making http request: %w", err) 42 | } 43 | defer resp.Body.Close() 44 | 45 | if resp.StatusCode != http.StatusOK { 46 | return fmt.Errorf("unexpected response code: %d", resp.StatusCode) 47 | } 48 | 49 | body, err := io.ReadAll(resp.Body) 50 | if err != nil { 51 | return fmt.Errorf("reading response body: %w", err) 52 | } 53 | 54 | certs, err := decodeCertificates(body) 55 | if err != nil { 56 | return fmt.Errorf("decoding certificates from response body: %w", err) 57 | } 58 | 59 | return collectCertificateMetrics(certs, registry) 60 | } 61 | -------------------------------------------------------------------------------- /prober/http_file_test.go: -------------------------------------------------------------------------------- 1 | package prober 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | 10 | "github.com/prometheus/client_golang/prometheus" 11 | "github.com/ribbybibby/ssl_exporter/v2/config" 12 | "github.com/ribbybibby/ssl_exporter/v2/test" 13 | ) 14 | 15 | func TestProbeHTTPFile(t *testing.T) { 16 | testcertPEM, _ := test.GenerateTestCertificate(time.Now().AddDate(0, 0, 1)) 17 | 18 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 | w.Write(testcertPEM) 20 | })) 21 | 22 | server.Start() 23 | defer server.Close() 24 | 25 | registry := prometheus.NewRegistry() 26 | 27 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 28 | defer cancel() 29 | 30 | if err := ProbeHTTPFile(ctx, newTestLogger(), server.URL+"/file", config.Module{}, registry); err != nil { 31 | t.Fatalf("error: %s", err) 32 | } 33 | 34 | cert, err := newCertificate(testcertPEM) 35 | if err != nil { 36 | t.Fatal(err) 37 | } 38 | checkCertificateMetrics(cert, registry, t) 39 | } 40 | 41 | func TestProbeHTTPFile_NotCertificate(t *testing.T) { 42 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 43 | w.Write([]byte("foobar")) 44 | })) 45 | 46 | server.Start() 47 | defer server.Close() 48 | 49 | registry := prometheus.NewRegistry() 50 | 51 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 52 | defer cancel() 53 | 54 | if err := ProbeHTTPFile(ctx, newTestLogger(), server.URL+"/file", config.Module{}, registry); err == nil { 55 | t.Errorf("expected error but got nil") 56 | } 57 | } 58 | 59 | func TestProbeHTTPFile_NotFound(t *testing.T) { 60 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 61 | w.WriteHeader(http.StatusNotFound) 62 | })) 63 | 64 | server.Start() 65 | defer server.Close() 66 | 67 | registry := prometheus.NewRegistry() 68 | 69 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 70 | defer cancel() 71 | 72 | if err := ProbeHTTPFile(ctx, newTestLogger(), server.URL+"/file", config.Module{}, registry); err == nil { 73 | t.Errorf("expected error but got nil") 74 | } 75 | } 76 | 77 | func TestProbeHTTPFileHTTPS(t *testing.T) { 78 | server, certPEM, _, caFile, teardown, err := test.SetupHTTPSServer() 79 | if err != nil { 80 | t.Fatalf(err.Error()) 81 | } 82 | defer teardown() 83 | 84 | server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 85 | w.Write(certPEM) 86 | }) 87 | 88 | server.StartTLS() 89 | defer server.Close() 90 | 91 | module := config.Module{ 92 | TLSConfig: config.TLSConfig{ 93 | CAFile: caFile, 94 | InsecureSkipVerify: false, 95 | }, 96 | } 97 | 98 | registry := prometheus.NewRegistry() 99 | 100 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 101 | defer cancel() 102 | 103 | if err := ProbeHTTPFile(ctx, newTestLogger(), server.URL+"/file", module, registry); err != nil { 104 | t.Fatalf("error: %s", err) 105 | } 106 | 107 | cert, err := newCertificate(certPEM) 108 | if err != nil { 109 | t.Fatal(err) 110 | } 111 | checkCertificateMetrics(cert, registry, t) 112 | } 113 | -------------------------------------------------------------------------------- /prober/https.go: -------------------------------------------------------------------------------- 1 | package prober 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | 12 | "github.com/go-kit/log" 13 | "github.com/go-kit/log/level" 14 | "github.com/prometheus/client_golang/prometheus" 15 | "github.com/prometheus/common/version" 16 | "github.com/ribbybibby/ssl_exporter/v2/config" 17 | ) 18 | 19 | var userAgent = fmt.Sprintf("SSLExporter/%s", version.Version) 20 | 21 | // ProbeHTTPS performs a https probe 22 | func ProbeHTTPS(ctx context.Context, logger log.Logger, target string, module config.Module, registry *prometheus.Registry) error { 23 | tlsConfig, err := newTLSConfig("", registry, &module.TLSConfig) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | if strings.HasPrefix(target, "http://") { 29 | return fmt.Errorf("Target is using http scheme: %s", target) 30 | } 31 | 32 | if !strings.HasPrefix(target, "https://") { 33 | target = "https://" + target 34 | } 35 | 36 | targetURL, err := url.Parse(target) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | proxy := http.ProxyFromEnvironment 42 | if module.HTTPS.ProxyURL.URL != nil { 43 | proxy = http.ProxyURL(module.HTTPS.ProxyURL.URL) 44 | } 45 | 46 | client := &http.Client{ 47 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 48 | return http.ErrUseLastResponse 49 | }, 50 | Transport: &http.Transport{ 51 | TLSClientConfig: tlsConfig, 52 | Proxy: proxy, 53 | DisableKeepAlives: true, 54 | }, 55 | } 56 | 57 | // Issue a GET request to the target 58 | request, err := http.NewRequest(http.MethodGet, targetURL.String(), nil) 59 | if err != nil { 60 | return err 61 | } 62 | request = request.WithContext(ctx) 63 | request.Header.Set("User-Agent", userAgent) 64 | resp, err := client.Do(request) 65 | if err != nil { 66 | return err 67 | } 68 | defer func() { 69 | _, err := io.Copy(ioutil.Discard, resp.Body) 70 | if err != nil { 71 | level.Error(logger).Log("msg", err) 72 | } 73 | resp.Body.Close() 74 | }() 75 | 76 | // Check if the response from the target is encrypted 77 | if resp.TLS == nil { 78 | return fmt.Errorf("The response from %s is unencrypted", targetURL.String()) 79 | } 80 | 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /prober/https_test.go: -------------------------------------------------------------------------------- 1 | package prober 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/rand" 7 | "crypto/rsa" 8 | "crypto/tls" 9 | "crypto/x509" 10 | "fmt" 11 | "math/big" 12 | "net/http" 13 | "net/http/httptest" 14 | "net/url" 15 | "os" 16 | "testing" 17 | "time" 18 | 19 | "github.com/prometheus/client_golang/prometheus" 20 | "github.com/ribbybibby/ssl_exporter/v2/config" 21 | "github.com/ribbybibby/ssl_exporter/v2/test" 22 | "golang.org/x/crypto/ocsp" 23 | ) 24 | 25 | // TestProbeHTTPS tests the typical case 26 | func TestProbeHTTPS(t *testing.T) { 27 | server, certPEM, _, caFile, teardown, err := test.SetupHTTPSServer() 28 | if err != nil { 29 | t.Fatalf(err.Error()) 30 | } 31 | defer teardown() 32 | 33 | server.StartTLS() 34 | defer server.Close() 35 | 36 | module := config.Module{ 37 | TLSConfig: config.TLSConfig{ 38 | CAFile: caFile, 39 | InsecureSkipVerify: false, 40 | }, 41 | } 42 | 43 | registry := prometheus.NewRegistry() 44 | 45 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 46 | defer cancel() 47 | 48 | if err := ProbeHTTPS(ctx, newTestLogger(), server.URL, module, registry); err != nil { 49 | t.Fatalf("error: %s", err) 50 | } 51 | 52 | cert, err := newCertificate(certPEM) 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | checkCertificateMetrics(cert, registry, t) 57 | checkOCSPMetrics([]byte{}, registry, t) 58 | checkTLSVersionMetrics("TLS 1.3", registry, t) 59 | } 60 | 61 | // TestProbeHTTPSTimeout tests that the https probe respects the timeout in the 62 | // context 63 | func TestProbeHTTPSTimeout(t *testing.T) { 64 | server, _, _, caFile, teardown, err := test.SetupHTTPSServer() 65 | if err != nil { 66 | t.Fatalf(err.Error()) 67 | } 68 | defer teardown() 69 | 70 | server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 71 | time.Sleep(3 * time.Second) 72 | fmt.Fprintln(w, "Hello world") 73 | }) 74 | 75 | server.StartTLS() 76 | defer server.Close() 77 | 78 | module := config.Module{ 79 | TLSConfig: config.TLSConfig{ 80 | CAFile: caFile, 81 | }, 82 | } 83 | 84 | registry := prometheus.NewRegistry() 85 | 86 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) 87 | defer cancel() 88 | 89 | if err := ProbeHTTPS(ctx, newTestLogger(), server.URL, module, registry); err == nil { 90 | t.Fatalf("Expected error but returned error was nil") 91 | } 92 | } 93 | 94 | // TestProbeHTTPSInvalidName tests hitting the server on an address which isn't 95 | // in the SANs (localhost) 96 | func TestProbeHTTPSInvalidName(t *testing.T) { 97 | server, _, _, caFile, teardown, err := test.SetupHTTPSServer() 98 | if err != nil { 99 | t.Fatalf(err.Error()) 100 | } 101 | defer teardown() 102 | 103 | server.StartTLS() 104 | defer server.Close() 105 | 106 | module := config.Module{ 107 | TLSConfig: config.TLSConfig{ 108 | CAFile: caFile, 109 | InsecureSkipVerify: false, 110 | }, 111 | } 112 | 113 | u, err := url.Parse(server.URL) 114 | if err != nil { 115 | t.Fatalf(err.Error()) 116 | } 117 | 118 | registry := prometheus.NewRegistry() 119 | 120 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 121 | defer cancel() 122 | 123 | if err := ProbeHTTPS(ctx, newTestLogger(), "https://localhost:"+u.Port(), module, registry); err == nil { 124 | t.Fatalf("expected error, but err was nil") 125 | } 126 | } 127 | 128 | // TestProbeHTTPSNoScheme tests that the probe is successful when the scheme is 129 | // omitted from the target. The scheme should be added by the prober. 130 | func TestProbeHTTPSNoScheme(t *testing.T) { 131 | server, certPEM, _, caFile, teardown, err := test.SetupHTTPSServer() 132 | if err != nil { 133 | t.Fatalf(err.Error()) 134 | } 135 | defer teardown() 136 | 137 | server.StartTLS() 138 | defer server.Close() 139 | 140 | module := config.Module{ 141 | TLSConfig: config.TLSConfig{ 142 | CAFile: caFile, 143 | InsecureSkipVerify: false, 144 | }, 145 | } 146 | 147 | u, err := url.Parse(server.URL) 148 | if err != nil { 149 | t.Fatalf(err.Error()) 150 | } 151 | 152 | registry := prometheus.NewRegistry() 153 | 154 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 155 | defer cancel() 156 | 157 | if err := ProbeHTTPS(ctx, newTestLogger(), u.Host, module, registry); err != nil { 158 | t.Fatalf("error: %s", err) 159 | } 160 | 161 | cert, err := newCertificate(certPEM) 162 | if err != nil { 163 | t.Fatal(err) 164 | } 165 | checkCertificateMetrics(cert, registry, t) 166 | checkOCSPMetrics([]byte{}, registry, t) 167 | checkTLSVersionMetrics("TLS 1.3", registry, t) 168 | } 169 | 170 | // TestProbeHTTPSServername tests that the probe is successful when the 171 | // servername is provided in the TLS config 172 | func TestProbeHTTPSServerName(t *testing.T) { 173 | server, certPEM, _, caFile, teardown, err := test.SetupHTTPSServer() 174 | if err != nil { 175 | t.Fatalf(err.Error()) 176 | } 177 | defer teardown() 178 | 179 | server.StartTLS() 180 | defer server.Close() 181 | 182 | u, err := url.Parse(server.URL) 183 | if err != nil { 184 | t.Fatalf(err.Error()) 185 | } 186 | 187 | module := config.Module{ 188 | TLSConfig: config.TLSConfig{ 189 | CAFile: caFile, 190 | InsecureSkipVerify: false, 191 | ServerName: u.Hostname(), 192 | }, 193 | } 194 | 195 | registry := prometheus.NewRegistry() 196 | 197 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 198 | defer cancel() 199 | 200 | if err := ProbeHTTPS(ctx, newTestLogger(), "https://localhost:"+u.Port(), module, registry); err != nil { 201 | t.Fatalf("error: %s", err) 202 | } 203 | 204 | cert, err := newCertificate(certPEM) 205 | if err != nil { 206 | t.Fatal(err) 207 | } 208 | checkCertificateMetrics(cert, registry, t) 209 | checkOCSPMetrics([]byte{}, registry, t) 210 | checkTLSVersionMetrics("TLS 1.3", registry, t) 211 | } 212 | 213 | // TestProbeHTTPSHTTP tests that the prober fails when hitting a HTTP server 214 | func TestProbeHTTPSHTTP(t *testing.T) { 215 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 216 | fmt.Fprintln(w, "Hello world") 217 | })) 218 | server.Start() 219 | defer server.Close() 220 | 221 | registry := prometheus.NewRegistry() 222 | 223 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 224 | defer cancel() 225 | 226 | if err := ProbeHTTPS(ctx, newTestLogger(), server.URL, config.Module{}, registry); err == nil { 227 | t.Fatalf("expected error, but err was nil") 228 | } 229 | } 230 | 231 | // TestProbeHTTPSClientAuth tests that the probe is successful when using client auth 232 | func TestProbeHTTPSClientAuth(t *testing.T) { 233 | server, certPEM, keyPEM, caFile, teardown, err := test.SetupHTTPSServer() 234 | if err != nil { 235 | t.Fatalf(err.Error()) 236 | } 237 | defer teardown() 238 | 239 | // Configure client auth on the server 240 | certPool := x509.NewCertPool() 241 | certPool.AppendCertsFromPEM(certPEM) 242 | 243 | server.TLS.ClientAuth = tls.RequireAndVerifyClientCert 244 | server.TLS.RootCAs = certPool 245 | server.TLS.ClientCAs = certPool 246 | 247 | server.StartTLS() 248 | defer server.Close() 249 | 250 | // Create cert file 251 | certFile, err := test.WriteFile("cert.pem", certPEM) 252 | if err != nil { 253 | t.Fatalf(err.Error()) 254 | } 255 | defer os.Remove(certFile) 256 | 257 | // Create key file 258 | keyFile, err := test.WriteFile("key.pem", keyPEM) 259 | if err != nil { 260 | t.Fatalf(err.Error()) 261 | } 262 | defer os.Remove(keyFile) 263 | 264 | module := config.Module{ 265 | TLSConfig: config.TLSConfig{ 266 | CAFile: caFile, 267 | CertFile: certFile, 268 | KeyFile: keyFile, 269 | InsecureSkipVerify: false, 270 | }, 271 | } 272 | 273 | registry := prometheus.NewRegistry() 274 | 275 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 276 | defer cancel() 277 | 278 | if err := ProbeHTTPS(ctx, newTestLogger(), server.URL, module, registry); err != nil { 279 | t.Fatalf("error: %s", err) 280 | } 281 | 282 | cert, err := newCertificate(certPEM) 283 | if err != nil { 284 | t.Fatal(err) 285 | } 286 | checkCertificateMetrics(cert, registry, t) 287 | checkOCSPMetrics([]byte{}, registry, t) 288 | checkTLSVersionMetrics("TLS 1.3", registry, t) 289 | } 290 | 291 | // TestProbeHTTPSClientAuthWrongClientCert tests that the probe fails with a bad 292 | // client certificate 293 | func TestProbeHTTPSClientAuthWrongClientCert(t *testing.T) { 294 | server, serverCertPEM, _, caFile, teardown, err := test.SetupHTTPSServer() 295 | if err != nil { 296 | t.Fatalf(err.Error()) 297 | } 298 | defer teardown() 299 | 300 | // Configure client auth on the server 301 | certPool := x509.NewCertPool() 302 | certPool.AppendCertsFromPEM(serverCertPEM) 303 | 304 | server.TLS.ClientAuth = tls.RequireAndVerifyClientCert 305 | server.TLS.RootCAs = certPool 306 | server.TLS.ClientCAs = certPool 307 | 308 | server.StartTLS() 309 | defer server.Close() 310 | 311 | // Create a different cert/key pair that won't be accepted by the server 312 | certPEM, keyPEM := test.GenerateTestCertificate(time.Now().AddDate(0, 0, 1)) 313 | 314 | // Create cert file 315 | certFile, err := test.WriteFile("cert.pem", certPEM) 316 | if err != nil { 317 | t.Fatalf(err.Error()) 318 | } 319 | defer os.Remove(certFile) 320 | 321 | // Create key file 322 | keyFile, err := test.WriteFile("key.pem", keyPEM) 323 | if err != nil { 324 | t.Fatalf(err.Error()) 325 | } 326 | defer os.Remove(keyFile) 327 | 328 | module := config.Module{ 329 | TLSConfig: config.TLSConfig{ 330 | CAFile: caFile, 331 | CertFile: certFile, 332 | KeyFile: keyFile, 333 | InsecureSkipVerify: false, 334 | }, 335 | } 336 | 337 | registry := prometheus.NewRegistry() 338 | 339 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 340 | defer cancel() 341 | 342 | if err := ProbeHTTPS(ctx, newTestLogger(), server.URL, module, registry); err == nil { 343 | t.Fatalf("expected error but err is nil") 344 | } 345 | } 346 | 347 | // TestProbeHTTPSExpired tests that the probe fails with an expired server cert 348 | func TestProbeHTTPSExpired(t *testing.T) { 349 | server, _, _, caFile, teardown, err := test.SetupHTTPSServer() 350 | if err != nil { 351 | t.Fatalf(err.Error()) 352 | } 353 | defer teardown() 354 | 355 | // Create a certificate with a notAfter date in the past 356 | certPEM, keyPEM := test.GenerateTestCertificate(time.Now().AddDate(0, 0, -1)) 357 | testcert, err := tls.X509KeyPair(certPEM, keyPEM) 358 | if err != nil { 359 | t.Fatalf(err.Error()) 360 | } 361 | server.TLS.Certificates = []tls.Certificate{testcert} 362 | 363 | server.StartTLS() 364 | defer server.Close() 365 | 366 | module := config.Module{ 367 | TLSConfig: config.TLSConfig{ 368 | CAFile: caFile, 369 | InsecureSkipVerify: false, 370 | }, 371 | } 372 | 373 | registry := prometheus.NewRegistry() 374 | 375 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 376 | defer cancel() 377 | 378 | if err := ProbeHTTPS(ctx, newTestLogger(), server.URL, module, registry); err == nil { 379 | t.Fatalf("expected error but err is nil") 380 | } 381 | } 382 | 383 | // TestProbeHTTPSExpiredInsecure tests that the probe succeeds with an expired server cert 384 | // when skipping cert verification 385 | func TestProbeHTTPSExpiredInsecure(t *testing.T) { 386 | server, certPEM, _, caFile, teardown, err := test.SetupHTTPSServer() 387 | if err != nil { 388 | t.Fatalf(err.Error()) 389 | } 390 | defer teardown() 391 | 392 | // Create a certificate with a notAfter date in the past 393 | certPEM, keyPEM := test.GenerateTestCertificate(time.Now().AddDate(0, 0, -1)) 394 | testcert, err := tls.X509KeyPair(certPEM, keyPEM) 395 | if err != nil { 396 | t.Fatalf(err.Error()) 397 | } 398 | server.TLS.Certificates = []tls.Certificate{testcert} 399 | 400 | server.StartTLS() 401 | defer server.Close() 402 | 403 | module := config.Module{ 404 | TLSConfig: config.TLSConfig{ 405 | CAFile: caFile, 406 | InsecureSkipVerify: true, 407 | }, 408 | } 409 | 410 | registry := prometheus.NewRegistry() 411 | 412 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 413 | defer cancel() 414 | 415 | if err := ProbeHTTPS(ctx, newTestLogger(), server.URL, module, registry); err != nil { 416 | t.Fatalf("error: %s", err) 417 | } 418 | 419 | cert, err := newCertificate(certPEM) 420 | if err != nil { 421 | t.Fatal(err) 422 | } 423 | checkCertificateMetrics(cert, registry, t) 424 | checkOCSPMetrics([]byte{}, registry, t) 425 | checkTLSVersionMetrics("TLS 1.3", registry, t) 426 | } 427 | 428 | // TestProbeHTTPSProxy tests the proxy_url field in the configuration 429 | func TestProbeHTTPSProxy(t *testing.T) { 430 | server, certPEM, _, caFile, teardown, err := test.SetupHTTPSServer() 431 | if err != nil { 432 | t.Fatalf(err.Error()) 433 | } 434 | defer teardown() 435 | 436 | proxyServer, err := test.SetupHTTPProxyServer() 437 | if err != nil { 438 | t.Fatalf(err.Error()) 439 | } 440 | server.StartTLS() 441 | defer server.Close() 442 | 443 | proxyServer.Start() 444 | defer proxyServer.Close() 445 | 446 | proxyURL, err := url.Parse(proxyServer.URL) 447 | if err != nil { 448 | t.Fatalf(err.Error()) 449 | } 450 | 451 | badProxyURL, err := url.Parse("http://localhost:6666") 452 | if err != nil { 453 | t.Fatalf(err.Error()) 454 | } 455 | 456 | module := config.Module{ 457 | TLSConfig: config.TLSConfig{ 458 | CAFile: caFile, 459 | InsecureSkipVerify: false, 460 | }, 461 | HTTPS: config.HTTPSProbe{ 462 | // Test with a bad proxy url first 463 | ProxyURL: config.URL{URL: badProxyURL}, 464 | }, 465 | } 466 | 467 | registry := prometheus.NewRegistry() 468 | 469 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 470 | defer cancel() 471 | 472 | if err := ProbeHTTPS(ctx, newTestLogger(), server.URL, module, registry); err == nil { 473 | t.Fatalf("expected error but err was nil") 474 | } 475 | 476 | // Test with the proxy url, this shouldn't return an error 477 | module.HTTPS.ProxyURL = config.URL{URL: proxyURL} 478 | 479 | if err := ProbeHTTPS(ctx, newTestLogger(), server.URL, module, registry); err != nil { 480 | t.Fatalf("error: %s", err) 481 | } 482 | 483 | cert, err := newCertificate(certPEM) 484 | if err != nil { 485 | t.Fatal(err) 486 | } 487 | checkCertificateMetrics(cert, registry, t) 488 | checkOCSPMetrics([]byte{}, registry, t) 489 | checkTLSVersionMetrics("TLS 1.3", registry, t) 490 | } 491 | 492 | // TestProbeHTTPSOCSP tests a HTTPS probe with OCSP stapling 493 | func TestProbeHTTPSOCSP(t *testing.T) { 494 | server, certPEM, keyPEM, caFile, teardown, err := test.SetupHTTPSServer() 495 | if err != nil { 496 | t.Fatalf(err.Error()) 497 | } 498 | defer teardown() 499 | 500 | cert, err := newCertificate(certPEM) 501 | if err != nil { 502 | t.Fatal(err) 503 | } 504 | key, err := newKey(keyPEM) 505 | if err != nil { 506 | t.Fatal(err) 507 | } 508 | 509 | resp, err := ocsp.CreateResponse(cert, cert, ocsp.Response{SerialNumber: big.NewInt(64), Status: 1}, key) 510 | if err != nil { 511 | t.Fatalf(err.Error()) 512 | } 513 | server.TLS.Certificates[0].OCSPStaple = resp 514 | 515 | server.StartTLS() 516 | defer server.Close() 517 | 518 | module := config.Module{ 519 | TLSConfig: config.TLSConfig{ 520 | CAFile: caFile, 521 | }, 522 | } 523 | 524 | registry := prometheus.NewRegistry() 525 | 526 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 527 | defer cancel() 528 | 529 | if err := ProbeHTTPS(ctx, newTestLogger(), server.URL, module, registry); err != nil { 530 | t.Fatalf("error: %s", err) 531 | } 532 | 533 | checkCertificateMetrics(cert, registry, t) 534 | checkOCSPMetrics(resp, registry, t) 535 | checkTLSVersionMetrics("TLS 1.3", registry, t) 536 | } 537 | 538 | // TestProbeHTTPSVerifiedChains tests the verified chain metrics returned by a 539 | // https probe 540 | func TestProbeHTTPSVerifiedChains(t *testing.T) { 541 | rootPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) 542 | if err != nil { 543 | t.Fatalf(err.Error()) 544 | } 545 | 546 | rootCertExpiry := time.Now().AddDate(0, 0, 5) 547 | rootCertTmpl := test.GenerateCertificateTemplate(rootCertExpiry) 548 | rootCertTmpl.IsCA = true 549 | rootCertTmpl.SerialNumber = big.NewInt(1) 550 | rootCert, rootCertPem := test.GenerateSelfSignedCertificateWithPrivateKey(rootCertTmpl, rootPrivateKey) 551 | 552 | olderRootCertExpiry := time.Now().AddDate(0, 0, 3) 553 | olderRootCertTmpl := test.GenerateCertificateTemplate(olderRootCertExpiry) 554 | olderRootCertTmpl.IsCA = true 555 | olderRootCertTmpl.SerialNumber = big.NewInt(2) 556 | olderRootCert, olderRootCertPem := test.GenerateSelfSignedCertificateWithPrivateKey(olderRootCertTmpl, rootPrivateKey) 557 | 558 | oldestRootCertExpiry := time.Now().AddDate(0, 0, 1) 559 | oldestRootCertTmpl := test.GenerateCertificateTemplate(oldestRootCertExpiry) 560 | oldestRootCertTmpl.IsCA = true 561 | oldestRootCertTmpl.SerialNumber = big.NewInt(3) 562 | oldestRootCert, oldestRootCertPem := test.GenerateSelfSignedCertificateWithPrivateKey(oldestRootCertTmpl, rootPrivateKey) 563 | 564 | serverCertExpiry := time.Now().AddDate(0, 0, 4) 565 | serverCertTmpl := test.GenerateCertificateTemplate(serverCertExpiry) 566 | serverCertTmpl.SerialNumber = big.NewInt(4) 567 | serverCert, serverCertPem, serverKey := test.GenerateSignedCertificate(serverCertTmpl, olderRootCert, rootPrivateKey) 568 | 569 | verifiedChains := [][]*x509.Certificate{ 570 | []*x509.Certificate{ 571 | serverCert, 572 | rootCert, 573 | }, 574 | []*x509.Certificate{ 575 | serverCert, 576 | olderRootCert, 577 | }, 578 | []*x509.Certificate{ 579 | serverCert, 580 | oldestRootCert, 581 | }, 582 | } 583 | 584 | caCertPem := bytes.Join([][]byte{oldestRootCertPem, olderRootCertPem, rootCertPem}, []byte("")) 585 | 586 | server, caFile, teardown, err := test.SetupHTTPSServerWithCertAndKey( 587 | caCertPem, 588 | serverCertPem, 589 | serverKey, 590 | ) 591 | if err != nil { 592 | t.Fatalf(err.Error()) 593 | } 594 | defer teardown() 595 | 596 | server.StartTLS() 597 | defer server.Close() 598 | 599 | module := config.Module{ 600 | TLSConfig: config.TLSConfig{ 601 | CAFile: caFile, 602 | }, 603 | } 604 | 605 | registry := prometheus.NewRegistry() 606 | 607 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 608 | defer cancel() 609 | 610 | if err := ProbeHTTPS(ctx, newTestLogger(), server.URL, module, registry); err != nil { 611 | t.Fatalf("error: %s", err) 612 | } 613 | 614 | checkCertificateMetrics(serverCert, registry, t) 615 | checkOCSPMetrics([]byte{}, registry, t) 616 | checkVerifiedChainMetrics(verifiedChains, registry, t) 617 | checkTLSVersionMetrics("TLS 1.3", registry, t) 618 | } 619 | -------------------------------------------------------------------------------- /prober/kubeconfig.go: -------------------------------------------------------------------------------- 1 | package prober 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/go-kit/log" 11 | "github.com/prometheus/client_golang/prometheus" 12 | "github.com/ribbybibby/ssl_exporter/v2/config" 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | type KubeConfigCluster struct { 17 | Name string 18 | Cluster KubeConfigClusterCert 19 | } 20 | 21 | type KubeConfigClusterCert struct { 22 | CertificateAuthority string `yaml:"certificate-authority"` 23 | CertificateAuthorityData string `yaml:"certificate-authority-data"` 24 | } 25 | 26 | type KubeConfigUser struct { 27 | Name string 28 | User KubeConfigUserCert 29 | } 30 | 31 | type KubeConfigUserCert struct { 32 | ClientCertificate string `yaml:"client-certificate"` 33 | ClientCertificateData string `yaml:"client-certificate-data"` 34 | } 35 | 36 | type KubeConfig struct { 37 | Path string 38 | Clusters []KubeConfigCluster 39 | Users []KubeConfigUser 40 | } 41 | 42 | // ProbeKubeconfig collects certificate metrics from kubeconfig files 43 | func ProbeKubeconfig(ctx context.Context, logger log.Logger, target string, module config.Module, registry *prometheus.Registry) error { 44 | if _, err := os.Stat(target); err != nil { 45 | return fmt.Errorf("kubeconfig not found: %s", target) 46 | } 47 | k, err := ParseKubeConfig(target) 48 | if err != nil { 49 | return err 50 | } 51 | err = collectKubeconfigMetrics(logger, *k, registry) 52 | if err != nil { 53 | return err 54 | } 55 | return nil 56 | } 57 | 58 | func ParseKubeConfig(file string) (*KubeConfig, error) { 59 | k := &KubeConfig{} 60 | 61 | data, err := ioutil.ReadFile(file) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | err = yaml.Unmarshal([]byte(data), k) 67 | if err != nil { 68 | return nil, err 69 | } 70 | k.Path = file 71 | clusters := []KubeConfigCluster{} 72 | users := []KubeConfigUser{} 73 | for _, c := range k.Clusters { 74 | // Path is relative to kubeconfig path 75 | if c.Cluster.CertificateAuthority != "" && !filepath.IsAbs(c.Cluster.CertificateAuthority) { 76 | newPath := filepath.Join(filepath.Dir(k.Path), c.Cluster.CertificateAuthority) 77 | c.Cluster.CertificateAuthority = newPath 78 | } 79 | clusters = append(clusters, c) 80 | } 81 | for _, u := range k.Users { 82 | // Path is relative to kubeconfig path 83 | if u.User.ClientCertificate != "" && !filepath.IsAbs(u.User.ClientCertificate) { 84 | newPath := filepath.Join(filepath.Dir(k.Path), u.User.ClientCertificate) 85 | u.User.ClientCertificate = newPath 86 | } 87 | users = append(users, u) 88 | } 89 | k.Clusters = clusters 90 | k.Users = users 91 | return k, nil 92 | } 93 | -------------------------------------------------------------------------------- /prober/kubeconfig_test.go: -------------------------------------------------------------------------------- 1 | package prober 2 | 3 | import ( 4 | "context" 5 | "crypto/x509" 6 | "encoding/base64" 7 | "encoding/pem" 8 | "io/ioutil" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "testing" 13 | "time" 14 | 15 | "github.com/ribbybibby/ssl_exporter/v2/config" 16 | "github.com/ribbybibby/ssl_exporter/v2/test" 17 | 18 | "github.com/prometheus/client_golang/prometheus" 19 | "gopkg.in/yaml.v3" 20 | ) 21 | 22 | // TestProbeFile tests a file 23 | func TestProbeKubeconfig(t *testing.T) { 24 | cert, kubeconfig, err := createTestKubeconfig("", "kubeconfig") 25 | if err != nil { 26 | t.Fatalf(err.Error()) 27 | } 28 | defer os.Remove(kubeconfig) 29 | 30 | module := config.Module{} 31 | 32 | registry := prometheus.NewRegistry() 33 | 34 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 35 | defer cancel() 36 | 37 | if err := ProbeKubeconfig(ctx, newTestLogger(), kubeconfig, module, registry); err != nil { 38 | t.Fatalf("error: %s", err) 39 | } 40 | 41 | checkKubeconfigMetrics(cert, kubeconfig, registry, t) 42 | } 43 | 44 | func TestParseKubeConfigRelative(t *testing.T) { 45 | tmpFile, err := ioutil.TempFile("", "kubeconfig") 46 | if err != nil { 47 | t.Fatalf("Unable to create Tempfile: %s", err.Error()) 48 | } 49 | defer os.Remove(tmpFile.Name()) 50 | file := []byte(` 51 | clusters: 52 | - cluster: 53 | certificate-authority: certs/example/ca.pem 54 | server: https://master.example.com 55 | name: example 56 | users: 57 | - user: 58 | client-certificate: test/ca.pem 59 | name: example`) 60 | if _, err := tmpFile.Write(file); err != nil { 61 | t.Fatalf("Unable to write Tempfile: %s", err.Error()) 62 | } 63 | expectedClusterPath := filepath.Join(filepath.Dir(tmpFile.Name()), "certs/example/ca.pem") 64 | expectedUserPath := filepath.Join(filepath.Dir(tmpFile.Name()), "test/ca.pem") 65 | k, err := ParseKubeConfig(tmpFile.Name()) 66 | if err != nil { 67 | t.Fatalf("Error parsing kubeconfig: %s", err.Error()) 68 | } 69 | if len(k.Clusters) != 1 { 70 | t.Fatalf("Unexpected length for Clusters, got %d", len(k.Clusters)) 71 | } 72 | if k.Clusters[0].Cluster.CertificateAuthority != expectedClusterPath { 73 | t.Errorf("Unexpected CertificateAuthority value\nExpected: %s\nGot: %s", expectedClusterPath, k.Clusters[0].Cluster.CertificateAuthority) 74 | } 75 | if len(k.Users) != 1 { 76 | t.Fatalf("Unexpected length for Users, got %d", len(k.Users)) 77 | } 78 | if k.Users[0].User.ClientCertificate != expectedUserPath { 79 | t.Errorf("Unexpected ClientCertificate value\nExpected: %s\nGot: %s", expectedUserPath, k.Users[0].User.ClientCertificate) 80 | } 81 | } 82 | 83 | // Create a certificate and write it to a file 84 | func createTestKubeconfig(dir, filename string) (*x509.Certificate, string, error) { 85 | certPEM, _ := test.GenerateTestCertificate(time.Now().Add(time.Hour * 1)) 86 | clusterCert := KubeConfigClusterCert{CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(certPEM))} 87 | clusters := []KubeConfigCluster{KubeConfigCluster{Name: "kubernetes", Cluster: clusterCert}} 88 | userCert := KubeConfigUserCert{ClientCertificateData: base64.StdEncoding.EncodeToString([]byte(certPEM))} 89 | users := []KubeConfigUser{KubeConfigUser{Name: "kubernetes-admin", User: userCert}} 90 | k := KubeConfig{ 91 | Clusters: clusters, 92 | Users: users, 93 | } 94 | block, _ := pem.Decode([]byte(certPEM)) 95 | cert, err := x509.ParseCertificate(block.Bytes) 96 | if err != nil { 97 | return nil, "", err 98 | } 99 | tmpFile, err := ioutil.TempFile(dir, filename) 100 | if err != nil { 101 | return nil, tmpFile.Name(), err 102 | } 103 | k.Path = tmpFile.Name() 104 | d, err := yaml.Marshal(&k) 105 | if err != nil { 106 | return nil, tmpFile.Name(), err 107 | } 108 | if _, err := tmpFile.Write(d); err != nil { 109 | return nil, tmpFile.Name(), err 110 | } 111 | if err := tmpFile.Close(); err != nil { 112 | return nil, tmpFile.Name(), err 113 | } 114 | 115 | return cert, tmpFile.Name(), nil 116 | } 117 | 118 | // Check metrics 119 | func checkKubeconfigMetrics(cert *x509.Certificate, kubeconfig string, registry *prometheus.Registry, t *testing.T) { 120 | mfs, err := registry.Gather() 121 | if err != nil { 122 | t.Fatal(err) 123 | } 124 | ips := "," 125 | for _, ip := range cert.IPAddresses { 126 | ips = ips + ip.String() + "," 127 | } 128 | expectedResults := []*registryResult{ 129 | ®istryResult{ 130 | Name: "ssl_kubeconfig_cert_not_after", 131 | LabelValues: map[string]string{ 132 | "kubeconfig": kubeconfig, 133 | "name": "kubernetes", 134 | "type": "cluster", 135 | "serial_no": cert.SerialNumber.String(), 136 | "issuer_cn": cert.Issuer.CommonName, 137 | "cn": cert.Subject.CommonName, 138 | "dnsnames": "," + strings.Join(cert.DNSNames, ",") + ",", 139 | "ips": ips, 140 | "emails": "," + strings.Join(cert.EmailAddresses, ",") + ",", 141 | "ou": "," + strings.Join(cert.Subject.OrganizationalUnit, ",") + ",", 142 | }, 143 | Value: float64(cert.NotAfter.Unix()), 144 | }, 145 | ®istryResult{ 146 | Name: "ssl_kubeconfig_cert_not_before", 147 | LabelValues: map[string]string{ 148 | "kubeconfig": kubeconfig, 149 | "name": "kubernetes", 150 | "type": "cluster", 151 | "serial_no": cert.SerialNumber.String(), 152 | "issuer_cn": cert.Issuer.CommonName, 153 | "cn": cert.Subject.CommonName, 154 | "dnsnames": "," + strings.Join(cert.DNSNames, ",") + ",", 155 | "ips": ips, 156 | "emails": "," + strings.Join(cert.EmailAddresses, ",") + ",", 157 | "ou": "," + strings.Join(cert.Subject.OrganizationalUnit, ",") + ",", 158 | }, 159 | Value: float64(cert.NotBefore.Unix()), 160 | }, 161 | ®istryResult{ 162 | Name: "ssl_kubeconfig_cert_not_after", 163 | LabelValues: map[string]string{ 164 | "kubeconfig": kubeconfig, 165 | "name": "kubernetes-admin", 166 | "type": "user", 167 | "serial_no": cert.SerialNumber.String(), 168 | "issuer_cn": cert.Issuer.CommonName, 169 | "cn": cert.Subject.CommonName, 170 | "dnsnames": "," + strings.Join(cert.DNSNames, ",") + ",", 171 | "ips": ips, 172 | "emails": "," + strings.Join(cert.EmailAddresses, ",") + ",", 173 | "ou": "," + strings.Join(cert.Subject.OrganizationalUnit, ",") + ",", 174 | }, 175 | Value: float64(cert.NotAfter.Unix()), 176 | }, 177 | ®istryResult{ 178 | Name: "ssl_kubeconfig_cert_not_before", 179 | LabelValues: map[string]string{ 180 | "kubeconfig": kubeconfig, 181 | "name": "kubernetes-admin", 182 | "type": "user", 183 | "serial_no": cert.SerialNumber.String(), 184 | "issuer_cn": cert.Issuer.CommonName, 185 | "cn": cert.Subject.CommonName, 186 | "dnsnames": "," + strings.Join(cert.DNSNames, ",") + ",", 187 | "ips": ips, 188 | "emails": "," + strings.Join(cert.EmailAddresses, ",") + ",", 189 | "ou": "," + strings.Join(cert.Subject.OrganizationalUnit, ",") + ",", 190 | }, 191 | Value: float64(cert.NotBefore.Unix()), 192 | }, 193 | } 194 | checkRegistryResults(expectedResults, mfs, t) 195 | } 196 | -------------------------------------------------------------------------------- /prober/kubernetes.go: -------------------------------------------------------------------------------- 1 | package prober 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/bmatcuk/doublestar/v2" 9 | "github.com/go-kit/log" 10 | "github.com/prometheus/client_golang/prometheus" 11 | "github.com/ribbybibby/ssl_exporter/v2/config" 12 | v1 "k8s.io/api/core/v1" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/client-go/kubernetes" 15 | "k8s.io/client-go/tools/clientcmd" 16 | 17 | // Support oidc in kube config files 18 | _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" 19 | ) 20 | 21 | var ( 22 | // ErrKubeBadTarget is returned when the target doesn't match the 23 | // expected form for the kubernetes prober 24 | ErrKubeBadTarget = fmt.Errorf("Target secret must be provided in the form: /") 25 | ) 26 | 27 | // ProbeKubernetes collects certificate metrics from kubernetes.io/tls Secrets 28 | func ProbeKubernetes(ctx context.Context, logger log.Logger, target string, module config.Module, registry *prometheus.Registry) error { 29 | client, err := newKubeClient(module.Kubernetes.Kubeconfig) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | return probeKubernetes(ctx, target, module, registry, client) 35 | } 36 | 37 | func probeKubernetes(ctx context.Context, target string, module config.Module, registry *prometheus.Registry, client kubernetes.Interface) error { 38 | parts := strings.Split(target, "/") 39 | if len(parts) != 2 || parts[0] == "" || parts[1] == "" { 40 | return ErrKubeBadTarget 41 | } 42 | 43 | ns := parts[0] 44 | name := parts[1] 45 | 46 | var tlsSecrets []v1.Secret 47 | secrets, err := client.CoreV1().Secrets("").List(ctx, metav1.ListOptions{FieldSelector: "type=kubernetes.io/tls"}) 48 | if err != nil { 49 | return err 50 | } 51 | for _, secret := range secrets.Items { 52 | nMatch, err := doublestar.Match(ns, secret.Namespace) 53 | if err != nil { 54 | return err 55 | } 56 | sMatch, err := doublestar.Match(name, secret.Name) 57 | if err != nil { 58 | return err 59 | } 60 | if nMatch && sMatch { 61 | tlsSecrets = append(tlsSecrets, secret) 62 | } 63 | } 64 | 65 | return collectKubernetesSecretMetrics(tlsSecrets, registry) 66 | } 67 | 68 | // newKubeClient returns a Kubernetes client (clientset) from the supplied 69 | // kubeconfig path, the KUBECONFIG environment variable, the default config file 70 | // location ($HOME/.kube/config) or from the in-cluster service account environment. 71 | func newKubeClient(path string) (*kubernetes.Clientset, error) { 72 | loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() 73 | if path != "" { 74 | loadingRules.ExplicitPath = path 75 | } 76 | kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( 77 | loadingRules, 78 | &clientcmd.ConfigOverrides{}, 79 | ) 80 | config, err := kubeConfig.ClientConfig() 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | return kubernetes.NewForConfig(config) 86 | } 87 | -------------------------------------------------------------------------------- /prober/kubernetes_test.go: -------------------------------------------------------------------------------- 1 | package prober 2 | 3 | import ( 4 | "context" 5 | "crypto/x509" 6 | "encoding/pem" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/prometheus/client_golang/prometheus" 12 | "github.com/ribbybibby/ssl_exporter/v2/config" 13 | "github.com/ribbybibby/ssl_exporter/v2/test" 14 | v1 "k8s.io/api/core/v1" 15 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | "k8s.io/client-go/kubernetes/fake" 17 | ) 18 | 19 | func TestKubernetesProbe(t *testing.T) { 20 | certPEM, _ := test.GenerateTestCertificate(time.Now().Add(time.Hour * 1)) 21 | block, _ := pem.Decode([]byte(certPEM)) 22 | cert, err := x509.ParseCertificate(block.Bytes) 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | caPEM, _ := test.GenerateTestCertificate(time.Now().Add(time.Hour * 10)) 28 | block, _ = pem.Decode([]byte(caPEM)) 29 | caCert, err := x509.ParseCertificate(block.Bytes) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | fakeKubeClient := fake.NewSimpleClientset(&v1.Secret{ 35 | ObjectMeta: metav1.ObjectMeta{ 36 | Name: "foo", 37 | Namespace: "bar", 38 | }, 39 | Data: map[string][]byte{ 40 | "tls.crt": certPEM, 41 | "ca.crt": caPEM, 42 | }, 43 | Type: "kubernetes.io/tls", 44 | }) 45 | 46 | module := config.Module{} 47 | 48 | registry := prometheus.NewRegistry() 49 | 50 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 51 | defer cancel() 52 | 53 | if err := probeKubernetes(ctx, "bar/foo", module, registry, fakeKubeClient); err != nil { 54 | t.Fatalf("error: %s", err) 55 | } 56 | 57 | checkKubernetesMetrics(cert, "bar", "foo", "tls.crt", registry, t) 58 | checkKubernetesMetrics(caCert, "bar", "foo", "ca.crt", registry, t) 59 | } 60 | 61 | func TestKubernetesProbeGlob(t *testing.T) { 62 | certPEM, _ := test.GenerateTestCertificate(time.Now().Add(time.Hour * 1)) 63 | block, _ := pem.Decode([]byte(certPEM)) 64 | cert, err := x509.ParseCertificate(block.Bytes) 65 | if err != nil { 66 | t.Fatal(err) 67 | } 68 | 69 | caPEM, _ := test.GenerateTestCertificate(time.Now().Add(time.Hour * 10)) 70 | block, _ = pem.Decode([]byte(caPEM)) 71 | caCert, err := x509.ParseCertificate(block.Bytes) 72 | if err != nil { 73 | t.Fatal(err) 74 | } 75 | 76 | certPEM2, _ := test.GenerateTestCertificate(time.Now().Add(time.Hour * 1)) 77 | block, _ = pem.Decode([]byte(certPEM2)) 78 | cert2, err := x509.ParseCertificate(block.Bytes) 79 | if err != nil { 80 | t.Fatal(err) 81 | } 82 | 83 | caPEM2, _ := test.GenerateTestCertificate(time.Now().Add(time.Hour * 10)) 84 | block, _ = pem.Decode([]byte(caPEM2)) 85 | caCert2, err := x509.ParseCertificate(block.Bytes) 86 | if err != nil { 87 | t.Fatal(err) 88 | } 89 | 90 | fakeKubeClient := fake.NewSimpleClientset(&v1.Secret{ 91 | ObjectMeta: metav1.ObjectMeta{ 92 | Name: "foo", 93 | Namespace: "bar", 94 | }, 95 | Data: map[string][]byte{ 96 | "tls.crt": certPEM, 97 | "ca.crt": caPEM, 98 | }, 99 | Type: "kubernetes.io/tls", 100 | }, 101 | &v1.Secret{ 102 | ObjectMeta: metav1.ObjectMeta{ 103 | Name: "fooz", 104 | Namespace: "baz", 105 | }, 106 | Data: map[string][]byte{ 107 | "tls.crt": certPEM2, 108 | "ca.crt": caPEM2, 109 | }, 110 | Type: "kubernetes.io/tls", 111 | }) 112 | 113 | module := config.Module{} 114 | 115 | registry := prometheus.NewRegistry() 116 | 117 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 118 | defer cancel() 119 | 120 | if err := probeKubernetes(ctx, "ba*/*", module, registry, fakeKubeClient); err != nil { 121 | t.Fatalf("error: %s", err) 122 | } 123 | 124 | checkKubernetesMetrics(cert, "bar", "foo", "tls.crt", registry, t) 125 | checkKubernetesMetrics(caCert, "bar", "foo", "ca.crt", registry, t) 126 | checkKubernetesMetrics(cert2, "baz", "fooz", "tls.crt", registry, t) 127 | checkKubernetesMetrics(caCert2, "baz", "fooz", "ca.crt", registry, t) 128 | } 129 | 130 | func TestKubernetesProbeBadTarget(t *testing.T) { 131 | fakeKubeClient := fake.NewSimpleClientset() 132 | 133 | module := config.Module{} 134 | 135 | registry := prometheus.NewRegistry() 136 | 137 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 138 | defer cancel() 139 | 140 | if err := probeKubernetes(ctx, "bar/foo/bar", module, registry, fakeKubeClient); err != ErrKubeBadTarget { 141 | t.Fatalf("Expected error: %v, but got %v", ErrKubeBadTarget, err) 142 | } 143 | } 144 | 145 | func checkKubernetesMetrics(cert *x509.Certificate, namespace, name, key string, registry *prometheus.Registry, t *testing.T) { 146 | mfs, err := registry.Gather() 147 | if err != nil { 148 | t.Fatal(err) 149 | } 150 | 151 | ips := "," 152 | for _, ip := range cert.IPAddresses { 153 | ips = ips + ip.String() + "," 154 | } 155 | expectedResults := []*registryResult{ 156 | ®istryResult{ 157 | Name: "ssl_kubernetes_cert_not_after", 158 | LabelValues: map[string]string{ 159 | "namespace": namespace, 160 | "secret": name, 161 | "key": key, 162 | "serial_no": cert.SerialNumber.String(), 163 | "issuer_cn": cert.Issuer.CommonName, 164 | "cn": cert.Subject.CommonName, 165 | "dnsnames": "," + strings.Join(cert.DNSNames, ",") + ",", 166 | "ips": ips, 167 | "emails": "," + strings.Join(cert.EmailAddresses, ",") + ",", 168 | "ou": "," + strings.Join(cert.Subject.OrganizationalUnit, ",") + ",", 169 | }, 170 | Value: float64(cert.NotAfter.Unix()), 171 | }, 172 | ®istryResult{ 173 | Name: "ssl_kubernetes_cert_not_before", 174 | LabelValues: map[string]string{ 175 | "namespace": namespace, 176 | "secret": name, 177 | "key": key, 178 | "serial_no": cert.SerialNumber.String(), 179 | "issuer_cn": cert.Issuer.CommonName, 180 | "cn": cert.Subject.CommonName, 181 | "dnsnames": "," + strings.Join(cert.DNSNames, ",") + ",", 182 | "ips": ips, 183 | "emails": "," + strings.Join(cert.EmailAddresses, ",") + ",", 184 | "ou": "," + strings.Join(cert.Subject.OrganizationalUnit, ",") + ",", 185 | }, 186 | Value: float64(cert.NotBefore.Unix()), 187 | }, 188 | } 189 | checkRegistryResults(expectedResults, mfs, t) 190 | } 191 | -------------------------------------------------------------------------------- /prober/metrics.go: -------------------------------------------------------------------------------- 1 | package prober 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "encoding/base64" 7 | "fmt" 8 | "io/ioutil" 9 | "sort" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/go-kit/log" 15 | "github.com/go-kit/log/level" 16 | "github.com/prometheus/client_golang/prometheus" 17 | "golang.org/x/crypto/ocsp" 18 | v1 "k8s.io/api/core/v1" 19 | ) 20 | 21 | const ( 22 | namespace = "ssl" 23 | ) 24 | 25 | func collectConnectionStateMetrics(state tls.ConnectionState, registry *prometheus.Registry) error { 26 | if err := collectTLSVersionMetrics(state.Version, registry); err != nil { 27 | return err 28 | } 29 | 30 | if err := collectCertificateMetrics(state.PeerCertificates, registry); err != nil { 31 | return err 32 | } 33 | 34 | if err := collectVerifiedChainMetrics(state.VerifiedChains, registry); err != nil { 35 | return err 36 | } 37 | 38 | return collectOCSPMetrics(state.OCSPResponse, registry) 39 | } 40 | 41 | func collectTLSVersionMetrics(version uint16, registry *prometheus.Registry) error { 42 | var ( 43 | tlsVersion = prometheus.NewGaugeVec( 44 | prometheus.GaugeOpts{ 45 | Name: prometheus.BuildFQName(namespace, "", "tls_version_info"), 46 | Help: "The TLS version used", 47 | }, 48 | []string{"version"}, 49 | ) 50 | ) 51 | registry.MustRegister(tlsVersion) 52 | 53 | var v string 54 | switch version { 55 | case tls.VersionTLS10: 56 | v = "TLS 1.0" 57 | case tls.VersionTLS11: 58 | v = "TLS 1.1" 59 | case tls.VersionTLS12: 60 | v = "TLS 1.2" 61 | case tls.VersionTLS13: 62 | v = "TLS 1.3" 63 | default: 64 | v = "unknown" 65 | } 66 | 67 | tlsVersion.WithLabelValues(v).Set(1) 68 | 69 | return nil 70 | } 71 | 72 | func collectCertificateMetrics(certs []*x509.Certificate, registry *prometheus.Registry) error { 73 | var ( 74 | notAfter = prometheus.NewGaugeVec( 75 | prometheus.GaugeOpts{ 76 | Name: prometheus.BuildFQName(namespace, "", "cert_not_after"), 77 | Help: "NotAfter expressed as a Unix Epoch Time", 78 | }, 79 | []string{"serial_no", "issuer_cn", "cn", "dnsnames", "ips", "emails", "ou"}, 80 | ) 81 | notBefore = prometheus.NewGaugeVec( 82 | prometheus.GaugeOpts{ 83 | Name: prometheus.BuildFQName(namespace, "", "cert_not_before"), 84 | Help: "NotBefore expressed as a Unix Epoch Time", 85 | }, 86 | []string{"serial_no", "issuer_cn", "cn", "dnsnames", "ips", "emails", "ou"}, 87 | ) 88 | ) 89 | registry.MustRegister(notAfter, notBefore) 90 | 91 | certs = uniq(certs) 92 | 93 | if len(certs) == 0 { 94 | return fmt.Errorf("No certificates found") 95 | } 96 | 97 | for _, cert := range certs { 98 | labels := labelValues(cert) 99 | 100 | if !cert.NotAfter.IsZero() { 101 | notAfter.WithLabelValues(labels...).Set(float64(cert.NotAfter.Unix())) 102 | } 103 | 104 | if !cert.NotBefore.IsZero() { 105 | notBefore.WithLabelValues(labels...).Set(float64(cert.NotBefore.Unix())) 106 | } 107 | } 108 | 109 | return nil 110 | } 111 | 112 | func collectVerifiedChainMetrics(verifiedChains [][]*x509.Certificate, registry *prometheus.Registry) error { 113 | var ( 114 | verifiedNotAfter = prometheus.NewGaugeVec( 115 | prometheus.GaugeOpts{ 116 | Name: prometheus.BuildFQName(namespace, "", "verified_cert_not_after"), 117 | Help: "NotAfter expressed as a Unix Epoch Time", 118 | }, 119 | []string{"chain_no", "serial_no", "issuer_cn", "cn", "dnsnames", "ips", "emails", "ou"}, 120 | ) 121 | verifiedNotBefore = prometheus.NewGaugeVec( 122 | prometheus.GaugeOpts{ 123 | Name: prometheus.BuildFQName(namespace, "", "verified_cert_not_before"), 124 | Help: "NotBefore expressed as a Unix Epoch Time", 125 | }, 126 | []string{"chain_no", "serial_no", "issuer_cn", "cn", "dnsnames", "ips", "emails", "ou"}, 127 | ) 128 | ) 129 | registry.MustRegister(verifiedNotAfter, verifiedNotBefore) 130 | 131 | sort.Slice(verifiedChains, func(i, j int) bool { 132 | iExpiry := time.Time{} 133 | for _, cert := range verifiedChains[i] { 134 | if (iExpiry.IsZero() || cert.NotAfter.Before(iExpiry)) && !cert.NotAfter.IsZero() { 135 | iExpiry = cert.NotAfter 136 | } 137 | } 138 | jExpiry := time.Time{} 139 | for _, cert := range verifiedChains[j] { 140 | if (jExpiry.IsZero() || cert.NotAfter.Before(jExpiry)) && !cert.NotAfter.IsZero() { 141 | jExpiry = cert.NotAfter 142 | } 143 | } 144 | 145 | return iExpiry.After(jExpiry) 146 | }) 147 | 148 | for i, chain := range verifiedChains { 149 | chain = uniq(chain) 150 | for _, cert := range chain { 151 | chainNo := strconv.Itoa(i) 152 | labels := append([]string{chainNo}, labelValues(cert)...) 153 | 154 | if !cert.NotAfter.IsZero() { 155 | verifiedNotAfter.WithLabelValues(labels...).Set(float64(cert.NotAfter.Unix())) 156 | } 157 | 158 | if !cert.NotBefore.IsZero() { 159 | verifiedNotBefore.WithLabelValues(labels...).Set(float64(cert.NotBefore.Unix())) 160 | } 161 | } 162 | } 163 | 164 | return nil 165 | } 166 | 167 | func collectOCSPMetrics(ocspResponse []byte, registry *prometheus.Registry) error { 168 | var ( 169 | ocspStapled = prometheus.NewGauge( 170 | prometheus.GaugeOpts{ 171 | Name: prometheus.BuildFQName(namespace, "", "ocsp_response_stapled"), 172 | Help: "If the connection state contains a stapled OCSP response", 173 | }, 174 | ) 175 | ocspStatus = prometheus.NewGauge( 176 | prometheus.GaugeOpts{ 177 | Name: prometheus.BuildFQName(namespace, "", "ocsp_response_status"), 178 | Help: "The status in the OCSP response 0=Good 1=Revoked 2=Unknown", 179 | }, 180 | ) 181 | ocspProducedAt = prometheus.NewGauge( 182 | prometheus.GaugeOpts{ 183 | Name: prometheus.BuildFQName(namespace, "", "ocsp_response_produced_at"), 184 | Help: "The producedAt value in the OCSP response, expressed as a Unix Epoch Time", 185 | }, 186 | ) 187 | ocspThisUpdate = prometheus.NewGauge( 188 | prometheus.GaugeOpts{ 189 | Name: prometheus.BuildFQName(namespace, "", "ocsp_response_this_update"), 190 | Help: "The thisUpdate value in the OCSP response, expressed as a Unix Epoch Time", 191 | }, 192 | ) 193 | ocspNextUpdate = prometheus.NewGauge( 194 | prometheus.GaugeOpts{ 195 | Name: prometheus.BuildFQName(namespace, "", "ocsp_response_next_update"), 196 | Help: "The nextUpdate value in the OCSP response, expressed as a Unix Epoch Time", 197 | }, 198 | ) 199 | ocspRevokedAt = prometheus.NewGauge( 200 | prometheus.GaugeOpts{ 201 | Name: prometheus.BuildFQName(namespace, "", "ocsp_response_revoked_at"), 202 | Help: "The revocationTime value in the OCSP response, expressed as a Unix Epoch Time", 203 | }, 204 | ) 205 | ) 206 | registry.MustRegister( 207 | ocspStapled, 208 | ocspStatus, 209 | ocspProducedAt, 210 | ocspThisUpdate, 211 | ocspNextUpdate, 212 | ocspRevokedAt, 213 | ) 214 | 215 | if len(ocspResponse) == 0 { 216 | return nil 217 | } 218 | 219 | resp, err := ocsp.ParseResponse(ocspResponse, nil) 220 | if err != nil { 221 | return err 222 | } 223 | 224 | ocspStapled.Set(1) 225 | ocspStatus.Set(float64(resp.Status)) 226 | ocspProducedAt.Set(float64(resp.ProducedAt.Unix())) 227 | ocspThisUpdate.Set(float64(resp.ThisUpdate.Unix())) 228 | ocspNextUpdate.Set(float64(resp.NextUpdate.Unix())) 229 | ocspRevokedAt.Set(float64(resp.RevokedAt.Unix())) 230 | 231 | return nil 232 | } 233 | 234 | func collectFileMetrics(logger log.Logger, files []string, registry *prometheus.Registry) error { 235 | var ( 236 | totalCerts []*x509.Certificate 237 | fileNotAfter = prometheus.NewGaugeVec( 238 | prometheus.GaugeOpts{ 239 | Name: prometheus.BuildFQName(namespace, "", "file_cert_not_after"), 240 | Help: "NotAfter expressed as a Unix Epoch Time for a certificate found in a file", 241 | }, 242 | []string{"file", "serial_no", "issuer_cn", "cn", "dnsnames", "ips", "emails", "ou"}, 243 | ) 244 | fileNotBefore = prometheus.NewGaugeVec( 245 | prometheus.GaugeOpts{ 246 | Name: prometheus.BuildFQName(namespace, "", "file_cert_not_before"), 247 | Help: "NotBefore expressed as a Unix Epoch Time for a certificate found in a file", 248 | }, 249 | []string{"file", "serial_no", "issuer_cn", "cn", "dnsnames", "ips", "emails", "ou"}, 250 | ) 251 | ) 252 | registry.MustRegister(fileNotAfter, fileNotBefore) 253 | 254 | for _, f := range files { 255 | data, err := ioutil.ReadFile(f) 256 | if err != nil { 257 | level.Debug(logger).Log("msg", fmt.Sprintf("Error reading file %s: %s", f, err)) 258 | continue 259 | } 260 | certs, err := decodeCertificates(data) 261 | if err != nil { 262 | return err 263 | } 264 | totalCerts = append(totalCerts, certs...) 265 | for _, cert := range certs { 266 | labels := append([]string{f}, labelValues(cert)...) 267 | 268 | if !cert.NotAfter.IsZero() { 269 | fileNotAfter.WithLabelValues(labels...).Set(float64(cert.NotAfter.Unix())) 270 | } 271 | 272 | if !cert.NotBefore.IsZero() { 273 | fileNotBefore.WithLabelValues(labels...).Set(float64(cert.NotBefore.Unix())) 274 | } 275 | } 276 | } 277 | 278 | if len(totalCerts) == 0 { 279 | return fmt.Errorf("No certificates found") 280 | } 281 | 282 | return nil 283 | } 284 | 285 | func collectKubernetesSecretMetrics(secrets []v1.Secret, registry *prometheus.Registry) error { 286 | var ( 287 | totalCerts []*x509.Certificate 288 | kubernetesNotAfter = prometheus.NewGaugeVec( 289 | prometheus.GaugeOpts{ 290 | Name: prometheus.BuildFQName(namespace, "", "kubernetes_cert_not_after"), 291 | Help: "NotAfter expressed as a Unix Epoch Time for a certificate found in a kubernetes secret", 292 | }, 293 | []string{"namespace", "secret", "key", "serial_no", "issuer_cn", "cn", "dnsnames", "ips", "emails", "ou"}, 294 | ) 295 | kubernetesNotBefore = prometheus.NewGaugeVec( 296 | prometheus.GaugeOpts{ 297 | Name: prometheus.BuildFQName(namespace, "", "kubernetes_cert_not_before"), 298 | Help: "NotBefore expressed as a Unix Epoch Time for a certificate found in a kubernetes secret", 299 | }, 300 | []string{"namespace", "secret", "key", "serial_no", "issuer_cn", "cn", "dnsnames", "ips", "emails", "ou"}, 301 | ) 302 | ) 303 | registry.MustRegister(kubernetesNotAfter, kubernetesNotBefore) 304 | 305 | for _, secret := range secrets { 306 | for _, key := range []string{"tls.crt", "ca.crt"} { 307 | data := secret.Data[key] 308 | if len(data) == 0 { 309 | continue 310 | } 311 | certs, err := decodeCertificates(data) 312 | if err != nil { 313 | return err 314 | } 315 | totalCerts = append(totalCerts, certs...) 316 | for _, cert := range certs { 317 | labels := append([]string{secret.Namespace, secret.Name, key}, labelValues(cert)...) 318 | 319 | if !cert.NotAfter.IsZero() { 320 | kubernetesNotAfter.WithLabelValues(labels...).Set(float64(cert.NotAfter.Unix())) 321 | } 322 | 323 | if !cert.NotBefore.IsZero() { 324 | kubernetesNotBefore.WithLabelValues(labels...).Set(float64(cert.NotBefore.Unix())) 325 | } 326 | } 327 | } 328 | } 329 | 330 | if len(totalCerts) == 0 { 331 | return fmt.Errorf("No certificates found") 332 | } 333 | 334 | return nil 335 | } 336 | 337 | func collectKubeconfigMetrics(logger log.Logger, kubeconfig KubeConfig, registry *prometheus.Registry) error { 338 | var ( 339 | totalCerts []*x509.Certificate 340 | kubeconfigNotAfter = prometheus.NewGaugeVec( 341 | prometheus.GaugeOpts{ 342 | Name: prometheus.BuildFQName(namespace, "kubeconfig", "cert_not_after"), 343 | Help: "NotAfter expressed as a Unix Epoch Time for a certificate found in a kubeconfig", 344 | }, 345 | []string{"kubeconfig", "name", "type", "serial_no", "issuer_cn", "cn", "dnsnames", "ips", "emails", "ou"}, 346 | ) 347 | kubeconfigNotBefore = prometheus.NewGaugeVec( 348 | prometheus.GaugeOpts{ 349 | Name: prometheus.BuildFQName(namespace, "kubeconfig", "cert_not_before"), 350 | Help: "NotBefore expressed as a Unix Epoch Time for a certificate found in a kubeconfig", 351 | }, 352 | []string{"kubeconfig", "name", "type", "serial_no", "issuer_cn", "cn", "dnsnames", "ips", "emails", "ou"}, 353 | ) 354 | ) 355 | registry.MustRegister(kubeconfigNotAfter, kubeconfigNotBefore) 356 | 357 | for _, c := range kubeconfig.Clusters { 358 | var data []byte 359 | var err error 360 | if c.Cluster.CertificateAuthorityData != "" { 361 | data, err = base64.StdEncoding.DecodeString(c.Cluster.CertificateAuthorityData) 362 | if err != nil { 363 | return err 364 | } 365 | } else if c.Cluster.CertificateAuthority != "" { 366 | data, err = ioutil.ReadFile(c.Cluster.CertificateAuthority) 367 | if err != nil { 368 | level.Debug(logger).Log("msg", fmt.Sprintf("Error reading file %s: %s", c.Cluster.CertificateAuthority, err)) 369 | return err 370 | } 371 | } 372 | if data == nil { 373 | continue 374 | } 375 | certs, err := decodeCertificates(data) 376 | if err != nil { 377 | return err 378 | } 379 | totalCerts = append(totalCerts, certs...) 380 | for _, cert := range certs { 381 | labels := append([]string{kubeconfig.Path, c.Name, "cluster"}, labelValues(cert)...) 382 | 383 | if !cert.NotAfter.IsZero() { 384 | kubeconfigNotAfter.WithLabelValues(labels...).Set(float64(cert.NotAfter.Unix())) 385 | } 386 | 387 | if !cert.NotBefore.IsZero() { 388 | kubeconfigNotBefore.WithLabelValues(labels...).Set(float64(cert.NotBefore.Unix())) 389 | } 390 | } 391 | } 392 | 393 | for _, u := range kubeconfig.Users { 394 | var data []byte 395 | var err error 396 | if u.User.ClientCertificateData != "" { 397 | data, err = base64.StdEncoding.DecodeString(u.User.ClientCertificateData) 398 | if err != nil { 399 | return err 400 | } 401 | } else if u.User.ClientCertificate != "" { 402 | data, err = ioutil.ReadFile(u.User.ClientCertificate) 403 | if err != nil { 404 | level.Debug(logger).Log("msg", fmt.Sprintf("Error reading file %s: %s", u.User.ClientCertificate, err)) 405 | return err 406 | } 407 | } 408 | if data == nil { 409 | continue 410 | } 411 | certs, err := decodeCertificates(data) 412 | if err != nil { 413 | return err 414 | } 415 | totalCerts = append(totalCerts, certs...) 416 | for _, cert := range certs { 417 | labels := append([]string{kubeconfig.Path, u.Name, "user"}, labelValues(cert)...) 418 | 419 | if !cert.NotAfter.IsZero() { 420 | kubeconfigNotAfter.WithLabelValues(labels...).Set(float64(cert.NotAfter.Unix())) 421 | } 422 | 423 | if !cert.NotBefore.IsZero() { 424 | kubeconfigNotBefore.WithLabelValues(labels...).Set(float64(cert.NotBefore.Unix())) 425 | } 426 | } 427 | } 428 | 429 | if len(totalCerts) == 0 { 430 | return fmt.Errorf("No certificates found") 431 | } 432 | 433 | return nil 434 | } 435 | 436 | func labelValues(cert *x509.Certificate) []string { 437 | return []string{ 438 | cert.SerialNumber.String(), 439 | cert.Issuer.CommonName, 440 | cert.Subject.CommonName, 441 | dnsNames(cert), 442 | ipAddresses(cert), 443 | emailAddresses(cert), 444 | organizationalUnits(cert), 445 | } 446 | } 447 | 448 | func dnsNames(cert *x509.Certificate) string { 449 | if len(cert.DNSNames) > 0 { 450 | return "," + strings.Join(cert.DNSNames, ",") + "," 451 | } 452 | 453 | return "" 454 | } 455 | 456 | func emailAddresses(cert *x509.Certificate) string { 457 | if len(cert.EmailAddresses) > 0 { 458 | return "," + strings.Join(cert.EmailAddresses, ",") + "," 459 | } 460 | 461 | return "" 462 | } 463 | 464 | func ipAddresses(cert *x509.Certificate) string { 465 | if len(cert.IPAddresses) > 0 { 466 | ips := "," 467 | for _, ip := range cert.IPAddresses { 468 | ips = ips + ip.String() + "," 469 | } 470 | return ips 471 | } 472 | 473 | return "" 474 | } 475 | 476 | func organizationalUnits(cert *x509.Certificate) string { 477 | if len(cert.Subject.OrganizationalUnit) > 0 { 478 | return "," + strings.Join(cert.Subject.OrganizationalUnit, ",") + "," 479 | } 480 | 481 | return "" 482 | } 483 | -------------------------------------------------------------------------------- /prober/metrics_test.go: -------------------------------------------------------------------------------- 1 | package prober 2 | 3 | import ( 4 | "crypto/rsa" 5 | "crypto/x509" 6 | "encoding/pem" 7 | "fmt" 8 | "reflect" 9 | "strconv" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/prometheus/client_golang/prometheus" 14 | dto "github.com/prometheus/client_model/go" 15 | "golang.org/x/crypto/ocsp" 16 | ) 17 | 18 | type registryResult struct { 19 | Name string 20 | LabelValues map[string]string 21 | Value float64 22 | } 23 | 24 | func (rr *registryResult) String() string { 25 | var labels []string 26 | for k, v := range rr.LabelValues { 27 | labels = append(labels, k+"=\""+v+"\"") 28 | } 29 | m := rr.Name 30 | if len(labels) > 0 { 31 | m = fmt.Sprintf("%s{%s}", m, strings.Join(labels, ",")) 32 | } 33 | return fmt.Sprintf("%s %f", m, rr.Value) 34 | } 35 | 36 | func checkRegistryResults(expectedResults []*registryResult, mfs []*dto.MetricFamily, t *testing.T) { 37 | for _, expRes := range expectedResults { 38 | checkRegistryResult(expRes, mfs, t) 39 | } 40 | } 41 | 42 | func checkRegistryResult(expRes *registryResult, mfs []*dto.MetricFamily, t *testing.T) { 43 | var results []*registryResult 44 | for _, mf := range mfs { 45 | for _, metric := range mf.Metric { 46 | result := ®istryResult{ 47 | Name: mf.GetName(), 48 | Value: metric.GetGauge().GetValue(), 49 | } 50 | if len(metric.GetLabel()) > 0 { 51 | labelValues := make(map[string]string) 52 | for _, l := range metric.GetLabel() { 53 | labelValues[l.GetName()] = l.GetValue() 54 | } 55 | result.LabelValues = labelValues 56 | } 57 | results = append(results, result) 58 | } 59 | } 60 | var ok bool 61 | var resStr string 62 | for _, res := range results { 63 | resStr = resStr + "\n" + res.String() 64 | if reflect.DeepEqual(res, expRes) { 65 | ok = true 66 | } 67 | } 68 | if !ok { 69 | t.Fatalf("Expected %s, got: %s", expRes.String(), resStr) 70 | } 71 | } 72 | 73 | func checkCertificateMetrics(cert *x509.Certificate, registry *prometheus.Registry, t *testing.T) { 74 | mfs, err := registry.Gather() 75 | if err != nil { 76 | t.Fatal(err) 77 | } 78 | ips := "," 79 | for _, ip := range cert.IPAddresses { 80 | ips = ips + ip.String() + "," 81 | } 82 | expectedLabels := map[string]string{ 83 | "serial_no": cert.SerialNumber.String(), 84 | "issuer_cn": cert.Issuer.CommonName, 85 | "cn": cert.Subject.CommonName, 86 | "dnsnames": "," + strings.Join(cert.DNSNames, ",") + ",", 87 | "ips": ips, 88 | "emails": "," + strings.Join(cert.EmailAddresses, ",") + ",", 89 | "ou": "," + strings.Join(cert.Subject.OrganizationalUnit, ",") + ",", 90 | } 91 | expectedResults := []*registryResult{ 92 | ®istryResult{ 93 | Name: "ssl_cert_not_after", 94 | LabelValues: expectedLabels, 95 | Value: float64(cert.NotAfter.Unix()), 96 | }, 97 | ®istryResult{ 98 | Name: "ssl_cert_not_before", 99 | LabelValues: expectedLabels, 100 | Value: float64(cert.NotBefore.Unix()), 101 | }, 102 | } 103 | checkRegistryResults(expectedResults, mfs, t) 104 | } 105 | 106 | func checkVerifiedChainMetrics(verifiedChains [][]*x509.Certificate, registry *prometheus.Registry, t *testing.T) { 107 | mfs, err := registry.Gather() 108 | if err != nil { 109 | t.Fatal(err) 110 | } 111 | for i, chain := range verifiedChains { 112 | for _, cert := range chain { 113 | ips := "," 114 | for _, ip := range cert.IPAddresses { 115 | ips = ips + ip.String() + "," 116 | } 117 | expectedLabels := map[string]string{ 118 | "chain_no": strconv.Itoa(i), 119 | "serial_no": cert.SerialNumber.String(), 120 | "issuer_cn": cert.Issuer.CommonName, 121 | "cn": cert.Subject.CommonName, 122 | "dnsnames": "," + strings.Join(cert.DNSNames, ",") + ",", 123 | "ips": ips, 124 | "emails": "," + strings.Join(cert.EmailAddresses, ",") + ",", 125 | "ou": "," + strings.Join(cert.Subject.OrganizationalUnit, ",") + ",", 126 | } 127 | expectedResults := []*registryResult{ 128 | ®istryResult{ 129 | Name: "ssl_verified_cert_not_after", 130 | LabelValues: expectedLabels, 131 | Value: float64(cert.NotAfter.Unix()), 132 | }, 133 | ®istryResult{ 134 | Name: "ssl_verified_cert_not_before", 135 | LabelValues: expectedLabels, 136 | Value: float64(cert.NotBefore.Unix()), 137 | }, 138 | } 139 | checkRegistryResults(expectedResults, mfs, t) 140 | } 141 | } 142 | } 143 | 144 | func checkOCSPMetrics(resp []byte, registry *prometheus.Registry, t *testing.T) { 145 | var ( 146 | stapled float64 147 | status float64 148 | nextUpdate float64 149 | thisUpdate float64 150 | revokedAt float64 151 | producedAt float64 152 | ) 153 | mfs, err := registry.Gather() 154 | if err != nil { 155 | t.Fatal(err) 156 | } 157 | if len(resp) > 0 { 158 | parsedResponse, err := ocsp.ParseResponse(resp, nil) 159 | if err != nil { 160 | t.Fatal(err) 161 | } 162 | stapled = 1 163 | status = float64(parsedResponse.Status) 164 | nextUpdate = float64(parsedResponse.NextUpdate.Unix()) 165 | thisUpdate = float64(parsedResponse.ThisUpdate.Unix()) 166 | revokedAt = float64(parsedResponse.RevokedAt.Unix()) 167 | producedAt = float64(parsedResponse.ProducedAt.Unix()) 168 | } 169 | expectedResults := []*registryResult{ 170 | ®istryResult{ 171 | Name: "ssl_ocsp_response_stapled", 172 | Value: stapled, 173 | }, 174 | ®istryResult{ 175 | Name: "ssl_ocsp_response_status", 176 | Value: status, 177 | }, 178 | ®istryResult{ 179 | Name: "ssl_ocsp_response_next_update", 180 | Value: nextUpdate, 181 | }, 182 | ®istryResult{ 183 | Name: "ssl_ocsp_response_this_update", 184 | Value: thisUpdate, 185 | }, 186 | ®istryResult{ 187 | Name: "ssl_ocsp_response_revoked_at", 188 | Value: revokedAt, 189 | }, 190 | ®istryResult{ 191 | Name: "ssl_ocsp_response_produced_at", 192 | Value: producedAt, 193 | }, 194 | } 195 | checkRegistryResults(expectedResults, mfs, t) 196 | } 197 | 198 | func checkTLSVersionMetrics(version string, registry *prometheus.Registry, t *testing.T) { 199 | mfs, err := registry.Gather() 200 | if err != nil { 201 | t.Fatal(err) 202 | } 203 | expectedResults := []*registryResult{ 204 | ®istryResult{ 205 | Name: "ssl_tls_version_info", 206 | LabelValues: map[string]string{ 207 | "version": version, 208 | }, 209 | Value: 1, 210 | }, 211 | } 212 | checkRegistryResults(expectedResults, mfs, t) 213 | } 214 | 215 | func newCertificate(certPEM []byte) (*x509.Certificate, error) { 216 | block, _ := pem.Decode(certPEM) 217 | return x509.ParseCertificate(block.Bytes) 218 | } 219 | 220 | func newKey(keyPEM []byte) (*rsa.PrivateKey, error) { 221 | block, _ := pem.Decode([]byte(keyPEM)) 222 | return x509.ParsePKCS1PrivateKey(block.Bytes) 223 | } 224 | -------------------------------------------------------------------------------- /prober/prober.go: -------------------------------------------------------------------------------- 1 | package prober 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/go-kit/log" 7 | "github.com/prometheus/client_golang/prometheus" 8 | "github.com/ribbybibby/ssl_exporter/v2/config" 9 | ) 10 | 11 | var ( 12 | // Probers maps a friendly name to a corresponding probe function 13 | Probers = map[string]ProbeFn{ 14 | "https": ProbeHTTPS, 15 | "http": ProbeHTTPS, 16 | "tcp": ProbeTCP, 17 | "file": ProbeFile, 18 | "http_file": ProbeHTTPFile, 19 | "kubernetes": ProbeKubernetes, 20 | "kubeconfig": ProbeKubeconfig, 21 | } 22 | ) 23 | 24 | // ProbeFn probes 25 | type ProbeFn func(ctx context.Context, logger log.Logger, target string, module config.Module, registry *prometheus.Registry) error 26 | -------------------------------------------------------------------------------- /prober/tcp.go: -------------------------------------------------------------------------------- 1 | package prober 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "crypto/tls" 8 | "fmt" 9 | "io" 10 | "net" 11 | "regexp" 12 | 13 | "github.com/go-kit/log" 14 | "github.com/go-kit/log/level" 15 | "github.com/prometheus/client_golang/prometheus" 16 | "github.com/ribbybibby/ssl_exporter/v2/config" 17 | ) 18 | 19 | // ProbeTCP performs a tcp probe 20 | func ProbeTCP(ctx context.Context, logger log.Logger, target string, module config.Module, registry *prometheus.Registry) error { 21 | tlsConfig, err := newTLSConfig(target, registry, &module.TLSConfig) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | dialer := &net.Dialer{} 27 | conn, err := dialer.DialContext(ctx, "tcp", target) 28 | if err != nil { 29 | return err 30 | } 31 | defer conn.Close() 32 | 33 | deadline, _ := ctx.Deadline() 34 | if err := conn.SetDeadline(deadline); err != nil { 35 | return fmt.Errorf("Error setting deadline") 36 | } 37 | 38 | if module.TCP.StartTLS != "" { 39 | err = startTLS(logger, conn, module.TCP.StartTLS) 40 | if err != nil { 41 | return err 42 | } 43 | } 44 | 45 | tlsConn := tls.Client(conn, tlsConfig) 46 | defer tlsConn.Close() 47 | 48 | return tlsConn.Handshake() 49 | } 50 | 51 | type queryResponse struct { 52 | expect string 53 | send string 54 | sendBytes []byte 55 | expectBytes []byte 56 | } 57 | 58 | var ( 59 | // These are the protocols for which I had servers readily available to test 60 | // against. There are plenty of other protocols that should be added here in 61 | // the future. 62 | // 63 | // See openssl s_client for more examples: 64 | // https://github.com/openssl/openssl/blob/openssl-3.0.0-alpha3/apps/s_client.c#L2229-L2728 65 | startTLSqueryResponses = map[string][]queryResponse{ 66 | "smtp": []queryResponse{ 67 | queryResponse{ 68 | expect: "^220", 69 | }, 70 | queryResponse{ 71 | send: "EHLO prober", 72 | }, 73 | queryResponse{ 74 | expect: "^250(-| )STARTTLS", 75 | }, 76 | queryResponse{ 77 | send: "STARTTLS", 78 | }, 79 | queryResponse{ 80 | expect: "^220", 81 | }, 82 | }, 83 | "ftp": []queryResponse{ 84 | queryResponse{ 85 | expect: "^220", 86 | }, 87 | queryResponse{ 88 | send: "AUTH TLS", 89 | }, 90 | queryResponse{ 91 | expect: "^234", 92 | }, 93 | }, 94 | "imap": []queryResponse{ 95 | queryResponse{ 96 | expect: "OK", 97 | }, 98 | queryResponse{ 99 | send: ". CAPABILITY", 100 | }, 101 | queryResponse{ 102 | expect: "STARTTLS", 103 | }, 104 | queryResponse{ 105 | expect: "OK", 106 | }, 107 | queryResponse{ 108 | send: ". STARTTLS", 109 | }, 110 | queryResponse{ 111 | expect: "OK", 112 | }, 113 | }, 114 | "postgres": []queryResponse{ 115 | queryResponse{ 116 | sendBytes: []byte{0x00, 0x00, 0x00, 0x08, 0x04, 0xd2, 0x16, 0x2f}, 117 | }, 118 | queryResponse{ 119 | expectBytes: []byte{0x53}, 120 | }, 121 | }, 122 | "pop3": []queryResponse{ 123 | queryResponse{ 124 | expect: "OK", 125 | }, 126 | queryResponse{ 127 | send: "STLS", 128 | }, 129 | queryResponse{ 130 | expect: "OK", 131 | }, 132 | }, 133 | } 134 | ) 135 | 136 | // startTLS will send the STARTTLS command for the given protocol 137 | func startTLS(logger log.Logger, conn net.Conn, proto string) error { 138 | var err error 139 | 140 | qr, ok := startTLSqueryResponses[proto] 141 | if !ok { 142 | return fmt.Errorf("STARTTLS is not supported for %s", proto) 143 | } 144 | 145 | scanner := bufio.NewScanner(conn) 146 | for _, qr := range qr { 147 | if qr.expect != "" { 148 | var match bool 149 | for scanner.Scan() { 150 | level.Debug(logger).Log("msg", fmt.Sprintf("read line: %s", scanner.Text())) 151 | match, err = regexp.Match(qr.expect, scanner.Bytes()) 152 | if err != nil { 153 | return err 154 | } 155 | if match { 156 | level.Debug(logger).Log("msg", fmt.Sprintf("regex: %s matched: %s", qr.expect, scanner.Text())) 157 | break 158 | } 159 | } 160 | if scanner.Err() != nil { 161 | return scanner.Err() 162 | } 163 | if !match { 164 | return fmt.Errorf("regex: %s didn't match: %s", qr.expect, scanner.Text()) 165 | } 166 | } 167 | if len(qr.expectBytes) > 0 { 168 | buffer := make([]byte, len(qr.expectBytes)) 169 | _, err = io.ReadFull(conn, buffer) 170 | if err != nil { 171 | return nil 172 | } 173 | level.Debug(logger).Log("msg", fmt.Sprintf("read bytes: %x", buffer)) 174 | if bytes.Compare(buffer, qr.expectBytes) != 0 { 175 | return fmt.Errorf("read bytes %x didn't match with expected bytes %x", buffer, qr.expectBytes) 176 | } else { 177 | level.Debug(logger).Log("msg", fmt.Sprintf("expected bytes %x matched with read bytes %x", qr.expectBytes, buffer)) 178 | } 179 | } 180 | if qr.send != "" { 181 | level.Debug(logger).Log("msg", fmt.Sprintf("sending line: %s", qr.send)) 182 | if _, err := fmt.Fprintf(conn, "%s\r\n", qr.send); err != nil { 183 | return err 184 | } 185 | } 186 | if len(qr.sendBytes) > 0 { 187 | level.Debug(logger).Log("msg", fmt.Sprintf("sending bytes: %x", qr.sendBytes)) 188 | if _, err = conn.Write(qr.sendBytes); err != nil { 189 | return err 190 | } 191 | } 192 | } 193 | return nil 194 | } 195 | -------------------------------------------------------------------------------- /prober/tcp_test.go: -------------------------------------------------------------------------------- 1 | package prober 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/rand" 7 | "crypto/rsa" 8 | "crypto/tls" 9 | "crypto/x509" 10 | "math/big" 11 | "net" 12 | "testing" 13 | "time" 14 | 15 | "github.com/ribbybibby/ssl_exporter/v2/config" 16 | "github.com/ribbybibby/ssl_exporter/v2/test" 17 | "golang.org/x/crypto/ocsp" 18 | 19 | "github.com/prometheus/client_golang/prometheus" 20 | ) 21 | 22 | // TestProbeTCP tests the typical case 23 | func TestProbeTCP(t *testing.T) { 24 | server, certPEM, _, caFile, teardown, err := test.SetupTCPServer() 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | defer teardown() 29 | 30 | server.StartTLS() 31 | defer server.Close() 32 | 33 | module := config.Module{ 34 | TLSConfig: config.TLSConfig{ 35 | CAFile: caFile, 36 | InsecureSkipVerify: false, 37 | }, 38 | } 39 | 40 | registry := prometheus.NewRegistry() 41 | 42 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 43 | defer cancel() 44 | 45 | if err := ProbeTCP(ctx, newTestLogger(), server.Listener.Addr().String(), module, registry); err != nil { 46 | t.Fatalf("error: %s", err) 47 | } 48 | 49 | cert, err := newCertificate(certPEM) 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | checkCertificateMetrics(cert, registry, t) 54 | checkOCSPMetrics([]byte{}, registry, t) 55 | checkTLSVersionMetrics("TLS 1.3", registry, t) 56 | } 57 | 58 | // TestProbeTCPInvalidName tests hitting the server on an address which isn't 59 | // in the SANs (localhost) 60 | func TestProbeTCPInvalidName(t *testing.T) { 61 | server, _, _, caFile, teardown, err := test.SetupTCPServer() 62 | if err != nil { 63 | t.Fatalf(err.Error()) 64 | } 65 | defer teardown() 66 | 67 | server.StartTLS() 68 | defer server.Close() 69 | 70 | module := config.Module{ 71 | TLSConfig: config.TLSConfig{ 72 | CAFile: caFile, 73 | InsecureSkipVerify: false, 74 | }, 75 | } 76 | 77 | _, listenPort, _ := net.SplitHostPort(server.Listener.Addr().String()) 78 | 79 | registry := prometheus.NewRegistry() 80 | 81 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 82 | defer cancel() 83 | 84 | if err := ProbeTCP(ctx, newTestLogger(), "localhost:"+listenPort, module, registry); err == nil { 85 | t.Fatalf("expected error but err was nil") 86 | } 87 | } 88 | 89 | // TestProbeTCPServerName tests that the probe is successful when the 90 | // servername is provided in the TLS config 91 | func TestProbeTCPServerName(t *testing.T) { 92 | server, certPEM, _, caFile, teardown, err := test.SetupTCPServer() 93 | if err != nil { 94 | t.Fatalf(err.Error()) 95 | } 96 | defer teardown() 97 | 98 | server.StartTLS() 99 | defer server.Close() 100 | 101 | host, listenPort, _ := net.SplitHostPort(server.Listener.Addr().String()) 102 | 103 | module := config.Module{ 104 | TLSConfig: config.TLSConfig{ 105 | CAFile: caFile, 106 | InsecureSkipVerify: false, 107 | ServerName: host, 108 | }, 109 | } 110 | 111 | registry := prometheus.NewRegistry() 112 | 113 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 114 | defer cancel() 115 | 116 | if err := ProbeTCP(ctx, newTestLogger(), "localhost:"+listenPort, module, registry); err != nil { 117 | t.Fatalf("error: %s", err) 118 | } 119 | 120 | cert, err := newCertificate(certPEM) 121 | if err != nil { 122 | t.Fatal(err) 123 | } 124 | checkCertificateMetrics(cert, registry, t) 125 | checkOCSPMetrics([]byte{}, registry, t) 126 | checkTLSVersionMetrics("TLS 1.3", registry, t) 127 | } 128 | 129 | // TestProbeTCPExpired tests that the probe fails with an expired server cert 130 | func TestProbeTCPExpired(t *testing.T) { 131 | server, _, _, caFile, teardown, err := test.SetupTCPServer() 132 | if err != nil { 133 | t.Fatalf(err.Error()) 134 | } 135 | defer teardown() 136 | 137 | // Create a certificate with a notAfter date in the past 138 | certPEM, keyPEM := test.GenerateTestCertificate(time.Now().AddDate(0, 0, -1)) 139 | testcert, err := tls.X509KeyPair(certPEM, keyPEM) 140 | if err != nil { 141 | t.Fatalf(err.Error()) 142 | } 143 | server.TLS.Certificates = []tls.Certificate{testcert} 144 | 145 | server.StartTLS() 146 | defer server.Close() 147 | 148 | module := config.Module{ 149 | TLSConfig: config.TLSConfig{ 150 | CAFile: caFile, 151 | InsecureSkipVerify: false, 152 | }, 153 | } 154 | 155 | registry := prometheus.NewRegistry() 156 | 157 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 158 | defer cancel() 159 | 160 | if err := ProbeTCP(ctx, newTestLogger(), server.Listener.Addr().String(), module, registry); err == nil { 161 | t.Fatalf("expected error but err is nil") 162 | } 163 | } 164 | 165 | // TestProbeTCPExpiredInsecure tests that the probe succeeds with an expired server cert 166 | // when skipping cert verification 167 | func TestProbeTCPExpiredInsecure(t *testing.T) { 168 | server, certPEM, _, caFile, teardown, err := test.SetupTCPServer() 169 | if err != nil { 170 | t.Fatalf(err.Error()) 171 | } 172 | defer teardown() 173 | 174 | // Create a certificate with a notAfter date in the past 175 | certPEM, keyPEM := test.GenerateTestCertificate(time.Now().AddDate(0, 0, -1)) 176 | testcert, err := tls.X509KeyPair(certPEM, keyPEM) 177 | if err != nil { 178 | t.Fatalf(err.Error()) 179 | } 180 | server.TLS.Certificates = []tls.Certificate{testcert} 181 | 182 | server.StartTLS() 183 | defer server.Close() 184 | 185 | module := config.Module{ 186 | TLSConfig: config.TLSConfig{ 187 | CAFile: caFile, 188 | InsecureSkipVerify: true, 189 | }, 190 | } 191 | 192 | registry := prometheus.NewRegistry() 193 | 194 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 195 | defer cancel() 196 | 197 | if err := ProbeTCP(ctx, newTestLogger(), server.Listener.Addr().String(), module, registry); err != nil { 198 | t.Fatalf("error: %s", err) 199 | } 200 | 201 | cert, err := newCertificate(certPEM) 202 | if err != nil { 203 | t.Fatal(err) 204 | } 205 | checkCertificateMetrics(cert, registry, t) 206 | checkOCSPMetrics([]byte{}, registry, t) 207 | checkTLSVersionMetrics("TLS 1.3", registry, t) 208 | } 209 | 210 | // TestProbeTCPStartTLSSMTP tests STARTTLS against a mock SMTP server 211 | func TestProbeTCPStartTLSSMTP(t *testing.T) { 212 | server, certPEM, _, caFile, teardown, err := test.SetupTCPServer() 213 | if err != nil { 214 | t.Fatalf(err.Error()) 215 | } 216 | defer teardown() 217 | 218 | server.StartSMTP() 219 | defer server.Close() 220 | 221 | module := config.Module{ 222 | TCP: config.TCPProbe{ 223 | StartTLS: "smtp", 224 | }, 225 | TLSConfig: config.TLSConfig{ 226 | CAFile: caFile, 227 | InsecureSkipVerify: false, 228 | }, 229 | } 230 | 231 | registry := prometheus.NewRegistry() 232 | 233 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 234 | defer cancel() 235 | 236 | if err := ProbeTCP(ctx, newTestLogger(), server.Listener.Addr().String(), module, registry); err != nil { 237 | t.Fatalf("error: %s", err) 238 | } 239 | 240 | cert, err := newCertificate(certPEM) 241 | if err != nil { 242 | t.Fatal(err) 243 | } 244 | checkCertificateMetrics(cert, registry, t) 245 | checkOCSPMetrics([]byte{}, registry, t) 246 | checkTLSVersionMetrics("TLS 1.3", registry, t) 247 | } 248 | 249 | // TestProbeTCPStartTLSSMTPWithDashInResponse tests STARTTLS against a mock SMTP server 250 | // which provides STARTTLS as option with dash which is okay when it used as the last option 251 | func TestProbeTCPStartTLSSMTPWithDashInResponse(t *testing.T) { 252 | server, certPEM, _, caFile, teardown, err := test.SetupTCPServer() 253 | if err != nil { 254 | t.Fatalf(err.Error()) 255 | } 256 | defer teardown() 257 | 258 | server.StartSMTPWithDashInResponse() 259 | defer server.Close() 260 | 261 | module := config.Module{ 262 | TCP: config.TCPProbe{ 263 | StartTLS: "smtp", 264 | }, 265 | TLSConfig: config.TLSConfig{ 266 | CAFile: caFile, 267 | InsecureSkipVerify: false, 268 | }, 269 | } 270 | 271 | registry := prometheus.NewRegistry() 272 | 273 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 274 | defer cancel() 275 | 276 | if err := ProbeTCP(ctx, newTestLogger(), server.Listener.Addr().String(), module, registry); err != nil { 277 | t.Fatalf("error: %s", err) 278 | } 279 | 280 | cert, err := newCertificate(certPEM) 281 | if err != nil { 282 | t.Fatal(err) 283 | } 284 | checkCertificateMetrics(cert, registry, t) 285 | checkOCSPMetrics([]byte{}, registry, t) 286 | checkTLSVersionMetrics("TLS 1.3", registry, t) 287 | } 288 | 289 | // TestProbeTCPStartTLSFTP tests STARTTLS against a mock FTP server 290 | func TestProbeTCPStartTLSFTP(t *testing.T) { 291 | server, certPEM, _, caFile, teardown, err := test.SetupTCPServer() 292 | if err != nil { 293 | t.Fatalf(err.Error()) 294 | } 295 | defer teardown() 296 | 297 | server.StartFTP() 298 | defer server.Close() 299 | 300 | module := config.Module{ 301 | TCP: config.TCPProbe{ 302 | StartTLS: "ftp", 303 | }, 304 | TLSConfig: config.TLSConfig{ 305 | CAFile: caFile, 306 | InsecureSkipVerify: false, 307 | }, 308 | } 309 | 310 | registry := prometheus.NewRegistry() 311 | 312 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 313 | defer cancel() 314 | 315 | if err := ProbeTCP(ctx, newTestLogger(), server.Listener.Addr().String(), module, registry); err != nil { 316 | t.Fatalf("error: %s", err) 317 | } 318 | 319 | cert, err := newCertificate(certPEM) 320 | if err != nil { 321 | t.Fatal(err) 322 | } 323 | checkCertificateMetrics(cert, registry, t) 324 | checkOCSPMetrics([]byte{}, registry, t) 325 | checkTLSVersionMetrics("TLS 1.3", registry, t) 326 | } 327 | 328 | // TestProbeTCPStartTLSIMAP tests STARTTLS against a mock IMAP server 329 | func TestProbeTCPStartTLSIMAP(t *testing.T) { 330 | server, certPEM, _, caFile, teardown, err := test.SetupTCPServer() 331 | if err != nil { 332 | t.Fatalf(err.Error()) 333 | } 334 | defer teardown() 335 | 336 | server.StartIMAP() 337 | defer server.Close() 338 | 339 | module := config.Module{ 340 | TCP: config.TCPProbe{ 341 | StartTLS: "imap", 342 | }, 343 | TLSConfig: config.TLSConfig{ 344 | CAFile: caFile, 345 | InsecureSkipVerify: false, 346 | }, 347 | } 348 | 349 | registry := prometheus.NewRegistry() 350 | 351 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 352 | defer cancel() 353 | 354 | if err := ProbeTCP(ctx, newTestLogger(), server.Listener.Addr().String(), module, registry); err != nil { 355 | t.Fatalf("error: %s", err) 356 | } 357 | 358 | cert, err := newCertificate(certPEM) 359 | if err != nil { 360 | t.Fatal(err) 361 | } 362 | checkCertificateMetrics(cert, registry, t) 363 | checkOCSPMetrics([]byte{}, registry, t) 364 | checkTLSVersionMetrics("TLS 1.3", registry, t) 365 | } 366 | 367 | // TestProbeTCPStartTLSPOP3 tests STARTTLS against a mock POP3 server 368 | func TestProbeTCPStartTLSPOP3(t *testing.T) { 369 | server, certPEM, _, caFile, teardown, err := test.SetupTCPServer() 370 | if err != nil { 371 | t.Fatalf(err.Error()) 372 | } 373 | defer teardown() 374 | 375 | server.StartPOP3() 376 | defer server.Close() 377 | 378 | module := config.Module{ 379 | TCP: config.TCPProbe{ 380 | StartTLS: "pop3", 381 | }, 382 | TLSConfig: config.TLSConfig{ 383 | CAFile: caFile, 384 | InsecureSkipVerify: false, 385 | }, 386 | } 387 | 388 | registry := prometheus.NewRegistry() 389 | 390 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 391 | defer cancel() 392 | 393 | if err := ProbeTCP(ctx, newTestLogger(), server.Listener.Addr().String(), module, registry); err != nil { 394 | t.Fatalf("error: %s", err) 395 | } 396 | 397 | cert, err := newCertificate(certPEM) 398 | if err != nil { 399 | t.Fatal(err) 400 | } 401 | checkCertificateMetrics(cert, registry, t) 402 | checkOCSPMetrics([]byte{}, registry, t) 403 | checkTLSVersionMetrics("TLS 1.3", registry, t) 404 | } 405 | 406 | // TestProbeTCPStartTLSPostgreSQL tests STARTTLS against a mock PostgreSQL server 407 | func TestProbeTCPStartTLSPostgreSQL(t *testing.T) { 408 | server, certPEM, _, caFile, teardown, err := test.SetupTCPServer() 409 | if err != nil { 410 | t.Fatalf(err.Error()) 411 | } 412 | defer teardown() 413 | 414 | server.StartPostgreSQL() 415 | defer server.Close() 416 | 417 | module := config.Module{ 418 | TCP: config.TCPProbe{ 419 | StartTLS: "postgres", 420 | }, 421 | TLSConfig: config.TLSConfig{ 422 | CAFile: caFile, 423 | InsecureSkipVerify: false, 424 | }, 425 | } 426 | 427 | registry := prometheus.NewRegistry() 428 | 429 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 430 | defer cancel() 431 | 432 | if err := ProbeTCP(ctx, newTestLogger(), server.Listener.Addr().String(), module, registry); err != nil { 433 | t.Fatalf("error: %s", err) 434 | } 435 | 436 | cert, err := newCertificate(certPEM) 437 | if err != nil { 438 | t.Fatal(err) 439 | } 440 | checkCertificateMetrics(cert, registry, t) 441 | checkOCSPMetrics([]byte{}, registry, t) 442 | checkTLSVersionMetrics("TLS 1.3", registry, t) 443 | } 444 | 445 | // TestProbeTCPTimeout tests that the TCP probe respects the timeout in the 446 | // context 447 | func TestProbeTCPTimeout(t *testing.T) { 448 | server, _, _, caFile, teardown, err := test.SetupTCPServer() 449 | if err != nil { 450 | t.Fatal(err) 451 | } 452 | defer teardown() 453 | 454 | server.StartTLSWait(time.Second * 3) 455 | defer server.Close() 456 | 457 | module := config.Module{ 458 | TLSConfig: config.TLSConfig{ 459 | CAFile: caFile, 460 | InsecureSkipVerify: false, 461 | }, 462 | } 463 | 464 | registry := prometheus.NewRegistry() 465 | 466 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) 467 | defer cancel() 468 | 469 | if err := ProbeTCP(ctx, newTestLogger(), server.Listener.Addr().String(), module, registry); err == nil { 470 | t.Fatalf("Expected error but returned error was nil") 471 | } 472 | } 473 | 474 | // TestProbeTCPOCSP tests a TCP probe with OCSP stapling 475 | func TestProbeTCPOCSP(t *testing.T) { 476 | server, certPEM, keyPEM, caFile, teardown, err := test.SetupTCPServer() 477 | if err != nil { 478 | t.Fatal(err) 479 | } 480 | defer teardown() 481 | 482 | cert, err := newCertificate(certPEM) 483 | if err != nil { 484 | t.Fatal(err) 485 | } 486 | key, err := newKey(keyPEM) 487 | if err != nil { 488 | t.Fatal(err) 489 | } 490 | 491 | resp, err := ocsp.CreateResponse(cert, cert, ocsp.Response{SerialNumber: big.NewInt(64), Status: 1}, key) 492 | if err != nil { 493 | t.Fatalf(err.Error()) 494 | } 495 | server.TLS.Certificates[0].OCSPStaple = resp 496 | 497 | server.StartTLS() 498 | defer server.Close() 499 | 500 | module := config.Module{ 501 | TLSConfig: config.TLSConfig{ 502 | CAFile: caFile, 503 | InsecureSkipVerify: false, 504 | }, 505 | } 506 | 507 | registry := prometheus.NewRegistry() 508 | 509 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 510 | defer cancel() 511 | 512 | if err := ProbeTCP(ctx, newTestLogger(), server.Listener.Addr().String(), module, registry); err != nil { 513 | t.Fatalf("error: %s", err) 514 | } 515 | 516 | checkCertificateMetrics(cert, registry, t) 517 | checkOCSPMetrics(resp, registry, t) 518 | checkTLSVersionMetrics("TLS 1.3", registry, t) 519 | } 520 | 521 | // TestProbeTCPVerifiedChains tests the verified chain metrics returned by a tcp 522 | // probe 523 | func TestProbeTCPVerifiedChains(t *testing.T) { 524 | rootPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) 525 | if err != nil { 526 | t.Fatalf(err.Error()) 527 | } 528 | 529 | rootCertExpiry := time.Now().AddDate(0, 0, 5) 530 | rootCertTmpl := test.GenerateCertificateTemplate(rootCertExpiry) 531 | rootCertTmpl.IsCA = true 532 | rootCertTmpl.SerialNumber = big.NewInt(1) 533 | rootCert, rootCertPem := test.GenerateSelfSignedCertificateWithPrivateKey(rootCertTmpl, rootPrivateKey) 534 | 535 | olderRootCertExpiry := time.Now().AddDate(0, 0, 3) 536 | olderRootCertTmpl := test.GenerateCertificateTemplate(olderRootCertExpiry) 537 | olderRootCertTmpl.IsCA = true 538 | olderRootCertTmpl.SerialNumber = big.NewInt(2) 539 | olderRootCert, olderRootCertPem := test.GenerateSelfSignedCertificateWithPrivateKey(olderRootCertTmpl, rootPrivateKey) 540 | 541 | oldestRootCertExpiry := time.Now().AddDate(0, 0, 1) 542 | oldestRootCertTmpl := test.GenerateCertificateTemplate(oldestRootCertExpiry) 543 | oldestRootCertTmpl.IsCA = true 544 | oldestRootCertTmpl.SerialNumber = big.NewInt(3) 545 | oldestRootCert, oldestRootCertPem := test.GenerateSelfSignedCertificateWithPrivateKey(oldestRootCertTmpl, rootPrivateKey) 546 | 547 | serverCertExpiry := time.Now().AddDate(0, 0, 4) 548 | serverCertTmpl := test.GenerateCertificateTemplate(serverCertExpiry) 549 | serverCertTmpl.SerialNumber = big.NewInt(4) 550 | serverCert, serverCertPem, serverKey := test.GenerateSignedCertificate(serverCertTmpl, olderRootCert, rootPrivateKey) 551 | 552 | verifiedChains := [][]*x509.Certificate{ 553 | []*x509.Certificate{ 554 | serverCert, 555 | rootCert, 556 | }, 557 | []*x509.Certificate{ 558 | serverCert, 559 | olderRootCert, 560 | }, 561 | []*x509.Certificate{ 562 | serverCert, 563 | oldestRootCert, 564 | }, 565 | } 566 | 567 | caCertPem := bytes.Join([][]byte{oldestRootCertPem, olderRootCertPem, rootCertPem}, []byte("")) 568 | 569 | server, caFile, teardown, err := test.SetupTCPServerWithCertAndKey( 570 | caCertPem, 571 | serverCertPem, 572 | serverKey, 573 | ) 574 | if err != nil { 575 | t.Fatalf(err.Error()) 576 | } 577 | defer teardown() 578 | 579 | server.StartTLS() 580 | defer server.Close() 581 | 582 | module := config.Module{ 583 | TLSConfig: config.TLSConfig{ 584 | CAFile: caFile, 585 | }, 586 | } 587 | 588 | registry := prometheus.NewRegistry() 589 | 590 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 591 | defer cancel() 592 | 593 | if err := ProbeTCP(ctx, newTestLogger(), server.Listener.Addr().String(), module, registry); err != nil { 594 | t.Fatalf("error: %s", err) 595 | } 596 | 597 | checkCertificateMetrics(serverCert, registry, t) 598 | checkOCSPMetrics([]byte{}, registry, t) 599 | checkVerifiedChainMetrics(verifiedChains, registry, t) 600 | checkTLSVersionMetrics("TLS 1.3", registry, t) 601 | } 602 | -------------------------------------------------------------------------------- /prober/test.go: -------------------------------------------------------------------------------- 1 | package prober 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/go-kit/log" 7 | ) 8 | 9 | func newTestLogger() log.Logger { 10 | return log.NewLogfmtLogger(log.NewSyncWriter(os.Stdout)) 11 | } 12 | -------------------------------------------------------------------------------- /prober/tls.go: -------------------------------------------------------------------------------- 1 | package prober 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "encoding/pem" 7 | "net" 8 | 9 | "github.com/prometheus/client_golang/prometheus" 10 | "github.com/ribbybibby/ssl_exporter/v2/config" 11 | ) 12 | 13 | // newTLSConfig sets up TLS config and instruments it with a function that 14 | // collects metrics for the verified chain 15 | func newTLSConfig(target string, registry *prometheus.Registry, cfg *config.TLSConfig) (*tls.Config, error) { 16 | tlsConfig, err := config.NewTLSConfig(cfg) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | if tlsConfig.ServerName == "" && target != "" { 22 | targetAddress, _, err := net.SplitHostPort(target) 23 | if err != nil { 24 | return nil, err 25 | } 26 | tlsConfig.ServerName = targetAddress 27 | } 28 | 29 | tlsConfig.VerifyConnection = func(state tls.ConnectionState) error { 30 | return collectConnectionStateMetrics(state, registry) 31 | } 32 | 33 | return tlsConfig, nil 34 | } 35 | 36 | func uniq(certs []*x509.Certificate) []*x509.Certificate { 37 | r := []*x509.Certificate{} 38 | 39 | for _, c := range certs { 40 | if !contains(r, c) { 41 | r = append(r, c) 42 | } 43 | } 44 | 45 | return r 46 | } 47 | 48 | func contains(certs []*x509.Certificate, cert *x509.Certificate) bool { 49 | for _, c := range certs { 50 | if (c.SerialNumber.String() == cert.SerialNumber.String()) && (c.Issuer.CommonName == cert.Issuer.CommonName) { 51 | return true 52 | } 53 | } 54 | return false 55 | } 56 | 57 | func decodeCertificates(data []byte) ([]*x509.Certificate, error) { 58 | var certs []*x509.Certificate 59 | for block, rest := pem.Decode(data); block != nil; block, rest = pem.Decode(rest) { 60 | if block.Type == "CERTIFICATE" || block.Type == "TRUSTED CERTIFICATE" { 61 | cert, err := x509.ParseCertificate(block.Bytes) 62 | if err != nil { 63 | return certs, err 64 | } 65 | if !contains(certs, cert) { 66 | certs = append(certs, cert) 67 | } 68 | } 69 | } 70 | 71 | return certs, nil 72 | } 73 | -------------------------------------------------------------------------------- /prober/tls_test.go: -------------------------------------------------------------------------------- 1 | package prober 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestDecodeCertificates(t *testing.T) { 8 | data := []byte(` 9 | -----BEGIN CERTIFICATE----- 10 | MIIFszCCA5ugAwIBAgIUdpzowWDU/AI7QBhLSRB9DPqpWvcwDQYJKoZIhvcNAQEL 11 | BQAwaTELMAkGA1UEBhMCWFgxEjAQBgNVBAgMCVN0YXRlTmFtZTERMA8GA1UEBwwI 12 | Q2l0eU5hbWUxFDASBgNVBAoMC0NvbXBhbnlOYW1lMQwwCgYDVQQLDANGb28xDzAN 13 | BgNVBAMMBkZvb2JhcjAeFw0yNDA0MjgxNjIzMzNaFw0zNDA0MjYxNjIzMzNaMGkx 14 | CzAJBgNVBAYTAlhYMRIwEAYDVQQIDAlTdGF0ZU5hbWUxETAPBgNVBAcMCENpdHlO 15 | YW1lMRQwEgYDVQQKDAtDb21wYW55TmFtZTEMMAoGA1UECwwDRm9vMQ8wDQYDVQQD 16 | DAZGb29iYXIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCmxZBW/Ays 17 | VBxt7jJQeTrPdLQpUdxnGVXOa4M54FHWA2DwwmZ2DZth5Eioq1wC9WCrByWkd8px 18 | mvU0XDUT5ESceEKcwmDhKgYHAcJ4qXEyk1jYuBy6zw95cmV2BiTf0Xoo/8JxiQR4 19 | YBd7Tbm52eV5Hw5oaqgVEdaOCVMnO8S57AuQGfeC5AO18ty0cZ7mKsXQje2celMH 20 | QipujXrhRwdLBgu6FISTuS0XtqiuJnp+vllMjiTMF/uCmJUTUCpmayWSpM1FRpKe 21 | lM8B9666uuEmVJ4V5gzy4Oe0i5Apfh4qXX6pj+Y/oeOfXRc3NfYqcIJ2hm82ghJL 22 | 5Kt6FZ9fkQ6pyk7nXMAOaf8WX+JkhqaWvlzTme8Z+6DBXLPfyyoi3VR2kG3lRida 23 | qfyh2EPvEXip810s4f8EOHi+sehmjWLbsgn2HAHQmE7zYTEMp2Xsh0M7lGfUiMfP 24 | P1RU/RNDkZK59yXsG9RmoDMD03qI8M4990TL7BW0FQZvBg9Rfr3KgFpWngsn84mN 25 | l6/MKyc5X1e+RGyYJPbi0S1j+fhBeNzFqQPFZZUnWIPTxVdqF91SgY9EwE6MHuD6 26 | t7G+eANWWQkolvwACMT+0GqiRMVHVWeIqF2SQ7Dx2tiNXjE9rSX3lMpMNt4PvWcM 27 | PGuao3FMqXts0j2L8FSljcVU/hiLOGC2mwIDAQABo1MwUTAdBgNVHQ4EFgQU+231 28 | i0rY0CwQ0JwT2EUX4cq81LIwHwYDVR0jBBgwFoAU+231i0rY0CwQ0JwT2EUX4cq8 29 | 1LIwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAVRqUVxovcZ9C 30 | BYUUhvyT8BMAC6PS8U59BoDhnbrp1JDiPDbMnWlRp+450kZlEkSdN9PRLGWoGi84 31 | QqT7RIry/jA4z62B6N+IDDDcuWWstDTTC/gRA46eeHGRnIz7GJeseqrxb9z8KoO3 32 | /c8Pdb4lu/cfG5/m2X8hkYzjLZrIQ8qz53nUAag4uael19uy3HPQ3EEr936y+vQW 33 | rH8itgQ+5kOTr9d3ihcTSwTJGTyWq7j3T0xPu9tCzoPragpEDjoUCqCjU7uv6Tdq 34 | UhaQ8vneimSf9VdbDfEuEU9S2Xbdg6e+BsdV9uMeWkhtD1WgtSLHcliYktwnh/kh 35 | r4vQG86xn+LDxTrdTssjt+UmJnXzRiGOEZCDz7kBchYLiWSQn9tqcn28KK2/YobD 36 | AghlR24hUL1uslJOjLFc4BwLmlv/4Iy+b/3iQWY+yoban/OLZo6gCZx/4Nvg5R9e 37 | tcuxgn5Jhm2T/e8REucXQfiqKgxQVibFmqXeLH3Yj7ussA5t/ozo4VuypXUnnil0 38 | hB+PQsViaHFId1LyBKFwMoA6JzlNle6cbWVtXJswffkjk0UUr26SLHMV87lrk2kn 39 | IQ7ROZcT8K7gzzTxuiC2g6npmG1fDfgmgJeS7IqgiFcRTKa0hj6bvc/WoHoCpLNs 40 | v5KXxrGlqibeNjyc3Wng6S0Kpg6YNqU= 41 | -----END CERTIFICATE----- 42 | -----BEGIN TRUSTED CERTIFICATE----- 43 | MIIF7zCCA9egAwIBAgIUZXsnB0gxoPSFaUjnQeLwTW+sqbYwDQYJKoZIhvcNAQEL 44 | BQAwgYYxCzAJBgNVBAYTAlhYMRIwEAYDVQQIDAlTdGF0ZU5hbWUxETAPBgNVBAcM 45 | CENpdHlOYW1lMRQwEgYDVQQKDAtDb21wYW55TmFtZTEbMBkGA1UECwwSQ29tcGFu 46 | eVNlY3Rpb25OYW1lMR0wGwYDVQQDDBRDb21tb25OYW1lT3JIb3N0bmFtZTAeFw0y 47 | NDA0MjgxNjE3MzZaFw0zNDA0MjYxNjE3MzZaMIGGMQswCQYDVQQGEwJYWDESMBAG 48 | A1UECAwJU3RhdGVOYW1lMREwDwYDVQQHDAhDaXR5TmFtZTEUMBIGA1UECgwLQ29t 49 | cGFueU5hbWUxGzAZBgNVBAsMEkNvbXBhbnlTZWN0aW9uTmFtZTEdMBsGA1UEAwwU 50 | Q29tbW9uTmFtZU9ySG9zdG5hbWUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK 51 | AoICAQDJ+g1z+nZFj0DFc+wV1AH4giAqDFhwx25yziaQJnHkoXpllN0bRpr50Fhx 52 | QsMuMqjMCM0eFPo7kDWSrAUusdh3j1jdF7KidZRZdyUgioQvbmNtqtwdU/38Gq0s 53 | yTspOTZTWziIMFsLYxvFb0Ia8dwQFvyDB3pM1/eXFJMr2Cz/nF4/g4IUFEtPY2pe 54 | 4sQDMhszvROMfI5LB4SmGiim/zUS7+ZDEQigf10/CrbLntjXHiFy+V0hcrDQXkuY 55 | gXU9HsiwRpCx+GRqcI6SWPIZ2rYa7goAVBENv5KWFXdWaOFCpi55XldYYzDeaXWa 56 | On9EOjyylLbJ2pv/yhpt/VYDC6Lsqki1XcU/zeETcBRlqzCwZhMqFErSPRBGxaTN 57 | lEGKNS2qQnE7I7bmUksRcCxA0WIn4V2xxxGHkLFnEsCipFGgMm7LaGSukYZGQ2MF 58 | 0ELqaaRMaTBfvPhFRtolkFChm7JQlCrk9j47b07OTMj4KUoBkhYPNoK26sHjvKue 59 | Iks7BTGy8NdZk3jVDNEgGwngj+bNyJcjDvq+cHhWhS/N8c8Tg2tFb3ZOnwbyFPLf 60 | 1DdsP/SW6TybMc6NtpWvmSCFMvxqRpr+7LscpBGThepAUaoPkATnT6UdYGqr+l5N 61 | z1+cZEuHBF7Jk5xThXCCFwuBC/KREvRuU2LyAhm9RV1ci8XyYwIDAQABo1MwUTAd 62 | BgNVHQ4EFgQUa1FF7jssOpGY0UY1qbFQH83rxOQwHwYDVR0jBBgwFoAUa1FF7jss 63 | OpGY0UY1qbFQH83rxOQwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC 64 | AgEAhRPDPmVetjE5w/igfnGiNUKhQiqB3E6yT1wMwF5LbNIC+h8E8WqF0mzF3XQC 65 | t9+Eib+mFptHGfRGdA8JmJ79xBgch6JLagw8Ot6hA1oEqWy/PAzcFLemccvhonhZ 66 | +N7umRO27agIYVJ71mxlS7rD1SItBKZ+g4/Lt3sr/iDM0kIhWjWdQiYgJMNhH2Mt 67 | XAd53tmPSom1Vsca1ZorB0HJNIw8RB7QYwceAi0xk0Tui7Z6pHg8p99XHCK/2vD+ 68 | +u5a1nHjtrAvLdNti8o82EUScK4j91CODCm5bv2KDMIUUv+1O83UMicK8DLSIi0P 69 | 1acuIdbRrY5JiYewCC+NFCfODZT6y00QL7aF9cbT+ZySiYtpBUiLFlIZCw4dcJ6r 70 | yYJca1gv+kqZugdgLD7nXWIzXgfreQ+Q/BiVouXroXaWqU/9dqzGw4LlPSpyqI9o 71 | nFdu970j4BvVc809i1aNo3odGz9yRx7wOOMyiyHoRpYenlf3hFbqJHS2FUgJ/Ajl 72 | TC1joWDPdwdqQY56WOpk2LMK1KJuMYQEEHb3Ib0ZaxXAT6Nd12VashG5gYhw9vGn 73 | aZ3nCnZFahgB2tB5DtkK+p0PQVpZOv6/0CKSsfQkBFLTjeVIRnlL/UlK3VlwHH9h 74 | 0LfsLFabedOS9R1XIaQ7aKXPK4tKmNb5H/ONzH2zxU5Oqoo= 75 | -----END TRUSTED CERTIFICATE----- 76 | `) 77 | 78 | certs, err := decodeCertificates(data) 79 | if err != nil { 80 | t.Errorf("unexpected error: %s", err) 81 | } 82 | 83 | if len(certs) != 2 { 84 | t.Errorf("unexpected number of certs: %d", len(certs)) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /ssl_exporter.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/alecthomas/kingpin/v2" 12 | "github.com/go-kit/log" 13 | "github.com/go-kit/log/level" 14 | "github.com/prometheus/client_golang/prometheus" 15 | versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version" 16 | "github.com/prometheus/client_golang/prometheus/promhttp" 17 | "github.com/prometheus/common/promlog" 18 | promlogflag "github.com/prometheus/common/promlog/flag" 19 | "github.com/prometheus/common/version" 20 | "github.com/ribbybibby/ssl_exporter/v2/config" 21 | "github.com/ribbybibby/ssl_exporter/v2/prober" 22 | ) 23 | 24 | const ( 25 | namespace = "ssl" 26 | ) 27 | 28 | func probeHandler(logger log.Logger, w http.ResponseWriter, r *http.Request, conf *config.Config) { 29 | moduleName := r.URL.Query().Get("module") 30 | if moduleName == "" { 31 | moduleName = conf.DefaultModule 32 | if moduleName == "" { 33 | http.Error(w, "Module parameter must be set", http.StatusBadRequest) 34 | return 35 | } 36 | } 37 | module, ok := conf.Modules[moduleName] 38 | if !ok { 39 | http.Error(w, fmt.Sprintf("Unknown module %q", moduleName), http.StatusBadRequest) 40 | return 41 | } 42 | 43 | timeout := module.Timeout 44 | if timeout == 0 { 45 | // The following timeout block was taken wholly from the blackbox exporter 46 | // https://github.com/prometheus/blackbox_exporter/blob/master/main.go 47 | var timeoutSeconds float64 48 | if v := r.Header.Get("X-Prometheus-Scrape-Timeout-Seconds"); v != "" { 49 | var err error 50 | timeoutSeconds, err = strconv.ParseFloat(v, 64) 51 | if err != nil { 52 | http.Error(w, fmt.Sprintf("Failed to parse timeout from Prometheus header: %s", err), http.StatusInternalServerError) 53 | return 54 | } 55 | } else { 56 | timeoutSeconds = 10 57 | } 58 | if timeoutSeconds == 0 { 59 | timeoutSeconds = 10 60 | } 61 | 62 | timeout = time.Duration((timeoutSeconds) * 1e9) 63 | } 64 | 65 | ctx, cancel := context.WithTimeout(r.Context(), timeout) 66 | defer cancel() 67 | 68 | target := module.Target 69 | if target == "" { 70 | target = r.URL.Query().Get("target") 71 | if target == "" { 72 | http.Error(w, "Target parameter is missing", http.StatusBadRequest) 73 | return 74 | } 75 | } 76 | 77 | probeFunc, ok := prober.Probers[module.Prober] 78 | if !ok { 79 | http.Error(w, fmt.Sprintf("Unknown prober %q", module.Prober), http.StatusBadRequest) 80 | return 81 | } 82 | 83 | var ( 84 | probeSuccess = prometheus.NewGauge( 85 | prometheus.GaugeOpts{ 86 | Name: prometheus.BuildFQName(namespace, "", "probe_success"), 87 | Help: "If the probe was a success", 88 | }, 89 | ) 90 | proberType = prometheus.NewGaugeVec( 91 | prometheus.GaugeOpts{ 92 | Name: prometheus.BuildFQName(namespace, "", "prober"), 93 | Help: "The prober used by the exporter to connect to the target", 94 | }, 95 | []string{"prober"}, 96 | ) 97 | ) 98 | 99 | registry := prometheus.NewRegistry() 100 | registry.MustRegister(probeSuccess, proberType) 101 | proberType.WithLabelValues(module.Prober).Set(1) 102 | 103 | logger = log.With(logger, "target", target, "prober", module.Prober, "timeout", timeout) 104 | 105 | err := probeFunc(ctx, logger, target, module, registry) 106 | if err != nil { 107 | level.Error(logger).Log("msg", err) 108 | probeSuccess.Set(0) 109 | } else { 110 | probeSuccess.Set(1) 111 | } 112 | 113 | // Serve 114 | h := promhttp.HandlerFor(registry, promhttp.HandlerOpts{}) 115 | h.ServeHTTP(w, r) 116 | } 117 | 118 | func init() { 119 | prometheus.MustRegister(versioncollector.NewCollector(namespace + "_exporter")) 120 | } 121 | 122 | func main() { 123 | var ( 124 | listenAddress = kingpin.Flag("web.listen-address", "Address to listen on for web interface and telemetry.").Default(":9219").String() 125 | metricsPath = kingpin.Flag("web.metrics-path", "Path under which to expose metrics").Default("/metrics").String() 126 | probePath = kingpin.Flag("web.probe-path", "Path under which to expose the probe endpoint").Default("/probe").String() 127 | configFile = kingpin.Flag("config.file", "SSL exporter configuration file").Default("").String() 128 | promlogConfig = promlog.Config{} 129 | err error 130 | ) 131 | 132 | promlogflag.AddFlags(kingpin.CommandLine, &promlogConfig) 133 | kingpin.Version(version.Print(namespace + "_exporter")) 134 | kingpin.HelpFlag.Short('h') 135 | kingpin.Parse() 136 | 137 | logger := promlog.New(&promlogConfig) 138 | 139 | conf := config.DefaultConfig 140 | if *configFile != "" { 141 | conf, err = config.LoadConfig(*configFile) 142 | if err != nil { 143 | level.Error(logger).Log("msg", err) 144 | os.Exit(1) 145 | } 146 | } 147 | 148 | level.Info(logger).Log("msg", fmt.Sprintf("Starting %s_exporter %s", namespace, version.Info())) 149 | level.Info(logger).Log("msg", fmt.Sprintf("Build context %s", version.BuildContext())) 150 | 151 | http.Handle(*metricsPath, promhttp.Handler()) 152 | http.HandleFunc(*probePath, func(w http.ResponseWriter, r *http.Request) { 153 | probeHandler(logger, w, r, conf) 154 | }) 155 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 156 | _, _ = w.Write([]byte(` 157 | SSL Exporter 158 | 159 |

SSL Exporter

160 |

Probe example.com:443 for SSL cert metrics

161 |

Metrics

162 | 163 | `)) 164 | }) 165 | 166 | level.Info(logger).Log("msg", fmt.Sprintf("Listening on %s", *listenAddress)) 167 | level.Error(logger).Log("msg", http.ListenAndServe(*listenAddress, nil)) 168 | os.Exit(1) 169 | } 170 | -------------------------------------------------------------------------------- /ssl_exporter_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "os" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/go-kit/log" 11 | "github.com/ribbybibby/ssl_exporter/v2/config" 12 | "github.com/ribbybibby/ssl_exporter/v2/test" 13 | ) 14 | 15 | // TestProbeHandler tests that the probe handler sets the ssl_probe_success and 16 | // ssl_prober metrics correctly 17 | func TestProbeHandler(t *testing.T) { 18 | server, _, _, caFile, teardown, err := test.SetupHTTPSServer() 19 | if err != nil { 20 | t.Fatalf(err.Error()) 21 | } 22 | defer teardown() 23 | 24 | server.StartTLS() 25 | defer server.Close() 26 | 27 | conf := &config.Config{ 28 | Modules: map[string]config.Module{ 29 | "https": config.Module{ 30 | Prober: "https", 31 | TLSConfig: config.TLSConfig{ 32 | CAFile: caFile, 33 | }, 34 | }, 35 | }, 36 | } 37 | 38 | rr, err := probe(server.URL, "https", conf) 39 | if err != nil { 40 | t.Fatalf(err.Error()) 41 | } 42 | 43 | // Check probe success 44 | if ok := strings.Contains(rr.Body.String(), "ssl_probe_success 1"); !ok { 45 | t.Errorf("expected `ssl_probe_success 1`") 46 | } 47 | 48 | // Check prober metric 49 | if ok := strings.Contains(rr.Body.String(), "ssl_prober{prober=\"https\"} 1"); !ok { 50 | t.Errorf("expected `ssl_prober{prober=\"https\"} 1`") 51 | } 52 | } 53 | 54 | // TestProbeHandlerFail tests that the probe handler sets the ssl_probe_success and 55 | // ssl_prober metrics correctly when the probe fails 56 | func TestProbeHandlerFail(t *testing.T) { 57 | rr, err := probe("localhost:6666", "", config.DefaultConfig) 58 | if err != nil { 59 | t.Fatalf(err.Error()) 60 | } 61 | 62 | // Check probe success 63 | if ok := strings.Contains(rr.Body.String(), "ssl_probe_success 0"); !ok { 64 | t.Errorf("expected `ssl_probe_success 0`") 65 | } 66 | 67 | // Check prober metric 68 | if ok := strings.Contains(rr.Body.String(), "ssl_prober{prober=\"tcp\"} 1"); !ok { 69 | t.Errorf("expected `ssl_prober{prober=\"tcp\"} 1`") 70 | } 71 | } 72 | 73 | // TestProbeHandlerDefaultModule tests the default module is used correctly 74 | func TestProbeHandlerDefaultModule(t *testing.T) { 75 | server, _, _, caFile, teardown, err := test.SetupHTTPSServer() 76 | if err != nil { 77 | t.Fatalf(err.Error()) 78 | } 79 | defer teardown() 80 | 81 | server.StartTLS() 82 | defer server.Close() 83 | 84 | conf := &config.Config{ 85 | DefaultModule: "https", 86 | Modules: map[string]config.Module{ 87 | "tcp": config.Module{ 88 | Prober: "tcp", 89 | TLSConfig: config.TLSConfig{ 90 | CAFile: caFile, 91 | }, 92 | }, 93 | "https": config.Module{ 94 | Prober: "https", 95 | TLSConfig: config.TLSConfig{ 96 | CAFile: caFile, 97 | }, 98 | }, 99 | }, 100 | } 101 | 102 | rr, err := probe(server.URL, "", conf) 103 | if err != nil { 104 | t.Fatalf(err.Error()) 105 | } 106 | 107 | // Should have used the https prober 108 | if ok := strings.Contains(rr.Body.String(), "ssl_prober{prober=\"https\"} 1"); !ok { 109 | t.Errorf("expected `ssl_prober{prober=\"https\"} 1`") 110 | } 111 | 112 | conf.DefaultModule = "" 113 | 114 | rr, err = probe(server.URL, "", conf) 115 | if err != nil { 116 | t.Fatalf(err.Error()) 117 | } 118 | 119 | // It should fail when there's no default module 120 | if rr.Code != http.StatusBadRequest { 121 | t.Errorf("expected code: %d, got: %d", http.StatusBadRequest, rr.Code) 122 | } 123 | 124 | } 125 | 126 | // TestProbeHandlerTarget tests the target module parameter is used correctly 127 | func TestProbeHandlerDefaultTarget(t *testing.T) { 128 | server, _, _, caFile, teardown, err := test.SetupHTTPSServer() 129 | if err != nil { 130 | t.Fatalf(err.Error()) 131 | } 132 | defer teardown() 133 | 134 | server.StartTLS() 135 | defer server.Close() 136 | 137 | conf := &config.Config{ 138 | Modules: map[string]config.Module{ 139 | "https": config.Module{ 140 | Prober: "https", 141 | Target: server.URL, 142 | TLSConfig: config.TLSConfig{ 143 | CAFile: caFile, 144 | }, 145 | }, 146 | }, 147 | } 148 | 149 | // Should use the target in the module configuration 150 | rr, err := probe("", "https", conf) 151 | if err != nil { 152 | t.Fatalf(err.Error()) 153 | } 154 | 155 | // Check probe success 156 | if ok := strings.Contains(rr.Body.String(), "ssl_probe_success 1"); !ok { 157 | t.Errorf("expected `ssl_probe_success 1`") 158 | } 159 | 160 | // Check prober metric 161 | if ok := strings.Contains(rr.Body.String(), "ssl_prober{prober=\"https\"} 1"); !ok { 162 | t.Errorf("expected `ssl_prober{prober=\"https\"} 1`") 163 | } 164 | 165 | // Should ignore a different target in the target parameter 166 | rr, err = probe("localhost:6666", "https", conf) 167 | if err != nil { 168 | t.Fatalf(err.Error()) 169 | } 170 | 171 | // Check probe success 172 | if ok := strings.Contains(rr.Body.String(), "ssl_probe_success 1"); !ok { 173 | t.Errorf("expected `ssl_probe_success 1`") 174 | } 175 | 176 | // Check prober metric 177 | if ok := strings.Contains(rr.Body.String(), "ssl_prober{prober=\"https\"} 1"); !ok { 178 | t.Errorf("expected `ssl_prober{prober=\"https\"} 1`") 179 | } 180 | 181 | conf.Modules["tcp"] = config.Module{ 182 | Prober: "tcp", 183 | TLSConfig: config.TLSConfig{ 184 | CAFile: caFile, 185 | }, 186 | } 187 | 188 | rr, err = probe("", "tcp", conf) 189 | if err != nil { 190 | t.Fatalf(err.Error()) 191 | } 192 | 193 | // It should fail when there's no target in the module configuration or 194 | // the query parameters 195 | if rr.Code != http.StatusBadRequest { 196 | t.Errorf("expected code: %d, got: %d", http.StatusBadRequest, rr.Code) 197 | } 198 | } 199 | 200 | func probe(target, module string, conf *config.Config) (*httptest.ResponseRecorder, error) { 201 | uri := "/probe?target=" + target 202 | if module != "" { 203 | uri = uri + "&module=" + module 204 | } 205 | req, err := http.NewRequest("GET", uri, nil) 206 | if err != nil { 207 | return nil, err 208 | } 209 | 210 | rr := httptest.NewRecorder() 211 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 212 | probeHandler(newTestLogger(), w, r, conf) 213 | }) 214 | 215 | handler.ServeHTTP(rr, req) 216 | 217 | return rr, nil 218 | } 219 | 220 | func newTestLogger() log.Logger { 221 | return log.NewLogfmtLogger(log.NewSyncWriter(os.Stdout)) 222 | } 223 | -------------------------------------------------------------------------------- /test/https.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "io" 7 | "net" 8 | "net/http" 9 | "net/http/httptest" 10 | "os" 11 | "time" 12 | ) 13 | 14 | // SetupHTTPSServer sets up a server for testing with a generated cert and key 15 | // pair 16 | func SetupHTTPSServer() (*httptest.Server, []byte, []byte, string, func(), error) { 17 | testcertPEM, testkeyPEM := GenerateTestCertificate(time.Now().AddDate(0, 0, 1)) 18 | 19 | server, caFile, teardown, err := SetupHTTPSServerWithCertAndKey(testcertPEM, testcertPEM, testkeyPEM) 20 | if err != nil { 21 | return nil, testcertPEM, testkeyPEM, caFile, teardown, err 22 | } 23 | 24 | return server, testcertPEM, testkeyPEM, caFile, teardown, nil 25 | } 26 | 27 | // SetupHTTPSServerWithCertAndKey sets up a server with a provided certs and key 28 | func SetupHTTPSServerWithCertAndKey(caPEM, certPEM, keyPEM []byte) (*httptest.Server, string, func(), error) { 29 | var teardown func() 30 | 31 | caFile, err := WriteFile("certfile.pem", caPEM) 32 | if err != nil { 33 | return nil, caFile, teardown, err 34 | } 35 | 36 | teardown = func() { 37 | os.Remove(caFile) 38 | } 39 | 40 | testCert, err := tls.X509KeyPair(certPEM, keyPEM) 41 | if err != nil { 42 | return nil, caFile, teardown, err 43 | } 44 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 45 | fmt.Fprintln(w, "Hello world") 46 | })) 47 | server.TLS = &tls.Config{ 48 | Certificates: []tls.Certificate{testCert}, 49 | } 50 | 51 | return server, caFile, teardown, nil 52 | } 53 | 54 | // SetupHTTPProxyServer sets up a proxy server 55 | func SetupHTTPProxyServer() (*httptest.Server, error) { 56 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 57 | if r.Method == http.MethodConnect { 58 | destConn, err := net.DialTimeout("tcp", r.Host, 10*time.Second) 59 | if err != nil { 60 | http.Error(w, err.Error(), http.StatusServiceUnavailable) 61 | return 62 | } 63 | w.WriteHeader(http.StatusOK) 64 | hijacker, ok := w.(http.Hijacker) 65 | if !ok { 66 | http.Error(w, "Hijacking not supported", http.StatusInternalServerError) 67 | return 68 | } 69 | clientConn, _, err := hijacker.Hijack() 70 | if err != nil { 71 | http.Error(w, err.Error(), http.StatusServiceUnavailable) 72 | } 73 | go func() { 74 | defer destConn.Close() 75 | defer clientConn.Close() 76 | 77 | _, err := io.Copy(destConn, clientConn) 78 | if err != nil { 79 | http.Error(w, err.Error(), http.StatusInternalServerError) 80 | } 81 | 82 | }() 83 | go func() { 84 | defer clientConn.Close() 85 | defer destConn.Close() 86 | 87 | _, err := io.Copy(clientConn, destConn) 88 | if err != nil { 89 | http.Error(w, err.Error(), http.StatusInternalServerError) 90 | } 91 | 92 | }() 93 | } else { 94 | fmt.Fprintln(w, "Hello world") 95 | } 96 | })) 97 | 98 | return server, nil 99 | } 100 | -------------------------------------------------------------------------------- /test/tcp.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "fmt" 7 | "io" 8 | "net" 9 | "os" 10 | "time" 11 | 12 | "github.com/go-kit/log" 13 | "github.com/go-kit/log/level" 14 | ) 15 | 16 | // TCPServer allows manipulation of the tls.Config before starting the listener 17 | type TCPServer struct { 18 | Listener net.Listener 19 | TLS *tls.Config 20 | stopCh chan struct{} 21 | logger log.Logger 22 | } 23 | 24 | // StartTLS starts a listener that performs an immediate TLS handshake 25 | func (t *TCPServer) StartTLS() { 26 | go func() { 27 | ln := tls.NewListener(t.Listener, t.TLS) 28 | conn, err := ln.Accept() 29 | if err != nil { 30 | panic(fmt.Sprintf("Error accepting on socket: %s", err)) 31 | } 32 | defer conn.Close() 33 | 34 | // Immediately upgrade to TLS. 35 | if err := conn.(*tls.Conn).Handshake(); err != nil { 36 | level.Error(t.logger).Log("msg", err) 37 | } else { 38 | // Send some bytes before terminating the connection. 39 | fmt.Fprintf(conn, "Hello World!\n") 40 | } 41 | 42 | t.stopCh <- struct{}{} 43 | }() 44 | } 45 | 46 | // StartTLSWait starts a listener and waits for duration 'd' before performing 47 | // the TLS handshake 48 | func (t *TCPServer) StartTLSWait(d time.Duration) { 49 | go func() { 50 | ln := tls.NewListener(t.Listener, t.TLS) 51 | conn, err := ln.Accept() 52 | if err != nil { 53 | panic(fmt.Sprintf("Error accepting on socket: %s", err)) 54 | } 55 | defer conn.Close() 56 | 57 | time.Sleep(d) 58 | 59 | if err := conn.(*tls.Conn).Handshake(); err != nil { 60 | level.Error(t.logger).Log(err) 61 | } else { 62 | // Send some bytes before terminating the connection. 63 | fmt.Fprintf(conn, "Hello World!\n") 64 | } 65 | 66 | t.stopCh <- struct{}{} 67 | }() 68 | } 69 | 70 | // StartSMTP starts a listener that negotiates a TLS connection with an smtp 71 | // client using STARTTLS 72 | func (t *TCPServer) StartSMTP() { 73 | go func() { 74 | conn, err := t.Listener.Accept() 75 | if err != nil { 76 | panic(fmt.Sprintf("Error accepting on socket: %s", err)) 77 | } 78 | defer conn.Close() 79 | 80 | if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { 81 | panic("Error setting deadline") 82 | } 83 | 84 | fmt.Fprintf(conn, "220 ESMTP StartTLS pseudo-server\n") 85 | if _, e := fmt.Fscanf(conn, "EHLO prober\n"); e != nil { 86 | panic("Error in dialog. No EHLO received.") 87 | } 88 | fmt.Fprintf(conn, "250-pseudo-server.example.net\n") 89 | fmt.Fprintf(conn, "250-STARTTLS\n") 90 | fmt.Fprintf(conn, "250 DSN\n") 91 | 92 | if _, e := fmt.Fscanf(conn, "STARTTLS\n"); e != nil { 93 | panic("Error in dialog. No (TLS) STARTTLS received.") 94 | } 95 | fmt.Fprintf(conn, "220 2.0.0 Ready to start TLS\n") 96 | 97 | // Upgrade to TLS. 98 | tlsConn := tls.Server(conn, t.TLS) 99 | if err := tlsConn.Handshake(); err != nil { 100 | level.Error(t.logger).Log("msg", err) 101 | } 102 | defer tlsConn.Close() 103 | 104 | t.stopCh <- struct{}{} 105 | }() 106 | } 107 | 108 | // StartSMTPWithDashInResponse starts a listener that negotiates a TLS connection with an smtp 109 | // client using STARTTLS. The server provides the STARTTLS response in the form '250 STARTTLS' 110 | // (with a space, rather than a dash) 111 | func (t *TCPServer) StartSMTPWithDashInResponse() { 112 | go func() { 113 | conn, err := t.Listener.Accept() 114 | if err != nil { 115 | panic(fmt.Sprintf("Error accepting on socket: %s", err)) 116 | } 117 | defer conn.Close() 118 | 119 | if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { 120 | panic("Error setting deadline") 121 | } 122 | 123 | fmt.Fprintf(conn, "220 ESMTP StartTLS pseudo-server\n") 124 | if _, e := fmt.Fscanf(conn, "EHLO prober\n"); e != nil { 125 | panic("Error in dialog. No EHLO received.") 126 | } 127 | fmt.Fprintf(conn, "250-pseudo-server.example.net\n") 128 | fmt.Fprintf(conn, "250-DSN\n") 129 | fmt.Fprintf(conn, "250 STARTTLS\n") 130 | 131 | if _, e := fmt.Fscanf(conn, "STARTTLS\n"); e != nil { 132 | panic("Error in dialog. No (TLS) STARTTLS received.") 133 | } 134 | fmt.Fprintf(conn, "220 2.0.0 Ready to start TLS\n") 135 | 136 | // Upgrade to TLS. 137 | tlsConn := tls.Server(conn, t.TLS) 138 | if err := tlsConn.Handshake(); err != nil { 139 | level.Error(t.logger).Log("msg", err) 140 | } 141 | defer tlsConn.Close() 142 | 143 | t.stopCh <- struct{}{} 144 | }() 145 | } 146 | 147 | // StartFTP starts a listener that negotiates a TLS connection with an ftp 148 | // client using AUTH TLS 149 | func (t *TCPServer) StartFTP() { 150 | go func() { 151 | conn, err := t.Listener.Accept() 152 | if err != nil { 153 | panic(fmt.Sprintf("Error accepting on socket: %s", err)) 154 | } 155 | defer conn.Close() 156 | 157 | fmt.Fprintf(conn, "220 Test FTP Service\n") 158 | if _, e := fmt.Fscanf(conn, "AUTH TLS\n"); e != nil { 159 | panic("Error in dialog. No AUTH TLS received.") 160 | } 161 | fmt.Fprintf(conn, "234 AUTH command ok. Expecting TLS Negotiation.\n") 162 | 163 | // Upgrade to TLS. 164 | tlsConn := tls.Server(conn, t.TLS) 165 | if err := tlsConn.Handshake(); err != nil { 166 | level.Error(t.logger).Log(err) 167 | } 168 | defer tlsConn.Close() 169 | 170 | t.stopCh <- struct{}{} 171 | }() 172 | } 173 | 174 | // StartIMAP starts a listener that negotiates a TLS connection with an imap 175 | // client using STARTTLS 176 | func (t *TCPServer) StartIMAP() { 177 | go func() { 178 | conn, err := t.Listener.Accept() 179 | if err != nil { 180 | panic(fmt.Sprintf("Error accepting on socket: %s", err)) 181 | } 182 | defer conn.Close() 183 | 184 | fmt.Fprintf(conn, "* OK XIMAP ready for requests\n") 185 | if _, e := fmt.Fscanf(conn, ". CAPABILITY\n"); e != nil { 186 | panic("Error in dialog. No . CAPABILITY received.") 187 | } 188 | fmt.Fprintf(conn, "* CAPABILITY IMAP4 IMAP4rev1 AUTH=PLAIN STARTTLS\n") 189 | fmt.Fprintf(conn, ". OK CAPABILITY completed.\n") 190 | if _, e := fmt.Fscanf(conn, ". STARTTLS\n"); e != nil { 191 | panic("Error in dialog. No . STARTTLS received.") 192 | } 193 | fmt.Fprintf(conn, ". OK Begin TLS negotiation now.\n") 194 | 195 | // Upgrade to TLS. 196 | tlsConn := tls.Server(conn, t.TLS) 197 | if err := tlsConn.Handshake(); err != nil { 198 | level.Error(t.logger).Log("msg", err) 199 | } 200 | defer tlsConn.Close() 201 | 202 | t.stopCh <- struct{}{} 203 | }() 204 | } 205 | 206 | // StartPOP3 starts a listener that negotiates a TLS connection with an pop3 207 | // client using STARTTLS 208 | func (t *TCPServer) StartPOP3() { 209 | go func() { 210 | conn, err := t.Listener.Accept() 211 | if err != nil { 212 | panic(fmt.Sprintf("Error accepting on socket: %s", err)) 213 | } 214 | defer conn.Close() 215 | 216 | fmt.Fprintf(conn, "+OK XPOP3 ready.\n") 217 | if _, e := fmt.Fscanf(conn, "STLS\n"); e != nil { 218 | panic("Error in dialog. No STLS received.") 219 | } 220 | fmt.Fprintf(conn, "+OK Begin TLS negotiation now.\n") 221 | 222 | // Upgrade to TLS. 223 | tlsConn := tls.Server(conn, t.TLS) 224 | if err := tlsConn.Handshake(); err != nil { 225 | level.Error(t.logger).Log("msg", err) 226 | } 227 | defer tlsConn.Close() 228 | 229 | t.stopCh <- struct{}{} 230 | }() 231 | } 232 | 233 | // StartPostgreSQL starts a listener that negotiates a TLS connection with an postgresql 234 | // client using STARTTLS 235 | func (t *TCPServer) StartPostgreSQL() { 236 | go func() { 237 | conn, err := t.Listener.Accept() 238 | if err != nil { 239 | panic(fmt.Sprintf("Error accepting on socket: %s", err)) 240 | } 241 | defer conn.Close() 242 | 243 | sslRequestMessage := []byte{0x00, 0x00, 0x00, 0x08, 0x04, 0xd2, 0x16, 0x2f} 244 | 245 | buffer := make([]byte, len(sslRequestMessage)) 246 | 247 | _, err = io.ReadFull(conn, buffer) 248 | if err != nil { 249 | panic("Error reading input from client") 250 | } 251 | 252 | if bytes.Compare(buffer, sslRequestMessage) != 0 { 253 | panic(fmt.Sprintf("Error in dialog. No %x received", buffer)) 254 | } 255 | 256 | sslRequestResponse := []byte{0x53} 257 | 258 | if _, err := conn.Write(sslRequestResponse); err != nil { 259 | panic("Error writing response to client") 260 | } 261 | 262 | tlsConn := tls.Server(conn, t.TLS) 263 | if err := tlsConn.Handshake(); err != nil { 264 | level.Error(t.logger).Log("msg", err) 265 | } 266 | defer tlsConn.Close() 267 | 268 | t.stopCh <- struct{}{} 269 | }() 270 | } 271 | 272 | // Close stops the server and closes the listener 273 | func (t *TCPServer) Close() { 274 | <-t.stopCh 275 | t.Listener.Close() 276 | } 277 | 278 | // SetupTCPServer sets up a server for testing with a generated cert and key 279 | // pair 280 | func SetupTCPServer() (*TCPServer, []byte, []byte, string, func(), error) { 281 | testcertPEM, testkeyPEM := GenerateTestCertificate(time.Now().AddDate(0, 0, 1)) 282 | 283 | server, caFile, teardown, err := SetupTCPServerWithCertAndKey(testcertPEM, testcertPEM, testkeyPEM) 284 | if err != nil { 285 | return nil, testcertPEM, testkeyPEM, caFile, teardown, err 286 | } 287 | 288 | return server, testcertPEM, testkeyPEM, caFile, teardown, nil 289 | } 290 | 291 | // SetupTCPServerWithCertAndKey sets up a server with the provided certs and key 292 | func SetupTCPServerWithCertAndKey(caPEM, certPEM, keyPEM []byte) (*TCPServer, string, func(), error) { 293 | var teardown func() 294 | 295 | caFile, err := WriteFile("certfile.pem", caPEM) 296 | if err != nil { 297 | return nil, caFile, teardown, err 298 | } 299 | 300 | teardown = func() { 301 | os.Remove(caFile) 302 | } 303 | 304 | testCert, err := tls.X509KeyPair(certPEM, keyPEM) 305 | if err != nil { 306 | return nil, caFile, teardown, err 307 | } 308 | 309 | tlsConfig := &tls.Config{ 310 | ServerName: "127.0.0.1", 311 | Certificates: []tls.Certificate{testCert}, 312 | MinVersion: tls.VersionTLS13, 313 | MaxVersion: tls.VersionTLS13, 314 | } 315 | 316 | ln, err := net.Listen("tcp", "127.0.0.1:0") 317 | if err != nil { 318 | return nil, caFile, teardown, err 319 | } 320 | 321 | server := &TCPServer{ 322 | Listener: ln, 323 | TLS: tlsConfig, 324 | stopCh: make(chan (struct{})), 325 | logger: log.NewLogfmtLogger(log.NewSyncWriter(os.Stdout)), 326 | } 327 | 328 | return server, caFile, teardown, err 329 | } 330 | -------------------------------------------------------------------------------- /test/test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "crypto/x509/pkix" 8 | "encoding/pem" 9 | "fmt" 10 | "io/ioutil" 11 | "math/big" 12 | "net" 13 | "time" 14 | ) 15 | 16 | // GenerateTestCertificate generates a test certificate with the given expiry date 17 | func GenerateTestCertificate(expiry time.Time) ([]byte, []byte) { 18 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 19 | if err != nil { 20 | panic(fmt.Sprintf("Error creating rsa key: %s", err)) 21 | } 22 | pemKey := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}) 23 | 24 | cert := GenerateCertificateTemplate(expiry) 25 | cert.IsCA = true 26 | 27 | _, pemCert := GenerateSelfSignedCertificateWithPrivateKey(cert, privateKey) 28 | 29 | return pemCert, pemKey 30 | } 31 | 32 | // GenerateSignedCertificate generates a certificate that is signed 33 | func GenerateSignedCertificate(cert, parentCert *x509.Certificate, parentKey *rsa.PrivateKey) (*x509.Certificate, []byte, []byte) { 34 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 35 | if err != nil { 36 | panic(fmt.Sprintf("Error creating rsa key: %s", err)) 37 | } 38 | 39 | derCert, err := x509.CreateCertificate(rand.Reader, cert, parentCert, &privateKey.PublicKey, parentKey) 40 | if err != nil { 41 | panic(fmt.Sprintf("Error signing test-certificate: %s", err)) 42 | } 43 | 44 | genCert, err := x509.ParseCertificate(derCert) 45 | if err != nil { 46 | panic(fmt.Sprintf("Error parsing test-certificate: %s", err)) 47 | } 48 | 49 | return genCert, 50 | pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derCert}), 51 | pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}) 52 | } 53 | 54 | // GenerateSelfSignedCertificateWithPrivateKey generates a self signed 55 | // certificate with the given private key 56 | func GenerateSelfSignedCertificateWithPrivateKey(cert *x509.Certificate, privateKey *rsa.PrivateKey) (*x509.Certificate, []byte) { 57 | derCert, err := x509.CreateCertificate(rand.Reader, cert, cert, &privateKey.PublicKey, privateKey) 58 | if err != nil { 59 | panic(fmt.Sprintf("Error signing test-certificate: %s", err)) 60 | } 61 | 62 | genCert, err := x509.ParseCertificate(derCert) 63 | if err != nil { 64 | panic(fmt.Sprintf("Error parsing test-certificate: %s", err)) 65 | } 66 | 67 | return genCert, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derCert}) 68 | } 69 | 70 | // GenerateCertificateTemplate generates the template used to issue test certificates 71 | func GenerateCertificateTemplate(expiry time.Time) *x509.Certificate { 72 | return &x509.Certificate{ 73 | BasicConstraintsValid: true, 74 | SubjectKeyId: []byte{1}, 75 | SerialNumber: big.NewInt(100), 76 | NotBefore: time.Now(), 77 | NotAfter: expiry, 78 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, 79 | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, 80 | IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, 81 | Subject: pkix.Name{ 82 | CommonName: "example.ribbybibby.me", 83 | Organization: []string{"ribbybibby"}, 84 | OrganizationalUnit: []string{"ribbybibbys org"}, 85 | }, 86 | EmailAddresses: []string{"me@ribbybibby.me", "example@ribbybibby.me"}, 87 | DNSNames: []string{"example.ribbybibby.me", "example-2.ribbybibby.me", "example-3.ribbybibby.me"}, 88 | } 89 | } 90 | 91 | // WriteFile writes some content to a temporary file 92 | func WriteFile(filename string, contents []byte) (string, error) { 93 | tmpFile, err := ioutil.TempFile("", filename) 94 | if err != nil { 95 | return tmpFile.Name(), err 96 | } 97 | if _, err := tmpFile.Write(contents); err != nil { 98 | return tmpFile.Name(), err 99 | } 100 | if err := tmpFile.Close(); err != nil { 101 | return tmpFile.Name(), err 102 | } 103 | 104 | return tmpFile.Name(), nil 105 | } 106 | --------------------------------------------------------------------------------